diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 15137cb26..3a32f2b78 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -7,7 +7,6 @@ import { MapPin, Receipt, Settings, - Sparkles, } from "lucide-react"; import { useDisclosure } from "@mantine/hooks"; import { useEffect, useRef } from "react"; @@ -21,6 +20,7 @@ import { } from "react-router-dom"; import useAuth from "./hooks/useAuth"; +import OnboardingResumeBanner from "./components/onboarding/OnboardingResumeBanner"; import OnboardingWizardDialog from "./components/onboarding/OnboardingWizardDialog"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; @@ -155,28 +155,6 @@ function OnboardingGate() { ); } -/** 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/onboarding/OnboardingResumeBanner.tsx b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx new file mode 100644 index 000000000..e0df7346f --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingResumeBanner.tsx @@ -0,0 +1,145 @@ +import { useQuery } from "@tanstack/react-query"; +import { ArrowRight } from "lucide-react"; +import { api } from "@/services/api"; +import { + getProfileCompletion, + type ProfileCompletion, +} from "@/utils/profileCompletion"; + +interface OnboardingResumeBannerProps { + /** Re-opens the onboarding wizard. */ + onResume: () => void; +} + +interface BannerCopy { + title: string; + subtitle: string; + cta: string; +} + +/** Picks wording based on how far through setup the user actually is. */ +function getCopy( + completion: ProfileCompletion, + 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) { + return { + title: "Set up your company profile", + subtitle: "Unlock bookings, tracking and billing — it only takes a minute.", + cta: "Start onboarding", + }; + } + + const remaining = completion.total - completion.completed; + 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.`, + 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.`, + cta: "Continue onboarding", + }; +} + +/** Circular percentage meter that reads at a glance against the dark banner. */ +function ProgressRing({ pct }: { pct: number }) { + const size = 56; + const stroke = 5; + const r = (size - stroke) / 2; + const circumference = 2 * Math.PI * r; + const offset = circumference * (1 - pct / 100); + + return ( + + + + + + {pct}% + + ); +} + +/** + * 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. + */ +export default function OnboardingResumeBanner({ + onResume, +}: OnboardingResumeBannerProps) { + const profileQuery = useQuery( + api.companies.getProfile.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, + ); + + return ( +
+
+
+ + + + + + + + + {title} + + + {subtitle} + +
+ +
+
+ ); +} 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 3628d7403..b25f6f12e 100644 --- a/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx +++ b/apps/edr-freight-web/portal/src/components/onboarding/OnboardingWizardDialog.tsx @@ -355,7 +355,7 @@ export default function OnboardingWizardDialog({ withCloseButton closeOnClickOutside={false} closeOnEscape - size={1040} + size={720} radius="lg" padding="xl" centered diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx index 0a4dcb6ce..13ab980b8 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/MyPortalPage.tsx @@ -7,7 +7,6 @@ import { HelloSection, InvoicesSection, RecentActivitySection, - SetupPrompt, ShipmentsSection, StatsSection, } from "./components"; @@ -16,7 +15,6 @@ import { useMyPortalData } from "./hooks"; export default function MyPortalPage() { const navigate = useNavigate(); const { - customer, bookingsQuery, dashboardQuery, allBookings, @@ -40,8 +38,6 @@ export default function MyPortalPage() { - - !profile[field]); -} - -interface SetupPromptProps { - show: boolean; -} - -export const SetupPrompt = memo(function SetupPrompt({ show }: SetupPromptProps) { - const profileQuery = useQuery( - api.companies.getProfile.queryOptions({ retry: false }), - ); - - const incomplete = !profileQuery.isPending && isProfileIncomplete(profileQuery.data); - - if (!show && !incomplete) return null; - - return ( - - - - - {incomplete && } - - {incomplete ? "Complete Your Profile" : "Setup your Company Profile"} - - - - {incomplete - ? "Your company profile is incomplete. Fill in the missing details to unlock all features." - : "Complete your company information to unlock all features and start booking shipments."} - - - - - {incomplete ? "Complete Profile" : "Complete Setup"} - - - - - - - - - - - ); -}); diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts index de3ddb448..4bf94f45f 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/index.ts @@ -6,8 +6,8 @@ export { FreightVolumeSection } from "./FreightVolumeSection"; export { HelloSection } from "./HelloSection"; export { InvoicesSection } from "./InvoicesSection"; export { RecentActivitySection } from "./RecentActivitySection"; -export { SetupPrompt } from "./SetupPrompt"; export { ShipmentsSection } from "./ShipmentsSection"; export { StatKpi } from "./StatKpi"; export { StatsSection } from "./StatsSection"; export { Stepper } from "./Stepper"; + diff --git a/apps/edr-freight-web/portal/src/utils/profileCompletion.ts b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts new file mode 100644 index 000000000..5e95af40d --- /dev/null +++ b/apps/edr-freight-web/portal/src/utils/profileCompletion.ts @@ -0,0 +1,56 @@ +import type { ProfileResponse } from "@/types/profile"; + +/** + * Company-profile fields that must be filled before onboarding is considered + * finished. Shared between the portal SetupPrompt and the onboarding banner so + * both agree on what "done" means. + */ +export const REQUIRED_PROFILE_FIELDS: (keyof ProfileResponse)[] = [ + "companyEmail", + "companyPhone", + "companyAddress", + "fanNumber", + "contactPersonName", + "contactPersonPhone", + "generalManagerName", + "generalManagerEmail", + "generalManagerPhone", +]; + +export interface ProfileCompletion { + /** Number of required fields that are filled in. */ + completed: number; + /** Total number of required fields. */ + total: number; + /** Required fields still missing a value. */ + missing: (keyof ProfileResponse)[]; + /** True when every required field is filled. */ + isComplete: boolean; +} + +/** Breaks a profile down into how much of the required setup is complete. */ +export function getProfileCompletion( + profile?: ProfileResponse | null, +): ProfileCompletion { + const total = REQUIRED_PROFILE_FIELDS.length; + if (!profile) { + return { + completed: 0, + total, + missing: [...REQUIRED_PROFILE_FIELDS], + isComplete: false, + }; + } + const missing = REQUIRED_PROFILE_FIELDS.filter((field) => !profile[field]); + return { + completed: total - missing.length, + total, + missing, + isComplete: missing.length === 0, + }; +} + +/** Convenience predicate kept for existing call sites. */ +export function isProfileIncomplete(profile?: ProfileResponse | null): boolean { + return !getProfileCompletion(profile).isComplete; +}