enhance booking form UI with new components and improved layouts

This commit is contained in:
Marshal
2026-06-17 14:12:14 +00:00
parent c89f7dcc11
commit 9dc3bc0765
9 changed files with 816 additions and 467 deletions

View File

@@ -2,84 +2,88 @@ import { Check } from "lucide-react";
import { Fragment } from "react";
import { STEPS } from "./schema";
const GREEN = "var(--mantine-color-edr-green-5)";
const GREEN_DEEP = "var(--mantine-color-edr-green-7)";
const BORDER = "var(--mantine-color-edr-border-0)";
const MUTED = "var(--mantine-color-edr-muted-0)";
const INK = "var(--mantine-color-edr-text-0)";
export function StepIndicator({ step }: { step: number }) {
return (
<div className="flex items-center">
{STEPS.map((item, index) => (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-1">
<div
style={{
width: 28,
height: 28,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
flexShrink: 0,
transition: "all 0.2s",
...(step > item.id
? {
backgroundColor: "var(--mantine-color-edr-green-5)",
color: "#fff",
boxShadow: "0 2px 8px rgba(14,163,113,0.4)",
}
: step === item.id
<div className="flex items-start">
{STEPS.map((item, index) => {
const done = step > item.id;
const active = step === item.id;
return (
<Fragment key={item.id}>
<div className="flex shrink-0 flex-col items-center gap-2" style={{ minWidth: 34 }}>
<div
style={{
width: 34,
height: 34,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 13,
fontWeight: 700,
flexShrink: 0,
transition: "all 0.2s",
...(done
? {
border: "2.5px solid var(--mantine-color-edr-green-5)",
color: "var(--mantine-color-edr-green-7)",
backgroundColor: "#fff",
boxShadow: "0 0 0 3px rgba(14,163,113,0.12)",
background: "linear-gradient(135deg, #12B981, #0A8A5F)",
color: "#fff",
boxShadow: "0 4px 10px rgba(14,163,113,0.35)",
}
: {
backgroundColor: "#fff",
color: "var(--mantine-color-edr-muted-0)",
border: "2px solid var(--mantine-color-edr-border-0)",
}),
}}
>
{step > item.id ? (
<Check style={{ width: 13, height: 13 }} />
) : (
item.id
)}
: active
? {
border: `2.5px solid ${GREEN}`,
color: GREEN_DEEP,
backgroundColor: "#fff",
boxShadow: "0 0 0 4px rgba(14,163,113,0.12)",
}
: {
backgroundColor: "#fff",
color: MUTED,
border: `2px solid ${BORDER}`,
}),
}}
>
{done ? <Check style={{ width: 15, height: 15 }} strokeWidth={3} /> : item.id}
</div>
<span
style={{
fontSize: 11,
fontWeight: active ? 700 : 500,
textAlign: "center",
lineHeight: 1.2,
maxWidth: 72,
display: "none",
transition: "color 0.2s",
color: step >= item.id ? INK : MUTED,
}}
className="md:!block"
>
{item.short}
</span>
</div>
<span
style={{
fontSize: 10,
fontWeight: 500,
display: "none",
transition: "color 0.2s",
color:
step >= item.id
? "var(--mantine-color-edr-text-0)"
: "var(--mantine-color-edr-muted-0)",
}}
className="lg:!block"
>
{item.short}
</span>
</div>
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 2,
borderRadius: 999,
margin: "0 6px",
marginBottom: 14,
transition: "background-color 0.3s",
backgroundColor:
step > item.id
? "var(--mantine-color-edr-green-5)"
: "var(--mantine-color-edr-border-0)",
}}
/>
)}
</Fragment>
))}
{index < STEPS.length - 1 && (
<div
style={{
flex: 1,
height: 3,
borderRadius: 999,
margin: "16px 8px 0",
transition: "background 0.3s",
background: done
? "linear-gradient(90deg, #0A8A5F, #12B981)"
: BORDER,
}}
/>
)}
</Fragment>
);
})}
</div>
);
}

View File

