diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 5394c1fb8..e436eaff6 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -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 ; } return ( <> + {needsOnboarding && !wizardOpen && ( + + )} - + ); } +/** Slim sticky prompt shown on allowed pages after the wizard is dismissed. */ +function OnboardingResumeBanner({ onResume }: { onResume: () => void }) { + return ( +
+
+ + + Finish setting up your company to unlock bookings, tracking and + billing. + +
+ +
+ ); +} + /** Keeps authenticated users off the login/signup pages. */ function RedirectIfAuthed() { const { isPending, isAuthenticated } = useAuth(); diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index f96eaf03d..db415705d 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -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 = { 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); diff --git a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx index 8e9f59b16..9b11267cc 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -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 ( {}} - 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} /> ) : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx index 3a26c92b2..913726008 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/CompanyProfileForm.tsx @@ -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; @@ -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("company"); + const [step, setStep] = useState(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 >({}); diff --git a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx index 71db345ec..6f06d9c48 100644 --- a/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx +++ b/apps/edr-freight-web/portal/src/pages/accounts/ForwarderForm.tsx @@ -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; @@ -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("company"); + const [step, setStep] = useState(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>({}); const documentFiles = controlledFiles ?? internalFiles; const setDocumentFiles = onDocumentFilesChange ?? setInternalFiles; diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 8615061d7..e2c92fde6 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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,