# Kiln & Co: online store

> 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.

A shadcn/ui block from the [@ballmac](https://ui.ballmac.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ballmac/template-goods).

- Page, with a live preview: https://shadcn.exchange/r/ballmac/template-goods
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ballmac/template-goods-light-2ea92948bb718e91.png
- Registry item: https://ui.ballmac.com/r/template-goods.json
- Categories: templates
- Listed: 2026-10-14, source last changed 2026-10-14

## Install

```bash
npx shadcn@latest add https://ui.ballmac.com/r/template-goods.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `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`

## Notes from the registry

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.

## Files

### `registry/ballmac/components/templates/goods/goods-fonts.ts`

```ts
// Ballmac UI: Goods template fonts. https://ui.ballmac.com/templates/template-goods
import { Fraunces, Hanken_Grotesk } from "next/font/google"

export const goodsSerif = Fraunces({ variable: "--goods-serif", subsets: ["latin"], display: "swap", preload: false })
export const goodsSans = Hanken_Grotesk({ variable: "--goods-sans", subsets: ["latin"], display: "swap", preload: false })
```

### `registry/ballmac/components/templates/goods/goods-data.ts`

```ts
import type { MediaSource } from "@/components/ballmac/media"
// Ballmac UI: Goods template data. https://ui.ballmac.com/templates/template-goods
export type Shape = "mug" | "bowl" | "vase" | "plate" | "pitcher" | "cup"
export type Category = "Mugs" | "Bowls" | "Vases" | "Plates"

export const glazes = [
  { name: "Terracotta", cls: "bg-chart-1", border: "border-chart-1" },
  { name: "Sage", cls: "bg-chart-2", border: "border-chart-2" },
  { name: "Mustard", cls: "bg-chart-3", border: "border-chart-3" },
  { name: "Slate", cls: "bg-chart-4", border: "border-chart-4" },
  { name: "Blush", cls: "bg-chart-5", border: "border-chart-5" },
] as const

export type Product = {
  id: string
  name: string
  category: Category
  shape: Shape
  price: number
  glazes: number[]
  tagline: string
  description: string
  size: string
  rating: number
  reviews: number
  tag?: "New" | "Low stock" | "Bestseller"
  inStock: boolean
  /** 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. */
  images?: Partial<Record<number, MediaSource>>
}

export const products: Product[] = [
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
  { 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 },
]

export const productById = (id: string) => products.find((p) => p.id === id) ?? products[0]!

export const reviewList = [
  { 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" },
  { 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" },
  { 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" },
  { 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" },
]

export const FREE_SHIPPING = 80
export const PROMO = { code: "KILN10", rate: 0.1 }
export const money = (n: number) => `$${n.toLocaleString("en-US", { minimumFractionDigits: n % 1 ? 2 : 0, maximumFractionDigits: 2 })}`
```

### `registry/ballmac/components/templates/goods/goods-cart.ts`

```ts
// Ballmac UI: Goods cart store. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"

import { productById } from "@/components/ballmac/templates/goods/goods-data"

export type CartLine = { id: string; glaze: number; qty: number }

const KEY = "kiln-bag"
let lines: CartLine[] = []
let loaded = false
const listeners = new Set<() => void>()

function load() {
  if (loaded || typeof window === "undefined") return
  loaded = true
  try {
    const raw = window.sessionStorage.getItem(KEY)
    if (raw) lines = (JSON.parse(raw) as CartLine[]).filter((l) => l && typeof l.id === "string" && l.qty > 0)
  } catch {
    // Storage can be blocked; the bag still works for this page.
  }
}
function commit(next: CartLine[]) {
  lines = next
  try {
    window.sessionStorage.setItem(KEY, JSON.stringify(next))
  } catch {
    // ignore
  }
  listeners.forEach((l) => l())
}

/** The bag, shared by every page and kept for the browser session. */
export const cart = {
  add(id: string, glaze: number, qty = 1) {
    load()
    const found = lines.find((l) => l.id === id && l.glaze === glaze)
    commit(found ? lines.map((l) => (l === found ? { ...l, qty: Math.min(10, l.qty + qty) } : l)) : [...lines, { id, glaze, qty }])
  },
  setQty(id: string, glaze: number, qty: number) {
    load()
    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)))
  },
  remove(id: string, glaze: number) {
    load()
    commit(lines.filter((l) => !(l.id === id && l.glaze === glaze)))
  },
  clear() {
    load()
    commit([])
  },
}

let promoOn = false
let promoLoaded = false
function loadPromo() {
  if (promoLoaded || typeof window === "undefined") return
  promoLoaded = true
  try {
    promoOn = window.sessionStorage.getItem("kiln-promo") === "1"
  } catch {
    // ignore
  }
}
/** The KILN10 code, kept for the session so the bag and checkout agree. */
export const promo = {
  set(on: boolean) {
    loadPromo()
    promoOn = on
    try {
      window.sessionStorage.setItem("kiln-promo", on ? "1" : "0")
    } catch {
      // ignore
    }
    listeners.forEach((l) => l())
  },
}
export function usePromo() {
  return React.useSyncExternalStore(subscribe, () => { loadPromo(); return promoOn }, () => false)
}

const empty: CartLine[] = []
function subscribe(cb: () => void) {
  listeners.add(cb)
  return () => void listeners.delete(cb)
}
/** Lines in the bag, with totals. Empty on the server and first render so markup matches. */
export function useCart() {
  const current = React.useSyncExternalStore(subscribe, () => { load(); return lines }, () => empty)
  const detailed = current.map((l) => ({ ...l, product: productById(l.id) }))
  const count = detailed.reduce((n, l) => n + l.qty, 0)
  const subtotal = detailed.reduce((n, l) => n + l.qty * l.product.price, 0)
  return { lines: detailed, count, subtotal }
}
```

### `registry/ballmac/components/templates/goods/goods-theme.tsx`

```tsx
// Ballmac UI: Goods template shell. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"
import { Menu, Minus, Plus, ShoppingBag, Star, X } from "lucide-react"

import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ballmac/sheet"
import { cart, useCart } from "@/components/ballmac/templates/goods/goods-cart"
import { FREE_SHIPPING, glazes, money, type Shape } from "@/components/ballmac/templates/goods/goods-data"
import { goodsSans, goodsSerif } from "@/components/ballmac/templates/goods/goods-fonts"
import { cn } from "@/lib/utils"
import { Media, type MediaSource } from "@/components/ballmac/media"

type GoodsPage = "home" | "shop" | "product" | "cart" | "checkout"
type GoodsHrefs = Record<GoodsPage, string>

const defaultHrefs: GoodsHrefs = { home: "/goods", shop: "/goods/shop", product: "/goods/shop/morning-mug", cart: "/goods/cart", checkout: "/goods/checkout" }

/** Kiln & Co's palette: oat paper, warm brown ink and five glazes. Dark mode is a studio at night with the kiln still glowing. */
const goodsCss = `
.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}
.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)}
body:not(:has([data-gallery])):has(.goods-theme){font-family:var(--goods-sans),ui-sans-serif,system-ui,sans-serif}
`

const serif = "[font-family:var(--goods-serif),ui-serif,Georgia,serif] font-medium tracking-[-0.02em]"
const onClay = "text-[var(--goods-on-clay)]"

/**
 * A piece of ceramics drawn in CSS: a mug, bowl, vase, plate, pitcher or cup in one of five glazes, on a wall.
 * `view` changes the composition (front, close, pair, dark wall) so a gallery has something to switch between.
 */
function Piece({ shape, glaze = 0, view = 0, className, image, imageAlt }: { shape: Shape; glaze?: number; view?: number; className?: string; image?: MediaSource; imageAlt?: string }) {
  if (image) return <Media media={image} alt={imageAlt} fill className={cn("aspect-[4/5] w-full", className)} />
  const gl = glazes[glaze % glazes.length]!
  const g = gl.cls
  const shine = "bg-gradient-to-br from-white/35 via-transparent to-black/25"
  const wall = view === 3 ? "bg-[var(--goods-wall-2)]" : "bg-[var(--goods-wall)]"
  const body = (cls: string, extra?: React.ReactNode) => (
    <div className={cn("absolute overflow-hidden", g, cls)}>
      <div className={cn("absolute inset-0 rounded-[inherit]", shine)} />
      {extra}
    </div>
  )
  const art: Record<Shape, React.ReactNode> = {
    mug: (<>
      <div className={cn("absolute top-[40%] start-[54%] aspect-square w-[22%] rounded-full border-[7px] border-solid bg-transparent", gl.border)} />
      {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" />)}
    </>),
    cup: (<>
      {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" />)}
    </>),
    bowl: (<>
      {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" />)}
    </>),
    vase: (<>
      {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" />)}
      {body("top-[28%] start-[27%] h-[44%] w-[46%] rounded-[50%_50%_30%_30%/45%_45%_22%_22%]")}
    </>),
    plate: (<>
      {body("top-[52%] start-[10%] h-[10%] w-[80%] rounded-[50%]")}
      <div className="absolute top-[53.5%] start-[18%] h-[5%] w-[64%] rounded-[50%] bg-black/15" />
    </>),
    pitcher: (<>
      <div className={cn("absolute top-[36%] start-[56%] aspect-square w-[20%] rounded-full border-[7px] bg-transparent", gl.border)} />
      {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" />)}
      {body("top-[28%] start-[18%] h-[8%] w-[14%] -rotate-[28deg] rounded-md")}
    </>),
  }
  const ground = shape === "plate" ? "top-[60%]" : shape === "vase" ? "top-[70%]" : shape === "bowl" ? "top-[68%]" : "top-[66%]"
  return (
    <div aria-hidden="true" className={cn("relative isolate aspect-[4/5] w-full overflow-hidden", wall, className)}>
      <div className="absolute inset-x-0 bottom-0 h-[34%] bg-black/[0.06]" />
      <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 }} />
      <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>
      {view === 2 && (
        <div className="absolute inset-0 origin-center" style={{ transform: "scale(0.6) translate(48%, 14%)" }}>{art[shape === "mug" ? "cup" : shape === "bowl" ? "plate" : "mug"]}</div>
      )}
    </div>
  )
}

function Stars({ value, className }: { value: number; className?: string }) {
  return (
    <span role="img" aria-label={`${value} out of 5 stars`} className={cn("inline-flex gap-0.5", className)}>
      {[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")} />)}
    </span>
  )
}

const links: { key: GoodsPage; label: string }[] = [{ key: "shop", label: "Shop" }, { key: "home", label: "Our story" }]

type GoodsShellProps = React.ComponentProps<"div"> & {
  /** The page being shown, so its nav link is marked current. */
  page: GoodsPage
  /** Override where pages live (used by previews). */
  hrefs?: Partial<GoodsHrefs>
}

/** Kiln & Co's frame: an announcement bar, a header with the bag and a drawer that shows what you have picked. */
function GoodsShell({ page, hrefs: overrides, className, style, children, ...props }: GoodsShellProps) {
  const hrefs = { ...defaultHrefs, ...overrides }
  const [menu, setMenu] = React.useState(false)
  const [bag, setBag] = React.useState(false)
  const { lines, count, subtotal } = useCart()
  React.useEffect(() => {
    const classes = [goodsSerif.variable, goodsSans.variable].filter(Boolean)
    document.body.classList.add(...classes)
    return () => document.body.classList.remove(...classes)
  }, [])
  const left = Math.max(0, FREE_SHIPPING - subtotal)
  return (
    <div
      data-slot="goods"
      className={cn("goods-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", goodsSerif.variable, goodsSans.variable, className)}
      style={{ fontFamily: "var(--goods-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
      {...props}
    >
      <style>{goodsCss}</style>
      <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>
      <header className="bg-background/90 sticky top-0 z-40 border-b backdrop-blur-xl">
        <div className="mx-auto grid h-16 max-w-7xl grid-cols-[1fr_auto_1fr] items-center px-4 sm:px-6">
          <nav aria-label="Main" className="hidden items-center gap-1 md:flex">
            {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>)}
          </nav>
          <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>
          <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>
          <div className="flex justify-end">
            <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]">
              <ShoppingBag className="size-5" aria-hidden="true" /><span className="hidden sm:inline">Bag</span>
              <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>
            </button>
          </div>
        </div>
        {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>}
      </header>

      <Sheet open={bag} onOpenChange={setBag}>
        <SheetContent side="end" className="goods-theme bg-background flex w-full flex-col gap-0 p-0 sm:max-w-md">
          <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>
          <div className="flex-1 overflow-y-auto p-5">
            {lines.length === 0 ? (
              <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>
            ) : (
              <ul className="grid gap-5">
                {lines.map((l) => (
                  <li key={l.id + l.glaze} className="grid grid-cols-[5rem_minmax(0,1fr)] gap-4">
                    <Piece shape={l.product.shape} glaze={l.glaze} image={l.product.images?.[l.glaze]} imageAlt={l.product.name} className="rounded-xl" />
                    <div className="min-w-0">
                      <p className="font-semibold">{l.product.name}</p>
                      <p className="text-muted-foreground text-sm">{glazes[l.glaze]!.name} · {money(l.product.price)}</p>
                      <div className="mt-2 flex items-center justify-between">
                        <div role="group" aria-label={`Quantity of ${l.product.name}`} className="flex items-center rounded-full border">
                          <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>
                          <span className="w-7 text-center text-sm font-semibold tabular-nums">{l.qty}</span>
                          <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>
                        </div>
                        <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>
                      </div>
                    </div>
                  </li>
                ))}
              </ul>
            )}
          </div>
          {lines.length > 0 && (
            <div className="grid gap-3 border-t p-5">
              <p className="text-muted-foreground text-sm">{left > 0 ? `${money(left)} away from free shipping.` : "You have free shipping."}</p>
              <p className="flex justify-between text-lg font-semibold"><span>Subtotal</span><span className="tabular-nums">{money(subtotal)}</span></p>
              <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>
              <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>
            </div>
          )}
        </SheetContent>
      </Sheet>

      {children}

      <footer className="bg-primary text-primary-foreground mt-24">
        <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]">
          <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>
          {[["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]) => (
            <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>
          ))}
        </div>
        <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>
      </footer>
    </div>
  )
}

export { GoodsShell, Piece, Stars, defaultHrefs as goodsDefaultHrefs, onClay as goodsOnClay, serif as goodsSerifClass, type GoodsHrefs, type GoodsPage, type GoodsShellProps }
```

### `registry/ballmac/components/templates/goods/goods-home.tsx`

```tsx
// Ballmac UI: Goods home page. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"
import { ArrowRight, Check, Flame, Recycle, RotateCcw, Truck } from "lucide-react"

import { cart } from "@/components/ballmac/templates/goods/goods-cart"
import { glazes, money, products, reviewList, type Product } from "@/components/ballmac/templates/goods/goods-data"
import { GoodsShell, Piece, Stars, goodsOnClay, goodsSerifClass, type GoodsHrefs } from "@/components/ballmac/templates/goods/goods-theme"
import { cn } from "@/lib/utils"

/** Link to one product: preview pages pass the id as ?p=, installed routes use /goods/shop/<id>. */
function productLink(base: string, id: string) {
  return base.includes("/preview/") ? `${base}?p=${id}` : base.replace(/[^/]+$/, id)
}

/** A product tile with the glaze dots and a quick add that confirms in place. */
function ProductCard({ product, href }: { product: Product; href: string }) {
  const [added, setAdded] = React.useState(false)
  React.useEffect(() => {
    if (!added) return
    const id = window.setTimeout(() => setAdded(false), 1800)
    return () => window.clearTimeout(id)
  }, [added])
  return (
    <li className="group relative">
      <a href={href} className="focus-visible:ring-ring/50 block rounded-3xl outline-none focus-visible:ring-[3px]">
        <div className="relative overflow-hidden rounded-3xl">
          <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" />
          {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>}
        </div>
        <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>
        <span className="text-muted-foreground mt-1 block text-sm text-pretty">{product.tagline}</span>
      </a>
      <div className="mt-3 flex items-center justify-between gap-3">
        <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>
        <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>
      </div>
    </li>
  )
}

const 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
const cats = [["Mugs", "mug", 1], ["Bowls", "bowl", 2], ["Vases", "vase", 3], ["Plates", "plate", 4]] as const

type GoodsHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<GoodsHrefs> }

/** The Goods home page: a hero collage, values, categories, bestsellers, the story, reviews and a newsletter. */
function GoodsHome({ hrefs, ...props }: GoodsHomeProps) {
  const link = { shop: "/goods/shop", product: "/goods/shop/morning-mug", ...hrefs }
  const [email, setEmail] = React.useState("")
  const [error, setError] = React.useState("")
  const [done, setDone] = React.useState(false)
  function join(e: React.FormEvent) {
    e.preventDefault()
    if (!/^\S+@\S+\.\S+$/.test(email.trim())) return setError("Enter an email like you@example.com.")
    setError("")
    setDone(true)
  }
  return (
    <GoodsShell page="home" hrefs={hrefs} {...props}>
      <main>
        <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">
          <div>
            <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>
            <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>
            <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>
            <div className="mt-8 flex flex-wrap gap-3">
              <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-[var(--bm-lift,-2px)] 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>
              <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>
            </div>
          </div>
          <div aria-hidden="true" className="grid grid-cols-2 gap-4">
            <Piece shape="vase" glaze={3} view={0} className="rounded-[2.5rem] rounded-ee-[6rem]" />
            <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>
          </div>
        </section>

        <section aria-label="Why Kiln & Co" className="bg-surface border-y">
          <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>
        </section>

        <section aria-labelledby="gh-cats" className="mx-auto max-w-7xl px-4 py-20 sm:px-6">
          <h2 id="gh-cats" className={cn("text-4xl sm:text-5xl", goodsSerifClass)}>Shop by piece</h2>
          <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-[calc(var(--bm-lift,-2px)*2)] 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>
        </section>

        <section aria-labelledby="gh-best" className="mx-auto max-w-7xl px-4 pb-20 sm:px-6">
          <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>
          <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>
        </section>

        <section aria-labelledby="gh-story" className="bg-primary text-primary-foreground">
          <div className="mx-auto grid max-w-7xl items-center gap-12 px-4 py-20 sm:px-6 lg:grid-cols-2">
            <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>
            <div>
              <h2 id="gh-story" className={cn("text-4xl text-balance sm:text-6xl", goodsSerifClass)}>A small studio with a very big kiln.</h2>
              <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>
              <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>
            </div>
          </div>
        </section>

        <section aria-labelledby="gh-rev" className="mx-auto max-w-7xl px-4 py-20 sm:px-6">
          <h2 id="gh-rev" className={cn("text-4xl sm:text-5xl", goodsSerifClass)}>Kind words</h2>
          <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>
        </section>

        <section aria-labelledby="gh-news" className="mx-auto max-w-7xl px-4 pb-8 sm:px-6">
          <div className="bg-chart-3/40 rounded-[2rem] px-6 py-14 text-center sm:px-12">
            <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>
            <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>
            {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> : (
              <form onSubmit={join} noValidate className="mx-auto mt-6 flex max-w-md flex-col gap-2 sm:flex-row">
                <label htmlFor="gh-email" className="sr-only">Email address</label>
                <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]" />
                <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>
              </form>
            )}
            {error && <p id="gh-err" role="alert" className="text-destructive mt-2 text-sm font-medium">{error}</p>}
          </div>
        </section>
      </main>
    </GoodsShell>
  )
}

export { GoodsHome, ProductCard, productLink, type GoodsHomeProps }
```

### `registry/ballmac/components/templates/goods/goods-shop.tsx`

```tsx
// Ballmac UI: Goods shop page. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"
import { SlidersHorizontal, X } from "lucide-react"

import { Slider } from "@/components/ballmac/slider"
import { ProductCard, productLink } from "@/components/ballmac/templates/goods/goods-home"
import { glazes, money, products, type Category } from "@/components/ballmac/templates/goods/goods-data"
import { GoodsShell, goodsSerifClass, type GoodsHrefs } from "@/components/ballmac/templates/goods/goods-theme"
import { cn } from "@/lib/utils"

const categories: Category[] = ["Mugs", "Bowls", "Vases", "Plates"]
const sorts = [["featured", "Featured"], ["low", "Price: low to high"], ["high", "Price: high to low"], ["rating", "Best rated"]] as const

type GoodsShopProps = React.ComponentProps<"div"> & { hrefs?: Partial<GoodsHrefs> }

/** The shop: category, price, glaze and stock filters with removable chips, sorting and an honest empty state. */
function GoodsShop({ hrefs, ...props }: GoodsShopProps) {
  const link = { product: "/goods/shop/morning-mug", ...hrefs }
  const [cats, setCats] = React.useState<Category[]>([])
  const [maxPrice, setMaxPrice] = React.useState(80)
  const [picked, setPicked] = React.useState<number[]>([])
  const [stock, setStock] = React.useState(false)
  const [sort, setSort] = React.useState<(typeof sorts)[number][0]>("featured")
  const toggle = <T,>(set: React.Dispatch<React.SetStateAction<T[]>>, v: T) => set((a) => (a.includes(v) ? a.filter((x) => x !== v) : [...a, v]))
  const list = products
    .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))
    .sort((a, b) => (sort === "low" ? a.price - b.price : sort === "high" ? b.price - a.price : sort === "rating" ? b.rating - a.rating : 0))
  const active = cats.length + picked.length + (stock ? 1 : 0) + (maxPrice < 80 ? 1 : 0)
  const clear = () => { setCats([]); setPicked([]); setStock(false); setMaxPrice(80) }
  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]"

  const filters = (p: string) => (
    <div className="grid gap-8">
      <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>
      <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>
      <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>
      <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>
    </div>
  )

  return (
    <GoodsShell page="shop" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6">
        <h1 className={cn("text-[clamp(3rem,8vw,6rem)] leading-none", goodsSerifClass)}>The shop</h1>
        <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>

        <div className="mt-10 grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[15rem_minmax(0,1fr)]">
          <aside aria-label="Filters" className="hidden lg:block">{filters("gs-d")}</aside>
          <div>
            <div className="flex flex-wrap items-center justify-between gap-3">
              <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>
              <p role="status" className="text-muted-foreground text-sm">{list.length} {list.length === 1 ? "piece" : "pieces"}</p>
              <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>
            </div>
            {active > 0 && (
              <div className="mt-4 flex flex-wrap items-center gap-2" aria-label="Active filters" role="group">
                {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>)}
                {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>)}
                {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>}
                {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>}
                <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>
              </div>
            )}
            {list.length > 0 ? (
              <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>
            ) : (
              <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>
            )}
          </div>
        </div>
      </main>
    </GoodsShell>
  )
}

