Logistics

Freight Quote

A lane quote card with an ocean/air/truck mode toggle driving live price and transit time for an origin-to-destination route.

Install

npx shadcn@latest add @paragon/freight-quote

Also installs: number-ticker, tooltip

freight-quote.tsx

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