A lane quote card with an ocean/air/truck mode toggle driving live price and transit time for an origin-to-destination route.
npx shadcn@latest add @paragon/freight-quoteAlso installs: number-ticker, tooltip
"use client";
import * as React from "react";
import { Plane, Ship, Truck, ArrowRight, Clock, Info } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export type FreightMode = "ocean" | "air" | "truck";
export interface FreightLineItem {
label: string;
amount: number;
/** Optional explanation shown in a tooltip. */
hint?: string;
}
export interface FreightRate {
/** Transit time, e.g. "18–22 days". */
transit: string;
/** Optional short note, e.g. "Direct" or "1 transfer". */
note?: string;
/** Itemized charges. The total is their sum. */
breakdown: FreightLineItem[];
}
export interface FreightQuoteProps extends React.ComponentProps<"div"> {
origin: string;
destination: string;
/** Rate per shipping mode. Absent modes are shown disabled. */
rates: Partial<Record<FreightMode, FreightRate>>;
/** Selected mode (uncontrolled default). */
defaultMode?: FreightMode;
/** Controlled mode. */
mode?: FreightMode;
onModeChange?: (mode: FreightMode) => void;
/** ISO currency code. */
currency?: string;
/** Renders totals immediately without the ticker roll. */
static?: boolean;
}
const MODES: { id: FreightMode; label: string; icon: typeof Ship }[] = [
{ id: "ocean", label: "Ocean", icon: Ship },
{ id: "air", label: "Air", icon: Plane },
{ id: "truck", label: "Truck", icon: Truck },
];
/**
* A lane quote card: origin → destination with a mode toggle (ocean / air /
* truck) driving an itemized charge breakdown and a headline total. Switching
* mode rolls the total to its new value via number-ticker and each row's amount
* lives in a fixed-width, right-aligned tabular-nums column so labels never
* collide with values. The toggle is a real radio group with press feedback.
*/
export function FreightQuote({
origin,
destination,
rates,
defaultMode = "ocean",
mode: controlledMode,
onModeChange,
currency = "USD",
static: isStatic = false,
className,
...props
}: FreightQuoteProps) {
const [uncontrolled, setUncontrolled] = React.useState<FreightMode>(() => {
if (rates[defaultMode]) return defaultMode;
return (MODES.find((m) => rates[m.id])?.id ?? defaultMode) as FreightMode;
});
const mode = controlledMode ?? uncontrolled;
const active = rates[mode];
const setMode = (next: FreightMode) => {
if (!rates[next]) return;
if (controlledMode === undefined) setUncontrolled(next);
onModeChange?.(next);
};
const total = React.useMemo(
() => active?.breakdown.reduce((sum, li) => sum + li.amount, 0) ?? 0,
[active],
);
const money = React.useMemo(
() =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumFractionDigits: 0,
}),
[currency],
);
const tickerFormat: Intl.NumberFormatOptions = {
style: "currency",
currency,
maximumFractionDigits: 0,
};
return (
<TooltipProvider>
<div
data-slot="freight-quote"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center gap-2 text-sm font-medium">
<span className="min-w-0 truncate">{origin}</span>
<ArrowRight
aria-hidden
className="size-4 shrink-0 text-muted-foreground"
/>
<span className="min-w-0 truncate">{destination}</span>
</div>
<div
role="radiogroup"
aria-label="Shipping mode"
className="mt-4 grid grid-cols-3 gap-1 rounded-lg bg-secondary p-1"
>
{MODES.map(({ id, label, icon: Icon }) => {
const available = Boolean(rates[id]);
const selected = id === mode;
return (
<button
key={id}
type="button"
role="radio"
aria-checked={selected}
disabled={!available}
onClick={() => setMode(id)}
className={cn(
"flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium outline-none transition-[background-color,color,box-shadow,scale] duration-150 ease-out focus-visible:ring-2 focus-visible:ring-ring active:not-disabled:scale-[0.97] disabled:cursor-not-allowed disabled:opacity-40",
selected
? "bg-card text-foreground shadow-border"
: "text-muted-foreground hover:text-foreground",
)}
>
<Icon aria-hidden className="size-3.5 shrink-0" />
{label}
</button>
);
})}
</div>
{active ? (
<div className="mt-4">
<div className="flex items-baseline justify-between gap-3">
<p className="text-3xl font-semibold tabular-nums">
<NumberTicker
key={`${mode}-${currency}`}
value={total}
startValue={0}
duration={0.5}
formatOptions={tickerFormat}
static={isStatic}
/>
</p>
<div className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1 tabular-nums">
<Clock aria-hidden className="size-3.5" />
{active.transit}
</span>
{active.note && (
<span className="rounded-full bg-secondary px-2 py-0.5 text-[11px] font-medium text-foreground">
{active.note}
</span>
)}
</div>
</div>
<dl className="mt-4 flex flex-col gap-2 border-t border-border pt-3">
{active.breakdown.map((li) => (
<div
key={li.label}
className="flex items-center justify-between gap-3 text-sm"
>
<dt className="flex min-w-0 items-center gap-1 text-muted-foreground">
<span className="truncate">{li.label}</span>
{li.hint && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`About ${li.label}`}
className="shrink-0 rounded-full text-muted-foreground/70 outline-none transition-colors duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<Info className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>{li.hint}</TooltipContent>
</Tooltip>
)}
</dt>
<dd className="w-24 shrink-0 text-right font-medium tabular-nums">
{money.format(li.amount)}
</dd>
</div>
))}
<div className="mt-1 flex items-center justify-between gap-3 border-t border-border pt-2 text-sm font-semibold">
<span className="text-foreground">Total</span>
<span className="w-24 shrink-0 text-right tabular-nums">
<NumberTicker
key={`total-${mode}-${currency}`}
value={total}
startValue={0}
duration={0.5}
formatOptions={tickerFormat}
static={isStatic}
/>
</span>
</div>
</dl>
</div>
) : (
<div className="mt-4 rounded-lg border border-dashed border-border px-4 py-8 text-center">
<p className="text-sm text-muted-foreground">
No rate available for this mode on this lane.
</p>
</div>
)}
</div>
</TooltipProvider>
);
}