import { escapeHtml } from '../util.js'; import { logoLockup } from '../logo.js'; import { renderCornerRail } from '../cornerRail.js'; import { attachTooltips } from '../tooltip.js'; import { renderTopRail } from '../topRail.js'; import { api, ApiError } from '../api.js'; import { getUser } from '../auth.js'; import { FLAIR_ICONS, RACER_SPRITES, CLOSE_ICON } from './icons.js'; function formatPrice(cents) { return `$${(cents / 100).toFixed(2)}`; } // Enough to show what kind of thing is in a bundle without making one card // 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'; return 'Flair'; } // Buying sends the browser to the processor's own checkout page. Nothing is // granted here: the item appears once the processor's webhook reaches the // server. That is why every Buy button leaves the site rather than updating // in place. export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }) { 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 }; let status = 'loading'; 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 `Soon`; } const signedOut = status === 'signed-out'; return ``; } function isOwned(id) { return status !== 'signed-out' && mine.owned.includes(id); } function swatchFor(item) { // A caret is a colour, so the swatch is the colour itself; flair and // sprites are drawings, so the swatch is the drawing. if (item.category === 'caret') { return ``; } const set = item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS; return `${set[item.value] || ''}`; } function itemMarkup(item) { const signedOut = status === 'signed-out'; const owned = isOwned(item.id); const equipped = !signedOut && ( mine.equipped_caret === item.id || mine.equipped_flair === item.id || mine.equipped_sprite === item.id ); return `
${swatchFor(item)}
${escapeHtml(item.name)}
${!owned ? `
${formatPrice(item.price_cents)}
` : ''} ${owned ? `` : buyControl('buy', item.id, 'Buy')}
`; } function bundleMarkup(b) { const signedOut = status === 'signed-out'; const items = b.items || []; const ownedCount = items.filter(isOwned).length; const complete = items.length > 0 && ownedCount === items.length; const saving = b.full_price_cents - b.price_cents; // The contents are shown as their own swatches: a bundle you cannot // see the inside of is a bundle nobody buys. Capped, because The Lot // holds every item and its 26 swatches made the card three rows // taller than the one beside it, which then sat in dead space. const resolved = items.map(id => catalog.find(c => c.id === id)).filter(Boolean); const shown = resolved.slice(0, MAX_PREVIEWS); const hidden = resolved.length - shown.length; const previews = shown .map(item => `${swatchFor(item)}`) .join('') + (hidden > 0 ? `+${hidden}` : ''); return `
${escapeHtml(b.name)}
${saving > 0 ? `
Save ${formatPrice(saving)}
` : ''}
${b.description ? `
${escapeHtml(b.description)}
` : ''}
${previews}
${formatPrice(b.price_cents)} ${saving > 0 ? `${formatPrice(b.full_price_cents)}` : ''}
${complete ? `You own all of these` : buyControl('buy-bundle', b.id, `Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}`, ' primary')}
${ownedCount && !complete ? `
You already own ${ownedCount} of these ${items.length}. The price does not change.
` : ''}
`; } function merchMarkup(item) { const signedOut = status === 'signed-out'; const needsVariant = (item.variants || []).length > 0; const picked = chosenVariant[item.id]; return `
${escapeHtml(item.name)}
${formatPrice(item.price_cents)}
${item.description ? `
${escapeHtml(item.description)}
` : ''} ${needsVariant ? `
${item.variants.map(v => ` `).join('')}
` : ''}
${item.shipping_cents ? `plus ${formatPrice(item.shipping_cents)} postage` : 'postage included'} ${buyControl('buy-merch', item.id, 'Order', ' primary')}
`; } function supporterMarkup() { if (offline || status === 'signed-out') return ''; if (mine.is_supporter) { return `
You are a supporter. Thank you. The site runs without ads for you.
`; } return `
Supporter Removes the ad slot for 30 days and pays for the servers.
`; } function bodyMarkup() { if (status === 'loading') return `
Loading...
`; if (status === 'error') return `
${escapeHtml(message)}
`; const signedOut = status === 'signed-out'; const banner = offline ? `
The shop is not open yet. Everything below is what will be in it.
` : signedOut ? `
Sign in to buy and equip these.
` : ''; const bundleSection = bundles.length ? `

Bundles

${bundles.map(bundleMarkup).join('')}
` : ''; const merchKinds = ['shirt', 'mug', 'deskmat']; const merchSections = merch.length ? `

Merch

Posted to 21 countries. Your address is entered on the payment page and never on this site.
` + merchKinds.map(kind => { const items = merch.filter(m => m.kind === kind); if (!items.length) return ''; return `

${merchKindLabel(kind)}

${items.map(merchMarkup).join('')}
`; }).join('') : ''; const categories = ['caret', 'flair', 'sprite']; const itemSections = categories.map(cat => { const items = catalog.filter(i => i.category === cat); if (items.length === 0) return ''; return `

${categoryLabel(cat)}

${items.map(itemMarkup).join('')}
`; }).join(''); return banner + supporterMarkup() + bundleSection + itemSections + merchSections + `
`; } /// 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, body) { try { const data = await api.post(path, body); if (data && data.url) window.location.assign(data.url); } catch (err) { if (!errorEl) return; errorEl.textContent = err instanceof ApiError ? err.message : 'Could not start checkout. Try again.'; } } function render() { root.innerHTML = `
${logoLockup()}

Store

${bodyMarkup()}
`; root.querySelectorAll('[data-action="menu"]').forEach(el => el.addEventListener('click', onBack)); // The swatch colour comes from the catalogue, so it cannot be a class. // Set through element.style because a style attribute is blocked by the // Content-Security-Policy. root.querySelectorAll('[data-swatch-colour]').forEach(el => { el.style.background = el.dataset.swatchColour; }); attachTooltips(root); const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends }); root.querySelectorAll('[data-action="go-account"]').forEach(el => el.addEventListener('click', onShowAccount)); const errorEl = root.querySelector('.store-error'); root.querySelectorAll('[data-action="buy"]').forEach(btn => { btn.addEventListener('click', () => startCheckout(`/api/billing/checkout/${encodeURIComponent(btn.dataset.id)}`, errorEl)); }); root.querySelectorAll('[data-action="buy-bundle"]').forEach(btn => { btn.addEventListener('click', () => startCheckout(`/api/billing/bundle/${encodeURIComponent(btn.dataset.id)}`, errorEl)); }); 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(() => {}); await loadMine(); rerender(); }); }); root.querySelectorAll('[data-action="unequip"]').forEach(btn => { btn.addEventListener('click', async () => { await api.post('/api/cosmetics/unequip', { category: btn.dataset.category }).catch(() => {}); await loadMine(); rerender(); }); }); const cleanupRail = renderCornerRail(root, { onShowStats, onShowPlaceholder, onShowAccount, onShowLeaderboard, onShowFriends, onShowMultiplayer }); return () => { cleanupTheme(); cleanupRail(); }; } let cleanup; function rerender() { if (stopped) return; if (cleanup) cleanup(); 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.'; } } 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'; mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false }; return; } try { mine = await api.get('/api/cosmetics/me'); status = 'ok'; } catch { status = 'error'; message = 'Could not load your cosmetics. Try again.'; } } cleanup = render(); (async () => { await loadCatalog(); await loadMine(); rerender(); })(); return () => { stopped = true; if (cleanup) cleanup(); }; }