mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
feat: implement onboarding wizard enhancements and resume functionality
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
>({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user