diff --git a/src/data/catalog.ts b/src/data/catalog.ts index 77bf894..fa9a9a4 100644 --- a/src/data/catalog.ts +++ b/src/data/catalog.ts @@ -3058,7 +3058,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra 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.', + 'A catalog 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.' }, @@ -3066,7 +3066,7 @@ import Link from '../components/LinkEffects.astro'; // the same file, as a wra ], 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: 'catalog', type: 'ShopCatalog', note: 'Required. The catalog 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.' }, @@ -3138,8 +3138,8 @@ const catalog = await fetchCatalog('domino-works'); 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.', + 'The shop’s products as a grid of cards, built at deploy from the Superhero Shop catalog: 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 catalog, 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'] }, @@ -3150,8 +3150,8 @@ const catalog = await fetchCatalog('domino-works'); { 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: 'products', type: 'ShopProduct[]', note: 'Required. The catalog’s products, fetched at build (fetchCatalog).' }, + { name: 'currency', type: 'string | null', default: "'USD'", note: 'The catalog’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.' }, @@ -3200,7 +3200,7 @@ const catalog = await fetchCatalog('domino-works'); 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.', + 'One product built at deploy from the Superhero Shop catalog: 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. @@ -3212,9 +3212,9 @@ const catalog = await fetchCatalog('domino-works'); { 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: 'product', type: 'ShopProduct', note: 'Required. One product from the catalog 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: 'currency', type: 'string | null', default: "'USD'", note: 'The catalog’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." }, @@ -3243,7 +3243,7 @@ const catalog = await fetchCatalog('domino-works'); ], 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.', + 'Variations are real radios in a fieldset with a legend, each labeled with its name and price; a sold-out one is disabled and says “Sold out”. Quantity is a labeled 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.', diff --git a/src/library/cart/Cart.astro b/src/library/cart/Cart.astro index 1dbd0b5..97497ec 100644 --- a/src/library/cart/Cart.astro +++ b/src/library/cart/Cart.astro @@ -253,7 +253,7 @@ function cartClient() { const minorDigits = (currency: string) => new Intl.NumberFormat('en-US', { style: 'currency', currency }).resolvedOptions().maximumFractionDigits ?? 2; const money = (cents: number, currency: string) => new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cents / 10 ** minorDigits(currency)); /** A new key for every checkout attempt, never one from an earlier attempt. */ - function newKey(c: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array } | undefined) { + function newKey(c: Crypto | undefined) { if (c && typeof c.randomUUID === 'function') return c.randomUUID(); const b = new Uint8Array(16); if (c && typeof c.getRandomValues === 'function') c.getRandomValues(b); diff --git a/src/library/product-page/ProductPage.astro b/src/library/product-page/ProductPage.astro index 38a2a89..bf8a18c 100644 --- a/src/library/product-page/ProductPage.astro +++ b/src/library/product-page/ProductPage.astro @@ -1,6 +1,6 @@ --- /** - * ProductPage — one product of the Superhero Shop, built at deploy from the catalogue + * ProductPage — one product of the Superhero Shop, built at deploy from the catalog * (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 @@ -17,7 +17,7 @@ * 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 + * a fieldset with a legend, `optionLabel`), each labeled 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. * @@ -26,11 +26,11 @@ * 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. + * Worker on a catalog 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 + * Quantity is a labeled 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`. @@ -58,9 +58,9 @@ interface PickupPoint { details?: string; } interface Props { - /** One product from the catalogue. Required. */ + /** One product from the catalog. Required. */ product: Product; - /** The catalogue's currency. Default USD. */ + /** The catalog's currency. Default USD. */ currency?: string | null; /** The shop's site key: the cart this page adds to. Required. */ shop: string; @@ -117,7 +117,7 @@ const { 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 (!product || !Array.isArray(product.variations) || product.variations.length === 0) throw new Error('ProductPage: `product` is required, one catalog 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'; @@ -129,11 +129,11 @@ 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 first = product.variations.find((v: Variation) => available(v) !== false) ?? product.variations[0]; +const allOut = product.variations.every((v: Variation) => available(v) === false); const images = product.images ?? []; -const paragraphs = (product.description ?? '').split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean); +const paragraphs = (product.description ?? '').split(/\n\s*\n/).map((p: string) => p.trim()).filter(Boolean); const H = `h${headingLevel}` as 'h1' | 'h2' | 'h3'; const uid = `pp-${product.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`; @@ -146,7 +146,7 @@ const fulfil = 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 names = pickupPoints.map((p: PickupPoint) => 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.`); } @@ -167,7 +167,7 @@ const ld = jsonLd productID: product.id, ...(brand ? { brand: { '@type': 'Brand', name: brand } } : {}), ...(pageUrl ? { url: pageUrl } : {}), - offers: product.variations.map((v) => ({ + offers: product.variations.map((v: Variation) => ({ '@type': 'Offer', sku: v.id, name: many ? `${product.name}, ${v.name}` : product.name, @@ -178,7 +178,7 @@ const ld = jsonLd })), }).replace(/ v.tracked) ? JSON.stringify({ api, shop, ids: product.variations.filter((v) => v.tracked).map((v) => v.id) }) : undefined; +const live = liveStock && product.variations.some((v: Variation) => v.tracked) ? JSON.stringify({ api, shop, ids: product.variations.filter((v: Variation) => v.tracked).map((v: Variation) => v.id) }) : undefined; ---
@@ -190,7 +190,7 @@ const live = liveStock && product.variations.some((v) => v.tracked) ? JSON.strin )} {images.length > 1 && (
    - {images.slice(1).map((src, i) => ( + {images.slice(1).map((src: string, i: number) => (
  • {`${product.name},
  • ))}
@@ -201,14 +201,14 @@ const live = liveStock && product.variations.some((v) => v.tracked) ? JSON.strin

{money(first.price_cents)}

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

{p}

)} + {paragraphs.map((p: string) =>

{p}

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