diff options
Diffstat (limited to 'web')
| -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 |
3 files changed, 408 insertions, 16 deletions
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 { |