diff --git a/apps/edr-freight-web/portal/src/components/PhoneField.tsx b/apps/edr-freight-web/portal/src/components/PhoneField.tsx index a50621e60..abc7083b5 100644 --- a/apps/edr-freight-web/portal/src/components/PhoneField.tsx +++ b/apps/edr-freight-web/portal/src/components/PhoneField.tsx @@ -1,5 +1,4 @@ -import { Input } from "@mantine/core"; -import { forwardRef } from "react"; +import { Input, TextInput } from "@mantine/core"; import { Controller, type Control, @@ -32,17 +31,6 @@ export const toEthiopianE164 = (raw?: string | null): string => { return `+251${digits}`; }; -/** - * The text input rendered inside react-phone-number-input, styled to match the - * portal's Mantine fields (44px height, 10px radius, edr border). Must forward - * the ref and accept native input props for the library to drive it. - */ -const StyledInput = forwardRef>( - function StyledInput(props, ref) { - return ; - }, -); - export interface PhoneFieldProps { label?: string; value?: string; @@ -75,10 +63,10 @@ export function PhoneField({ required={required} error={error} styles={{ - label: { fontWeight: 600, fontSize: 13, color: "#10202F", marginBottom: 6 }, + label: { fontWeight: 600, fontSize: 14, color: "#10202F", }, }} > -
+
diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 17fde6528..3628d7403 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -1,8 +1,33 @@ -import { Modal, ScrollArea, Stack, Text } from "@mantine/core"; +import { + Box, + Button, + Group, + Modal, + ScrollArea, + Stack, + Text, + Title, +} from "@mantine/core"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + ArrowLeft, + ArrowRight, + Building2, + CheckCircle2, + FileText, + Globe2, + UploadCloud, + User, + UserCheck, +} from "lucide-react"; +import type { ReactNode } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; +import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; import useAuth from "@/hooks/useAuth"; +import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; +import NationalitySelect from "@/pages/settings/NationalitySelect"; +import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; import { api } from "@/services/api"; import type { CompanyNationality, @@ -12,12 +37,8 @@ import type { import { companiesService } from "@/services/companies.service"; import type { UpdateProfilePayload } from "@/types/profile"; import { extractApiError } from "@/utils/result"; -import CompanyProfileForm from "@/pages/accounts/CompanyProfileForm"; -import type { RoleLicenseProfile } from "@/components/onboarding/RoleLicenseStep"; -import NationalitySelect from "@/pages/settings/NationalitySelect"; -import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect"; -/** Form steps shared by CompanyProfileForm and ForwarderForm. */ +/** Form steps rendered by CompanyProfileForm. */ type FormStep = | "company" | "personnel" @@ -34,6 +55,58 @@ const FORM_STEPS: FormStep[] = [ "additional", ]; +/** The full onboarding journey: the two pre-form phases + the form steps. */ +type WizardStep = "nationality" | "role" | FormStep; +const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS]; + +/** Icon + title + description shown in the global dialog header per step. */ +const STEP_META: Record< + WizardStep, + { icon: ReactNode; title: string; description: string } +> = { + nationality: { + icon: , + title: "Where is your company registered?", + description: "This determines the documents we'll ask you to provide.", + }, + role: { + icon: , + title: "What does your company do?", + description: + "Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.", + }, + company: { + icon: , + title: "Company Information", + description: "Tell us about your company and its registration details.", + }, + personnel: { + icon: , + title: "General Manager", + description: "Who is the general manager of the company?", + }, + contact: { + icon: , + title: "Contact Person", + description: "Who should we reach out to about this account?", + }, + poa: { + icon: , + title: "Power of Attorney", + description: "Optionally add a representative with power of attorney.", + }, + documents: { + icon: , + title: "Upload Documents", + description: "Provide the required company documents.", + }, + additional: { + icon: , + title: "Business License", + description: "Upload a business license for each operational profile.", + }, +}; + interface OnboardingWizardDialogProps { opened: boolean; /** Dismiss the dialog (user clicked the close icon). */ @@ -98,6 +171,9 @@ export default function OnboardingWizardDialog({ // Newly-selected business-license files per company_profile id. const [licenseFiles, setLicenseFiles] = useState>({}); const [startError, setStartError] = useState(null); + // Mirror of CompanyProfileForm's active step so the global header + progress + // pill can reflect it (the form no longer renders its own stepper). + const [formStep, setFormStep] = useState(resumeFormStep); // Saved profile data, for rehydrating the form fields after a refresh. const profileQuery = useQuery( @@ -164,6 +240,15 @@ export default function OnboardingWizardDialog({ api.companies.setOnboardingStep.call({ step }).catch(() => {}); }, []); + // Mirror the form's step locally (for the header/pill) and persist it. + const handleStepChange = useCallback( + (step: string) => { + setFormStep(step as FormStep); + persistStep(step); + }, + [persistStep], + ); + // The company query may resolve AFTER this dialog mounts (it's kept mounted by // the gate), so the phase/roles/nationality initial state can be stale — a // draft that already exists would otherwise leave us stuck on the first @@ -239,12 +324,10 @@ export default function OnboardingWizardDialog({ existingFiles: p.licenseFiles ?? [], })); - const titleHint = - phase === "nationality" - ? "Where is your company registered?" - : phase === "role" - ? "Tell us what your company does to get started." - : "Set up your company profile to finish."; + // The active step across the whole journey, driving the header + progress pill. + const activeStep: WizardStep = phase === "form" ? formStep : phase; + const stepMeta = STEP_META[activeStep]; + const activeIdx = WIZARD_STEPS.indexOf(activeStep); const formProps = { documentSettingCode: documentSettingCode(effectiveNationality), @@ -257,7 +340,7 @@ export default function OnboardingWizardDialog({ hideFirstStepBack: true, initialStep: resumeFormStep, resyncOpen: opened, - onStepChange: persistStep, + onStepChange: handleStepChange, onSaveStep: saveStep, rehydrate: profileQuery.data ?? null, roleProfiles, @@ -279,63 +362,98 @@ export default function OnboardingWizardDialog({ keepMounted scrollAreaComponent={ScrollArea.Autosize} overlayProps={{ backgroundOpacity: 0.55, blur: 4 }} + styles={{ + header: { + alignItems:"flex-start" + }, + title: { + flex: 1 + } + }} title={ - - - Complete your onboarding - - - {titleHint} - + + + + {stepMeta.icon} + {stepMeta.title} + + + {stepMeta.description} + + + } > - {phase === "nationality" ? ( - - - - - ) : phase === "role" ? ( - - - {startError && ( - - {startError} - - )} - - - ) : ( - - )} + + + {phase === "nationality" ? ( + + + + + + + ) : phase === "role" ? ( + + + {startError && ( + + {startError} + + )} + + + + + + ) : ( + + )} + ); } -function RoleContinueBar({ - disabled, - loading, - onClick, -}: { - disabled: boolean; - loading?: boolean; - onClick: () => void; -}) { +/** + * Continuous progress pill: a single rounded track that fills left-to-right as + * the user advances, with faint ticks marking each step boundary. + */ +function ProgressPill({ current, total }: { current: number; total: number }) { + const pct = total > 0 ? ((current + 1) / total) * 100 : 0; return ( - + + + ); } diff --git a/apps/edr-freight-web/portal/src/components/phone-field.css b/apps/edr-freight-web/portal/src/components/phone-field.css index 2fd037b99..3a0396ffd 100644 --- a/apps/edr-freight-web/portal/src/components/phone-field.css +++ b/apps/edr-freight-web/portal/src/components/phone-field.css @@ -11,9 +11,9 @@ .edr-phone-wrapper .PhoneInputCountry { margin: 0; padding: 0 10px; - height: 44px; - border: 1px solid #e6ecf2; - border-radius: 10px; + height: 2.25rem; + border: 0.0625rem solid #b0bfce; + border-radius: 6px; background: #fff; display: flex; align-items: center; diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 29055a2e8..115351440 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -1,6 +1,5 @@ import { Alert, - Box, Button, Checkbox, Divider, @@ -10,7 +9,6 @@ import { Stack, Text, TextInput, - ThemeIcon, } from "@mantine/core"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQuery } from "@tanstack/react-query"; @@ -18,12 +16,6 @@ import { AlertCircle, ArrowLeft, ArrowRight, - Building2, - CheckCircle2, - ChevronLeft, - FileText, - UploadCloud, - User, UserCheck, } from "lucide-react"; import { useEffect, useRef, useState } from "react"; @@ -495,7 +487,6 @@ export default function CompanyProfileForm({ "documents", "additional", ]; - const totalSteps = stepOrder.length; const currentIdx = stepOrder.indexOf(step); /** Validate + persist the current step, returning whether we may advance. */ @@ -553,79 +544,8 @@ export default function CompanyProfileForm({ // selection); otherwise always available. const showBack = !(hideFirstStepBack && step === "company"); - const STEP_ICONS: Record = { - company: , - personnel: , - contact: , - poa: , - documents: , - additional: , - }; - - const STEP_TITLES: Record = { - company: "Company Information", - personnel: "General Manager", - contact: "Contact Person", - poa: "Power of Attorney (Optional)", - documents: "Upload Documents", - additional: "Business License", - }; - - const stepLabel = `Step ${currentIdx + 1} of ${totalSteps} — ${STEP_TITLES[step]}`; - return ( <> - - - - - - {stepOrder.map((key, i) => { - const done = i < currentIdx; - const active = i === currentIdx; - return done || active ? ( - - {done ? : STEP_ICONS[key]} - - ) : ( - - {STEP_ICONS[key]} - - ); - })} - - - - {stepLabel} - - -
e.preventDefault()}> {step === "company" && ( diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx deleted file mode 100644 index e13ec2282..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ /dev/null @@ -1,580 +0,0 @@ -import { Alert, Box, Button, Divider, Group, Loader, SimpleGrid, Stack, Text, TextInput, ThemeIcon } from "@mantine/core"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - ArrowLeft, - ArrowRight, - Building2, - CheckCircle2, - ChevronLeft, - FileText, - UploadCloud, - User, -} from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; - -import type { AuthUser } from "@/types/auth"; -import type { CreateCompanyPayload } from "@/services/companies.service"; -import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile"; -import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; -import { SmartFileInput } from "@edr/ui-common"; -import { api } from "@/services/api"; -import RoleLicenseStep, { - type RoleLicenseProfile, -} from "@/components/onboarding/RoleLicenseStep"; - -type ForwarderStep = "company" | "personnel" | "poa" | "documents" | "additional"; - -const forwarderSchema = 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") - .refine(isValidPhone, "Enter a valid phone number"), - 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") - .refine(isValidPhone, "Enter a valid phone number"), - 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") - .refine(isValidPhone, "Enter a valid phone number"), - poaName: z.string().optional(), - poaPhone: z - .string() - .optional() - .refine((v) => !v || isValidPhone(v), "Enter a valid phone number"), - poaAddress: z.string().optional(), - poaEmail: z.string().optional(), - poaLocation: z.string().optional(), -}); - -type FormData = z.infer; - -const stepFields: Record = { - company: ["companyName", "companyEmail", "companyPhone", "companyLocation", "companyAddress", "tinNumber", "vatNumber", "fanNumber"], - personnel: ["contactPersonName", "contactPersonPhone", "generalManagerName", "generalManagerEmail", "generalManagerPhone"], - poa: [], - documents: [], - additional: [], -}; - -function buildPayload(data: FormData, _user: AuthUser): CreateCompanyPayload { - return { - companyName: data.companyName, - companyEmail: data.companyEmail, - companyPhone: data.companyPhone, - companyLocation: data.companyLocation, - companyAddress: data.companyAddress, - tin: data.tinNumber, - vatNumber: data.vatNumber, - fanNumber: data.fanNumber, - attributes: { - contactPersonName: data.contactPersonName, - contactPersonPhone: data.contactPersonPhone, - generalManagerName: data.generalManagerName, - generalManagerEmail: data.generalManagerEmail, - generalManagerPhone: data.generalManagerPhone, - poaName: data.poaName || undefined, - poaPhone: data.poaPhone || undefined, - poaAddress: data.poaAddress || undefined, - poaEmail: data.poaEmail || undefined, - poaLocation: data.poaLocation || undefined, - }, - }; -} - -/** Map one wizard step's form values to the profile-update payload it saves. */ -function stepPayload(step: ForwarderStep, d: FormData): Partial { - switch (step) { - case "company": - return { - companyName: d.companyName, - companyEmail: d.companyEmail, - companyPhone: d.companyPhone, - companyLocation: d.companyLocation, - companyAddress: d.companyAddress, - tin: d.tinNumber, - vatNumber: d.vatNumber, - fanNumber: d.fanNumber, - }; - case "personnel": - return { - contactPersonName: d.contactPersonName, - contactPersonPhone: d.contactPersonPhone, - generalManagerName: d.generalManagerName, - generalManagerEmail: d.generalManagerEmail, - generalManagerPhone: d.generalManagerPhone, - }; - case "poa": - return { - poaName: d.poaName || undefined, - poaPhone: d.poaPhone || undefined, - poaEmail: d.poaEmail || undefined, - poaLocation: d.poaLocation || undefined, - poaAddress: d.poaAddress || undefined, - }; - default: - return {}; - } -} - -/** Seed the form from previously-saved profile data. */ -function toFormValues(p: ProfileResponse): FormData { - const tin = p.tinNumber && !p.tinNumber.startsWith("D") ? p.tinNumber : ""; - return { - companyName: p.companyName ?? "", - companyEmail: p.companyEmail ?? "", - companyPhone: p.companyPhone ?? "", - companyLocation: p.companyLocation ?? "", - companyAddress: p.companyAddress ?? "", - tinNumber: tin, - vatNumber: p.vatNumber ?? "", - fanNumber: p.fanNumber ?? "", - contactPersonName: p.contactPersonName ?? "", - contactPersonPhone: p.contactPersonPhone ?? "", - generalManagerName: p.generalManagerName ?? "", - generalManagerEmail: p.generalManagerEmail ?? "", - generalManagerPhone: p.generalManagerPhone ?? "", - poaName: p.poaName ?? "", - poaPhone: p.poaPhone ?? "", - poaAddress: p.poaAddress ?? "", - poaEmail: p.poaEmail ?? "", - poaLocation: p.poaLocation ?? "", - }; -} - -export default function ForwarderForm({ - documentSettingCode, - documentFiles: controlledFiles, - onDocumentFilesChange, - user, - onSubmit, - isPending, - onBack, - initialStep, - resyncOpen, - hideFirstStepBack, - onStepChange, - onSaveStep, - rehydrate, - roleProfiles, - licenseFiles, - onLicenseChange, -}: { - documentSettingCode: string; - documentFiles?: Record; - onDocumentFilesChange?: (files: Record) => void; - user: AuthUser; - onSubmit: (data: CreateCompanyPayload) => void; - isPending: boolean; - onBack: () => void; - /** Step to resume at (defaults to "company"). */ - initialStep?: ForwarderStep; - /** When this flips true (dialog reopened), jump back to initialStep (furthest reached). */ - resyncOpen?: boolean; - /** Hide the Back button on the first step (onboarding can't go back to role pick). */ - hideFirstStepBack?: boolean; - /** Reports the active step so the parent can persist resume progress. */ - onStepChange?: (step: ForwarderStep) => void; - /** Persist the current step's data before advancing; returns an error to show. */ - onSaveStep?: ( - data: Partial, - ) => Promise<{ ok: true } | { ok: false; error: string }>; - /** Saved profile to seed the form with (rehydration after refresh). */ - rehydrate?: ProfileResponse | null; - /** Operational profiles for the final per-role license step. */ - roleProfiles?: RoleLicenseProfile[]; - /** Newly-selected license files per profile id. */ - licenseFiles?: Record; - onLicenseChange?: (value: Record) => void; -}) { - const [step, setStep] = useState(initialStep ?? "company"); - const [saving, setSaving] = useState(false); - const [saveError, setSaveError] = useState(null); - - // Report each step change up so the wizard can persist it for resume. - useEffect(() => { - onStepChange?.(step); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [step]); - - // On reopen, jump to the furthest step reached so progress never resets. - const wasOpen = useRef(resyncOpen); - useEffect(() => { - if (resyncOpen && !wasOpen.current && initialStep) { - setStep(initialStep); - setSaveError(null); - } - wasOpen.current = resyncOpen; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [resyncOpen]); - const [internalFiles, setInternalFiles] = useState>({}); - const documentFiles = controlledFiles ?? internalFiles; - const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; - - const { data: uploadSetting, isLoading: loadingDocuments } = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ input: { code: documentSettingCode }, refetchOnMount: false }), - ); - - const { register, control, handleSubmit, trigger, watch, formState: { errors } } = useForm({ - resolver: zodResolver(forwarderSchema), - defaultValues: { - companyName: "", companyEmail: "", companyPhone: "", - companyLocation: "", companyAddress: "", tinNumber: "", vatNumber: "", fanNumber: "", - contactPersonName: "", contactPersonPhone: "", - generalManagerName: "", generalManagerEmail: "", generalManagerPhone: "", - poaName: "", poaPhone: "", poaAddress: "", poaEmail: "", poaLocation: "", - }, - // Rehydrate from previously-saved data (RHF re-syncs when `values` change). - values: rehydrate ? toFormValues(rehydrate) : undefined, - }); - - const hasDocuments = Boolean(uploadSetting?.fields?.length); - const totalSteps = 5; - - /** Validate + persist the current step, returning whether we may advance. */ - const saveCurrentStep = async (): Promise => { - setSaveError(null); - const isValid = await trigger(stepFields[step]); - if (!isValid) return false; - if (!onSaveStep) return true; - setSaving(true); - try { - const res = await onSaveStep(stepPayload(step, watch())); - if (!res.ok) { - setSaveError(res.error); - return false; - } - return true; - } finally { - setSaving(false); - } - }; - - // Every role needs at least one license file (existing or newly selected). - const licenseComplete = (roleProfiles ?? []).every( - (p) => - (licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0, - ); - - const nextStep = async () => { - if (step === "additional") { - if (!licenseComplete) { - setSaveError( - "Please upload a business license for each of your operational profiles.", - ); - return; - } - handleSubmit((data) => onSubmit(buildPayload(data, user)))(); - return; - } - if (step === "documents") { setStep("additional"); return; } - const ok = await saveCurrentStep(); - if (!ok) return; - setStep(step === "company" ? "personnel" : step === "personnel" ? "poa" : "documents"); - }; - - const skipDocuments = () => setStep("additional"); - - const prevStep = () => { - setSaveError(null); - if (step === "company") onBack(); - else if (step === "personnel") setStep("company"); - else if (step === "poa") setStep("personnel"); - else if (step === "documents") setStep("poa"); - else setStep("documents"); - }; - - const showBack = !(hideFirstStepBack && step === "company"); - - const STEPS: { key: ForwarderStep; icon: React.ReactNode }[] = [ - { key: "company", icon: }, - { key: "personnel", icon: }, - { key: "poa", icon: }, - { key: "documents", icon: }, - { key: "additional", icon: }, - ]; - - const STEP_LABELS: Record = { - company: `Step 1 of ${totalSteps} — Company Information`, - personnel: `Step 2 of ${totalSteps} — Personnel Details`, - poa: `Step 3 of ${totalSteps} — Power of Attorney (Optional)`, - documents: `Step 4 of ${totalSteps} — Upload Documents`, - additional: `Step 5 of ${totalSteps} — Business License`, - }; - - const stepOrder: ForwarderStep[] = ["company", "personnel", "poa", "documents", "additional"]; - const currentIdx = stepOrder.indexOf(step); - - return ( - <> - - - - - - {STEPS.map(({ key, icon }, i) => { - const done = i < currentIdx; - const active = i === currentIdx; - return done || active ? ( - - {done ? : icon} - - ) : ( - - {icon} - - ); - })} - - - - {STEP_LABELS[step]} - - - - e.preventDefault()}> - - {step === "company" && ( - <> - - - - - - - - - - - - - - - - )} - - {step === "personnel" && ( - <> - Contact Person - - - - - - - - General Manager - - - - - - - )} - - {step === "poa" && ( - <> - - Power of Attorney details are optional. Fill them in if you have them, or skip to continue. - - - - - - - - - - - - )} - - {step === "documents" && ( - <> - {loadingDocuments ? ( - - - - ) : !uploadSetting ? ( - - No document requirements found for your account type. - - ) : ( - - )} - - )} - - {step === "additional" && ( - {})} - /> - )} - - {saveError && ( - } - title={step === "additional" ? "Business license required" : "Couldn't save this step"} - > - {saveError} - - )} - - - {showBack ? ( - - ) : ( - - )} - - {step === "documents" && ( - - )} - - - - - - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx b/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx deleted file mode 100644 index 861fbbb75..000000000 --- a/apps/edr-freight-web/portal/src/pages/accounts/OnboardingPage.tsx +++ /dev/null @@ -1,300 +0,0 @@ -import { - Box, - Group, - SimpleGrid, - Stack, - Text, - ThemeIcon, - UnstyledButton, -} from "@mantine/core"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { - ArrowDownToLine, - ArrowUpFromLine, - Building2, - ChevronRight, -} from "lucide-react"; -import { useState } from "react"; - -import AuthLayout from "@/components/auth/AuthLayout"; -import useAuth from "@/hooks/useAuth"; -import { api } from "@/services/api"; -import type { CreateCompanyPayload } from "@/services/companies.service"; -import { companiesService } from "@/services/companies.service"; -import CompanyProfileForm from "./CompanyProfileForm"; -import DjiboutiAgentForm from "./DjiboutiAgentForm"; -import ForwarderForm from "./ForwarderForm"; -import TransporterForm from "./TransporterForm"; -import type { OnboardingUserType } from "./types"; - -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 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.", - }, -}; - -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", - ], - stats: { - label: "Active Customers", - value: "500+", - footer: "And growing", - progress: "w-[95%]", - }, -}; - -const DOCUMENT_SETTING_CODE_MAP: Record = { - importer: "company_onboarding_documents_customer", - exporter: "company_onboarding_documents_customer", - "freight-forwarder-et": "company_onboarding_documents_forwarder", - "freight-forwarder-dj": "company_onboarding_documents_forwarder_dj", - transporter: "company_onboarding_documents_transporter", -}; - -export default function OnboardingPage() { - const queryClient = useQueryClient(); - const { user } = useAuth(); - const [userType, setUserType] = useState(null); - const [documentFiles, setDocumentFiles] = useState< - Record - >({}); - - const COMPANY_TYPE_MAP: Record = { - importer: "customer", - exporter: "customer", - "freight-forwarder-et": "forwarder", - "freight-forwarder-dj": "forwarder", - transporter: "transporter", - }; - - const createCompanyMutation = useMutation({ - mutationFn: (payload: CreateCompanyPayload) => - api.companies.create.call(payload), - onSuccess: async (data) => { - const hasFiles = Object.values(documentFiles).some( - (f) => f !== null && (Array.isArray(f) ? f.length > 0 : true), - ); - if (hasFiles) { - await companiesService.uploadDocuments(data.company.id, documentFiles); - } - await queryClient.invalidateQueries({ - queryKey: api.companies.getInfo.queryKey(), - }); - }, - }); - - if (!user) return null; - - const handleSubmit = (payload: CreateCompanyPayload) => { - const enriched: CreateCompanyPayload = { - ...payload, - companyType: COMPANY_TYPE_MAP[userType!], - }; - createCompanyMutation.mutate(enriched); - }; - - const handleSelectType = (type: OnboardingUserType) => setUserType(type); - const handleBack = () => setUserType(null); - - if (!userType) { - return ( - - - - - Select Account Type - - - Choose the account type that fits your role. - - - - - {USER_TYPE_CARDS.map((card) => ( - handleSelectType(card.id)} - className="group block rounded-lg shadow-lg! border! border-edr-border! bg-edr-card! p-5! text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-[var(--mantine-color-edr-green-5)]! hover:bg-edr-soft hover:shadow-[0_12px_28px_-14px_rgba(14,163,83,0.55)]" - > - - - {card.icon} - - - - {card.label} - - - {card.description} - - - - - - ))} - - - - ); - } - - 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 ( - - {userType === "transporter" ? ( - - ) : userType === "freight-forwarder-dj" ? ( - - ) : userType === "freight-forwarder-et" ? ( - - ) : ( - - )} - - ); -} diff --git a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx index 1d1dbb5c4..5b779847a 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/NationalitySelect.tsx @@ -7,6 +7,8 @@ import RoleCard from "./RoleCard"; interface NationalitySelectProps { value: CompanyNationality | null; onChange: (next: CompanyNationality) => void; + /** Render only the option grid — the wizard supplies its own header/card. */ + embedded?: boolean; } /** @@ -18,7 +20,29 @@ interface NationalitySelectProps { export default function NationalitySelect({ value, onChange, + embedded = false, }: NationalitySelectProps) { + const grid = ( + + } + selected={value === "ethiopian"} + onClick={() => onChange("ethiopian")} + /> + } + selected={value === "foreign"} + onClick={() => onChange("foreign")} + /> + + ); + + if (embedded) return grid; + return ( @@ -28,23 +52,7 @@ export default function NationalitySelect({ This determines the documents we'll ask you to provide. - - - } - selected={value === "ethiopian"} - onClick={() => onChange("ethiopian")} - /> - } - selected={value === "foreign"} - onClick={() => onChange("foreign")} - /> - + {grid} ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx index d5196d024..ca4afb9b9 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/OnboardingRoleSelect.tsx @@ -7,6 +7,8 @@ interface OnboardingRoleSelectProps { /** Currently selected profile types (e.g. ["importer"], ["importer","exporter","freight_forwarder"]). */ value: string[]; onChange: (next: string[]) => void; + /** Render only the option grid — the wizard supplies its own header/card. */ + embedded?: boolean; } /** @@ -19,6 +21,7 @@ interface OnboardingRoleSelectProps { export default function OnboardingRoleSelect({ value, onChange, + embedded = false, }: OnboardingRoleSelectProps) { const selected = new Set(value); @@ -29,6 +32,23 @@ export default function OnboardingRoleSelect({ onChange([...next]); }; + const grid = ( + + {CUSTOMER_ROLES.map((role) => ( + toggleRole(role.type)} + /> + ))} + + ); + + if (embedded) return grid; + return ( @@ -39,19 +59,7 @@ export default function OnboardingRoleSelect({ Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license. - - - {CUSTOMER_ROLES.map((role) => ( - toggleRole(role.type)} - /> - ))} - + {grid} ); } diff --git a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx index 1f49baf7c..995acc104 100644 --- a/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx +++ b/apps/edr-freight-web/portal/src/pages/settings/TabCompanyProfile.tsx @@ -1,27 +1,26 @@ -import { useMemo, 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 { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; -import { - Card, - Group, - Stack, - Title, - Text, - TextInput, - Button, - Grid, -} from "@mantine/core"; -import { api } from "@/services/api"; import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField"; -import type { ProfileResponse } from "@/types/profile"; +import { api } from "@/services/api"; import type { - CreateCompanyPayload, - CompanyProfileInput, + CompanyProfileInput, + CreateCompanyPayload, } from "@/services/companies.service"; -import CompanyRolesCard from "./CompanyRolesCard"; +import type { ProfileResponse } from "@/types/profile"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { + Button, + Card, + Grid, + Group, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { Building2, CheckCircle2, Save, XCircle } from "lucide-react"; +import { useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; import OnboardingRoleSelect from "./OnboardingRoleSelect"; export const COMPANY_PROFILE_SCHEMA = z.object({ @@ -143,9 +142,7 @@ export default function TabCompanyProfile({ value={selectedRoles} onChange={setSelectedRoles} /> - ) : ( - profile && - )} + ) : null} {showForm && (