mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat: centralized the user onboaridn requriements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user