{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "order-summary",
  "type": "registry:ui",
  "title": "Order Summary",
  "description": "Checkout totals that roll as they change and fold behind the total on phones.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/order-summary.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Cart, ChevronDown, Image as ImageIcon } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Money = { currency: string; locale: string; total: number };\nconst MoneyContext = createContext<Money>({ currency: \"USD\", locale: \"en-US\", total: 0 });\n\nconst format = (amount: number, currency: string) =>\n  ({ style: \"currency\", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }) as const;\n\n/** Rolling money, pinned to one line height so the digits' mask never moves the row. */\nfunction Amount({ value, className }: { value: number; className?: string }) {\n  const { currency, locale } = useContext(MoneyContext);\n  return (\n    <span className={cn(\"inline-flex h-[1.4em] items-center\", className)}>\n      <NumberFlow value={value} locales={locale} format={format(value, currency)} className=\"tabular leading-none\" />\n    </span>\n  );\n}\n\nexport type OrderSummaryProps = Omit<React.ComponentProps<\"section\">, \"title\"> & {\n  /** The grand total, shown in the phone header and in OrderSummaryTotal. */\n  total: number;\n  currency?: string;\n  locale?: string;\n  /** Units in the order, shown in the phone header. */\n  count?: number;\n  title?: string;\n  /**\n   * \"auto\" folds the summary behind a header on phones (below 640px) and shows it open on wider\n   * screens. true always folds, false never does.\n   */\n  collapsible?: \"auto\" | boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n};\n\nexport function OrderSummary({\n  total,\n  currency = \"USD\",\n  locale = \"en-US\",\n  count,\n  title = \"Order summary\",\n  collapsible = \"auto\",\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  className,\n  children,\n  ...rest\n}: OrderSummaryProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const id = useId();\n  const auto = collapsible === \"auto\";\n  const folds = collapsible !== false;\n\n  return (\n    <MoneyContext.Provider value={{ currency, locale, total }}>\n      <section\n        aria-label={title}\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\"flex flex-col rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)}\n        {...rest}\n      >\n        {folds && (\n          <button\n            type=\"button\"\n            aria-expanded={open}\n            aria-controls={`${id}-panel`}\n            onClick={() => setOpen(!open)}\n            className={cn(\n              \"group/head flex h-12 w-full items-center gap-2.5 rounded-xl px-4 text-left outline-none\",\n              \"transition-colors duration-150 hover:bg-hover/60 active:bg-hover\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n              open && \"rounded-b-none\",\n              auto && \"sm:hidden\",\n            )}\n          >\n            <Cart size={16} className=\"shrink-0 text-fg-3\" />\n            <span className=\"flex min-w-0 flex-1 items-center gap-1.5 text-[13px] font-medium text-fg\">\n              {/* Both labels share a cell, so the chevron doesn't jump when the verb changes. */}\n              <span className=\"grid min-w-0\">\n                <span aria-hidden className=\"invisible col-start-1 row-start-1 truncate\">Hide order summary</span>\n                <span className=\"col-start-1 row-start-1 truncate\">{open ? \"Hide order summary\" : \"Show order summary\"}</span>\n              </span>\n              <ChevronDown size={14} className=\"shrink-0 text-fg-3 transition-transform duration-200 ease-out-expo group-aria-expanded/head:rotate-180 motion-reduce:transition-none\" />\n            </span>\n            {count != null && <span className=\"tabular shrink-0 text-[12px] text-fg-3 max-[420px]:hidden\">{count === 1 ? \"1 item\" : `${count} items`}</span>}\n            <Amount value={total} className=\"shrink-0 text-[14px] font-medium text-fg\" />\n          </button>\n        )}\n        <div\n          id={`${id}-panel`}\n          className={cn(\n            \"grid transition-[grid-template-rows,visibility] duration-300 ease-in-out-quart motion-reduce:transition-none\",\n            !folds || open ? \"visible grid-rows-[1fr]\" : \"invisible grid-rows-[0fr]\",\n            // On wider screens \"auto\" is always open, whatever the phone state says.\n            auto && \"sm:visible sm:grid-rows-[1fr]\",\n          )}\n        >\n          <div className=\"min-h-0 overflow-hidden\">\n            <div className={cn(\"flex flex-col gap-3.5 p-4\", folds && \"border-t border-line\", auto && \"sm:border-t-0\")}>\n              {auto && <h2 className=\"hidden text-[14px] font-medium tracking-[-0.015em] text-fg sm:block\">{title}</h2>}\n              {children}\n            </div>\n          </div>\n        </div>\n      </section>\n    </MoneyContext.Provider>\n  );\n}\n\nexport type OrderSummaryItemsProps = React.ComponentProps<\"ul\">;\n\nexport function OrderSummaryItems({ className, children, ...rest }: OrderSummaryItemsProps) {\n  return (\n    <ul aria-label=\"Items\" className={cn(\"flex flex-col\", className)} {...rest}>\n      <AnimatePresence initial={false}>{children}</AnimatePresence>\n    </ul>\n  );\n}\n\nexport type OrderSummaryItemProps = Omit<React.ComponentProps<\"li\">, \"children\"> & {\n  name: string;\n  variant?: string;\n  quantity: number;\n  /** Unit price; the line shows price × quantity. */\n  price: number;\n  /** Image URL or any node. */\n  image?: string | React.ReactNode;\n};\n\nexport function OrderSummaryItem({ name, variant, quantity, price, image, className, ...rest }: OrderSummaryItemProps) {\n  const reduce = useReducedMotion();\n  const [broken, setBroken] = useState(false);\n  const [moving, setMoving] = useState(false);\n  return (\n    <motion.li\n      initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      animate={{ opacity: 1, height: \"auto\" }}\n      exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n      transition={{ duration: 0.26, ease: ease.inOut }}\n      // Clipped only while the height moves, so the quantity badge can overhang at rest.\n      onAnimationStart={() => setMoving(true)}\n      onAnimationComplete={() => setMoving(false)}\n      className={cn(moving && \"overflow-hidden\", className)}\n      {...(rest as React.ComponentProps<typeof motion.li>)}\n    >\n      <div className=\"flex items-center gap-3 py-1.5\">\n        <span className=\"relative shrink-0\">\n          <span className=\"grid size-11 place-items-center overflow-hidden rounded-lg border border-line bg-hover text-fg-4\">\n            {typeof image === \"string\" && !broken ? (\n              // eslint-disable-next-line @next/next/no-img-element -- a copied component can't assume next/image\n              <img src={image} alt=\"\" width={44} height={44} loading=\"lazy\" onError={() => setBroken(true)} className=\"size-full object-cover\" />\n            ) : typeof image === \"string\" || image == null ? (\n              <ImageIcon size={16} />\n            ) : (\n              image\n            )}\n          </span>\n          <span\n            aria-hidden\n            className=\"tabular absolute -right-1.5 -top-1.5 grid h-[18px] min-w-[18px] place-items-center rounded-full bg-fg px-1 text-[10.5px] font-medium text-frame ring-2 ring-raised\"\n          >\n            <NumberFlow value={quantity} className=\"leading-none\" />\n          </span>\n        </span>\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"text-[13px] font-medium leading-[1.35] text-fg\">\n            {name}\n            <span className=\"sr-only\">, quantity {quantity}</span>\n          </p>\n          {variant && <p className=\"truncate text-[12px] text-fg-3\">{variant}</p>}\n        </div>\n        <Amount value={price * quantity} className=\"shrink-0 text-[13px] text-fg\" />\n      </div>\n    </motion.li>\n  );\n}\n\nexport type OrderSummaryRowsProps = React.ComponentProps<\"dl\">;\n\nexport function OrderSummaryRows({ className, ...rest }: OrderSummaryRowsProps) {\n  return <dl className={cn(\"flex flex-col gap-2 border-t border-line pt-3.5\", className)} {...rest} />;\n}\n\nexport type OrderSummaryRowProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  label: React.ReactNode;\n  /** A small note after the label, like a code or a rate: \"SPRING20\", \"VAT 21%\". */\n  detail?: React.ReactNode;\n  /** The amount. 0 shows freeLabel; null shows pendingLabel. Discounts are negative. */\n  amount: number | null;\n  /** Show a placeholder while the amount is being worked out. */\n  loading?: boolean;\n  tone?: \"default\" | \"discount\";\n  freeLabel?: string;\n  pendingLabel?: string;\n};\n\nexport function OrderSummaryRow({\n  label,\n  detail,\n  amount,\n  loading = false,\n  tone = \"default\",\n  freeLabel = \"Free\",\n  pendingLabel = \"Calculated at next step\",\n  className,\n  ...rest\n}: OrderSummaryRowProps) {\n  const reduce = useReducedMotion();\n  const discount = tone === \"discount\";\n  const state = loading ? \"loading\" : amount == null ? \"pending\" : amount === 0 ? \"free\" : \"amount\";\n\n  return (\n    <div className={cn(\"flex min-h-5 items-center justify-between gap-4 text-[13px]\", className)} {...rest}>\n      <dt className=\"flex min-w-0 items-center gap-2 text-fg-2\">\n        <span className=\"shrink-0\">{label}</span>\n        {detail && (\n          <span className={cn(\"truncate rounded-[4px] px-1.5 py-px font-mono text-[11px] tracking-[0.02em]\", discount ? \"bg-success-soft text-success\" : \"bg-hover text-fg-3\")}>\n            {detail}\n          </span>\n        )}\n      </dt>\n      <dd aria-busy={loading || undefined} className={cn(\"relative flex shrink-0 justify-end\", discount ? \"text-success\" : \"text-fg\")}>\n        {/* Loading, pending and free swap in place; a real amount rolls instead of swapping. */}\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={state}\n            initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n            animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n            transition={{ duration: 0.2, ease: ease.out }}\n            className=\"flex items-center\"\n          >\n            {state === \"loading\" ? (\n              <span className=\"block h-3 w-12 animate-pulse-soft rounded-[4px] bg-line-2 motion-reduce:animate-none\">\n                <span className=\"sr-only\">Calculating</span>\n              </span>\n            ) : state === \"pending\" ? (\n              <span className=\"text-[12.5px] text-fg-3\">{pendingLabel}</span>\n            ) : state === \"free\" ? (\n              <span className=\"font-medium\">{freeLabel}</span>\n            ) : discount ? (\n              <span className=\"flex items-center\">\n                −<Amount value={Math.abs(amount ?? 0)} />\n              </span>\n            ) : (\n              <Amount value={amount ?? 0} />\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </dd>\n    </div>\n  );\n}\n\nexport type OrderSummaryTotalProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The total to show. Defaults to the summary's total. */\n  value?: number;\n  label?: string;\n  /** One quiet line under the total, like \"Including €18.80 in VAT\". */\n  note?: React.ReactNode;\n};\n\nexport function OrderSummaryTotal({ value, label = \"Total\", note, className, ...rest }: OrderSummaryTotalProps) {\n  const { currency, total } = useContext(MoneyContext);\n  return (\n    <div className={cn(\"flex flex-col gap-1 border-t border-line pt-3.5\", className)} {...rest}>\n      <div className=\"flex items-center justify-between gap-4\">\n        <span className=\"text-[14px] font-medium text-fg\">{label}</span>\n        <span className=\"flex items-baseline gap-1.5\">\n          <span className=\"font-mono text-2xs text-fg-4\">{currency}</span>\n          <Amount value={value ?? total} className=\"text-[17px] font-medium tracking-[-0.015em] text-fg\" />\n        </span>\n      </div>\n      {note && <p className=\"text-[12px] text-fg-3\">{note}</p>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/order-summary"
}
