From 70a648e00fdc8a284787e79066791f40f2bf5325 Mon Sep 17 00:00:00 2001 From: Nadav Nir Date: Wed, 17 Jun 2026 11:21:53 +0200 Subject: [PATCH 1/7] feat: rework new opportunity page to match profile edit layout Replaces the simple card form with the same ProfilePage section-card layout used by the opportunity profile. Title and contact details are presented as SectionCards in edit mode; contact fields are pre-filled from the current agent's representative on load (keepDirtyValues so manual edits are not overwritten). Validation uses zod + react-hook-form with the same phone/email rules as the opportunity contact details schema. Save POSTs to /api/opportunity/ and redirects to the new opportunity's profile page on success. Co-Authored-By: Claude Sonnet 4.6 --- .../NewOpportunity/NewOpportunity.tsx | 325 ++++++++++-------- 1 file changed, 179 insertions(+), 146 deletions(-) diff --git a/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx b/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx index 812c5e5d..821d92e0 100644 --- a/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx +++ b/src/components/Dashboard/NewOpportunity/NewOpportunity.tsx @@ -1,182 +1,215 @@ "use client"; -import { Button } from "@/components/core/button"; -import { FormInput } from "@/components/core/common"; -import { apiPathOpportunity, DashboardRoutes } from "@/config/constants"; +import { EditableField } from "@/components/EditableField/EditableField"; +import { SectionCard } from "@/components/Dashboard/Profile/common/SectionCard"; +import { useEnumTranslation } from "@/components/Dashboard/Profile/sections/ContactDetails/shared"; +import { FormDetails } from "@/components/Dashboard/Profile/sections/shared/styles"; +import { BackButton, PageContainer } from "@/components/Dashboard/Profile/styles"; +import { IconName } from "@/components/Dashboard/Profile/types"; +import Button from "@/components/core/button/Button/Button"; +import { apiPathOpportunity, DashboardRoutes, PHONE_NUMBER_REGEX } from "@/config/constants"; import { useMutationQuery } from "@/hooks"; import { useGetCurrentAgent } from "@/hooks/useGetCurrentAgent"; -import { ApiOpportunityGet } from "need4deed-sdk"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Heading2 } from "@/components/styled/text"; +import { ArrowLeftIcon } from "@phosphor-icons/react"; +import { ApiOpportunityGet, PreferredCommunicationType } from "need4deed-sdk"; import i18next from "i18next"; import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; -import { - AgentInfo, - AgentLabel, - AgentRow, - AgentValue, - Card, - ErrorBanner, - FieldLabel, - FieldWrapper, - PageSubtitle, - PageTitle, - SectionTitle, - Wrapper, -} from "./styled"; +import styled from "styled-components"; +import { z } from "zod"; + +const WAYS_TO_CONTACT_TYPES = Object.values(PreferredCommunicationType); + +const createSchema = (t: (key: string) => string) => + z.object({ + title: z.string().min(1, t("form.error.required")), + name: z.string().min(1, t("dashboard.opportunityProfile.contactDetails.validation.nameRequired")), + phone: z + .string() + .min(1, t("dashboard.opportunityProfile.contactDetails.validation.phoneRequired")) + .regex(PHONE_NUMBER_REGEX, t("dashboard.opportunityProfile.contactDetails.validation.phoneInvalid")), + email: z + .string() + .min(1, t("dashboard.opportunityProfile.contactDetails.validation.emailRequired")) + .email(t("dashboard.opportunityProfile.contactDetails.validation.emailInvalid")), + waysToContact: z.array(z.nativeEnum(PreferredCommunicationType)).optional(), + }); + +type FormData = z.infer>; type CreateOpportunityBody = { title: string; - contact: { - name: string; - phone: string; - email: string; - }; + contact: { name: string; phone: string; email: string }; agentId?: number; }; export function NewOpportunity() { const { t } = useTranslation(); const router = useRouter(); - const { agent, isLoading: agentLoading } = useGetCurrentAgent(); - - const [title, setTitle] = useState(""); - const [contactName, setContactName] = useState(""); - const [contactPhone, setContactPhone] = useState(""); - const [contactEmail, setContactEmail] = useState(""); - const [errors, setErrors] = useState>({}); + const { agent } = useGetCurrentAgent(); + const { options, keysToLabels, labelsToKeys } = useEnumTranslation( + WAYS_TO_CONTACT_TYPES, + "dashboard.opportunityProfile.contactDetails.waysToContact", + ); - const [prefilled, setPrefilled] = useState(false); - useEffect(() => { - if (!agent?.representative || prefilled) return; - setContactName(agent.representative.firstName ?? ""); - setContactPhone(agent.representative.phone ?? ""); - setContactEmail(agent.representative.email ?? ""); - setPrefilled(true); - }, [agent, prefilled]); + const methods = useForm({ + resolver: zodResolver(createSchema(t)), + mode: "onChange", + defaultValues: { title: "", name: "", phone: "", email: "", waysToContact: [] }, + }); const { - mutate: createOpportunity, - isPending, - error, - } = useMutationQuery({ + control, + handleSubmit, + reset, + formState: { errors, isValid }, + } = methods; + + useEffect(() => { + if (!agent?.representative) return; + const rep = agent.representative; + reset( + { title: "", name: rep.firstName ?? "", phone: rep.phone ?? "", email: rep.email ?? "", waysToContact: [] }, + { keepDirtyValues: true }, + ); + }, [agent, reset]); + + const { mutate: createOpportunity, isPending } = useMutationQuery< + CreateOpportunityBody, + { message: string; data: ApiOpportunityGet } + >({ apiPath: `${apiPathOpportunity}/`, method: "post", onSuccessCallback: (response) => { const id = response?.data?.id; - if (id) { - router.push(`/${i18next.language}${DashboardRoutes.Opportunities}/${id}`); - } + if (id) router.push(`/${i18next.language}${DashboardRoutes.Opportunities}/${id}`); }, }); - const validate = () => { - const next: Record = {}; - const required = t("form.error.required"); - if (!title.trim()) next.title = required; - if (!contactName.trim()) next.contactName = required; - if (!contactEmail.trim()) next.contactEmail = required; - else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contactEmail)) next.contactEmail = t("form.error.email"); - if (!contactPhone.trim()) next.contactPhone = required; - setErrors(next); - return Object.keys(next).length === 0; - }; - - const handleSubmit = () => { - if (!validate()) return; + const onSubmit = (values: FormData) => { createOpportunity({ - title: title.trim(), - contact: { - name: contactName.trim(), - phone: contactPhone.trim(), - email: contactEmail.trim(), - }, + title: values.title, + contact: { name: values.name, phone: values.phone, email: values.email }, agentId: agent?.id, }); }; return ( - - - {t("dashboard.newOpportunity.title")} - {t("dashboard.newOpportunity.subtitle")} - - {error && {t("message.errorGeneric")}} - - - {t("dashboard.newOpportunity.fields.title")} - - + + router.back()}> + + {t("dashboard.volunteerProfile.backToDashboard")} + + + {t("dashboard.newOpportunity.title")} + + + + ( + + )} + /> + + } + /> - {t("dashboard.newOpportunity.contactSection")} + + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + field.onChange(labelsToKeys(Array.isArray(value) ? value : [value]))} + options={options} + /> + )} + /> + + } + /> - - {t("dashboard.opportunityProfile.contactDetails.name")} - +