fix: type error and fixes

This commit is contained in:
Nathnael
2026-06-23 14:20:16 +00:00
parent cbbad02b29
commit 10d74a65b0
9 changed files with 254 additions and 1096 deletions

View File

@@ -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<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
function StyledInput(props, ref) {
return <input {...props} ref={ref} className="edr-phone-input" />;
},
);
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", },
}}
>
<div className={`edr-phone-wrapper${error ? " edr-phone-wrapper--error" : ""}`}>
<div className={`edr-phone-wrapper ${error ? " edr-phone-wrapper--error" : ""}`}>
<RPNInput
international
defaultCountry="ET"
@@ -86,10 +74,9 @@ export function PhoneField({
addInternationalOption
value={value}
onChange={onChange}
onBlur={onBlur}
inputComponent={TextInput}
disabled={disabled}
placeholder={placeholder}
inputComponent={StyledInput}
/>
</div>
</Input.Wrapper>

View File

@@ -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: <Globe2 size={20} />,
title: "Where is your company registered?",
description: "This determines the documents we'll ask you to provide.",
},
role: {
icon: <Building2 size={20} />,
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: <Building2 size={20} />,
title: "Company Information",
description: "Tell us about your company and its registration details.",
},
personnel: {
icon: <User size={20} />,
title: "General Manager",
description: "Who is the general manager of the company?",
},
contact: {
icon: <UserCheck size={20} />,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
poa: {
icon: <FileText size={20} />,
title: "Power of Attorney",
description: "Optionally add a representative with power of attorney.",
},
documents: {
icon: <UploadCloud size={20} />,
title: "Upload Documents",
description: "Provide the required company documents.",
},
additional: {
icon: <CheckCircle2 size={20} />,
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<Record<string, File[]>>({});
const [startError, setStartError] = useState<string | null>(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<FormStep>(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={
<Stack gap={2}>
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
Complete your onboarding
</Text>
<Text size="sm" c="edr-muted">
{titleHint}
</Text>
<Stack gap="md">
<Box>
<Group gap="sm" mb={4}>
{stepMeta.icon}
<Title order={3}>{stepMeta.title}</Title>
</Group>
<Text c="edr-muted" size="sm">
{stepMeta.description}
</Text>
</Box>
<ProgressPill current={activeIdx} total={WIZARD_STEPS.length} />
</Stack>
}
>
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect value={nationality} onChange={setNationality} />
<RoleContinueBar
disabled={!nationality}
onClick={handleNationalityContinue}
/>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<RoleContinueBar
disabled={!rolesValid}
loading={startMutation.isPending}
onClick={handleRolesContinue}
/>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
<Stack gap="xl">
{phase === "nationality" ? (
<Stack gap="lg">
<NationalitySelect
value={nationality}
onChange={setNationality}
embedded
/>
<Group justify="flex-end" pt="xs">
<Button
color="edr-green"
onClick={handleNationalityContinue}
disabled={!nationality}
rightSection={<ArrowRight size={16} />}
>
Continue
</Button>
</Group>
</Stack>
) : phase === "role" ? (
<Stack gap="lg">
<OnboardingRoleSelect value={roles} onChange={setRoles} embedded />
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<Group justify="space-between" pt="xs">
<Button
variant="default"
leftSection={<ArrowLeft size={16} />}
onClick={() => setPhase("nationality")}
>
Back
</Button>
<Button
color="edr-green"
onClick={handleRolesContinue}
disabled={!rolesValid}
loading={startMutation.isPending}
rightSection={
startMutation.isPending ? undefined : <ArrowRight size={16} />
}
>
Continue
</Button>
</Group>
</Stack>
) : (
<CompanyProfileForm {...formProps} />
)}
</Stack>
</Modal>
);
}
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 (
<button
type="button"
disabled={disabled || loading}
onClick={onClick}
className="ml-auto rounded-lg bg-[var(--mantine-color-edr-green-6)] px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "Setting up…" : "Continue"}
</button>
<Box className="relative h-1.5 w-full overflow-hidden rounded-full bg-edr-border">
<Box
className="absolute inset-y-0 left-0 rounded-full bg-[var(--mantine-color-edr-green-6)] transition-[width] duration-500 ease-out"
style={{ width: `${pct}%` }}
/>
</Box>
);
}

View File

@@ -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;

View File

@@ -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<CompanyStep, React.ReactNode> = {
company: <Building2 size={18} />,
personnel: <User size={18} />,
contact: <UserCheck size={18} />,
poa: <FileText size={18} />,
documents: <UploadCloud size={18} />,
additional: <CheckCircle2 size={18} />,
};
const STEP_TITLES: Record<CompanyStep, string> = {
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 (
<>
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
>
Change account type
</Button>
<Group
justify="space-between"
align="center"
className="relative max-w-lg mx-auto px-2"
>
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{stepOrder.map((key, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon
key={key}
size={40}
radius="xl"
variant="filled"
color="edr-green"
className="relative z-10"
>
{done ? <CheckCircle2 size={18} /> : STEP_ICONS[key]}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{STEP_ICONS[key]}
</Box>
);
})}
</Group>
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{stepLabel}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (

View File

@@ -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<typeof forwarderSchema>;
const stepFields: Record<ForwarderStep, (keyof FormData)[]> = {
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<UpdateProfilePayload> {
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<string, File | File[] | null>;
onDocumentFilesChange?: (files: Record<string, File | File[] | null>) => 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<UpdateProfilePayload>,
) => 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<string, File[]>;
onLicenseChange?: (value: Record<string, File[]>) => void;
}) {
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(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<Record<string, File | File[] | null>>({});
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<FormData>({
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<boolean> => {
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: <Building2 size={18} /> },
{ key: "personnel", icon: <User size={18} /> },
{ key: "poa", icon: <FileText size={18} /> },
{ key: "documents", icon: <UploadCloud size={18} /> },
{ key: "additional", icon: <CheckCircle2 size={18} /> },
];
const STEP_LABELS: Record<ForwarderStep, string> = {
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 (
<>
<Box mb="xl">
<Button
variant="subtle"
c="edr-muted"
size="sm"
mb="sm"
leftSection={<ChevronLeft size={16} />}
onClick={prevStep}
>
Change account type
</Button>
<Group justify="space-between" align="center" className="relative max-w-lg mx-auto px-2">
<Box className="absolute top-1/2 left-2 right-2 h-0.5 bg-edr-border -translate-y-1/2 z-0" />
{STEPS.map(({ key, icon }, i) => {
const done = i < currentIdx;
const active = i === currentIdx;
return done || active ? (
<ThemeIcon key={key} size={40} radius="xl" variant="filled" color="edr-green" className="relative z-10">
{done ? <CheckCircle2 size={18} /> : icon}
</ThemeIcon>
) : (
<Box
key={key}
w={40}
h={40}
c="edr-slate"
className="relative z-10 flex items-center justify-center rounded-full border-2 border-edr-border bg-edr-card"
>
{icon}
</Box>
);
})}
</Group>
<Text size="sm" c="edr-muted" ta="center" mt="sm">
{STEP_LABELS[step]}
</Text>
</Box>
<form onSubmit={(e) => e.preventDefault()}>
<Stack gap="md">
{step === "company" && (
<>
<TextInput
label="Company Name"
placeholder="Global Logistics Ltd"
error={errors.companyName?.message}
{...register("companyName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Company Email"
type="email"
placeholder="ops@company.com"
error={errors.companyEmail?.message}
{...register("companyEmail")}
/>
<ControlledPhoneField
control={control}
name="companyPhone"
label="Company Phone"
required
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Location"
placeholder="Addis Ababa, Ethiopia"
error={errors.companyLocation?.message}
{...register("companyLocation")}
/>
<TextInput
label="Address"
placeholder="Bole Subcity, Woreda 03"
error={errors.companyAddress?.message}
{...register("companyAddress")}
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="TIN Number (10 digits)"
placeholder="1234567890"
maxLength={10}
error={errors.tinNumber?.message}
{...register("tinNumber")}
/>
<TextInput
label="VAT Number"
placeholder="VAT-12345"
maxLength={10}
error={errors.vatNumber?.message}
{...register("vatNumber")}
/>
</SimpleGrid>
<TextInput
label="FAN Number (16 digits)"
placeholder="1234567890123456"
maxLength={16}
error={errors.fanNumber?.message}
{...register("fanNumber")}
/>
</>
)}
{step === "personnel" && (
<>
<Text fw={600} size="sm" c="edr-text">Contact Person</Text>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Name"
placeholder="Jane Smith"
error={errors.contactPersonName?.message}
{...register("contactPersonName")}
/>
<ControlledPhoneField
control={control}
name="contactPersonPhone"
label="Phone"
required
/>
</SimpleGrid>
<Divider color="edr-border" />
<Text fw={600} size="sm" c="edr-text">General Manager</Text>
<TextInput
label="Name"
placeholder="Abebe Bikila"
error={errors.generalManagerName?.message}
{...register("generalManagerName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Email"
type="email"
placeholder="gm@company.com"
error={errors.generalManagerEmail?.message}
{...register("generalManagerEmail")}
/>
<ControlledPhoneField
control={control}
name="generalManagerPhone"
label="Phone"
required
/>
</SimpleGrid>
</>
)}
{step === "poa" && (
<>
<Text size="sm" c="edr-muted">
Power of Attorney details are optional. Fill them in if you have them, or skip to continue.
</Text>
<TextInput
label="PoA Name"
placeholder="Authorized Representative Name"
error={errors.poaName?.message}
{...register("poaName")}
/>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Email"
type="email"
placeholder="poa@company.com"
error={errors.poaEmail?.message}
{...register("poaEmail")}
/>
<ControlledPhoneField
control={control}
name="poaPhone"
label="PoA Phone"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing="md">
<TextInput
label="PoA Location"
placeholder="City, Country"
error={errors.poaLocation?.message}
{...register("poaLocation")}
/>
<TextInput
label="PoA Address"
placeholder="Full Address"
error={errors.poaAddress?.message}
{...register("poaAddress")}
/>
</SimpleGrid>
</>
)}
{step === "documents" && (
<>
{loadingDocuments ? (
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
) : !uploadSetting ? (
<Text size="sm" c="edr-muted" ta="center" py="md">
No document requirements found for your account type.
</Text>
) : (
<SmartFileInput file={uploadSetting} value={documentFiles} onChange={setDocumentFiles} />
)}
</>
)}
{step === "additional" && (
<RoleLicenseStep
profiles={roleProfiles ?? []}
value={licenseFiles ?? {}}
onChange={onLicenseChange ?? (() => {})}
/>
)}
{saveError && (
<Alert
color="red"
variant="light"
icon={<AlertCircle size={18} />}
title={step === "additional" ? "Business license required" : "Couldn't save this step"}
>
{saveError}
</Alert>
)}
<Group justify="space-between" pt="xs">
{showBack ? (
<Button variant="default" onClick={prevStep} leftSection={<ArrowLeft size={16} />}>
{step === "additional" ? "Back to Documents" : "Back"}
</Button>
) : (
<span />
)}
<Group gap="sm">
{step === "documents" && (
<Button variant="default" onClick={skipDocuments} disabled={isPending || saving}>
Skip for now
</Button>
)}
<Button
color="edr-green"
onClick={nextStep}
disabled={isPending || saving || (step === "documents" && !hasDocuments && loadingDocuments)}
loading={isPending || saving}
rightSection={!isPending && !saving && step !== "additional" && step !== "documents" ? <ArrowRight size={16} /> : undefined}
>
{step === "documents" ? "Continue" : step === "additional" ? "Finish onboarding" : "Save & Continue"}
</Button>
</Group>
</Group>
</Stack>
</form>
</>
);
}

View File

@@ -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: <ArrowDownToLine size={22} />,
},
{
id: "exporter",
label: "Exporter",
description: "Export goods from Ethiopia via rail.",
icon: <ArrowUpFromLine size={22} />,
},
{
id: "freight-forwarder-et",
label: "Freight Forwarder (Ethiopia)",
description: "Ethiopian freight forwarding company handling client cargo.",
icon: <Building2 size={22} />,
},
// {
// id: "freight-forwarder-dj",
// label: "FF Agent (Djibouti)",
// description: "Djibouti-based agent coordinating cross-border logistics.",
// icon: <Ship size={22} />,
// },
// {
// id: "transporter",
// label: "Transporter",
// description: "Trucking company providing first/last-mile services.",
// icon: <Truck size={22} />,
// },
];
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<OnboardingUserType, string> = {
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<OnboardingUserType | null>(null);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
const COMPANY_TYPE_MAP: Record<OnboardingUserType, string> = {
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 (
<AuthLayout left={PREFLIGHT_LEFT}>
<Stack gap="lg">
<Box>
<Text fz={20} fw={800} c="edr-text" className="tracking-tight">
Select Account Type
</Text>
<Text size="sm" c="edr-muted" mt={4}>
Choose the account type that fits your role.
</Text>
</Box>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{USER_TYPE_CARDS.map((card) => (
<UnstyledButton
key={card.id}
onClick={() => 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)]"
>
<Group gap="md" wrap="nowrap" align="start">
<ThemeIcon
size={56}
radius="lg"
variant="light"
color="edr-green"
className="shrink-0 transition-colors group-hover:!bg-[var(--mantine-color-edr-green-6)] group-hover:!text-white"
>
{card.icon}
</ThemeIcon>
<Box className="min-w-0 flex-1">
<Text fw={700} c="edr-text" fz={15}>
{card.label}
</Text>
<Text size="xs" c="edr-muted" mt={4} lh={1.5}>
{card.description}
</Text>
</Box>
<ChevronRight
size={18}
className="shrink-0 text-[var(--mantine-color-edr-muted-6)] transition-all group-hover:translate-x-0.5 group-hover:text-[var(--mantine-color-edr-green-6)]"
/>
</Group>
</UnstyledButton>
))}
</SimpleGrid>
</Stack>
</AuthLayout>
);
}
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 (
<AuthLayout left={leftProps}>
{userType === "transporter" ? (
<TransporterForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-dj" ? (
<DjiboutiAgentForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : userType === "freight-forwarder-et" ? (
<ForwarderForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
) : (
<CompanyProfileForm
documentSettingCode={DOCUMENT_SETTING_CODE_MAP[userType]}
documentFiles={documentFiles}
onDocumentFilesChange={setDocumentFiles}
user={user}
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBack}
/>
)}
</AuthLayout>
);
}

View File

@@ -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 = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}
/>
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
</SimpleGrid>
);
if (embedded) return grid;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -28,23 +52,7 @@ export default function NationalitySelect({
<Text c="edr-muted" size="sm" mb="lg">
This determines the documents we'll ask you to provide.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<RoleCard
label="Ethiopian Company"
description="Registered in Ethiopia. You'll provide a TIN certificate, commercial license and national ID."
icon={<MapPin size={22} />}
selected={value === "ethiopian"}
onClick={() => onChange("ethiopian")}
/>
<RoleCard
label="Foreign Company"
description="Registered abroad. You'll provide a passport and investment license."
icon={<Globe2 size={22} />}
selected={value === "foreign"}
onClick={() => onChange("foreign")}
/>
</SimpleGrid>
{grid}
</Card>
);
}

View File

@@ -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 = (
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleRole(role.type)}
/>
))}
</SimpleGrid>
);
if (embedded) return grid;
return (
<Card padding="lg">
<Group gap="sm" mb="xs">
@@ -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.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
{CUSTOMER_ROLES.map((role) => (
<RoleCard
key={role.type}
label={role.label}
description={role.description}
icon={role.icon}
selected={selected.has(role.type)}
onClick={() => toggleRole(role.type)}
/>
))}
</SimpleGrid>
{grid}
</Card>
);
}

View File

@@ -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 && <CompanyRolesCard profile={profile} />
)}
) : null}
{showForm && (
<Card padding="lg">
<Group gap="sm" mb="xs">