@@ -1,48 +1,167 @@
import { Alert, Combobox, Input, InputBase, Select, Text, Title, useCombobox } from "@mantine/core";
import { AlertTriangle, Check, CheckCircle2, Info, Loader, XCircle } from "lucide-react";
import {
Alert,
Box,
Combobox,
Group,
Input,
InputBase,
Paper,
Select,
Text,
Title,
useCombobox,
} from "@mantine/core";
import {
AlertTriangle,
Check,
CheckCircle2,
Info,
Loader,
XCircle,
} from "lucide-react";
import type { ReactNode } from "react";
import { useMemo } from "react";
import type { ControllerRenderProps, FieldError as RhfFieldError } from "react-hook-form";
import type {
ControllerRenderProps,
FieldError as RhfFieldError,
} from "react-hook-form";
import type { BookingFormInputValues } from "./schema";
// Brand tokens (kept local so the form reads consistently with the booking
// detail page and the scheduling step).
const INK = "#10202F";
const MUTED = "#6B7C8E";
const GREEN = "#0EA371";
const GREEN_DARK = "#0A6F4D";
const BORDER = "#E6ECF2";
export function OptionFieldError({ error }: { error?: { message?: string } }) {
if (!error?.message) return null;
return (
<Text size="xs" c="red" mt={4}>
<Text size="xs" c="red" mt={6}>
{error.message}
</Text>
);
}
/**
* Premium selectable option card with an icon tile, title, and description.
* Pass `icon`/`iconBg`/`iconColor` for the leading tile, or compose freely via
* `children` (legacy callers still work).
*/
export function OptionCard({
selected,
onClick,
disabled,
icon,
iconBg = "#ECF6F1",
iconColor = GREEN_DARK,
title,
description,
children,
}: {
selected: boolean;
onClick?: () => void;
disabled?: boolean;
children: ReactNode;
icon?: ReactNode;
iconBg?: string;
iconColor?: string;
title?: ReactNode;
description?: ReactNode;
children?: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`relative w-full rounded-xl border-2 p-4 text-left transition-all duration-150 ${
disabled
? "cursor-not-allowed border-gray-200 bg-gray-100 opacity-60"
style={{
position: "relative",
width: "100%",
textAlign: "left",
borderRadius: 16,
padding: 18,
cursor: disabled ? "not-allowed" : "pointer",
transition: "all 150ms ease",
border: `1.5px solid ${
disabled ? BORDER : selected ? GREEN : BORDER
}`,
background: disabled
? "#F6F8FA"
: selected
? "border-emerald-500 bg-emerald-50 shadow-sm shadow-emerald-500/20"
: "border-gray-200 bg-white hover:border-emerald-300 hover:shadow-sm"
}`}
? "linear-gradient(135deg, #F4FBF7 0%, #FFFFFF 70%)"
: "#FFFFFF",
boxShadow: selected
? `0 0 0 1px ${GREEN}, 0 8px 20px rgba(14,163,113,0.10)`
: "0 1px 2px rgba(16,24,40,0.04)",
opacity: disabled ? 0.65 : 1,
}}
onMouseEnter={(e) => {
if (!disabled && !selected) {
e.currentTarget.style.borderColor = "#BFE3D2";
e.currentTarget.style.boxShadow = "0 6px 16px rgba(16,24,40,0.07)";
}
}}
onMouseLeave={(e) => {
if (!disabled && !selected) {
e.currentTarget.style.borderColor = BORDER;
e.currentTarget.style.boxShadow = "0 1px 2px rgba(16,24,40,0.04)";
}
}}
>
{selected && !disabled && (
<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-white" />
<span
style={{
position: "absolute",
right: 14,
top: 14,
display: "flex",
height: 22,
width: 22,
alignItems: "center",
justifyContent: "center",
borderRadius: "50%",
background: GREEN,
boxShadow: "0 2px 6px rgba(14,163,113,0.45)",
}}
>
<Check style={{ width: 13, height: 13, color: "#fff" }} strokeWidth={3} />
</span>
)}
{/* Structured form (icon + title + description) */}
{(icon || title || description) && (
<Box>
{icon && (
<Box
style={{
marginBottom: 12,
display: "flex",
height: 42,
width: 42,
alignItems: "center",
justifyContent: "center",
borderRadius: 12,
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
)}
{title && (
<Text fz={15} fw={800} c={INK}>
{title}
</Text>
)}
{description && (
<Text fz={12.5} c={MUTED} mt={3} style={{ lineHeight: 1.5 }}>
{description}
</Text>
)}
</Box>
)}
{children}
</button>
);
@@ -63,7 +182,7 @@ export function AlertBox({
};
const { color, icon } = map[tone];
return (
<Alert color={color} icon={icon} radius="md" fz="sm">
<Alert color={color} icon={icon} radius="lg" fz="sm">
{children}
</Alert>
);
@@ -71,31 +190,89 @@ export function AlertBox({
export function StepLabel({ children }: { children: ReactNode }) {
return (
<Text size="sm" fw={600} tt="uppercase" c="dimmed" className="tracking-wide">
<Text
fz={11}
fw={700}
tt="uppercase"
c={MUTED}
style={{ letterSpacing: "0.07em" }}
>
{children}
</Text>
);
}
/**
* Card shell that wraps a step's body. Gives every step the same premium
* surface, padding, and an optional eyebrow.
*/
export function StepCard({
children,
eyebrow,
}: {
children: ReactNode;
eyebrow?: ReactNode;
}) {
return (
<Paper
radius={20}
p={{ base: "lg", sm: 28 }}
withBorder
bg="white"
style={{ borderColor: BORDER, boxShadow: "0 2px 14px rgba(16,24,40,0.04)" }}
>
{eyebrow}
{children}
</Paper>
);
}
export function StepHeader({
title,
description,
icon,
}: {
title: string;
description: string;
icon?: ReactNode;
}) {
return (
<div>
<Title order={3} className="tracking-tight">
{title}
</Title>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</div>
<Group gap={14} align="flex-start" wrap="nowrap" mb={22}>
{icon && (
<Box
style={{
flexShrink: 0,
width: 44,
height: 44,
borderRadius: 13,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #ECF6F1, #E4F3EC)",
color: GREEN_DARK,
}}
>
{icon}
</Box>
)}
<Box>
<Title order={3} fz={20} fw={800} c={INK} style={{ letterSpacing: "-0.01em" }}>
{title}
</Title>
<Text size="sm" c={MUTED} mt={4} style={{ lineHeight: 1.5 }}>
{description}
</Text>
</Box>
</Group>
);
}
/** Shared Mantine input styling so every field in the form matches. */
export const fieldStyles = {
label: { fontWeight: 600, fontSize: 13, color: INK, marginBottom: 6 },
input: { borderRadius: 10, minHeight: 44, height: 44, borderColor: BORDER },
} as const;
export function SelectField({
field,
error,
@@ -103,6 +280,7 @@ export function SelectField({
placeholder,
disabled,
data,
leftSection,
}: {
field: ControllerRenderProps<BookingFormInputValues>;
error?: RhfFieldError;
@@ -110,6 +288,7 @@ export function SelectField({
placeholder: string;
disabled?: boolean;
data: string[] | { value: string; label: string }[];
leftSection?: ReactNode;
}) {
return (
<Select
@@ -122,6 +301,11 @@ export function SelectField({
onBlur={field.onBlur}
error={error?.message}
allowDeselect={false}
radius={10}
checkIconPosition="right"
leftSection={leftSection}
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
);
}
@@ -166,12 +350,14 @@ export function AsyncComboboxField({
};
return (
<Input.Wrapper label={label} error={error?.message}>
<Combobox store={combobox} disabled={disabled}>
<Input.Wrapper label={label} error={error?.message} styles={fieldStyles}>
<Combobox store={combobox} disabled={disabled} shadow="md" radius="md" withinPortal>
<Combobox.Target>
<InputBase
placeholder={placeholder}
disabled={disabled}
radius={10}
styles={fieldStyles}
value={searchQuery || selectedLabel}
onChange={(e) => {
onSearchChange(e.currentTarget.value);

View File

@@ -1,6 +1,6 @@
import { Box, Group, Text } from "@mantine/core";
import { SmartFileInput } from "@edr/ui-common";
import { CheckCircle2 } from "lucide-react";
import { CheckCircle2, FileUp } from "lucide-react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -9,7 +9,7 @@ import {
type BookingDocuments,
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { StepCard, StepHeader } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -30,10 +30,11 @@ export function StepDocuments({ form }: { form: BookingForm }) {
const total = BOOKING_DOCS_SETTING.fields.length;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileUp size={22} />}
title="Shipment Documents"
description="Attach your shipment documents now, or skip this step and upload them later from the booking page."
description="Attach your shipment documents now, or skip and upload them later from the booking page."
/>
<Group
@@ -86,6 +87,6 @@ export function StepDocuments({ form }: { form: BookingForm }) {
/>
)}
/>
</div>
</StepCard>
);
}

View File

@@ -385,64 +385,147 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
<Modal
opened={!!selectedDayForModal}
onClose={() => setSelectedDayForModal(null)}
title={selectedDayForModal ? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEE, MMM d yyyy") : ""}
centered
size="sm"
styles={{
header: { borderBottom: `1px solid ${theme.colors["edr-border"][0]}` },
body: { padding: 24 },
}}
size={520}
radius={18}
padding={0}
withCloseButton={false}
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
>
<Stack gap={12}>
<Text fz={13} c="edr-muted" fw={500}>
Choose a departure time
{/* Header */}
<Box
px={24}
py={20}
style={{
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
}}
>
<Group gap={7} align="center" mb={6}>
<CalendarIcon size={15} color="#9FE9CC" />
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
Available departures
</Text>
</Group>
<Text fw={800} fz={19} c="#fff">
{selectedDayForModal
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
: ""}
</Text>
{selectedDayForModal?.schedules.map((schedule) => (
<Button
key={schedule.id}
variant="outline"
fullWidth
onClick={() => handleSelectScheduleFromModal(schedule.id)}
style={{ height: 64, justifyContent: "flex-start" }}
styles={{
inner: { justifyContent: "flex-start" },
root: {
borderColor: theme.colors["edr-border"][0],
<Text fz={12.5} c="#A9BBCB" mt={2}>
{selectedDayForModal?.schedules.length ?? 0} train
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
{originName} {destinationName}
</Text>
</Box>
{/* Schedule list */}
<Stack gap={12} p={24}>
{selectedDayForModal?.schedules.map((schedule) => {
const remaining = schedule.remainingWagons;
const max = schedule.maxWagons || 1;
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
const isSelected = schedule.id === selectedScheduleId;
return (
<Box
key={schedule.id}
role="button"
tabIndex={0}
onClick={() => handleSelectScheduleFromModal(schedule.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleSelectScheduleFromModal(schedule.id);
}
}}
style={{
cursor: "pointer",
borderRadius: 14,
padding: 16,
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
boxShadow: isSelected
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
: "0 1px 2px rgba(16,24,40,0.04)",
transition: "all 150ms ease",
"&:hover": {
borderColor: theme.colors["edr-green"][5],
backgroundColor: theme.colors["edr-soft"][0],
},
},
}}
>
<Group gap={16} w="100%">
<Box
style={{
width: 48,
height: 48,
borderRadius: theme.radius.md,
backgroundColor: theme.colors["edr-soft"][0],
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][5]} />
</Box>
<Stack gap={3} style={{ flex: 1, alignItems: "flex-start" }}>
<Text fw={700} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
{schedule.trainNumber && (
<Text fz={12} c="edr-muted">
Train {schedule.trainNumber}
</Text>
)}
</Stack>
</Group>
</Button>
))}
}}
>
<Group gap={14} wrap="nowrap" align="center">
<Box
style={{
width: 50,
height: 50,
flexShrink: 0,
borderRadius: 13,
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Train size={24} color={theme.colors["edr-green"][6]} />
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} align="baseline">
<Text fw={800} fz={18} c="edr-text.0">
{format(new Date(schedule.scheduleDate), "HH:mm")}
</Text>
<Text fz={12.5} c="edr-muted">
{schedule.trainNumber
? `Train ${schedule.trainNumber}`
: `#${schedule.id.slice(0, 6)}`}
</Text>
</Group>
{/* capacity bar */}
<Box mt={8}>
<Group justify="space-between" mb={4}>
<Text fz={11} fw={600} c="edr-muted">
{remaining} / {max} wagons free
</Text>
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
{pct}%
</Text>
</Group>
<Box
style={{
height: 6,
borderRadius: 999,
background: "#EEF2F6",
overflow: "hidden",
}}
>
<Box
style={{
width: `${pct}%`,
height: "100%",
borderRadius: 999,
background:
pct > 25
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
: "#F2A516",
}}
/>
</Box>
</Box>
</Box>
<Box
style={{
width: 26,
height: 26,
flexShrink: 0,
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
}}
>
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
</Box>
</Group>
</Box>
);
})}
</Stack>
</Modal>
</Group>
@@ -561,36 +644,42 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
)}
</Group>
{/* Schedule times */}
{/* Availability marker — a dot + count, never the schedule list itself. */}
{d.hasSchedule && (
<Stack gap={3} style={{ flex: 1, overflow: "hidden", minWidth: 0 }}>
{d.schedules.slice(0, 2).map((s) => (
<Group key={s.id} gap={6} align="center" style={{ minWidth: 0 }}>
<Box
style={{
width: 4,
height: 4,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
}}
/>
<Text
fz={12}
fw={700}
c="edr-text.0"
style={{ flex: 1, minWidth: 0 }}
>
{format(new Date(s.scheduleDate), "HH:mm")}
</Text>
</Group>
))}
{d.schedules.length > 2 && (
<Text fz={11} fw={600} c="edr-green.7" style={{ paddingTop: 2 }}>
+{d.schedules.length - 2} more
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
<Group
gap={6}
align="center"
wrap="nowrap"
px={9}
py={4}
style={{
borderRadius: 999,
backgroundColor: d.isSelectedDate
? "#fff"
: theme.colors["edr-soft"][0],
border: `1px solid ${
d.isSelectedDate
? theme.colors["edr-green"][2]
: "transparent"
}`,
}}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: "50%",
backgroundColor: theme.colors["edr-green"][5],
flexShrink: 0,
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
}}
/>
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
</Text>
)}
</Stack>
</Group>
</Box>
)}
</Box>
);

View File

@@ -10,8 +10,11 @@ import {
AsyncComboboxField,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
} from "./shared";
import { FileSignature } from "lucide-react";
import { Stack } from "@mantine/core";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -52,7 +55,6 @@ export function Step1ContractType({
);
const contractOptions = useMemo<PreviousContractOption[]>(() => {
console.log("Bookings data:", bookings);
if (!bookings) return [];
return bookings?.items
@@ -182,10 +184,11 @@ export function Step1ContractType({
};
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<FileSignature size={22} />}
title="Contract Type"
description="New contract or renewal of an existing one."
description="Start a new contract or renew an existing one to reuse its details."
/>
<Controller
@@ -193,40 +196,33 @@ export function Step1ContractType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
<OptionCard
selected={field.value === "new"}
icon={<FileText className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="New Contract"
description="Create a fresh freight contract from scratch."
onClick={() => {
field.onChange("new");
form.clearErrors(["contractType", "previousContractRef"]);
form.setValue("previousContractRef", "");
}}
>
<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-emerald-600" />
</div>
<p className="font-semibold">New Contract</p>
<p className="mt-0.5 text-xs text-gray-500">
Create a new contract.
</p>
</OptionCard>
/>
<OptionCard
selected={field.value === "renewal"}
icon={<RefreshCw className="h-5 w-5" />}
iconBg="#EAF1FB"
iconColor="#2E5B96"
title="Contract Renewal"
description="Pick a previous reference to auto-fill historical parameters."
onClick={() => {
field.onChange("renewal");
form.clearErrors("contractType");
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-sky-100">
<RefreshCw className="h-4 w-4 text-sky-600" />
</div>
<p className="font-semibold">Contract Renewal</p>
<p className="mt-0.5 text-xs text-gray-500">
Select a previous reference to auto-populate historical
parameters.
</p>
</OptionCard>
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -234,7 +230,7 @@ export function Step1ContractType({
/>
{contractType === "renewal" && (
<div className="space-y-3 pt-1">
<Stack gap={12} mt={22}>
{error && (
<AlertBox tone="error">
Failed to load previous contracts. Please try again later.
@@ -263,8 +259,8 @@ export function Step1ContractType({
details will be pre-filled.
</AlertBox>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}

View File

@@ -1,9 +1,17 @@
import { Switch, TextInput } from "@mantine/core";
import { FileText, Train, Truck } from "lucide-react";
import { Box, Group, Stack, Switch, Text, TextInput } from "@mantine/core";
import type { ReactNode } from "react";
import { FileText, Layers, Train, Truck } from "lucide-react";
import { useEffect, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { BookingFormInputValues, type BookingFormValues } from "./schema";
import { OptionCard, OptionFieldError, StepHeader } from "./shared";
import {
fieldStyles,
OptionCard,
OptionFieldError,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
import type { Freight } from "@edr/types";
@@ -61,10 +69,11 @@ export function Step2ServiceType({
const showServiceSections =
includesCustoms || includesFirstMile || includesLastMile;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Layers size={22} />}
title="Service Type"
description="Select the service combination and configure trucking options."
description="Choose the service combination, then configure your trucking options."
/>
<Controller
@@ -72,25 +81,21 @@ export function Step2ServiceType({
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 md:grid-cols-2">
<div className="grid gap-4 md:grid-cols-2">
{referenceData?.service
.filter((s) => s.canBeBookedAlone)
.map((s) => {
return (
<OptionCard
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-100">
<Train className="h-4 w-4 text-indigo-600" />
</div>
<p className="font-semibold">{s.serviceName}</p>
<p className="mt-0.5 text-xs text-gray-500">
{s.description}
</p>
</OptionCard>
);
})}
.map((s) => (
<OptionCard
key={s.id}
selected={field.value === s.id}
onClick={() => field.onChange(s.id)}
icon={<Train className="h-5 w-5" />}
iconBg="#EEF0FB"
iconColor="#4F46E5"
title={s.serviceName}
description={s.description}
/>
))}
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -98,185 +103,201 @@ export function Step2ServiceType({
/>
{showServiceSections && (
<div className="divide-y divide-gray-200 rounded-xl border border-gray-200">
<Stack gap={12} mt={24}>
<StepLabel>Trucking & customs options</StepLabel>
{/* First Mile */}
{includesFirstMile && (
<div className="p-4">
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
First Mile Pick-up
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck pick-up from your premises (Door to Port) to the
origin rail yard.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius="md"
<Controller
name="firstMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="First Mile — Pick-up"
description="Truck pick-up from your premises (Door to Port) to the origin rail yard."
checked={field.value}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("firstMile.pickUpAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
}
}}
>
{firstMileEnabled && (
<Controller
name="firstMile.pickUpAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Pick-up address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Last Mile */}
{includesLastMile && (
<div className="p-4">
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<Truck className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Last Mile Delivery
</p>
<p className="mt-0.5 text-xs text-gray-500">
Truck delivery from the destination rail yard to the
final address (Port to Door).
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => {
const value = e.currentTarget.checked;
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
color="edr-green"
/>
</div>
)}
/>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field, fieldState }) => (
<TextInput
{...field}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius="md"
<Controller
name="lastMile.enabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Last Mile — Delivery"
description="Truck delivery from the destination rail yard to the final address (Port to Door)."
checked={field.value}
onChange={(value) => {
field.onChange(value);
if (!value) {
form.setValue("lastMile.deliveryAddress", "", {
shouldDirty: true,
shouldValidate: true,
});
form.setValue("equipmentReturn", "with_return", {
shouldDirty: true,
});
}
}}
>
{lastMileEnabled && (
<Controller
name="lastMile.deliveryAddress"
control={form.control}
render={({ field: af, fieldState }) => (
<TextInput
{...af}
mt="sm"
placeholder="Delivery address *"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
/>
</ServiceToggle>
)}
</div>
/>
)}
{/* Equipment Return */}
{includesLastMile && lastMileEnabled && (
<div className="p-4">
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-medium">Equipment Return</p>
<p className="mt-0.5 text-xs text-gray-500">
{field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."}
</p>
</div>
<Switch
checked={field.value === "with_return"}
onChange={(e) => {
field.onChange(
e.currentTarget.checked
? "with_return"
: "without_return",
);
}}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="equipmentReturn"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<Truck size={18} />}
title="Equipment Return"
description={
field.value === "with_return"
? "Container returned to EDR after unloading."
: "Container retained by the customer after delivery."
}
checked={field.value === "with_return"}
onChange={(v) =>
field.onChange(v ? "with_return" : "without_return")
}
/>
)}
/>
)}
{/* Customs Clearing */}
{includesCustoms && (
<div className="p-4">
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3">
<FileText className="mt-0.5 h-4 w-4 shrink-0 text-gray-400" />
<div>
<p className="text-sm font-medium">
Customs Clearing Service
</p>
<p className="mt-0.5 text-xs text-gray-500">
EDR handles customs documentation and clearance on
your behalf.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
)}
/>
</div>
<Controller
name="customsClearingEnabled"
control={form.control}
render={({ field }) => (
<ServiceToggle
icon={<FileText size={18} />}
title="Customs Clearing Service"
description="EDR handles customs documentation and clearance on your behalf."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
)}
</div>
</Stack>
)}
</div>
</StepCard>
);
}
function ServiceToggle({
icon,
title,
description,
checked,
onChange,
children,
}: {
icon: ReactNode;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
children?: ReactNode;
}) {
return (
<Box
px={16}
py={14}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" gap={12}>
<Group gap={13} align="flex-start" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: checked ? "#ECF6F1" : "#F1F4F7",
color: checked ? "#0A6F4D" : "#64748B",
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
style={{ flexShrink: 0 }}
/>
</Group>
{children}
</Box>
);
}

View File

@@ -1,6 +1,6 @@
import type { Freight } from "@edr/types";
import { Divider, Skeleton, Stack, Switch } from "@mantine/core";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Box, Divider, Group, Skeleton, Stack, Switch, Text } from "@mantine/core";
import { Flame, MapPin, Route as RouteIcon, Snowflake } from "lucide-react";
import { useEffect, useMemo } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import {
@@ -8,7 +8,7 @@ import {
type BookingFormValues,
getRouteDirection,
} from "./schema";
import { SelectField, StepHeader, StepLabel } from "./shared";
import { SelectField, StepCard, StepHeader, StepLabel } from "./shared";
type BookingForm = UseFormReturn<
BookingFormInputValues,
@@ -63,7 +63,6 @@ export function Step4Route({
const origin = referenceData?.yard.find((y) => y.id === originYard);
const dest = referenceData?.yard.find((y) => y.id === destinationYard);
const direction = getRouteDirection(origin, dest);
console.log({ yardOptions, originYard, destinationYard, direction, origin, dest });
const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
@@ -85,10 +84,11 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0;
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<RouteIcon size={22} />}
title="Route"
description="Select the origin and destination yards."
description="Choose the origin and destination yards for your shipment."
/>
{isLoading ? (
@@ -96,7 +96,7 @@ export function Step4Route({
) : (
<div className="space-y-3">
<StepLabel>Route</StepLabel>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="originYard"
control={form.control}
@@ -153,56 +153,108 @@ export function Step4Route({
/>
)}
<Divider />
<Divider my={22} color="#EEF2F6" />
<div className="divide-y divide-gray-200">
<StepLabel>Cargo handling</StepLabel>
<Stack gap={12} mt={12}>
<Controller
name="isHazardous"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Flame className="h-4 w-4 shrink-0 text-red-500" />
<div>
<p className="text-sm font-medium">Hazardous Material</p>
<p className="text-xs text-gray-500">
Applies a Hazard Surcharge to the final bill.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Flame size={18} />}
iconBg="#FBEAE7"
iconColor="#C0392B"
title="Hazardous Material"
description="Applies a hazard surcharge to the final bill."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
<Controller
name="isRefrigerated"
control={form.control}
render={({ field }) => (
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-3">
<Snowflake className="h-4 w-4 shrink-0 text-sky-500" />
<div>
<p className="text-sm font-medium">Refrigerated Cargo</p>
<p className="text-xs text-gray-500">
Temperature-controlled transport applies a Refrigerator
Surcharge.
</p>
</div>
</div>
<Switch
checked={field.value}
onChange={(e) => field.onChange(e.currentTarget.checked)}
color="edr-green"
/>
</div>
<ToggleRow
icon={<Snowflake size={18} />}
iconBg="#E9F0F8"
iconColor="#2E5B96"
title="Refrigerated Cargo"
description="Temperature-controlled transport applies a refrigeration surcharge."
checked={field.value}
onChange={(v) => field.onChange(v)}
/>
)}
/>
</div>
</div>
</Stack>
</StepCard>
);
}
function ToggleRow({
icon,
iconBg,
iconColor,
title,
description,
checked,
onChange,
}: {
icon: React.ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<Group
justify="space-between"
align="center"
wrap="nowrap"
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${checked ? "#CDEBDD" : "#E6ECF2"}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: iconBg,
color: iconColor,
}}
>
{icon}
</Box>
<Box>
<Text fz={14} fw={700} c="#10202F">
{title}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
<Switch
checked={checked}
onChange={(e) => onChange(e.currentTarget.checked)}
color="edr-green"
size="md"
/>
</Group>
);
}

View File

@@ -5,7 +5,6 @@ import {
ActionIcon,
Button,
Skeleton,
InputLabel,
Text,
TextInput,
} from "@mantine/core";
@@ -17,9 +16,11 @@ import {
} from "./schema";
import {
AlertBox,
fieldStyles,
OptionCard,
OptionFieldError,
SelectField,
StepCard,
StepHeader,
StepLabel,
} from "./shared";
@@ -116,70 +117,66 @@ export function Step5CargoDetails({
if (isLoading) {
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
<div className="space-y-4 rounded-xl border border-gray-200 p-4">
<div className="space-y-4">
<Skeleton height={14} w={96} radius="sm" />
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton height={96} radius="xl" />
<Skeleton height={96} radius="xl" />
<div className="grid gap-4 sm:grid-cols-2">
<Skeleton height={96} radius="lg" />
<Skeleton height={96} radius="lg" />
</div>
<Skeleton height={40} radius="md" />
<Skeleton height={40} w="33%" radius="md" />
<Skeleton height={44} radius="md" />
<Skeleton height={44} w="33%" radius="md" />
</div>
</div>
</StepCard>
);
}
return (
<div className="space-y-6">
<StepCard>
<StepHeader
icon={<Package size={22} />}
title="Cargo Details"
description="Define your cargo type, weight, and container configuration."
/>
{/* Cargo Type */}
<div className="space-y-3">
<InputLabel>Cargo Type *</InputLabel>
<StepLabel>Cargo Type *</StepLabel>
<Controller
name="cargoType"
control={form.control}
render={({ field, fieldState }) => (
<div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-4 sm:grid-cols-2">
<OptionCard
selected={cargoType === "container"}
icon={<Package className="h-5 w-5" />}
iconBg="#ECF6F1"
iconColor="#0A6F4D"
title="Containerized"
description="Pre-packed containerized cargo (20ft / 40ft)."
onClick={() => {
field.onChange("container");
form.setValue("cargoTypePath", [], { shouldDirty: true });
}}
>
<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-emerald-600" />
</div>
<p className="font-semibold">Containerized</p>
<p className="mt-0.5 text-xs text-gray-500">
Pre-packed containerized cargo (20ft / 40ft).
</p>
</OptionCard>
/>
<OptionCard
selected={cargoType === "bulk"}
icon={<Weight className="h-5 w-5" />}
iconBg="#FDF3E0"
iconColor="#C77F09"
title="General Cargo"
description="Bulk commodities or break-bulk cargo."
onClick={() => {
field.onChange("bulk");
form.setValue("containers", [], { shouldDirty: true });
}}
>
<div className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg bg-amber-100">
<Weight className="h-4 w-4 text-amber-600" />
</div>
<p className="font-semibold">General Cargo</p>
<p className="mt-0.5 text-xs text-gray-500">
Bulk commodities or break-bulk cargo.
</p>
</OptionCard>
/>
</div>
<OptionFieldError error={fieldState.error} />
</div>
@@ -201,7 +198,8 @@ export function Step5CargoDetails({
placeholder="0.00"
leftSection={<Weight className="h-4 w-4" />}
error={fieldState.error?.message}
radius="md"
radius={10}
styles={fieldStyles}
min={0}
step={0.01}
/>
@@ -484,6 +482,6 @@ export function Step5CargoDetails({
})()}
</>
)}
</div>
</StepCard>
);
}

View File

@@ -19,6 +19,7 @@ import {
type BookingFormValues,
} from "./schema";
import { StepHeader } from "./shared";
import { ClipboardCheck } from "lucide-react";
import type { Freight } from "@/types";
import type { GeneratePriceResponse } from "@/services/bookings.service";
@@ -154,6 +155,7 @@ export function Step8Review({
return (
<Stack gap="md">
<StepHeader
icon={<ClipboardCheck size={22} />}
title="Review & Submit"
description="Confirm your contract request before sending it for EDR staff review."
/>