feat: implement onboarding wizard enhancements and resume functionality

This commit is contained in:
Marshal
2026-06-19 22:24:59 +00:00
parent ed91f817ff
commit 19dea313b7
6 changed files with 149 additions and 25 deletions

View File

@@ -6,8 +6,10 @@ import {
MapPin,
Receipt,
Settings,
Sparkles,
User,
} from "lucide-react";
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useRef } from "react";
import {
Navigate,
@@ -85,30 +87,95 @@ function RequireCompany() {
}
/**
* Enforces first-run onboarding. Until the user finishes, only the home
* (/portal) page is reachable; any attempt to navigate elsewhere bounces back
* to home with the blocking wizard dialog open. New users (no company yet) are
* treated the same as users who haven't completed onboarding.
* Routes an un-onboarded user may still visit. The wizard auto-opens but is
* dismissable, so they can browse these freely; any other route forces the
* wizard back open and bounces them home.
*/
const ONBOARDING_ALLOWED_PATHS = ["/portal", "/signature"];
function isOnboardingAllowedPath(pathname: string): boolean {
const path = pathname.toLowerCase();
return ONBOARDING_ALLOWED_PATHS.some(
(p) => path === p || path.startsWith(p + "/"),
);
}
/**
* Enforces first-run onboarding. The home (dashboard) and signature pages stay
* reachable while onboarding is incomplete; the wizard auto-opens on login but
* can be dismissed to use those pages. Visiting any other page bounces back to
* home and re-opens the wizard. New users (no company yet) are treated the same
* as users who haven't completed onboarding.
*/
function OnboardingGate() {
const { company, onboardingCompleted } = useAuth();
const location = useLocation();
const needsOnboarding = !company || !onboardingCompleted;
const onHome = location.pathname === "/portal";
const allowedHere = isOnboardingAllowedPath(location.pathname);
if (needsOnboarding && !onHome) {
// Open by default while onboarding is pending (covers the login case).
const [wizardOpen, { open: openWizard, close: closeWizard }] =
useDisclosure(false);
// Re-evaluate on every navigation: force the wizard open on blocked routes,
// and auto-open on first arrival while onboarding is pending.
useEffect(() => {
if (needsOnboarding && !allowedHere) {
openWizard();
}
}, [needsOnboarding, allowedHere, location.pathname, openWizard]);
// Auto-open once when onboarding becomes/loads as pending (login).
const autoOpenedRef = useRef(false);
useEffect(() => {
if (needsOnboarding && !autoOpenedRef.current) {
autoOpenedRef.current = true;
openWizard();
}
if (!needsOnboarding) autoOpenedRef.current = false;
}, [needsOnboarding, openWizard]);
if (needsOnboarding && !allowedHere) {
return <Navigate to="/portal" replace />;
}
return (
<>
{needsOnboarding && !wizardOpen && (
<OnboardingResumeBanner onResume={openWizard} />
)}
<Outlet />
<OnboardingWizardDialog opened={needsOnboarding} />
<OnboardingWizardDialog
opened={needsOnboarding && wizardOpen}
onClose={closeWizard}
/>
</>
);
}
/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */
function OnboardingResumeBanner({ onResume }: { onResume: () => void }) {
return (
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#0EA371]/20 bg-[#ECF6F1] px-6 py-3">
<div className="flex items-center gap-2">
<Sparkles size={16} className="text-[#0A6F4D]" />
<span className="text-sm font-medium text-[#0A6F4D]">
Finish setting up your company to unlock bookings, tracking and
billing.
</span>
</div>
<button
type="button"
onClick={onResume}
className="rounded-lg bg-[#0EA371] px-4 py-2 text-sm font-semibold text-white transition-opacity hover:opacity-90"
>
Continue onboarding
</button>
</div>
);
}
/** Keeps authenticated users off the login/signup pages. */
function RedirectIfAuthed() {
const { isPending, isAuthenticated } = useAuth();

View File

@@ -74,7 +74,9 @@ export interface AppLayoutProps {
}
type ImporterExporter = "importer" | "exporter";
type SwitchResult = { success: boolean; error?: string };
type SwitchResult =
| { success: true; data?: unknown }
| { success: false; error?: { message?: string } };
const PROFILE_TYPE_LABELS: Record<string, string> = {
importer: "Importer",
@@ -208,7 +210,7 @@ export function AppLayout({
businessLicense.trim() || undefined,
);
if (res && !res.success) {
setCreateError(res.error ?? "Failed to create profile");
setCreateError(res.error?.message ?? "Failed to create profile");
return;
}
setCreateOpen(false);

View File

@@ -15,8 +15,20 @@ import ForwarderForm from "@/pages/accounts/ForwarderForm";
import { FREIGHT_FORWARDER } from "@/pages/settings/companyRoles";
import OnboardingRoleSelect from "@/pages/settings/OnboardingRoleSelect";
/** Form steps shared by CompanyProfileForm and ForwarderForm. */
type FormStep = "company" | "personnel" | "poa" | "documents" | "confirm";
const FORM_STEPS: FormStep[] = [
"company",
"personnel",
"poa",
"documents",
"confirm",
];
interface OnboardingWizardDialogProps {
opened: boolean;
/** Dismiss the dialog (user clicked the close icon). */
onClose: () => void;
}
/** Map the chosen operational roles to the company type they belong to. */
@@ -39,15 +51,24 @@ function documentSettingCode(companyType: string): string {
*/
export default function OnboardingWizardDialog({
opened,
onClose,
}: OnboardingWizardDialogProps) {
const queryClient = useQueryClient();
const { user, company } = useAuth();
const { user, company, onboardingStep } = useAuth();
// A company already exists but onboarding wasn't marked complete (e.g. the
// browser closed after create but before finishing). Don't re-create it —
// just let the user finish.
const companyAlreadyCreated = Boolean(company?.company?.id);
// Resume position from the backend-persisted step. A form step means the user
// had already passed role selection. Cross-session we still start at role
// selection (the roles + field values aren't persisted), but within a session
// the dialog stays mounted so dismiss/reopen continues exactly where it was.
const resumeFormStep: FormStep = FORM_STEPS.includes(onboardingStep as FormStep)
? (onboardingStep as FormStep)
: "company";
// "role" → pick roles; otherwise the company/forwarder form drives its own
// internal steps.
const [phase, setPhase] = useState<"role" | "form">("role");
@@ -84,13 +105,20 @@ export default function OnboardingWizardDialog({
},
});
const handleRolesContinue = useCallback(() => {
setPhase("form");
// Best-effort: remember that the user moved past role selection.
api.companies.setOnboardingStep.call({ step: "company" }).catch(() => {});
// Persist the resume step to the backend (best-effort, fire-and-forget).
const persistStep = useCallback((step: string) => {
api.companies.setOnboardingStep.call({ step }).catch(() => {});
}, []);
const handleBackToRoles = useCallback(() => setPhase("role"), []);
const handleRolesContinue = useCallback(() => {
setPhase("form");
persistStep("company");
}, [persistStep]);
const handleBackToRoles = useCallback(() => {
setPhase("role");
persistStep("role");
}, [persistStep]);
const handleSubmit = useCallback(
(payload: CreateCompanyPayload) => {
@@ -116,14 +144,15 @@ export default function OnboardingWizardDialog({
return (
<Modal
opened={opened}
onClose={() => {}}
withCloseButton={false}
onClose={onClose}
withCloseButton
closeOnClickOutside={false}
closeOnEscape={false}
size="xl"
closeOnEscape
size={1040}
radius="lg"
padding="xl"
centered
keepMounted
scrollAreaComponent={ScrollArea.Autosize}
overlayProps={{ backgroundOpacity: 0.55, blur: 4 }}
title={
@@ -173,6 +202,8 @@ export default function OnboardingWizardDialog({
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBackToRoles}
initialStep={resumeFormStep}
onStepChange={persistStep}
/>
) : (
<CompanyProfileForm
@@ -183,6 +214,8 @@ export default function OnboardingWizardDialog({
onSubmit={handleSubmit}
isPending={createCompanyMutation.isPending}
onBack={handleBackToRoles}
initialStep={resumeFormStep}
onStepChange={persistStep}
/>
)}
</Modal>

View File

@@ -22,7 +22,7 @@ import {
UploadCloud,
User,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -126,6 +126,8 @@ export default function CompanyProfileForm({
onSubmit,
isPending,
onBack,
initialStep,
onStepChange,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -134,8 +136,18 @@ export default function CompanyProfileForm({
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
/** Step to resume at (defaults to "company"). */
initialStep?: CompanyStep;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: CompanyStep) => void;
}) {
const [step, setStep] = useState<CompanyStep>("company");
const [step, setStep] = useState<CompanyStep>(initialStep ?? "company");
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
onStepChange?.(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
const [internalFiles, setInternalFiles] = useState<
Record<string, File | File[] | null>
>({});

View File

@@ -11,7 +11,7 @@ import {
UploadCloud,
User,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -91,6 +91,8 @@ export default function ForwarderForm({
onSubmit,
isPending,
onBack,
initialStep,
onStepChange,
}: {
documentSettingCode: string;
documentFiles?: Record<string, File | File[] | null>;
@@ -99,8 +101,18 @@ export default function ForwarderForm({
onSubmit: (data: CreateCompanyPayload) => void;
isPending: boolean;
onBack: () => void;
/** Step to resume at (defaults to "company"). */
initialStep?: ForwarderStep;
/** Reports the active step so the parent can persist resume progress. */
onStepChange?: (step: ForwarderStep) => void;
}) {
const [step, setStep] = useState<ForwarderStep>("company");
const [step, setStep] = useState<ForwarderStep>(initialStep ?? "company");
// Report each step change up so the wizard can persist it for resume.
useEffect(() => {
onStepChange?.(step);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step]);
const [internalFiles, setInternalFiles] = useState<Record<string, File | File[] | null>>({});
const documentFiles = controlledFiles ?? internalFiles;
const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles;

View File

@@ -2,11 +2,9 @@ import type { Freight, PaginatedResponse } from "@edr/types";
import { endpoint } from "@/utils/endpoint";
import type {
CreateFileUploadFieldDto,
CreateFileUploadSettingDto,
FileUploadField,
FileUploadSetting,
UpdateFileUploadFieldDto,
UpdateFileUploadSettingDto,
} from "@/types/fileUploadSettings";
import {
bookingsService,