Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions src/data/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3058,15 +3058,15 @@ 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.' },
{ 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: '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 <id>-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.' },
Expand Down Expand Up @@ -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'] },
Expand All @@ -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 <hrefBase><slug>/.' },
{ 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.' },
Expand Down Expand Up @@ -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.
Expand All @@ -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 <Cart shop="…"> 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." },
Expand Down Expand Up @@ -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 “<name>, 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.',
Expand Down
2 changes: 1 addition & 1 deletion src/library/cart/Cart.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 16 additions & 16 deletions src/library/product-page/ProductPage.astro
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
*
Expand All @@ -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 "<name>, 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`.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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';
Expand All @@ -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, '-')}`;

Expand All @@ -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.`);
}
Expand All @@ -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,
Expand All @@ -178,7 +178,7 @@ const ld = jsonLd
})),
}).replace(/</g, '\\u003c')
: null;
const live = liveStock && product.variations.some((v) => 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;
---

<article class:list={['pp', className]} data-pp={live ?? ''}>
Expand All @@ -190,7 +190,7 @@ const live = liveStock && product.variations.some((v) => v.tracked) ? JSON.strin
)}
{images.length > 1 && (
<ul class="pp__more" role="list">
{images.slice(1).map((src, i) => (
{images.slice(1).map((src: string, i: number) => (
<li><img class="pp__thumb" src={src} alt={`${product.name}, view ${i + 2}`} width={imageWidth} height={imageHeight} loading="lazy" decoding="async" /></li>
))}
</ul>
Expand All @@ -201,14 +201,14 @@ const live = liveStock && product.variations.some((v) => v.tracked) ? JSON.strin
<p class="pp__price" data-pp-price>{money(first.price_cents)}</p>
{paragraphs.length > 0 && (
<div class="pp__desc">
{paragraphs.map((p) => <p>{p}</p>)}
{paragraphs.map((p: string) => <p>{p}</p>)}
</div>
)}
<form class="pp__form" data-cart-form data-cart-shop={shop}>
{many ? (
<fieldset class="pp__options">
<legend>{optionLabel}</legend>
{product.variations.map((v) => {
{product.variations.map((v: Variation) => {
const out = available(v) === false;
return (
<label class="pp__option" data-pp-option={v.id}>
Expand Down
Loading