export { GoodsShop, type GoodsShopProps }
```

### `registry/ballmac/components/templates/goods/goods-product.tsx`

```tsx
// Ballmac UI: Goods product page. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"
import { Check, Minus, Plus, Truck } from "lucide-react"

import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ballmac/accordion"
import { cart } from "@/components/ballmac/templates/goods/goods-cart"
import { glazes, money, productById, products, reviewList } from "@/components/ballmac/templates/goods/goods-data"
import { ProductCard, productLink } from "@/components/ballmac/templates/goods/goods-home"
import { GoodsShell, Piece, Stars, goodsSerifClass, type GoodsHrefs } from "@/components/ballmac/templates/goods/goods-theme"
import { cn } from "@/lib/utils"

const viewNames = ["Front", "Close up", "In a pair", "On a dark wall"]
const bars = [[5, 78], [4, 15], [3, 5], [2, 1], [1, 1]]

type GoodsProductProps = React.ComponentProps<"div"> & {
  hrefs?: Partial<GoodsHrefs>
  /** Product id. When left out the page reads ?p= from the address, then falls back to the first product. */
  slug?: string
}

/** A product page: gallery with four views, glaze and quantity pickers, an add-to-bag that confirms, details, reviews and pairings. */
function GoodsProduct({ hrefs, slug, ...props }: GoodsProductProps) {
  const link = { shop: "/goods/shop", product: "/goods/shop/morning-mug", cart: "/goods/cart", ...hrefs }
  const [id, setId] = React.useState(slug ?? products[0]!.id)
  React.useEffect(() => {
    if (slug) return setId(slug)
    const p = new URLSearchParams(window.location.search).get("p")
    if (p) setId(p)
  }, [slug])
  const product = productById(id)
  const [glaze, setGlaze] = React.useState(product.glazes[0]!)
  const [view, setView] = React.useState(0)
  const [qty, setQty] = React.useState(1)
  const [added, setAdded] = React.useState(false)
  React.useEffect(() => { setGlaze(product.glazes[0]!); setView(0); setQty(1); setAdded(false) }, [product])
  const pairs = products.filter((p) => p.category !== product.category && p.id !== product.id).slice(0, 4)
  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]"

  return (
    <GoodsShell page="product" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-7xl px-4 pt-8 pb-8 sm:px-6">
        <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>

        <div className="mt-6 grid grid-cols-[minmax(0,1fr)] gap-10 lg:grid-cols-[1.1fr_1fr]">
          <div className="grid gap-4 sm:grid-cols-[5rem_minmax(0,1fr)]">
            <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">
              {viewNames.map((v, i) => (
                <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>
              ))}
            </div>
            <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>
          </div>

          <div>
            <h1 className={cn("text-[clamp(2.6rem,5vw,4rem)] leading-none", goodsSerifClass)}>{product.name}</h1>
            <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>
            <p className="mt-5 text-3xl font-semibold tabular-nums">{money(product.price)}</p>
            <p className="text-muted-foreground mt-4 text-lg text-pretty">{product.description}</p>

            <fieldset className="mt-8"><legend className="font-bold">Glaze: <span className="font-normal">{glazes[glaze]!.name}</span></legend>
              <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>
            </fieldset>

            <div className="mt-8 flex flex-wrap items-center gap-4">
              <div role="group" aria-label="Quantity" className="flex items-center rounded-full border">
                <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>
                <output aria-live="polite" className="w-9 text-center font-semibold tabular-nums">{qty}</output>
                <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>
              </div>
              <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>
            </div>
            <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>
            <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>

            <Accordion type="single" collapsible defaultValue="size" className="mt-8">
              <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>
              <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>
              <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>
            </Accordion>
          </div>
        </div>

        <section id="reviews" aria-labelledby="gp-rev" className="mt-20 scroll-mt-24 grid gap-10 lg:grid-cols-[18rem_minmax(0,1fr)]">
          <div>
            <h2 id="gp-rev" className={cn("text-3xl", goodsSerifClass)}>Reviews</h2>
            <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>
            <p className="text-muted-foreground mt-1 text-sm">{product.reviews} reviews</p>
            <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>
          </div>
          <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>
        </section>

        <section aria-labelledby="gp-pair" className="mt-20">
          <h2 id="gp-pair" className={cn("text-3xl sm:text-4xl", goodsSerifClass)}>Pairs well with</h2>
          <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>
        </section>
      </main>
    </GoodsShell>
  )
}

