mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
fix: type error and fixes
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user