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.
// 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 })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 })}`// 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 }
}// 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">&</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 & 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 & 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 }// 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-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>
<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-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>
</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 }// 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 }// 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 }// 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 }// 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 }// 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 />
}// 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 />
}// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods
import type { Metadata } from "next"
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.",
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <GoodsProduct slug={id} />
}// 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 />
}// 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 />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-goodsInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/sheet @ballmac/slider @ballmac/progress @ballmac/accordion @ballmac/mediaCopy the source into your project.
components/ballmac/templates/goods/goods-fonts.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 })components/ballmac/templates/goods/goods-data.tsimport 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 })}`components/ballmac/templates/goods/goods-cart.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 } }components/ballmac/templates/goods/goods-theme.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">&</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 & 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 & 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 }components/ballmac/templates/goods/goods-home.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-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> <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-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> </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 }components/ballmac/templates/goods/goods-shop.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 }components/ballmac/templates/goods/goods-product.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 }components/ballmac/templates/goods/goods-cart-page.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 }components/ballmac/templates/goods/goods-checkout.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 }app/goods/page.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 /> }app/goods/shop/page.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 /> }app/goods/shop/[id]/page.tsx// Ballmac UI: Goods template route. https://ui.ballmac.com/templates/template-goods import type { Metadata } from "next" 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.", } export default async function Page({ params }: { params: Promise<{ id: string }> }) { const { id } = await params return <GoodsProduct slug={id} /> }app/goods/cart/page.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 /> }app/goods/checkout/page.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 /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Home/goods
- Shop/goods/shop
- Product/goods/shop/morning-mug
- Bag/goods/cart
- Checkout/goods/checkout
- Fonts
- Fraunces, Hanken Grotesk (Google Fonts, loaded with next/font)
- Files
- 14 files · 5 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
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. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Kiln & Co: online store (@ballmac/template-goods) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Small online shops and product drops
- Any store that needs filters, a product page, a bag and checkout
Not for
- Large catalogues that need server-side search and pagination
Registry JSON: https://ui.ballmac.com/r/template-goods.json
Free to use in personal and commercial projects.