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,
Clock,
FileText,
Globe2,
PartyPopper,
ShieldCheck,
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,
CreateCompanyPayload,
ProfileTypeValue,
} from "@/services/companies.service";
import { companiesService } from "@/services/companies.service";
import type { UpdateProfilePayload } from "@/types/profile";
import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */
type FormStep =
| "company"
| "personnel"
| "contact"
| "verify"
| "poa"
| "documents"
| "additional";
const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"contact",
"verify",
"poa",
"documents",
"additional",
];
/** The full onboarding journey: the two pre-form phases + the form steps. */
type WizardStep = "nationality" | "role" | FormStep;
const WIZARD_STEPS: WizardStep[] = ["nationality", "role", ...FORM_STEPS];
/** Icon + title + description shown in the global dialog header per step. */
const STEP_META: Record<
WizardStep,
{ icon: ReactNode; title: string; description: string }
> = {
nationality: {
icon: ,
title: "Where is your company registered?",
description: "This determines the documents we'll ask you to provide.",
},
role: {
icon: ,
title: "What does your company do?",
description:
"Pick any combination of Importer, Exporter and Freight Forwarder — each is set up with its own business license.",
},
company: {
icon: ,
title: "Company Information",
description: "Tell us about your company and its registration details.",
},
personnel: {
icon: ,
title: "General Manager",
description: "Who is the general manager of the company?",
},
contact: {
icon: ,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
verify: {
icon: ,
title: "Verify Contact Person",
description: "Confirm the contact phone with a one-time SMS code.",
},
poa: {
icon: ,
title: "Power of Attorney",
description: "Optionally add a representative with power of attorney.",
},
documents: {
icon: ,
title: "Upload Documents",
description: "Provide the required company documents.",
},
additional: {
icon: ,
title: "Business License",
description: "Upload a business license for each operational profile.",
},
};
interface OnboardingWizardDialogProps {
opened: boolean;
/** Dismiss the dialog (user clicked the close icon). */
onClose: () => void;
}
/**
* The company type for the onboarding selection. Importer / Exporter / Freight
* Forwarder are all services a single "customer" company can hold (in any
* combination), each with its own business license — so the company is always
* registered as a "customer".
*/
function companyTypeForRoles(_roles: string[]): string {
return "customer";
}
/** Document upload setting code per company nationality. */
function documentSettingCode(nationality: CompanyNationality): string {
return nationality === "foreign"
? "company_onboarding_documents_foreign"
: "company_onboarding_documents_ethiopian";
}
/**
* First-run onboarding wizard with a "draft-first" flow: picking the role(s)
* immediately creates a draft company + profile on the backend, so every
* subsequent step saves its data incrementally (PATCH /profile, /onboarding-step)
* against existing rows. The final step uploads documents and marks onboarding
* complete. Dismissable — the gate keeps it reachable until finished.
*/
export default function OnboardingWizardDialog({
opened,
onClose,
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
// A draft can exist with zero operational profiles (e.g. an interrupted start).
// Such a draft must re-run role selection so the profiles actually get created
// — otherwise the user is stuck with nothing to upload a license against.
const hasOperationalProfiles = existingProfiles.length > 0;
const savedNationality =
(company?.company?.nationality as CompanyNationality | null) ?? null;
// Resume position from the backend-persisted step.
const resumeFormStep: FormStep = FORM_STEPS.includes(
onboardingStep as FormStep,
)
? (onboardingStep as FormStep)
: "company";
// Phases: nationality → role → form. If a draft already exists, resume
// straight into the form with nationality + roles pre-selected.
const [phase, setPhase] = useState<"nationality" | "role" | "form">(
companyAlreadyStarted
? hasOperationalProfiles
? "form"
: "role"
: "nationality",
);
const [nationality, setNationality] = useState(
savedNationality,
);
const [roles, setRoles] = useState(
existingProfiles.map((p) => p.type),
);
const [documentFiles, setDocumentFiles] = useState<
Record
>({});
// Newly-selected business-license files per company_profile id.
const [licenseFiles, setLicenseFiles] = useState>({});
const [startError, setStartError] = useState(null);
// Mirror of CompanyProfileForm's active step so the global header + progress
// pill can reflect it (the form no longer renders its own stepper).
const [formStep, setFormStep] = useState(resumeFormStep);
// Once submission succeeds we swap the whole wizard body for a congratulations
// panel, and keep the modal open (the gate would otherwise tear it down the
// moment onboardingCompleted flips true).
const [completed, setCompleted] = useState(false);
// Saved profile data, for rehydrating the form fields after a refresh.
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({
enabled: companyAlreadyStarted,
retry: false,
refetchOnWindowFocus: false,
}),
);
// Server-driven onboarding requirements: the backend decides which document
// set applies (by nationality) and what's still outstanding, so the client
// never makes that choice itself. This is the heavier "second request" — it's
// only issued while onboarding is still incomplete; once the getInfo flag says
// we're done, it never fires.
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({
enabled: companyAlreadyStarted && !onboardingCompleted,
retry: false,
refetchOnWindowFocus: false,
}),
);
const refreshInfo = useCallback(
() =>
queryClient.invalidateQueries({
queryKey: api.companies.getInfo.queryKey(),
}),
[queryClient],
);
// Begin onboarding: create the draft company + profile + role(s) + nationality.
const startMutation = useMutation({
mutationFn: (vars: {
companyType: string;
roles: ProfileTypeValue[];
nationality?: CompanyNationality;
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
await refreshInfo();
setPhase("form");
},
onError: (err) => setStartError(extractApiError(err).message),
});
// Finalize: upload per-role license files + company documents, then complete.
const finishMutation = useMutation({
mutationFn: async () => {
const companyId = company?.company?.id;
// Per-role business licenses (file model, resource=company_profiles).
for (const [profileId, files] of Object.entries(licenseFiles)) {
if (files.length > 0) {
await companiesService.uploadProfileLicense(profileId, files);
}
}
// Nationality-based company documents (resource=companies).
const hasDocs = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (companyId && hasDocs) {
await companiesService.uploadDocuments(companyId, documentFiles);
}
return api.companies.completeOnboarding.call();
},
onSuccess: async () => {
await refreshInfo();
setCompleted(true);
},
onError: (err) => setStartError(extractApiError(err).message),
});
// Persist the resume step to the backend, but only ever move FORWARD — going
// Back must never downgrade the furthest step the user reached, so reopening
// always lands on the furthest step.
const furthestIdxRef = useRef(FORM_STEPS.indexOf(resumeFormStep));
const persistStep = useCallback((step: string) => {
const idx = FORM_STEPS.indexOf(step as FormStep);
if (idx < 0 || idx <= furthestIdxRef.current) return;
furthestIdxRef.current = idx;
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
// (nationality) phase. Once a draft loads, jump straight into the form with
// the persisted roles/nationality. Runs once per resumed draft.
const resumedRef = useRef(false);
useEffect(() => {
if (!companyAlreadyStarted || resumedRef.current) return;
resumedRef.current = true;
setRoles(existingProfiles.map((p) => p.type));
setNationality(savedNationality);
// Resume into the form only when profiles exist; otherwise send the user to
// role selection so the missing operational profiles get created.
setPhase(hasOperationalProfiles ? "form" : "role");
const idx = FORM_STEPS.indexOf(resumeFormStep);
if (idx > furthestIdxRef.current) furthestIdxRef.current = idx;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [companyAlreadyStarted, resumeFormStep]);
const handleNationalityContinue = useCallback(() => {
if (nationality) setPhase("role");
}, [nationality]);
const handleRolesContinue = useCallback(() => {
setStartError(null);
startMutation.mutate({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
});
}, [roles, nationality, startMutation]);
// Note: no "back to role selection" — once the draft is created the role(s)
// are fixed; the form's first-step Back is a no-op so progress never resets.
const handleBackToRoles = useCallback(() => { }, []);
// Save the current step's fields to the draft (PATCH /profile). Returns the
// server error message on failure so the form can show it (e.g. duplicate TIN).
const saveStep = useCallback(
async (
data: Partial,
): Promise<{ ok: true } | { ok: false; error: string }> => {
try {
await api.companies.updateProfile.call(data as UpdateProfilePayload);
return { ok: true };
} catch (err) {
return { ok: false, error: extractApiError(err).message };
}
},
[],
);
// Auto-upload the documents the user just selected as they leave the documents
// step. Only the in-memory selections are sent; once uploaded they're cleared
// (so the final submit never re-uploads them) and the requirements query is
// refreshed so the "Already uploaded" badges light up. Partial uploads are
// allowed — the user may continue even with required docs still outstanding.
const handleUploadDocuments = useCallback(async (): Promise<
{ ok: true } | { ok: false; error: string }
> => {
const companyId = company?.company?.id;
const hasNew = Object.values(documentFiles).some(
(f) => f !== null && (Array.isArray(f) ? f.length > 0 : true),
);
if (!companyId || !hasNew) return { ok: true };
try {
await companiesService.uploadDocuments(companyId, documentFiles);
setDocumentFiles({});
await queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
});
return { ok: true };
} catch (err) {
return { ok: false, error: extractApiError(err).message };
}
}, [company?.company?.id, documentFiles, queryClient]);
// Final confirm step → finalize onboarding (no company create; it already
// exists as a draft that's been filled in step-by-step).
const handleSubmit = useCallback(
(_payload: CreateCompanyPayload) => {
finishMutation.mutate();
},
[finishMutation],
);
if (!user) return null;
// Any non-empty combination of importer/exporter/freight-forwarder is valid.
const rolesValid = roles.length > 0;
// Documents depend on nationality; fall back to the saved one (resume) then ethiopian.
const effectiveNationality: CompanyNationality =
nationality ?? savedNationality ?? "ethiopian";
// Per-role license cards for the final step (from the created profiles).
const roleProfiles: RoleLicenseProfile[] = existingProfiles.map((p) => ({
id: p.id,
type: p.type,
reference: p.reference,
existingFiles: p.licenseFiles ?? [],
}));
// 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);
// Closing from the congratulations panel also clears the completed flag so a
// future reopen (shouldn't happen once onboarded) starts clean.
const handleClose = useCallback(() => {
if (completed) setCompleted(false);
onClose();
}, [completed, onClose]);
// Prefer the backend-resolved document code; fall back to the local mapping
// only until the requirements query lands (the documents step is reached well
// after the draft — and thus the requirements — exist).
const resolvedDocumentSettingCode =
requirementsQuery.data?.documentSettingCode ??
documentSettingCode(effectiveNationality);
// Server-confirmed document state, used both to badge already-uploaded fields
// and to keep a refreshed resume from over-shooting the documents step.
const requirementDocuments = requirementsQuery.data?.documents ?? [];
const uploadedDocumentKeys = requirementDocuments
.filter((d) => d.uploaded)
.map((d) => d.fileKey);
// If any REQUIRED document is still missing, the resume must not rest past the
// documents step (don't skip to Business License) — clamp it back. This only
// changes the target once requirements load; the form follows the correction
// as long as the user hasn't navigated yet.
const requiredDocsMissing = requirementDocuments.some(
(d) => d.isRequired && !d.uploaded,
);
const effectiveResumeStep: FormStep =
requiredDocsMissing &&
FORM_STEPS.indexOf(resumeFormStep) > FORM_STEPS.indexOf("documents")
? "documents"
: resumeFormStep;
const formProps = {
documentSettingCode: resolvedDocumentSettingCode,
documentFiles,
onDocumentFilesChange: setDocumentFiles,
user,
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
hideFirstStepBack: true,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,
onSaveStep: saveStep,
rehydrate: profileQuery.data ?? null,
roleProfiles,
licenseFiles,
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// Surface a failed final submit (license/document upload or complete) inside
// the form — otherwise the server message (e.g. a 500) would be invisible on
// the submit step.
submitError: phase === "form" ? startError : null,
};
return (
{stepMeta.icon}
{stepMeta.title}
{stepMeta.description}
)
}
>
{completed ? (
) : (
{phase === "nationality" ? (
}
>
Continue
) : phase === "role" ? (
{startError && (
{startError}
)}
}
onClick={() => setPhase("nationality")}
>
Back
)
}
>
Continue
) : (
)}
)}
);
}
/**
* Replaces the wizard body once onboarding is submitted: congratulates the user
* and sets the expectation that their company is now under review, and that
* bookings unlock per profile as the team approves each one.
*/
function OnboardingCompletePanel({ onClose }: { onClose: () => void }) {
return (
You're all set!
Thanks for completing your company profile. Your application has been
submitted and is now with our team for review.
Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually.
You can start creating bookings under a profile as soon as it's
approved — we'll let you know the moment that happens.
);
}
/**
* 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 (
);
}