mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
style: improvment to the booking
This commit is contained in:
@@ -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 (
|
||||
<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();
|
||||
|
||||
@@ -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 (
|
||||
<span className="relative flex shrink-0 items-center justify-center">
|
||||
<svg width={size} height={size} className="-rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="rgba(255,255,255,0.22)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
stroke="#6ee7b7"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: "stroke-dashoffset 600ms ease" }}
|
||||
/>
|
||||
</svg>
|
||||
<span className="absolute text-sm font-bold text-white">{pct}%</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="bg-gradient-to-r from-[#065f46] via-[#0A6F4D] to-[#0A8A5F] px-6 py-4 shadow-md">
|
||||
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<ProgressRing pct={pct} />
|
||||
<span className="flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-[#6ee7b7] opacity-75" />
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#6ee7b7]" />
|
||||
</span>
|
||||
<span className="text-base font-bold tracking-tight text-white">
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
<span className="text-sm text-white/80">{subtitle}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResume}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-white px-5 py-2.5 text-sm font-semibold text-[#0A6F4D] shadow-sm transition-transform hover:scale-[1.02] hover:bg-white/95"
|
||||
>
|
||||
{cta}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -355,7 +355,7 @@ export default function OnboardingWizardDialog({
|
||||
withCloseButton
|
||||
closeOnClickOutside={false}
|
||||
closeOnEscape
|
||||
size={1040}
|
||||
size={720}
|
||||
radius="lg"
|
||||
padding="xl"
|
||||
centered
|
||||
|
||||
@@ -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() {
|
||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||
<HelloSection greeting={greeting} companyName={companyName} />
|
||||
|
||||
<SetupPrompt show={!customer} />
|
||||
|
||||
<StatsSection
|
||||
activeBookingsLength={activeBookings.length}
|
||||
newActiveThisWeek={newActiveThisWeek}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Box, Group, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ArrowRight, Truck, AlertTriangle } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/services/api";
|
||||
import type { ProfileResponse } from "@/types/profile";
|
||||
import { cv } from "../constants";
|
||||
|
||||
const REQUIRED_FIELDS: (keyof ProfileResponse)[] = [
|
||||
"companyEmail",
|
||||
"companyPhone",
|
||||
"companyAddress",
|
||||
"fanNumber",
|
||||
"contactPersonName",
|
||||
"contactPersonPhone",
|
||||
"generalManagerName",
|
||||
"generalManagerEmail",
|
||||
"generalManagerPhone",
|
||||
];
|
||||
|
||||
function isProfileIncomplete(profile?: ProfileResponse | null): boolean {
|
||||
if (!profile) return true;
|
||||
return REQUIRED_FIELDS.some((field) => !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 (
|
||||
<Box className="rounded-2xl border border-edr-border bg-gradient-to-r from-edr-blue/5 to-edr-green/5 px-7 py-6">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Box className="flex-1">
|
||||
<Group gap={6} align="center" mb={6}>
|
||||
{incomplete && <AlertTriangle size={16} color={cv("edr-orange")} />}
|
||||
<Text fz={15} fw={700} c="edr-text">
|
||||
{incomplete ? "Complete Your Profile" : "Setup your Company Profile"}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="edr-muted" mb={12}>
|
||||
{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."}
|
||||
</Text>
|
||||
<Link to="/settings" className="no-underline">
|
||||
<Group gap={8} align="center" className="w-fit">
|
||||
<Text fz={13} fw={600} c="edr-green.7">
|
||||
{incomplete ? "Complete Profile" : "Complete Setup"}
|
||||
</Text>
|
||||
<ArrowRight size={16} color={cv("edr-green.7")} />
|
||||
</Group>
|
||||
</Link>
|
||||
</Box>
|
||||
<Box className="hidden shrink-0 sm:block">
|
||||
<Truck size={48} color={cv("edr-blue")} opacity={0.3} />
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
|
||||
56
apps/edr-freight-web/portal/src/utils/profileCompletion.ts
Normal file
56
apps/edr-freight-web/portal/src/utils/profileCompletion.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user