diff options
Diffstat (limited to 'web/src')
| -rw-r--r-- | web/src/adSlot.js | 30 | ||||
| -rw-r--r-- | web/src/app.js | 4 | ||||
| -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 | ||||
| -rw-r--r-- | web/src/styles.css | 243 |
6 files changed, 394 insertions, 25 deletions
diff --git a/web/src/adSlot.js b/web/src/adSlot.js index 6dfb5b5..b87ae4f 100644 --- a/web/src/adSlot.js +++ b/web/src/adSlot.js @@ -31,6 +31,8 @@ import { getUser, onAuthChange } from './auth.js'; const SIZES = { // Roughly a leaderboard unit, and a mobile banner below the breakpoint. banner: { w: 728, h: 90, mobileW: 320, mobileH: 100 }, + // A wide skyscraper, the usual unit for a page margin. + skyscraper: { w: 160, h: 600, mobileW: 160, mobileH: 600 }, }; export function shouldShowAds() { @@ -65,6 +67,34 @@ export function renderAdSlot(root, { size = 'banner', label = 'Advertisement' } } +// The two page margins. They appear only where there is genuinely room for +// them beside the content, which the stylesheet decides, and they are the +// first thing dropped on a narrow window. +export function mountSideAdRails() { + const rails = ['left', 'right'].map(side => { + const rail = document.createElement('div'); + rail.className = `ad-rail ad-rail-${side}`; + document.body.appendChild(rail); + return { rail, cleanup: renderAdSlot(rail, { size: 'skyscraper' }) }; + }); + + let screenName = 'menu'; + function paint() { + const show = shouldShowAds() && !HIDDEN_ON.has(screenName); + for (const { rail } of rails) rail.hidden = !show; + } + paint(); + const unsubscribe = onAuthChange(paint); + + return { + setScreen(name) { screenName = name; paint(); }, + cleanup() { + unsubscribe(); + for (const { rail, cleanup } of rails) { cleanup(); rail.remove(); } + }, + }; +} + // Screens the banner stays off. Typing and passive reading are both somebody // working through a text, and the race lobby leads straight into typing. const HIDDEN_ON = new Set(['typing', 'passive', 'multiplayer']); diff --git a/web/src/app.js b/web/src/app.js index 4ef6c2e..b306c66 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -18,7 +18,7 @@ import { saveDocument, setPosition, getDocument, listDocuments, removeDocument } import { getSettings } from './settings.js'; import { generateWordStream, generateWeakKeyStream, wordCountForDuration } from './wordGenerator.js'; import { getWeakChars } from './keyStats.js'; -import { mountTopAdBanner } from './adSlot.js'; +import { mountTopAdBanner, mountSideAdRails } from './adSlot.js'; const FALLBACK_TEXT = { category: 'general', content: 'The quick brown fox jumps over the lazy dog.', attribution: 'Traditional pangram' }; @@ -98,6 +98,7 @@ export function startApp(root, localTexts) { let game = null; let cleanupScreen = null; const adBanner = mountTopAdBanner(); + const adRails = mountSideAdRails(); // Approved community submissions, merged on top of the bundled dataset. // Best-effort: the app is fully usable on the packs it ships with, so a @@ -146,6 +147,7 @@ export function startApp(root, localTexts) { currentScreenName = name; escapeIsCostly = costly; adBanner.setScreen(name); + adRails.setScreen(name); } function handleGlobalEscape(e) { 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() { diff --git a/web/src/styles.css b/web/src/styles.css index f51bb65..ee132d9 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -8,6 +8,9 @@ --neutral-color: #646669; --caret-color: #00ff9d; --sub-color: #8b8e92; + /* Panel and control outlines. Derived from the neutral so it tracks each + theme, rather than being a sixth colour to keep in step by hand. */ + --border-color: color-mix(in srgb, var(--neutral-color) 55%, transparent); /* Unified top offset below fixed header/toggle */ /* Reserved height for the top advertising banner, and the amount the header moves down when one is shown. --ad-banner-h is set on the root @@ -1945,21 +1948,23 @@ body { left out the one bar you most need to see: your own. */ .mp-racers { position: fixed; - top: 58%; + top: 56%; left: 50%; transform: translateX(-50%); z-index: 90; display: flex; flex-direction: column; - gap: 0.5rem; - width: min(680px, 80vw); + gap: 0.35rem; + /* Wider than it was. The field is the thing you look up at between + glances at the passage, so it gets the room. */ + width: min(1040px, 94vw); } .mp-racer-row { display: flex; align-items: center; - gap: 0.75rem; - font-size: 0.8rem; + gap: 0.85rem; + font-size: 0.9rem; color: var(--racer-color, var(--sub-color)); } @@ -1970,32 +1975,61 @@ body { } .mp-racer-name { - min-width: 7rem; - max-width: 7rem; + min-width: 9rem; + max-width: 9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; } -.mp-racer-bar { +/* The track the sprite travels along. Tall enough to hold the sprite, with + the trail drawn behind it. */ +.mp-racer-track { + position: relative; flex: 1 1 auto; - height: 0.4rem; - background: color-mix(in srgb, var(--neutral-color) 45%, transparent); - border-radius: var(--radius-pill); - overflow: hidden; + height: 2rem; + border-bottom: var(--border-width) dashed color-mix(in srgb, var(--neutral-color) 45%, transparent); } -.mp-racer-bar-fill { - height: 100%; +/* How far this racer has come. Sits at the foot of the track, on the dashed + line, so the line reads as road ahead and the trail as road behind. */ +.mp-racer-trail { + position: absolute; + left: 0; + bottom: -1px; + height: 2px; background: var(--racer-color, var(--primary-color)); transition: width 0.15s ease; } +/* The sprite rides the track. The position is the fraction of the way along, + scaled by the room left after the sprite's own width, so at 100 percent the + sprite sits at the finish rather than overhanging it. */ +.mp-racer-sprite { + position: absolute; + bottom: 0.15rem; + left: calc(var(--p, 0) * (100% - 1.9rem)); + width: 1.9rem; + height: 1.9rem; + transition: left 0.15s ease; +} + +.mp-racer-row.me .mp-racer-sprite { + filter: drop-shadow(0 0 4px var(--racer-color, var(--primary-color))); +} + +.mp-racer-percent, .mp-racer-wpm { - min-width: 2.5rem; - text-align: left; + min-width: 3rem; + text-align: right; font-family: 'JetBrains Mono', monospace; + font-variant-numeric: tabular-nums; +} + +.mp-racer-percent { + color: var(--sub-color); + font-size: 0.8rem; } /* The one genuinely time-critical state in the app: seconds left to get your @@ -3050,6 +3084,138 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { min-width: 5.5rem; } +/* A postal address, shown to an administrator so an order can be packed. */ +.order-address { + margin: 0.4rem 0; + padding: 0.5rem 0.75rem; + border-left: 2px solid var(--border-color); + color: var(--text-color); + font-size: 0.8rem; + line-height: 1.5; + white-space: normal; +} + +/* Physical goods. A card, like a bundle, because each one has a description, + possibly a size to pick, and a postage line. */ +.merch-note { + text-align: left; + width: 100%; + max-width: none; + margin-bottom: 0.75rem; +} + +.merch-kind { + width: 100%; + text-align: left; + color: var(--sub-color); + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 1px; + margin: 0.75rem 0 0.4rem 0; +} + +.merch-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 0.75rem; + width: 100%; + margin-bottom: 0.75rem; + box-sizing: border-box; +} + +.merch-card { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.9rem 1rem; + border: var(--border-width) solid var(--border-color); + border-radius: var(--radius-panel); + text-align: left; +} + +.merch-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 0.5rem; +} + +.merch-name { + color: var(--text-color); + font-size: 0.95rem; + font-weight: 600; +} + +.merch-price { + color: var(--text-color); + font-size: 1rem; + font-weight: 600; + white-space: nowrap; +} + +.merch-description { + color: var(--sub-color); + font-size: 0.8rem; + line-height: 1.45; +} + +.merch-variants { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.merch-variant { + padding: 0.3rem 0.6rem; + border: var(--border-width) solid var(--border-color); + border-radius: var(--radius); + background: none; + color: var(--sub-color); + font-family: inherit; + font-size: 0.75rem; + cursor: pointer; +} + +.merch-variant:hover { + border-color: var(--primary-color); + color: var(--text-color); +} + +.merch-variant.active { + border-color: var(--primary-color); + background: var(--primary-color); + color: var(--background-color); +} + +.merch-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: auto; + padding-top: 0.25rem; +} + +.merch-foot .menu-button.small { + margin: 0; + padding: 0.35rem 0.9rem; + font-size: 0.8rem; +} + +.merch-shipping { + color: var(--sub-color); + font-size: 0.72rem; +} + +.merch-error:empty { + display: none; +} + +.merch-error { + color: var(--error-color, var(--incorrect-color)); + font-size: 0.75rem; +} + /* The supporter offer, above the catalogue. It is the one thing in the store that is not cosmetic, so it does not sit in a category with the rest. */ .store-supporter { @@ -3673,12 +3839,18 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { display: inline-flex; align-items: center; justify-content: center; - width: 1.15rem; - height: 1.15rem; flex-shrink: 0; color: var(--racer-color, var(--primary-color)); } +/* Beside a name in a list, rather than on a track. */ +.leaderboard-name .mp-racer-sprite, +.mp-standings-name .mp-racer-sprite { + position: static; + width: 1.15rem; + height: 1.15rem; +} + .mp-racer-sprite svg { width: 100%; height: 100%; @@ -3754,6 +3926,41 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar { box-sizing: border-box; } +/* The page margins. Hidden by default and shown only on a window wide enough + to hold them beside the content without crowding it: the widest content + column is 1040px, and two 160px rails with gaps need 1424px before they fit + without pushing anything. Below that they simply do not exist. */ +.ad-rail { + display: none; +} + +.ad-rail[hidden] { + display: none; +} + +@media (min-width: 1424px) { + .ad-rail:not([hidden]) { + display: flex; + position: fixed; + top: 50%; + transform: translateY(-50%); + z-index: 80; + align-items: center; + } + + .ad-rail-left:not([hidden]) { + left: 1.5rem; + } + + .ad-rail-right:not([hidden]) { + right: 1.5rem; + } + + .ad-rail .ad-slot { + margin: 0; + } +} + /* Dashed and unfilled, which is the one place that convention is right: it is a placeholder, and it should not be mistaken for content. */ .ad-slot-label { |