style: ui revamp

This commit is contained in:
ghost2023
2026-06-09 09:27:14 +03:00
parent 41061462ff
commit 4566033046
16 changed files with 2188 additions and 2308 deletions

View File

@@ -5,6 +5,12 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EDR Freight Portal</title> <title>EDR Freight Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@500;600;700;800&display=swap"
rel="stylesheet"
/>
</head> </head>
<body> <body>

View File

@@ -19,6 +19,7 @@ import { useDisclosure } from "@mantine/hooks";
import { import {
Bell, Bell,
ChevronDown, ChevronDown,
ChevronRight,
Languages, Languages,
LogOut, LogOut,
Moon, Moon,
@@ -93,14 +94,24 @@ function getActivePage(
return null; return null;
} }
// Shared NavLink styling — green tint only when active, quiet neutral otherwise. // NavLink classNames for the dark sidebar — Tailwind utilities (with v4 `!`
const navLinkStyles = { // important suffix) override Mantine's default active styling.
root: { const navClassNames = (active: boolean) => {
borderRadius: "var(--mantine-radius-md)", const base =
fontWeight: 500, "rounded-[10px] font-medium transition-all duration-150 active:scale-[0.98]";
}, if (active) {
label: { fontSize: "var(--mantine-font-size-sm)" }, return {
} as const; root: `${base} bg-gradient-to-br! from-emerald-600! to-emerald-500! text-white! shadow-[0_2px_8px_-4px_rgba(16,185,129,0.45)]`,
label: "text-white!",
section: "text-white!",
};
}
return {
root: `${base} text-white/60! hover:bg-white/[0.07]! hover:text-white!`,
label: "text-inherit!",
section: "text-inherit! opacity-90",
};
};
export function AppLayout({ export function AppLayout({
title = "EDR Freight", title = "EDR Freight",
@@ -139,7 +150,7 @@ export function AppLayout({
<AppShell <AppShell
layout="alt" layout="alt"
navbar={{ navbar={{
width: 256, width: 264,
breakpoint: "sm", breakpoint: "sm",
collapsed: { mobile: !mobileOpen }, collapsed: { mobile: !mobileOpen },
}} }}
@@ -148,8 +159,8 @@ export function AppLayout({
> >
{/* ── Header ──────────────────────────────────────────────────────────── */} {/* ── Header ──────────────────────────────────────────────────────────── */}
<AppShell.Header <AppShell.Header
withBorder withBorder={false}
style={{ background: "var(--mantine-color-body)" }} className="border-b border-[var(--mantine-color-gray-2)]! bg-white/70! backdrop-blur-md backdrop-saturate-150"
> >
<Group h="100%" px="lg" justify="space-between"> <Group h="100%" px="lg" justify="space-between">
<Group gap="sm"> <Group gap="sm">
@@ -159,9 +170,24 @@ export function AppLayout({
hiddenFrom="sm" hiddenFrom="sm"
size="sm" size="sm"
/> />
<Text fw={600} size="md"> <Group gap={6} wrap="nowrap">
{activePage ? activePage.label : title} <Text
</Text> size="sm"
className="font-medium text-[var(--mantine-color-gray-5)]"
visibleFrom="xs"
>
{title}
</Text>
<Box visibleFrom="xs" className="flex">
<ChevronRight size={14} color="var(--mantine-color-gray-4)" />
</Box>
<Text
size="md"
className="font-semibold tracking-tight text-[var(--mantine-color-gray-8)]"
>
{activePage ? activePage.label : title}
</Text>
</Group>
</Group> </Group>
{/* Right: utility actions + user menu */} {/* Right: utility actions + user menu */}
@@ -170,16 +196,20 @@ export function AppLayout({
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size="lg"
radius="md"
className="transition-transform hover:-translate-y-px"
aria-label="Change language" aria-label="Change language"
> >
<Languages size={18} /> <Languages size={18} />
</ActionIcon> </ActionIcon>
<Indicator color="red" size={7} offset={5} zIndex={10}> <Indicator color="red" size={7} offset={6} zIndex={10} withBorder>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size="lg"
radius="md"
className="transition-transform hover:-translate-y-px"
aria-label="Notifications" aria-label="Notifications"
> >
<Bell size={18} /> <Bell size={18} />
@@ -191,6 +221,8 @@ export function AppLayout({
variant="subtle" variant="subtle"
color="gray" color="gray"
size="lg" size="lg"
radius="md"
className="transition-transform hover:-translate-y-px"
onClick={toggleTheme} onClick={toggleTheme}
aria-label="Toggle theme" aria-label="Toggle theme"
> >
@@ -198,38 +230,42 @@ export function AppLayout({
</ActionIcon> </ActionIcon>
)} )}
<Divider orientation="vertical" my={14} mx={6} />
<Menu <Menu
width={220} width={232}
position="bottom-end" position="bottom-end"
withinPortal withinPortal
shadow="md" shadow="md"
offset={8} offset={8}
radius="md"
> >
<Menu.Target> <Menu.Target>
<UnstyledButton <UnstyledButton
px={6} px={6}
py={4} py={4}
style={{ borderRadius: "var(--mantine-radius-md)" }} className="rounded-[10px] transition-colors hover:bg-[var(--mantine-color-gray-1)]"
> >
<Group gap={8} wrap="nowrap"> <Group gap={8} wrap="nowrap">
<Avatar color="edr-green" radius="xl" size={30}> <Avatar
<Text fw={600} fz={11}> radius="xl"
size={32}
gradient={{ from: "edr-green.5", to: "edr-green.8", deg: 135 }}
variant="gradient"
>
<Text fw={600} fz={11} c="white">
{initials} {initials}
</Text> </Text>
</Avatar> </Avatar>
<Text <Box visibleFrom="sm" className="min-w-0">
size="sm" <Text size="sm" fw={600} maw={120} truncate lh={1.2}>
fw={500} {userName}
visibleFrom="sm" </Text>
maw={120} <Text size="xs" c="dimmed" lh={1.2}>
truncate Customer
> </Text>
{userName} </Box>
</Text> <ChevronDown size={14} color="var(--mantine-color-gray-5)" />
<ChevronDown
size={14}
color="var(--mantine-color-gray-5)"
/>
</Group> </Group>
</UnstyledButton> </UnstyledButton>
</Menu.Target> </Menu.Target>
@@ -267,49 +303,29 @@ export function AppLayout({
{/* ── Sidebar ─────────────────────────────────────────────────────────── */} {/* ── Sidebar ─────────────────────────────────────────────────────────── */}
<AppShell.Navbar <AppShell.Navbar
withBorder withBorder={false}
style={{ className="flex flex-col border-r border-white/[0.06]! bg-gradient-to-b from-[#1a2230] to-[#141a26]"
// Soft, near-neutral gray — warm enough to avoid the cold blue tint.
background: "#f7f7f6",
display: "flex",
flexDirection: "column",
}}
> >
{/* Brand */} {/* Brand */}
<Box <Box className="flex h-[60px] flex-shrink-0 items-center border-b border-white/[0.07] px-4">
h={60}
px="md"
style={{
display: "flex",
alignItems: "center",
borderBottom: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
}}
>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Box <Box className="flex h-[34px] w-[34px] flex-shrink-0 items-center justify-center rounded-[10px] bg-gradient-to-br from-emerald-500 to-emerald-700 shadow-[inset_0_1px_0_rgba(255,255,255,0.2)]">
style={{
width: 32,
height: 32,
borderRadius: "var(--mantine-radius-md)",
background: "var(--mantine-color-edr-green-6)",
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
}}
>
<Train size={18} color="white" strokeWidth={2.2} /> <Train size={18} color="white" strokeWidth={2.2} />
</Box> </Box>
<Text fw={650} size="md"> <Box>
{title} <Text className="font-bold leading-tight tracking-tight text-white" size="md">
</Text> {title}
</Text>
<Text className="text-[10px] font-semibold uppercase tracking-[0.12em] text-white/40">
Logistics Portal
</Text>
</Box>
</Group> </Group>
</Box> </Box>
{/* Nav links */} {/* Nav links */}
<ScrollArea flex={1} type="never" p="sm"> <ScrollArea flex={1} type="never" p="sm">
<Stack gap={2}> <Stack gap={3}>
{sidebarItems.map((item, i) => { {sidebarItems.map((item, i) => {
const active = isItemActive(item); const active = isItemActive(item);
const hasChildren = !!item.children?.length; const hasChildren = !!item.children?.length;
@@ -324,13 +340,11 @@ export function AppLayout({
<Text <Text
key={`section-${item.section}`} key={`section-${item.section}`}
size="xs" size="xs"
fw={600}
c="dimmed"
tt="uppercase" tt="uppercase"
px="sm" px="sm"
mt={i === 0 ? 0 : "md"} mt={i === 0 ? 4 : "md"}
mb={4} mb={6}
style={{ letterSpacing: "0.06em" }} className="font-bold tracking-[0.1em] text-white/30"
> >
{item.section} {item.section}
</Text> </Text>
@@ -344,23 +358,18 @@ export function AppLayout({
label={item.label} label={item.label}
leftSection={item.icon} leftSection={item.icon}
active={active || childActive} active={active || childActive}
color="edr-green"
variant="filled"
defaultOpened={childActive} defaultOpened={childActive}
styles={navLinkStyles} classNames={navClassNames(active || childActive)}
> >
{item.children!.map((child) => { {item.children!.map((child) => {
const cActive = const cActive = activePath === child.href.toLowerCase();
activePath === child.href.toLowerCase();
return ( return (
<NavLink <NavLink
key={child.href} key={child.href}
label={child.label} label={child.label}
active={cActive} active={cActive}
color="edr-green"
variant="filled"
onClick={() => navigate(child.href)} onClick={() => navigate(child.href)}
styles={navLinkStyles} classNames={navClassNames(cActive)}
/> />
); );
})} })}
@@ -376,10 +385,8 @@ export function AppLayout({
label={item.label} label={item.label}
leftSection={item.icon} leftSection={item.icon}
active={active} active={active}
color="edr-green"
variant="filled"
onClick={() => navigate(item.href)} onClick={() => navigate(item.href)}
styles={navLinkStyles} classNames={navClassNames(active)}
/> />
</Fragment> </Fragment>
); );
@@ -388,26 +395,24 @@ export function AppLayout({
</ScrollArea> </ScrollArea>
{/* Bottom user */} {/* Bottom user */}
<Box <Box className="m-3 flex-shrink-0 rounded-xl border border-white/[0.07] bg-white/[0.04] p-2.5">
px="md"
py="sm"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
flexShrink: 0,
}}
>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<Avatar color="edr-green" radius="xl" size={30}> <Avatar
<Text fw={600} fz={11}> radius="xl"
size={34}
gradient={{ from: "edr-green.5", to: "edr-green.8", deg: 135 }}
variant="gradient"
>
<Text fw={600} fz={12} c="white">
{initials} {initials}
</Text> </Text>
</Avatar> </Avatar>
<Box style={{ minWidth: 0 }}> <Box className="min-w-0">
<Text size="sm" fw={500} truncate> <Text size="sm" fw={600} truncate className="text-white">
{userName} {userName}
</Text> </Text>
{userEmail && ( {userEmail && (
<Text size="xs" c="dimmed" truncate> <Text size="xs" truncate className="text-white/40">
{userEmail} {userEmail}
</Text> </Text>
)} )}
@@ -417,7 +422,7 @@ export function AppLayout({
</AppShell.Navbar> </AppShell.Navbar>
{/* ── Main ────────────────────────────────────────────────────────────── */} {/* ── Main ────────────────────────────────────────────────────────────── */}
<AppShell.Main style={{ background: "var(--mantine-color-body)" }}> <AppShell.Main className="bg-[var(--mantine-color-gray-0)]">
{children} {children}
</AppShell.Main> </AppShell.Main>
</AppShell> </AppShell>

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Box, Group, Stack, Text, ThemeIcon, Title } from "@mantine/core";
import { ShieldCheck, Train } from "lucide-react"; import { ShieldCheck, Train } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -27,67 +28,102 @@ export default function AuthLayout({
left, left,
}: AuthLayoutProps) { }: AuthLayoutProps) {
return ( return (
<div className="min-h-screen bg-background text-foreground"> <Box className="min-h-screen bg-white text-[var(--mantine-color-gray-9)]">
<div className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}> <Box className={cn("grid min-h-screen lg:grid-cols-2", parentClassName)}>
<div className="relative hidden overflow-hidden bg-primary p-8 px-12 text-primary-foreground lg:flex lg:flex-col lg:justify-between"> {/* ── Left: branded panel ─────────────────────────────────────── */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(255,255,255,0.14),transparent_35%)]" /> <Box className="relative hidden overflow-hidden bg-gradient-to-br from-emerald-900 via-emerald-700 to-emerald-600 px-12 py-10 text-white lg:flex lg:flex-col lg:justify-between">
<div className="relative z-10"> {/* rail-line motif */}
<div className="flex items-center gap-3"> <Box
<div className="flex size-14 items-center justify-center rounded-3xl bg-white/10 backdrop-blur"> aria-hidden
<Train className="size-7" /> className="pointer-events-none absolute inset-y-0 right-0 w-[360px] opacity-10 bg-[repeating-linear-gradient(90deg,#fff_0,#fff_2px,transparent_2px,transparent_18px)] [mask-image:linear-gradient(90deg,transparent_0%,#000_90%)] [-webkit-mask-image:linear-gradient(90deg,transparent_0%,#000_90%)]"
</div> />
<div> {/* corner glow */}
<h1 className="text-xl font-bold">EDR Freight</h1> <Box
<p className="mt-1 text-sm opacity-80"> aria-hidden
Railway Logistics Platform className="pointer-events-none absolute -right-24 -top-24 h-[320px] w-[320px] rounded-full blur-2xl bg-[radial-gradient(circle,rgba(255,255,255,0.16),transparent_70%)]"
</p> />
</div>
</div> {/* Brand */}
<div className="mt-16 max-w-lg"> <Group gap="sm" className="relative z-10">
<div className="inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur"> <Box className="flex size-14 items-center justify-center rounded-3xl border border-white/20 bg-white/10 backdrop-blur">
{left.badge} <Train className="size-7" />
</div> </Box>
<h2 className="mt-6 text-4xl font-bold leading-tight tracking-tight"> <Box>
{left.title} <Text fw={700} fz={20} className="leading-tight">
</h2> EDR Freight
<p className="mt-6 text-lg leading-8 opacity-85"> </Text>
{left.description} <Text fz="sm" className="text-white/70">
</p> Railway Logistics Platform
</div> </Text>
<div className="mt-14 grid gap-5"> </Box>
</Group>
{/* Headline + features */}
<Box className="relative z-10 my-10 max-w-lg">
<Box className="inline-flex rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold backdrop-blur">
{left.badge}
</Box>
<Title
order={1}
mt="lg"
c="white"
className="text-4xl! font-bold! leading-tight tracking-tight"
>
{left.title}
</Title>
<Text mt="lg" fz="lg" className="leading-8 text-white/85">
{left.description}
</Text>
<Stack gap="md" mt={40}>
{left.features.map((item) => ( {left.features.map((item) => (
<div key={item} className="flex items-center gap-3"> <Group key={item} gap="sm" wrap="nowrap">
<div className="flex size-10 items-center justify-center rounded-2xl bg-white/10"> <Box className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-white/10">
<ShieldCheck className="size-5" /> <ShieldCheck className="size-5" />
</div> </Box>
<span className="font-medium">{item}</span> <Text fw={500} className="text-white/95">
</div> {item}
</Text>
</Group>
))} ))}
</div> </Stack>
</div> </Box>
</div>
<div {/* spacer keeps brand pinned top / content centered */}
<Box className="relative z-10" />
</Box>
{/* ── Right: form area ────────────────────────────────────────── */}
<Box
className={cn( className={cn(
"flex items-center justify-center p-6 md:p-10", "flex items-center justify-center bg-[var(--mantine-color-gray-0)] p-6 md:p-10",
contentClassName, contentClassName,
)} )}
> >
<div className="w-full max-w-lg"> <Box className="w-full max-w-lg">
<div className="mb-8 flex items-center gap-3 lg:hidden"> {/* Mobile brand */}
<div className="flex size-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground"> <Group gap="sm" mb="xl" className="lg:hidden">
<ThemeIcon
size={48}
radius="lg"
variant="gradient"
gradient={{ from: "edr-green.5", to: "edr-green.7", deg: 135 }}
>
<Train className="size-6" /> <Train className="size-6" />
</div> </ThemeIcon>
<div> <Box>
<h1 className="text-2xl font-bold">EDR Freight</h1> <Text fw={700} fz={22}>
<p className="text-sm text-muted-foreground"> EDR Freight
</Text>
<Text fz="sm" c="dimmed">
Railway Logistics Platform Railway Logistics Platform
</p> </Text>
</div> </Box>
</div> </Group>
<div>{children}</div> <Box>{children}</Box>
</div> </Box>
</div> </Box>
</div> </Box>
</div> </Box>
); );
} }

View File

@@ -4,6 +4,7 @@ import { format } from "date-fns";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
Anchor, Anchor,
Avatar,
Badge, Badge,
Box, Box,
Button, Button,
@@ -30,6 +31,7 @@ import {
Plus, Plus,
Receipt, Receipt,
Train, Train,
TrendingUp,
UploadCloud, UploadCloud,
} from "lucide-react"; } from "lucide-react";
@@ -71,6 +73,10 @@ const INVOICE_STATUS_META: Record<InvoiceStatus, { color: string }> = {
Cancelled: { color: "gray" }, Cancelled: { color: "gray" },
}; };
// Card hover-lift, shared via Tailwind utilities.
const LIFT =
"transition-all duration-200 hover:-translate-y-[3px] hover:shadow-[0_16px_34px_-16px_rgba(16,24,40,0.22)] hover:border-emerald-300!";
export default function MyPortalPage() { export default function MyPortalPage() {
const { user, customer } = useAuth(); const { user, customer } = useAuth();
const myShipments = useMemo(() => getMyShipments(), []); const myShipments = useMemo(() => getMyShipments(), []);
@@ -109,39 +115,30 @@ export default function MyPortalPage() {
const paidPct = Math.round((completedInvoices / invoiceTotal) * 100); const paidPct = Math.round((completedInvoices / invoiceTotal) * 100);
const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—"; const displayName = user?.name?.en ?? user?.username ?? user?.email ?? "—";
const initials = displayName
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((n) => n[0]?.toUpperCase())
.join("");
const documentsComplete = !!(customer as any)?.documentsComplete; const documentsComplete = !!(customer as any)?.documentsComplete;
const hasOutstanding = outstandingInvoices.length > 0; const hasOutstanding = outstandingInvoices.length > 0;
const today = format(new Date(), "EEEE, MMMM d");
return ( return (
<Stack gap="xl" p="xl" maw={1240} mx="auto"> <Stack gap="xl" p="xl" maw={1280} mx="auto">
{/* ── Document setup notice ───────────────────────────────────── */} {/* ── Document setup notice ───────────────────────────────────── */}
{!documentsComplete && !dismissed && ( {!documentsComplete && !dismissed && (
<Group <Group
align="center" align="center"
gap="md" gap="md"
style={{ className="rounded-2xl border border-amber-200 bg-gradient-to-r from-amber-50 to-amber-100 px-5 py-4"
borderRadius: "var(--mantine-radius-xl)",
background: "#fffbeb",
border: "1px solid #fde68a",
padding: "16px 20px",
}}
> >
<Box <Box className="flex h-[42px] w-[42px] flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-amber-500 shadow-[0_6px_16px_-6px_rgba(245,158,11,0.7)]">
style={{ <UploadCloud size={20} color="white" strokeWidth={2} />
width: 40,
height: 40,
borderRadius: 11,
flexShrink: 0,
background: "#f59e0b",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<UploadCloud size={19} color="white" strokeWidth={2} />
</Box> </Box>
<Box style={{ flex: 1 }}> <Box className="flex-1">
<Text fw={700} size="lg" c="#78350f" lh={1.3}> <Text fw={700} size="lg" c="#78350f" lh={1.3}>
Finish setting up your account Finish setting up your account
</Text> </Text>
@@ -157,12 +154,7 @@ export default function MyPortalPage() {
to="/settings?tab=documents" to="/settings?tab=documents"
size="sm" size="sm"
radius="xl" radius="xl"
style={{ className="flex-shrink-0 bg-gradient-to-br from-amber-500 to-amber-600! font-semibold text-white!"
background: "#f59e0b",
color: "white",
fontWeight: 600,
flexShrink: 0,
}}
rightSection={<ArrowRight size={16} />} rightSection={<ArrowRight size={16} />}
> >
Upload docs Upload docs
@@ -170,56 +162,58 @@ export default function MyPortalPage() {
</Group> </Group>
)} )}
{/* ── Welcome (branded band) ──────────────────────────────────── */} {/* ── Welcome (branded hero) ──────────────────────────────────── */}
<Box <Box className="relative overflow-hidden rounded-[20px] bg-gradient-to-br from-emerald-900 via-emerald-700 to-emerald-600 p-9 text-white shadow-[0_14px_32px_-20px_rgba(16,24,40,0.4)]">
p="xl"
style={{
position: "relative",
overflow: "hidden",
borderRadius: "var(--mantine-radius-lg)",
border: "1px solid var(--mantine-color-edr-green-1)",
background:
"linear-gradient(120deg, var(--mantine-color-edr-green-0) 0%, var(--mantine-color-body) 60%)",
}}
>
{/* faint rail-line motif */} {/* faint rail-line motif */}
<Box <Box
aria-hidden aria-hidden
style={{ className="pointer-events-none absolute inset-y-0 right-0 w-[320px] opacity-10 bg-[repeating-linear-gradient(90deg,#fff_0,#fff_2px,transparent_2px,transparent_18px)] [mask-image:linear-gradient(90deg,transparent_0%,#000_90%)] [-webkit-mask-image:linear-gradient(90deg,transparent_0%,#000_90%)]"
position: "absolute", />
inset: 0, <Box
left: "auto", aria-hidden
width: 240, className="pointer-events-none absolute -bottom-32 left-[35%] h-[200px] w-[200px] rounded-full blur-xl bg-[radial-gradient(circle,rgba(255,255,255,0.12),transparent_70%)]"
opacity: 0.5,
backgroundImage:
"repeating-linear-gradient(90deg, var(--mantine-color-edr-green-2) 0, var(--mantine-color-edr-green-2) 2px, transparent 2px, transparent 16px)",
maskImage:
"linear-gradient(90deg, transparent 0%, black 100%)",
WebkitMaskImage:
"linear-gradient(90deg, transparent 0%, black 100%)",
pointerEvents: "none",
}}
/> />
<Group <Group
justify="space-between" justify="space-between"
align="flex-end" align="flex-end"
wrap="wrap" wrap="wrap"
gap="md" gap="lg"
style={{ position: "relative" }} className="relative"
> >
<Box> <Group gap="md" wrap="nowrap" align="center">
<Text size="sm" c="edr-green.7" fw={600}> <Avatar
Welcome back radius="xl"
</Text> size={56}
<Title order={1} mt={4}> className="border border-white/30 bg-white/[0.16] backdrop-blur-sm"
{displayName} >
</Title> <Text fw={700} fz={18} c="white">
</Box> {initials}
</Text>
</Avatar>
<Box>
<Text
size="xs"
fw={600}
tt="uppercase"
className="tracking-[0.1em] text-white/75"
>
{today}
</Text>
<Title order={1} mt={2} c="white" className="tracking-[-0.02em]">
Welcome back, {displayName.split(" ")[0]}
</Title>
<Text size="sm" mt={4} className="text-white/[0.78]">
Here's what's moving across your account today.
</Text>
</Box>
</Group>
<Button <Button
component={Link} component={Link}
to="/bookings/new" to="/bookings/new"
color="edr-green" size="md"
leftSection={<Plus size={16} />} radius="xl"
leftSection={<Plus size={17} />}
className="bg-white! font-semibold text-emerald-800! shadow-[0_10px_24px_-10px_rgba(0,0,0,0.4)]"
> >
New Booking New Booking
</Button> </Button>
@@ -231,13 +225,13 @@ export default function MyPortalPage() {
<StatCard <StatCard
label="Active Bookings" label="Active Bookings"
value={myBookings.length.toString()} value={myBookings.length.toString()}
icon={<Package2 size={18} />} icon={<Package2 size={20} />}
color="blue" color="blue"
/> />
<StatCard <StatCard
label="In Transit" label="In Transit"
value={activeShipments.length.toString()} value={activeShipments.length.toString()}
icon={<Train size={18} />} icon={<Train size={20} />}
color="teal" color="teal"
/> />
<StatCard <StatCard
@@ -246,13 +240,13 @@ export default function MyPortalPage() {
sub={ sub={
hasOutstanding ? `${outstandingInvoices.length} unpaid` : "All clear" hasOutstanding ? `${outstandingInvoices.length} unpaid` : "All clear"
} }
icon={<Receipt size={18} />} icon={<Receipt size={20} />}
color={hasOutstanding ? "red" : "edr-green"} color={hasOutstanding ? "red" : "edr-green"}
/> />
<StatCard <StatCard
label="Invoices Paid" label="Invoices Paid"
value={`${completedInvoices} / ${invoiceTotal}`} value={`${completedInvoices} / ${invoiceTotal}`}
icon={<CalendarDays size={18} />} icon={<CalendarDays size={20} />}
color="edr-green" color="edr-green"
ring={{ value: paidPct, color: "edr-green" }} ring={{ value: paidPct, color: "edr-green" }}
/> />
@@ -262,20 +256,31 @@ export default function MyPortalPage() {
<Grid gutter="md"> <Grid gutter="md">
{/* Recent Bookings (left, wider) */} {/* Recent Bookings (left, wider) */}
<Grid.Col span={{ base: 12, lg: 7 }}> <Grid.Col span={{ base: 12, lg: 7 }}>
<Card h="100%"> <Card h="100%" className={LIFT}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Box> <Group gap="sm">
<Title order={4}>Recent Bookings</Title> <ThemeIcon
<Text size="sm" c="dimmed"> size={36}
Your latest freight requests radius="md"
</Text> variant="gradient"
</Box> gradient={{ from: "edr-green.5", to: "edr-green.7", deg: 135 }}
>
<Package2 size={18} />
</ThemeIcon>
<Box>
<Title order={4}>Recent Bookings</Title>
<Text size="sm" c="dimmed">
Your latest freight requests
</Text>
</Box>
</Group>
<Button <Button
component={Link} component={Link}
to="/bookings" to="/bookings"
variant="subtle" variant="light"
color="gray" color="edr-green"
size="xs" size="xs"
radius="xl"
rightSection={<ArrowUpRight size={14} />} rightSection={<ArrowUpRight size={14} />}
> >
View all View all
@@ -291,7 +296,7 @@ export default function MyPortalPage() {
) : recentBookings.length === 0 ? ( ) : recentBookings.length === 0 ? (
<EmptyState message="You haven't booked any freight yet." /> <EmptyState message="You haven't booked any freight yet." />
) : ( ) : (
<Box style={{ overflowX: "auto" }}> <Box className="overflow-x-auto">
<Table highlightOnHover verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
@@ -307,24 +312,31 @@ export default function MyPortalPage() {
return ( return (
<Table.Tr <Table.Tr
key={booking.id} key={booking.id}
style={{ cursor: "pointer" }} className="cursor-pointer"
onClick={() => navigate(`/bookings/${booking.id}`)} onClick={() => navigate(`/bookings/${booking.id}`)}
> >
<Table.Td> <Table.Td>
<Text fw={600} size="sm"> <Text fw={600} size="sm" c="edr-green.8">
{booking.reference} {booking.reference}
</Text> </Text>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" c="dimmed"> <Group gap={6} wrap="nowrap">
{booking.originYard?.label ?? <Text size="sm" fw={500}>
booking.originYard?.code ?? {booking.originYard?.label ??
"—"} booking.originYard?.code ??
{" → "} "—"}
{booking.destinationYard?.label ?? </Text>
booking.destinationYard?.code ?? <ArrowRight
"—"} size={13}
</Text> color="var(--mantine-color-gray-4)"
/>
<Text size="sm" fw={500}>
{booking.destinationYard?.label ??
booking.destinationYard?.code ??
"—"}
</Text>
</Group>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
@@ -353,15 +365,20 @@ export default function MyPortalPage() {
<Grid.Col span={{ base: 12, lg: 5 }}> <Grid.Col span={{ base: 12, lg: 5 }}>
<Stack gap="md" h="100%"> <Stack gap="md" h="100%">
{/* Active Shipments */} {/* Active Shipments */}
<Card style={{ flex: 1 }}> <Card className={`flex-1 ${LIFT}`}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Box> <Group gap="sm">
<Title order={4}>Shipments</Title> <ThemeIcon size={36} radius="md" color="teal" variant="light">
<Text size="sm" c="dimmed"> <Train size={18} />
In-flight cargo </ThemeIcon>
</Text> <Box>
</Box> <Title order={4}>Shipments</Title>
<Anchor component={Link} to="/tracking" size="sm" fw={500}> <Text size="sm" c="dimmed">
In-flight cargo
</Text>
</Box>
</Group>
<Anchor component={Link} to="/tracking" size="sm" fw={500} c="edr-green.7">
<Group gap={4}> <Group gap={4}>
All <ArrowRight size={13} /> All <ArrowRight size={13} />
</Group> </Group>
@@ -380,15 +397,20 @@ export default function MyPortalPage() {
</Card> </Card>
{/* Invoice Summary */} {/* Invoice Summary */}
<Card> <Card className={LIFT}>
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Box> <Group gap="sm">
<Title order={4}>Invoices</Title> <ThemeIcon size={36} radius="md" color="edr-green" variant="light">
<Text size="sm" c="dimmed"> <Receipt size={18} />
{outstandingInvoices.length} outstanding </ThemeIcon>
</Text> <Box>
</Box> <Title order={4}>Invoices</Title>
<Anchor component={Link} to="/billing" size="sm" fw={500}> <Text size="sm" c="dimmed">
{outstandingInvoices.length} outstanding
</Text>
</Box>
</Group>
<Anchor component={Link} to="/billing" size="sm" fw={500} c="edr-green.7">
<Group gap={4}> <Group gap={4}>
All <ArrowRight size={13} /> All <ArrowRight size={13} />
</Group> </Group>
@@ -407,10 +429,7 @@ export default function MyPortalPage() {
justify="space-between" justify="space-between"
wrap="nowrap" wrap="nowrap"
p="sm" p="sm"
style={{ className="rounded-md border border-[var(--mantine-color-gray-2)] transition-all duration-150 hover:translate-x-0.5 hover:border-emerald-300 hover:bg-emerald-50"
borderRadius: "var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-gray-2)",
}}
> >
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<ThemeIcon <ThemeIcon
@@ -466,36 +485,68 @@ function StatCard({
color: string; color: string;
ring?: { value: number; color: string }; ring?: { value: number; color: string };
}) { }) {
const accentBg: Record<string, string> = {
blue: "bg-blue-500",
teal: "bg-teal-500",
red: "bg-red-500",
"edr-green": "bg-emerald-500",
};
const accentText: Record<string, string> = {
blue: "text-blue-600",
teal: "text-teal-600",
red: "text-red-600",
"edr-green": "text-emerald-600",
};
const accent = accentBg[color] ?? "bg-emerald-500";
return ( return (
<Card> <Card className={`relative overflow-hidden ${LIFT}`}>
{/* top accent stripe */}
<Box className={`pointer-events-none absolute inset-x-0 top-0 h-[3px] ${accent}`} />
{/* soft corner glow */}
<Box className={`pointer-events-none absolute -right-8 -top-10 h-[110px] w-[110px] rounded-full opacity-[0.08] blur-md ${accent}`} />
<Group justify="space-between" align="flex-start" wrap="nowrap"> <Group justify="space-between" align="flex-start" wrap="nowrap">
<Box style={{ flex: 1, minWidth: 0 }}> <Box className="min-w-0 flex-1">
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lh={1.4}> <Text
size="xs"
c="dimmed"
fw={600}
tt="uppercase"
lh={1.4}
className="tracking-[0.04em]"
>
{label} {label}
</Text> </Text>
<Text fz={28} fw={650} lh={1.2} mt={6}> <Text fz={30} fw={700} lh={1.15} mt={8} className="tracking-[-0.02em]">
{value} {value}
</Text> </Text>
{sub && ( {sub && (
<Text size="xs" c="dimmed" mt={4}> <Group gap={4} mt={6} wrap="nowrap">
{sub} <TrendingUp size={12} className={accentText[color] ?? "text-emerald-600"} />
</Text> <Text size="xs" c="dimmed">
{sub}
</Text>
</Group>
)} )}
</Box> </Box>
{ring ? ( {ring ? (
<RingProgress <RingProgress
size={56} size={60}
thickness={5} thickness={5}
roundCaps roundCaps
sections={[{ value: ring.value, color: ring.color }]} sections={[{ value: ring.value, color: ring.color }]}
label={ label={
<Text ta="center" fz={11} fw={600}> <Text ta="center" fz={12} fw={700}>
{ring.value}% {ring.value}%
</Text> </Text>
} }
/> />
) : ( ) : (
<ThemeIcon size={40} radius="md" color={color} variant="light"> <ThemeIcon
size={46}
radius="md"
variant="gradient"
gradient={{ from: `${color}.4`, to: `${color}.6`, deg: 135 }}
>
{icon} {icon}
</ThemeIcon> </ThemeIcon>
)} )}
@@ -512,13 +563,7 @@ function ShipmentCard({
const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus]; const meta = SHIPMENT_STATUS_META[shipment.status as ShipmentStatus];
const color = meta?.color ?? "gray"; const color = meta?.color ?? "gray";
return ( return (
<Box <Box className="rounded-md border border-[var(--mantine-color-gray-2)] p-3 transition-all duration-150 hover:translate-x-0.5 hover:border-emerald-300 hover:bg-emerald-50">
style={{
borderRadius: "var(--mantine-radius-md)",
border: "1px solid var(--mantine-color-gray-2)",
padding: "var(--mantine-spacing-sm)",
}}
>
<Group justify="space-between" mb={6} wrap="nowrap"> <Group justify="space-between" mb={6} wrap="nowrap">
<Text fw={600} size="sm" truncate> <Text fw={600} size="sm" truncate>
{shipment.reference} {shipment.reference}
@@ -530,15 +575,21 @@ function ShipmentCard({
<Text size="xs" c="dimmed" mb={8}> <Text size="xs" c="dimmed" mb={8}>
{shipment.originStation} {shipment.destinationStation} {shipment.originStation} {shipment.destinationStation}
</Text> </Text>
<Progress value={shipment.progress} size={4} color={color} mb={8} /> <Progress
value={shipment.progress}
size={5}
radius="xl"
color={color}
mb={8}
/>
<Group justify="space-between" gap="xs" wrap="nowrap"> <Group justify="space-between" gap="xs" wrap="nowrap">
<Group gap={4} c="dimmed" wrap="nowrap" style={{ minWidth: 0 }}> <Group gap={4} c="dimmed" wrap="nowrap" className="min-w-0">
<MapPin size={11} style={{ flexShrink: 0 }} /> <MapPin size={11} className="flex-shrink-0" />
<Text size="xs" c="dimmed" truncate> <Text size="xs" c="dimmed" truncate>
{shipment.currentLocation} {shipment.currentLocation}
</Text> </Text>
</Group> </Group>
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}> <Text size="xs" c="dimmed" className="flex-shrink-0">
ETA {shipment.eta} ETA {shipment.eta}
</Text> </Text>
</Group> </Group>
@@ -550,11 +601,7 @@ function EmptyState({ message }: { message: string }) {
return ( return (
<Box <Box
py="xl" py="xl"
style={{ className="rounded-md border border-dashed border-[var(--mantine-color-gray-3)] text-center"
border: "1px dashed var(--mantine-color-gray-3)",
borderRadius: "var(--mantine-radius-md)",
textAlign: "center",
}}
> >
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{message} {message}

View File

@@ -41,11 +41,7 @@ import {
type BookingFormValues, type BookingFormValues,
type RouteDirection, type RouteDirection,
} from "./new-booking-form/schema"; } from "./new-booking-form/schema";
import { import { SelectField, AlertBox } from "./new-booking-form/shared";
SelectField,
SelectItem,
AlertBox,
} from "./new-booking-form/shared";
function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string { function yardNameFromBooking(yard: { label?: string; code?: string; name?: string } | undefined | null): string {
return yard?.label ?? yard?.name ?? yard?.code ?? ""; return yard?.label ?? yard?.name ?? yard?.code ?? "";
@@ -399,10 +395,11 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Contract Type *" label="Contract Type *"
placeholder="Select contract type..." placeholder="Select contract type..."
> data={[
<SelectItem value="new">New Contract</SelectItem> { value: "new", label: "New Contract" },
<SelectItem value="renewal">Contract Renewal</SelectItem> { value: "renewal", label: "Contract Renewal" },
</SelectField> ]}
/>
)} )}
/> />
@@ -431,10 +428,11 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Service Type *" label="Service Type *"
placeholder="Select service type..." placeholder="Select service type..."
> data={[
<SelectItem value="rail">Rail Transport Only</SelectItem> { value: "rail", label: "Rail Transport Only" },
<SelectItem value="rail_forwarding">Logistics (Rail + Forwarding)</SelectItem> { value: "rail_forwarding", label: "Logistics (Rail + Forwarding)" },
</SelectField> ]}
/>
)} )}
/> />
@@ -447,10 +445,11 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Equipment Return" label="Equipment Return"
placeholder="Select..." placeholder="Select..."
> data={[
<SelectItem value="with_return">With Return</SelectItem> { value: "with_return", label: "With Return" },
<SelectItem value="without_return">Without Return</SelectItem> { value: "without_return", label: "Without Return" },
</SelectField> ]}
/>
)} )}
/> />
</div> </div>
@@ -602,19 +601,8 @@ export default function EditBookingPage() {
label="Origin Yard *" label="Origin Yard *"
placeholder="Select origin..." placeholder="Select origin..."
disabled={yardOptions.length === 0} disabled={yardOptions.length === 0}
> data={yardOptions.filter((y) => y.value !== destinationYard)}
{yardOptions.length === 0 ? ( />
<SelectItem value="__empty" disabled>No yards available</SelectItem>
) : (
yardOptions
.filter((y) => y.value !== destinationYard)
.map((y) => (
<SelectItem key={y.value} value={y.value}>
{y.label}
</SelectItem>
))
)}
</SelectField>
)} )}
/> />
@@ -628,19 +616,8 @@ export default function EditBookingPage() {
label="Destination Yard *" label="Destination Yard *"
placeholder="Select destination..." placeholder="Select destination..."
disabled={yardOptions.length === 0} disabled={yardOptions.length === 0}
> data={yardOptions.filter((y) => y.value !== originYard)}
{yardOptions.length === 0 ? ( />
<SelectItem value="__empty" disabled>No yards available</SelectItem>
) : (
yardOptions
.filter((y) => y.value !== originYard)
.map((y) => (
<SelectItem key={y.value} value={y.value}>
{y.label}
</SelectItem>
))
)}
</SelectField>
)} )}
/> />
</div> </div>
@@ -664,13 +641,8 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Shipping Line" label="Shipping Line"
placeholder="Select shipping line..." placeholder="Select shipping line..."
> data={shippingLineOptions}
{shippingLineOptions.map((sl) => ( />
<SelectItem key={sl.value} value={sl.value}>
{sl.label}
</SelectItem>
))}
</SelectField>
)} )}
/> />
)} )}
@@ -736,10 +708,11 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Cargo Type *" label="Cargo Type *"
placeholder="Select cargo type..." placeholder="Select cargo type..."
> data={[
<SelectItem value="container">Containerized</SelectItem> { value: "container", label: "Containerized" },
<SelectItem value="bulk">General Cargo</SelectItem> { value: "bulk", label: "General Cargo" },
</SelectField> ]}
/>
)} )}
/> />
@@ -782,13 +755,11 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Freight Type *" label="Freight Type *"
placeholder="Select freight type..." placeholder="Select freight type..."
> data={freightTypeGroups.map((g) => ({
{freightTypeGroups.map((group) => ( value: g.code.toLowerCase(),
<SelectItem key={group.code} value={group.code.toLowerCase()}> label: g.name,
{group.name} }))}
</SelectItem> />
))}
</SelectField>
)} )}
/> />
@@ -802,13 +773,8 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Commodity *" label="Commodity *"
placeholder="Select commodity..." placeholder="Select commodity..."
> data={commodityOptions}
{commodityOptions.map((option) => ( />
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)} )}
/> />
)} )}
@@ -903,10 +869,11 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Size *" label="Size *"
placeholder="Size..." placeholder="Size..."
> data={[
<SelectItem value="20ft">20ft (TEU)</SelectItem> { value: "20ft", label: "20ft (TEU)" },
<SelectItem value="40ft">40ft (FEU)</SelectItem> { value: "40ft", label: "40ft (FEU)" },
</SelectField> ]}
/>
)} )}
/> />
@@ -919,13 +886,8 @@ export default function EditBookingPage() {
error={fieldState.error} error={fieldState.error}
label="Type *" label="Type *"
placeholder="Type..." placeholder="Type..."
> data={containerTypeOptions}
{containerTypeOptions.map((option) => ( />
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)} )}
/> />

View File

@@ -1,6 +1,21 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
Menu,
SimpleGrid,
Stack,
Text,
TextInput,
ThemeIcon,
Title,
} from "@mantine/core";
import { import {
ArrowRight, ArrowRight,
Clock, Clock,
@@ -20,17 +35,6 @@ import {
DataTableFooter, DataTableFooter,
type ColumnDef, type ColumnDef,
usePagination, usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from "@edr/ui-common"; } from "@edr/ui-common";
export default function MyBookings() { export default function MyBookings() {
@@ -80,15 +84,19 @@ export default function MyBookings() {
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original; const booking = row.original;
return ( return (
<div className="flex items-center gap-3"> <Group gap="sm" wrap="nowrap">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground"> <Box className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-700 text-white shadow-sm shadow-emerald-500/30">
<Package className="h-5 w-5" /> <Package className="h-5 w-5" />
</div> </Box>
<div> <Box>
<p className="font-medium text-foreground">{booking.reference}</p> <Text fw={600} size="sm">
<p className="text-sm text-muted-foreground">{booking.scheduledDate ?? booking.createdAt}</p> {booking.reference}
</div> </Text>
</div> <Text size="xs" c="dimmed">
{booking.scheduledDate ?? booking.createdAt}
</Text>
</Box>
</Group>
); );
}, },
}, },
@@ -96,11 +104,15 @@ export default function MyBookings() {
id: "route", id: "route",
header: "Route", header: "Route",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <Group gap={6} wrap="nowrap">
<span>{row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}</span> <Text size="sm" fw={500}>
<ArrowRight className="text-muted-foreground" /> {row.original.originYard?.label ?? row.original.originYard?.code ?? "—"}
<span>{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}</span> </Text>
</div> <ArrowRight className="h-4 w-4 text-[var(--mantine-color-gray-4)]" />
<Text size="sm" fw={500}>
{row.original.destinationYard?.label ?? row.original.destinationYard?.code ?? "—"}
</Text>
</Group>
), ),
}, },
{ {
@@ -111,12 +123,15 @@ export default function MyBookings() {
const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0; const containerCount = b.containers?.reduce((sum, c) => sum + c.qty, 0) ?? 0;
const containerType = b.containers?.[0]?.type ?? null; const containerType = b.containers?.[0]?.type ?? null;
return ( return (
<div className="text-sm text-muted-foreground"> <Box>
<p>{b.freightType === "BULK" ? "Bulk" : "Break Bulk"}</p> <Text size="sm" fw={500}>
<p className="text-xs text-muted-foreground"> {b.freightType === "BULK" ? "Bulk" : "Break Bulk"}
{containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}{b.cargoTotalWeightVgm}t </Text>
</p> <Text size="xs" c="dimmed">
</div> {containerType && containerCount > 0 ? `${containerCount} × ${containerType} · ` : ""}
{b.cargoTotalWeightVgm}t
</Text>
</Box>
); );
}, },
}, },
@@ -124,9 +139,9 @@ export default function MyBookings() {
id: "transportMode", id: "transportMode",
header: "Transport", header: "Transport",
cell: ({ row }) => ( cell: ({ row }) => (
<span className="text-sm text-muted-foreground"> <Text size="sm" c="dimmed">
{row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"} {row.original.serviceType === "RAIL_AND_FORWARDING" ? "Rail & Forwarding" : "Rail"}
</span> </Text>
), ),
}, },
{ {
@@ -140,26 +155,23 @@ export default function MyBookings() {
cell: ({ row }) => { cell: ({ row }) => {
const booking = row.original; const booking = row.original;
return ( return (
<div <Group justify="flex-end" onClick={(e) => e.stopPropagation()}>
className="flex justify-end" <Menu position="bottom-end" withinPortal shadow="md" radius="md">
onClick={(e) => e.stopPropagation()} <Menu.Target>
> <ActionIcon variant="default" radius="md" aria-label="Row actions">
<DropdownMenu> <MoreHorizontal size={16} />
<DropdownMenuTrigger asChild> </ActionIcon>
<Button variant="outline" size="icon"> </Menu.Target>
<MoreHorizontal /> <Menu.Dropdown>
</Button> <Menu.Item
</DropdownMenuTrigger> leftSection={<Eye size={15} />}
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/bookings/${booking.id}`)} onClick={() => navigate(`/bookings/${booking.id}`)}
> >
<Eye />
View View
</DropdownMenuItem> </Menu.Item>
</DropdownMenuContent> </Menu.Dropdown>
</DropdownMenu> </Menu>
</div> </Group>
); );
}, },
}, },
@@ -168,148 +180,191 @@ export default function MyBookings() {
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success"; const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
return ( return (
<div className="min-h-screen p-6"> <Box className="min-h-screen bg-gray-50/50 p-6">
<div className="space-y-6"> <Stack gap="lg" maw={1280} mx="auto">
<Card className="p-6 flex-row justify-between"> {/* ── Header band ─────────────────────────────────────────── */}
<div> <Card
<h1 className="text-3xl font-bold tracking-tight text-foreground"> radius="lg"
My Bookings withBorder
</h1> className="relative overflow-hidden border-emerald-100! bg-gradient-to-br from-emerald-50 via-white to-white"
<p className="mt-1 text-sm text-muted-foreground"> >
View and manage your freight booking requests. <Box className="pointer-events-none absolute -right-10 -top-16 h-48 w-48 rounded-full bg-emerald-400/10 blur-2xl" />
</p> <Group justify="space-between" align="flex-end" wrap="wrap" gap="md" className="relative">
</div> <Box>
<Title order={1} className="tracking-tight">
My Bookings
</Title>
<Text size="sm" c="dimmed" mt={4}>
View and manage your freight booking requests.
</Text>
</Box>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <Group gap="sm" wrap="wrap">
<div className="relative w-full sm:w-80"> <TextInput
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
placeholder="Search bookings..." placeholder="Search bookings..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.currentTarget.value)}
className="pl-8!" leftSection={<Search size={16} />}
radius="md"
className="w-full sm:w-80"
styles={{ input: { background: "white" } }}
/> />
</div> <Button
component={Link}
<Link to="/bookings/new"> to="/bookings/new"
<Button> color="edr-green"
<Plus /> radius="md"
leftSection={<Plus size={16} />}
className="shadow-sm shadow-emerald-500/30"
>
New Booking New Booking
</Button> </Button>
</Link> </Group>
</div> </Group>
</Card> </Card>
<div className="grid gap-4 md:grid-cols-3"> {/* ── Stat cards ──────────────────────────────────────────── */}
<Card> <SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
<CardContent className="flex items-center justify-between"> <StatCard
<div> label="Total Bookings"
<p className="text-sm text-muted-foreground">Total Bookings</p> value={bookings.length}
<h3 className="mt-2 text-3xl font-bold text-foreground"> icon={<Package className="h-6 w-6" />}
{bookings.length} gradient="from-emerald-500 to-emerald-700 shadow-emerald-500/30"
</h3> />
</div> <StatCard
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary"> label="Active Bookings"
<Package /> value={activeCount}
</div> icon={<Truck className="h-6 w-6" />}
</CardContent> gradient="from-sky-500 to-blue-600 shadow-sky-500/30"
</Card> />
<StatCard
label="Pending Approval"
value={pendingCount}
icon={<Clock className="h-6 w-6" />}
gradient="from-amber-400 to-orange-500 shadow-amber-500/30"
/>
</SimpleGrid>
<Card> {/* ── Table ───────────────────────────────────────────────── */}
<CardContent className="flex items-center justify-between"> <Card radius="lg" withBorder p={0} className="overflow-hidden">
<div> <Group justify="space-between" align="flex-start" p="lg" className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60">
<p className="text-sm text-muted-foreground">Active Bookings</p> <Box>
<h3 className="mt-2 text-3xl font-bold text-foreground"> <Title order={4}>Recent Requests</Title>
{activeCount} <Text size="sm" c="dimmed">
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Pending Approval</p>
<h3 className="mt-2 text-3xl font-bold text-foreground">
{pendingCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Recent Requests</CardTitle>
<CardDescription>
A list of your recent freight bookings and their statuses. A list of your recent freight bookings and their statuses.
</CardDescription> </Text>
</div> </Box>
<Button variant="default" size="sm" radius="md" leftSection={<Filter size={15} />}>
<Button variant="secondary" size="sm">
<Filter />
Filter Filter
</Button> </Button>
</CardHeader> </Group>
<CardContent className="px-0"> {total === 0 && dataTableStatus === "success" ? (
{total === 0 && dataTableStatus === "success" ? ( <Stack align="center" gap={4} px="lg" py={64} className="text-center">
<div className="flex flex-col items-center justify-center py-12 px-6 text-center"> <ThemeIcon size={56} radius="lg" color="edr-green" variant="light" mb="xs">
<Package className="h-12 w-12 text-muted-foreground mb-4" /> <Package size={28} />
<h3 className="text-sm font-semibold text-foreground">No bookings found</h3> </ThemeIcon>
<p className="text-xs text-muted-foreground mt-1 max-w-sm"> <Text size="sm" fw={600}>
{searchTerm ? "No bookings match your current search filter." : "You haven't requested any bookings yet."} No bookings found
</p> </Text>
</div> <Text size="xs" c="dimmed" maw={320}>
) : ( {searchTerm
<DataTable ? "No bookings match your current search filter."
columns={columns} : "You haven't requested any bookings yet."}
data={paginatedData} </Text>
status={dataTableStatus} {!searchTerm && (
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)} <Button
pagination={{ component={Link}
pageIndex: pagination.pageIndex, to="/bookings/new"
pageSize: pagination.pageSize, size="sm"
pageCount: pageCount, color="edr-green"
totalCount: total, radius="md"
}} mt="md"
tableOptions={{ leftSection={<Plus size={15} />}
state: { pagination }, >
onPaginationChange: setPagination, Create your first booking
}} </Button>
containerClassName="border-b shadow-none" )}
footer={DataTableFooter} </Stack>
/> ) : (
)} <DataTable
</CardContent> columns={columns}
data={paginatedData}
status={dataTableStatus}
onRowClick={(row) => navigate(`/bookings/${(row as Freight.IBooking).id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none"
footer={DataTableFooter}
/>
)}
</Card> </Card>
</div> </Stack>
</div> </Box>
);
}
function StatCard({
label,
value,
icon,
gradient,
}: {
label: string;
value: number;
icon: React.ReactNode;
gradient: string;
}) {
return (
<Card
radius="lg"
withBorder
className="group transition-all duration-200 hover:-translate-y-0.5 hover:shadow-md"
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box>
<Text size="sm" fw={500} c="dimmed">
{label}
</Text>
<Text fz={30} fw={700} mt={6} className="tracking-tight">
{value}
</Text>
</Box>
<Box
className={`flex h-12 w-12 items-center justify-center rounded-2xl bg-gradient-to-br text-white shadow-md transition-transform duration-200 group-hover:scale-105 ${gradient}`}
>
{icon}
</Box>
</Group>
</Card>
); );
} }
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = { const colorMap: Record<string, string> = {
DRAFT: "bg-amber-100 text-amber-700", DRAFT: "amber",
CONFIRMED: "bg-primary/10 text-primary", CONFIRMED: "edr-green",
IN_TRANSIT: "bg-muted text-foreground", IN_TRANSIT: "blue",
DELIVERED: "bg-primary/10 text-primary", DELIVERED: "edr-green",
CANCELLED: "bg-destructive/10 text-destructive", CANCELLED: "red",
}; };
return ( return (
<span <Badge
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status] ?? "bg-muted text-muted-foreground"}`} variant="light"
color={colorMap[status] ?? "gray"}
radius="sm"
size="sm"
> >
{status.replace(/_/g, ' ')} {status.replace(/_/g, " ")}
</span> </Badge>
); );
} }

View File

@@ -3,14 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { AlertCircle, Check, ChevronLeft, ChevronRight } from "lucide-react";
AlertCircle, import { Alert, Box, Button, Text } from "@mantine/core";
Check,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from "lucide-react";
import { Button } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service"; import type { CreateBookingPayload } from "@/services/bookings.service";
import { import {
@@ -210,27 +204,34 @@ export default function NewBookingPage() {
className="flex flex-col" className="flex flex-col"
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
<div className="sticky top-0 z-20"> {/* Step indicator — sticky */}
<div className="mx-auto max-w-4xl space-y-3 px-6 pt-4"> <Box className="sticky top-0 z-20 border-b border-gray-100 bg-white/90 backdrop-blur-sm">
<Box className="mx-auto max-w-4xl px-6 py-4">
<StepIndicator step={step} /> <StepIndicator step={step} />
</div> </Box>
</div> </Box>
<div className="flex-1"> {/* Step content */}
<div className="mx-auto max-w-4xl px-6 py-8"> <Box flex={1}>
<Box className="mx-auto max-w-4xl px-6 py-8">
{createMutation.isError && ( {createMutation.isError && (
<div className="mb-6 flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"> <Alert
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-500" /> color="red"
<div> icon={<AlertCircle size={16} />}
<p className="font-semibold">Failed to save draft</p> radius="md"
<p className="mt-1 text-red-600"> mb="lg"
{createMutation.error instanceof Error >
? createMutation.error.message <Text size="sm" fw={600}>
: "An unexpected error occurred. Please try again."} Failed to save draft
</p> </Text>
</div> <Text size="sm" mt={4} c="red.7">
</div> {createMutation.error instanceof Error
? createMutation.error.message
: "An unexpected error occurred. Please try again."}
</Text>
</Alert>
)} )}
{step === 1 && <Step1ContractType form={form} />} {step === 1 && <Step1ContractType form={form} />}
{step === 2 && <Step2ServiceType form={form} />} {step === 2 && <Step2ServiceType form={form} />}
{step === 3 && ( {step === 3 && (
@@ -251,43 +252,49 @@ export default function NewBookingPage() {
{step === 5 && ( {step === 5 && (
<Step8Review form={form} setStep={setStep} direction={direction} /> <Step8Review form={form} setStep={setStep} direction={direction} />
)} )}
</div> </Box>
</div> </Box>
<div className="sticky bottom-0 z-20 border-t border-border bg-background px-6 py-4"> {/* Navigation footer — sticky */}
<div className="mx-auto flex max-w-4xl items-center justify-between"> <Box className="sticky bottom-0 z-20 border-t border-gray-200 bg-white/90 px-6 py-4 backdrop-blur-sm">
<Box className="mx-auto flex max-w-4xl items-center justify-between">
<Button <Button
type="button" type="button"
variant="outline" variant="default"
onClick={() => radius="md"
setStep((currentStep) => Math.max(1, currentStep - 1)) leftSection={<ChevronLeft size={16} />}
} onClick={() => setStep((s) => Math.max(1, s - 1))}
disabled={step === 1} disabled={step === 1}
> >
<ChevronLeft className="mr-1 h-4 w-4" />
Back Back
</Button> </Button>
{step < STEPS.length ? ( {step < STEPS.length ? (
<Button type="button" onClick={handleContinue}> <Button
type="button"
color="edr-green"
radius="md"
rightSection={<ChevronRight size={16} />}
onClick={handleContinue}
>
Continue Continue
<ChevronRight />
</Button> </Button>
) : ( ) : (
<Button <Button
type="submit" type="submit"
form="new-booking-form" form="new-booking-form"
disabled={createMutation.isPending} color="edr-green"
radius="md"
loading={createMutation.isPending}
leftSection={
createMutation.isPending ? undefined : <Check size={16} />
}
> >
{createMutation.isPending ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Check />
)}
{createMutation.isPending ? "Saving Draft..." : "Save as Draft"} {createMutation.isPending ? "Saving Draft..." : "Save as Draft"}
</Button> </Button>
)} )}
</div> </Box>
</div> </Box>
</form> </form>
); );
} }

View File

@@ -9,19 +9,19 @@ export function StepIndicator({ step }: { step: number }) {
<Fragment key={item.id}> <Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1"> <div className="flex shrink-0 flex-col items-center gap-1">
<div <div
className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-colors ${ className={`flex h-7 w-7 items-center justify-center rounded-full text-xs font-semibold transition-all duration-200 ${
step > item.id step > item.id
? "bg-primary text-primary-foreground" ? "bg-emerald-600 text-white shadow-sm shadow-emerald-600/40"
: step === item.id : step === item.id
? "border-2 border-primary text-primary" ? "border-2 border-emerald-500 text-emerald-600 shadow-sm shadow-emerald-500/30"
: "bg-muted text-muted-foreground" : "bg-gray-100 text-gray-400"
}`} }`}
> >
{step > item.id ? <Check className="h-3.5 w-3.5" /> : item.id} {step > item.id ? <Check className="h-3.5 w-3.5" /> : item.id}
</div> </div>
<span <span
className={`hidden text-[10px] font-medium lg:block ${ className={`hidden text-[10px] font-medium lg:block transition-colors ${
step >= item.id ? "text-foreground" : "text-muted-foreground" step >= item.id ? "text-gray-800" : "text-gray-400"
}`} }`}
> >
{item.short} {item.short}
@@ -29,8 +29,8 @@ export function StepIndicator({ step }: { step: number }) {
</div> </div>
{index < STEPS.length - 1 && ( {index < STEPS.length - 1 && (
<div <div
className={`mx-1 h-0.5 flex-1 rounded-full transition-colors ${ className={`mx-1 h-0.5 flex-1 rounded-full transition-all duration-300 ${
step > item.id ? "bg-primary" : "bg-border" step > item.id ? "bg-emerald-500" : "bg-gray-200"
}`} }`}
/> />
)} )}

View File

@@ -1,31 +1,16 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import type { import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
ControllerRenderProps, import { AlertTriangle, Check, CheckCircle2, Info, XCircle } from "lucide-react";
FieldError as RhfFieldError, import { Alert, Select, Text, Title } from "@mantine/core";
} from "react-hook-form"; import type { BookingFormInputValues } from "./schema";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
XCircle,
} from "lucide-react";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@edr/ui-common";
import type { BookingFormInputValues, BookingFormValues } from "./schema";
import { cn } from "@/lib/utils";
export function OptionFieldError({ error }: { error?: { message?: string } }) { export function OptionFieldError({ error }: { error?: { message?: string } }) {
return <FieldError errors={[error]} />; if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
{error.message}
</Text>
);
} }
export function OptionCard({ export function OptionCard({
@@ -44,16 +29,17 @@ export function OptionCard({
type="button" type="button"
onClick={onClick} onClick={onClick}
disabled={disabled} disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition ${disabled className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
? "cursor-not-allowed border-border bg-muted opacity-60" disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
: selected : selected
? "border-primary bg-primary/5" ? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-border bg-card hover:border-primary/40" : "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`} }`}
> >
{selected && !disabled && ( {selected && !disabled && (
<span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-primary"> <span className="absolute right-3 top-3 flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500">
<Check className="h-3 w-3 text-primary-foreground" /> <Check className="h-3 w-3 text-white" />
</span> </span>
)} )}
{children} {children}
@@ -68,34 +54,25 @@ export function AlertBox({
tone: "warning" | "error" | "success" | "info"; tone: "warning" | "error" | "success" | "info";
children: ReactNode; children: ReactNode;
}) { }) {
const styles = { const map: Record<string, { color: string; icon: ReactNode }> = {
warning: "bg-amber-50 border-amber-200 text-amber-800", warning: { color: "yellow", icon: <AlertTriangle size={16} /> },
error: "bg-red-50 border-red-200 text-red-800", error: { color: "red", icon: <XCircle size={16} /> },
success: "bg-emerald-50 border-emerald-200 text-emerald-800", success: { color: "teal", icon: <CheckCircle2 size={16} /> },
info: "bg-sky-50 border-sky-200 text-sky-800", info: { color: "blue", icon: <Info size={16} /> },
}; };
const icons = { const { color, icon } = map[tone];
warning: <AlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />,
error: <XCircle className="h-4 w-4 shrink-0 text-red-500" />,
success: <CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-600" />,
info: <Info className="h-4 w-4 shrink-0 text-sky-500" />,
};
return ( return (
<div <Alert color={color} icon={icon} radius="md" fz="sm">
className={`flex items-start gap-3 rounded-xl border p-3 text-sm ${styles[tone]}`} {children}
> </Alert>
{icons[tone]}
<div>{children}</div>
</div>
); );
} }
export function StepLabel({ children }: { children: ReactNode }) { export function StepLabel({ children }: { children: ReactNode }) {
return ( return (
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> <Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
{children} {children}
</p> </Text>
); );
} }
@@ -108,8 +85,12 @@ export function StepHeader({
}) { }) {
return ( return (
<div> <div>
<h2 className="text-xl font-bold tracking-tight">{title}</h2> <Title order={3} className="tracking-tight">
<p className="mt-1 text-sm text-muted-foreground">{description}</p> {title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div> </div>
); );
} }
@@ -120,46 +101,27 @@ export function SelectField({
label, label,
placeholder, placeholder,
disabled, disabled,
children, data,
}: { }: {
field: ControllerRenderProps<BookingFormInputValues>; field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError; error?: RhfFieldError;
label: string; label: string;
placeholder: string; placeholder: string;
disabled?: boolean; disabled?: boolean;
children: ReactNode; data: string[] | { value: string; label: string }[];
}) { }) {
return ( return (
<Field data-invalid={Boolean(error)}> <Select
<FieldLabel>{label}</FieldLabel> label={label}
<Select placeholder={placeholder}
value={String(field.value)} disabled={disabled}
onValueChange={field.onChange} data={data}
disabled={disabled} value={String(field.value) || null}
> onChange={(v) => field.onChange(v ?? "")}
<SelectTrigger onBlur={field.onBlur}
className={cn("w-full ", error ? "border-destructive!" : "")} error={error?.message}
aria-invalid={Boolean(error)} radius="md"
> allowDeselect={false}
<SelectValue placeholder={placeholder} /> />
</SelectTrigger>
<SelectContent>{children}</SelectContent>
</Select>
<FieldError errors={[error]} />
</Field>
);
}
export { SelectItem };
export function SelectOptions({ options }: { options: readonly string[] }) {
return (
<>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</>
); );
} }

View File

@@ -1,6 +1,5 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, RefreshCw } from "lucide-react"; import { FileText, RefreshCw } from "lucide-react";
import { Field } from "@edr/ui-common";
import { import {
BookingFormInputValues, BookingFormInputValues,
MOCK_VALID_CONTRACTS, MOCK_VALID_CONTRACTS,
@@ -11,15 +10,10 @@ import {
OptionCard, OptionCard,
OptionFieldError, OptionFieldError,
SelectField, SelectField,
SelectItem,
StepHeader, StepHeader,
} from "./shared"; } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step1ContractType({ form }: { form: BookingForm }) { export function Step1ContractType({ form }: { form: BookingForm }) {
const contractType = form.watch("contractType"); const contractType = form.watch("contractType");
@@ -36,7 +30,7 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
name="contractType" name="contractType"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<OptionCard <OptionCard
selected={field.value === "new"} selected={field.value === "new"}
@@ -46,11 +40,11 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
form.setValue("previousContractRef", ""); form.setValue("previousContractRef", "");
}} }}
> >
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<FileText className="h-4 w-4 text-primary" /> <FileText className="h-4 w-4 text-emerald-600" />
</div> </div>
<p className="font-semibold">New Contract</p> <p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-gray-500">
Create a new contract. Create a new contract.
</p> </p>
</OptionCard> </OptionCard>
@@ -66,14 +60,14 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
<RefreshCw className="h-4 w-4 text-sky-600" /> <RefreshCw className="h-4 w-4 text-sky-600" />
</div> </div>
<p className="font-semibold">Contract Renewal</p> <p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical Select a previous reference to auto-populate historical
parameters. parameters.
</p> </p>
</OptionCard> </OptionCard>
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</Field> </div>
)} )}
/> />
@@ -88,13 +82,8 @@ export function Step1ContractType({ form }: { form: BookingForm }) {
error={fieldState.error} error={fieldState.error}
label="Previous Contract Reference Number" label="Previous Contract Reference Number"
placeholder="Select a contract..." placeholder="Select a contract..."
> data={MOCK_VALID_CONTRACTS}
{MOCK_VALID_CONTRACTS.map((ref) => ( />
<SelectItem key={ref} value={ref}>
{ref}
</SelectItem>
))}
</SelectField>
)} )}
/> />
{previousContractRef && ( {previousContractRef && (

View File

@@ -1,15 +1,11 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { FileText, Package, Train, Truck } from "lucide-react"; import { FileText, Package, Train, Truck } from "lucide-react";
import { Badge, Field, FieldError, Input, Switch } from "@edr/ui-common"; import { Badge, Switch, TextInput } from "@mantine/core";
import { BookingFormInputValues, type BookingFormValues } from "./schema"; import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared"; import { OptionCard, OptionFieldError, StepHeader } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step2ServiceType({ form }: { form: BookingForm }) { export function Step2ServiceType({ form }: { form: BookingForm }) {
const serviceType = form.watch("serviceType"); const serviceType = form.watch("serviceType");
@@ -35,34 +31,18 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
{ enabled: false, deliveryAddress: "" }, { enabled: false, deliveryAddress: "" },
{ shouldDirty: true, shouldValidate: true }, { shouldDirty: true, shouldValidate: true },
); );
form.setValue("equipmentReturn", "with_return", { form.setValue("equipmentReturn", "with_return", { shouldDirty: true });
shouldDirty: true, form.setValue("customsClearingEnabled", false, { shouldDirty: true });
});
form.setValue("customsClearingEnabled", false, {
shouldDirty: true,
});
} else if (serviceType === "rail_forwarding") { } else if (serviceType === "rail_forwarding") {
form.setValue( form.setValue(
"firstMile", "firstMile",
{ { enabled: false, pickUpAddress: "" },
enabled: false, { shouldDirty: false, shouldValidate: false },
pickUpAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
); );
form.setValue( form.setValue(
"lastMile", "lastMile",
{ { enabled: false, deliveryAddress: "" },
enabled: false, { shouldDirty: false, shouldValidate: false },
deliveryAddress: "",
},
{
shouldDirty: false,
shouldValidate: false,
},
); );
} }
}, [serviceType, form]); }, [serviceType, form]);
@@ -80,7 +60,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="serviceType" name="serviceType"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <div>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-3 md:grid-cols-2">
<OptionCard <OptionCard
selected={serviceType === "rail"} selected={serviceType === "rail"}
@@ -90,11 +70,11 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
<Train className="h-4 w-4 text-indigo-600" /> <Train className="h-4 w-4 text-indigo-600" />
</div> </div>
<p className="font-semibold">Rail Transport Only</p> <p className="font-semibold">Rail Transport Only</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-gray-500">
Rail transport along the EDR corridor, with optional Rail transport along the EDR corridor, with optional
first/last mile trucking. first/last mile trucking.
</p> </p>
<Badge className="mt-2 bg-indigo-100 text-indigo-700 hover:bg-indigo-100"> <Badge color="indigo" variant="light" mt="xs" size="sm">
Option A Option A
</Badge> </Badge>
</OptionCard> </OptionCard>
@@ -103,26 +83,27 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
selected={serviceType === "rail_forwarding"} selected={serviceType === "rail_forwarding"}
onClick={() => field.onChange("rail_forwarding")} onClick={() => field.onChange("rail_forwarding")}
> >
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-primary" /> <Package className="h-4 w-4 text-emerald-600" />
</div> </div>
<p className="font-semibold">Logistics</p> <p className="font-semibold">Logistics</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-gray-500">
Rail transport plus documentation, customs liaison, and a Rail transport plus documentation, customs liaison, and a
dedicated coordinator. dedicated coordinator.
</p> </p>
<Badge className="mt-2 bg-emerald-100 text-emerald-700 hover:bg-emerald-100"> <Badge color="edr-green" variant="light" mt="xs" size="sm">
Option B Option B
</Badge> </Badge>
</OptionCard> </OptionCard>
</div> </div>
<OptionFieldError error={fieldState.error} /> <OptionFieldError error={fieldState.error} />
</Field> </div>
)} )}
/> />
{showServiceSections && ( {showServiceSections && (
<div className="divide-y divide-border rounded-xl border border-border"> <div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
{/* First Mile */}
<div className="p-4"> <div className="p-4">
<Controller <Controller
name="firstMile.enabled" name="firstMile.enabled"
@@ -130,12 +111,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => ( render={({ field }) => (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> <Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div> <div>
<p className="text-sm font-medium"> <p className="text-sm font-medium">First Mile Pick-up</p>
First Mile - Pick-up <p className="mt-0.5 text-xs text-gray-500">
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck pick-up from your premises (Door to Port) to the Truck pick-up from your premises (Door to Port) to the
origin rail yard. origin rail yard.
</p> </p>
@@ -143,7 +122,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div> </div>
<Switch <Switch
checked={field.value} checked={field.value}
onCheckedChange={(value) => { onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value); field.onChange(value);
if (!value) { if (!value) {
form.setValue("firstMile.pickUpAddress", "", { form.setValue("firstMile.pickUpAddress", "", {
@@ -152,6 +132,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
}); });
} }
}} }}
color="edr-green"
/> />
</div> </div>
)} )}
@@ -161,19 +142,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="firstMile.pickUpAddress" name="firstMile.pickUpAddress"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}> <TextInput
<Input {...field}
{...field} mt="sm"
aria-invalid={fieldState.invalid} placeholder="Pick-up address *"
placeholder="Pick-up address *" error={fieldState.error?.message}
/> radius="md"
<FieldError errors={[fieldState.error]} /> />
</Field>
)} )}
/> />
)} )}
</div> </div>
{/* Last Mile */}
<div className="p-4"> <div className="p-4">
<Controller <Controller
name="lastMile.enabled" name="lastMile.enabled"
@@ -181,12 +162,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => ( render={({ field }) => (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> <Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div> <div>
<p className="text-sm font-medium"> <p className="text-sm font-medium">Last Mile Delivery</p>
Last Mile - Delivery <p className="mt-0.5 text-xs text-gray-500">
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
Truck delivery from the destination rail yard to the Truck delivery from the destination rail yard to the
final address (Port to Door). final address (Port to Door).
</p> </p>
@@ -194,7 +173,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div> </div>
<Switch <Switch
checked={field.value} checked={field.value}
onCheckedChange={(value) => { onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value); field.onChange(value);
if (!value) { if (!value) {
form.setValue("lastMile.deliveryAddress", "", { form.setValue("lastMile.deliveryAddress", "", {
@@ -206,6 +186,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
}); });
} }
}} }}
color="edr-green"
/> />
</div> </div>
)} )}
@@ -215,19 +196,19 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
name="lastMile.deliveryAddress" name="lastMile.deliveryAddress"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field className="mt-3" data-invalid={fieldState.invalid}> <TextInput
<Input {...field}
{...field} mt="sm"
aria-invalid={fieldState.invalid} placeholder="Delivery address *"
placeholder="Delivery address *" error={fieldState.error?.message}
/> radius="md"
<FieldError errors={[fieldState.error]} /> />
</Field>
)} )}
/> />
)} )}
</div> </div>
{/* Equipment Return */}
{lastMileEnabled && ( {lastMileEnabled && (
<div className="p-4"> <div className="p-4">
<Controller <Controller
@@ -235,23 +216,22 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3"> <div>
<div> <p className="text-sm font-medium">Equipment Return</p>
<p className="text-sm font-medium">Equipment Return</p> <p className="mt-0.5 text-xs text-gray-500">
<p className="mt-0.5 text-xs text-muted-foreground"> {field.value === "with_return"
{field.value === "with_return" ? "Container returned to EDR after unloading."
? "Container returned to EDR after unloading." : "Container retained by the customer after delivery."}
: "Container retained by the customer after delivery."} </p>
</p>
</div>
</div> </div>
<Switch <Switch
checked={field.value === "with_return"} checked={field.value === "with_return"}
onCheckedChange={(value) => { onChange={(e) => {
field.onChange( field.onChange(
value ? "with_return" : "without_return", e.currentTarget.checked ? "with_return" : "without_return",
); );
}} }}
color="edr-green"
/> />
</div> </div>
)} )}
@@ -259,6 +239,7 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div> </div>
)} )}
{/* Customs Clearing */}
<div className="p-4"> <div className="p-4">
<Controller <Controller
name="customsClearingEnabled" name="customsClearingEnabled"
@@ -266,12 +247,10 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
render={({ field }) => ( render={({ field }) => (
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> <FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div> <div>
<p className="text-sm font-medium"> <p className="text-sm font-medium">Customs Clearing Service</p>
Customs Clearing Service <p className="mt-0.5 text-xs text-gray-500">
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
EDR handles customs documentation and clearance on your EDR handles customs documentation and clearance on your
behalf. behalf.
</p> </p>
@@ -279,7 +258,8 @@ export function Step2ServiceType({ form }: { form: BookingForm }) {
</div> </div>
<Switch <Switch
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/> />
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react"; import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Skeleton, Switch } from "@edr/ui-common"; import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { import {
BookingFormInputValues, BookingFormInputValues,
@@ -10,11 +10,7 @@ import {
} from "./schema"; } from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared"; import { SelectField, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step4Route({ export function Step4Route({
form, form,
@@ -30,26 +26,29 @@ export function Step4Route({
const yardOptions = useMemo(() => { const yardOptions = useMemo(() => {
if (!referenceData?.yard) return []; if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ return referenceData.yard.map((y) => ({ value: y.name, label: y.name }));
value: y.name,
label: y.name,
country: y.country,
}));
}, [referenceData]); }, [referenceData]);
const shippingLineOptions = useMemo(() => { const shippingLineOptions = useMemo(() => {
if (!referenceData?.shipping_line) return []; if (!referenceData?.shipping_line) return [];
return referenceData.shipping_line.map((sl) => ({ return referenceData.shipping_line.map((sl) => ({ value: sl.name, label: sl.name }));
value: sl.name,
label: sl.name,
}));
}, [referenceData]); }, [referenceData]);
const originData = useMemo(
() => yardOptions.filter((o) => o.value !== destinationYard),
[yardOptions, destinationYard],
);
const destData = useMemo(
() => yardOptions.filter((o) => o.value !== originYard),
[yardOptions, originYard],
);
const direction = getRouteDirection(originYard, destinationYard); const direction = getRouteDirection(originYard, destinationYard);
const directionStyle: Record<string, string> = { const directionStyle: Record<string, string> = {
export: "bg-sky-50 text-sky-800 border-sky-200", export: "bg-sky-50 text-sky-800 border-sky-200",
import: "bg-amber-50 text-amber-800 border-amber-200", import: "bg-amber-50 text-amber-800 border-amber-200",
domestic: "bg-muted text-muted-foreground border-border", domestic: "bg-gray-100 text-gray-600 border-gray-200",
}; };
const directionLabel: Record<string, string> = { const directionLabel: Record<string, string> = {
export: "Export workflow (inside country to outside country)", export: "Export workflow (inside country to outside country)",
@@ -85,15 +84,11 @@ export function Step4Route({
<SelectField <SelectField
field={field} field={field}
error={fieldState.error} error={fieldState.error}
label="Origin Yard*" label="Origin Yard *"
placeholder="Select origin..." placeholder="Select origin..."
disabled={stationSelectDisabled} disabled={stationSelectDisabled}
> data={originData}
<YardSelectOptions />
options={yardOptions}
excludeValue={destinationYard}
/>
</SelectField>
)} )}
/> />
<Controller <Controller
@@ -106,12 +101,8 @@ export function Step4Route({
label="Destination Yard *" label="Destination Yard *"
placeholder="Select destination..." placeholder="Select destination..."
disabled={stationSelectDisabled} disabled={stationSelectDisabled}
> data={destData}
<YardSelectOptions />
options={yardOptions}
excludeValue={originYard}
/>
</SelectField>
)} )}
/> />
</div> </div>
@@ -126,7 +117,7 @@ export function Step4Route({
</div> </div>
)} )}
{direction && direction != "domestic" && ( {direction && direction !== "domestic" && (
<Controller <Controller
name="shippingLine" name="shippingLine"
control={form.control} control={form.control}
@@ -136,19 +127,15 @@ export function Step4Route({
error={fieldState.error} error={fieldState.error}
label="Shipping Line" label="Shipping Line"
placeholder="Select shipping line..." placeholder="Select shipping line..."
> data={shippingLineOptions}
{shippingLineOptions.map((sl) => ( />
<SelectItem key={sl.value} value={sl.value}>
{sl.label}
</SelectItem>
))}
</SelectField>
)} )}
/> />
)} )}
<Separator />
<div className="space-y-0 divide-y divide-border"> <Divider />
<div className="divide-y divide-gray-200">
<Controller <Controller
name="isHazardous" name="isHazardous"
control={form.control} control={form.control}
@@ -158,12 +145,16 @@ export function Step4Route({
<Flame className="h-4 w-4 shrink-0 text-red-500" /> <Flame className="h-4 w-4 shrink-0 text-red-500" />
<div> <div>
<p className="text-sm font-medium">Hazardous Material</p> <p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill. Applies a Hazard Surcharge to the final bill.
</p> </p>
</div> </div>
</div> </div>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div> </div>
)} )}
/> />
@@ -176,13 +167,17 @@ export function Step4Route({
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" /> <Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div> <div>
<p className="text-sm font-medium">Refrigerated Cargo</p> <p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator Temperature-controlled transport applies a Refrigerator
Surcharge. Surcharge.
</p> </p>
</div> </div>
</div> </div>
<Switch checked={field.value} onCheckedChange={field.onChange} /> <Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div> </div>
)} )}
/> />
@@ -193,48 +188,18 @@ export function Step4Route({
function LoadingSkeleton() { function LoadingSkeleton() {
return ( return (
<div className="space-y-4 rounded-xl border border-border p-4"> <div className="space-y-4 rounded-xl border border-gray-200 p-4">
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2"> <Stack gap={8}>
<Skeleton className="h-3 w-20" /> <Skeleton height={12} w={80} radius="sm" />
<Skeleton className="h-10 w-full" /> <Skeleton height={40} radius="md" />
</div> </Stack>
<div className="space-y-2"> <Stack gap={8}>
<Skeleton className="h-3 w-24" /> <Skeleton height={12} w={96} radius="sm" />
<Skeleton className="h-10 w-full" /> <Skeleton height={40} radius="md" />
</div> </Stack>
</div> </div>
<Skeleton className="h-8 w-full" /> <Skeleton height={32} radius="md" />
</div> </div>
); );
} }
function YardSelectOptions({
options,
excludeValue,
}: {
options: Array<{ value: string; label: string; country: string }>;
excludeValue: string;
}) {
if (options.length === 0) {
return (
<SelectItem value="__yards_empty" disabled>
No yards available
</SelectItem>
);
}
const availableOptions = options.filter(
(option) => option.value !== excludeValue,
);
return (
<>
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</>
);
}

View File

@@ -1,14 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form"; import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react"; import { MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import { import { ActionIcon, Button, Skeleton, Stack, Text, TextInput } from "@mantine/core";
Button,
Field,
FieldError,
FieldLabel,
Input,
Skeleton,
} from "@edr/ui-common";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { import {
BookingFormInputValues, BookingFormInputValues,
@@ -19,17 +12,13 @@ import {
import { import {
AlertBox, AlertBox,
OptionCard, OptionCard,
OptionFieldError,
SelectField, SelectField,
SelectItem,
StepHeader, StepHeader,
StepLabel, StepLabel,
} from "./shared"; } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step5CargoDetails({ export function Step5CargoDetails({
form, form,
@@ -44,7 +33,6 @@ export function Step5CargoDetails({
}) { }) {
const cargoType = form.watch("cargoType"); const cargoType = form.watch("cargoType");
const freightType = form.watch("freightType"); const freightType = form.watch("freightType");
const bulkCommoditytype = form.watch("bulkCommoditytype");
const containers = form.watch("containers"); const containers = form.watch("containers");
const { fields, append, remove } = useFieldArray({ const { fields, append, remove } = useFieldArray({
@@ -61,9 +49,7 @@ export function Step5CargoDetails({
const freightTypeGroups = useMemo(() => { const freightTypeGroups = useMemo(() => {
if (!referenceData?.cargo_type) return []; if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.filter( return referenceData.cargo_type.filter((g) => g.code !== "CONTAINER");
(g) => g.code !== "CONTAINER",
);
}, [referenceData]); }, [referenceData]);
const commodityOptions = useMemo(() => { const commodityOptions = useMemo(() => {
@@ -97,14 +83,14 @@ export function Step5CargoDetails({
title="Cargo Details" title="Cargo Details"
description="Define your cargo type, weight, and container configuration." description="Define your cargo type, weight, and container configuration."
/> />
<div className="space-y-4 rounded-xl border border-border p-4"> <div className="space-y-4 rounded-xl border border-gray-200 p-4">
<Skeleton className="h-4 w-24" /> <Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-24 w-full" /> <Skeleton height={96} radius="xl" />
<Skeleton className="h-24 w-full" /> <Skeleton height={96} radius="xl" />
</div> </div>
<Skeleton className="h-10 w-full" /> <Skeleton height={40} radius="md" />
<Skeleton className="h-10 w-1/3" /> <Skeleton height={40} w="33%" radius="md" />
</div> </div>
</div> </div>
); );
@@ -117,28 +103,27 @@ export function Step5CargoDetails({
description="Define your cargo type, weight, and container configuration." description="Define your cargo type, weight, and container configuration."
/> />
{/* Cargo Type */}
<div className="space-y-3"> <div className="space-y-3">
<StepLabel>Cargo Type *</StepLabel> <StepLabel>Cargo Type *</StepLabel>
<Controller <Controller
name="cargoType" name="cargoType"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<OptionCard <OptionCard
selected={cargoType === "container"} selected={cargoType === "container"}
onClick={() => { onClick={() => {
field.onChange("container"); field.onChange("container");
form.setValue("freightType", "", { form.setValue("freightType", "", { shouldDirty: true });
shouldDirty: true,
});
}} }}
> >
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10"> <div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-100">
<Package className="h-4 w-4 text-primary" /> <Package className="h-4 w-4 text-emerald-600" />
</div> </div>
<p className="font-semibold">Containerized</p> <p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft). Pre-packed containerized cargo (20ft / 40ft).
</p> </p>
</OptionCard> </OptionCard>
@@ -153,45 +138,41 @@ export function Step5CargoDetails({
<Weight className="h-4 w-4 text-amber-600" /> <Weight className="h-4 w-4 text-amber-600" />
</div> </div>
<p className="font-semibold">General Cargo</p> <p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo. Bulk commodities or break-bulk cargo.
</p> </p>
</OptionCard> </OptionCard>
</div> </div>
<FieldError errors={[fieldState.error]} /> <OptionFieldError error={fieldState.error} />
</Field> </div>
)} )}
/> />
</div> </div>
{/* Weight */}
<div className="space-y-3"> <div className="space-y-3">
<StepLabel>Weight</StepLabel> <StepLabel>Weight</StepLabel>
<Controller <Controller
name="cargoWeight" name="cargoWeight"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <TextInput
<FieldLabel htmlFor="cargoWeight"> {...field}
Total Cargo Weight(Tons)* id="cargoWeight"
</FieldLabel> type="number"
<div className="relative"> label="Total Cargo Weight (Tons) *"
<Weight className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" /> placeholder="0.00"
<Input leftSection={<Weight className="h-4 w-4" />}
{...field} error={fieldState.error?.message}
id="cargoWeight" radius="md"
type="number" min={0}
aria-invalid={fieldState.invalid} step={0.01}
placeholder="0.00" />
className="pl-9"
min="0"
step="0.01"
/>
</div>
<FieldError errors={[fieldState.error]} />
</Field>
)} )}
/> />
</div> </div>
{/* Bulk freight type */}
{cargoType === "bulk" && ( {cargoType === "bulk" && (
<div className="space-y-3"> <div className="space-y-3">
<StepLabel>Freight Type *</StepLabel> <StepLabel>Freight Type *</StepLabel>
@@ -199,7 +180,7 @@ export function Step5CargoDetails({
name="freightType" name="freightType"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <div>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
{freightTypeGroups.map((group) => { {freightTypeGroups.map((group) => {
const val = group.code.toLowerCase(); const val = group.code.toLowerCase();
@@ -219,36 +200,30 @@ export function Step5CargoDetails({
); );
})} })}
</div> </div>
<FieldError errors={[fieldState.error]} /> <OptionFieldError error={fieldState.error} />
</Field> </div>
)} )}
/> />
{freightType && commodityOptions.length > 0 && ( {freightType && commodityOptions.length > 0 && (
<div className="space-y-2"> <Controller
<Controller name="bulkCommoditytype"
name="bulkCommoditytype" control={form.control}
control={form.control} render={({ field, fieldState }) => (
render={({ field, fieldState }) => ( <SelectField
<SelectField field={field}
field={field} error={fieldState.error}
error={fieldState.error} label="Cargo type *"
label="Cargo type *" placeholder="Select type *"
placeholder="Select type *" data={commodityOptions}
> />
{commodityOptions.map((option) => ( )}
<SelectItem key={option} value={option}> />
{option}
</SelectItem>
))}
</SelectField>
)}
/>
</div>
)} )}
</div> </div>
)} )}
{/* Container list */}
{cargoType === "container" && ( {cargoType === "container" && (
<> <>
<div className="space-y-4"> <div className="space-y-4">
@@ -256,18 +231,14 @@ export function Step5CargoDetails({
<StepLabel>Containers</StepLabel> <StepLabel>Containers</StepLabel>
<Button <Button
type="button" type="button"
variant="outline" variant="default"
size="sm" size="sm"
radius="md"
leftSection={<Plus size={14} />}
onClick={() => onClick={() =>
append({ append({ type: "20ft", containerType: "", qty: "1", vgm: "" })
type: "20ft",
containerType: "",
qty: "1",
vgm: "",
})
} }
> >
<Plus className="mr-1 h-3.5 w-3.5" />
Add Container Add Container
</Button> </Button>
</div> </div>
@@ -280,25 +251,31 @@ export function Step5CargoDetails({
return ( return (
<div <div
key={field.id} key={field.id}
className="rounded-xl flex flex-col border border-border p-3 gap-2" className="flex flex-col gap-3 rounded-xl border border-gray-200 bg-gray-50/50 p-4"
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Text size="xs" fw={600} c="dimmed" tt="uppercase" className="tracking-wide">
Container {index + 1}
</Text>
{fields.length > 1 && ( {fields.length > 1 && (
<button <ActionIcon
type="button" color="red"
variant="subtle"
size="sm"
onClick={() => remove(index)} onClick={() => remove(index)}
className="text-xs text-destructive hover:underline" aria-label="Remove container"
> >
<Trash2 className="h-3.5 w-3.5" /> <Trash2 size={15} />
</button> </ActionIcon>
)} )}
</div> </div>
{/* Container size */}
<Controller <Controller
name={`containers.${index}.type`} name={`containers.${index}.type`}
control={form.control} control={form.control}
render={({ field: typeField, fieldState }) => ( render={({ field: typeField, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <div>
<div className="grid gap-2 sm:grid-cols-2"> <div className="grid gap-2 sm:grid-cols-2">
{[ {[
{ {
@@ -321,52 +298,47 @@ export function Step5CargoDetails({
onClick={() => typeField.onChange(ct.val)} onClick={() => typeField.onChange(ct.val)}
> >
<div className="mb-1 flex items-center gap-2"> <div className="mb-1 flex items-center gap-2">
<Package className="h-4 w-4 text-primary" /> <Package className="h-4 w-4 text-emerald-600" />
<p className="font-semibold">{ct.label}</p> <p className="font-semibold">{ct.label}</p>
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-gray-500">{ct.limit}</p>
{ct.limit}
</p>
</OptionCard> </OptionCard>
))} ))}
</div> </div>
<FieldError errors={[fieldState.error]} /> <OptionFieldError error={fieldState.error} />
</Field> </div>
)} )}
/> />
{/* Qty + VGM + Type */}
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 sm:grid-cols-3">
<Controller <Controller
name={`containers.${index}.qty`} name={`containers.${index}.qty`}
control={form.control} control={form.control}
render={({ field: qtyField, fieldState }) => ( render={({ field: qtyField, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <div>
<FieldLabel>Quantity *</FieldLabel> <Text size="sm" fw={500} mb={4}>
Quantity *
</Text>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => onClick={() =>
qtyField.onChange( qtyField.onChange(
Math.max( Math.max(1, Number(qtyField.value ?? 1) - 1).toString(),
1,
Number(qtyField.value ?? 1) - 1,
).toString(),
) )
} }
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
> >
-
</button> </button>
<Input <input
value={qtyField.value ?? 1} value={qtyField.value ?? 1}
onChange={(e) => onChange={(e) => qtyField.onChange(e.target.value)}
qtyField.onChange(e.target.value)
}
onBlur={qtyField.onBlur} onBlur={qtyField.onBlur}
type="number" type="number"
aria-invalid={fieldState.invalid} min={1}
className="text-center" className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
min="1"
/> />
<button <button
type="button" type="button"
@@ -375,13 +347,17 @@ export function Step5CargoDetails({
(Number(qtyField.value ?? 1) + 1).toString(), (Number(qtyField.value ?? 1) + 1).toString(),
) )
} }
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-input transition hover:bg-accent" className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-white text-sm font-medium transition hover:bg-gray-50"
> >
+ +
</button> </button>
</div> </div>
<FieldError errors={[fieldState.error]} /> {fieldState.error?.message && (
</Field> <Text size="xs" c="red" mt={4}>
{fieldState.error.message}
</Text>
)}
</div>
)} )}
/> />
@@ -389,20 +365,18 @@ export function Step5CargoDetails({
name={`containers.${index}.vgm`} name={`containers.${index}.vgm`}
control={form.control} control={form.control}
render={({ field: vgmField, fieldState }) => ( render={({ field: vgmField, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <TextInput
<FieldLabel>Tons*</FieldLabel> value={vgmField.value ?? 0}
<Input onChange={(e) => vgmField.onChange(e.target.value)}
value={vgmField.value ?? 0} onBlur={vgmField.onBlur}
onChange={(e) => vgmField.onChange(e.target.value)} type="number"
onBlur={vgmField.onBlur} label="Tons *"
type="number" placeholder="e.g. 18.5"
aria-invalid={fieldState.invalid} error={fieldState.error?.message}
placeholder="e.g. 18.5" radius="md"
min="0" min={0}
step="0.1" step={0.1}
/> />
<FieldError errors={[fieldState.error]} />
</Field>
)} )}
/> />
@@ -415,13 +389,8 @@ export function Step5CargoDetails({
error={fieldState.error} error={fieldState.error}
label="Container Type *" label="Container Type *"
placeholder="Select type..." placeholder="Select type..."
> data={containerTypeOptions}
{containerTypeOptions.map((option) => ( />
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectField>
)} )}
/> />
</div> </div>
@@ -441,18 +410,13 @@ export function Step5CargoDetails({
if (result.hasOddUnit) { if (result.hasOddUnit) {
return ( return (
<AlertBox tone="warning"> <AlertBox tone="warning">
<div className="flex items-start gap-2"> <p className="font-semibold">Unpaired 20ft Container</p>
<div> <p className="mt-1 text-xs">
<p className="font-semibold">Unpaired 20ft Container</p> One 20ft container occupies only half a wagon. The wagon
<p className="mt-1 text-xs"> will depart once a co-loader is found to fill the remaining
One 20ft container occupies only half a wagon. The wagon slot, which <strong>may delay departure</strong> beyond the
will depart once a co-loader is found to fill the standard lead time.
remaining slot, which{" "} </p>
<strong>may delay departure</strong> beyond the standard
lead time.
</p>
</div>
</div>
</AlertBox> </AlertBox>
); );
} }

View File

@@ -1,15 +1,5 @@
import { Controller, type UseFormReturn } from "react-hook-form"; import { Controller, type UseFormReturn } from "react-hook-form";
import { Check } from "lucide-react"; import { Box, Card, Checkbox, SimpleGrid, Text, Textarea, Title } from "@mantine/core";
import {
Card,
CardContent,
CardHeader,
CardTitle,
Field,
FieldError,
FieldLabel,
Textarea,
} from "@edr/ui-common";
import { import {
BookingFormInputValues, BookingFormInputValues,
type BookingFormValues, type BookingFormValues,
@@ -17,11 +7,7 @@ import {
} from "./schema"; } from "./schema";
import { StepHeader } from "./shared"; import { StepHeader } from "./shared";
type BookingForm = UseFormReturn< type BookingForm = UseFormReturn<BookingFormInputValues, any, BookingFormValues>;
BookingFormInputValues,
any,
BookingFormValues
>;
export function Step8Review({ export function Step8Review({
form, form,
@@ -47,13 +33,17 @@ export function Step8Review({
return ( return (
<div className="flex items-start justify-between gap-4 py-2"> <div className="flex items-start justify-between gap-4 py-2">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p> <Text size="xs" c="dimmed">
<p className="mt-0.5 truncate text-sm font-medium">{value || "-"}</p> {label}
</Text>
<Text size="sm" fw={500} mt={2} className="truncate">
{value || "—"}
</Text>
</div> </div>
<button <button
type="button" type="button"
onClick={() => setStep(target)} onClick={() => setStep(target)}
className="shrink-0 text-xs font-medium text-primary hover:underline" className="shrink-0 text-xs font-medium text-emerald-600 hover:underline"
> >
Edit Edit
</button> </button>
@@ -64,26 +54,53 @@ export function Step8Review({
const containerSummary = const containerSummary =
values.cargoType === "container" && values.containers.length > 0 values.cargoType === "container" && values.containers.length > 0
? values.containers ? values.containers
.filter((c) => +c.qty > 0) .filter((c) => +c.qty > 0)
.map((c) => `${c.qty} × ${c.type}`) .map((c) => `${c.qty} × ${c.type}`)
.join(", ") .join(", ")
: ""; : "";
const totalVgm = const totalVgm =
values.cargoType === "container" values.cargoType === "container"
? values.containers.reduce( ? values.containers.reduce(
(sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0), (sum, c) => sum + (+c.qty || 0) * (+c.vgm || 0),
0, 0,
) )
: 0; : 0;
const cargoValue = const cargoValue =
values.cargoType === "container" values.cargoType === "container"
? containerSummary ? containerSummary
: values.freightType === "bulk" : values.freightType === "bulk"
? `Bulk - ${values.bulkCommodity === "Others" ? values.bulkCommodityOther : values.bulkCommodity}` ? `Bulk ${values.bulkCommoditytype}`
: values.freightType === "break_bulk" : values.freightType === "break_bulk"
? `Break-Bulk - ${values.breakBulkType === "Others" ? values.breakBulkTypeOther : values.breakBulkType}` ? `Break-Bulk`
: ""; : "";
function ReviewCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card radius="lg" withBorder p={0} className="overflow-hidden">
<Box
px="md"
py="sm"
className="border-b border-[var(--mantine-color-gray-2)] bg-gray-50/60"
>
<Text size="xs" fw={600} tt="uppercase" c="dimmed" className="tracking-wider">
{title}
</Text>
</Box>
<Box px="md" py="xs" className="divide-y divide-gray-100">
{children}
</Box>
</Card>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<StepHeader <StepHeader
@@ -91,153 +108,118 @@ export function Step8Review({
description="Confirm your contract request before sending it for EDR staff review." description="Confirm your contract request before sending it for EDR staff review."
/> />
<div className="grid gap-4 md:grid-cols-2"> <SimpleGrid cols={{ base: 1, md: 2 }} spacing="md">
<Card className="gap-0 overflow-hidden py-0"> <ReviewCard title="Contract & Service">
<CardHeader className="border-b px-5 py-3!"> <Row
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> label="Type"
Contract & Service value={values.contractType === "new" ? "New Contract" : "Renewal"}
</CardTitle> target={1}
</CardHeader> />
<CardContent className="divide-y divide-border px-5 pb-2 pt-0"> <Row
<Row label="Service"
label="Type" value={
value={values.contractType === "new" ? "New Contract" : "Renewal"} values.serviceType === "rail"
target={1} ? "Rail Only"
/> : values.serviceType === "rail_forwarding"
<Row ? "Rail + Forwarding"
label="Service"
value={
values.serviceType === "rail"
? "Rail Only"
: values.serviceType === "rail_forwarding"
? "Rail + Forwarding"
: ""
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
First & Last Mile
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="First Mile"
value={
values.firstMile.enabled
? values.firstMile.pickUpAddress
: "Not requested"
}
target={2}
/>
<Row
label="Last Mile"
value={
values.lastMile.enabled
? values.lastMile.deliveryAddress
: "Not requested"
}
target={2}
/>
<Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={
values.customsClearingEnabled ? "Enabled" : "Not requested"
}
target={2}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0">
<CardHeader className="border-b px-5 py-3!">
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Route & Cargo
</CardTitle>
</CardHeader>
<CardContent className="divide-y divide-border px-5 pb-2 pt-0">
<Row
label="Route"
value={`${values.originYard} -> ${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: "" : ""
} }
target={3} target={2}
/> />
<Row </ReviewCard>
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</CardContent>
</Card>
<Card className="gap-0 overflow-hidden py-0"> <ReviewCard title="First & Last Mile">
<CardHeader className="border-b px-5 py-3!"> <Row
<CardTitle className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> label="First Mile"
Container & Wagons value={
</CardTitle> values.firstMile.enabled
</CardHeader> ? values.firstMile.pickUpAddress
<CardContent className="divide-y divide-border px-5 pb-2 pt-0"> : "Not requested"
<Row }
label="Containers" target={2}
value={containerSummary || "-"} />
target={4} <Row
/> label="Last Mile"
<Row value={
label="Total VGM" values.lastMile.enabled
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""} ? values.lastMile.deliveryAddress
target={4} : "Not requested"
/> }
</CardContent> target={2}
</Card> />
</div> <Row
label="Equipment Return"
value={
values.equipmentReturn === "with_return"
? "With Return"
: "Without Return"
}
target={2}
/>
<Row
label="Customs Clearing"
value={values.customsClearingEnabled ? "Enabled" : "Not requested"}
target={2}
/>
</ReviewCard>
<ReviewCard title="Route & Cargo">
<Row
label="Route"
value={`${values.originYard}${values.destinationYard}`}
target={3}
/>
<Row
label="Workflow"
value={
direction
? direction.charAt(0).toUpperCase() + direction.slice(1)
: ""
}
target={3}
/>
<Row
label="Weight (VGM)"
value={values.cargoWeight ? `${values.cargoWeight} tons` : ""}
target={4}
/>
<Row label="Cargo" value={cargoValue} target={4} />
<Row
label="Modifiers"
value={
[
values.isHazardous && "Hazardous",
values.isRefrigerated && "Refrigerated",
]
.filter(Boolean)
.join(", ") || "None"
}
target={3}
/>
</ReviewCard>
<ReviewCard title="Container & Wagons">
<Row label="Containers" value={containerSummary || "—"} target={4} />
<Row
label="Total VGM"
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : ""}
target={4}
/>
</ReviewCard>
</SimpleGrid>
<Controller <Controller
name="notes" name="notes"
control={form.control} control={form.control}
render={({ field }) => ( render={({ field }) => (
<Field> <Textarea
<FieldLabel htmlFor="notes">Additional Notes</FieldLabel> {...field}
<Textarea id="notes"
{...field} label="Additional Notes"
id="notes" placeholder="Any special instructions or notes for EDR operations..."
placeholder="Any special instructions or notes for EDR operations..." rows={3}
rows={3} radius="md"
/> />
</Field>
)} )}
/> />
@@ -245,31 +227,24 @@ export function Step8Review({
name="termsAccepted" name="termsAccepted"
control={form.control} control={form.control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}> <Checkbox
<label className="flex cursor-pointer items-start gap-3"> label={
<button <Text size="sm" c="dimmed">
type="button"
role="checkbox"
aria-checked={field.value}
aria-invalid={fieldState.invalid}
onClick={() => field.onChange(!field.value)}
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border-2 transition ${field.value ? "border-primary bg-primary" : "border-input"
}`}
>
{field.value && (
<Check className="h-3 w-3 text-primary-foreground" />
)}
</button>
<span className="text-sm text-muted-foreground">
I confirm the information is accurate and agree to EDR's{" "} I confirm the information is accurate and agree to EDR's{" "}
<span className="text-primary"> <Text component="span" c="edr-green" fw={500}>
freight contract terms and conditions freight contract terms and conditions
</span> </Text>
. .
</span> </Text>
</label> }
<FieldError errors={[fieldState.error, errors.termsAccepted]} /> checked={field.value}
</Field> onChange={(e) => field.onChange(e.currentTarget.checked)}
error={
fieldState.error?.message ?? errors.termsAccepted?.message
}
color="edr-green"
radius="sm"
/>
)} )}
/> />
</div> </div>

View File

@@ -40,7 +40,7 @@ export const mantineTheme = createTheme({
black: "#1c2129", black: "#1c2129",
fontFamily: fontFamily:
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', '"Plus Jakarta Sans",ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
defaultRadius: "md", defaultRadius: "md",
@@ -84,8 +84,8 @@ export const mantineTheme = createTheme({
fontFamily: '"Plus Jakarta Sans", var(--mantine-font-family)', fontFamily: '"Plus Jakarta Sans", var(--mantine-font-family)',
fontWeight: "600", fontWeight: "600",
sizes: { sizes: {
h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "700" }, h1: { fontSize: "40px", lineHeight: "1.1", fontWeight: "800" },
h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "650" }, h2: { fontSize: "30px", lineHeight: "1.2", fontWeight: "700" },
h3: { fontSize: "23px", lineHeight: "1.3", fontWeight: "600" }, h3: { fontSize: "23px", lineHeight: "1.3", fontWeight: "600" },
h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" }, h4: { fontSize: "18px", lineHeight: "1.4", fontWeight: "600" },
h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" }, h5: { fontSize: "15px", lineHeight: "1.45", fontWeight: "600" },