srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens
diff options
context:
space:
mode:
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() {