export { GoodsProduct, type GoodsProductProps }
```

### `registry/ballmac/components/templates/goods/goods-cart-page.tsx`

```tsx
// Ballmac UI: Goods bag page. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"
import { Minus, Plus, ShoppingBag, Truck } from "lucide-react"

import { Progress } from "@/components/ballmac/progress"
import { cart, promo, useCart, usePromo } from "@/components/ballmac/templates/goods/goods-cart"
import { FREE_SHIPPING, PROMO, glazes, money } from "@/components/ballmac/templates/goods/goods-data"
import { GoodsShell, Piece, goodsSerifClass, type GoodsHrefs } from "@/components/ballmac/templates/goods/goods-theme"
import { cn } from "@/lib/utils"

/** Puts two pieces in an empty bag once, so previews and screenshots show a full page. Not used by the installed route. */
function useDemoBag(on: boolean) {
  React.useEffect(() => {
    if (!on) return
    let empty = false
    try {
      empty = window.sessionStorage.getItem("kiln-bag") === null
    } catch {
      // Storage is blocked; the demo bag still fills for this page.
      empty = true
    }
    if (empty) {
      cart.add("morning-mug", 0, 2)
      cart.add("noodle-bowl", 2, 1)
    }
  }, [on])
}

type GoodsCartPageProps = React.ComponentProps<"div"> & { hrefs?: Partial<GoodsHrefs>; demo?: boolean }

