diff options
| author | srdusr <[email protected]> | 2026-03-13 23:44:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2026-03-13 23:44:00 +0200 |
| commit | e37b76136e49ac90bef841e318bceeef76a07129 (patch) | |
| tree | 2b99369bceae6332965c91f8656e7e457431bdf0 | |
| parent | 4ffaeda5cf0da756ea05b6d13913ce3d775990d6 (diff) | |
| download | typerpunk-e37b76136e49ac90bef841e318bceeef76a07129.tar.gz typerpunk-e37b76136e49ac90bef841e318bceeef76a07129.zip | |
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.
| -rw-r--r-- | scripts/export_store.js | 53 | ||||
| -rw-r--r-- | web/src/data/store.json | 331 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 75 | ||||
| -rw-r--r-- | web/src/styles.css | 18 |
4 files changed, 461 insertions, 16 deletions
diff --git a/scripts/export_store.js b/scripts/export_store.js new file mode 100644 index 0000000..d69ac29 --- /dev/null +++ b/scripts/export_store.js @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Writes the store catalogue to web/src/data/store.json. +// +// The store reads its catalogue from the server. With no server the page +// rendered its heading over an empty shop, which looks like a fault rather +// than a feature that needs signing in. This file is the same fallback the +// text packs already use: bundled with the client, served from the database +// and merged over the top once a server exists. +// +// Run it after changing prices or adding items: +// node scripts/export_store.js +// +// It shells out to psql so it needs no node dependencies, matching the rest +// of the scripts here. +const { execFileSync } = require('node:child_process'); +const { writeFileSync } = require('node:fs'); +const { join } = require('node:path'); + +const URL = process.env.DATABASE_URL + || 'postgresql://typerpunk:typerpunk@localhost/typerpunk'; + +function query(sql) { + const out = execFileSync('psql', [URL, '-At', '-c', sql], { encoding: 'utf8' }); + return JSON.parse(out.trim() || '[]'); +} + +const cosmetics = query(` + SELECT COALESCE(json_agg(row_to_json(c) ORDER BY c.category, c.price_cents), '[]'::json) + FROM (SELECT id, name, category, price_cents, value FROM cosmetics) c`); + +const bundles = query(` + SELECT COALESCE(json_agg(b ORDER BY b.sort_order), '[]'::json) FROM ( + SELECT b.id, b.name, b.description, b.price_cents, b.sort_order, + COALESCE(SUM(c.price_cents), 0)::int AS full_price_cents, + COALESCE(ARRAY_AGG(c.id ORDER BY c.id) + FILTER (WHERE c.id IS NOT NULL), '{}') AS items + FROM bundles b + LEFT JOIN bundle_items bi ON bi.bundle_id = b.id + LEFT JOIN cosmetics c ON c.id = bi.cosmetic_id + GROUP BY b.id, b.name, b.description, b.price_cents, b.sort_order + ) b`); + +const merch = query(` + SELECT COALESCE(json_agg(m ORDER BY m.sort_order), '[]'::json) FROM ( + SELECT id, name, description, price_cents, shipping_cents, kind, variants, sort_order + FROM merch WHERE available + ) m`); + +for (const row of [...bundles, ...merch]) delete row.sort_order; + +const out = join(__dirname, '..', 'web', 'src', 'data', 'store.json'); +writeFileSync(out, JSON.stringify({ cosmetics, bundles, merch }, null, 2) + '\n'); +console.log(`Wrote ${cosmetics.length} cosmetics, ${bundles.length} bundles and ${merch.length} merch items to ${out}`); diff --git a/web/src/data/store.json b/web/src/data/store.json new file mode 100644 index 0000000..0f21770 --- /dev/null +++ b/web/src/data/store.json @@ -0,0 +1,331 @@ +{ + "cosmetics": [ + { + "id": "caret-crimson", + "name": "Crimson Caret", + "category": "caret", + "price_cents": 149, + "value": "#ff3b5c" + }, + { + "id": "caret-amber", + "name": "Amber Caret", + "category": "caret", + "price_cents": 149, + "value": "#ffb703" + }, + { + "id": "caret-cyan", + "name": "Cyan Caret", + "category": "caret", + "price_cents": 149, + "value": "#00e5ff" + }, + { + "id": "caret-magenta", + "name": "Magenta Caret", + "category": "caret", + "price_cents": 149, + "value": "#ff2fb0" + }, + { + "id": "caret-ice", + "name": "Ice Caret", + "category": "caret", + "price_cents": 179, + "value": "#7dd3fc" + }, + { + "id": "caret-violet", + "name": "Violet Caret", + "category": "caret", + "price_cents": 179, + "value": "#a855f7" + }, + { + "id": "caret-lime", + "name": "Lime Caret", + "category": "caret", + "price_cents": 179, + "value": "#a3e635" + }, + { + "id": "caret-ember", + "name": "Ember Caret", + "category": "caret", + "price_cents": 199, + "value": "#fb923c" + }, + { + "id": "caret-bone", + "name": "Bone Caret", + "category": "caret", + "price_cents": 199, + "value": "#e7e5e4" + }, + { + "id": "caret-signal", + "name": "Signal Caret", + "category": "caret", + "price_cents": 349, + "value": "#22d3ee" + }, + { + "id": "caret-void", + "name": "Void Caret", + "category": "caret", + "price_cents": 349, + "value": "#6b21a8" + }, + { + "id": "flair-bolt", + "name": "Bolt", + "category": "flair", + "price_cents": 129, + "value": "bolt" + }, + { + "id": "flair-star", + "name": "Star", + "category": "flair", + "price_cents": 129, + "value": "star" + }, + { + "id": "flair-shard", + "name": "Shard", + "category": "flair", + "price_cents": 129, + "value": "shard" + }, + { + "id": "flair-eye", + "name": "Eye", + "category": "flair", + "price_cents": 179, + "value": "eye" + }, + { + "id": "flair-circuit", + "name": "Circuit", + "category": "flair", + "price_cents": 179, + "value": "circuit" + }, + { + "id": "flair-skull", + "name": "Skull", + "category": "flair", + "price_cents": 199, + "value": "skull" + }, + { + "id": "flair-moth", + "name": "Moth", + "category": "flair", + "price_cents": 249, + "value": "moth" + }, + { + "id": "flair-crown", + "name": "Crown", + "category": "flair", + "price_cents": 249, + "value": "crown" + }, + { + "id": "flair-reactor", + "name": "Reactor", + "category": "flair", + "price_cents": 299, + "value": "reactor" + }, + { + "id": "sprite-signal", + "name": "Signal", + "category": "sprite", + "price_cents": 249, + "value": "signal" + }, + { + "id": "sprite-dart", + "name": "Dart", + "category": "sprite", + "price_cents": 249, + "value": "dart" + }, + { + "id": "sprite-blade", + "name": "Blade", + "category": "sprite", + "price_cents": 299, + "value": "blade" + }, + { + "id": "sprite-helm", + "name": "Helm", + "category": "sprite", + "price_cents": 299, + "value": "helm" + }, + { + "id": "sprite-core", + "name": "Core", + "category": "sprite", + "price_cents": 349, + "value": "core" + }, + { + "id": "sprite-rocket", + "name": "Rocket", + "category": "sprite", + "price_cents": 399, + "value": "rocket" + } + ], + "bundles": [ + { + "id": "bundle-starter", + "name": "Starter Kit", + "description": "A caret, a flair and a sprite to make a profile your own.", + "price_cents": 399, + "full_price_cents": 527, + "items": [ + "caret-cyan", + "flair-bolt", + "sprite-dart" + ] + }, + { + "id": "bundle-neon", + "name": "Neon Set", + "description": "The brightest carets in the store, together.", + "price_cents": 549, + "full_price_cents": 826, + "items": [ + "caret-cyan", + "caret-lime", + "caret-magenta", + "caret-signal" + ] + }, + { + "id": "bundle-racer", + "name": "Racer Set", + "description": "Every race sprite.", + "price_cents": 1299, + "full_price_cents": 1844, + "items": [ + "sprite-blade", + "sprite-core", + "sprite-dart", + "sprite-helm", + "sprite-rocket", + "sprite-signal" + ] + }, + { + "id": "bundle-everything", + "name": "The Lot", + "description": "Every cosmetic currently in the store.", + "price_cents": 3499, + "full_price_cents": 5814, + "items": [ + "caret-amber", + "caret-bone", + "caret-crimson", + "caret-cyan", + "caret-ember", + "caret-ice", + "caret-lime", + "caret-magenta", + "caret-signal", + "caret-violet", + "caret-void", + "flair-bolt", + "flair-circuit", + "flair-crown", + "flair-eye", + "flair-moth", + "flair-reactor", + "flair-shard", + "flair-skull", + "flair-star", + "sprite-blade", + "sprite-core", + "sprite-dart", + "sprite-helm", + "sprite-rocket", + "sprite-signal" + ] + } + ], + "merch": [ + { + "id": "shirt-mark", + "name": "Mark T-Shirt", + "description": "The TyperPunk mark, printed small on the left chest.", + "price_cents": 2600, + "shipping_cents": 600, + "kind": "shirt", + "variants": [ + "S", + "M", + "L", + "XL", + "2XL" + ] + }, + { + "id": "shirt-layout", + "name": "Home Row T-Shirt", + "description": "ASDF JKL; across the chest, set in the same face the app types in.", + "price_cents": 2800, + "shipping_cents": 600, + "kind": "shirt", + "variants": [ + "S", + "M", + "L", + "XL", + "2XL" + ] + }, + { + "id": "mug-prompt", + "name": "Prompt Mug", + "description": "The mark on one side, a blinking block cursor on the other. 11oz.", + "price_cents": 1600, + "shipping_cents": 700, + "kind": "mug", + "variants": [] + }, + { + "id": "mug-wpm", + "name": "WPM Mug", + "description": "Reads \"measured in words per minute, drunk in cups per hour\". 11oz.", + "price_cents": 1600, + "shipping_cents": 700, + "kind": "mug", + "variants": [] + }, + { + "id": "deskmat-track", + "name": "Race Deskmat", + "description": "The race track across 900x400mm, stitched edge, rubber base.", + "price_cents": 3800, + "shipping_cents": 900, + "kind": "deskmat", + "variants": [] + }, + { + "id": "deskmat-mark", + "name": "Mark Deskmat", + "description": "The mark bottom-right on plain black. 900x400mm, stitched edge.", + "price_cents": 3600, + "shipping_cents": 900, + "kind": "deskmat", + "variants": [] + } + ] +} 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 `<span class="store-soon" data-tooltip="The shop opens when accounts do">Soon</span>`; + } + const signedOut = status === 'signed-out'; + return `<button class="menu-button small${signedOut ? ' quiet' : extraClass || ''}" + data-action="${signedOut ? 'go-account' : action}" data-id="${escapeHtml(id)}" + ${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>${label}</button>`; + } + function isOwned(id) { return status !== 'signed-out' && mine.owned.includes(id); } @@ -72,7 +88,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder ${!owned ? `<div class="leaderboard-acc store-price">${formatPrice(item.price_cents)}</div>` : ''} ${owned ? `<button class="menu-button small${equipped ? ' active' : ' quiet'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip, back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>` - : `<button class="menu-button small${signedOut ? ' quiet' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`} + : buyControl('buy', item.id, 'Buy')} </div> `; } @@ -110,7 +126,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder </div> ${complete ? `<span class="bundle-owned">You own all of these</span>` - : `<button class="menu-button small${signedOut ? ' quiet' : ' primary'}" data-action="${signedOut ? 'go-account' : 'buy-bundle'}" data-id="${escapeHtml(b.id)}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}</button>`} + : buyControl('buy-bundle', b.id, `Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}`, ' primary')} </div> ${ownedCount && !complete ? `<div class="bundle-note">You already own ${ownedCount} of these ${items.length}. The price does not change.</div>` : ''} </div>`; @@ -136,17 +152,14 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder </div>` : ''} <div class="merch-foot"> <span class="merch-shipping">${item.shipping_cents ? `plus ${formatPrice(item.shipping_cents)} postage` : 'postage included'}</span> - <button class="menu-button small${signedOut ? ' quiet' : ' primary'}" - data-action="${signedOut ? 'go-account' : 'buy-merch'}" - data-id="${escapeHtml(item.id)}" - ${signedOut ? ' data-tooltip="Sign in to order"' : ''}>Order</button> + ${buyControl('buy-merch', item.id, 'Order', ' primary')} </div> <div class="merch-error" data-error-for="${escapeHtml(item.id)}"></div> </div>`; } function supporterMarkup() { - if (status === 'signed-out') return ''; + if (offline || status === 'signed-out') return ''; if (mine.is_supporter) { return `<div class="store-supporter is-supporter"> <div class="store-supporter-text"> @@ -169,10 +182,12 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`; const signedOut = status === 'signed-out'; - const banner = signedOut - ? `<div class="store-signin-note">Sign in to buy and equip these. - <button class="menu-button small" data-action="go-account">Sign In</button></div>` - : ''; + const banner = offline + ? `<div class="store-signin-note store-offline-note">The shop is not open yet. Everything below is what will be in it.</div>` + : signedOut + ? `<div class="store-signin-note">Sign in to buy and equip these. + <button class="menu-button small" data-action="go-account">Sign In</button></div>` + : ''; const bundleSection = bundles.length ? `<h3>Bundles</h3><div class="bundle-grid">${bundles.map(bundleMarkup).join('')}</div>` @@ -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'; diff --git a/web/src/styles.css b/web/src/styles.css index 375f257..19c391a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -3133,6 +3133,24 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { white-space: normal; } +/* Stands in for a buy control while there is no server to buy through. A + label, not a button: there is nothing for a press to do. */ +.store-soon { + padding: 0.35rem 0.9rem; + border: var(--border-width) dashed var(--border-color); + border-radius: var(--radius); + color: var(--sub-color); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; + white-space: nowrap; +} + +.store-offline-note { + justify-content: center; + text-align: center; +} + /* Physical goods. A card, like a bundle, because each one has a description, possibly a size to pick, and a postage line. */ .merch-note { |