mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 23:28:11 +00:00
640 lines
22 KiB
TypeScript
640 lines
22 KiB
TypeScript
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: <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?",
|
|
},
|
|
verify: {
|
|
icon: <ShieldCheck size={20} />,
|
|
title: "Verify Contact Person",
|
|
description: "Confirm the contact phone with a one-time SMS code.",
|
|
},
|
|
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). */
|
|
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<CompanyNationality | null>(
|
|
savedNationality,
|
|
);
|
|
const [roles, setRoles] = useState<string[]>(
|
|
existingProfiles.map((p) => p.type),
|
|
);
|
|
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;
|
|
}) => 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<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],
|
|
);
|
|
|
|
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 (
|
|
<Modal
|
|
opened={opened || completed}
|
|
onClose={handleClose}
|
|
withCloseButton={!completed}
|
|
closeOnClickOutside={false}
|
|
closeOnEscape={!completed}
|
|
size={1440}
|
|
radius="lg"
|
|
padding="xl"
|
|
centered
|
|
keepMounted
|
|
scrollAreaComponent={ScrollArea.Autosize}
|
|
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
|
|
styles={{
|
|
header: {
|
|
alignItems: "flex-start",
|
|
},
|
|
title: {
|
|
flex: 1,
|
|
},
|
|
}}
|
|
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" ? (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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">
|
|
Go to my 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>
|
|
);
|
|
}
|