{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "order-summary",
  "title": "Order Summary",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Free-shipping progress, promo code field with applied state, itemised totals and the checkout CTA. Totals logic lives in a separate hook you can test.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "input"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/order-summary/order-summary.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Tag, Truck, X } from \"lucide-react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { Input } from \"@/components/ui/input\"\nimport { useOrderTotals, type OrderLine } from \"@/hooks/use-order-totals\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface OrderSummaryProps extends React.HTMLAttributes<HTMLDivElement> {\n  lines: OrderLine[]\n  currency?: string\n  locale?: string\n  freeShippingThreshold?: number\n  flatShipping?: number\n  taxRate?: number\n  appliedCode?: string | null\n  discountRate?: number\n  ctaLabel?: string\n  /** Reject with an Error whose message is shown inline. */\n  onApplyCode?: (code: string) => void | Promise<void>\n  onRemoveCode?: () => void\n  onCheckout?: () => void\n}\n\nexport function OrderSummary({\n  lines,\n  currency = \"USD\",\n  locale,\n  freeShippingThreshold,\n  flatShipping = 0,\n  taxRate = 0,\n  appliedCode = null,\n  discountRate = 0,\n  ctaLabel = \"Checkout\",\n  onApplyCode,\n  onRemoveCode,\n  onCheckout,\n  className,\n  ...props\n}: OrderSummaryProps) {\n  const [code, setCode] = React.useState(\"\")\n  const [error, setError] = React.useState<string | null>(null)\n  const [pending, setPending] = React.useState(false)\n\n  const t = useOrderTotals({\n    lines,\n    discountRate: appliedCode ? discountRate : 0,\n    freeShippingThreshold,\n    flatShipping,\n    taxRate,\n  })\n\n  const money = React.useMemo(\n    () => new Intl.NumberFormat(locale, { style: \"currency\", currency }),\n    [locale, currency]\n  )\n\n  const apply = async (event: React.FormEvent) => {\n    event.preventDefault()\n    if (!code.trim() || pending) return\n    setPending(true)\n    setError(null)\n    try {\n      await onApplyCode?.(code.trim())\n      setCode(\"\")\n    } catch (e) {\n      setError(e instanceof Error ? e.message : \"That code isn't valid.\")\n    } finally {\n      setPending(false)\n    }\n  }\n\n  return (\n    <div className={cn(\"space-y-5 border p-5 sm:p-6\", className)} {...props}>\n      <h2 className=\"text-2xl\">Summary</h2>\n\n      {freeShippingThreshold !== undefined ? (\n        <div className=\"space-y-2 border-y py-4\">\n          <p className=\"text-[13px]\">\n            {t.toFreeShipping > 0 ? (\n              <>\n                <span className=\"tabular-nums\">{money.format(t.toFreeShipping)}</span>{\" \"}\n                from free shipping\n              </>\n            ) : (\n              <span className=\"flex items-center gap-1.5 text-[var(--success)]\">\n                <Truck className=\"size-3.5\" aria-hidden /> Free shipping unlocked\n              </span>\n            )}\n          </p>\n          <div\n            role=\"progressbar\"\n            aria-valuemin={0}\n            aria-valuemax={100}\n            aria-valuenow={Math.round(t.freeShippingProgress * 100)}\n            aria-label=\"Progress toward free shipping\"\n            className=\"h-1 bg-secondary\"\n          >\n            <div\n              className=\"h-full bg-primary transition-[width] motion-reduce:transition-none\"\n              style={{ width: `${t.freeShippingProgress * 100}%` }}\n            />\n          </div>\n        </div>\n      ) : null}\n\n      {appliedCode ? (\n        <div className=\"flex items-center justify-between border border-dashed px-3 py-2.5\">\n          <span className=\"flex items-center gap-2 text-[11px] uppercase tracking-[0.16em] text-[var(--success)]\">\n            <Tag className=\"size-3\" aria-hidden /> {appliedCode}\n          </span>\n          <button type=\"button\" onClick={onRemoveCode} aria-label={`Remove code ${appliedCode}`}>\n            <X className=\"size-3.5 text-muted-foreground hover:text-foreground\" />\n          </button>\n        </div>\n      ) : onApplyCode ? (\n        <form onSubmit={apply} className=\"space-y-1.5\">\n          <div className=\"flex gap-2\">\n            <Input\n              value={code}\n              onChange={(e) => setCode(e.target.value.toUpperCase())}\n              placeholder=\"Promo code\"\n              aria-label=\"Promo code\"\n              aria-invalid={error ? true : undefined}\n              className=\"h-10 rounded-none text-[13px]\"\n            />\n            <Button type=\"submit\" variant=\"outline\" disabled={pending} className=\"h-10 text-[13px]\">\n              {pending ? \"Checking…\" : \"Apply\"}\n            </Button>\n          </div>\n          {error ? (\n            <p role=\"alert\" className=\"text-[12px] text-[var(--sale)]\">\n              {error}\n            </p>\n          ) : null}\n        </form>\n      ) : null}\n\n      <dl className=\"space-y-2.5 text-[13px] tabular-nums\">\n        <Row label={`Subtotal (${t.itemCount} items)`} value={money.format(t.subtotal)} />\n        {t.discount > 0 ? (\n          <Row label=\"Discount\" value={`−${money.format(t.discount)}`} tone=\"sale\" />\n        ) : null}\n        <Row\n          label=\"Shipping\"\n          value={t.shipping === 0 ? \"Free\" : money.format(t.shipping)}\n          tone={t.shipping === 0 ? \"success\" : undefined}\n        />\n        {taxRate > 0 ? (\n          <Row label=\"Estimated tax\" value={money.format(t.tax)} muted />\n        ) : null}\n      </dl>\n\n      <div className=\"flex items-baseline justify-between border-t pt-4\">\n        <span className=\"text-[11px] uppercase tracking-[0.16em]\">Total</span>\n        <span className=\"text-3xl tabular-nums\">{money.format(t.total)}</span>\n      </div>\n\n      <Button\n        onClick={onCheckout}\n        disabled={t.itemCount === 0}\n        className=\"h-12 w-full text-[13px] tracking-[0.12em]\"\n      >\n        {ctaLabel.toUpperCase()}\n      </Button>\n\n      {taxRate > 0 ? (\n        <p className=\"text-center text-[11px] text-muted-foreground\">\n          Tax and shipping are estimates until an address is entered.\n        </p>\n      ) : null}\n    </div>\n  )\n}\n\nfunction Row({\n  label,\n  value,\n  muted,\n  tone,\n}: {\n  label: string\n  value: string\n  muted?: boolean\n  tone?: \"sale\" | \"success\"\n}) {\n  return (\n    <div className=\"flex justify-between\">\n      <dt className={cn(muted && \"text-muted-foreground\")}>{label}</dt>\n      <dd\n        className={cn(\n          muted && \"text-muted-foreground\",\n          tone === \"sale\" && \"text-[var(--sale)]\",\n          tone === \"success\" && \"text-[var(--success)]\"\n        )}\n      >\n        {value}\n      </dd>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/order-summary.tsx"
    },
    {
      "path": "src/registry/ecomcn/order-summary/use-order-totals.ts",
      "content": "import * as React from \"react\"\n\nexport interface OrderLine {\n  id: string\n  unitPrice: number\n  quantity: number\n}\n\nexport interface OrderTotalsOptions {\n  lines: OrderLine[]\n  /** 0–1. Applied to the subtotal before shipping and tax. */\n  discountRate?: number\n  /** Order value at which shipping becomes free. Omit to disable the meter. */\n  freeShippingThreshold?: number\n  flatShipping?: number\n  /** 0–1. An estimate until a real address exists — say so in the UI. */\n  taxRate?: number\n}\n\nexport interface OrderTotals {\n  itemCount: number\n  subtotal: number\n  discount: number\n  shipping: number\n  tax: number\n  total: number\n  /** Remaining spend to unlock free shipping, or 0 once unlocked. */\n  toFreeShipping: number\n  freeShippingProgress: number\n}\n\n/**\n * Kept out of the component on purpose: totals are the part of a cart that\n * actually needs unit tests, and the part every store tweaks.\n */\nexport function useOrderTotals({\n  lines,\n  discountRate = 0,\n  freeShippingThreshold,\n  flatShipping = 0,\n  taxRate = 0,\n}: OrderTotalsOptions): OrderTotals {\n  return React.useMemo(() => {\n    const itemCount = lines.reduce((n, l) => n + l.quantity, 0)\n    const subtotal = lines.reduce((n, l) => n + l.unitPrice * l.quantity, 0)\n    const discount = subtotal * discountRate\n    const afterDiscount = subtotal - discount\n\n    const qualifies =\n      freeShippingThreshold !== undefined && afterDiscount >= freeShippingThreshold\n    const shipping = itemCount === 0 || qualifies ? 0 : flatShipping\n    const tax = afterDiscount * taxRate\n\n    return {\n      itemCount,\n      subtotal,\n      discount,\n      shipping,\n      tax,\n      total: afterDiscount + shipping + tax,\n      toFreeShipping:\n        freeShippingThreshold === undefined\n          ? 0\n          : Math.max(0, freeShippingThreshold - afterDiscount),\n      freeShippingProgress:\n        freeShippingThreshold === undefined\n          ? 1\n          : Math.min(1, afterDiscount / freeShippingThreshold),\n    }\n  }, [lines, discountRate, freeShippingThreshold, flatShipping, taxRate])\n}\n",
      "type": "registry:hook",
      "target": "hooks/use-order-totals.ts"
    }
  ],
  "cssVars": {
    "light": {
      "sale": "0 72% 42%",
      "success": "138 30% 34%"
    },
    "dark": {
      "sale": "4 74% 58%",
      "success": "138 26% 52%"
    }
  },
  "categories": [
    "ecommerce",
    "cart",
    "checkout"
  ],
  "type": "registry:block"
}