feat: centralized the user onboaridn requriements

This commit is contained in:
Nathnael
2026-06-24 11:50:28 +00:00
parent 43be6892aa
commit 9b48955eaf
17 changed files with 670 additions and 150 deletions

View File

@@ -0,0 +1,60 @@
import { Box, Button, Tooltip } from "@mantine/core";
import { Link } from "react-router-dom";
import { Lock, Plus } from "lucide-react";
import useAuth from "@/hooks/useAuth";
interface NewBookingButtonProps {
label?: string;
size?: string;
mt?: string;
}
/**
* New-booking entry point that respects approval status: a customer can only
* create bookings under a profile once the backoffice has approved it. While the
* active profile is pending the button is disabled with an explanation, so the
* gate is communicated rather than silently failing at submit time.
*/
export function NewBookingButton({
label = "New booking",
size,
mt,
}: NewBookingButtonProps) {
const { canBook, activeProfileStatus } = useAuth();
if (!canBook) {
const message =
activeProfileStatus === "pending"
? "Your profile is awaiting approval. You'll be able to create bookings as soon as it's approved."
: "Bookings aren't available for this profile yet.";
return (
<Tooltip label={message} multiline w={250} withArrow position="bottom-end">
<Box mt={mt}>
<Button
color="edr-green"
radius="md"
size={size}
disabled
leftSection={<Lock size={16} />}
>
{label}
</Button>
</Box>
</Tooltip>
);
}
return (
<Button
component={Link}
to="/bookings/new"
color="edr-green"
radius="md"
size={size}
mt={mt}
leftSection={<Plus size={16} />}
>
{label}
</Button>
);
}

View File

