From d7f400537914706392dad1c4a3f3dc0f52da9cc4 Mon Sep 17 00:00:00 2001 From: Warren Date: Wed, 7 Oct 2026 22:51:32 -0500 Subject: [PATCH 1/2] WIP: cart, product-list and product-page elements (no catalog entries, demos or checks yet) Built to the Superhero Shop Worker contract (PR #1, claude/worker-stub). Not finished: catalog entries, demos, GOLDEN categories, check-shop.mjs with mutation tests, skill sections. Co-Authored-By: Claude Opus 5.5 --- public/demo/shop-candle.svg | 1 + public/demo/shop-catalog.json | 50 + public/demo/shop-mug-side.svg | 1 + public/demo/shop-mug.svg | 1 + public/demo/shop-notebook.svg | 1 + public/demo/shop-stock.json | 9 + public/demo/shop-tote.svg | 1 + src/library/cart/Cart.astro | 1260 ++++++++++++++++++++ src/library/product-list/ProductList.astro | 270 +++++ src/library/product-list/fetchCatalog.ts | 147 +++ src/library/product-page/ProductPage.astro | 434 +++++++ 11 files changed, 2175 insertions(+) create mode 100644 public/demo/shop-candle.svg create mode 100644 public/demo/shop-catalog.json create mode 100644 public/demo/shop-mug-side.svg create mode 100644 public/demo/shop-mug.svg create mode 100644 public/demo/shop-notebook.svg create mode 100644 public/demo/shop-stock.json create mode 100644 public/demo/shop-tote.svg create mode 100644 src/library/cart/Cart.astro create mode 100644 src/library/product-list/ProductList.astro create mode 100644 src/library/product-list/fetchCatalog.ts create mode 100644 src/library/product-page/ProductPage.astro diff --git a/public/demo/shop-candle.svg b/public/demo/shop-candle.svg new file mode 100644 index 0000000..3b3989f --- /dev/null +++ b/public/demo/shop-candle.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-catalog.json b/public/demo/shop-catalog.json new file mode 100644 index 0000000..724de8b --- /dev/null +++ b/public/demo/shop-catalog.json @@ -0,0 +1,50 @@ +{ + "site": "demo-shop", + "open": true, + "currency": "USD", + "products": [ + { + "id": "ITEMMUG", + "slug": "enamel-mug", + "name": "Enamel mug", + "description": "A camp mug in steel with a glass enamel coat. Holds 12 oz (< 350 ml).\n\nHand wash only. The rim is rolled, so it stays cool to the lip.", + "images": ["shop-mug.svg", "shop-mug-side.svg"], + "variations": [ + { "id": "VARMUGCREAM", "name": "Cream", "price_cents": 1800, "tracked": true }, + { "id": "VARMUGSLATE", "name": "Slate", "price_cents": 2000, "tracked": true } + ] + }, + { + "id": "ITEMNOTEBOOK", + "slug": "field-notebook", + "name": "Field notebook", + "description": "Forty-eight pages of dot grid, stitched, in a card cover that fits a back pocket.", + "images": ["shop-notebook.svg"], + "variations": [ + { "id": "VARNOTEBOOK", "name": "Regular", "price_cents": 900, "tracked": false } + ] + }, + { + "id": "ITEMCANDLE", + "slug": "beeswax-candle", + "name": "Beeswax candle", + "description": "Poured by hand from local beeswax, with a cotton wick.\nSmall burns for about 20 hours, large for about 45.", + "images": ["shop-candle.svg"], + "variations": [ + { "id": "VARCANDLESM", "name": "Small", "price_cents": 1400, "tracked": true }, + { "id": "VARCANDLELG", "name": "Large", "price_cents": 2600, "tracked": true } + ] + }, + { + "id": "ITEMTOTE", + "slug": "canvas-tote", + "name": "Canvas tote", + "description": "Heavy cotton canvas with long handles and an inside pocket.", + "images": ["shop-tote.svg"], + "variations": [ + { "id": "VARTOTENAT", "name": "Natural", "price_cents": 2400, "tracked": true }, + { "id": "VARTOTEBLK", "name": "Black", "price_cents": 2400, "tracked": true } + ] + } + ] +} diff --git a/public/demo/shop-mug-side.svg b/public/demo/shop-mug-side.svg new file mode 100644 index 0000000..4558149 --- /dev/null +++ b/public/demo/shop-mug-side.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-mug.svg b/public/demo/shop-mug.svg new file mode 100644 index 0000000..7a2f454 --- /dev/null +++ b/public/demo/shop-mug.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-notebook.svg b/public/demo/shop-notebook.svg new file mode 100644 index 0000000..02b2e60 --- /dev/null +++ b/public/demo/shop-notebook.svg @@ -0,0 +1 @@ + diff --git a/public/demo/shop-stock.json b/public/demo/shop-stock.json new file mode 100644 index 0000000..f0d899c --- /dev/null +++ b/public/demo/shop-stock.json @@ -0,0 +1,9 @@ +{ + "VARMUGCREAM": 6, + "VARMUGSLATE": 2, + "VARNOTEBOOK": null, + "VARCANDLESM": 12, + "VARCANDLELG": 1, + "VARTOTENAT": 0, + "VARTOTEBLK": 0 +} diff --git a/public/demo/shop-tote.svg b/public/demo/shop-tote.svg new file mode 100644 index 0000000..ac0697b --- /dev/null +++ b/public/demo/shop-tote.svg @@ -0,0 +1 @@ + diff --git a/src/library/cart/Cart.astro b/src/library/cart/Cart.astro new file mode 100644 index 0000000..8bba5a4 --- /dev/null +++ b/src/library/cart/Cart.astro @@ -0,0 +1,1260 @@ +--- +/** + * Cart — the Superhero Shop cart: a cart button with its count, and a drawer that lists the + * lines, asks ship-or-pickup and sends the buyer to Square's checkout page. It replaces the cart + * of WooCommerce, the Shopify Buy Button, Ecwid and Square Online on a static site whose shop is + * the Superhero Shop Worker (shop.superherotech.ai, ecropolis/SuperheroShop). + * + * THE CONTRACT WITH THE WORKER (its README, "The checkout contract") + * GET /v1/shops//stock?ids=a,b -> { [variationId]: number | null } + * null = untracked (always available); an id left out is no longer in the shop. + * POST /v1/shops//checkout + * { items: [{ variationId, quantity }], fulfillment: "ship" | "pickup", + * pickupPoint?, idempotencyKey } -> 200 { url } + * 409 { error: "short", short: [{ variationId, available }] }, 503 { error: "shop_closed" }, + * 400 { error: "unknown_variation", ids }, and the rest in the README. + * The body carries ids and quantities and nothing else. The Worker refuses any other field, + * `price` included, with 400 unexpected_field, and prices the order from Square. So the cart + * NEVER SENDS A PRICE: the subtotal it shows is for display only, computed from the catalogue + * the page was built with. Shipping is shown as an estimate from props for the same reason. + * Every checkout attempt mints a fresh `idempotencyKey` (crypto.randomUUID), as the README + * requires: a reused key gets the stored link back whatever the cart now says. + * + * THE CART is kept in localStorage under `superhero-cart:`, ids and quantities only: + * at most 20 lines, 1 to 20 of each (the Worker's own limits). On load it is cleaned: unknown + * ids, bad quantities and duplicates are dropped, clamped or merged. The drawer re-checks stock + * against /stock when it opens and again before checkout; a short line is clamped to what is + * left and says so on the line, a sold-out or withdrawn one is taken out with a sentence. The + * checkout's own 409 `short` is handled the same way. A 503 reads "This shop is closed for the + * moment." A network failure is one sentence. The cart is kept after the redirect (the buyer may + * come back without paying); the thanks page renders to empty it. + * + * ADD TO CART, from any markup (product-list and product-page use this; elements never import + * each other, so the contract is attributes): + * + * optional data-cart-qty="2". The button is `hidden` until a cart for that shop is on the + * page and its script has run, so without JavaScript nothing that cannot work is shown. + *
with a field `variation` (radios or a hidden + * input) and a field `quantity`; submitting adds. Give its submit button + * `data-cart-needs-js data-cart-shop="" hidden`, which the cart reveals the same way. + * An element `[data-cart-note]` inside the form (or right after the button) receives a refusal + * ("Your cart holds up to 20 of each item."). + * Several s for one shop on a page (a header button and a footer one) share one cart. + * + * EVENTS for hosts: `cart-checkout` (bubbles, cancelable, detail { url, shop }) right before the + * redirect; cancel it to handle the URL yourself (the demo does, so it never leaves the page). + * `cart-change` (bubbles, detail { shop, count }) after any change. + * window.__superheroCart: add(shop, variationId, qty), clear(shop), lines(shop), open(id). + * + * ACCESSIBILITY. The trigger is a + + + +
+

{title}

+ +
+
+ +

Your cart is empty.

+
    + +
    +
    + + +)} + + diff --git a/src/library/product-list/fetchCatalog.ts b/src/library/product-list/fetchCatalog.ts new file mode 100644 index 0000000..5f26358 --- /dev/null +++ b/src/library/product-list/fetchCatalog.ts @@ -0,0 +1,147 @@ +/** + * fetchCatalog — the Superhero Shop catalogue, for an Astro build. Copy this file into the site's + * `src/lib/` beside the shop elements (cart, product-list, product-page); it is the one place a + * shop site talks to the Worker at build time. + * + * --- + * import { fetchCatalog } from '../lib/fetchCatalog'; + * const catalog = await fetchCatalog('domino-works'); + * --- + * + * + * and for one page per product (src/pages/shop/[slug].astro): + * + * export async function getStaticPaths() { + * const catalog = await fetchCatalog('domino-works'); + * return catalog.products.map((product) => ({ params: { slug: product.slug }, props: { product, catalog } })); + * } + * + * GET https://shop.superherotech.ai/v1/shops//catalog is public (no key) and answers + * `{ site, open, currency, products: [{ id, slug, name, description, images, variations: + * [{ id, name, price_cents, tracked }] }] }`. The Worker keeps its last good copy past a Square + * outage, and answers 503 only when it has never had one (`not_connected`, `no_location`, + * `no_category`). + * + * A failure THROWS, with one sentence, so the build fails and the last good deploy stays live. + * That is deliberate: a shop page built from nothing would publish an empty shop, and a + * catalogue edit in Square fires the deploy hook again anyway. Every product is checked + * against the contract; a malformed one fails the build rather than reaching a page with a + * missing price. Deploy hooks: the Worker fires the site's on `catalog.version.updated`, at most + * once per five minutes, with a trailing edge. + * + * `description` is Square's item description as PLAIN TEXT (the Worker passes Square's + * `description` field through and never returns HTML); render it as text. + */ +export interface ShopVariation { + id: string; + name: string; + /** In the currency's minor unit (cents for USD). For display only: the cart never sends it. */ + price_cents: number; + /** false: Square does not count it, so it is always available. */ + tracked: boolean; +} +export interface ShopProduct { + id: string; + slug: string; + name: string; + /** Plain text; may hold newlines. */ + description: string; + images: string[]; + variations: ShopVariation[]; +} +export interface ShopCatalog { + site: string; + /** false unless the shop is live. A build-time snapshot: the checkout's 503 is the truth. */ + open: boolean; + /** ISO 4217, or null when the shop has no priced variation yet. */ + currency: string | null; + products: ShopProduct[]; +} +export interface FetchCatalogOptions { + /** The Worker's origin. Default https://shop.superherotech.ai. */ + api?: string; + /** For tests: a fetch to use instead of the global one. */ + fetch?: typeof globalThis.fetch; + /** Give up after this many ms. Default 15000. */ + timeoutMs?: number; +} + +const SITE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const ID_RE = /^[A-Za-z0-9_-]{1,64}$/; + +/** Throws a sentence naming what is wrong; returns the catalogue as the contract describes it. */ +export function parseCatalog(raw: unknown, site: string): ShopCatalog { + const bad = (why: string): never => { + throw new Error(`fetchCatalog("${site}"): ${why}`); + }; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) bad('the answer is not a JSON object.'); + const c = raw as Record; + if (!Array.isArray(c.products)) bad('the answer has no `products` list.'); + if (c.currency !== null && (typeof c.currency !== 'string' || !/^[A-Z]{3}$/.test(c.currency))) bad(`currency ${JSON.stringify(c.currency)} is not an ISO 4217 code.`); + const slugs = new Set(); + const variationIds = new Set(); + const products: ShopProduct[] = (c.products as unknown[]).map((p, i) => { + const at = `product #${i + 1}`; + if (!p || typeof p !== 'object') bad(`${at} is not an object.`); + const q = p as Record; + if (typeof q.id !== 'string' || !ID_RE.test(q.id)) bad(`${at} has no Square id.`); + if (typeof q.slug !== 'string' || !SITE_RE.test(q.slug)) bad(`${at} ("${String(q.name)}") has slug ${JSON.stringify(q.slug)}, which is not a URL segment.`); + if (slugs.has(q.slug as string)) bad(`slug "${String(q.slug)}" appears twice.`); + slugs.add(q.slug as string); + if (typeof q.name !== 'string' || !q.name.trim()) bad(`${at} has no name.`); + if (q.description !== undefined && typeof q.description !== 'string') bad(`${at} has a description that is not text.`); + if (q.images !== undefined && (!Array.isArray(q.images) || !q.images.every((u) => typeof u === 'string' && /^https?:\/\//.test(u)))) bad(`${at} has images that are not URLs.`); + if (!Array.isArray(q.variations) || q.variations.length === 0) bad(`${at} ("${q.name}") has no variations; the Worker leaves out a product with nothing to sell.`); + const variations = (q.variations as unknown[]).map((v, j) => { + const vat = `${at} ("${q.name}"), variation #${j + 1}`; + if (!v || typeof v !== 'object') bad(`${vat} is not an object.`); + const w = v as Record; + if (typeof w.id !== 'string' || !ID_RE.test(w.id)) bad(`${vat} has no Square id.`); + if (variationIds.has(w.id as string)) bad(`variation id ${String(w.id)} appears twice.`); + variationIds.add(w.id as string); + if (typeof w.name !== 'string') bad(`${vat} has no name.`); + if (typeof w.price_cents !== 'number' || !Number.isInteger(w.price_cents) || w.price_cents < 0) bad(`${vat} has price_cents ${JSON.stringify(w.price_cents)}, not a whole number of minor units.`); + if (typeof w.tracked !== 'boolean') bad(`${vat} has no \`tracked\` true or false.`); + return { id: w.id as string, name: w.name as string, price_cents: w.price_cents as number, tracked: w.tracked as boolean }; + }); + return { + id: q.id as string, + slug: q.slug as string, + name: q.name as string, + description: (q.description as string | undefined) ?? '', + images: (q.images as string[] | undefined) ?? [], + variations, + }; + }); + if (products.length && c.currency === null) bad('the catalogue has products but no currency.'); + return { site: typeof c.site === 'string' ? c.site : site, open: c.open === true, currency: (c.currency as string | null) ?? null, products }; +} + +export async function fetchCatalog(site: string, options: FetchCatalogOptions = {}): Promise { + const { api = 'https://shop.superherotech.ai', fetch: f = globalThis.fetch, timeoutMs = 15000 } = options; + if (!SITE_RE.test(site)) throw new Error(`fetchCatalog: "${site}" is not a site key (lowercase words joined by single hyphens).`); + const url = `${api.replace(/\/+$/, '')}/v1/shops/${site}/catalog`; + let res: Response; + try { + res = await f(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) }); + } catch (e) { + throw new Error(`fetchCatalog("${site}"): could not reach ${url} (${(e as Error).message}), so the shop pages cannot be built.`); + } + let body: unknown = null; + try { + body = await res.json(); + } catch { + body = null; + } + if (!res.ok) { + const reason = body && typeof body === 'object' && typeof (body as { error?: unknown }).error === 'string' ? (body as { error: string }).error : `HTTP ${res.status}`; + const hint: Record = { + not_found: 'the Worker has no shop for this site key', + not_connected: 'the seller has not connected Square yet', + no_location: 'the shop has no Square location chosen', + no_category: 'the shop has no visible Square category chosen', + }; + throw new Error(`fetchCatalog("${site}"): ${url} answered ${res.status} (${reason}${hint[reason] ? `: ${hint[reason]}` : ''}), so the shop pages cannot be built.`); + } + return parseCatalog(body, site); +} diff --git a/src/library/product-page/ProductPage.astro b/src/library/product-page/ProductPage.astro new file mode 100644 index 0000000..b1dd404 --- /dev/null +++ b/src/library/product-page/ProductPage.astro @@ -0,0 +1,434 @@ +--- +/** + * ProductPage — one product of the Superhero Shop, built at deploy from the catalogue + * (fetchCatalog, beside product-list): its images, name, description, a variation picker when + * it has more than one variation, quantity, Add to cart, a shipping or pickup note, and the + * page's Product structured data. Replaces the single-product page of WooCommerce, Ecwid and + * Square Online, and the Shopify Buy Button's product embed. + * + * DESCRIPTION. The Worker returns Square's item `description`, which is PLAIN TEXT: it passes the + * field through and neither returns nor sanitises HTML (Square's `description_html` is not in + * the contract). So it is rendered as text: paragraphs at blank lines, single newlines kept. + * Anything that looks like markup is shown as typed, never parsed. + * + * ADD TO CART uses the cart element's attribute contract, not an import: a + * with fields `variation` and `quantity`. Its button stays + * `hidden` until a for the same shop has run on the page, so put the cart in + * the layout. Without JavaScript the page reads in full and a line says ordering needs it. + * + * VARIATIONS. One variation: no picker, the price is shown. More: a radio group (real radios in + * a fieldset with a legend, `optionLabel`), each labelled with its name and price; the price + * line follows the choice. A variation known to be sold out (build-time `stock`, or `liveStock`) + * is disabled and says "Sold out"; when all are, the button says so and cannot be pressed. + * + * JSON-LD. One `Product` (name, description, image, productID, optional brand and url) with + * `offers`: an array of one `Offer` per variation (sku = the Square variation id, name, price as + * a decimal string, priceCurrency, url, and availability when stock is known: untracked means + * InStock). It is the page's only Product node: delete any hand-written one. `<` is escaped, so a + * description can never close the script. Prices are the build's; the next deploy (fired by the + * Worker on a catalogue change) updates them. + * + * ACCESSIBILITY. The name is a heading at `headingLevel` (1 on a product's own page). The main + * image's alt is the product name (`imageAlt` to say more); the others are ", view 2". + * Quantity is a labelled number input, 1 to 20 (the Worker's limit). No live region here: the + * cart announces its count. No motion. + * + * CSP: `img-src` for Square's image host; with `liveStock`, `connect-src https://shop.superherotech.ai`. + * + * Theming: --pp-accent / --pp-on-accent (the button, 7.27:1), --pp-fg, --pp-muted, --pp-border, + * --pp-radius, --pp-image-bg, --pp-focus, --pp-gap, --pp-aspect. + */ +interface Variation { + id: string; + name: string; + price_cents: number; + tracked: boolean; +} +interface Product { + id: string; + slug: string; + name: string; + description?: string; + images?: string[]; + variations: Variation[]; +} +interface PickupPoint { + id: string; + name: string; + details?: string; +} +interface Props { + /** One product from the catalogue. Required. */ + product: Product; + /** The catalogue's currency. Default USD. */ + currency?: string | null; + /** The shop's site key: the cart this page adds to. Required. */ + shop: string; + /** Absolute URL of this page, for the JSON-LD. Default: Astro.site + the path, when the site is configured. */ + url?: string; + /** The seller's name, for the JSON-LD brand. */ + brand?: string; + /** Emit the Product JSON-LD. Default true. */ + jsonLd?: boolean; + /** Level of the name's heading. Default 1. */ + headingLevel?: 1 | 2 | 3; + /** The picker's question. Default "Choose one". */ + optionLabel?: string; + /** The main image's alt. Default the product name. */ + imageAlt?: string; + /** The image box, in px. Default 800 × 800. */ + imageWidth?: number; + imageHeight?: number; + /** Build-time stock, { [variationId]: number | null }. */ + stock?: Record; + /** Check stock from the browser on load. Default false. */ + liveStock?: boolean; + /** The Worker's origin. Default https://shop.superherotech.ai. */ + api?: string; + /** As the cart's props: the note under the button is written from these. */ + flatShippingCents?: number | null; + freeShippingOverCents?: number | null; + pickupPoints?: PickupPoint[]; + /** Your own note instead of the one written from the three props above. */ + note?: string; + class?: string; +} + +const { + product, + currency: cur = 'USD', + shop, + url: urlProp, + brand, + jsonLd = true, + headingLevel = 1, + optionLabel = 'Choose one', + imageAlt, + imageWidth = 800, + imageHeight = 800, + stock, + liveStock = false, + api = 'https://shop.superherotech.ai', + flatShippingCents = null, + freeShippingOverCents = null, + pickupPoints = [], + note, + class: className, +} = Astro.props; + +if (!product || !Array.isArray(product.variations) || product.variations.length === 0) throw new Error('ProductPage: `product` is required, one catalogue product with at least one variation.'); +if (!shop || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(shop)) throw new Error(`ProductPage "${product.name}": \`shop\` "${shop}" is not a site key.`); + +const currency = cur || 'USD'; +const nf = new Intl.NumberFormat('en-US', { style: 'currency', currency }); +const digits = nf.resolvedOptions().maximumFractionDigits ?? 2; +const money = (cents: number) => nf.format(cents / 10 ** digits); +const decimal = (cents: number) => (cents / 10 ** digits).toFixed(digits); + +/** true / false when known, null when not. Untracked is always available. */ +const available = (v: Variation): boolean | null => (!v.tracked ? true : stock && typeof stock[v.id] === 'number' ? (stock[v.id] as number) > 0 : null); +const many = product.variations.length > 1; +const first = product.variations.find((v) => available(v) !== false) ?? product.variations[0]; +const allOut = product.variations.every((v) => available(v) === false); + +const images = product.images ?? []; +const paragraphs = (product.description ?? '').split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean); +const H = `h${headingLevel}` as 'h1' | 'h2' | 'h3'; +const uid = `pp-${product.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; + +const pageUrl = urlProp ?? (Astro.site ? new URL(Astro.url.pathname, Astro.site).href : undefined); +const fulfil = + note ?? + (() => { + const parts: string[] = []; + if (typeof flatShippingCents === 'number') { + parts.push(flatShippingCents === 0 ? 'Ships free.' : `Ships for ${money(flatShippingCents)}${freeShippingOverCents !== null ? `, free on orders over ${money(freeShippingOverCents)}` : ''} (estimated).`); + } + if (pickupPoints.length) { + const names = pickupPoints.map((p) => p.name); + const list = names.length === 1 ? names[0] : `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}`; + parts.push(`${parts.length ? 'Or pick it up' : 'Pick it up'} at ${list}, with no shipping fee.`); + } + return parts.join(' '); + })(); + +const AVAIL = (v: Variation) => { + const a = available(v); + return a === null ? undefined : a ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock'; +}; +const ld = jsonLd + ? JSON.stringify({ + '@context': 'https://schema.org', + '@type': 'Product', + name: product.name, + ...(product.description ? { description: product.description } : {}), + ...(images.length ? { image: images } : {}), + productID: product.id, + ...(brand ? { brand: { '@type': 'Brand', name: brand } } : {}), + ...(pageUrl ? { url: pageUrl } : {}), + offers: product.variations.map((v) => ({ + '@type': 'Offer', + sku: v.id, + name: many ? `${product.name}, ${v.name}` : product.name, + price: decimal(v.price_cents), + priceCurrency: currency, + ...(pageUrl ? { url: pageUrl } : {}), + ...(AVAIL(v) ? { availability: AVAIL(v) } : {}), + })), + }).replace(/ v.tracked) ? JSON.stringify({ api, shop, ids: product.variations.filter((v) => v.tracked).map((v) => v.id) }) : undefined; +--- + +
    +
    + {images.length > 0 ? ( + {imageAlt + ) : ( +
    +
    + {product.name} +

    {money(first.price_cents)}

    + {paragraphs.length > 0 && ( +
    + {paragraphs.map((p) =>

    {p}

    )} +
    + )} + + {many ? ( +
    + {optionLabel} + {product.variations.map((v) => { + const out = available(v) === false; + return ( + + ); + })} +
    + ) : ( + + )} +
    + + +
    + + + + {fulfil &&

    {fulfil}

    } +
    +
    + +{ld &&
    assetBase + name; +const catalog = { ...fixture, products: fixture.products.map((p) => ({ ...p, images: p.images.map(asset) })) }; +const money = (c: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(c / 100); +const stub = `(() => { + if (window.__shopDemo) return; + const API = 'https://shop.demo.invalid'; + const stock = ${JSON.stringify(stock)}; + const state = { mode: 'ok' }; + window.__shopDemo = state; + const real = window.fetch.bind(window); + const later = (v, ms) => new Promise((ok) => setTimeout(() => ok(v), ms)); + const reply = (status, body) => later(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }), 450); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input.url; + if (!url.startsWith(API + '/')) return real(input, init); + const u = new URL(url); + if (u.pathname.endsWith('/stock')) { + const out = {}; + for (const id of (u.searchParams.get('ids') || '').split(',')) if (id in stock) out[id] = stock[id]; + return reply(200, out); + } + if (u.pathname.endsWith('/checkout')) { + const body = JSON.parse(init.body); + document.dispatchEvent(new CustomEvent('shop-demo-request', { detail: body })); + if (state.mode === 'network') return new Promise((_, no) => setTimeout(() => no(new TypeError('Failed to fetch')), 450)); + if (state.mode === 'closed') return reply(503, { ok: false, error: 'shop_closed' }); + if (state.mode === 'short') { + const first = body.items[0]; + return reply(409, { ok: false, error: 'short', short: [{ variationId: first.variationId, available: Math.max(0, first.quantity - 1) }] }); + } + return reply(200, { url: 'https://square.link/u/demo' + body.idempotencyKey.replace(/-/g, '').slice(0, 8) }); + } + return reply(404, { ok: false, error: 'not_found' }); + }; +})();`; +const seed = quiet + ? '' + : `try { const k = 'superhero-cart:demo-cart'; if (!localStorage.getItem(k)) localStorage.setItem(k, JSON.stringify({ v: 1, lines: [{ id: 'VARMUGCREAM', q: 2 }, { id: 'VARCANDLELG', q: 3 }] })); } catch {}`; +--- + + + diff --git a/src/components/demos/ProductListDemo.astro b/src/components/demos/ProductListDemo.astro new file mode 100644 index 0000000..961fbd7 --- /dev/null +++ b/src/components/demos/ProductListDemo.astro @@ -0,0 +1,23 @@ +--- +/** + * Demo for `product-list`: Neutral Goods' four products from public/demo/shop-catalog.json (the + * Superhero Shop Worker's catalogue shape), with abstract SVG images. The stock map + * (public/demo/shop-stock.json) is passed at build, so the canvas tote, whose two variations + * are both at 0, shows Sold out with no network. A real site gets its products from + * fetchCatalog at build; the cards link to its product pages, which here are #fragments. + */ +import ProductList from '../../library/product-list/ProductList.astro'; +import fixture from '../../../public/demo/shop-catalog.json'; +import stock from '../../../public/demo/shop-stock.json'; + +interface Props { + /** Where public/demo/ is served. The gallery uses /demo/; the website passes /elements-demo/. */ + assetBase?: string; + /** Unused: nothing here moves or opens by itself. */ + quiet?: boolean; +} +const { assetBase = '/demo/' } = Astro.props; +const asset = (name: string) => assetBase + name; +const products = fixture.products.map((p) => ({ ...p, images: p.images.map(asset) })); +--- + `#demo-pl-${p.slug}`} /> diff --git a/src/components/demos/ProductPageDemo.astro b/src/components/demos/ProductPageDemo.astro new file mode 100644 index 0000000..499a0f9 --- /dev/null +++ b/src/components/demos/ProductPageDemo.astro @@ -0,0 +1,127 @@ +--- +/** + * Demo for `product-page`: two of Neutral Goods' products from public/demo/shop-catalog.json, + * the enamel mug (two variations at two prices, so a picker, and a description holding "<" that + * must show as typed) and the field notebook (one variation, so no picker). A cart for the same + * shop sits above them, as it would in a site's header, so Add to cart works; its `api` is the + * fake origin a stub answers (the cart demo's, byte for byte; npm run check pins the copy), so + * nothing reaches a network. Stock is passed at + * build from public/demo/shop-stock.json. + * + * The structured data is shown, not emitted (`jsonLd="visible"`): this page is published on + * superherotech.ai, which must not carry Product markup for a product nobody sells. A real + * product page leaves `jsonLd` at its default and emits it. + */ +import Cart from '../../library/cart/Cart.astro'; +import ProductPage from '../../library/product-page/ProductPage.astro'; +import fixture from '../../../public/demo/shop-catalog.json'; +import stock from '../../../public/demo/shop-stock.json'; + +interface Props { + /** Where public/demo/ is served. The gallery uses /demo/; the website passes /elements-demo/. */ + assetBase?: string; + /** Unused: nothing here moves or opens by itself. */ + quiet?: boolean; +} +const { assetBase = '/demo/' } = Astro.props; +const asset = (name: string) => assetBase + name; +const catalog = { ...fixture, products: fixture.products.map((p) => ({ ...p, images: p.images.map(asset) })) }; +const mug = catalog.products.find((p) => p.slug === 'enamel-mug')!; +const notebook = catalog.products.find((p) => p.slug === 'field-notebook')!; +const shipping = { + flatShippingCents: 800, + freeShippingOverCents: 5000, + pickupPoints: [ + { id: 'market', name: 'the Saturday market' }, + { id: 'workshop', name: 'the workshop' }, + ], +}; +const stub = `(() => { + if (window.__shopDemo) return; + const API = 'https://shop.demo.invalid'; + const stock = ${JSON.stringify(stock)}; + const state = { mode: 'ok' }; + window.__shopDemo = state; + const real = window.fetch.bind(window); + const later = (v, ms) => new Promise((ok) => setTimeout(() => ok(v), ms)); + const reply = (status, body) => later(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }), 450); + window.fetch = (input, init) => { + const url = typeof input === 'string' ? input : input.url; + if (!url.startsWith(API + '/')) return real(input, init); + const u = new URL(url); + if (u.pathname.endsWith('/stock')) { + const out = {}; + for (const id of (u.searchParams.get('ids') || '').split(',')) if (id in stock) out[id] = stock[id]; + return reply(200, out); + } + if (u.pathname.endsWith('/checkout')) { + const body = JSON.parse(init.body); + document.dispatchEvent(new CustomEvent('shop-demo-request', { detail: body })); + if (state.mode === 'network') return new Promise((_, no) => setTimeout(() => no(new TypeError('Failed to fetch')), 450)); + if (state.mode === 'closed') return reply(503, { ok: false, error: 'shop_closed' }); + if (state.mode === 'short') { + const first = body.items[0]; + return reply(409, { ok: false, error: 'short', short: [{ variationId: first.variationId, available: Math.max(0, first.quantity - 1) }] }); + } + return reply(200, { url: 'https://square.link/u/demo' + body.idempotencyKey.replace(/-/g, '').slice(0, 8) }); + } + return reply(404, { ok: false, error: 'not_found' }); + }; +})();`; +--- + + + diff --git a/src/components/demos/index.ts b/src/components/demos/index.ts index 46c64f9..5cd94db 100644 --- a/src/components/demos/index.ts +++ b/src/components/demos/index.ts @@ -57,6 +57,9 @@ import StepperDemo from './StepperDemo.astro'; import TagsDemo from './TagsDemo.astro'; import ScrollboxDemo from './ScrollboxDemo.astro'; import StickerDemo from './StickerDemo.astro'; +import CartDemo from './CartDemo.astro'; +import ProductListDemo from './ProductListDemo.astro'; +import ProductPageDemo from './ProductPageDemo.astro'; // Astro components have no exported public type; this is the shape both callers need. // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -106,4 +109,7 @@ export const demos: Record = { tags: TagsDemo, scrollbox: ScrollboxDemo, sticker: StickerDemo, + cart: CartDemo, + 'product-list': ProductListDemo, + 'product-page': ProductPageDemo, }; diff --git a/src/data/catalog.ts b/src/data/catalog.ts index b6cea1d..0f160af 100644 --- a/src/data/catalog.ts +++ b/src/data/catalog.ts @@ -3041,6 +3041,232 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra file: 'src/library/sticker/Sticker.astro', added: '2026-09-26', }, + { + id: 'cart', + category: 'conversion', + name: 'Cart', + aka: ['WooCommerce cart', 'Shopify Buy Button cart', 'Ecwid cart', 'Square Online cart', 'side cart', 'cart drawer', 'mini cart', 'add to cart button', 'slide-out cart', 'shopping cart'], + summary: + 'A cart button with its count and a drawer (a native ) listing the lines, for a shop on the Superhero Shop Worker. Kept in the browser per shop (20 lines, 1 to 20 of each), it re-checks stock on open and before checkout, asks ship or pickup, then sends ids and quantities, never a price, to the Worker and the buyer on to Square’s checkout page.', + pitch: 'Let visitors fill a cart on your own site and pay on Square’s secure checkout, with the stock checked before they get there.', + // SE Ranking US, 2026-10-08: shopping cart for website 320/mo, difficulty 12 (website shopping + // cart 320/32 is the same searches); add to cart button 660/7 is developers asking for markup; + // side cart 480/20 and cart drawer 290/15 are Shopify theme vocabulary; add shopping cart to + // website 40/26. + search: { query: 'shopping cart for website', alsoRanks: ['website shopping cart', 'add shopping cart to website', 'cart drawer', 'side cart'] }, + replaces: ['WooCommerce’s cart and mini cart', 'the Shopify Buy Button’s cart', 'Ecwid’s cart widget', 'Square Online’s cart', 'side-cart and cart-drawer plugins'], + goodFor: + 'A seller with a handful to a few dozen products who already sells in person with Square: candles, prints, coffee, merch, a maker’s stock. Shipping at one flat rate (free over an amount) or pickup at a market or the shop.', + notFor: + 'A catalogue of hundreds of products, products with several option sets (size and color), live carrier rates, subscriptions or digital downloads. Those are a store platform’s job, and Shopify is the honest answer. Not without the Superhero Shop Worker: the cart has no checkout of its own.', + asks: [ + { key: 'square_account', label: 'Your Square account, connected to your site’s shop', hint: 'We send you a link; you approve it on Square. Products, prices, photos and stock are edited in Square from then on.', required: true }, + { key: 'shipping', label: 'How you ship: one flat rate, and the order total that ships free', hint: 'Leave it out if buyers only pick up.' }, + { key: 'pickup_points', label: 'Where buyers can pick up their order, if they can', hint: 'For example: the Saturday market, stall 14, 8 am to noon.' }, + ], + props: [ + { name: 'shop', type: 'string', note: 'Required. The shop’s site key on the Worker (domino-works). Also keys the stored cart, so two shops never share one.' }, + { name: 'catalog', type: 'ShopCatalog', note: 'Required. The catalogue fetched at build (fetchCatalog). Names, images and display prices come from it; the cart sends none of them.' }, + { name: 'id', type: 'string', default: "'cart'", note: 'The element’s id; the drawer is -panel.' }, + { name: 'api', type: 'string', default: "'https://shop.superherotech.ai'", note: 'The Worker’s origin.' }, + { name: 'flatShippingCents', type: 'number | null', default: 'null', note: 'The shop’s flat rate, shown as an estimate. null: the shop does not ship.' }, + { name: 'freeShippingOverCents', type: 'number | null', default: 'null', note: 'Free shipping from this subtotal (the Worker’s rule: subtotal ≥ this).' }, + { name: 'pickupPoints', type: '{ id, name, details? }[]', default: '[]', note: 'The shop’s pickup points; `id` is what the checkout sends. With one point it is preselected.' }, + { name: 'title', type: 'string', default: '“Your cart”', note: 'The drawer’s heading and name.' }, + { name: 'label', type: 'string', default: '“Cart”', note: 'The button’s text; its name adds the count (“Cart, 2 items”).' }, + { name: 'hrefBase', type: 'string | null', default: "'/shop/'", note: 'A line’s name links to /. null: no links.' }, + { name: 'clear', type: 'boolean', default: 'false', note: 'Empty the cart on load. Put it on the thanks page, which never claims the payment went through.' }, + { name: 'openOnAdd', type: 'boolean', default: 'false', note: 'Open the drawer after an add, instead of only announcing the count.' }, + { name: 'class', type: 'string', note: 'Class on the root, for the host to theme it.' }, + ], + theming: [ + { name: '--crt-accent', fallback: '#5933d8', note: 'Checkout button and count badge (7.27:1 with --crt-on-accent).' }, + { name: '--crt-on-accent', fallback: '#fff', note: 'Text on the accent.' }, + { name: '--crt-bg', fallback: '#fff', note: 'The drawer (14.75:1 with its text).' }, + { name: '--crt-fg', fallback: '#1e283c', note: 'Text in the drawer.' }, + { name: '--crt-muted', fallback: '#5b6577', note: 'Unit prices and notes (5.88:1 on white).' }, + { name: '--crt-line', fallback: '#dfe3ea', note: 'Rules between lines, the quantity box.' }, + { name: '--crt-warn', fallback: '#9a3412', note: 'A line’s stock message and the message bar’s edge (7.31:1 on white).' }, + { name: '--crt-trigger-bg', fallback: 'transparent', note: 'The cart button.' }, + { name: '--crt-trigger-fg', fallback: 'currentColor', note: 'The cart button’s text and icon.' }, + { name: '--crt-trigger-border', fallback: 'currentColor', note: 'The cart button’s border.' }, + { name: '--crt-radius', fallback: '999px', note: 'The cart button’s corners.' }, + { name: '--crt-size', fallback: 'min(26rem, 92vw)', note: 'The drawer’s width.' }, + { name: '--crt-backdrop', fallback: 'rgb(15 20 35 / 0.55)', note: 'Behind the drawer.' }, + { name: '--crt-shadow', fallback: '0 0 3rem rgb(0 0 0 / 0.25)', note: 'The drawer’s shadow.' }, + { name: '--crt-focus', fallback: 'var(--crt-accent)', note: 'Focus ring.' }, + { name: '--crt-duration', fallback: '0.28s', note: 'Slide time; none under reduced motion.' }, + { name: '--crt-z', fallback: '110', note: 'A backstop: the open drawer is in the browser’s top layer.' }, + ], + a11y: [ + 'The cart button is a real + + + + +`, + usedOn: [{ site: 'superherotech.ai', where: '/elements/cart/ (demo)' }], + file: 'src/library/cart/Cart.astro', + added: '2026-10-08', + }, + { + id: 'product-list', + category: 'conversion', + name: 'Product list', + aka: ['product grid', 'shop page', 'WooCommerce shop page', 'Shopify Buy Button collection', 'Ecwid product browser', 'Square Online shop', 'product catalog', 'collection grid'], + summary: + 'The shop’s products as a grid of cards, built at deploy from the Superhero Shop catalogue: an image, the name, the price (“From $18.00” when variations differ) and one link to the product’s page per card. Sold out shows when every tracked variation is at 0, from stock passed at build or one optional stock check in the browser.', + pitch: 'Show everything you sell on one page, straight from your Square catalogue, updated every time you change a product.', + // SE Ranking US, 2026-10-08: product listing page 140/mo, difficulty 15; product grid 90/7; + // shop page 3,200/65 is mostly retailers' own shop pages, out of reach and off target. + search: { query: 'product listing page', alsoRanks: ['product grid'] }, + replaces: ['WooCommerce’s shop and category pages', 'the Shopify Buy Button’s collection embed', 'Ecwid’s product browser', 'Square Online’s shop page'], + goodFor: 'A shop page or a “From the shop” band on the home page, for a handful to a few dozen products in one Square category.', + notFor: 'Filtering, sorting or searching hundreds of products, or a price that must be live to the minute: prices are the ones the site was built with, and the cart never sends them.', + asks: [ + { key: 'website_category', label: 'Your products in Square, in a category named Website', hint: 'Only that category shows on the site. Each needs a price, a photo and a short description.', required: true }, + ], + props: [ + { name: 'products', type: 'ShopProduct[]', note: 'Required. The catalogue’s products, fetched at build (fetchCatalog).' }, + { name: 'currency', type: 'string | null', default: "'USD'", note: 'The catalogue’s currency; prices are in its minor unit.' }, + { name: 'hrefBase', type: 'string', default: "'/shop/'", note: 'Each card links to /.' }, + { name: 'href', type: '(product) => string', note: 'Or name each product’s page yourself; wins over hrefBase.' }, + { name: 'label', type: 'string', default: '“Products”', note: 'The list’s accessible name.' }, + { name: 'headingLevel', type: '2 | 3 | 4', default: '3', note: 'Level of each card’s name.' }, + { name: 'stock', type: 'Record', note: 'Stock known at build, for Sold out in the HTML. The Worker’s /stock answers only the shop’s own origins, so this is mostly for fixtures.' }, + { name: 'liveStock', type: 'boolean', default: 'false', note: 'One GET /stock from the browser on load, which marks sold-out cards. Needs `shop`; a failure changes nothing.' }, + { name: 'shop', type: 'string', note: 'The shop’s site key, for liveStock.' }, + { name: 'api', type: 'string', default: "'https://shop.superherotech.ai'", note: 'The Worker’s origin.' }, + { name: 'imageWidth / imageHeight', type: 'number', default: '600 / 600', note: 'The box each image is drawn in (object-fit: cover), reserved before it loads.' }, + { name: 'soldOutLabel', type: 'string', default: '“Sold out”', note: 'The badge’s words.' }, + { name: 'class', type: 'string', note: 'Class on the list.' }, + ], + theming: [ + { name: '--pl-min', fallback: '15rem', note: 'The narrowest card; the grid fits as many as it can.' }, + { name: '--pl-gap', fallback: '1.5rem', note: 'Between cards.' }, + { name: '--pl-bg', fallback: '#fff', note: 'Card.' }, + { name: '--pl-fg', fallback: '#1e283c', note: 'Card text (14.75:1).' }, + { name: '--pl-muted', fallback: '#5b6577', note: 'The price (5.88:1).' }, + { name: '--pl-border', fallback: '#dfe3ea', note: 'Card border.' }, + { name: '--pl-radius', fallback: '12px', note: 'Card corners.' }, + { name: '--pl-image-bg', fallback: '#eef0f4', note: 'Behind an image, and a product with none.' }, + { name: '--pl-aspect', fallback: '1 / 1', note: 'The image box’s shape.' }, + { name: '--pl-sold-bg / --pl-sold-fg', fallback: '#1e283c / #fff', note: 'The Sold out badge (14.75:1).' }, + { name: '--pl-focus', fallback: '#5933d8', note: 'Focus ring round the card.' }, + ], + a11y: [ + 'A
      named by `label`. Each card is one link, its name, stretched over the card; the name is a heading at `headingLevel`. The focus ring goes round the whole card.', + 'Images have alt="" because the card’s name says what it is; each has width, height, loading="lazy" and decoding="async", so nothing shifts.', + 'Sold out is a visible badge and “, sold out” in the link’s name, never color alone.', + 'Without JavaScript everything shows except a live sold-out mark. The only motion is a small hover lift, none under prefers-reduced-motion.', + ], + usage: `--- +import ProductList from '../components/ProductList.astro'; +import { fetchCatalog } from '../lib/fetchCatalog'; // src/library/product-list/fetchCatalog.ts +const catalog = await fetchCatalog('domino-works'); +--- +

      Shop

      +`, + usedOn: [{ site: 'superherotech.ai', where: '/elements/product-list/ (demo)' }], + file: 'src/library/product-list/ProductList.astro', + added: '2026-10-08', + }, + { + id: 'product-page', + category: 'conversion', + name: 'Product page', + aka: ['product detail page', 'single product page', 'WooCommerce product page', 'Shopify Buy Button product', 'Ecwid product page', 'Square Online product page', 'variation picker', 'product schema'], + summary: + 'One product built at deploy from the Superhero Shop catalogue: images, name, description as plain text, a radio picker when it has more than one variation (the price follows it), quantity, Add to cart through the cart element, a shipping or pickup note, and Product JSON-LD with one Offer per variation.', + pitch: 'Give each product its own page, with its photos, options and price straight from Square, and a button that adds it to the cart.', + // SE Ranking US, 2026-10-08: product detail page 390/mo, difficulty 19; product page 260/27; + // product page design 260/28; product page template 90/14. + search: { query: 'product detail page', alsoRanks: ['product page', 'product page design', 'product page template'] }, + replaces: ['WooCommerce’s single product page', 'the Shopify Buy Button’s product embed', 'Ecwid’s product page', 'Square Online’s item page', 'product schema plugins'], + goodFor: 'One page per product in a small shop, at /shop//, built for every product by getStaticPaths.', + notFor: 'Products with several option sets (size and color) or bundles: Square variations give one option set per product, and the Worker sells exactly those. Live prices: prices are the build’s.', + asks: [ + { key: 'product_details', label: 'Each product’s photos, description and options, entered in Square', hint: 'One option set per product (size, or color). We read them from Square; nothing is typed twice.', required: true }, + ], + props: [ + { name: 'product', type: 'ShopProduct', note: 'Required. One product from the catalogue fetched at build.' }, + { name: 'shop', type: 'string', note: 'Required. The shop’s site key: the cart this page adds to. Put in the layout.' }, + { name: 'currency', type: 'string | null', default: "'USD'", note: 'The catalogue’s currency.' }, + { name: 'url', type: 'string', default: 'Astro.site + path', note: 'This page’s absolute URL, for the JSON-LD.' }, + { name: 'brand', type: 'string', note: 'The seller’s name, for the JSON-LD.' }, + { name: 'jsonLd', type: "boolean | 'visible'", default: 'true', note: "Emit the Product JSON-LD. 'visible' shows it as text instead, for demos that must not publish it." }, + { name: 'headingLevel', type: '1 | 2 | 3', default: '1', note: 'Level of the name.' }, + { name: 'optionLabel', type: 'string', default: '“Choose one”', note: 'The picker’s legend (“Choose a size”).' }, + { name: 'imageAlt', type: 'string', default: 'the name', note: 'The main image’s alt.' }, + { name: 'imageWidth / imageHeight', type: 'number', default: '800 / 800', note: 'The image box.' }, + { name: 'stock', type: 'Record', note: 'Stock known at build: sold-out variations disabled, and availability in the JSON-LD.' }, + { name: 'liveStock', type: 'boolean', default: 'false', note: 'One GET /stock from the browser on load, which disables sold-out variations.' }, + { name: 'api', type: 'string', default: "'https://shop.superherotech.ai'", note: 'The Worker’s origin.' }, + { name: 'flatShippingCents / freeShippingOverCents / pickupPoints', type: 'as the cart', note: 'The note under the button is written from these (“Ships for $8.00, free on orders over $75.00 (estimated). Or pick it up at…”).' }, + { name: 'note', type: 'string', note: 'Your own note instead.' }, + { name: 'class', type: 'string', note: 'Class on the article.' }, + ], + theming: [ + { name: '--pp-accent', fallback: '#5933d8', note: 'Add to cart, the chosen option’s border, focus.' }, + { name: '--pp-on-accent', fallback: '#fff', note: 'Text on the button (7.27:1).' }, + { name: '--pp-fg', fallback: '#1e283c', note: 'Text.' }, + { name: '--pp-muted', fallback: '#5b6577', note: 'Notes, a sold-out option (5.88:1).' }, + { name: '--pp-border', fallback: '#8a94a6', note: 'Option and quantity borders (3:1 on white).' }, + { name: '--pp-radius', fallback: '12px', note: 'Image corners.' }, + { name: '--pp-image-bg', fallback: '#eef0f4', note: 'Behind images.' }, + { name: '--pp-aspect', fallback: '1 / 1', note: 'The image box’s shape.' }, + { name: '--pp-gap', fallback: 'clamp(1.5rem, 4vw, 3rem)', note: 'Between images and text.' }, + { name: '--pp-focus', fallback: 'var(--pp-accent)', note: 'Focus ring.' }, + ], + a11y: [ + 'The name is a heading at `headingLevel` (1 on a product’s own page). The main image’s alt is the name; further images say “, view 2”.', + 'Variations are real radios in a fieldset with a legend, each labelled with its name and price; a sold-out one is disabled and says “Sold out”. Quantity is a labelled number input, 1 to 20.', + 'Add to cart is a real submit button, hidden until a cart for the same shop has run; the cart announces its count, so this page has no live region of its own.', + 'The description is Square’s plain text: anything that looks like markup is shown as typed.', + 'Without JavaScript the page reads in full and a line says ordering online needs it. No motion.', + ], + usage: `--- +// src/pages/shop/[slug].astro +import Shop from '../../layouts/Shop.astro'; // holds +import ProductPage from '../../components/ProductPage.astro'; +import { fetchCatalog } from '../../lib/fetchCatalog'; +export async function getStaticPaths() { + const catalog = await fetchCatalog('domino-works'); + return catalog.products.map((product) => ({ params: { slug: product.slug }, props: { product, currency: catalog.currency } })); +} +const { product, currency } = Astro.props; +--- + + +`, + usedOn: [{ site: 'superherotech.ai', where: '/elements/product-page/ (demo)' }], + file: 'src/library/product-page/ProductPage.astro', + added: '2026-10-08', + }, ]; export const byId = (id: string) => catalog.find((e) => e.id === id); diff --git a/src/library/cart/Cart.astro b/src/library/cart/Cart.astro index 8bba5a4..1dbd0b5 100644 --- a/src/library/cart/Cart.astro +++ b/src/library/cart/Cart.astro @@ -56,6 +56,9 @@ * prefers-reduced-motion: no slide. Without JavaScript: the cart cannot work, so the trigger * and every add button stay hidden and a