Files
edr-platform/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx
2026-08-12 07:12:04 +00:00

684 lines
26 KiB
TypeScript

import {
Box,
Button,
Checkbox,
Group,
Modal,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import { useMediaQuery } from "@mantine/hooks";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
Building2,
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 { documentSettingCode } from "@/utils/documentSettingCode";
import { extractApiError } from "@/utils/result";
/** Form steps rendered by CompanyProfileForm. */
type FormStep =
| "company"
| "owner"
| "representation"
| "contact"
| "documents";
const FORM_STEPS: FormStep[] = [
"company",
"owner",
"representation",
"contact",
"documents",
];
/** 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-role": {
icon: <Globe2 size={20} />,
title: "Tell us about your company",
description: "This determines the documents we'll ask you to provide.",
},
company: {
icon: <Building2 size={20} />,
title: "Company Information",
description:
"Confirm your VAT number and we'll pull your registration straight from eTrade.",
},
owner: {
icon: <User size={20} />,
title: "Company Owner",
description:
"The person registered on your eTrade licence. We fill in what eTrade and Fayda gave us.",
},
representation: {
icon: <FileText size={20} />,
title: "Who Acts For You",
description:
"Tell us whether anyone holds power of attorney — your answer decides whose identity we verify.",
},
contact: {
icon: <UserCheck size={20} />,
title: "Contact Person",
description: "Who should we reach out to about this account?",
},
documents: {
icon: <UploadCloud size={20} />,
title: "Upload Documents",
description: "Provide the required company documents.",
},
};
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";
}
/**
* 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 isMobile = useMediaQuery("(max-width: 48em)");
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 ? "form" : "nationality-role",
);
const [nationality, setNationality] = useState<CompanyNationality | null>(
savedNationality,
);
const [roles, setRoles] = useState<string[]>(
existingProfiles.map((p) => p.type),
);
const [cooperative, setCooperative] = useState<boolean>(
company?.company?.attributes?.cooperative === true,
);
// Ticking the box drops the selections the company can no longer hold, rather
// than letting Continue fail on ones the API refuses: a co-op cannot forward
// freight, and is registered in Ethiopia so it is never foreign.
const handleCooperativeChange = useCallback((checked: boolean) => {
setCooperative(checked);
if (checked) {
setRoles((prev) => prev.filter((r) => r !== "freight_forwarder"));
setNationality((prev) => (prev === "foreign" ? "ethiopian" : prev));
}
}, []);
const [documentFiles, setDocumentFiles] = useState<
Record<string, File | File[] | null>
>({});
// 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);
// 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;
cooperative?: boolean;
}) => api.companies.startOnboarding.call(vars),
onSuccess: async () => {
// Nationality drives the server-resolved identity requirements (Fayda vs
// passport), the document set and the PoA copy — all read from
// onboardingRequirements/profile. Re-entering role selection can change
// it, so both must be refetched alongside getInfo or the form step would
// keep rendering the previous nationality's requirements.
await Promise.all([
refreshInfo(),
queryClient.invalidateQueries({
queryKey: api.companies.onboardingRequirements.queryKey(),
}),
queryClient.invalidateQueries({
queryKey: api.companies.getProfile.queryKey(),
}),
]);
setPhase("form");
},
onError: (err) => setStartError(extractApiError(err).message),
});
// Finalize: upload per-role license files, then complete.
//
// Company documents are deliberately NOT uploaded here. The documents step
// uploads them via `onUploadDocuments` and then triggers submit in the same
// synchronous `nextStep` call (CompanyProfileForm), so the `setDocumentFiles({})`
// that clears them has not re-rendered by the time this mutation's closure
// runs — reading `documentFiles` here would re-send the exact same files and
// create a duplicate row per document. Licenses have no such auto-upload, so
// they are uploaded here.
const finishMutation = useMutation({
mutationFn: async () => {
// Per-role business licenses (file model, resource=company_profiles).
// Keys for roles the user deselected on a trip back to role selection are
// dropped — that profile no longer exists, so uploading against it would
// 404 (and the license isn't wanted any more anyway).
const liveProfileIds = new Set(existingProfiles.map((p) => p.id));
for (const [profileId, files] of Object.entries(licenseFiles)) {
if (files.length > 0 && liveProfileIds.has(profileId)) {
await companiesService.uploadProfileLicense(profileId, files);
}
}
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);
setCooperative(company?.company?.attributes?.cooperative === true);
// 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" : "nationality-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 handleRolesContinue = useCallback(() => {
setStartError(null);
startMutation.mutate({
companyType: companyTypeForRoles(roles),
roles: roles as ProfileTypeValue[],
nationality: nationality ?? undefined,
cooperative,
});
}, [roles, nationality, cooperative, startMutation]);
// Back from the form's first step returns to nationality/role selection.
// Safe to re-enter: startOnboarding is idempotent — it reuses the existing
// draft, refreshes the nationality and creates only roles that don't exist yet.
const handleBackToRoles = useCallback(() => {
setStartError(null);
setPhase("nationality-role");
}, []);
// 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<UpdateProfilePayload>,
): 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],
);
// Closing from the congratulations panel also clears the completed flag so a
// future reopen (shouldn't happen once onboarded) starts clean.
// Must stay above the `!user` early return: `user` flips to null while
// useAuth refetches, and skipping a hook on that render breaks hook order.
const handleClose = useCallback(() => {
if (completed) setCompleted(false);
onClose();
}, [completed, onClose]);
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);
// 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, cooperative);
// 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,
);
// The representation step gets the same treatment. An unanswered
// power-of-attorney question, or a declared representative still missing
// details or the DARS paper, must land the customer back on the step where
// all of that is entered — including a draft that predates the question
// existing at all, whose `declared` comes back null.
const representationIncomplete =
requirementsQuery.data?.poa?.declared == null ||
requirementsQuery.data?.poa?.complete === false ||
requirementsQuery.data?.identity?.identityProven === false;
// Each unmet requirement lowers the ceiling; resume never moves forward.
let ceiling = FORM_STEPS.length - 1;
if (requiredDocsMissing)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("documents"));
if (representationIncomplete)
ceiling = Math.min(ceiling, FORM_STEPS.indexOf("representation"));
const effectiveResumeStep: FormStep =
FORM_STEPS[
Math.min(Math.max(FORM_STEPS.indexOf(resumeFormStep), 0), ceiling)
];
const formProps = {
documentSettingCode: resolvedDocumentSettingCode,
documentFiles,
onDocumentFilesChange: setDocumentFiles,
user,
onSubmit: handleSubmit,
isPending: finishMutation.isPending,
onBack: handleBackToRoles,
initialStep: effectiveResumeStep,
resyncOpen: opened,
onStepChange: handleStepChange,
onSaveStep: saveStep,
rehydrate: profileQuery.data ?? null,
roleProfiles,
licenseFiles,
onLicenseChange: setLicenseFiles,
uploadedDocumentKeys,
onUploadDocuments: handleUploadDocuments,
// The company's single identity verification, and whose it is. Fayda is
// mandatory for an Ethiopian company; a foreign one may instead type a
// passport number for the same person.
identity: requirementsQuery.data?.identity,
// Server-confirmed, not the local checkbox: the flag is only real once
// startOnboarding has persisted it, and the form's whole company step
// branches on it.
cooperative: requirementsQuery.data?.cooperative ?? cooperative,
// A freight forwarder cannot answer the power-of-attorney question — the
// API forces "yes" — so the step offers no way to change it.
declarationLocked: requirementsQuery.data?.poa?.locked ?? false,
onIdentityChange: () => {
void profileQuery.refetch();
void requirementsQuery.refetch();
},
// 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 (
<Modal
opened={opened || completed}
onClose={handleClose}
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape={!completed}
fullScreen={isMobile}
size={1440}
radius="lg"
padding={isMobile ? "md" : "xl"}
centered
keepMounted
scrollAreaComponent={ScrollArea.Autosize}
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
styles={{
header: {
alignItems: "flex-start",
},
title: {
flex: 1,
},
body: isMobile
? { paddingBottom: "calc(100px + env(safe-area-inset-bottom))" }
: undefined,
}}
title={
completed ? null : (
<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>
)
}
>
{completed ? (
<OnboardingCompletePanel onClose={handleClose} />
) : (
<Stack gap="xl">
{phase === "nationality-role" ? (
<Stack gap="lg">
<Text fw={600} size="lg" c="edr-text">
Where is your company registered?
</Text>
<NationalitySelect
value={nationality}
onChange={setNationality}
embedded
// A co-op is registered in Ethiopia by the co-operative
// promotion agency — foreign is not on offer rather than
// refused later.
excludeForeign={cooperative}
/>
{/* A co-operative union or farm registers on a TIN alone. It
changes what the next step asks for (typed registration, no
eTrade lookup), which documents apply, and which roles are on
offer — so it is answered here, alongside the other two. */}
<Checkbox
checked={cooperative}
onChange={(e) => handleCooperativeChange(e.currentTarget.checked)}
label="We're a co-operative union or farm"
description="For members with a TIN but no business licence. You'll type your registration details instead of us pulling them from eTrade, and upload your co-operative papers in place of a trade licence."
/>
<Text fw={600} size="lg" c="edr-text">
What does your company do?(multiple)
</Text>
<OnboardingRoleSelect
value={roles}
onChange={setRoles}
embedded
// Forwarding is licensed work — a co-op holds no licence, so
// the role is not offered rather than refused later.
excludeTypes={cooperative ? ["freight_forwarder"] : undefined}
/>
{startError && (
<Text size="sm" c="red">
{startError}
</Text>
)}
<Group justify="flex-end" pt="xs">
<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>
);
}
/**
* 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 (
<Stack gap="lg" align="center" py="md" ta="center">
<Box
className="flex h-16 w-16 items-center justify-center rounded-full"
style={{ background: "var(--mantine-color-edr-green-1)" }}
>
<PartyPopper
size={32}
className="text-[var(--mantine-color-edr-green-7)]"
/>
</Box>
<Box>
<Title order={3}>You're all set!</Title>
<Text c="edr-muted" size="sm" mt={4} maw={460}>
Thanks for completing your company profile. Your application has been
submitted and is now with our team for review.
</Text>
</Box>
<Stack
gap="sm"
w="100%"
maw={460}
p="md"
className="rounded-lg"
style={{ background: "var(--mantine-color-edr-green-0)" }}
>
<Group gap="sm" wrap="nowrap" align="flex-start">
<Clock
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left">
Each operational profile (importer, exporter, freight forwarder) is
reviewed and approved individually.
</Text>
</Group>
<Group gap="sm" wrap="nowrap" align="flex-start">
<ShieldCheck
size={18}
className="mt-0.5 shrink-0 text-[var(--mantine-color-edr-green-7)]"
/>
<Text size="sm" ta="left">
You can start creating bookings under a profile as soon as it's
approved we'll let you know the moment that happens.
</Text>
</Group>
</Stack>
<Button color="edr-green" size="md" onClick={onClose} mt="xs">
Continue to Dashboard
</Button>
</Stack>
);
}
/**
* 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 (
<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>
);
}