@@ -1,10 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Clock } from "lucide-react";
import { api } from "@/services/api";
import {
getProfileCompletion,
type ProfileCompletion,
} from "@/utils/profileCompletion";
import useAuth from "@/hooks/useAuth";
import type { OnboardingRequirements } from "@/services/companies.service";
interface OnboardingResumeBannerProps {
/** Re-opens the onboarding wizard. */
@@ -17,15 +15,16 @@ interface BannerCopy {
cta: string;
}
/** Picks wording based on how far through setup the user actually is. */
/**
* Wording is driven entirely by the backend's outstanding-items list — the
* client never decides what's required, it just narrates what's left.
*/
function getCopy(
completion: ProfileCompletion,
requirements: OnboardingRequirements | undefined,
pct: number,
isPending: boolean,
): BannerCopy {
// Until the profile loads, or before anything is filled in, treat it as a
// fresh start rather than guessing progress.
if (isPending || completion.completed === 0) {
// No data yet (or nothing started) — treat it as a fresh start.
if (!requirements || requirements.progress.completed === 0) {
return {
title: "Set up your company profile",
subtitle: "Unlock bookings, tracking and billing — it only takes a minute.",
@@ -33,20 +32,29 @@ function getCopy(
};
}
const remaining = completion.total - completion.completed;
// Everything's filled in but not yet submitted for review.
if (requirements.isComplete) {
return {
title: "Everything's ready to go",
subtitle: "Submit your profile to send it for approval.",
cta: "Submit for review",
};
}
const remaining = requirements.outstanding.length;
if (remaining <= 2) {
return {
title: `Almost done — you're ${pct}% set up`,
subtitle: `Just ${remaining} more ${
remaining === 1 ? "detail" : "details"
} to unlock bookings, tracking and billing.`,
remaining === 1 ? "item" : "items"
} to finish: ${requirements.outstanding.join(", ")}.`,
cta: "Finish onboarding",
};
}
return {
title: `You're ${pct}% set up`,
subtitle: `${completion.completed} of ${completion.total} details added — finish to unlock bookings, tracking and billing.`,
subtitle: `${requirements.progress.completed} of ${requirements.progress.total} details added — finish to unlock bookings, tracking and billing.`,
cta: "Continue onboarding",
};
}
@@ -90,28 +98,23 @@ function ProgressRing({ pct }: { pct: number }) {
/**
* Prominent banner shown on onboarding-allowed pages after the wizard is
* dismissed. It reads the company profile directly so it stays aware of real
* progress: a percentage ring and the copy adapt as fields get filled, and the
* whole banner disappears once every required detail is complete.
* dismissed. Progress and copy are read straight from the backend's onboarding
* requirements, so the banner always agrees with the wizard about what's left.
*/
export default function OnboardingResumeBanner({
onResume,
}: OnboardingResumeBannerProps) {
const profileQuery = useQuery(
api.companies.getProfile.queryOptions({ retry: false }),
const requirementsQuery = useQuery(
api.companies.onboardingRequirements.queryOptions({ retry: false }),
);
const completion = getProfileCompletion(profileQuery.data);
// Reliably step aside once the user has genuinely finished onboarding.
if (!profileQuery.isPending && completion.isComplete) return null;
const pct = Math.round((completion.completed / completion.total) * 100);
const { title, subtitle, cta } = getCopy(
completion,
pct,
profileQuery.isPending,
);
const requirements = requirementsQuery.data;
const { completed, total } = requirements?.progress ?? {
completed: 0,
total: 0,
};
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
const { title, subtitle, cta } = getCopy(requirements, pct);
return (
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
@@ -143,3 +146,46 @@ export default function OnboardingResumeBanner({
</div>
);
}
/**
* Shown once onboarding is submitted but the company's operational profiles are
* still being reviewed. Communicates that approval is per-profile and that
* bookings unlock as each profile is cleared. Self-hides when nothing is pending.
*/
export function AccountReviewBanner() {
const { company } = useAuth();
const profiles = company?.company?.companyProfiles ?? [];
const pending = profiles.filter((p) => p.status === "pending");
const approved = profiles.filter((p) => p.status === "active");
if (profiles.length === 0 || pending.length === 0) return null;
const pendingLabel = pending
.map((p) => p.type.replace(/_/g, " "))
.join(", ");
return (
<div className="border-b border-amber-200 bg-amber-50 px-6 py-3">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-amber-100 text-amber-700">
<Clock size={18} />
</span>
<span className="flex flex-col gap-0.5">
<span className="text-sm font-semibold text-amber-900">
Your account is under review
</span>
<span className="text-xs text-amber-800">
We're reviewing your {pendingLabel}{" "}
{pending.length === 1 ? "profile" : "profiles"}. You can create
bookings under a profile as soon as it's approved.
</span>
</span>
</div>
<span className="text-xs font-medium text-amber-800">
{approved.length} of {profiles.length} approved
</span>
</div>
</div>
);
}

View File

@@ -14,8 +14,11 @@ import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
FileText,
Globe2,
PartyPopper,
ShieldCheck,
UploadCloud,
User,
UserCheck,
@@ -142,7 +145,7 @@ export default function OnboardingWizardDialog({
onClose,
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company, onboardingStep } = useAuth();
const { user, company, onboardingStep, onboardingCompleted } = useAuth();
const existingProfiles = company?.company?.companyProfiles ?? [];
const companyAlreadyStarted = Boolean(company?.company?.id);
@@ -174,6 +177,10 @@ export default function OnboardingWizardDialog({
// 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(
@@ -184,6 +191,19 @@ export default function OnboardingWizardDialog({
}),
);
// 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({
@@ -225,7 +245,10 @@ export default function OnboardingWizardDialog({
}
return api.companies.completeOnboarding.call();
},
onSuccess: refreshInfo,
onSuccess: async () => {
await refreshInfo();
setCompleted(true);
},
onError: (err) => setStartError(extractApiError(err).message),
});
@@ -329,8 +352,22 @@ export default function OnboardingWizardDialog({
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);
const formProps = {
documentSettingCode: documentSettingCode(effectiveNationality),
documentSettingCode: resolvedDocumentSettingCode,
documentFiles,
onDocumentFilesChange: setDocumentFiles,
user,
@@ -350,11 +387,11 @@ export default function OnboardingWizardDialog({
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton
opened={opened || completed}
onClose={handleClose}
withCloseButton={!completed}
closeOnClickOutside={false}
closeOnEscape
closeOnEscape={!completed}
size={720}
radius="lg"
padding="xl"
@@ -371,20 +408,25 @@ export default function OnboardingWizardDialog({
}
}}
title={
<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 ? 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" ? (
@@ -438,10 +480,65 @@ export default function OnboardingWizardDialog({
<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.