From e37b76136e49ac90bef841e318bceeef76a07129 Mon Sep 17 00:00:00 2001 From: srdusr <99972264+srdusr@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:44:00 +0200 Subject: Ship the store catalogue with the client The store read its catalogue from the server and rendered nothing without one: a heading, a sign-in prompt and an empty page, which reads as a fault rather than as a shop that needs an account. It now falls back to a copy that ships with the client, the same arrangement the text packs already use: bundled here, served from the database, and the served copy wins. With no server the store shows all 26 cosmetics, 4 bundles and 6 merchandise items at their real prices. Nothing pretends to work. The buy controls become a "Soon" label rather than a button, because offline there is nothing to buy with and nowhere to sign in to: sending someone to the account screen would be sending them to another screen that needs the server they have not got. A line at the top says the shop is not open yet. scripts/export_store.js regenerates the file. Run it after changing prices or adding items. It shells out to psql so it needs no dependencies, matching the other scripts here. Verified both ways. With no backend: 26 items, 4 bundles, 6 merch, 36 Soon labels, no live buy controls, no page errors. With the backend running: the same catalogue, 37 live buy controls, no Soon labels, no offline notice. 3 browser tests pass. --- web/src/screens/storeScreen.js | 75 +++++++++++++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 16 deletions(-) (limited to 'web/src/screens') diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js index 26cbade..9d66952 100644 --- a/web/src/screens/storeScreen.js +++ b/web/src/screens/storeScreen.js @@ -36,6 +36,9 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder let catalog = []; let bundles = []; let merch = []; + // True when the catalogue came from the bundled copy, which means there + // is no server to buy through. + let offline = false; // Size picked per item, so a shirt cannot be bought without one. const chosenVariant = {}; let mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false }; @@ -43,6 +46,19 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder let message = ''; let stopped = false; + /// The buy control for a card. Offline it is an inert label: sending + /// someone to the account screen would be sending them to another screen + /// that needs the server they have not got. + function buyControl(action, id, label, extraClass) { + if (offline) { + return `Soon`; + } + const signedOut = status === 'signed-out'; + return ``; + } + function isOwned(id) { return status !== 'signed-out' && mine.owned.includes(id); } @@ -72,7 +88,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ${!owned ? `
${formatPrice(item.price_cents)}
` : ''} ${owned ? `` - : ``} + : buyControl('buy', item.id, 'Buy')} `; } @@ -110,7 +126,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ${complete ? `You own all of these` - : ``} + : buyControl('buy-bundle', b.id, `Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}`, ' primary')} ${ownedCount && !complete ? `
You already own ${ownedCount} of these ${items.length}. The price does not change.
` : ''} `; @@ -136,17 +152,14 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ` : ''}
${item.shipping_cents ? `plus ${formatPrice(item.shipping_cents)} postage` : 'postage included'} - + ${buyControl('buy-merch', item.id, 'Order', ' primary')}
`; } function supporterMarkup() { - if (status === 'signed-out') return ''; + if (offline || status === 'signed-out') return ''; if (mine.is_supporter) { return `
@@ -169,10 +182,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder if (status === 'error') return `
${escapeHtml(message)}
`; const signedOut = status === 'signed-out'; - const banner = signedOut - ? `` - : ''; + const banner = offline + ? `` + : signedOut + ? `` + : ''; const bundleSection = bundles.length ? `

Bundles

${bundles.map(bundleMarkup).join('')}
` @@ -296,21 +311,49 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder cleanup = render(); } + /// The catalogue that ships with the client. Used when the server cannot + /// be reached, so the store shows what is in it rather than a heading over + /// an empty page, which reads as a fault rather than as a shop. + /// + /// Regenerate with `node scripts/export_store.js` after changing prices or + /// adding items. The same arrangement as the text packs: bundled here, + /// served from the database, and the served copy wins. + async function loadBundledCatalog() { + const res = await fetch(new URL('../data/store.json', import.meta.url), { cache: 'no-cache' }); + if (!res.ok) throw new Error(`store.json ${res.status}`); + return res.json(); + } + async function loadCatalog() { try { catalog = await api.get('/api/cosmetics'); + // Neither of these is worth failing the page over. + try { bundles = await api.get('/api/cosmetics/bundles'); } catch { bundles = []; } + try { merch = await api.get('/api/merch'); } catch { merch = []; } + return; + } catch { + // Fall through to what shipped with the client. + } + + try { + const bundled = await loadBundledCatalog(); + catalog = bundled.cosmetics || []; + bundles = bundled.bundles || []; + merch = bundled.merch || []; + offline = true; } catch { status = 'error'; message = 'Could not load the store. Try again.'; - return; } - // A store with no bundles is still a store, so this failing is not an - // error worth replacing the page with. - try { bundles = await api.get('/api/cosmetics/bundles'); } catch { bundles = []; } - try { merch = await api.get('/api/merch'); } catch { merch = []; } } async function loadMine() { + if (offline) { + // Nothing is owned or equippable without a server, and the buttons + // say so rather than failing when pressed. + status = 'signed-out'; + return; + } if (!getUser()) { // The catalogue still renders; only ownership needs an account. status = 'signed-out'; -- cgit v1.2.3