srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'web/src')
-rw-r--r--web/src/screens/storeScreen.js158
-rw-r--r--web/src/styles.css127
2 files changed, 252 insertions, 33 deletions
diff --git a/web/src/screens/storeScreen.js b/web/src/screens/storeScreen.js
index f66e3c8..34b0b2e 100644
--- a/web/src/screens/storeScreen.js
+++ b/web/src/screens/storeScreen.js
@@ -16,39 +16,107 @@ function categoryLabel(category) {
return 'Flair';
}
-// Real purchase/equip flow against the catalog, but purchase is a stub --
-// it grants ownership immediately with no actual charge, since real payment
-// processing needs the project owner's own Stripe (or other processor)
-// account. Parked as a question rather than assumed: see nightshift
-// questions.
+// 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 mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ let bundles = [];
+ let mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false };
let status = 'loading';
let message = '';
let stopped = false;
- function itemMarkup(item) {
- const signedOut = status === 'signed-out';
- const owned = !signedOut && mine.owned.includes(item.id);
- const equipped = !signedOut && (mine.equipped_caret === item.id || mine.equipped_flair === item.id);
+ 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.
- const swatch = item.category === 'caret'
- ? `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>`
- : `<span class="store-swatch store-flair-swatch">${(item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS)[item.value] || ''}</span>`;
+ if (item.category === 'caret') {
+ return `<span class="store-swatch" data-swatch-colour="${escapeHtml(item.value)}"></span>`;
+ }
+ const set = item.category === 'sprite' ? RACER_SPRITES : FLAIR_ICONS;
+ return `<span class="store-swatch store-flair-swatch">${set[item.value] || ''}</span>`;
+ }
+
+ 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 `
<div class="leaderboard-row store-item-row">
- ${swatch}
+ ${swatchFor(item)}
<div class="leaderboard-name">${escapeHtml(item.name)}</div>
- ${!owned ? `<div class="leaderboard-acc">${formatPrice(item.price_cents)}</div>` : ''}
+ ${!owned ? `<div class="leaderboard-acc store-price">${formatPrice(item.price_cents)}</div>` : ''}
${owned
- ? `<button class="menu-button small${equipped ? ' active' : ' quiet'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip - back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>`
+ ? `<button class="menu-button small${equipped ? ' active' : ' quiet'}" data-action="${equipped ? 'unequip' : 'equip'}" data-id="${item.id}" data-category="${item.category}" data-tooltip="${equipped ? 'Unequip, back to the default look' : `Replaces whichever ${categoryLabel(item.category).toLowerCase()} you have equipped now`}">${equipped ? 'Equipped' : 'Equip'}</button>`
: `<button class="menu-button small${signedOut ? ' quiet' : ''}" data-action="${signedOut ? 'go-account' : 'buy'}" data-id="${item.id}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy</button>`}
</div>
`;
}
+ 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.
+ const previews = items
+ .map(id => catalog.find(c => c.id === id))
+ .filter(Boolean)
+ .map(item => `<span class="bundle-preview-item${isOwned(item.id) ? ' owned' : ''}" data-tooltip="${escapeHtml(item.name)}${isOwned(item.id) ? ' (owned)' : ''}">${swatchFor(item)}</span>`)
+ .join('');
+
+ return `
+ <div class="bundle-card">
+ <div class="bundle-head">
+ <div class="bundle-name">${escapeHtml(b.name)}</div>
+ ${saving > 0 ? `<div class="bundle-saving">Save ${formatPrice(saving)}</div>` : ''}
+ </div>
+ ${b.description ? `<div class="bundle-description">${escapeHtml(b.description)}</div>` : ''}
+ <div class="bundle-previews">${previews}</div>
+ <div class="bundle-foot">
+ <div class="bundle-prices">
+ <span class="bundle-price">${formatPrice(b.price_cents)}</span>
+ ${saving > 0 ? `<span class="bundle-full-price">${formatPrice(b.full_price_cents)}</span>` : ''}
+ </div>
+ ${complete
+ ? `<span class="bundle-owned">You own all of these</span>`
+ : `<button class="menu-button small${signedOut ? ' quiet' : ' primary'}" data-action="${signedOut ? 'go-account' : 'buy-bundle'}" data-id="${escapeHtml(b.id)}"${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}</button>`}
+ </div>
+ ${ownedCount && !complete ? `<div class="bundle-note">You already own ${ownedCount} of these ${items.length}. The price does not change.</div>` : ''}
+ </div>`;
+ }
+
+ function supporterMarkup() {
+ if (status === 'signed-out') return '';
+ if (mine.is_supporter) {
+ return `<div class="store-supporter is-supporter">
+ <div class="store-supporter-text">
+ <strong>You are a supporter.</strong>
+ <span>Thank you. The site runs without ads for you.</span>
+ </div>
+ </div>`;
+ }
+ return `<div class="store-supporter">
+ <div class="store-supporter-text">
+ <strong>Supporter</strong>
+ <span>Removes the ad slot for 30 days and pays for the servers.</span>
+ </div>
+ <button class="menu-button small primary" data-action="buy-supporter">$3.00</button>
+ </div>`;
+ }
+
function bodyMarkup() {
if (status === 'loading') return `<div class="stats-empty">Loading...</div>`;
if (status === 'error') return `<div class="stats-empty">${escapeHtml(message)}</div>`;
@@ -58,12 +126,35 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
? `<div class="store-signin-note">Sign in to buy and equip these.
<button class="menu-button small" data-action="go-account">Sign In</button></div>`
: '';
+
+ const bundleSection = bundles.length
+ ? `<h3>Bundles</h3><div class="bundle-grid">${bundles.map(bundleMarkup).join('')}</div>`
+ : '';
+
const categories = ['caret', 'flair', 'sprite'];
- return banner + categories.map(cat => {
+ const itemSections = categories.map(cat => {
const items = catalog.filter(i => i.category === cat);
if (items.length === 0) return '';
return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
- }).join('') + `<div class="custom-error store-error"></div>`;
+ }).join('');
+
+ return banner + supporterMarkup() + bundleSection + itemSections
+ + `<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) {
+ try {
+ const data = await api.post(path);
+ 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() {
@@ -85,21 +176,18 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
attachTooltips(root);
const cleanupTheme = renderTopRail(root, { onShowAccount, onShowFriends });
- const goAccount = root.querySelector('[data-action="go-account"]');
- if (goAccount) goAccount.addEventListener('click', onShowAccount);
+ 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', async () => {
- try {
- await api.post(`/api/cosmetics/${btn.dataset.id}/purchase`);
- await loadMine();
- rerender();
- } catch (err) {
- if (errorEl) errorEl.textContent = err instanceof ApiError ? err.message : 'Could not complete the purchase - try again.';
- }
- });
+ 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="equip"]').forEach(btn => {
btn.addEventListener('click', async () => {
await api.post(`/api/cosmetics/${btn.dataset.id}/equip`).catch(() => {});
@@ -131,15 +219,19 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
catalog = await api.get('/api/cosmetics');
} catch {
status = 'error';
- message = 'Could not load the store - try again.';
+ message = 'Could not load the store. Try again.';
+ return;
}
+ // 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 = []; }
}
async function loadMine() {
if (!getUser()) {
- // The catalogue still renders - only ownership needs an account.
+ // The catalogue still renders; only ownership needs an account.
status = 'signed-out';
- mine = { owned: [], equipped_caret: null, equipped_flair: null };
+ mine = { owned: [], equipped_caret: null, equipped_flair: null, equipped_sprite: null, is_supporter: false };
return;
}
try {
@@ -147,7 +239,7 @@ export function renderStoreScreen(root, { onBack, onShowStats, onShowPlaceholder
status = 'ok';
} catch {
status = 'error';
- message = 'Could not load your cosmetics - try again.';
+ message = 'Could not load your cosmetics. Try again.';
}
}
diff --git a/web/src/styles.css b/web/src/styles.css
index 3dae32b..b53e99c 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -2740,6 +2740,133 @@ body::-webkit-scrollbar, .app::-webkit-scrollbar, #root::-webkit-scrollbar {
color: var(--sub-color);
}
+/* 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 {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ flex-wrap: wrap;
+ margin-bottom: 1.5rem;
+ padding: 0.9rem 1.1rem;
+ border: var(--border-width) solid color-mix(in srgb, var(--primary-color) 45%, transparent);
+ border-radius: var(--radius-panel);
+ background: color-mix(in srgb, var(--primary-color) 7%, transparent);
+ text-align: left;
+}
+
+.store-supporter.is-supporter {
+ border-color: color-mix(in srgb, var(--correct-color) 45%, transparent);
+ background: color-mix(in srgb, var(--correct-color) 7%, transparent);
+}
+
+.store-supporter-text {
+ display: flex;
+ flex-direction: column;
+ gap: 0.15rem;
+}
+
+.store-supporter-text strong {
+ color: var(--text-color);
+ font-size: 0.95rem;
+}
+
+.store-supporter-text span {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+}
+
+/* Bundles. A card rather than a row: a bundle has to show what is inside it,
+ which a one-line row has no space for. */
+.bundle-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
+ gap: 0.75rem;
+ margin-bottom: 1.5rem;
+}
+
+.bundle-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;
+}
+
+.bundle-head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.bundle-name {
+ color: var(--text-color);
+ font-size: 0.95rem;
+ font-weight: 600;
+}
+
+.bundle-saving {
+ color: var(--correct-color);
+ font-size: 0.75rem;
+ white-space: nowrap;
+}
+
+.bundle-description {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ line-height: 1.4;
+}
+
+.bundle-previews {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin: 0.15rem 0;
+}
+
+/* An item already owned is dimmed rather than hidden, so the bundle still
+ shows its full contents. */
+.bundle-preview-item.owned {
+ opacity: 0.35;
+}
+
+.bundle-foot {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+ margin-top: auto;
+ padding-top: 0.25rem;
+}
+
+.bundle-prices {
+ display: flex;
+ align-items: baseline;
+ gap: 0.4rem;
+}
+
+.bundle-price {
+ color: var(--text-color);
+ font-size: 1.05rem;
+ font-weight: 600;
+}
+
+.bundle-full-price {
+ color: var(--sub-color);
+ font-size: 0.8rem;
+ text-decoration: line-through;
+}
+
+.bundle-owned,
+.bundle-note {
+ color: var(--sub-color);
+ font-size: 0.75rem;
+}
+
/* Close, at the top-right of the content. Replaces the full-width "Back"
button every screen carried at the bottom - a third way to do what the
wordmark and Escape already did, and the least reachable of the three. */