/** The bag page: quantities, a free-shipping meter, a promo code and a summary that updates as you change things. */
function GoodsCartPage({ hrefs, demo = false, ...props }: GoodsCartPageProps) {
  const link = { shop: "/goods/shop", checkout: "/goods/checkout", product: "/goods/shop/morning-mug", ...hrefs }
  useDemoBag(demo)
  const { lines, count, subtotal } = useCart()
  const promoOn = usePromo()
  const [code, setCode] = React.useState("")
  const [bad, setBad] = React.useState(false)
  const discount = promoOn ? subtotal * PROMO.rate : 0
  const after = subtotal - discount
  const shipping = after === 0 || after >= FREE_SHIPPING ? 0 : 6
  const total = after + shipping
  const left = Math.max(0, FREE_SHIPPING - after)
  function apply(e: React.FormEvent) {
    e.preventDefault()
    const ok = code.trim().toUpperCase() === PROMO.code
    setBad(!ok)
    if (ok) promo.set(true)
  }
  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]"
  return (
    <GoodsShell page="cart" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-7xl px-4 pt-12 pb-8 sm:px-6">
        <h1 className={cn("text-[clamp(3rem,8vw,5.5rem)] leading-none", goodsSerifClass)}>Your bag</h1>
        {lines.length === 0 ? (
          <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>
        ) : (
          <div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-10 lg:grid-cols-[minmax(0,1fr)_24rem]">
            <div>
              <div className="bg-secondary rounded-2xl p-4">
                <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>
                <Progress className="mt-3" value={Math.min(100, (after / FREE_SHIPPING) * 100)} aria-label="Progress to free shipping" />
              </div>
              <ul className="mt-2 divide-y">
                {lines.map((l) => (
                  <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]">
                    <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>
                    <div className="min-w-0">
                      <h2 className={cn("text-2xl", goodsSerifClass)}>{l.product.name}</h2>
                      <p className="text-muted-foreground text-sm">{glazes[l.glaze]!.name} · {l.product.size}</p>
                      <div className="mt-4 flex items-center gap-4">
                        <div role="group" aria-label={`Quantity of ${l.product.name}`} className="flex items-center rounded-full border">
                          <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>
                          <span className="w-8 text-center font-semibold tabular-nums">{l.qty}</span>
                          <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>
                        </div>
                        <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>
                      </div>
                    </div>
                    <p className="col-start-2 text-lg font-semibold tabular-nums sm:col-start-auto">{money(l.product.price * l.qty)}</p>
                  </li>
                ))}
              </ul>
            </div>
            <aside aria-labelledby="gc-sum" className="bg-card rounded-3xl border p-6 lg:sticky lg:top-24">
              <h2 id="gc-sum" className={cn("text-2xl", goodsSerifClass)}>Summary</h2>
              <form onSubmit={apply} className="mt-5 flex gap-2">
                <label htmlFor="gc-code" className="sr-only">Promo code</label>
                <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]" />
                <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>
              </form>
              <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>
              <dl className="mt-3 grid gap-2 border-t pt-4 text-sm">
                <div className="flex justify-between"><dt>Subtotal ({count})</dt><dd className="tabular-nums">{money(subtotal)}</dd></div>
                {discount > 0 && <div className="flex justify-between"><dt>Promo {PROMO.code}</dt><dd className="tabular-nums">−{money(discount)}</dd></div>}
                <div className="flex justify-between"><dt>Shipping</dt><dd className="tabular-nums">{shipping === 0 ? "Free" : money(shipping)}</dd></div>
                <div className="flex justify-between border-t pt-3 text-lg font-bold"><dt>Total</dt><dd className="tabular-nums">{money(total)}</dd></div>
              </dl>
              <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>
              <a href={link.shop} className="mt-3 block text-center text-sm font-semibold underline underline-offset-4">Keep shopping</a>
            </aside>
          </div>
        )}
      </main>
    </GoodsShell>
  )
}

