style: improvment to the booking

This commit is contained in:
Nathnael
2026-06-24 08:16:49 +00:00
parent 10d74a65b0
commit 9d5c195d94
7 changed files with 204 additions and 99 deletions

View File

@@ -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();

View File

@@ -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>
);
}

View File

@@ -355,7 +355,7 @@ export default function OnboardingWizardDialog({
withCloseButton
closeOnClickOutside={false}
closeOnEscape
size={1040}
size={720}
radius="lg"
padding="xl"
centered

View File

@@ -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}

View File

@@ -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>
);
});

View File

@@ -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";

View 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;
}