diff --git a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx index 310cac6ca..9d023a3ec 100644 --- a/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/auth/AuthLayout.tsx @@ -1,8 +1,11 @@ import type { ReactNode } from "react"; import { ShieldCheck, Train } from "lucide-react"; +import { cn } from "@/lib/utils"; export interface AuthLayoutProps { children: ReactNode; + parentClassName?: string; + contentClassName?: string; left: { badge: string; title: string; @@ -17,10 +20,15 @@ export interface AuthLayoutProps { }; } -export default function AuthLayout({ children, left }: AuthLayoutProps) { +export default function AuthLayout({ + children, + parentClassName, + contentClassName, + left, +}: AuthLayoutProps) { return (
-
+
@@ -58,8 +66,13 @@ export default function AuthLayout({ children, left }: AuthLayoutProps) {
-
-
+
+
diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx new file mode 100644 index 000000000..cea9d1774 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -0,0 +1,520 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + ArrowRight, + ArrowLeft, + Building2, + User, + FileText, + CheckCircle2, + Loader2, +} from "lucide-react"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import type { CreateCustomerDto } from "@/types/customers"; +import AuthLayout from "@/components/auth/AuthLayout"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; + +type OnboardingStep = "company" | "personnel" | "poa"; + +const onboardingSchema = z.object({ + companyName: z.string().min(1, "Company name is required"), + companyEmail: z.string().email("Invalid email address"), + companyPhone: z.string().min(1, "Company phone is required"), + companyPhoneCountryCode: z.string().min(1, "Country code is required"), + companyLocation: z.string().min(1, "Location is required"), + companyAddress: z.string().min(1, "Address is required"), + tinNumber: z.string().length(10, "TIN must be exactly 10 digits"), + vatNumber: z + .string() + .min(1, "VAT number is required") + .length(10, "VAT number must be exactly 10 digits"), + fanNumber: z.string().length(16, "FAN must be exactly 16 digits"), + contactPersonName: z.string().min(1, "Contact person name is required"), + contactPersonPhone: z.string().min(1, "Contact person phone is required"), + contactPersonPhoneCountryCode: z.string().min(1, "Country code is required"), + generalManagerName: z.string().min(1, "GM name is required"), + generalManagerEmail: z.string().email("Invalid GM email"), + generalManagerPhone: z.string().min(1, "GM phone is required"), + generalManagerPhoneCountryCode: z.string().min(1, "Country code is required"), + poaName: z.string().optional(), + poaPhone: z.string().optional(), + poaPhoneCountryCode: z.string().optional(), + poaAddress: z.string().optional(), + poaEmail: z.string().optional(), + poaLocation: z.string().optional(), +}); + +type FormData = z.infer; + +const stepFields: Record = { + company: [ + "companyName", + "companyEmail", + "companyPhone", + "companyPhoneCountryCode", + "companyLocation", + "companyAddress", + "tinNumber", + "vatNumber", + "fanNumber", + ], + personnel: [ + "contactPersonName", + "contactPersonPhone", + "contactPersonPhoneCountryCode", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", + "generalManagerPhoneCountryCode", + ], + poa: [], +}; + +export default function OnboardingPage() { + const queryClient = useQueryClient(); + const { user } = useAuth(); + const [step, setStep] = useState("company"); + + const { + register, + handleSubmit, + trigger, + formState: { errors }, + } = useForm({ + resolver: zodResolver(onboardingSchema), + defaultValues: { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+251", + companyLocation: "", + companyAddress: "", + tinNumber: "", + vatNumber: "", + fanNumber: "", + contactPersonName: "", + contactPersonPhone: "", + contactPersonPhoneCountryCode: "+251", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + generalManagerPhoneCountryCode: "+251", + poaName: "", + poaPhone: "", + poaPhoneCountryCode: "+251", + poaAddress: "", + poaEmail: "", + poaLocation: "", + }, + }); + + const createCustomerMutation = useMutation({ + mutationFn: (payload: CreateCustomerDto) => + api.customers.create.call(payload), + onSuccess: () => { + if (user) + queryClient.invalidateQueries({ + queryKey: api.customers.getByUserId.queryKey({ id: user.id }), + }); + }, + }); + + const nextStep = async () => { + if (step === "poa") { + handleSubmit(onSubmit)(); + return; + } + const fields = stepFields[step]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep(step === "company" ? "personnel" : "poa"); + }; + + const prevStep = () => { + if (step === "personnel") setStep("company"); + else if (step === "poa") setStep("personnel"); + }; + + const onSubmit = async (data: FormData) => { + const nameParts = (user?.name?.en ?? "").split(" "); + const payload: CreateCustomerDto = { + userId: user!.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user!.email, + phone: user!.phoneNumber, + companyName: data.companyName, + companyEmail: data.companyEmail, + companyPhone: `${data.companyPhoneCountryCode}${data.companyPhone}`, + companyLocation: data.companyLocation, + companyAddress: data.companyAddress, + contactPersonName: data.contactPersonName, + contactPersonPhone: `${data.contactPersonPhoneCountryCode}${data.contactPersonPhone}`, + tinNumber: data.tinNumber, + vatNumber: data.vatNumber, + fanNumber: data.fanNumber, + generalManagerName: data.generalManagerName, + generalManagerEmail: data.generalManagerEmail, + generalManagerPhone: `${data.generalManagerPhoneCountryCode}${data.generalManagerPhone}`, + poaName: data.poaName || undefined, + poaPhone: + data.poaPhone && data.poaPhoneCountryCode + ? `${data.poaPhoneCountryCode}${data.poaPhone}` + : undefined, + poaAddress: data.poaAddress || undefined, + poaEmail: data.poaEmail || undefined, + poaLocation: data.poaLocation || undefined, + }; + createCustomerMutation.mutate(payload); + }; + + return ( + +
+
+
+ } + active={step === "company"} + completed={step !== "company"} + /> + } + active={step === "personnel"} + completed={step === "poa"} + /> + } + active={step === "poa"} + completed={false} + /> +
+

+ {step === "company" && "Step 1 of 3 — Company Information"} + {step === "personnel" && "Step 2 of 3 — Personnel Details"} + {step === "poa" && "Step 3 of 3 — Power of Attorney (Optional)"} +

+
+ +
+ + {step === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location + + + + + + Address + + + +
+ +
+ + TIN Number (10 digits) + + + + + + VAT Number + + + +
+ + + FAN Number (16 digits) + + + + + )} + + {step === "personnel" && ( + <> +

+ Personal details are pulled from your account. Contact and + management info is collected below. +

+ +
+

+ Contact Person +

+
+ + Name + + + + + +
+
+ +
+ +
+

+ General Manager +

+
+ + Name + + + + + + Email + + + + + +
+
+ + )} + + {step === "poa" && ( + <> +

+ Power of Attorney details are optional. Skip if not applicable. +

+ + + PoA Name + + + +
+ + PoA Email + + + + +
+ +
+ + PoA Location + + + + + PoA Address + + +
+ + )} +
+ +
+ + + +
+
+ + ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/services/customers.service.ts b/apps/edr-freight-web/portal/src/services/customers.service.ts index 3781859fa..3d809709d 100644 --- a/apps/edr-freight-web/portal/src/services/customers.service.ts +++ b/apps/edr-freight-web/portal/src/services/customers.service.ts @@ -7,6 +7,7 @@ import type { Customer, UpdateCustomerDto, } from "@/types/customers"; +import { isAxiosError } from "axios"; const BASE = URL_CONSTANTS.CUSTOMERS_API.BASE; @@ -23,22 +24,26 @@ export const customersService = { return unwrap(response.data); }, - getByUserId: async (userId: string): Promise => { - const response = await client.get>( - URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), - ); + getByUserId: async (userId: string): Promise => { + try { + const response = await client.get>( + URL_CONSTANTS.CUSTOMERS_API.BY_USER_ID(userId), + ); + return unwrap(response.data); + } catch (e) { + if (isAxiosError(e) && e.response?.status === 404) { + return null; + } + throw e; + } + }, + + create: async (payload: CreateCustomerDto): Promise => { + const response = await client.post>(BASE, payload); return unwrap(response.data); }, - create: async (payload: any): Promise => { - const response = await client.post>(BASE, payload); - return unwrap(response.data); - }, - - update: async ( - id: string, - payload: UpdateCustomerDto, - ): Promise => { + update: async (id: string, payload: UpdateCustomerDto): Promise => { const response = await client.patch>( URL_CONSTANTS.CUSTOMERS_API.BY_ID(id), payload,