diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 17dd94533..d16a70175 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -4,13 +4,13 @@ import { Routes, Route, Navigate, + Outlet, } from "react-router-dom"; import { DashboardLayout, type SidebarItem } from "@edr/ui-common"; import { CalendarCheck, MapPin, Receipt, - FileText, Home, Loader2, User, @@ -36,7 +36,7 @@ import CustomerOnBoarding from "./pages/customers/on_boarding/TransportrOnBoardi import CustomerOnboardingPage from "./pages/customers/on_boarding/CustomerOnboardingPage"; const sidebarItems: SidebarItem[] = [ - { label: "Home", href: "/", icon: }, + { label: "Home", href: "/portal", icon: }, { label: "My Bookings", href: "/bookings", icon: }, { label: "Tracking", href: "/tracking", icon: }, { label: "Billing", href: "/billing", icon: }, @@ -46,13 +46,21 @@ const sidebarItems: SidebarItem[] = [ const App = () => { const navigate = useNavigate(); const location = useLocation(); - const { user, isPending, logout, customer, customerQuery } = useAuth(); - - console.log({ customer, isPending, user }); + const { user, isPending, logout, customer } = useAuth(); useEffect(() => { - if (!user) return; - // if (!user.hasSetPassword) navigate("/set-password"); - }, [user]); + if (isPending) return; + const isInProtectedRoutes = sidebarItems.find((item) => + location.pathname.startsWith(item.href), + ); + if (!user) { + if (isInProtectedRoutes) return navigate("/login"); + return; + } + + if (user && location.pathname === "/") navigate("/portal"); + else if (!customer && !!isInProtectedRoutes) navigate("/onboarding"); + else if (customer && !isInProtectedRoutes) return navigate("/portal"); + }, [user, location, customer]); if (isPending) { return ( @@ -62,50 +70,45 @@ const App = () => { ); } - if (!user) { - return ( - - } /> - } /> - } /> - } /> - } /> - } /> - - ); - } - - if (user && !customer && !customerQuery.isPending) { - return ; - } - - return - const displayName = user?.name?.en || user?.username || user?.email || "User"; const userEmail = user?.email; return ( - - - } /> + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + + } + > + } /> } /> } /> } /> } /> } /> } /> - } /> - - + + } /> + ); }; diff --git a/apps/edr-freight-web/portal/src/hooks/useAuth.ts b/apps/edr-freight-web/portal/src/hooks/useAuth.ts index dd9a1c5b6..b661ed98a 100644 --- a/apps/edr-freight-web/portal/src/hooks/useAuth.ts +++ b/apps/edr-freight-web/portal/src/hooks/useAuth.ts @@ -9,6 +9,7 @@ import type { } from "@/types/auth"; import type { Result } from "@/utils/result"; import { extractApiError } from "@/utils/result"; +import { useEffect } from "react"; function setCookie(name: string, value: string, days: number) { const expires = new Date(); @@ -44,6 +45,16 @@ const useAuth = () => { }), ); + useEffect(() => { + console.log({ + user: authQuery.data, + customer: customerQuery.data, + isCustomer: !!customerQuery.data, + isUserPending: authQuery.isPending, + isCustomerPending: customerQuery.isPending, + }); + }, [authQuery, customerQuery]); + const hasToken = !!getCookie("auth-token"); const isPending = authQuery.isPending && hasToken; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx new file mode 100644 index 000000000..fa82a85a2 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -0,0 +1,574 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + ArrowRight, + ArrowLeft, + Building2, + User, + FileText, + CheckCircle2, + Loader2, + ChevronLeft, +} from "lucide-react"; +import type { OnboardingUserType } from "./types"; +import type { AuthUser } from "@/types/auth"; +import type { CreateCustomerDto } from "@/types/customers"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; + +type CompanyStep = "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: [], +}; + +const POA_FIELDS: (keyof FormData)[] = [ + "poaName", + "poaPhone", + "poaPhoneCountryCode", + "poaAddress", + "poaEmail", + "poaLocation", +]; + +const POA_LABELS: Record = { + poaName: "PoA name", + poaPhone: "PoA phone", + poaPhoneCountryCode: "PoA country code", + poaAddress: "PoA address", + poaEmail: "PoA email", + poaLocation: "PoA location", +}; + +function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto { + const nameParts = (user.name?.en ?? "").split(" "); + return { + 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, + }; +} + +export default function CompanyProfileForm({ + userType, + user, + onSubmit, + isPending, + onBack, +}: { + userType: OnboardingUserType; + user: AuthUser; + onSubmit: (data: CreateCustomerDto) => void; + isPending: boolean; + onBack: () => void; +}) { + const requirePoA = userType === "freight-forwarder-et"; + + const [step, setStep] = useState("company"); + + const { + register, + handleSubmit, + trigger, + setError, + clearErrors, + getValues, + 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 nextStep = async () => { + if (step === "poa") { + if (requirePoA) { + clearErrors(POA_FIELDS); + const values = getValues(); + let hasError = false; + for (const field of POA_FIELDS) { + const val = values[field]; + if (!val || val.toString().trim().length === 0) { + setError(field, { + message: `${ + POA_LABELS[field].charAt(0).toUpperCase() + + POA_LABELS[field].slice(1) + } is required for Freight Forwarders`, + }); + hasError = true; + } + } + if (hasError) return; + } + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + return; + } + const fields = stepFields[step]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep(step === "company" ? "personnel" : "poa"); + }; + + const prevStep = () => { + if (step === "company") { + onBack(); + } else if (step === "personnel") { + setStep("company"); + } else if (step === "poa") { + setStep("personnel"); + } + }; + + 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 ${requirePoA ? "(Required)" : "(Optional)"}`} +

+
+ +
onSubmit(buildPayload(data, user)))} + className="flex flex-col gap-4" + > + + {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" && ( + <> +

+ {requirePoA + ? "Power of Attorney details are required for Freight Forwarder registration." + : "Power of Attorney details are optional. Skip if not applicable."} +

+ + + + PoA Name + {requirePoA && *} + + + + + +
+ + + PoA Email + {requirePoA && *} + + + + + + +
+ +
+ + + PoA Location + {requirePoA && *} + + + + + + + + PoA Address + {requirePoA && *} + + + + +
+ + )} +
+ +
+ + + +
+
+ + ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx new file mode 100644 index 000000000..a2f08a012 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/DjiboutiAgentForm.tsx @@ -0,0 +1,322 @@ +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { + ArrowRight, + ArrowLeft, + Building2, + UserRound, + CheckCircle2, + Loader2, + ChevronLeft, +} from "lucide-react"; +import type { AuthUser } from "@/types/auth"; +import type { CreateCustomerDto } from "@/types/customers"; +import PhoneInput from "@/components/auth/PhoneInput"; +import { + Button, + Input, + Field, + FieldLabel, + FieldError, + FieldGroup, +} from "@edr/ui-common"; + +type DjiboutiStep = "company" | "representative"; + +const djiboutiSchema = 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 / Country is required"), + companyAddress: z.string().min(1, "Address is required"), + repName: z.string().min(1, "Representative name is required"), + repEmail: z.string().email("Invalid representative email"), + repPhone: z.string().min(1, "Representative phone is required"), + repPhoneCountryCode: z.string().min(1, "Country code is required"), +}); + +type FormData = z.infer; + +const stepLabels: Record = { + company: "Step 1 of 2 — Company Information", + representative: "Step 2 of 2 — Representative Details", +}; + +function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto { + const nameParts = (user.name?.en ?? "").split(" "); + return { + 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.repName, + contactPersonPhone: `${data.repPhoneCountryCode}${data.repPhone}`, + tinNumber: "", + vatNumber: "", + fanNumber: "", + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + }; +} + +export default function DjiboutiAgentForm({ + user, + onSubmit, + isPending, + onBack, +}: { + user: AuthUser; + onSubmit: (data: CreateCustomerDto) => void; + isPending: boolean; + onBack: () => void; +}) { + const [step, setStep] = useState("company"); + + const { + register, + handleSubmit, + trigger, + formState: { errors }, + } = useForm({ + resolver: zodResolver(djiboutiSchema), + defaultValues: { + companyName: "", + companyEmail: "", + companyPhone: "", + companyPhoneCountryCode: "+253", + companyLocation: "", + companyAddress: "", + repName: "", + repEmail: "", + repPhone: "", + repPhoneCountryCode: "+253", + }, + }); + + const nextStep = async () => { + if (step === "representative") { + handleSubmit((data) => onSubmit(buildPayload(data, user)))(); + return; + } + const fields: (keyof FormData)[] = + step === "company" + ? [ + "companyName", + "companyEmail", + "companyPhone", + "companyPhoneCountryCode", + "companyLocation", + "companyAddress", + ] + : ["repName", "repEmail", "repPhone", "repPhoneCountryCode"]; + const isValid = await trigger(fields); + if (!isValid) return; + setStep("representative"); + }; + + const prevStep = () => { + if (step === "company") { + onBack(); + } else { + setStep("company"); + } + }; + + return ( + <> +
+ + +
+
+ } + active={step === "company"} + completed={step === "representative"} + /> + } + active={step === "representative"} + completed={false} + /> +
+

+ {stepLabels[step]} +

+
+ +
onSubmit(buildPayload(data, user)))} + className="flex flex-col gap-4" + > + + {step === "company" && ( + <> + + Company Name + + + + +
+ + Company Email + + + + + +
+ +
+ + Location / Country + + + + + + Address + + + +
+ + )} + + {step === "representative" && ( + <> +

+ Provide the company representative details for this account. +

+ + + Representative Name + + + + +
+ + Representative Email + + + + + +
+ + )} +
+ +
+ + + +
+
+ + ); +} + +function StepIcon({ + icon, + active, + completed, +}: { + icon: React.ReactNode; + active: boolean; + completed: boolean; +}) { + return ( +
+ {completed ? : icon} +
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx index cea9d1774..b32d2b192 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx @@ -1,124 +1,125 @@ 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, + ArrowDownToLine, + ArrowUpFromLine, Building2, - User, - FileText, - CheckCircle2, - Loader2, + Ship, + Truck, + Check, } 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"; +import CompanyProfileForm from "./CompanyProfileForm"; +import DjiboutiAgentForm from "./DjiboutiAgentForm"; +import TransporterForm from "./TransporterForm"; +import type { OnboardingUserType } from "./types"; -type OnboardingStep = "company" | "personnel" | "poa"; +const USER_TYPE_CARDS: { + id: OnboardingUserType; + label: string; + description: string; + icon: React.ReactNode; +}[] = [ + { + id: "importer", + label: "Importer", + description: "Import goods into Ethiopia via the railway corridor.", + icon: , + }, + { + id: "exporter", + label: "Exporter", + description: "Export goods from Ethiopia via rail.", + icon: , + }, + { + id: "freight-forwarder-et", + label: "Freight Forwarder (Ethiopia)", + description: + "Ethiopian freight forwarding company handling client cargo.", + icon: , + }, + { + id: "freight-forwarder-dj", + label: "FF Agent (Djibouti)", + description: + "Djibouti-based agent coordinating cross-border logistics.", + icon: , + }, + { + id: "transporter", + label: "Transporter", + description: + "Trucking company providing first/last-mile services.", + icon: , + }, +]; -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(), -}); +const USER_TYPE_LEFT_MAP: Record< + OnboardingUserType, + { + badge: string; + title: string; + description: string; + } +> = { + importer: { + badge: "Importer Registration", + title: "Register as an Importer", + description: + "Set up your company profile to manage imports, track shipments, and streamline customs clearance across the Ethiopia-Djibouti corridor.", + }, + exporter: { + badge: "Exporter Registration", + title: "Register as an Exporter", + description: + "Set up your company profile to manage exports, coordinate outbound logistics, and access rail transport services.", + }, + "freight-forwarder-et": { + badge: "Freight Forwarder Registration (Ethiopia)", + title: "Register Your Forwarding Company", + description: + "Complete your company profile and Power of Attorney to handle cargo on behalf of importers and exporters.", + }, + "freight-forwarder-dj": { + badge: "FF Agent Registration (Djibouti)", + title: "Register as a Djibouti Agent", + description: + "Register your company details and representative information to coordinate cross-border freight operations.", + }, + transporter: { + badge: "Transporter Registration", + title: "Register Your Transport Services", + description: + "Provide your vehicle and fleet details to offer first-mile and last-mile trucking services integrated with rail.", + }, +}; -type FormData = z.infer; - -const stepFields: Record = { - company: [ - "companyName", - "companyEmail", - "companyPhone", - "companyPhoneCountryCode", - "companyLocation", - "companyAddress", - "tinNumber", - "vatNumber", - "fanNumber", +const PREFLIGHT_LEFT = { + badge: "Get Started", + title: "Choose your account type", + description: + "Select the profile that best matches your role in the logistics chain. Each account type provides a tailored onboarding experience.", + features: [ + "Importers & Exporters", + "Freight Forwarders (Ethiopia & Djibouti)", + "Transporters & Fleet Operators", ], - personnel: [ - "contactPersonName", - "contactPersonPhone", - "contactPersonPhoneCountryCode", - "generalManagerName", - "generalManagerEmail", - "generalManagerPhone", - "generalManagerPhoneCountryCode", - ], - poa: [], + stats: { + label: "Active Customers", + value: "500+", + footer: "And growing", + progress: "w-[95%]", + }, }; 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 [userType, setUserType] = useState(null); const createCustomerMutation = useMutation({ mutationFn: (payload: CreateCustomerDto) => @@ -131,390 +132,119 @@ export default function OnboardingPage() { }, }); - 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"); - }; + if (!user) return null; - 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, - }; + const handleSubmit = (payload: CreateCustomerDto) => { createCustomerMutation.mutate(payload); }; + const handleSelectType = (type: OnboardingUserType) => { + setUserType(type); + }; + + const handleBack = () => { + setUserType(null); + }; + + // Preflight: user type selection + if (!userType) { + return ( + +
+
+

+ Select Account Type +

+

+ Choose the account type that fits your role. +

+
+ +
+ {USER_TYPE_CARDS.map((card) => ( + + ))} +
+
+
+ ); + } + + // Render the appropriate form based on user type + const leftConfig = USER_TYPE_LEFT_MAP[userType]; + const leftProps = { + ...leftConfig, + features: + userType === "transporter" + ? [ + "Vehicle & fleet registration", + "TIN & FAN verification", + "First-mile / Last-mile eligibility", + ] + : userType === "freight-forwarder-dj" + ? [ + "Company details", + "Representative information", + "Cross-border operations", + ] + : [ + "Company registration details", + "Contact and management personnel", + "Power of Attorney (optional)", + ], + stats: { + label: "Active Customers", + value: "500+", + footer: "And growing", + progress: "w-[95%]", + }, + }; + 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 - - -
- - )} -
- -
- - - -
-
+ + {userType === "transporter" ? ( + + ) : userType === "freight-forwarder-dj" ? ( + + ) : ( + + )} ); } - -function StepIcon({ - icon, - active, - completed, -}: { - icon: React.ReactNode; - active: boolean; - completed: boolean; -}) { - return ( -
- {completed ? : icon} -
- ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx index bfb175895..b5289a5dc 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/SignupPage.tsx @@ -26,7 +26,11 @@ const userSchema = z.object({ .min(9, "Phone number is too short") .max(9, "Phone number is too long"), userType: z.string(), - name: z.object({ + firstName: z.object({ + en: z.string().min(2, "Name is required"), + am: z.string().nullable(), + }), + lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable(), }), @@ -51,7 +55,8 @@ export default function SignupPage() { countryCode: "+251", phone: "", userType: userType.individual, - name: { en: "", am: "" }, + firstName: { en: "", am: "" }, + lastName: { en: "", am: "" }, }, }); @@ -67,11 +72,12 @@ export default function SignupPage() { username: data.email, phoneNumber: `${data.countryCode}${normalizedPhone}`, userType: data.userType, - name: { en: data.name.en, am: data.name.am ?? "" }, + name: { en: `${data.firstName.en} ${data.lastName.en}`, am: "" }, }; const result = await signup(payload); if (result.success) { - navigate("/otp"); + navigate("/portal"); + // navigate("/otp"); } else { setError(result.error.message); } @@ -121,18 +127,31 @@ export default function SignupPage() {
- - Full Name - - - +
+ + First Name + + + + + Last Name + + + +
Email Address { + if (data.truckType === "Casoni" && (!data.plateNumber2 || data.plateNumber2.trim().length === 0)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["plateNumber2"], + message: "Second plate number is required for Casoni", + }); + } + }); + +type FormData = z.infer; + +function buildPayload(data: FormData, user: AuthUser): CreateCustomerDto { + const nameParts = (user.name?.en ?? "").split(" "); + return { + userId: user.id, + firstName: nameParts[0] || "", + lastName: nameParts.slice(-1)[0] || "", + email: user.email, + phone: user.phoneNumber, + companyName: "", + companyEmail: "", + companyPhone: "", + companyLocation: "", + companyAddress: "", + contactPersonName: "", + contactPersonPhone: "", + tinNumber: data.tinNumber, + vatNumber: "", + fanNumber: data.fanNumber, + generalManagerName: "", + generalManagerEmail: "", + generalManagerPhone: "", + notes: JSON.stringify({ + truckType: data.truckType, + plateNumber: data.plateNumber, + plateNumber2: data.plateNumber2 || null, + vehicleModel: data.vehicleModel, + yearOfManufacturing: data.yearOfManufacturing, + }), + }; +} + +export default function TransporterForm({ + user, + onSubmit, + isPending, + onBack, +}: { + user: AuthUser; + onSubmit: (data: CreateCustomerDto) => void; + isPending: boolean; + onBack: () => void; +}) { + const { + register, + handleSubmit, + watch, + control, + formState: { errors }, + } = useForm({ + resolver: zodResolver(transporterSchema), + defaultValues: { + tinNumber: "", + fanNumber: "", + truckType: "", + plateNumber: "", + plateNumber2: "", + vehicleModel: "", + yearOfManufacturing: "", + }, + }); + + const truckType = watch("truckType"); + const isCasoni = truckType === "Casoni"; + + return ( + <> +
+ + +
+
+ +
+
+

+ Transporter Registration +

+
+ + onSubmit(buildPayload(data, user)))} + className="flex flex-col gap-4" + > + {/* Personal Info (read-only) */} +
+
+ + Account Holder +
+

+ {user.name?.en} — {user.email} — {user.phoneNumber} +

+
+ +
+ + TIN Number (10 digits) + + + + + + FAN Number (16 digits) + + + +
+ +
+ +

+ Vehicle / Truck Information +

+ + ( + + Truck Type + + + + )} + /> + +
+ + + Plate Number{isCasoni ? " (Front)" : ""} + + + + + + {isCasoni && ( + + Plate Number (Trailer) + + + + )} + + {!isCasoni && ( + + Vehicle Model + + + + )} +
+ +
+ {isCasoni && ( + + Vehicle Model + + + + )} + + + Year of Manufacturing + + + +
+ +
+ + + +
+ + + ); +} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/types.ts b/apps/edr-freight-web/portal/src/pages/accounts/types.ts new file mode 100644 index 000000000..088387ff4 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/accounts/types.ts @@ -0,0 +1,15 @@ +import type { LucideIcon } from "lucide-react"; + +export type OnboardingUserType = + | "importer" + | "exporter" + | "freight-forwarder-et" + | "freight-forwarder-dj" + | "transporter"; + +export interface UserTypeOption { + id: OnboardingUserType; + label: string; + description: string; + icon: LucideIcon; +}