{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "eased-commerce-core",
  "title": "Commerce core primitive",
  "description": "Shared pieces for Eased e-commerce blocks: drawn product art, colour swatches, the price line, a rolling quantity stepper, the bag button and money and stock formats.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-number-roll.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/_primitives/commerce-core/commerce-core.tsx",
      "content": "\"use client\";\n\n/**\n * commerce-core: the pieces every Eased e-commerce block shares, so a product card, a cart and\n * a wishlist read as one store: drawn product art, colour swatches, the price line, the\n * quantity stepper and the money and stock formats. Import from here.\n */\nimport { AnimatePresence, motion, useAnimate } from \"motion/react\";\nimport { useEffect, useId, useRef, type ComponentProps, type CSSProperties } from \"react\";\nimport { trueMinus } from \"../../lib/format\";\nimport { ease, reducedFade, swap, swapIn, swapOut, swapShown, useSafeReducedMotion } from \"../../lib/motion\";\nimport { pressIcon } from \"../../lib/press\";\nimport { NumberRoll } from \"../number-roll/number-roll\";\n\n/* ---------- Types ------------------------------------------------------------------------ */\n\n/** One buyable combination, as every commerce block takes it. */\nexport type ProductVariant = {\n  id: string;\n  /** Colourway name, e.g. \"Moss\". */\n  color: string;\n  /** The colourway's swatch colour (product data, like a photo). */\n  swatch: string;\n  /** \"M\", or \"One size\". */\n  size: string;\n  stock: number;\n  price: number;\n  compareAt?: number;\n};\n\n/* ---------- Formats ---------------------------------------------------------------------- */\n\nexport const COMMERCE_LOCALE = \"en-US\";\n\nexport type MoneyOptions = { currency?: string; locale?: Intl.LocalesArgument; /** Default: none for whole amounts, 2 otherwise. */ decimals?: number };\n\n/** Intl options for a price, ready for NumberRoll's `format`: `$248`, `$89.50`. */\nexport const moneyFormat = (value: number, { currency = \"USD\", decimals }: MoneyOptions = {}): Intl.NumberFormatOptions => {\n  const d = decimals ?? (Number.isInteger(value) ? 0 : 2);\n  return { style: \"currency\", currency, minimumFractionDigits: d, maximumFractionDigits: d };\n};\n\n/** `$248`, `$1,284.50`, `−$51.30` (true minus). */\nexport const formatMoney = (value: number, { locale = COMMERCE_LOCALE, ...o }: MoneyOptions = {}) =>\n  trueMinus(new Intl.NumberFormat(locale, moneyFormat(value, o)).format(value));\n\n/** `−20%` for a markdown, or undefined when there is none. */\nexport const discountLabel = (price: number, compareAt?: number, locale: Intl.LocalesArgument = COMMERCE_LOCALE) =>\n  compareAt && compareAt > price\n    ? trueMinus(new Intl.NumberFormat(locale, { style: \"percent\", maximumFractionDigits: 0, signDisplay: \"always\" }).format(-(1 - price / compareAt)))\n    : undefined;\n\nexport type StockState = \"in\" | \"low\" | \"out\";\n/** `out` at 0, `low` at or under `lowAt` (default 3), else `in`. */\nexport const stockState = (stock: number, lowAt = 3): StockState => (stock <= 0 ? \"out\" : stock <= lowAt ? \"low\" : \"in\");\n\n/* ---------- Price ------------------------------------------------------------------------ */\n\nexport type PriceProps = MoneyOptions & {\n  value: number;\n  /** The original price; shown struck through when higher than `value`. */\n  compareAt?: number;\n  /** Roll the digits when the value changes. Default true. */\n  roll?: boolean;\n  className?: string;\n};\n\n/** The selling price (rolls when it changes), then the struck original, muted. */\nexport function Price({ value, compareAt, roll = true, currency = \"USD\", locale = COMMERCE_LOCALE, decimals, className = \"\" }: PriceProps) {\n  const sale = compareAt !== undefined && compareAt > value;\n  const fmt = moneyFormat(value, { currency, decimals });\n  const spoken = `${formatMoney(value, { currency, locale, decimals })}${sale ? `, was ${formatMoney(compareAt, { currency, locale, decimals })}` : \"\"}`;\n  return (\n    <span className={`inline-flex items-baseline gap-1.5 tabular-nums ${className}`}>\n      <span className=\"sr-only\">{spoken}</span>\n      <span aria-hidden className=\"text-text\">\n        {roll ? <NumberRoll value={value} locale={locale} format={fmt} aria-hidden /> : formatMoney(value, { currency, locale, decimals })}\n      </span>\n      <AnimatePresence initial={false}>\n        {sale && (\n          <motion.s\n            key=\"was\"\n            aria-hidden\n            className=\"text-text-muted decoration-text-muted/70\"\n            initial={{ opacity: 0 }}\n            animate={{ opacity: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n          >\n            {formatMoney(compareAt, { currency, locale, decimals })}\n          </motion.s>\n        )}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/* ---------- Swatch ----------------------------------------------------------------------- */\n\n/** A colourway dot. The colour is product data (like a photo), so it is the one raw value allowed in. */\nexport function Swatch({ color, size = 16, className = \"\" }: { color: string; size?: number; className?: string }) {\n  return (\n    <span\n      aria-hidden\n      style={{ \"--swatch\": color, width: size, height: size } as CSSProperties}\n      className={`relative inline-block shrink-0 rounded-pill bg-(--swatch) image-ring after:rounded-[inherit] ${className}`}\n    />\n  );\n}\n\n/* ---------- Product art ------------------------------------------------------------------ */\n\nexport type ProductArtKind = \"jacket\" | \"shell\" | \"crew\" | \"short\" | \"pack\" | \"beanie\";\n\nexport type ProductArtProps = {\n  kind: ProductArtKind;\n  /** The colourway's hex: the garment's fill; the backdrop is a 12% tint of it on the sunken surface. */\n  color: string;\n  /** Accessible name, e.g. \"Ridgeline Down Jacket in Moss\". Leave it out when a caption already names it. */\n  label?: string;\n  /** Classes for the frame. Give it a size or an aspect ratio; the art keeps 4:5 and centres. */\n  className?: string;\n};\n\nconst BODY = \"M50 25h20c4 4 10 6 16 8 8 3 12 10 14 22l4 52q0 4-4 4h-8q-3 0-3-3l-3-42v52q0 4-4 4H38q-4 0-4-4V66l-3 42q0 3-3 3h-8q-4 0-4-4l4-52c2-12 6-19 14-22 6-2 12-4 16-8Z\";\n\n/**\n * A drawn product shot: a garment silhouette in the colourway over a soft tint of it, lit from\n * the top left, with a contact shadow. Stands in for photography in demos and empty states;\n * swap it for an `<img>` with `image-ring` in production.\n */\nexport function ProductArt({ kind, color, label, className = \"\" }: ProductArtProps) {\n  const id = useId().replace(/:/g, \"\");\n  const shade = `url(#${id}s)`;\n  const line = \"stroke-black/20\";\n  const lift = \"stroke-white/15\";\n  return (\n    <span\n      role={label ? \"img\" : undefined}\n      aria-label={label}\n      aria-hidden={label ? undefined : true}\n      style={{ \"--art\": color } as CSSProperties}\n      className={`relative flex items-center justify-center overflow-hidden bg-[color-mix(in_oklch,var(--art)_13%,var(--color-surface-sunken))] image-ring after:rounded-[inherit] ${className}`}\n    >\n      <svg viewBox=\"0 0 120 150\" className=\"size-full dark:brightness-[.93] dark:saturate-[.96]\" aria-hidden>\n        <defs>\n          <linearGradient id={`${id}s`} x1=\"0\" y1=\"0\" x2=\"1\" y2=\"1\">\n            <stop offset=\"0\" stopColor=\"#fff\" stopOpacity=\"0.2\" />\n            <stop offset=\"0.45\" stopColor=\"#fff\" stopOpacity=\"0\" />\n            <stop offset=\"1\" stopColor=\"#000\" stopOpacity=\"0.22\" />\n          </linearGradient>\n          <filter id={`${id}b`} x=\"-20%\" y=\"-200%\" width=\"140%\" height=\"500%\">\n            <feGaussianBlur stdDeviation=\"2.5\" />\n          </filter>\n        </defs>\n        <ellipse cx=\"60\" cy=\"131\" rx={kind === \"beanie\" ? 30 : 36} ry=\"3.5\" fill=\"#000\" opacity=\"0.16\" filter={`url(#${id}b)`} />\n        <g fill=\"var(--art)\" strokeWidth=\"1\" fillRule=\"evenodd\" strokeLinecap=\"round\">\n          {(kind === \"jacket\" || kind === \"shell\" || kind === \"crew\") && (\n            <>\n              {kind === \"shell\" && <path d=\"M45 32c-3-15 6-22 15-22s18 7 15 22Z\" />}\n              {kind === \"shell\" && <path d=\"M45 32c-3-15 6-22 15-22s18 7 15 22Z\" fill={shade} />}\n              <path d={BODY} />\n              <path d={BODY} fill={shade} />\n              <path d=\"M86 38v28M34 38v28\" fill=\"none\" className={line} />\n            </>\n          )}\n          {kind === \"jacket\" && (\n            <g fill=\"none\">\n              <path d=\"M48 21q12-4 24 0v11q-12 4-24 0Z\" fill=\"var(--art)\" />\n              <path d=\"M48 21q12-4 24 0v11q-12 4-24 0Z\" fill={shade} />\n              {[48, 60, 72, 84, 96, 108].map((y) => (\n                <path key={y} d={`M34 ${y}q26 3 52 0`} className={line} />\n              ))}\n              {[49, 61, 73, 85, 97, 109].map((y) => (\n                <path key={y} d={`M36 ${y}q24 3 48 0`} className={lift} />\n              ))}\n              {[62, 76, 90, 104].map((y) => (\n                <path key={y} d={`M${18 + (y - 62) / 14} ${y}h12M${90 - (y - 62) / 14} ${y}h12`} className={line} />\n              ))}\n              <path d=\"M60 32v90\" className=\"stroke-black/30\" strokeWidth=\"1.2\" />\n              <path d=\"M45 88l3 12M75 88l-3 12\" className={line} />\n            </g>\n          )}\n          {kind === \"shell\" && (\n            <g fill=\"none\">\n              <path d=\"M50 30q10 5 20 0\" className={line} />\n              <path d=\"M60 30v92\" className=\"stroke-black/30\" strokeWidth=\"1.2\" />\n              <path d=\"M66 46l12-4M44 90l3 12M76 90l-3 12\" className={line} />\n              <path d=\"M38 50q22 6 44 0\" className={lift} />\n              <path d=\"M54 33v6M66 33v6\" className=\"stroke-black/25\" strokeWidth=\"1.5\" />\n            </g>\n          )}\n          {kind === \"crew\" && (\n            <g fill=\"none\">\n              <path d=\"M48 26q12 12 24 0\" className=\"stroke-black/25\" strokeWidth=\"3\" />\n              <path d=\"M34 112h52M20 100h11M89 100h11\" className={line} />\n              {Array.from({ length: 13 }, (_, i) => 37 + i * 4).map((x) => (\n                <path key={x} d={`M${x} 113v8`} className=\"stroke-black/12\" />\n              ))}\n              <path d=\"M38 52q22 5 44 0\" className={lift} />\n            </g>\n          )}\n          {kind === \"short\" && (\n            <>\n              <path d=\"M32 42h56l5 62q0 3-3 3l-26 3q-3 0-3-3l-1-34-1 34q0 3-3 3l-26-3q-3 0-3-3Z\" />\n              <path d=\"M32 42h56l5 62q0 3-3 3l-26 3q-3 0-3-3l-1-34-1 34q0 3-3 3l-26-3q-3 0-3-3Z\" fill={shade} />\n              <g fill=\"none\">\n                <path d=\"M32 52h56\" className={line} />\n                <path d=\"M60 52v20M40 52q2 12 12 14M80 52q-2 12-12 14M44 42v10M76 42v10\" className={line} />\n                <path d=\"M57 46q3 6 6 0\" className=\"stroke-black/30\" strokeWidth=\"1.2\" />\n                <path d=\"M36 60q8 2 14 0\" className={lift} />\n              </g>\n            </>\n          )}\n          {kind === \"pack\" && (\n            <>\n              <path d=\"M52 30q0-10 8-10t8 10\" fill=\"none\" className=\"stroke-black/35\" strokeWidth=\"3\" />\n              <rect x=\"34\" y=\"28\" width=\"52\" height=\"98\" rx=\"16\" />\n              <rect x=\"34\" y=\"28\" width=\"52\" height=\"98\" rx=\"16\" fill={shade} />\n              <path d=\"M34 58q0-30 26-30t26 30q-26 6-52 0Z\" />\n              <path d=\"M34 58q0-30 26-30t26 30q-26 6-52 0Z\" fill={shade} />\n              <rect x=\"43\" y=\"76\" width=\"34\" height=\"40\" rx=\"9\" />\n              <g fill=\"none\">\n                <path d=\"M34 58q26 6 52 0\" className={line} />\n                <rect x=\"43\" y=\"76\" width=\"34\" height=\"40\" rx=\"9\" className={line} />\n                <path d=\"M47 84h26\" className=\"stroke-black/30\" strokeWidth=\"1.2\" />\n                <path d=\"M30 72h8M82 72h8M30 100h8M82 100h8\" className=\"stroke-black/30\" strokeWidth=\"2\" />\n                <path d=\"M42 40q18-8 36 0\" className={lift} />\n              </g>\n            </>\n          )}\n          {kind === \"beanie\" && (\n            <>\n              <path d=\"M32 96c0-34 12-50 28-50s28 16 28 50Z\" />\n              <path d=\"M32 96c0-34 12-50 28-50s28 16 28 50Z\" fill={shade} />\n              <rect x=\"29\" y=\"90\" width=\"62\" height=\"28\" rx=\"7\" />\n              <rect x=\"29\" y=\"90\" width=\"62\" height=\"28\" rx=\"7\" fill={shade} />\n              <g fill=\"none\">\n                {[-18, -9, 0, 9, 18].map((dx) => (\n                  <path key={dx} d={`M${60 + dx * 1.4} 92Q${60 + dx * 1.1} 60 60 47`} className=\"stroke-black/12\" />\n                ))}\n                {Array.from({ length: 14 }, (_, i) => 33 + i * 4).map((x) => (\n                  <path key={x} d={`M${x} 93v22`} className=\"stroke-black/15\" />\n                ))}\n                <path d=\"M29 92h62\" className={line} />\n                <path d=\"M42 64q8-10 18-12\" className={lift} strokeWidth=\"1.5\" />\n              </g>\n            </>\n          )}\n        </g>\n      </svg>\n    </span>\n  );\n}\n\n/* ---------- Quantity stepper ------------------------------------------------------------- */\n\nexport type QtyStepperProps = {\n  value: number;\n  onValueChange: (value: number) => void;\n  min?: number;\n  max?: number;\n  /** At `min`, the minus becomes a bin and calls this instead. */\n  onRemove?: () => void;\n  /** What is being counted, for the buttons' names: \"Ridgeline Down Jacket\". */\n  label: string;\n  locale?: Intl.LocalesArgument;\n  className?: string;\n};\n\n/**\n * − 2 +, 32px, digits rolling in the direction of the step. With `onRemove`, the minus morphs\n * into a bin at the minimum, so the last press removes the line instead of dead-ending.\n */\nexport function QtyStepper({ value, onValueChange, min = 1, max = 99, onRemove, label, locale = COMMERCE_LOCALE, className = \"\" }: QtyStepperProps) {\n  const reduced = useSafeReducedMotion();\n  const bin = onRemove !== undefined && value <= min;\n  const btn = `relative inline-flex h-full w-8 shrink-0 cursor-pointer items-center justify-center rounded-[inherit] text-text-secondary hover-fine:not-disabled:bg-surface-hover hover-fine:not-disabled:text-text disabled:cursor-not-allowed disabled:text-text-disabled ${pressIcon}`;\n  return (\n    <div role=\"group\" aria-label={`Quantity, ${label}`} className={`inline-flex h-control-sm shrink-0 items-stretch rounded-control-sm border border-line p-px [--pad:1px] ${className}`}>\n      <button\n        type=\"button\"\n        aria-label={bin ? `Remove ${label}` : `Decrease quantity, ${label}`}\n        disabled={!bin && value <= min}\n        onClick={() => (bin ? onRemove?.() : onValueChange(Math.max(min, value - 1)))}\n        className={`${btn} radius-nested`}\n      >\n        <span className=\"inline-grid place-items-center *:col-start-1 *:row-start-1\">\n          <AnimatePresence initial={false}>\n            <motion.svg\n              key={bin ? \"bin\" : \"minus\"}\n              viewBox=\"0 0 16 16\"\n              className=\"size-4\"\n              fill=\"none\"\n              stroke=\"currentColor\"\n              strokeWidth={1.5}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              aria-hidden\n              initial={reduced ? { opacity: 0 } : swapIn}\n              animate={reduced ? { opacity: 1 } : swapShown}\n              exit={reduced ? { opacity: 0 } : swapOut}\n              transition={reduced ? reducedFade : swap}\n            >\n              {bin ? <path d=\"M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.1a1 1 0 0 0 1 .9h3.8a1 1 0 0 0 1-.9l.6-8.1\" /> : <path d=\"M4 8h8\" />}\n            </motion.svg>\n          </AnimatePresence>\n        </span>\n      </button>\n      <output aria-live=\"polite\" aria-label={`Quantity ${value}`} className=\"flex min-w-7 items-center justify-center text-ui-sm font-medium text-text tabular-nums\">\n        <NumberRoll value={value} locale={locale} aria-hidden />\n      </output>\n      <button\n        type=\"button\"\n        aria-label={`Increase quantity, ${label}`}\n        disabled={value >= max}\n        onClick={() => onValueChange(Math.min(max, value + 1))}\n        className={`${btn} radius-nested`}\n      >\n        <svg viewBox=\"0 0 16 16\" className=\"size-4\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" aria-hidden>\n          <path d=\"M4 8h8M8 4v8\" />\n        </svg>\n      </button>\n    </div>\n  );\n}\n\n/* ---------- Icons ------------------------------------------------------------------------ */\n\n/** The bag glyph every block's cart button uses, 16px, 1.5 stroke. */\nexport function BagIcon({ className = \"size-4\" }: { className?: string }) {\n  return (\n    <svg viewBox=\"0 0 16 16\" className={`shrink-0 ${className}`} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M3.25 5.25h9.5l-.7 7.6a1.5 1.5 0 0 1-1.5 1.4h-5.1a1.5 1.5 0 0 1-1.5-1.4Z\" />\n      <path d=\"M5.75 5.25V4.5a2.25 2.25 0 0 1 4.5 0v.75\" />\n    </svg>\n  );\n}\n\n\n/* ---------- Bag button ------------------------------------------------------------------- */\n\nexport type BagButtonProps = Omit<ComponentProps<\"button\">, \"children\" | \"type\"> & {\n  /** Items in the bag. The count rolls, and pops whenever it goes up. */\n  count: number;\n  locale?: Intl.LocalesArgument;\n};\n\n/**\n * The header's bag: a 32px icon button with a count bubble that rolls its digits and pops on\n * every add, so a quick add elsewhere on the page lands somewhere visible. Hidden at zero.\n */\nexport function BagButton({ count, locale = COMMERCE_LOCALE, className = \"\", ref, ...props }: BagButtonProps) {\n  const reduced = useSafeReducedMotion();\n  const [scope, animate] = useAnimate<HTMLSpanElement>();\n  const prev = useRef(count);\n  useEffect(() => {\n    if (count > prev.current && prev.current > 0 && !reduced && scope.current) {\n      animate(scope.current, { scale: [1, 1.22, 1] }, { duration: 0.4, ease: ease.enter });\n    }\n    prev.current = count;\n  }, [count, reduced, animate, scope]);\n  return (\n    <button\n      ref={ref}\n      type=\"button\"\n      aria-label={`Bag, ${count} item${count === 1 ? \"\" : \"s\"}`}\n      {...props}\n      className={`relative inline-flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-control-sm text-text-secondary hover-fine:bg-surface-hover hover-fine:text-text ${pressIcon} ${className}`}\n    >\n      <BagIcon className=\"size-[18px]\" />\n      <AnimatePresence initial={false}>\n        {count > 0 && (\n          <motion.span\n            key=\"count\"\n            aria-hidden\n            className=\"absolute -top-0.5 -right-0.5 flex\"\n            initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n            animate={{ opacity: 1, scale: 1, transition: reduced ? reducedFade : swap }}\n            exit={{ opacity: 0, scale: reduced ? 1 : 0.8, transition: { duration: 0.1 } }}\n          >\n            <span ref={scope} className=\"flex h-4 min-w-4 items-center justify-center rounded-pill bg-text px-1 text-ui-2xs font-medium text-surface tabular-nums ring-2 ring-surface\">\n              <NumberRoll value={count} locale={locale} aria-hidden />\n            </span>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/_primitives/commerce-core/commerce-core.tsx"
    }
  ],
  "type": "registry:ui"
}