srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/web/src/screens/storeScreen.js
blob: 9d66952d096dd8dbec32ff24a95d809a22855b71 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
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 `<span class="store-soon" data-tooltip="The shop opens when accounts do">Soon</span>`;
        }
        const signedOut = status === 'signed-out';
        return `<button class="menu-button small${signedOut ? ' quiet' : extraClass || ''}"
                data-action="${signedOut ? 'go-account' : action}" data-id="${escapeHtml(id)}"
                ${signedOut ? ' data-tooltip="Sign in to buy this"' : ''}>${label}</button>`;
    }

    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 `<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">
                ${swatchFor(item)}
                <div class="leaderboard-name">${escapeHtml(item.name)}</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>`
                    : buyControl('buy', item.id, 'Buy')}
            </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. 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 => `<span class="bundle-preview-item${isOwned(item.id) ? ' owned' : ''}" data-tooltip="${escapeHtml(item.name)}${isOwned(item.id) ? ' (owned)' : ''}">${swatchFor(item)}</span>`)
            .join('') + (hidden > 0 ? `<span class="bundle-preview-more">+${hidden}</span>` : '');

        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>`
                        : buyControl('buy-bundle', b.id, `Buy${ownedCount ? ` the other ${items.length - ownedCount}` : ''}`, ' primary')}
                </div>
                ${ownedCount && !complete ? `<div class="bundle-note">You already own ${ownedCount} of these ${items.length}. The price does not change.</div>` : ''}
            </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>
                    ${buyControl('buy-merch', item.id, 'Order', ' primary')}
                </div>
                <div class="merch-error" data-error-for="${escapeHtml(item.id)}"></div>
            </div>`;
    }

    function supporterMarkup() {
        if (offline || 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>`;

        const signedOut = status === 'signed-out';
        const banner = offline
            ? `<div class="store-signin-note store-offline-note">The shop is not open yet. Everything below is what will be in it.</div>`
            : signedOut
                ? `<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 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);
            if (items.length === 0) return '';
            return `<h3>${categoryLabel(cat)}</h3><div class="leaderboard-list">${items.map(itemMarkup).join('')}</div>`;
        }).join('');

        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, 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 = `
            <div class="stats-screen">
                <button class="screen-close" data-action="menu" aria-label="Close" data-tooltip="Close (Esc)">${CLOSE_ICON}</button>
                ${logoLockup()}
                <h2>Store</h2>
                ${bodyMarkup()}
            </div>
        `;
        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();
    };
}