srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/src/data/store.json331
-rw-r--r--web/src/screens/storeScreen.js75
-rw-r--r--web/src/styles.css18
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 {