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,