{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "template-goods",
  "title": "Kiln & Co: online store",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A five-page store with a bag that persists across pages: a collage home, a filterable shop, a product page with glaze and gallery pickers, a bag with a free-shipping meter and promo code, and a validated checkout. Products are drawn in CSS.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/sheet.json",
    "https://ui.ballmac.com/r/slider.json",
    "https://ui.ballmac.com/r/progress.json",
    "https://ui.ballmac.com/r/accordion.json",
    "https://ui.ballmac.com/r/media.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/templates/goods/goods-fonts.ts",
      "content": "// Ballmac UI: Goods template fonts. https://ui.ballmac.com/templates/template-goods\nimport { Fraunces, Hanken_Grotesk } from \"next/font/google\"\n\nexport const goodsSerif = Fraunces({ variable: \"--goods-serif\", subsets: [\"latin\"], display: \"swap\", preload: false })\nexport const goodsSans = Hanken_Grotesk({ variable: \"--goods-sans\", subsets: [\"latin\"], display: \"swap\", preload: false })\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-fonts.ts"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-data.ts",
      "content": "import type { MediaSource } from \"@/components/ballmac/media\"\n// Ballmac UI: Goods template data. https://ui.ballmac.com/templates/template-goods\nexport type Shape = \"mug\" | \"bowl\" | \"vase\" | \"plate\" | \"pitcher\" | \"cup\"\nexport type Category = \"Mugs\" | \"Bowls\" | \"Vases\" | \"Plates\"\n\nexport const glazes = [\n  { name: \"Terracotta\", cls: \"bg-chart-1\", border: \"border-chart-1\" },\n  { name: \"Sage\", cls: \"bg-chart-2\", border: \"border-chart-2\" },\n  { name: \"Mustard\", cls: \"bg-chart-3\", border: \"border-chart-3\" },\n  { name: \"Slate\", cls: \"bg-chart-4\", border: \"border-chart-4\" },\n  { name: \"Blush\", cls: \"bg-chart-5\", border: \"border-chart-5\" },\n] as const\n\nexport type Product = {\n  id: string\n  name: string\n  category: Category\n  shape: Shape\n  price: number\n  glazes: number[]\n  tagline: string\n  description: string\n  size: string\n  rating: number\n  reviews: number\n  tag?: \"New\" | \"Low stock\" | \"Bestseller\"\n  inStock: boolean\n  /** Your own photos instead of the drawn piece, keyed by glaze number (the index into `glazes`). A glaze without a photo keeps the drawing. Each photo is an image URL, an object with alt text and a dark-mode file, or your own element. */\n  images?: Partial<Record<number, MediaSource>>\n}\n\nexport const products: Product[] = [\n  { id: \"morning-mug\", name: \"Morning mug\", category: \"Mugs\", shape: \"mug\", price: 34, glazes: [0, 1, 2, 4], tagline: \"Thick walls, a thumb rest and room for a proper coffee.\", description: \"Thrown on the wheel and glazed twice, so the colour pools where your thumb rests. Holds 350 ml, which is exactly one slow coffee.\", size: \"9 cm × 8 cm · 350 ml\", rating: 4.9, reviews: 212, tag: \"Bestseller\", inStock: true },\n  { id: \"tea-cup\", name: \"Tea cup\", category: \"Mugs\", shape: \"cup\", price: 26, glazes: [1, 3, 4], tagline: \"No handle, all warmth.\", description: \"A handleless cup that sits in two hands. The wide foot keeps it steady on a crowded table.\", size: \"8 cm × 7 cm · 200 ml\", rating: 4.7, reviews: 98, inStock: true },\n  { id: \"noodle-bowl\", name: \"Noodle bowl\", category: \"Bowls\", shape: \"bowl\", price: 48, glazes: [0, 2, 3], tagline: \"Deep enough for broth, wide enough for company.\", description: \"A generous bowl with a slightly turned-in rim that keeps the steam where you want it. Dishwasher and microwave safe.\", size: \"19 cm × 9 cm · 900 ml\", rating: 4.8, reviews: 156, tag: \"Bestseller\", inStock: true },\n  { id: \"breakfast-bowl\", name: \"Breakfast bowl\", category: \"Bowls\", shape: \"bowl\", price: 36, glazes: [1, 4, 2], tagline: \"Porridge, yoghurt, whatever the day needs.\", description: \"A shallow bowl with a soft speckle that shows through the glaze. Stacks neatly in sets of four.\", size: \"15 cm × 6 cm · 500 ml\", rating: 4.6, reviews: 74, inStock: true },\n  { id: \"tall-vase\", name: \"Tall vase\", category: \"Vases\", shape: \"vase\", price: 78, glazes: [3, 0, 1], tagline: \"One stem looks intentional.\", description: \"A narrow-necked vase built to hold a single branch or three tulips. Watertight, with a ground foot that will not scratch wood.\", size: \"14 cm × 30 cm\", rating: 4.9, reviews: 63, tag: \"New\", inStock: true },\n  { id: \"bud-vase\", name: \"Bud vase\", category: \"Vases\", shape: \"vase\", price: 42, glazes: [2, 4, 0], tagline: \"Small, round and difficult to dislike.\", description: \"A pocket-sized vase for a single flower from the garden or the corner shop. Makes a good present.\", size: \"9 cm × 12 cm\", rating: 4.8, reviews: 121, inStock: true },\n  { id: \"dinner-plate\", name: \"Dinner plate\", category: \"Plates\", shape: \"plate\", price: 44, glazes: [0, 1, 3, 4], tagline: \"A wide rim, a quiet glaze.\", description: \"A flat plate with a raised rim, glazed in a satin finish that hides knife marks. Sold singly or in fours.\", size: \"27 cm diameter\", rating: 4.7, reviews: 187, inStock: true },\n  { id: \"side-plate\", name: \"Side plate\", category: \"Plates\", shape: \"plate\", price: 28, glazes: [1, 2, 4], tagline: \"For toast, cheese and everything in between.\", description: \"The plate you will reach for ten times a day. Light enough to carry two in one hand.\", size: \"19 cm diameter\", rating: 4.8, reviews: 143, tag: \"Low stock\", inStock: true },\n  { id: \"water-pitcher\", name: \"Water pitcher\", category: \"Vases\", shape: \"pitcher\", price: 68, glazes: [3, 1, 0], tagline: \"A pour that does not drip.\", description: \"A one-litre pitcher with a pinched spout and a handle you can hold with two fingers. Equally good with flowers.\", size: \"13 cm × 19 cm · 1 L\", rating: 4.9, reviews: 88, tag: \"New\", inStock: true },\n  { id: \"serving-bowl\", name: \"Serving bowl\", category: \"Bowls\", shape: \"bowl\", price: 62, glazes: [2, 0, 1], tagline: \"Big enough for the table.\", description: \"A wide bowl for salad, pasta or fruit. The inside is glazed in a lighter tone that makes food look good.\", size: \"28 cm × 12 cm · 2.4 L\", rating: 4.8, reviews: 52, inStock: true },\n  { id: \"espresso-cup\", name: \"Espresso cup\", category: \"Mugs\", shape: \"cup\", price: 22, glazes: [0, 3], tagline: \"Small, strong, a little bit smug.\", description: \"A thick-walled cup that keeps a shot warm. Pairs with the Morning mug for people who need both.\", size: \"6 cm × 6 cm · 90 ml\", rating: 4.5, reviews: 41, inStock: false },\n  { id: \"salad-plate\", name: \"Salad plate\", category: \"Plates\", shape: \"plate\", price: 36, glazes: [4, 1, 2], tagline: \"Between a dinner and a side.\", description: \"A 23 cm plate that suits lunch for one. Glazed on both sides so it lasts through years of dishwashers.\", size: \"23 cm diameter\", rating: 4.6, reviews: 66, inStock: true },\n]\n\nexport const productById = (id: string) => products.find((p) => p.id === id) ?? products[0]!\n\nexport const reviewList = [\n  { name: \"Hannah P.\", stars: 5, title: \"My favourite thing in the kitchen\", body: \"I have dropped it twice and it has survived both. The glaze is even prettier in person.\", date: \"Sep 12, 2026\" },\n  { name: \"Marcus L.\", stars: 5, title: \"A proper size\", body: \"Most mugs are too small. This one holds a full coffee and feels good to hold.\", date: \"Aug 30, 2026\" },\n  { name: \"Aiko T.\", stars: 4, title: \"Beautiful, a little heavy\", body: \"Heavier than I expected, in a good way. Shipped with no packing waste, which I loved.\", date: \"Aug 14, 2026\" },\n  { name: \"Sofia R.\", stars: 5, title: \"Bought a second set\", body: \"Gave one to my sister and then bought another for myself. They look better the longer you use them.\", date: \"Jul 29, 2026\" },\n]\n\nexport const FREE_SHIPPING = 80\nexport const PROMO = { code: \"KILN10\", rate: 0.1 }\nexport const money = (n: number) => `$${n.toLocaleString(\"en-US\", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}`\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-data.ts"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-cart.ts",
      "content": "// Ballmac UI: Goods cart store. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\n\nimport { productById } from \"@/components/ballmac/templates/goods/goods-data\"\n\nexport type CartLine = { id: string; glaze: number; qty: number }\n\nconst KEY = \"kiln-bag\"\nlet lines: CartLine[] = []\nlet loaded = false\nconst listeners = new Set<() => void>()\n\nfunction load() {\n  if (loaded || typeof window === \"undefined\") return\n  loaded = true\n  try {\n    const raw = window.sessionStorage.getItem(KEY)\n    if (raw) lines = (JSON.parse(raw) as CartLine[]).filter((l) => l && typeof l.id === \"string\" && l.qty > 0)\n  } catch {\n    // Storage can be blocked; the bag still works for this page.\n  }\n}\nfunction commit(next: CartLine[]) {\n  lines = next\n  try {\n    window.sessionStorage.setItem(KEY, JSON.stringify(next))\n  } catch {\n    // ignore\n  }\n  listeners.forEach((l) => l())\n}\n\n/** The bag, shared by every page and kept for the browser session. */\nexport const cart = {\n  add(id: string, glaze: number, qty = 1) {\n    load()\n    const found = lines.find((l) => l.id === id && l.glaze === glaze)\n    commit(found ? lines.map((l) => (l === found ? { ...l, qty: Math.min(10, l.qty + qty) } : l)) : [...lines, { id, glaze, qty }])\n  },\n  setQty(id: string, glaze: number, qty: number) {\n    load()\n    commit(qty <= 0 ? lines.filter((l) => !(l.id === id && l.glaze === glaze)) : lines.map((l) => (l.id === id && l.glaze === glaze ? { ...l, qty: Math.min(10, qty) } : l)))\n  },\n  remove(id: string, glaze: number) {\n    load()\n    commit(lines.filter((l) => !(l.id === id && l.glaze === glaze)))\n  },\n  clear() {\n    load()\n    commit([])\n  },\n}\n\nlet promoOn = false\nlet promoLoaded = false\nfunction loadPromo() {\n  if (promoLoaded || typeof window === \"undefined\") return\n  promoLoaded = true\n  try {\n    promoOn = window.sessionStorage.getItem(\"kiln-promo\") === \"1\"\n  } catch {\n    // ignore\n  }\n}\n/** The KILN10 code, kept for the session so the bag and checkout agree. */\nexport const promo = {\n  set(on: boolean) {\n    loadPromo()\n    promoOn = on\n    try {\n      window.sessionStorage.setItem(\"kiln-promo\", on ? \"1\" : \"0\")\n    } catch {\n      // ignore\n    }\n    listeners.forEach((l) => l())\n  },\n}\nexport function usePromo() {\n  return React.useSyncExternalStore(subscribe, () => { loadPromo(); return promoOn }, () => false)\n}\n\nconst empty: CartLine[] = []\nfunction subscribe(cb: () => void) {\n  listeners.add(cb)\n  return () => void listeners.delete(cb)\n}\n/** Lines in the bag, with totals. Empty on the server and first render so markup matches. */\nexport function useCart() {\n  const current = React.useSyncExternalStore(subscribe, () => { load(); return lines }, () => empty)\n  const detailed = current.map((l) => ({ ...l, product: productById(l.id) }))\n  const count = detailed.reduce((n, l) => n + l.qty, 0)\n  const subtotal = detailed.reduce((n, l) => n + l.qty * l.product.price, 0)\n  return { lines: detailed, count, subtotal }\n}\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-cart.ts"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-theme.tsx",
      "content": "// Ballmac UI: Goods template shell. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\nimport { Menu, Minus, Plus, ShoppingBag, Star, X } from \"lucide-react\"\n\nimport { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from \"@/components/ballmac/sheet\"\nimport { cart, useCart } from \"@/components/ballmac/templates/goods/goods-cart\"\nimport { FREE_SHIPPING, glazes, money, type Shape } from \"@/components/ballmac/templates/goods/goods-data\"\nimport { goodsSans, goodsSerif } from \"@/components/ballmac/templates/goods/goods-fonts\"\nimport { cn } from \"@/lib/utils\"\nimport { Media, type MediaSource } from \"@/components/ballmac/media\"\n\ntype GoodsPage = \"home\" | \"shop\" | \"product\" | \"cart\" | \"checkout\"\ntype GoodsHrefs = Record<GoodsPage, string>\n\nconst defaultHrefs: GoodsHrefs = { home: \"/goods\", shop: \"/goods/shop\", product: \"/goods/shop/morning-mug\", cart: \"/goods/cart\", checkout: \"/goods/checkout\" }\n\n/** Kiln & Co's palette: oat paper, warm brown ink and five glazes. Dark mode is a studio at night with the kiln still glowing. */\nconst goodsCss = `\n.goods-theme,body:not(:has([data-gallery])):has(.goods-theme){--background:oklch(0.968 0.02 85);--foreground:oklch(0.26 0.04 50);--card:oklch(0.985 0.012 85);--card-foreground:oklch(0.26 0.04 50);--popover:oklch(0.985 0.012 85);--popover-foreground:oklch(0.26 0.04 50);--primary:oklch(0.32 0.05 45);--primary-foreground:oklch(0.968 0.02 85);--secondary:oklch(0.93 0.03 80);--secondary-foreground:oklch(0.26 0.04 50);--muted:oklch(0.935 0.028 80);--muted-foreground:oklch(0.46 0.04 55);--accent:oklch(0.92 0.04 75);--accent-foreground:oklch(0.26 0.04 50);--border:oklch(0.26 0.04 50 / 14%);--input:oklch(0.26 0.04 50 / 22%);--ring:oklch(0.55 0.14 45);--surface:oklch(0.95 0.025 82);--destructive:oklch(0.52 0.21 27);--chart-1:oklch(0.62 0.14 42);--chart-2:oklch(0.68 0.07 150);--chart-3:oklch(0.8 0.13 88);--chart-4:oklch(0.55 0.06 240);--chart-5:oklch(0.82 0.07 25);--goods-wall:oklch(0.9 0.035 78);--goods-wall-2:oklch(0.82 0.045 70);--goods-on-clay:oklch(0.2 0.04 50);--radius:1rem}\n.dark .goods-theme,.dark body:not(:has([data-gallery])):has(.goods-theme){--background:oklch(0.19 0.02 50);--foreground:oklch(0.95 0.02 80);--card:oklch(0.23 0.022 50);--card-foreground:oklch(0.95 0.02 80);--popover:oklch(0.25 0.024 50);--popover-foreground:oklch(0.95 0.02 80);--primary:oklch(0.88 0.06 75);--primary-foreground:oklch(0.22 0.03 50);--secondary:oklch(0.28 0.025 50);--secondary-foreground:oklch(0.95 0.02 80);--muted:oklch(0.27 0.025 50);--muted-foreground:oklch(0.74 0.04 70);--accent:oklch(0.31 0.03 50);--accent-foreground:oklch(0.95 0.02 80);--border:oklch(1 0 0 / 11%);--input:oklch(1 0 0 / 16%);--ring:oklch(0.75 0.13 50);--surface:oklch(0.21 0.022 50);--destructive:oklch(0.7 0.19 27);--chart-1:oklch(0.7 0.14 45);--chart-2:oklch(0.74 0.08 150);--chart-3:oklch(0.84 0.12 88);--chart-4:oklch(0.68 0.08 240);--chart-5:oklch(0.8 0.08 25);--goods-wall:oklch(0.3 0.03 55);--goods-wall-2:oklch(0.26 0.03 50)}\nbody:not(:has([data-gallery])):has(.goods-theme){font-family:var(--goods-sans),ui-sans-serif,system-ui,sans-serif}\n`\n\nconst serif = \"[font-family:var(--goods-serif),ui-serif,Georgia,serif] font-medium tracking-[-0.02em]\"\nconst onClay = \"text-[var(--goods-on-clay)]\"\n\n/**\n * A piece of ceramics drawn in CSS: a mug, bowl, vase, plate, pitcher or cup in one of five glazes, on a wall.\n * `view` changes the composition (front, close, pair, dark wall) so a gallery has something to switch between.\n */\nfunction Piece({ shape, glaze = 0, view = 0, className, image, imageAlt }: { shape: Shape; glaze?: number; view?: number; className?: string; image?: MediaSource; imageAlt?: string }) {\n  if (image) return <Media media={image} alt={imageAlt} fill className={cn(\"aspect-[4/5] w-full\", className)} />\n  const gl = glazes[glaze % glazes.length]!\n  const g = gl.cls\n  const shine = \"bg-gradient-to-br from-white/35 via-transparent to-black/25\"\n  const wall = view === 3 ? \"bg-[var(--goods-wall-2)]\" : \"bg-[var(--goods-wall)]\"\n  const body = (cls: string, extra?: React.ReactNode) => (\n    <div className={cn(\"absolute overflow-hidden\", g, cls)}>\n      <div className={cn(\"absolute inset-0 rounded-[inherit]\", shine)} />\n      {extra}\n    </div>\n  )\n  const art: Record<Shape, React.ReactNode> = {\n    mug: (<>\n      <div className={cn(\"absolute top-[40%] start-[54%] aspect-square w-[22%] rounded-full border-[7px] border-solid bg-transparent\", gl.border)} />\n      {body(\"top-[34%] start-[28%] h-[34%] w-[38%] rounded-t-md rounded-b-[26%]\", <div className=\"absolute inset-x-0 top-0 h-[10%] rounded-t-md bg-black/20\" />)}\n    </>),\n    cup: (<>\n      {body(\"top-[40%] start-[30%] h-[28%] w-[40%] rounded-t-sm rounded-b-[45%]\", <div className=\"absolute inset-x-0 top-0 h-[12%] rounded-t-sm bg-black/20\" />)}\n    </>),\n    bowl: (<>\n      {body(\"top-[42%] start-[16%] h-[28%] w-[68%] rounded-t-md rounded-b-full\", <div className=\"absolute inset-x-0 top-0 h-[14%] rounded-t-md bg-black/20\" />)}\n    </>),\n    vase: (<>\n      {body(\"top-[16%] start-[43%] h-[16%] w-[14%] rounded-t-md\", <div className=\"absolute inset-x-0 top-0 h-[16%] bg-black/25\" />)}\n      {body(\"top-[28%] start-[27%] h-[44%] w-[46%] rounded-[50%_50%_30%_30%/45%_45%_22%_22%]\")}\n    </>),\n    plate: (<>\n      {body(\"top-[52%] start-[10%] h-[10%] w-[80%] rounded-[50%]\")}\n      <div className=\"absolute top-[53.5%] start-[18%] h-[5%] w-[64%] rounded-[50%] bg-black/15\" />\n    </>),\n    pitcher: (<>\n      <div className={cn(\"absolute top-[36%] start-[56%] aspect-square w-[20%] rounded-full border-[7px] bg-transparent\", gl.border)} />\n      {body(\"top-[28%] start-[26%] h-[44%] w-[40%] rounded-[30%_30%_26%_26%/20%_20%_22%_22%]\", <div className=\"absolute inset-x-0 top-0 h-[8%] rounded-t-[30%] bg-black/20\" />)}\n      {body(\"top-[28%] start-[18%] h-[8%] w-[14%] -rotate-[28deg] rounded-md\")}\n    </>),\n  }\n  const ground = shape === \"plate\" ? \"top-[60%]\" : shape === \"vase\" ? \"top-[70%]\" : shape === \"bowl\" ? \"top-[68%]\" : \"top-[66%]\"\n  return (\n    <div aria-hidden=\"true\" className={cn(\"relative isolate aspect-[4/5] w-full overflow-hidden\", wall, className)}>\n      <div className=\"absolute inset-x-0 bottom-0 h-[34%] bg-black/[0.06]\" />\n      <div className={cn(\"absolute left-1/2 h-[4%] w-[52%] -translate-x-1/2 rounded-[50%] bg-black/20 blur-[3px]\", ground)} style={{ top: shape === \"plate\" ? \"62%\" : undefined }} />\n      <div className=\"absolute inset-0 origin-center\" style={{ transform: view === 1 ? \"scale(1.25) translateY(2%)\" : view === 2 ? \"scale(0.82) translateX(-14%)\" : undefined }}>{art[shape]}</div>\n      {view === 2 && (\n        <div className=\"absolute inset-0 origin-center\" style={{ transform: \"scale(0.6) translate(48%, 14%)\" }}>{art[shape === \"mug\" ? \"cup\" : shape === \"bowl\" ? \"plate\" : \"mug\"]}</div>\n      )}\n    </div>\n  )\n}\n\nfunction Stars({ value, className }: { value: number; className?: string }) {\n  return (\n    <span role=\"img\" aria-label={`${value} out of 5 stars`} className={cn(\"inline-flex gap-0.5\", className)}>\n      {[1, 2, 3, 4, 5].map((i) => <Star key={i} aria-hidden=\"true\" className={cn(\"size-4\", i <= Math.round(value) ? \"fill-chart-3 stroke-chart-3\" : \"stroke-muted-foreground\")} />)}\n    </span>\n  )\n}\n\nconst links: { key: GoodsPage; label: string }[] = [{ key: \"shop\", label: \"Shop\" }, { key: \"home\", label: \"Our story\" }]\n\ntype GoodsShellProps = React.ComponentProps<\"div\"> & {\n  /** The page being shown, so its nav link is marked current. */\n  page: GoodsPage\n  /** Override where pages live (used by previews). */\n  hrefs?: Partial<GoodsHrefs>\n}\n\n/** Kiln & Co's frame: an announcement bar, a header with the bag and a drawer that shows what you have picked. */\nfunction GoodsShell({ page, hrefs: overrides, className, style, children, ...props }: GoodsShellProps) {\n  const hrefs = { ...defaultHrefs, ...overrides }\n  const [menu, setMenu] = React.useState(false)\n  const [bag, setBag] = React.useState(false)\n  const { lines, count, subtotal } = useCart()\n  React.useEffect(() => {\n    const classes = [goodsSerif.variable, goodsSans.variable].filter(Boolean)\n    document.body.classList.add(...classes)\n    return () => document.body.classList.remove(...classes)\n  }, [])\n  const left = Math.max(0, FREE_SHIPPING - subtotal)\n  return (\n    <div\n      data-slot=\"goods\"\n      className={cn(\"goods-theme bg-background text-foreground relative min-h-dvh overflow-x-clip\", goodsSerif.variable, goodsSans.variable, className)}\n      style={{ fontFamily: \"var(--goods-sans), ui-sans-serif, system-ui, sans-serif\", ...style }}\n      {...props}\n    >\n      <style>{goodsCss}</style>\n      <p className=\"bg-primary text-primary-foreground px-4 py-2 text-center text-sm font-medium\">Free shipping on orders over {money(FREE_SHIPPING)} · Made in small batches, shipped in two days</p>\n      <header className=\"bg-background/90 sticky top-0 z-40 border-b backdrop-blur-xl\">\n        <div className=\"mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6\">\n          <nav aria-label=\"Main\" className=\"hidden items-center gap-1 md:flex\">\n            {links.map((l) => <a key={l.label} href={hrefs[l.key]} aria-current={page === l.key || (l.key === \"shop\" && page === \"product\") ? \"page\" : undefined} className=\"hover:bg-accent aria-[current=page]:bg-accent focus-visible:ring-ring/50 rounded-full px-4 py-2 text-sm font-semibold outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\">{l.label}</a>)}\n          </nav>\n          <button type=\"button\" aria-label={menu ? \"Close menu\" : \"Open menu\"} aria-expanded={menu} aria-controls=\"goods-mobile-menu\" onClick={() => setMenu((v) => !v)} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px] md:hidden\">{menu ? <X className=\"size-5\" aria-hidden=\"true\" /> : <Menu className=\"size-5\" aria-hidden=\"true\" />}</button>\n          <a href={hrefs.home} className={cn(\"focus-visible:ring-ring/50 justify-self-center rounded-lg px-2 text-2xl outline-none focus-visible:ring-[3px]\", serif)}>Kiln <span className=\"text-chart-1\">&amp;</span> Co</a>\n          <div className=\"flex justify-end\">\n            <button type=\"button\" onClick={() => setBag(true)} aria-label={`Open your bag, ${count} ${count === 1 ? \"item\" : \"items\"}`} className=\"hover:bg-accent focus-visible:ring-ring/50 relative inline-flex h-10 items-center gap-2 rounded-full px-3 text-sm font-semibold outline-none focus-visible:ring-[3px]\">\n              <ShoppingBag className=\"size-5\" aria-hidden=\"true\" /><span className=\"hidden sm:inline\">Bag</span>\n              <span aria-hidden=\"true\" className={cn(\"bg-chart-1 inline-flex min-w-5 items-center justify-center rounded-full px-1.5 text-xs font-bold tabular-nums\", onClay)}>{count}</span>\n            </button>\n          </div>\n        </div>\n        {menu && <nav id=\"goods-mobile-menu\" aria-label=\"Mobile\" className=\"px-4 pb-4 md:hidden\">{links.map((l) => <a key={l.label} href={hrefs[l.key]} className=\"hover:bg-accent block rounded-2xl px-4 py-3 text-lg font-semibold\">{l.label}</a>)}</nav>}\n      </header>\n\n      <Sheet open={bag} onOpenChange={setBag}>\n        <SheetContent side=\"end\" className=\"goods-theme bg-background flex w-full flex-col gap-0 p-0 sm:max-w-md\">\n          <SheetHeader className=\"border-b p-5 text-start\"><SheetTitle className={cn(\"text-2xl\", serif)}>Your bag</SheetTitle><SheetDescription>{count === 0 ? \"Nothing in here yet.\" : `${count} ${count === 1 ? \"piece\" : \"pieces\"} picked out.`}</SheetDescription></SheetHeader>\n          <div className=\"flex-1 overflow-y-auto p-5\">\n            {lines.length === 0 ? (\n              <div className=\"grid h-full place-items-center text-center\"><div><ShoppingBag className=\"text-muted-foreground mx-auto size-10\" aria-hidden=\"true\" /><p className={cn(\"mt-4 text-2xl\", serif)}>Your bag is empty.</p><a href={hrefs.shop} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-5 inline-flex h-11 items-center rounded-full px-6 font-semibold outline-none focus-visible:ring-[3px]\">Browse the shop</a></div></div>\n            ) : (\n              <ul className=\"grid gap-5\">\n                {lines.map((l) => (\n                  <li key={l.id + l.glaze} className=\"grid grid-cols-[5rem_minmax(0,1fr)] gap-4\">\n                    <Piece shape={l.product.shape} glaze={l.glaze} image={l.product.images?.[l.glaze]} imageAlt={l.product.name} className=\"rounded-xl\" />\n                    <div className=\"min-w-0\">\n                      <p className=\"font-semibold\">{l.product.name}</p>\n                      <p className=\"text-muted-foreground text-sm\">{glazes[l.glaze]!.name} · {money(l.product.price)}</p>\n                      <div className=\"mt-2 flex items-center justify-between\">\n                        <div role=\"group\" aria-label={`Quantity of ${l.product.name}`} className=\"flex items-center rounded-full border\">\n                          <button type=\"button\" aria-label={`Fewer ${l.product.name}`} onClick={() => cart.setQty(l.id, l.glaze, l.qty - 1)} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex size-9 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]\"><Minus className=\"size-4\" aria-hidden=\"true\" /></button>\n                          <span className=\"w-7 text-center text-sm font-semibold tabular-nums\">{l.qty}</span>\n                          <button type=\"button\" aria-label={`More ${l.product.name}`} onClick={() => cart.setQty(l.id, l.glaze, l.qty + 1)} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex size-9 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]\"><Plus className=\"size-4\" aria-hidden=\"true\" /></button>\n                        </div>\n                        <button type=\"button\" onClick={() => cart.remove(l.id, l.glaze)} className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]\">Remove</button>\n                      </div>\n                    </div>\n                  </li>\n                ))}\n              </ul>\n            )}\n          </div>\n          {lines.length > 0 && (\n            <div className=\"grid gap-3 border-t p-5\">\n              <p className=\"text-muted-foreground text-sm\">{left > 0 ? `${money(left)} away from free shipping.` : \"You have free shipping.\"}</p>\n              <p className=\"flex justify-between text-lg font-semibold\"><span>Subtotal</span><span className=\"tabular-nums\">{money(subtotal)}</span></p>\n              <a href={hrefs.checkout} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex h-12 items-center justify-center rounded-full font-bold outline-none focus-visible:ring-[3px]\">Checkout</a>\n              <a href={hrefs.cart} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex h-11 items-center justify-center rounded-full border font-semibold outline-none focus-visible:ring-[3px]\">View full bag</a>\n            </div>\n          )}\n        </SheetContent>\n      </Sheet>\n\n      {children}\n\n      <footer className=\"bg-primary text-primary-foreground mt-24\">\n        <div className=\"mx-auto grid max-w-7xl gap-10 px-4 py-14 sm:px-6 md:grid-cols-[1.5fr_1fr_1fr_1fr]\">\n          <div><p className={cn(\"text-3xl\", serif)}>Kiln &amp; Co</p><p className=\"mt-3 max-w-xs text-pretty\">Everyday ceramics, thrown by hand in a shed in Bristol and fired in batches of forty.</p></div>\n          {[[\"Shop\", [[\"Mugs\", hrefs.shop], [\"Bowls\", hrefs.shop], [\"Vases\", hrefs.shop], [\"Plates\", hrefs.shop]]], [\"Help\", [[\"Shipping\", hrefs.cart], [\"Returns\", hrefs.cart], [\"Care guide\", hrefs.product], [\"Contact\", hrefs.home]]], [\"Studio\", [[\"Our story\", hrefs.home], [\"Visit\", hrefs.home], [\"Wholesale\", hrefs.home]]]].map(([t, items]) => (\n            <div key={t as string}><h2 className=\"text-xs font-bold tracking-[0.14em] uppercase\">{t as string}</h2><ul className=\"mt-4 grid gap-2.5\">{(items as string[][]).map(([l, h]) => <li key={l}><a href={h} className=\"hover:underline\">{l}</a></li>)}</ul></div>\n          ))}\n        </div>\n        <p className=\"mx-auto max-w-7xl border-t border-current/20 px-4 py-5 text-xs sm:px-6\">© 2026 Kiln &amp; Co. Every imperfection is deliberate.</p>\n      </footer>\n    </div>\n  )\n}\n\nexport { GoodsShell, Piece, Stars, defaultHrefs as goodsDefaultHrefs, onClay as goodsOnClay, serif as goodsSerifClass, type GoodsHrefs, type GoodsPage, type GoodsShellProps }\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-theme.tsx"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-home.tsx",
      "content": "// Ballmac UI: Goods home page. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowRight, Check, Flame, Recycle, RotateCcw, Truck } from \"lucide-react\"\n\nimport { cart } from \"@/components/ballmac/templates/goods/goods-cart\"\nimport { glazes, money, products, reviewList, type Product } from \"@/components/ballmac/templates/goods/goods-data\"\nimport { GoodsShell, Piece, Stars, goodsOnClay, goodsSerifClass, type GoodsHrefs } from \"@/components/ballmac/templates/goods/goods-theme\"\nimport { cn } from \"@/lib/utils\"\n\n/** Link to one product: preview pages pass the id as ?p=, installed routes use /goods/shop/<id>. */\nfunction productLink(base: string, id: string) {\n  return base.includes(\"/preview/\") ? `${base}?p=${id}` : base.replace(/[^/]+$/, id)\n}\n\n/** A product tile with the glaze dots and a quick add that confirms in place. */\nfunction ProductCard({ product, href }: { product: Product; href: string }) {\n  const [added, setAdded] = React.useState(false)\n  React.useEffect(() => {\n    if (!added) return\n    const id = window.setTimeout(() => setAdded(false), 1800)\n    return () => window.clearTimeout(id)\n  }, [added])\n  return (\n    <li className=\"group relative\">\n      <a href={href} className=\"focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px]\">\n        <div className=\"relative overflow-hidden rounded-3xl\">\n          <Piece shape={product.shape} glaze={product.glazes[0]} image={product.images?.[product.glazes[0]!]} imageAlt={product.name} className=\"transition-transform duration-500 group-hover:scale-105 motion-reduce:transition-none motion-reduce:group-hover:scale-100\" />\n          {product.tag && <span className={cn(\"bg-chart-3 absolute top-3 start-3 rounded-full px-3 py-1 text-xs font-bold\", goodsOnClay)}>{product.tag}</span>}\n        </div>\n        <span className=\"mt-4 flex items-start justify-between gap-3\"><span className={cn(\"text-xl\", goodsSerifClass)}>{product.name}</span><span className=\"font-semibold tabular-nums\">{money(product.price)}</span></span>\n        <span className=\"text-muted-foreground mt-1 block text-sm text-pretty\">{product.tagline}</span>\n      </a>\n      <div className=\"mt-3 flex items-center justify-between gap-3\">\n        <span className=\"flex gap-1.5\" aria-label={`Glazes: ${product.glazes.map((g) => glazes[g]!.name).join(\", \")}`} role=\"img\">{product.glazes.map((g) => <span key={g} aria-hidden=\"true\" className={cn(\"size-4 rounded-full border\", glazes[g]!.cls)} />)}</span>\n        <button type=\"button\" disabled={!product.inStock} onClick={() => { cart.add(product.id, product.glazes[0]!); setAdded(true) }} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex h-9 items-center gap-1.5 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px] disabled:opacity-60\"><span aria-live=\"polite\">{!product.inStock ? \"Sold out\" : added ? <span className=\"inline-flex items-center gap-1\"><Check className=\"size-4\" aria-hidden=\"true\" />Added</span> : \"Add to bag\"}</span></button>\n      </div>\n    </li>\n  )\n}\n\nconst values = [[Flame, \"Hand-thrown\", \"Every piece is made on the wheel.\"], [Recycle, \"Plastic-free\", \"Packed in paper and shredded card.\"], [Truck, \"Two-day dispatch\", \"From our shed to your door.\"], [RotateCcw, \"30-day returns\", \"No questions, free labels.\"]] as const\nconst cats = [[\"Mugs\", \"mug\", 1], [\"Bowls\", \"bowl\", 2], [\"Vases\", \"vase\", 3], [\"Plates\", \"plate\", 4]] as const\n\ntype GoodsHomeProps = React.ComponentProps<\"div\"> & { hrefs?: Partial<GoodsHrefs> }\n\n/** The Goods home page: a hero collage, values, categories, bestsellers, the story, reviews and a newsletter. */\nfunction GoodsHome({ hrefs, ...props }: GoodsHomeProps) {\n  const link = { shop: \"/goods/shop\", product: \"/goods/shop/morning-mug\", ...hrefs }\n  const [email, setEmail] = React.useState(\"\")\n  const [error, setError] = React.useState(\"\")\n  const [done, setDone] = React.useState(false)\n  function join(e: React.FormEvent) {\n    e.preventDefault()\n    if (!/^\\S+@\\S+\\.\\S+$/.test(email.trim())) return setError(\"Enter an email like you@example.com.\")\n    setError(\"\")\n    setDone(true)\n  }\n  return (\n    <GoodsShell page=\"home\" hrefs={hrefs} {...props}>\n      <main>\n        <section aria-labelledby=\"gh-title\" className=\"mx-auto grid max-w-7xl items-center gap-10 px-4 pt-12 pb-16 sm:px-6 lg:grid-cols-[1.05fr_1fr] lg:pt-20\">\n          <div>\n            <p className=\"bg-secondary inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-semibold\"><Stars value={5} /> 4.8 from 1,240 reviews</p>\n            <h1 id=\"gh-title\" className={cn(\"mt-6 text-[clamp(3.2rem,8vw,7rem)] leading-[0.95] text-balance\", goodsSerifClass)}>Made slowly.<br /><em className=\"text-chart-1\">Used daily.</em></h1>\n            <p className=\"text-muted-foreground mt-6 max-w-md text-xl text-pretty\">Everyday ceramics thrown by hand in a shed in Bristol, fired in batches of forty and sent to your table.</p>\n            <div className=\"mt-8 flex flex-wrap gap-3\">\n              <a href={link.shop} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 inline-flex h-14 items-center gap-2 rounded-full px-8 text-lg font-bold outline-none transition-transform hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0\">Shop the collection <ArrowRight className=\"size-5 rtl:rotate-180\" aria-hidden=\"true\" /></a>\n              <a href={productLink(link.product, \"morning-mug\")} className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex h-14 items-center rounded-full border-2 px-8 text-lg font-bold outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none\">Meet the Morning mug</a>\n            </div>\n          </div>\n          <div aria-hidden=\"true\" className=\"grid grid-cols-2 gap-4\">\n            <Piece shape=\"vase\" glaze={3} view={0} className=\"rounded-[2.5rem] rounded-ee-[6rem]\" />\n            <div className=\"grid gap-4 pt-10\"><Piece shape=\"mug\" glaze={0} view={2} className=\"aspect-square rounded-[2.5rem] rounded-ss-[6rem]\" /><Piece shape=\"bowl\" glaze={1} view={3} className=\"aspect-square rounded-full\" /></div>\n          </div>\n        </section>\n\n        <section aria-label=\"Why Kiln & Co\" className=\"bg-surface border-y\">\n          <ul className=\"mx-auto grid max-w-7xl gap-6 px-4 py-8 sm:grid-cols-2 sm:px-6 lg:grid-cols-4\">{values.map(([Icon, t, b]) => <li key={t} className=\"flex items-start gap-4\"><span className=\"bg-chart-2/30 inline-flex size-11 shrink-0 items-center justify-center rounded-full\"><Icon className=\"size-5\" aria-hidden=\"true\" /></span><span><span className=\"block font-bold\">{t}</span><span className=\"text-muted-foreground text-sm\">{b}</span></span></li>)}</ul>\n        </section>\n\n        <section aria-labelledby=\"gh-cats\" className=\"mx-auto max-w-7xl px-4 py-20 sm:px-6\">\n          <h2 id=\"gh-cats\" className={cn(\"text-4xl sm:text-5xl\", goodsSerifClass)}>Shop by piece</h2>\n          <ul className=\"mt-8 grid grid-cols-2 gap-4 lg:grid-cols-4\">{cats.map(([name, shape, g]) => <li key={name}><a href={link.shop} className=\"focus-visible:ring-ring/50 group block rounded-3xl outline-none focus-visible:ring-[3px]\"><Piece shape={shape} glaze={g} view={1} className=\"aspect-square rounded-3xl transition-transform group-hover:-translate-y-1 motion-reduce:transition-none motion-reduce:group-hover:translate-y-0\" /><span className={cn(\"mt-3 flex items-center justify-between text-2xl\", goodsSerifClass)}>{name}<ArrowRight className=\"size-5 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1\" aria-hidden=\"true\" /></span></a></li>)}</ul>\n        </section>\n\n        <section aria-labelledby=\"gh-best\" className=\"mx-auto max-w-7xl px-4 pb-20 sm:px-6\">\n          <div className=\"flex items-end justify-between gap-4\"><h2 id=\"gh-best\" className={cn(\"text-4xl sm:text-5xl\", goodsSerifClass)}>People keep buying these</h2><a href={link.shop} className=\"text-sm font-bold underline underline-offset-4\">Shop everything</a></div>\n          <ul className=\"mt-8 grid grid-cols-2 gap-x-4 gap-y-10 lg:grid-cols-4\">{[products[0]!, products[2]!, products[4]!, products[6]!].map((p) => <ProductCard key={p.id} product={p} href={productLink(link.product, p.id)} />)}</ul>\n        </section>\n\n        <section aria-labelledby=\"gh-story\" className=\"bg-primary text-primary-foreground\">\n          <div className=\"mx-auto grid max-w-7xl items-center gap-12 px-4 py-20 sm:px-6 lg:grid-cols-2\">\n            <div className=\"grid grid-cols-2 gap-4\" aria-hidden=\"true\"><Piece shape=\"pitcher\" glaze={4} view={3} className=\"rounded-3xl\" /><Piece shape=\"plate\" glaze={2} view={1} className=\"mt-12 rounded-3xl\" /></div>\n            <div>\n              <h2 id=\"gh-story\" className={cn(\"text-4xl text-balance sm:text-6xl\", goodsSerifClass)}>A small studio with a very big kiln.</h2>\n              <p className=\"mt-5 max-w-lg text-lg text-pretty\">Three potters, one shed and a kiln named Gerald. We make forty pieces at a time, so nothing sits in a warehouse and nothing is wasted.</p>\n              <dl className=\"mt-8 grid grid-cols-3 gap-4 border-t border-current/25 pt-6\">{[[\"412\", \"firings last year\"], [\"40\", \"pieces per batch\"], [\"0\", \"plastic in the box\"]].map(([n, l]) => <div key={l}><dd className={cn(\"text-4xl sm:text-5xl\", goodsSerifClass)}>{n}</dd><dt className=\"mt-1 text-sm\">{l}</dt></div>)}</dl>\n            </div>\n          </div>\n        </section>\n\n        <section aria-labelledby=\"gh-rev\" className=\"mx-auto max-w-7xl px-4 py-20 sm:px-6\">\n          <h2 id=\"gh-rev\" className={cn(\"text-4xl sm:text-5xl\", goodsSerifClass)}>Kind words</h2>\n          <ul className=\"mt-8 grid gap-4 md:grid-cols-3\">{reviewList.slice(0, 3).map((r) => <li key={r.name} className=\"bg-card rounded-3xl border p-7\"><Stars value={r.stars} /><p className={cn(\"mt-4 text-2xl text-balance\", goodsSerifClass)}>“{r.title}”</p><p className=\"text-muted-foreground mt-3 text-pretty\">{r.body}</p><p className=\"mt-5 text-sm font-semibold\">{r.name}</p></li>)}</ul>\n        </section>\n\n        <section aria-labelledby=\"gh-news\" className=\"mx-auto max-w-7xl px-4 pb-8 sm:px-6\">\n          <div className=\"bg-chart-3/40 rounded-[2rem] px-6 py-14 text-center sm:px-12\">\n            <h2 id=\"gh-news\" className={cn(\"mx-auto max-w-xl text-4xl text-balance sm:text-5xl\", goodsSerifClass)}>Get 10% off your first piece</h2>\n            <p className=\"mx-auto mt-3 max-w-md text-pretty\">Join the kiln list for new glazes, studio sales and the odd photo of Gerald.</p>\n            {done ? <p role=\"status\" className=\"mt-6 inline-flex items-center gap-2 font-semibold\"><Check className=\"size-5\" aria-hidden=\"true\" />Welcome. Your code is KILN10.</p> : (\n              <form onSubmit={join} noValidate className=\"mx-auto mt-6 flex max-w-md flex-col gap-2 sm:flex-row\">\n                <label htmlFor=\"gh-email\" className=\"sr-only\">Email address</label>\n                <input id=\"gh-email\" type=\"email\" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!error} aria-describedby={error ? \"gh-err\" : undefined} placeholder=\"you@example.com\" className=\"bg-background focus-visible:ring-ring/50 aria-invalid:border-destructive h-12 min-w-0 flex-1 rounded-full border px-5 outline-none focus-visible:ring-[3px]\" />\n                <button type=\"submit\" className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 h-12 rounded-full px-7 font-bold outline-none focus-visible:ring-[3px]\">Send my code</button>\n              </form>\n            )}\n            {error && <p id=\"gh-err\" role=\"alert\" className=\"text-destructive mt-2 text-sm font-medium\">{error}</p>}\n          </div>\n        </section>\n      </main>\n    </GoodsShell>\n  )\n}\n\nexport { GoodsHome, ProductCard, productLink, type GoodsHomeProps }\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-home.tsx"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-shop.tsx",
      "content": "// Ballmac UI: Goods shop page. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\nimport { SlidersHorizontal, X } from \"lucide-react\"\n\nimport { Slider } from \"@/components/ballmac/slider\"\nimport { ProductCard, productLink } from \"@/components/ballmac/templates/goods/goods-home\"\nimport { glazes, money, products, type Category } from \"@/components/ballmac/templates/goods/goods-data\"\nimport { GoodsShell, goodsSerifClass, type GoodsHrefs } from \"@/components/ballmac/templates/goods/goods-theme\"\nimport { cn } from \"@/lib/utils\"\n\nconst categories: Category[] = [\"Mugs\", \"Bowls\", \"Vases\", \"Plates\"]\nconst sorts = [[\"featured\", \"Featured\"], [\"low\", \"Price: low to high\"], [\"high\", \"Price: high to low\"], [\"rating\", \"Best rated\"]] as const\n\ntype GoodsShopProps = React.ComponentProps<\"div\"> & { hrefs?: Partial<GoodsHrefs> }\n\n/** The shop: category, price, glaze and stock filters with removable chips, sorting and an honest empty state. */\nfunction GoodsShop({ hrefs, ...props }: GoodsShopProps) {\n  const link = { product: \"/goods/shop/morning-mug\", ...hrefs }\n  const [cats, setCats] = React.useState<Category[]>([])\n  const [maxPrice, setMaxPrice] = React.useState(80)\n  const [picked, setPicked] = React.useState<number[]>([])\n  const [stock, setStock] = React.useState(false)\n  const [sort, setSort] = React.useState<(typeof sorts)[number][0]>(\"featured\")\n  const toggle = <T,>(set: React.Dispatch<React.SetStateAction<T[]>>, v: T) => set((a) => (a.includes(v) ? a.filter((x) => x !== v) : [...a, v]))\n  const list = products\n    .filter((p) => (cats.length === 0 || cats.includes(p.category)) && p.price <= maxPrice && (picked.length === 0 || p.glazes.some((g) => picked.includes(g))) && (!stock || p.inStock))\n    .sort((a, b) => (sort === \"low\" ? a.price - b.price : sort === \"high\" ? b.price - a.price : sort === \"rating\" ? b.rating - a.rating : 0))\n  const active = cats.length + picked.length + (stock ? 1 : 0) + (maxPrice < 80 ? 1 : 0)\n  const clear = () => { setCats([]); setPicked([]); setStock(false); setMaxPrice(80) }\n  const chip = \"bg-secondary hover:bg-accent focus-visible:ring-ring/50 inline-flex h-8 items-center gap-1.5 rounded-full ps-3 pe-2 text-sm font-medium outline-none focus-visible:ring-[3px]\"\n\n  const filters = (p: string) => (\n    <div className=\"grid gap-8\">\n      <fieldset><legend className=\"mb-3 font-bold\">Piece</legend><div className=\"flex flex-wrap gap-2\">{categories.map((c) => <button key={c} type=\"button\" aria-pressed={cats.includes(c)} onClick={() => toggle(setCats, c)} className=\"hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-10 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]\">{c} <span className=\"opacity-100\">({products.filter((p) => p.category === c).length})</span></button>)}</div></fieldset>\n      <div><p id={`${p}-price`} className=\"mb-3 flex justify-between font-bold\">Price<span className=\"text-muted-foreground font-normal\">up to {money(maxPrice)}</span></p><Slider aria-labelledby={`${p}-price`} thumbLabels={[\"Maximum price\"]} min={20} max={80} step={2} value={[maxPrice]} onValueChange={(v) => setMaxPrice(v[0]!)} /></div>\n      <fieldset><legend className=\"mb-3 font-bold\">Glaze</legend><div className=\"flex flex-wrap gap-3\">{glazes.map((g, i) => <button key={g.name} type=\"button\" aria-pressed={picked.includes(i)} aria-label={g.name} title={g.name} onClick={() => toggle(setPicked, i)} className={cn(\"focus-visible:ring-ring/50 relative size-9 rounded-full border-2 border-transparent outline-none focus-visible:ring-[3px] aria-pressed:border-foreground aria-pressed:ring-2 aria-pressed:ring-background aria-pressed:ring-offset-0\", g.cls)} />)}</div></fieldset>\n      <label className=\"flex cursor-pointer items-center gap-3 font-semibold\"><input type=\"checkbox\" checked={stock} onChange={(e) => setStock(e.target.checked)} className=\"accent-primary size-5\" />In stock only</label>\n    </div>\n  )\n\n  return (\n    <GoodsShell page=\"shop\" hrefs={hrefs} {...props}>\n      <main className=\"mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6\">\n        <h1 className={cn(\"text-[clamp(3rem,8vw,6rem)] leading-none\", goodsSerifClass)}>The shop</h1>\n        <p className=\"text-muted-foreground mt-3 max-w-xl text-xl text-pretty\">Twelve pieces, five glazes and nothing you do not need.</p>\n\n        <div className=\"mt-10 grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[15rem_minmax(0,1fr)]\">\n          <aside aria-label=\"Filters\" className=\"hidden lg:block\">{filters(\"gs-d\")}</aside>\n          <div>\n            <div className=\"flex flex-wrap items-center justify-between gap-3\">\n              <details className=\"lg:hidden\"><summary className=\"hover:bg-accent focus-visible:ring-ring/50 inline-flex h-10 cursor-pointer list-none items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]\"><SlidersHorizontal className=\"size-4\" aria-hidden=\"true\" />Filters{active > 0 ? ` (${active})` : \"\"}</summary><div className=\"bg-card mt-3 rounded-3xl border p-5\">{filters(\"gs-m\")}</div></details>\n              <p role=\"status\" className=\"text-muted-foreground text-sm\">{list.length} {list.length === 1 ? \"piece\" : \"pieces\"}</p>\n              <div className=\"ms-auto flex items-center gap-2 text-sm\"><label htmlFor=\"gs-sort\" className=\"text-muted-foreground\">Sort</label><select id=\"gs-sort\" value={sort} onChange={(e) => setSort(e.target.value as typeof sort)} className=\"bg-card focus-visible:ring-ring/50 h-10 rounded-full border px-3 font-medium outline-none focus-visible:ring-[3px]\">{sorts.map(([v, l]) => <option key={v} value={v}>{l}</option>)}</select></div>\n            </div>\n            {active > 0 && (\n              <div className=\"mt-4 flex flex-wrap items-center gap-2\" aria-label=\"Active filters\" role=\"group\">\n                {cats.map((c) => <button key={c} type=\"button\" aria-label={`Remove filter ${c}`} className={chip} onClick={() => toggle(setCats, c)}>{c}<X className=\"size-4\" aria-hidden=\"true\" /></button>)}\n                {picked.map((g) => <button key={g} type=\"button\" aria-label={`Remove filter ${glazes[g]!.name}`} className={chip} onClick={() => toggle(setPicked, g)}>{glazes[g]!.name}<X className=\"size-4\" aria-hidden=\"true\" /></button>)}\n                {maxPrice < 80 && <button type=\"button\" aria-label=\"Remove price filter\" className={chip} onClick={() => setMaxPrice(80)}>Up to {money(maxPrice)}<X className=\"size-4\" aria-hidden=\"true\" /></button>}\n                {stock && <button type=\"button\" aria-label=\"Remove in stock filter\" className={chip} onClick={() => setStock(false)}>In stock<X className=\"size-4\" aria-hidden=\"true\" /></button>}\n                <button type=\"button\" onClick={clear} className=\"focus-visible:ring-ring/50 rounded text-sm font-semibold underline underline-offset-4 outline-none focus-visible:ring-[3px]\">Clear all</button>\n              </div>\n            )}\n            {list.length > 0 ? (\n              <ul className=\"mt-6 grid grid-cols-2 gap-x-4 gap-y-10 md:grid-cols-3\">{list.map((p) => <ProductCard key={p.id} product={p} href={productLink(link.product, p.id)} />)}</ul>\n            ) : (\n              <div className=\"bg-surface mt-6 rounded-3xl border p-12 text-center\"><p className={cn(\"text-3xl\", goodsSerifClass)}>Nothing fits that exactly.</p><p className=\"text-muted-foreground mt-2\">Try a higher price or fewer glazes.</p><button type=\"button\" onClick={clear} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-6 h-11 rounded-full px-6 font-bold outline-none focus-visible:ring-[3px]\">Clear filters</button></div>\n            )}\n          </div>\n        </div>\n      </main>\n    </GoodsShell>\n  )\n}\n\nexport { GoodsShop, type GoodsShopProps }\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-shop.tsx"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-product.tsx",
      "content": "// Ballmac UI: Goods product page. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Minus, Plus, Truck } from \"lucide-react\"\n\nimport { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from \"@/components/ballmac/accordion\"\nimport { cart } from \"@/components/ballmac/templates/goods/goods-cart\"\nimport { glazes, money, productById, products, reviewList } from \"@/components/ballmac/templates/goods/goods-data\"\nimport { ProductCard, productLink } from \"@/components/ballmac/templates/goods/goods-home\"\nimport { GoodsShell, Piece, Stars, goodsSerifClass, type GoodsHrefs } from \"@/components/ballmac/templates/goods/goods-theme\"\nimport { cn } from \"@/lib/utils\"\n\nconst viewNames = [\"Front\", \"Close up\", \"In a pair\", \"On a dark wall\"]\nconst bars = [[5, 78], [4, 15], [3, 5], [2, 1], [1, 1]]\n\ntype GoodsProductProps = React.ComponentProps<\"div\"> & {\n  hrefs?: Partial<GoodsHrefs>\n  /** Product id. When left out the page reads ?p= from the address, then falls back to the first product. */\n  slug?: string\n}\n\n/** A product page: gallery with four views, glaze and quantity pickers, an add-to-bag that confirms, details, reviews and pairings. */\nfunction GoodsProduct({ hrefs, slug, ...props }: GoodsProductProps) {\n  const link = { shop: \"/goods/shop\", product: \"/goods/shop/morning-mug\", cart: \"/goods/cart\", ...hrefs }\n  const [id, setId] = React.useState(slug ?? products[0]!.id)\n  React.useEffect(() => {\n    if (slug) return setId(slug)\n    const p = new URLSearchParams(window.location.search).get(\"p\")\n    if (p) setId(p)\n  }, [slug])\n  const product = productById(id)\n  const [glaze, setGlaze] = React.useState(product.glazes[0]!)\n  const [view, setView] = React.useState(0)\n  const [qty, setQty] = React.useState(1)\n  const [added, setAdded] = React.useState(false)\n  React.useEffect(() => { setGlaze(product.glazes[0]!); setView(0); setQty(1); setAdded(false) }, [product])\n  const pairs = products.filter((p) => p.category !== product.category && p.id !== product.id).slice(0, 4)\n  const picker = \"hover:bg-accent focus-visible:ring-ring/50 inline-flex size-11 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]\"\n\n  return (\n    <GoodsShell page=\"product\" hrefs={hrefs} {...props}>\n      <main className=\"mx-auto max-w-7xl px-4 pt-8 pb-8 sm:px-6\">\n        <nav aria-label=\"Breadcrumb\" className=\"text-muted-foreground text-sm\"><a href={link.shop} className=\"hover:text-foreground underline underline-offset-4\">Shop</a><span aria-hidden=\"true\"> / </span><span>{product.category}</span><span aria-hidden=\"true\"> / </span><span aria-current=\"page\" className=\"text-foreground font-medium\">{product.name}</span></nav>\n\n        <div className=\"mt-6 grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[1.1fr_1fr]\">\n          <div className=\"grid gap-4 sm:grid-cols-[5rem_minmax(0,1fr)]\">\n            <div role=\"radiogroup\" aria-label=\"Gallery views\" className=\"order-2 grid grid-cols-4 gap-3 sm:order-1 sm:grid-cols-1 sm:content-start\">\n              {viewNames.map((v, i) => (\n                <button key={v} type=\"button\" role=\"radio\" aria-checked={view === i} aria-label={v} onClick={() => setView(i)} className={cn(\"focus-visible:ring-ring/50 overflow-hidden rounded-xl border-2 outline-none focus-visible:ring-[3px]\", view === i ? \"border-foreground\" : \"border-transparent\")}><Piece shape={product.shape} glaze={glaze} image={product.images?.[glaze]} imageAlt={product.name} view={i} /></button>\n              ))}\n            </div>\n            <div className=\"order-1 sm:order-2\"><Piece shape={product.shape} glaze={glaze} image={product.images?.[glaze]} imageAlt={product.name} view={view} className=\"rounded-3xl\" /><p role=\"status\" className=\"sr-only\">Showing the {viewNames[view]!.toLowerCase()} view in {glazes[glaze]!.name}</p></div>\n          </div>\n\n          <div>\n            <h1 className={cn(\"text-[clamp(2.6rem,5vw,4rem)] leading-none\", goodsSerifClass)}>{product.name}</h1>\n            <p className=\"mt-3 flex items-center gap-3 text-sm\"><Stars value={product.rating} /><a href=\"#reviews\" className=\"underline underline-offset-4\">{product.rating} · {product.reviews} reviews</a></p>\n            <p className=\"mt-5 text-3xl font-semibold tabular-nums\">{money(product.price)}</p>\n            <p className=\"text-muted-foreground mt-4 text-lg text-pretty\">{product.description}</p>\n\n            <fieldset className=\"mt-8\"><legend className=\"font-bold\">Glaze: <span className=\"font-normal\">{glazes[glaze]!.name}</span></legend>\n              <div className=\"mt-3 flex gap-3\">{product.glazes.map((g) => <label key={g} className={cn(\"focus-within:ring-ring/50 relative size-11 cursor-pointer rounded-full border-2 transition-shadow focus-within:ring-[3px] motion-reduce:transition-none\", glazes[g]!.cls, glaze === g ? \"border-foreground ring-background ring-2 ring-inset\" : \"border-transparent\")}><input type=\"radio\" name=\"glaze\" value={g} checked={glaze === g} onChange={() => setGlaze(g)} className=\"sr-only\" /><span className=\"sr-only\">{glazes[g]!.name}</span></label>)}</div>\n            </fieldset>\n\n            <div className=\"mt-8 flex flex-wrap items-center gap-4\">\n              <div role=\"group\" aria-label=\"Quantity\" className=\"flex items-center rounded-full border\">\n                <button type=\"button\" aria-label=\"Fewer\" disabled={qty <= 1} onClick={() => setQty((q) => Math.max(1, q - 1))} className={cn(picker, \"disabled:opacity-50\")}><Minus className=\"size-4\" aria-hidden=\"true\" /></button>\n                <output aria-live=\"polite\" className=\"w-9 text-center font-semibold tabular-nums\">{qty}</output>\n                <button type=\"button\" aria-label=\"More\" disabled={qty >= 10} onClick={() => setQty((q) => Math.min(10, q + 1))} className={cn(picker, \"disabled:opacity-50\")}><Plus className=\"size-4\" aria-hidden=\"true\" /></button>\n              </div>\n              <button type=\"button\" disabled={!product.inStock} onClick={() => { cart.add(product.id, glaze, qty); setAdded(true) }} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 h-14 flex-1 rounded-full px-8 text-lg font-bold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px] disabled:opacity-60\">{product.inStock ? `Add to bag · ${money(product.price * qty)}` : \"Sold out\"}</button>\n            </div>\n            <p role=\"status\" className=\"mt-3 min-h-6 text-sm font-medium\">{added ? <span className=\"inline-flex items-center gap-2\"><Check className=\"size-4\" aria-hidden=\"true\" />Added to your bag. <a href={link.cart} className=\"underline underline-offset-4\">View bag</a></span> : null}</p>\n            <p className=\"bg-secondary mt-4 flex items-center gap-3 rounded-2xl p-4 text-sm\"><Truck className=\"size-5 shrink-0\" aria-hidden=\"true\" />Ships in two working days. Free over $80, free returns for 30 days.</p>\n\n            <Accordion type=\"single\" collapsible defaultValue=\"size\" className=\"mt-8\">\n              <AccordionItem value=\"size\"><AccordionTrigger className=\"font-bold\">Size and capacity</AccordionTrigger><AccordionContent className=\"text-muted-foreground\">{product.size}. Sizes vary by up to 5 mm because each piece is made by hand.</AccordionContent></AccordionItem>\n              <AccordionItem value=\"care\"><AccordionTrigger className=\"font-bold\">Care</AccordionTrigger><AccordionContent className=\"text-muted-foreground\">Dishwasher, microwave and oven safe to 220°C. Avoid sudden changes from freezer to hob. Hand wash if you want the glaze to last a lifetime.</AccordionContent></AccordionItem>\n              <AccordionItem value=\"ship\"><AccordionTrigger className=\"font-bold\">Shipping and returns</AccordionTrigger><AccordionContent className=\"text-muted-foreground\">UK delivery takes two to three days and costs $6. Return anything within 30 days with the prepaid label in the box.</AccordionContent></AccordionItem>\n            </Accordion>\n          </div>\n        </div>\n\n        <section id=\"reviews\" aria-labelledby=\"gp-rev\" className=\"mt-20 scroll-mt-24 grid gap-10 lg:grid-cols-[18rem_minmax(0,1fr)]\">\n          <div>\n            <h2 id=\"gp-rev\" className={cn(\"text-3xl\", goodsSerifClass)}>Reviews</h2>\n            <p className=\"mt-4 flex items-end gap-3\"><span className={cn(\"text-6xl leading-none\", goodsSerifClass)}>{product.rating}</span><Stars value={product.rating} className=\"pb-1\" /></p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">{product.reviews} reviews</p>\n            <ul className=\"mt-5 grid gap-2\" aria-label=\"Rating breakdown\">{bars.map(([s, pct]) => <li key={s} className=\"grid grid-cols-[2.5rem_1fr_2.5rem] items-center gap-3 text-sm\"><span>{s} star</span><span className=\"bg-muted h-2 overflow-hidden rounded-full\" aria-hidden=\"true\"><span className=\"bg-chart-3 block h-full\" style={{ width: `${pct}%` }} /></span><span className=\"text-muted-foreground text-end tabular-nums\">{pct}%</span></li>)}</ul>\n          </div>\n          <ul className=\"divide-y border-y\">{reviewList.map((r) => <li key={r.name} className=\"py-6\"><div className=\"flex items-center justify-between gap-3\"><Stars value={r.stars} /><span className=\"text-muted-foreground text-sm\">{r.date}</span></div><h3 className={cn(\"mt-3 text-xl\", goodsSerifClass)}>{r.title}</h3><p className=\"text-muted-foreground mt-1 text-pretty\">{r.body}</p><p className=\"mt-3 text-sm font-semibold\">{r.name} <span className=\"text-muted-foreground font-normal\">· Verified buyer</span></p></li>)}</ul>\n        </section>\n\n        <section aria-labelledby=\"gp-pair\" className=\"mt-20\">\n          <h2 id=\"gp-pair\" className={cn(\"text-3xl sm:text-4xl\", goodsSerifClass)}>Pairs well with</h2>\n          <ul className=\"mt-6 grid grid-cols-2 gap-x-4 gap-y-10 lg:grid-cols-4\">{pairs.map((p) => <ProductCard key={p.id} product={p} href={productLink(link.product, p.id)} />)}</ul>\n        </section>\n      </main>\n    </GoodsShell>\n  )\n}\n\nexport { GoodsProduct, type GoodsProductProps }\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-product.tsx"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-cart-page.tsx",
      "content": "// Ballmac UI: Goods bag page. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\nimport { Minus, Plus, ShoppingBag, Truck } from \"lucide-react\"\n\nimport { Progress } from \"@/components/ballmac/progress\"\nimport { cart, promo, useCart, usePromo } from \"@/components/ballmac/templates/goods/goods-cart\"\nimport { FREE_SHIPPING, PROMO, glazes, money } from \"@/components/ballmac/templates/goods/goods-data\"\nimport { GoodsShell, Piece, goodsSerifClass, type GoodsHrefs } from \"@/components/ballmac/templates/goods/goods-theme\"\nimport { cn } from \"@/lib/utils\"\n\n/** Puts two pieces in an empty bag once, so previews and screenshots show a full page. Not used by the installed route. */\nfunction useDemoBag(on: boolean) {\n  React.useEffect(() => {\n    if (!on) return\n    let empty = false\n    try {\n      empty = window.sessionStorage.getItem(\"kiln-bag\") === null\n    } catch {\n      // Storage is blocked; the demo bag still fills for this page.\n      empty = true\n    }\n    if (empty) {\n      cart.add(\"morning-mug\", 0, 2)\n      cart.add(\"noodle-bowl\", 2, 1)\n    }\n  }, [on])\n}\n\ntype GoodsCartPageProps = React.ComponentProps<\"div\"> & { hrefs?: Partial<GoodsHrefs>; demo?: boolean }\n\n/** The bag page: quantities, a free-shipping meter, a promo code and a summary that updates as you change things. */\nfunction GoodsCartPage({ hrefs, demo = false, ...props }: GoodsCartPageProps) {\n  const link = { shop: \"/goods/shop\", checkout: \"/goods/checkout\", product: \"/goods/shop/morning-mug\", ...hrefs }\n  useDemoBag(demo)\n  const { lines, count, subtotal } = useCart()\n  const promoOn = usePromo()\n  const [code, setCode] = React.useState(\"\")\n  const [bad, setBad] = React.useState(false)\n  const discount = promoOn ? subtotal * PROMO.rate : 0\n  const after = subtotal - discount\n  const shipping = after === 0 || after >= FREE_SHIPPING ? 0 : 6\n  const total = after + shipping\n  const left = Math.max(0, FREE_SHIPPING - after)\n  function apply(e: React.FormEvent) {\n    e.preventDefault()\n    const ok = code.trim().toUpperCase() === PROMO.code\n    setBad(!ok)\n    if (ok) promo.set(true)\n  }\n  const step = \"hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-full outline-none focus-visible:ring-[3px]\"\n  return (\n    <GoodsShell page=\"cart\" hrefs={hrefs} {...props}>\n      <main className=\"mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6\">\n        <h1 className={cn(\"text-[clamp(3rem,8vw,5.5rem)] leading-none\", goodsSerifClass)}>Your bag</h1>\n        {lines.length === 0 ? (\n          <div className=\"bg-surface mt-10 rounded-[2rem] border px-6 py-20 text-center\"><ShoppingBag className=\"text-muted-foreground mx-auto size-12\" aria-hidden=\"true\" /><p className={cn(\"mt-5 text-3xl\", goodsSerifClass)}>Nothing in your bag yet.</p><p className=\"text-muted-foreground mt-2\">Pieces you add will wait here for you.</p><a href={link.shop} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-8 inline-flex h-12 items-center rounded-full px-8 font-bold outline-none focus-visible:ring-[3px]\">Browse the shop</a></div>\n        ) : (\n          <div className=\"mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-10 lg:grid-cols-[minmax(0,1fr)_24rem]\">\n            <div>\n              <div className=\"bg-secondary rounded-2xl p-4\">\n                <p className=\"flex items-center gap-2 text-sm font-semibold\"><Truck className=\"size-4\" aria-hidden=\"true\" />{left > 0 ? `${money(left)} away from free shipping` : \"You have free shipping\"}</p>\n                <Progress className=\"mt-3\" value={Math.min(100, (after / FREE_SHIPPING) * 100)} aria-label=\"Progress to free shipping\" />\n              </div>\n              <ul className=\"mt-2 divide-y\">\n                {lines.map((l) => (\n                  <li key={l.id + l.glaze} className=\"grid grid-cols-[6rem_minmax(0,1fr)] gap-5 py-6 sm:grid-cols-[7rem_minmax(0,1fr)_auto]\">\n                    <a href={link.product} aria-label={l.product.name}><Piece shape={l.product.shape} glaze={l.glaze} image={l.product.images?.[l.glaze]} imageAlt={l.product.name} className=\"rounded-2xl\" /></a>\n                    <div className=\"min-w-0\">\n                      <h2 className={cn(\"text-2xl\", goodsSerifClass)}>{l.product.name}</h2>\n                      <p className=\"text-muted-foreground text-sm\">{glazes[l.glaze]!.name} · {l.product.size}</p>\n                      <div className=\"mt-4 flex items-center gap-4\">\n                        <div role=\"group\" aria-label={`Quantity of ${l.product.name}`} className=\"flex items-center rounded-full border\">\n                          <button type=\"button\" aria-label={`Fewer ${l.product.name}`} onClick={() => cart.setQty(l.id, l.glaze, l.qty - 1)} className={step}><Minus className=\"size-4\" aria-hidden=\"true\" /></button>\n                          <span className=\"w-8 text-center font-semibold tabular-nums\">{l.qty}</span>\n                          <button type=\"button\" aria-label={`More ${l.product.name}`} onClick={() => cart.setQty(l.id, l.glaze, l.qty + 1)} className={step}><Plus className=\"size-4\" aria-hidden=\"true\" /></button>\n                        </div>\n                        <button type=\"button\" onClick={() => cart.remove(l.id, l.glaze)} className=\"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 rounded text-sm underline underline-offset-4 outline-none focus-visible:ring-[3px]\">Remove</button>\n                      </div>\n                    </div>\n                    <p className=\"col-start-2 text-lg font-semibold tabular-nums sm:col-start-auto\">{money(l.product.price * l.qty)}</p>\n                  </li>\n                ))}\n              </ul>\n            </div>\n            <aside aria-labelledby=\"gc-sum\" className=\"bg-card rounded-3xl border p-6 lg:sticky lg:top-24\">\n              <h2 id=\"gc-sum\" className={cn(\"text-2xl\", goodsSerifClass)}>Summary</h2>\n              <form onSubmit={apply} className=\"mt-5 flex gap-2\">\n                <label htmlFor=\"gc-code\" className=\"sr-only\">Promo code</label>\n                <input id=\"gc-code\" value={code} onChange={(e) => { setCode(e.target.value); setBad(false) }} placeholder=\"Promo code\" aria-describedby=\"gc-code-msg\" className=\"bg-background focus-visible:ring-ring/50 h-11 min-w-0 flex-1 rounded-full border px-4 text-sm outline-none focus-visible:ring-[3px]\" />\n                <button type=\"submit\" className=\"hover:bg-accent focus-visible:ring-ring/50 h-11 rounded-full border px-5 text-sm font-bold outline-none focus-visible:ring-[3px]\">Apply</button>\n              </form>\n              <p id=\"gc-code-msg\" role=\"status\" className={cn(\"mt-2 min-h-5 text-sm\", bad && \"text-destructive\")}>{promoOn ? `${PROMO.code} applied: 10% off.` : bad ? `That code is not valid. Try ${PROMO.code}.` : \"\"}</p>\n              <dl className=\"mt-3 grid gap-2 border-t pt-4 text-sm\">\n                <div className=\"flex justify-between\"><dt>Subtotal ({count})</dt><dd className=\"tabular-nums\">{money(subtotal)}</dd></div>\n                {discount > 0 && <div className=\"flex justify-between\"><dt>Promo {PROMO.code}</dt><dd className=\"tabular-nums\">−{money(discount)}</dd></div>}\n                <div className=\"flex justify-between\"><dt>Shipping</dt><dd className=\"tabular-nums\">{shipping === 0 ? \"Free\" : money(shipping)}</dd></div>\n                <div className=\"flex justify-between border-t pt-3 text-lg font-bold\"><dt>Total</dt><dd className=\"tabular-nums\">{money(total)}</dd></div>\n              </dl>\n              <a href={link.checkout} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-6 flex h-14 items-center justify-center rounded-full text-lg font-bold outline-none focus-visible:ring-[3px]\">Checkout</a>\n              <a href={link.shop} className=\"mt-3 block text-center text-sm font-semibold underline underline-offset-4\">Keep shopping</a>\n            </aside>\n          </div>\n        )}\n      </main>\n    </GoodsShell>\n  )\n}\n\nexport { GoodsCartPage, useDemoBag, type GoodsCartPageProps }\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-cart-page.tsx"
    },
    {
      "path": "registry/ballmac/components/templates/goods/goods-checkout.tsx",
      "content": "// Ballmac UI: Goods checkout page. https://ui.ballmac.com/templates/template-goods\n\"use client\"\n\nimport * as React from \"react\"\nimport { Check, Lock } from \"lucide-react\"\n\nimport { cart, useCart, usePromo } from \"@/components/ballmac/templates/goods/goods-cart\"\nimport { useDemoBag } from \"@/components/ballmac/templates/goods/goods-cart-page\"\nimport { FREE_SHIPPING, PROMO, glazes, money } from \"@/components/ballmac/templates/goods/goods-data\"\nimport { GoodsShell, Piece, goodsSerifClass, type GoodsHrefs } from \"@/components/ballmac/templates/goods/goods-theme\"\nimport { cn } from \"@/lib/utils\"\n\nconst methods = [[\"standard\", \"Standard\", \"2 to 3 working days\", 6], [\"express\", \"Express\", \"Next working day\", 12]] as const\nconst field = \"bg-background focus-visible:ring-ring/50 aria-invalid:border-destructive h-12 w-full rounded-xl border px-4 outline-none focus-visible:ring-[3px]\"\ntype Values = { email: string; name: string; address: string; city: string; postcode: string; card: string; expiry: string; cvc: string }\nconst empty: Values = { email: \"\", name: \"\", address: \"\", city: \"\", postcode: \"\", card: \"\", expiry: \"\", cvc: \"\" }\n\ntype GoodsCheckoutProps = React.ComponentProps<\"div\"> & { hrefs?: Partial<GoodsHrefs>; demo?: boolean }\n\n/** Checkout: contact, delivery, a demo card form that formats as you type, a live summary and a thank-you page. */\nfunction GoodsCheckout({ hrefs, demo = false, ...props }: GoodsCheckoutProps) {\n  const link = { shop: \"/goods/shop\", cart: \"/goods/cart\", ...hrefs }\n  useDemoBag(demo)\n  const { lines, subtotal } = useCart()\n  const promoOn = usePromo()\n  const [v, setV] = React.useState<Values>(empty)\n  const [method, setMethod] = React.useState<(typeof methods)[number][0]>(\"standard\")\n  const [errors, setErrors] = React.useState<Partial<Record<keyof Values, string>>>({})\n  const [order, setOrder] = React.useState<{ number: string; total: number; email: string } | null>(null)\n  const form = React.useRef<HTMLFormElement>(null)\n\n  const after = subtotal * (promoOn ? 1 - PROMO.rate : 1)\n  const base = methods.find((m) => m[0] === method)!\n  const shipping = lines.length === 0 ? 0 : method === \"standard\" && after >= FREE_SHIPPING ? 0 : base[3]\n  const total = after + shipping\n\n  const set = (k: keyof Values, format?: (s: string) => string) => ({\n    id: `gk-${k}`, name: k, value: v[k], \"aria-invalid\": !!errors[k], \"aria-describedby\": errors[k] ? `gk-err-${k}` : undefined,\n    onChange: (e: React.ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, [k]: format ? format(e.target.value) : e.target.value })),\n  })\n  const err = (k: keyof Values) => (errors[k] ? <p id={`gk-err-${k}`} role=\"alert\" className=\"text-destructive text-sm\">{errors[k]}</p> : null)\n\n  function submit(e: React.FormEvent) {\n    e.preventDefault()\n    const next: Partial<Record<keyof Values, string>> = {}\n    if (!/^\\S+@\\S+\\.\\S+$/.test(v.email.trim())) next.email = \"Enter an email like you@example.com.\"\n    if (!v.name.trim()) next.name = \"We need a name for the parcel.\"\n    if (!v.address.trim()) next.address = \"Enter a street address.\"\n    if (!v.city.trim()) next.city = \"Enter a town or city.\"\n    if (v.postcode.trim().length < 5) next.postcode = \"Enter a full postcode.\"\n    if (v.card.replace(/\\s/g, \"\").length < 16) next.card = \"Enter all 16 digits.\"\n    if (!/^\\d{2}\\/\\d{2}$/.test(v.expiry)) next.expiry = \"Use MM/YY.\"\n    if (v.cvc.length < 3) next.cvc = \"3 digits.\"\n    setErrors(next)\n    const first = Object.keys(next)[0]\n    if (first) return void form.current?.querySelector<HTMLInputElement>(`[name=\"${first}\"]`)?.focus()\n    setOrder({ number: \"KC-48213\", total, email: v.email.trim() })\n    cart.clear()\n  }\n\n  return (\n    <GoodsShell page=\"checkout\" hrefs={hrefs} {...props}>\n      <main className=\"mx-auto max-w-6xl px-4 pt-12 pb-8 sm:px-6\">\n        {order ? (\n          <section aria-labelledby=\"gk-done\" className=\"bg-chart-2/25 mx-auto max-w-2xl rounded-[2rem] border px-6 py-16 text-center sm:px-12\">\n            <span className=\"bg-primary text-primary-foreground mx-auto flex size-14 items-center justify-center rounded-full\"><Check className=\"size-7\" aria-hidden=\"true\" /></span>\n            <h1 id=\"gk-done\" className={cn(\"mt-6 text-5xl\", goodsSerifClass)}>Thank you.</h1>\n            <p role=\"status\" className=\"mt-4 text-lg text-pretty\">Order {order.number} is confirmed. A receipt for {money(order.total)} is on its way to {order.email}, and your pieces will leave the studio in two working days.</p>\n            <a href={link.shop} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-8 inline-flex h-12 items-center rounded-full px-8 font-bold outline-none focus-visible:ring-[3px]\">Back to the shop</a>\n          </section>\n        ) : lines.length === 0 ? (\n          <div className=\"bg-surface mx-auto max-w-2xl rounded-[2rem] border px-6 py-20 text-center\"><h1 className={cn(\"text-4xl\", goodsSerifClass)}>Your bag is empty.</h1><p className=\"text-muted-foreground mt-2\">Add a piece before you check out.</p><a href={link.shop} className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-8 inline-flex h-12 items-center rounded-full px-8 font-bold outline-none focus-visible:ring-[3px]\">Browse the shop</a></div>\n        ) : (\n          <>\n            <h1 className={cn(\"text-[clamp(3rem,8vw,5rem)] leading-none\", goodsSerifClass)}>Checkout</h1>\n            <div className=\"mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-10 lg:grid-cols-[minmax(0,1fr)_24rem]\">\n              <form ref={form} onSubmit={submit} noValidate className=\"grid gap-10\">\n                <section aria-labelledby=\"gk-contact\" className=\"grid gap-4\"><h2 id=\"gk-contact\" className={cn(\"text-2xl\", goodsSerifClass)}>Contact</h2>\n                  <div className=\"grid gap-2\"><label htmlFor=\"gk-email\" className=\"text-sm font-semibold\">Email</label><input type=\"email\" autoComplete=\"email\" className={field} {...set(\"email\")} />{err(\"email\")}</div></section>\n                <section aria-labelledby=\"gk-ship\" className=\"grid gap-4\"><h2 id=\"gk-ship\" className={cn(\"text-2xl\", goodsSerifClass)}>Delivery address</h2>\n                  <div className=\"grid gap-2\"><label htmlFor=\"gk-name\" className=\"text-sm font-semibold\">Full name</label><input autoComplete=\"name\" className={field} {...set(\"name\")} />{err(\"name\")}</div>\n                  <div className=\"grid gap-2\"><label htmlFor=\"gk-address\" className=\"text-sm font-semibold\">Address</label><input autoComplete=\"street-address\" className={field} {...set(\"address\")} />{err(\"address\")}</div>\n                  <div className=\"grid gap-4 sm:grid-cols-2\"><div className=\"grid gap-2\"><label htmlFor=\"gk-city\" className=\"text-sm font-semibold\">Town or city</label><input autoComplete=\"address-level2\" className={field} {...set(\"city\")} />{err(\"city\")}</div><div className=\"grid gap-2\"><label htmlFor=\"gk-postcode\" className=\"text-sm font-semibold\">Postcode</label><input autoComplete=\"postal-code\" className={field} {...set(\"postcode\", (s) => s.toUpperCase())} />{err(\"postcode\")}</div></div></section>\n                <fieldset className=\"grid gap-3\"><legend className={cn(\"mb-1 text-2xl\", goodsSerifClass)}>Delivery method</legend>\n                  {methods.map(([id, label, note, price]) => <label key={id} className={cn(\"bg-card focus-within:ring-ring/50 flex cursor-pointer items-center justify-between gap-4 rounded-2xl border-2 p-4 focus-within:ring-[3px]\", method === id ? \"border-primary\" : \"hover:border-primary/40\")}><input type=\"radio\" name=\"method\" value={id} checked={method === id} onChange={() => setMethod(id)} className=\"sr-only\" /><span><span className=\"block font-bold\">{label}</span><span className=\"text-muted-foreground text-sm\">{note}</span></span><span className=\"font-semibold tabular-nums\">{id === \"standard\" && after >= FREE_SHIPPING ? \"Free\" : money(price)}</span></label>)}</fieldset>\n                <section aria-labelledby=\"gk-pay\" className=\"grid gap-4\"><h2 id=\"gk-pay\" className={cn(\"flex items-center gap-2 text-2xl\", goodsSerifClass)}>Payment <Lock className=\"text-muted-foreground size-4\" aria-hidden=\"true\" /></h2>\n                  <div className=\"grid gap-2\"><label htmlFor=\"gk-card\" className=\"text-sm font-semibold\">Card number</label><input inputMode=\"numeric\" autoComplete=\"cc-number\" placeholder=\"1234 5678 9012 3456\" className={field} {...set(\"card\", (s) => s.replace(/\\D/g, \"\").slice(0, 16).replace(/(.{4})/g, \"$1 \").trim())} />{err(\"card\")}</div>\n                  <div className=\"grid grid-cols-2 gap-4\"><div className=\"grid gap-2\"><label htmlFor=\"gk-expiry\" className=\"text-sm font-semibold\">Expiry</label><input inputMode=\"numeric\" autoComplete=\"cc-exp\" placeholder=\"MM/YY\" className={field} {...set(\"expiry\", (s) => { const d = s.replace(/\\D/g, \"\").slice(0, 4); return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d })} />{err(\"expiry\")}</div><div className=\"grid gap-2\"><label htmlFor=\"gk-cvc\" className=\"text-sm font-semibold\">Security code</label><input inputMode=\"numeric\" autoComplete=\"cc-csc\" placeholder=\"123\" className={field} {...set(\"cvc\", (s) => s.replace(/\\D/g, \"\").slice(0, 4))} />{err(\"cvc\")}</div></div></section>\n                <div><button type=\"submit\" className=\"bg-primary text-primary-foreground focus-visible:ring-ring/50 h-14 w-full rounded-full text-lg font-bold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]\">Pay {money(total)}</button><p className=\"text-muted-foreground mt-3 text-center text-xs\">Demo checkout: no payment is taken.</p></div>\n              </form>\n              <aside aria-labelledby=\"gk-sum\" className=\"bg-card rounded-3xl border p-6 lg:sticky lg:top-24\">\n                <h2 id=\"gk-sum\" className={cn(\"text-2xl\", goodsSerifClass)}>In your bag</h2>\n                <ul className=\"mt-5 grid gap-4\">{lines.map((l) => <li key={l.id + l.glaze} className=\"grid grid-cols-[4rem_minmax(0,1fr)_auto] items-center gap-3\"><Piece shape={l.product.shape} glaze={l.glaze} image={l.product.images?.[l.glaze]} imageAlt={l.product.name} className=\"rounded-lg\" /><span className=\"min-w-0\"><span className=\"block font-semibold\">{l.product.name}</span><span className=\"text-muted-foreground text-sm\">{glazes[l.glaze]!.name} × {l.qty}</span></span><span className=\"font-semibold tabular-nums\">{money(l.product.price * l.qty)}</span></li>)}</ul>\n                <dl className=\"mt-5 grid gap-2 border-t pt-4 text-sm\">\n                  <div className=\"flex justify-between\"><dt>Subtotal</dt><dd className=\"tabular-nums\">{money(subtotal)}</dd></div>\n                  {promoOn && <div className=\"flex justify-between\"><dt>Promo {PROMO.code}</dt><dd className=\"tabular-nums\">−{money(subtotal * PROMO.rate)}</dd></div>}\n                  <div className=\"flex justify-between\"><dt>Shipping</dt><dd className=\"tabular-nums\">{shipping === 0 ? \"Free\" : money(shipping)}</dd></div>\n                  <div className=\"flex justify-between border-t pt-3 text-lg font-bold\"><dt>Total</dt><dd className=\"tabular-nums\">{money(total)}</dd></div>\n                </dl>\n                <a href={link.cart} className=\"mt-4 block text-center text-sm font-semibold underline underline-offset-4\">Edit bag</a>\n              </aside>\n            </div>\n          </>\n        )}\n      </main>\n    </GoodsShell>\n  )\n}\n\nexport { GoodsCheckout, type GoodsCheckoutProps }\n",
      "type": "registry:component",
      "target": "@components/ballmac/templates/goods/goods-checkout.tsx"
    },
    {
      "path": "registry/ballmac/app/goods/page.tsx",
      "content": "// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods\nimport type { Metadata } from \"next\"\n\nimport { GoodsHome } from \"@/components/ballmac/templates/goods/goods-home\"\n\nexport const metadata: Metadata = {\n  title: \"Kiln & Co: handmade ceramics\",\n  description: \"Everyday ceramics thrown by hand in Bristol and fired in small batches.\",\n}\n\nexport default function Page() {\n  return <GoodsHome />\n}\n",
      "type": "registry:page",
      "target": "app/goods/page.tsx"
    },
    {
      "path": "registry/ballmac/app/goods/shop/page.tsx",
      "content": "// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods\nimport type { Metadata } from \"next\"\n\nimport { GoodsShop } from \"@/components/ballmac/templates/goods/goods-shop\"\n\nexport const metadata: Metadata = {\n  title: \"Shop · Kiln & Co\",\n  description: \"Mugs, bowls, vases and plates in five glazes.\",\n}\n\nexport default function Page() {\n  return <GoodsShop />\n}\n",
      "type": "registry:page",
      "target": "app/goods/shop/page.tsx"
    },
    {
      "path": "registry/ballmac/app/goods/shop/[id]/page.tsx",
      "content": "// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods\nimport type { Metadata } from \"next\"\n\nimport { GoodsProduct } from \"@/components/ballmac/templates/goods/goods-product\"\n\nexport const metadata: Metadata = {\n  title: \"Kiln & Co: product\",\n  description: \"A handmade ceramic piece from Kiln & Co.\",\n}\n\nexport default async function Page({ params }: { params: Promise<{ id: string }> }) {\n  const { id } = await params\n  return <GoodsProduct slug={id} />\n}\n",
      "type": "registry:page",
      "target": "app/goods/shop/[id]/page.tsx"
    },
    {
      "path": "registry/ballmac/app/goods/cart/page.tsx",
      "content": "// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods\nimport type { Metadata } from \"next\"\n\nimport { GoodsCartPage } from \"@/components/ballmac/templates/goods/goods-cart-page\"\n\nexport const metadata: Metadata = {\n  title: \"Your bag · Kiln & Co\",\n  description: \"Review your pieces and apply a code.\",\n}\n\nexport default function Page() {\n  return <GoodsCartPage demo />\n}\n",
      "type": "registry:page",
      "target": "app/goods/cart/page.tsx"
    },
    {
      "path": "registry/ballmac/app/goods/checkout/page.tsx",
      "content": "// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods\nimport type { Metadata } from \"next\"\n\nimport { GoodsCheckout } from \"@/components/ballmac/templates/goods/goods-checkout\"\n\nexport const metadata: Metadata = {\n  title: \"Checkout · Kiln & Co\",\n  description: \"Delivery and payment.\",\n}\n\nexport default function Page() {\n  return <GoodsCheckout demo />\n}\n",
      "type": "registry:page",
      "target": "app/goods/checkout/page.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "template",
      "ecommerce",
      "store",
      "shop",
      "product",
      "cart",
      "checkout",
      "ceramics"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "Installs a five-page store with a shared bag drawer, filters, a product page, a bag page with free-shipping progress and a validated checkout. Edit goods-data.ts and goodsCss; replace Piece with photos.",
      "whenToUse": [
        "Small online shops and product drops",
        "Any store that needs filters, a product page, a bag and checkout"
      ],
      "whenNotToUse": [
        "Large catalogues that need server-side search and pagination"
      ],
      "composesWith": [
        "sheet",
        "slider",
        "accordion",
        "progress"
      ],
      "a11y": [
        {
          "keys": "Glaze swatches",
          "action": "A radio group; arrow keys change the glaze and the picture follows"
        },
        {
          "keys": "Bag drawer",
          "action": "Opens from the header; Escape closes it and focus returns to the button"
        },
        {
          "keys": "Filter chips",
          "action": "Each active filter is a button that removes itself"
        }
      ],
      "customization": [
        "Replace products and reviews in goods-data.ts",
        "Swap the cart store for your cart or commerce API",
        "Replace Piece with product photography",
        "Edit goodsCss for the palette"
      ]
    },
    "examples": [
      "template-goods-demo",
      "template-goods-shop",
      "template-goods-product",
      "template-goods-cart",
      "template-goods-checkout"
    ],
    "url": "https://ui.ballmac.com/components/template-goods"
  },
  "docs": "Pages are at /goods, /goods/shop, /goods/shop/[id], /goods/cart and /goods/checkout. Products, glazes and reviews live in goods-data.ts. The bag is kept in sessionStorage (goods-cart.ts); swap it for your cart API. The promo code in the demo is KILN10. Replace Piece with product photos.",
  "categories": [
    "templates"
  ],
  "type": "registry:block"
}