export { GoodsCartPage, useDemoBag, type GoodsCartPageProps }
```

### `registry/ballmac/components/templates/goods/goods-checkout.tsx`

```tsx
// Ballmac UI: Goods checkout page. https://ui.ballmac.com/templates/template-goods
"use client"

import * as React from "react"
import { Check, Lock } from "lucide-react"

import { cart, useCart, usePromo } from "@/components/ballmac/templates/goods/goods-cart"
import { useDemoBag } from "@/components/ballmac/templates/goods/goods-cart-page"
import { FREE_SHIPPING, PROMO, glazes, money } from "@/components/ballmac/templates/goods/goods-data"
import { GoodsShell, Piece, goodsSerifClass, type GoodsHrefs } from "@/components/ballmac/templates/goods/goods-theme"
import { cn } from "@/lib/utils"

const methods = [["standard", "Standard", "2 to 3 working days", 6], ["express", "Express", "Next working day", 12]] as const
const 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]"
type Values = { email: string; name: string; address: string; city: string; postcode: string; card: string; expiry: string; cvc: string }
const empty: Values = { email: "", name: "", address: "", city: "", postcode: "", card: "", expiry: "", cvc: "" }

type GoodsCheckoutProps = React.ComponentProps<"div"> & { hrefs?: Partial<GoodsHrefs>; demo?: boolean }

