diff options
Diffstat (limited to 'web/src/screens')
| -rw-r--r-- | web/src/screens/contributeScreen.js | 44 | ||||
| -rw-r--r-- | web/src/screens/multiplayerScreen.js | 17 | ||||
| -rw-r--r-- | web/src/screens/storeScreen.js | 81 |
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 ? ` · ${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() { |