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 `
`;
}
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 `
`;
}).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();
};
}