/** Checkout: contact, delivery, a demo card form that formats as you type, a live summary and a thank-you page. */
function GoodsCheckout({ hrefs, demo = false, ...props }: GoodsCheckoutProps) {
  const link = { shop: "/goods/shop", cart: "/goods/cart", ...hrefs }
  useDemoBag(demo)
  const { lines, subtotal } = useCart()
  const promoOn = usePromo()
  const [v, setV] = React.useState<Values>(empty)
  const [method, setMethod] = React.useState<(typeof methods)[number][0]>("standard")
  const [errors, setErrors] = React.useState<Partial<Record<keyof Values, string>>>({})
  const [order, setOrder] = React.useState<{ number: string; total: number; email: string } | null>(null)
  const form = React.useRef<HTMLFormElement>(null)

  const after = subtotal * (promoOn ? 1 - PROMO.rate : 1)
  const base = methods.find((m) => m[0] === method)!
  const shipping = lines.length === 0 ? 0 : method === "standard" && after >= FREE_SHIPPING ? 0 : base[3]
  const total = after + shipping

  const set = (k: keyof Values, format?: (s: string) => string) => ({
    id: `gk-${k}`, name: k, value: v[k], "aria-invalid": !!errors[k], "aria-describedby": errors[k] ? `gk-err-${k}` : undefined,
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => setV((s) => ({ ...s, [k]: format ? format(e.target.value) : e.target.value })),
  })
  const err = (k: keyof Values) => (errors[k] ? <p id={`gk-err-${k}`} role="alert" className="text-destructive text-sm">{errors[k]}</p> : null)

  function submit(e: React.FormEvent) {
    e.preventDefault()
    const next: Partial<Record<keyof Values, string>> = {}
    if (!/^\S+@\S+\.\S+$/.test(v.email.trim())) next.email = "Enter an email like you@example.com."
    if (!v.name.trim()) next.name = "We need a name for the parcel."
    if (!v.address.trim()) next.address = "Enter a street address."
    if (!v.city.trim()) next.city = "Enter a town or city."
    if (v.postcode.trim().length < 5) next.postcode = "Enter a full postcode."
    if (v.card.replace(/\s/g, "").length < 16) next.card = "Enter all 16 digits."
    if (!/^\d{2}\/\d{2}$/.test(v.expiry)) next.expiry = "Use MM/YY."
    if (v.cvc.length < 3) next.cvc = "3 digits."
    setErrors(next)
    const first = Object.keys(next)[0]
    if (first) return void form.current?.querySelector<HTMLInputElement>(`[name="${first}"]`)?.focus()
    setOrder({ number: "KC-48213", total, email: v.email.trim() })
    cart.clear()
  }

  return (
    <GoodsShell page="checkout" hrefs={hrefs} {...props}>
      <main className="mx-auto max-w-6xl px-4 pt-12 pb-8 sm:px-6">
        {order ? (
          <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">
            <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>
            <h1 id="gk-done" className={cn("mt-6 text-5xl", goodsSerifClass)}>Thank you.</h1>
            <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>
            <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>
          </section>
        ) : lines.length === 0 ? (
          <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>
        ) : (
          <>
            <h1 className={cn("text-[clamp(3rem,8vw,5rem)] leading-none", goodsSerifClass)}>Checkout</h1>
            <div className="mt-8 grid grid-cols-[minmax(0,1fr)] items-start gap-10 lg:grid-cols-[minmax(0,1fr)_24rem]">
              <form ref={form} onSubmit={submit} noValidate className="grid gap-10">
                <section aria-labelledby="gk-contact" className="grid gap-4"><h2 id="gk-contact" className={cn("text-2xl", goodsSerifClass)}>Contact</h2>
                  <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>
                <section aria-labelledby="gk-ship" className="grid gap-4"><h2 id="gk-ship" className={cn("text-2xl", goodsSerifClass)}>Delivery address</h2>
                  <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>
                  <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>
                  <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>
                <fieldset className="grid gap-3"><legend className={cn("mb-1 text-2xl", goodsSerifClass)}>Delivery method</legend>
                  {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>
                <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>
                  <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>
                  <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>
                <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>
              </form>
              <aside aria-labelledby="gk-sum" className="bg-card rounded-3xl border p-6 lg:sticky lg:top-24">
                <h2 id="gk-sum" className={cn("text-2xl", goodsSerifClass)}>In your bag</h2>
                <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>
                <dl className="mt-5 grid gap-2 border-t pt-4 text-sm">
                  <div className="flex justify-between"><dt>Subtotal</dt><dd className="tabular-nums">{money(subtotal)}</dd></div>
                  {promoOn && <div className="flex justify-between"><dt>Promo {PROMO.code}</dt><dd className="tabular-nums">−{money(subtotal * PROMO.rate)}</dd></div>}
                  <div className="flex justify-between"><dt>Shipping</dt><dd className="tabular-nums">{shipping === 0 ? "Free" : money(shipping)}</dd></div>
                  <div className="flex justify-between border-t pt-3 text-lg font-bold"><dt>Total</dt><dd className="tabular-nums">{money(total)}</dd></div>
                </dl>
                <a href={link.cart} className="mt-4 block text-center text-sm font-semibold underline underline-offset-4">Edit bag</a>
              </aside>
            </div>
          </>
        )}
      </main>
    </GoodsShell>
  )
}

export { GoodsCheckout, type GoodsCheckoutProps }
```

### `registry/ballmac/app/goods/page.tsx`

```tsx
// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods
import type { Metadata } from "next"

import { GoodsHome } from "@/components/ballmac/templates/goods/goods-home"

export const metadata: Metadata = {
  title: "Kiln & Co: handmade ceramics",
  description: "Everyday ceramics thrown by hand in Bristol and fired in small batches.",
}

export default function Page() {
  return <GoodsHome />
}
```

### `registry/ballmac/app/goods/shop/page.tsx`

```tsx
// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods
import type { Metadata } from "next"

import { GoodsShop } from "@/components/ballmac/templates/goods/goods-shop"

export const metadata: Metadata = {
  title: "Shop · Kiln & Co",
  description: "Mugs, bowls, vases and plates in five glazes.",
}

export default function Page() {
  return <GoodsShop />
}
```

### `registry/ballmac/app/goods/shop/[id]/page.tsx`

```tsx
// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods
import type { Metadata } from "next"
import { Suspense } from "react"

import { GoodsProduct } from "@/components/ballmac/templates/goods/goods-product"

export const metadata: Metadata = {
  title: "Kiln & Co: product",
  description: "A handmade ceramic piece from Kiln & Co.",
}

// Reading `params` is runtime data: with Cache Components (on by default in new Next.js 16.4 projects) it has to sit inside <Suspense>.
export default function Page({ params }: { params: Promise<{ id: string }> }) {
  return (
    <Suspense fallback={null}>
      <ProductView params={params} />
    </Suspense>
  )
}

async function ProductView({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params
  return <GoodsProduct slug={id} />
}
```

### `registry/ballmac/app/goods/cart/page.tsx`

```tsx
// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods
import type { Metadata } from "next"

import { GoodsCartPage } from "@/components/ballmac/templates/goods/goods-cart-page"

export const metadata: Metadata = {
  title: "Your bag · Kiln & Co",
  description: "Review your pieces and apply a code.",
}

export default function Page() {
  return <GoodsCartPage demo />
}
```

### `registry/ballmac/app/goods/checkout/page.tsx`

```tsx
// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods
import type { Metadata } from "next"

import { GoodsCheckout } from "@/components/ballmac/templates/goods/goods-checkout"

export const metadata: Metadata = {
  title: "Checkout · Kiln & Co",
  description: "Delivery and payment.",
}

export default function Page() {
  return <GoodsCheckout demo />
}
```
