srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
authorsrdusr <[email protected]>2026-02-28 01:17:00 +0200
committersrdusr <[email protected]>2026-02-28 01:17:00 +0200
commit5af72f55bb7fb620af740c9da394e093d7f1280a (patch)
tree81fb6ae1830886a25b0f81912a1a12636493ba51 /web/src/screens
parentd7c1c7b35e9d0995eed41d437404d5255529cf17 (diff)
downloadtyperpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.tar.gz
typerpunk-5af72f55bb7fb620af740c9da394e093d7f1280a.zip
Sell merchandise, put the sprites on the track, and add the page margins
Merchandise. Shirts, mugs and deskmats, sold through the same hosted checkout. Physical goods differ from cosmetics in three ways the schema had to carry: they have a size to choose, they need an address, and payment unlocks nothing. What a paid order produces is an obligation to pack and post something. The address is collected by Stripe on its own page and arrives here on the webhook, so no postal detail is ever entered on this site. Postage is a separate line item rather than folded into the price. A size is checked against the sizes the item actually comes in, on the server as well as in the browser, so a request naming anything else is refused rather than quietly posted as a medium. /api/admin/orders lists what has been paid for and not yet sent, and the Contribute screen shows an administrator the same list with the address and a button to mark each one posted. Without that the list of what is owed lives only in the processor's dashboard. Shipping is limited to 21 countries, which is a list of places somebody is willing to post to, not a technical limit. The race view. The sprite sat still at the left of a 6px bar, which made the one thing a player owns and can see the least visible part of the race. It rides the track now, moving with progress, on a dashed line that reads as road ahead with the trail behind it. The field is 1040px wide rather than 680, the sprites are 30px rather than 18, and each row carries its own percentage as well as its speed. Advertising. Two 160x600 rails in the page margins, shown only above 1424px, which is the width at which they fit beside the widest content column without crowding it. Below that they do not exist. They follow the same rules as the banner: never while typing, never for a supporter. --border-color was used in five places and defined in none. An undefined custom property invalidates the whole declaration at computed-value time, so every one of those borders fell back to currentColor: bundle cards, merch cards, the size buttons and the settings checkbox all had borders that were either invisible or faint text-coloured lines. It is defined now, derived from each theme's neutral so it tracks the palette. .settings-hint caps itself at 340px, which is right under a heading and wrong for a line introducing a full-width grid. Verified against a real database: the catalogue endpoint, a shirt refused without a size, a shirt refused with a size it does not come in, a valid size and a no-variant mug both reaching checkout, 404 on an unknown item, 401 unauthenticated, and 401 on the admin orders list without the role. 24 Rust tests, 3 browser tests, and all 21 screen and viewport combinations clean.
Diffstat (limited to 'web/src/screens')
-rw-r--r--web/src/screens/contributeScreen.js44
-rw-r--r--web/src/screens/multiplayerScreen.js17
-rw-r--r--web/src/screens/storeScreen.js81
3 files changed, 136 insertions, 6 deletions
diff --git a/web/src/screens/contributeScreen.js b/web/src/screens/contributeScreen.js
index b84ee40..b5eb262 100644
--- a/web/src/screens/contributeScreen.js
+++ b/web/src/screens/contributeScreen.js
@@ -28,6 +28,7 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
let isAdmin = false;
let roleHolders = [];
let userSearch = [];
+ let orders = [];
let cleanupInner = null;
function statusLabel(s) {
@@ -47,6 +48,14 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
// ordinary case for almost everyone.
try { roleHolders = await api.get('/api/admin/users'); isAdmin = true; }
catch { roleHolders = []; isAdmin = false; }
+ // Paid orders waiting to be posted. Only an administrator can see
+ // them, and they carry a postal address, so this is the one screen in
+ // the app that shows one.
+ if (isAdmin) {
+ try { orders = await api.get('/api/admin/orders'); } catch { orders = []; }
+ } else {
+ orders = [];
+ }
}
function submissionRow(s, moderating) {
@@ -116,6 +125,30 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
</div>
` : ''}
+ ${isAdmin ? `
+ <h3>Orders to post${orders.length ? ` (${orders.length})` : ''}</h3>
+ <div class="settings-hint">Paid and not yet posted. Marking one sent does not notify the buyer.</div>
+ ${orders.length ? `
+ <div class="submission-list">
+ ${orders.map(o => `
+ <div class="submission-row order-row" data-order="${escapeHtml(o.id)}">
+ <div class="submission-head">
+ <span class="submission-category">${escapeHtml(o.item)}${o.variant ? ` &middot; ${escapeHtml(o.variant)}` : ''}</span>
+ <span class="submission-by">for ${escapeHtml(o.username)}</span>
+ <span class="submission-status">$${(o.amount_cents / 100).toFixed(2)}</span>
+ </div>
+ <div class="order-address">${o.shipping_address
+ ? escapeHtml(o.shipping_address).replace(/\n/g, '<br>')
+ : 'No address recorded. Check the payment processor before posting.'}</div>
+ <div class="submission-actions">
+ <button class="menu-button small primary" data-action="mark-shipped" data-order="${escapeHtml(o.id)}">Mark posted</button>
+ </div>
+ </div>
+ `).join('')}
+ </div>
+ ` : '<div class="stats-empty">Nothing waiting to go out.</div>'}
+ ` : ''}
+
${isModerator ? `
<h3>Review queue${queue.length ? ` (${queue.length})` : ''}</h3>
${queue.length
@@ -185,7 +218,16 @@ export function renderContributeScreen(root, { onBack, onShowStats, onShowPlaceh
});
});
- root.querySelectorAll('.submission-actions button').forEach(btn => {
+ root.querySelectorAll('[data-action="mark-shipped"]').forEach(btn => {
+ btn.addEventListener('click', async () => {
+ try { await api.post(`/api/admin/orders/${encodeURIComponent(btn.dataset.order)}/shipped`); }
+ catch { /* reloading below shows what actually happened */ }
+ await load();
+ render();
+ });
+ });
+
+ root.querySelectorAll('.submission-actions button:not([data-action="mark-shipped"])').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.submission-row');
const decision = btn.dataset.action === 'approve' ? 'approve' : 'reject';
diff --git a/web/src/screens/multiplayerScreen.js b/web/src/screens/multiplayerScreen.js
index 878f659..053a461 100644
--- a/web/src/screens/multiplayerScreen.js
+++ b/web/src/screens/multiplayerScreen.js
@@ -50,6 +50,12 @@ function applyInlineStyles(container) {
container.querySelectorAll('[data-percent]').forEach(el => {
el.style.width = `${el.dataset.percent}%`;
});
+ // How far along the track a sprite has travelled, as a fraction. The
+ // stylesheet turns it into a position; a percentage on its own would put
+ // the sprite's left edge at the finish rather than the sprite itself.
+ container.querySelectorAll('[data-progress]').forEach(el => {
+ el.style.setProperty('--p', String(Math.max(0, Math.min(1, Number(el.dataset.progress) / 100)) || 0));
+ });
}
// No account required - a quick race with friends shouldn't need signing
@@ -281,11 +287,18 @@ export function renderMultiplayerScreen(root, { onBack, onFinish, onShowStats, o
opponents.innerHTML = players.map(p => {
const me = p.id === myId;
const prog = progressById[p.id] || {};
+ const percent = Math.round(prog.percent || 0);
+ // The sprite rides the track rather than sitting beside it.
+ // A static icon next to a thin bar made the one thing a
+ // player owns and can see the least visible part of the race.
return `
<div class="mp-racer-row${me ? ' me' : ''}" data-racer-color="${colors[p.id]}">
- <span class="mp-racer-sprite">${RACER_SPRITES[sprites[p.id]]}</span>
<span class="mp-racer-name">${escapeHtml(p.name)}${me ? ' (you)' : ''}</span>
- <div class="mp-racer-bar"><div class="mp-racer-bar-fill" data-percent="${prog.percent || 0}"></div></div>
+ <div class="mp-racer-track">
+ <div class="mp-racer-trail" data-percent="${percent}"></div>
+ <span class="mp-racer-sprite" data-progress="${percent}">${RACER_SPRITES[sprites[p.id]]}</span>
+ </div>
+ <span class="mp-racer-percent">${percent}%</span>
<span class="mp-racer-wpm">${Math.round(prog.wpm || 0)}</span>
</div>`;
}).join('');
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index 5299582..26cbade 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -15,6 +15,13 @@ function formatPrice(cents) {
// far taller than the others beside it.
const MAX_PREVIEWS = 10;
+function merchKindLabel(kind) {
+ if (kind === 'shirt') return 'Shirts';
+ if (kind === 'mug') return 'Mugs';
+ if (kind === 'deskmat') return 'Deskmats';
+ return 'Other';
+}
+
function categoryLabel(category) {
if (category === 'caret') return 'Caret Colours';
if (category === 'sprite') return 'Race Sprites';
@@ -28,6 +35,9 @@ function categoryLabel(category) {
export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) {
let catalog = [];
let bundles = [];
+ let merch = [];
+ // 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 };
let status = 'loading';
let message = '';
@@ -106,6 +116,35 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
</div>`;
}
+ function merchMarkup(item) {
+ const signedOut = status === 'signed-out';
+ const needsVariant = (item.variants || []).length > 0;
+ const picked = chosenVariant[item.id];
+ return `
+ <div class="merch-card">
+ <div class="merch-head">
+ <div class="merch-name">${escapeHtml(item.name)}</div>
+ <div class="merch-price">${formatPrice(item.price_cents)}</div>
+ </div>
+ ${item.description ? `<div class="merch-description">${escapeHtml(item.description)}</div>` : ''}
+ ${needsVariant ? `
+ <div class="merch-variants">
+ ${item.variants.map(v => `
+ <button class="merch-variant${picked === v ? ' active' : ''}" data-action="pick-variant"
+ data-id="${escapeHtml(item.id)}" data-variant="${escapeHtml(v)}">${escapeHtml(v)}</button>
+ `).join('')}
+ </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>
+ </div>
+ <div class="merch-error" data-error-for="${escapeHtml(item.id)}"></div>
+ </div>`;
+ }
+
function supporterMarkup() {
if (status === 'signed-out') return '';
if (mine.is_supporter) {
@@ -139,6 +178,18 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
? `<h3>Bundles</h3><div class="bundle-grid">${bundles.map(bundleMarkup).join('')}</div>`
: '';
+ const merchKinds = ['shirt', 'mug', 'deskmat'];
+ const merchSections = merch.length
+ ? `<h3>Merch</h3>
+ <div class="settings-hint merch-note">Posted to 21 countries. Your address is entered on the payment page and never on this site.</div>`
+ + merchKinds.map(kind => {
+ const items = merch.filter(m => m.kind === kind);
+ if (!items.length) return '';
+ return `<h4 class="merch-kind">${merchKindLabel(kind)}</h4>
+ <div class="merch-grid">${items.map(merchMarkup).join('')}</div>`;
+ }).join('')
+ : '';
+
const categories = ['caret', 'flair', 'sprite'];
const itemSections = categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
@@ -146,16 +197,16 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
}).join('');
- return banner + supporterMarkup() + bundleSection + itemSections
+ return banner + supporterMarkup() + bundleSection + itemSections + merchSections
+ `<div class="custom-error store-error"></div>`;
}
/// Starts a checkout and follows the redirect the server returns. A server
/// with no processor keys answers 501, which is reported rather than
/// leaving the button looking broken.
- async function startCheckout(path, errorEl) {
+ async function startCheckout(path, errorEl, body) {
try {
- const data = await api.post(path);
+ const data = await api.post(path, body);
if (data && data.url) window.location.assign(data.url);
} catch (err) {
if (!errorEl) return;
@@ -196,6 +247,29 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
root.querySelector('[data-action="buy-supporter"]')
?.addEventListener('click', () => startCheckout('/api/billing/supporter', errorEl));
+ root.querySelectorAll('[data-action="pick-variant"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ chosenVariant[btn.dataset.id] = btn.dataset.variant;
+ rerender();
+ });
+ });
+ root.querySelectorAll('[data-action="buy-merch"]').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const id = btn.dataset.id;
+ const item = merch.find(m => m.id === id);
+ const cardError = root.querySelector(`[data-error-for="${CSS.escape(id)}"]`);
+ // Checked here as well as on the server, so choosing a size is
+ // a click rather than a failed checkout.
+ if (item && (item.variants || []).length && !chosenVariant[id]) {
+ if (cardError) cardError.textContent = 'Choose a size first.';
+ return;
+ }
+ if (cardError) cardError.textContent = '';
+ startCheckout(`/api/billing/merch/${encodeURIComponent(id)}`, cardError || errorEl,
+ { variant: chosenVariant[id] || null });
+ });
+ });
+
root.querySelectorAll('[data-action="equip"]').forEach(btn => {
btn.addEventListener('click', async () => {
await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {});
@@ -233,6 +307,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
// 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() {