-
-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathcosmetics-shop.js
More file actions
417 lines (375 loc) · 17.1 KB
/
Copy pathcosmetics-shop.js
File metadata and controls
417 lines (375 loc) · 17.1 KB
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
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
// Cosmetics Shop (R21 browse/preview + R22 x402 purchase) — in-world panel to
// browse the cosmetics catalog, filter by rarity, preview any item LIVE on your
// own avatar, and BUY a premium item with a real USDC payment over x402.
//
// The shop is pure 2D chrome: it fetches the real catalog and calls back into
// the scene (coincommunities.js) to equip/unequip a preview on the local rig. A
// "Buy" runs the x402 flow in cosmetics-purchase.js — open wallet, settle USDC,
// record ownership server-side — and flips the card to owned only once the
// server confirms it (no optimistic unlock). Owned state is read per-account
// from the R22 ledger. The item's value is quoted in $THREE (the only coin);
// USDC is the settlement asset shown on the buy button.
//
// Catalog source: the real endpoint /api/cosmetics/catalog (with the caller's
// account, so owned items render owned), with the static CDN mirror
// /cosmetics/catalog.json as a resilient fallback (and the dev source, where
// /api/* proxies to prod). Both are real network fetches — no sample array.
import { log } from '../shared/log.js';
import { purchaseCosmetic, resolveShopAccount } from './cosmetics-purchase.js';
import { openModal } from './a11y.js';
import { t } from './i18n-play.js';
function el(tag, props = {}, kids = []) {
const n = document.createElement(tag);
for (const [k, v] of Object.entries(props)) {
if (k === 'class') n.className = v;
else if (k === 'text') n.textContent = v;
else if (k === 'html') n.innerHTML = v;
else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v);
else if (v !== null && v !== undefined && v !== false) n.setAttribute(k, v === true ? '' : v);
}
for (const kid of [].concat(kids)) if (kid != null && kid !== false) n.appendChild(typeof kid === 'string' ? document.createTextNode(kid) : kid);
return n;
}
const RARITY_LABEL = { common: 'Common', rare: 'Rare', epic: 'Epic', legendary: 'Legendary' };
// Placeholder glyph per slot, shown when an item has no preview image.
const SLOT_GLYPH = { hat: '🎩', glasses: '🕶️', earrings: '💎', outfit: '👕', skin: '🎨', emote: '✨' };
const fmtPrice = (n) => {
if (!n || !isFinite(n)) return '0';
if (n >= 1000) return (n / 1000).toFixed(n % 1000 ? 1 : 0) + 'K';
return String(n);
};
// Fetch the catalog from the real endpoint, falling back to the static mirror.
// Throws only if neither source yields items, so the UI can show its error state.
// `account` (when set) makes premium items the account already bought read as
// owned — the static mirror has no account view, so it always shows them locked.
async function fetchCatalog(account) {
const qs = account ? `?account=${encodeURIComponent(account)}` : '';
const sources = [`/api/cosmetics/catalog${qs}`, '/cosmetics/catalog.json'];
for (const url of sources) {
try {
const r = await fetch(url, { headers: { accept: 'application/json' } });
if (!r.ok) continue;
const data = await r.json();
if (Array.isArray(data?.items)) return data.items;
} catch { /* try the next source */ }
}
throw new Error('catalog unavailable');
}
export class CosmeticsShop {
/**
* @param {object} h handlers:
* onPreview(item) — equip the item live on the local avatar.
* onEndPreview() — revert the active preview.
* onPurchased(item) — (optional) a premium item was just bought.
* account — (optional) verified wallet to key ownership on;
* falls back to the persisted guest id.
*/
constructor(h = {}) {
this.h = h;
this.items = [];
this.rarity = 'all'; // active rarity filter
this.previewId = null; // id of the item currently previewing (toggle)
this.state = 'idle'; // idle | loading | ready | error
this.buyingId = null; // id of the item whose payment is in flight
// The account ownership + purchases are keyed on. Resolved once at build;
// re-resolved on each open() in case the player signs in between sessions.
this.account = resolveShopAccount(h.account);
this._build();
}
_build() {
this.closeBtn = el('button', {
class: 'cc-shop-close', type: 'button', 'aria-label': 'Close shop',
onclick: () => this.close(),
}, [el('span', { 'aria-hidden': 'true', text: '✕' })]);
this.filters = el('div', { class: 'cc-shop-filters', role: 'tablist', 'aria-label': 'Filter by rarity' });
this.grid = el('div', { class: 'cc-shop-grid' });
this.statusEl = el('div', { class: 'cc-shop-status', hidden: true, role: 'status', 'aria-live': 'polite' });
this.body = el('div', { class: 'cc-shop-body' }, [this.statusEl, this.grid]);
this.panel = el('div', {
class: 'cc-shop-panel', role: 'dialog', 'aria-modal': 'true',
'aria-label': t('play.shop_aria', 'Open cosmetics shop'),
'data-i18n-attr': 'aria-label:play.shop_panel_aria',
}, [
el('div', { class: 'cc-shop-head' }, [
el('div', { class: 'cc-shop-title' }, [
el('span', { class: 'cc-shop-title-main', text: 'Cosmetics', 'data-i18n': 'play.shop_h' }),
el('span', { class: 'cc-shop-title-sub', text: 'Preview live on your avatar', 'data-i18n': 'play.shop_sub' }),
]),
// Rarest Fits — the platform flex board (R25). Lazy-loaded on click so
// it never weighs down the shop bundle.
el('button', {
class: 'cc-shop-flex', type: 'button', title: 'See the rarest fits & top earning creators',
'aria-label': 'Open rarest fits leaderboard',
onclick: () => this._openFlex(),
}, [el('span', { 'aria-hidden': 'true', text: '✦' }), el('span', { class: 'cc-shop-flex-text', text: 'Rarest Fits' })]),
this.closeBtn,
]),
this.filters,
this.body,
]);
this.root = el('div', { class: 'cc-shop', id: 'cc-shop', hidden: true }, [this.panel]);
// Backdrop tap closes; panel taps don't bubble out.
this.root.addEventListener('click', (e) => { if (e.target === this.root) this.close(); });
// World hotkeys live on `window`; keystrokes aimed at the shop must not
// also drive the avatar behind it.
this.panel.addEventListener('keydown', (e) => e.stopPropagation());
document.body.appendChild(this.root);
this._buildFilters();
}
_buildFilters() {
this.filters.textContent = '';
const make = (key, label) => el('button', {
class: 'cc-shop-chip' + (this.rarity === key ? ' cc-active' : ''),
type: 'button', role: 'tab', 'aria-selected': this.rarity === key ? 'true' : 'false',
'data-rarity': key,
onclick: () => this._setRarity(key),
}, label);
this.filters.appendChild(make('all', 'All'));
for (const r of ['common', 'rare', 'epic', 'legendary']) this.filters.appendChild(make(r, RARITY_LABEL[r]));
}
_setRarity(key) {
if (this.rarity === key) return;
this.rarity = key;
this._buildFilters();
this._render();
}
// ── open / close ──────────────────────────────────────────────────────────
isOpen() { return !this.root.hidden; }
toggle() { this.isOpen() ? this.close() : this.open(); }
async open() {
if (this.isOpen()) return;
this.root.hidden = false;
requestAnimationFrame(() => this.root.classList.add('cc-shop-in'));
// Escape stack + Tab containment + focus restore, shared with every other
// /play panel (src/game/a11y.js).
this._releaseModal = openModal(this.panel, { close: () => this.close(), initialFocus: this.closeBtn });
// Re-resolve the account in case the player signed in since last open, so a
// fresh wallet sees its owned items rather than the guest's.
const acct = resolveShopAccount(this.h.account);
const accountChanged = acct !== this.account;
this.account = acct;
// Load once; re-open reuses the catalog (cheap, and avoids a flash) — unless
// the account changed, which flips owned/locked state and needs a refetch.
if (this.state !== 'ready' || accountChanged) await this._load();
else this._render();
}
close() {
if (!this.isOpen()) return;
this.root.classList.remove('cc-shop-in');
this._releaseModal?.();
this._releaseModal = null;
// Reverting any live preview is the whole point — don't leave a tried-on
// item stuck on the avatar after the shop closes.
this._clearPreview();
// Match the CSS transition before hiding so the exit animates.
setTimeout(() => { this.root.hidden = true; }, 180);
}
dispose() {
this._releaseModal?.();
this._releaseModal = null;
this.root.remove();
}
// ── data ──────────────────────────────────────────────────────────────────
async _load() {
this.state = 'loading';
this._renderSkeleton();
try {
// Always fetch the full catalog (keyed on the account so owned items read
// owned); rarity filtering is client-side so switching tabs is instant.
this.items = await fetchCatalog(this.account);
this.state = 'ready';
this._render();
} catch (err) {
log.warn('[shop] catalog load failed:', err?.message);
this.state = 'error';
this._renderError();
}
}
// ── rendering ───────────────────────────────────────────────────────────────
_status(text, kind = '') {
this.statusEl.textContent = text || '';
this.statusEl.hidden = !text;
this.statusEl.dataset.kind = text ? kind : '';
}
_renderSkeleton() {
this._status('');
this.grid.textContent = '';
this.grid.classList.remove('cc-shop-empty-grid');
for (let i = 0; i < 8; i++) {
this.grid.appendChild(el('div', { class: 'cc-shop-card cc-shop-skel', 'aria-hidden': 'true' }, [
el('div', { class: 'cc-shop-thumb cc-skel-box' }),
el('div', { class: 'cc-shop-meta' }, [
el('div', { class: 'cc-skel-line' }),
el('div', { class: 'cc-skel-line cc-skel-short' }),
]),
]));
}
}
_renderError() {
this._status('');
this.grid.textContent = '';
this.grid.classList.add('cc-shop-empty-grid');
this.grid.appendChild(el('div', { class: 'cc-shop-empty' }, [
el('div', { class: 'cc-shop-empty-ico', 'aria-hidden': 'true', text: '⚠' }),
el('div', { class: 'cc-shop-empty-title', text: 'Couldn’t load the shop' }),
el('div', { class: 'cc-shop-empty-sub', text: 'The catalog didn’t load. Check your connection and try again.' }),
el('button', { class: 'cc-shop-retry', type: 'button', text: 'Retry', onclick: () => this._load() }),
]));
}
_render() {
if (this.state === 'loading') return this._renderSkeleton();
if (this.state === 'error') return this._renderError();
const list = this.rarity === 'all' ? this.items : this.items.filter((i) => i.rarity === this.rarity);
this.grid.textContent = '';
if (!list.length) {
this.grid.classList.add('cc-shop-empty-grid');
this.grid.appendChild(el('div', { class: 'cc-shop-empty' }, [
el('div', { class: 'cc-shop-empty-ico', 'aria-hidden': 'true', text: '🔍' }),
el('div', { class: 'cc-shop-empty-title', text: 'Nothing in this tier yet' }),
el('div', { class: 'cc-shop-empty-sub', text: 'No cosmetics match this rarity. Try another filter.' }),
el('button', { class: 'cc-shop-retry', type: 'button', text: 'Show all', onclick: () => this._setRarity('all') }),
]));
this._status('');
return;
}
this.grid.classList.remove('cc-shop-empty-grid');
const ownedCount = list.filter((i) => i.owned).length;
this._status(`${list.length} item${list.length === 1 ? '' : 's'} · ${ownedCount} owned`);
for (const item of list) this.grid.appendChild(this._card(item));
}
_card(item) {
const active = this.previewId === item.id;
const thumb = item.previewImage
? el('img', {
class: 'cc-shop-thumb-img', src: item.previewImage, alt: '', loading: 'lazy',
// Missing/404 art degrades to the slot glyph instead of a broken icon.
onerror: (e) => { e.target.replaceWith(this._glyph(item)); },
})
: this._glyph(item);
// The thumb is the live-preview toggle (R21): a button so it's keyboard- and
// screen-reader-operable on its own, separate from the Buy button below.
const previewBtn = el('button', {
class: 'cc-shop-thumb cc-shop-thumb-btn',
type: 'button',
'aria-pressed': active ? 'true' : 'false',
'aria-label': `${active ? 'Stop previewing' : 'Preview'} ${item.name} on your avatar`,
title: active ? 'Click to stop preview' : 'Click to preview on your avatar',
onclick: () => this._toggleItem(item),
}, [
thumb,
el('span', { class: 'cc-shop-rarity', 'data-rarity': item.rarity, text: RARITY_LABEL[item.rarity] }),
el('span', { class: 'cc-shop-preview-tag', 'aria-hidden': 'true', text: active ? 'Previewing' : 'Preview' }),
]);
const card = el('div', {
class: 'cc-shop-card'
+ ` cc-rarity-${item.rarity}`
+ (item.owned ? ' cc-is-owned' : ' cc-is-locked')
+ (active ? ' cc-active' : ''),
'data-id': item.id,
}, [
previewBtn,
el('div', { class: 'cc-shop-meta' }, [
el('span', { class: 'cc-shop-name', text: item.name }),
// The item's value is quoted in $THREE — the only coin (R21 copy).
el('span', { class: 'cc-shop-value', text: `${fmtPrice(item.price)} $THREE` }),
this._action(item),
]),
]);
return card;
}
// The owned badge or the Buy button (R22). Built separately so a settled
// purchase can swap just this node to "Owned" without re-rendering the grid.
_action(item) {
if (item.owned) {
return el('span', { class: 'cc-shop-action cc-shop-badge cc-owned', text: 'Owned' });
}
const pending = this.buyingId === item.id;
const buy = el('button', {
class: 'cc-shop-action cc-shop-buy',
type: 'button',
disabled: pending || undefined,
// USDC is the settlement asset (not a coin we hold) — labelled on the CTA.
'aria-label': `Buy ${item.name} for ${item.priceUsdc} USDC`,
text: pending ? 'Opening wallet…' : `Buy · $${item.priceUsdc} USDC`,
onclick: () => this._buy(item),
});
return buy;
}
_glyph(item) {
return el('div', { class: 'cc-shop-glyph', 'aria-hidden': 'true', text: SLOT_GLYPH[item.slot] || '✦' });
}
// Open the Rarest Fits flex board (R25), highlighting the current coin world.
// Lazy chunk — the leaderboard UI only loads when a player asks for it.
async _openFlex() {
try {
const { openCosmeticsFlex } = await import('./cosmetics-flex.js');
openCosmeticsFlex({ coinMint: this.h.coinMint || '' });
} catch (err) {
log.warn('[shop] flex board failed to load', err?.message);
this._status('Couldn’t open the flex board. Try again in a moment.', 'error');
}
}
// ── purchase (R22) ──────────────────────────────────────────────────────────
// Run the real x402 USDC payment for a locked item and, on the server's
// confirmation, flip the card to owned. Every state is honest: pending while
// the wallet is open, owned only after the payment verifies, an actionable
// message on failure (including insufficient funds). No optimistic unlock.
async _buy(item) {
if (this.buyingId || item.owned) return;
this.buyingId = item.id;
const card = this.grid.querySelector(`.cc-shop-card[data-id="${CSS.escape(item.id)}"]`);
this._swapAction(card, item);
this._status(`Settling payment for ${item.name}…`, 'pending');
try {
const ticket = await purchaseCosmetic(item, { account: this.account, coin: this.h.coinMint });
this.buyingId = null;
item.owned = true;
if (card) {
card.classList.remove('cc-is-locked');
card.classList.add('cc-is-owned');
this._swapAction(card, item);
}
this._status(
ticket.newlyOwned ? `Unlocked ${item.name}.` : `${item.name} is already in your wardrobe.`,
'ok',
);
try { this.h.onPurchased?.(item, ticket); } catch (err) { log.warn('[shop] onPurchased', err?.message); }
} catch (err) {
this.buyingId = null;
this._swapAction(card, item);
if (err?.code === 'cancelled') { this._status(''); return; }
const msg = String(err?.message || err);
// The wallet relays an insufficient-balance error verbatim — turn it into
// an actionable nudge instead of a raw failure string.
const insufficient = /insufficient|balance|not enough|exceeds/i.test(msg);
this._status(
insufficient
? `Not enough USDC to buy ${item.name}. Top up your wallet and try again.`
: `Purchase failed: ${msg}`,
'error',
);
}
}
// Replace just the action node of a card in place (Buy ⇄ pending ⇄ Owned).
_swapAction(card, item) {
const old = card?.querySelector('.cc-shop-action');
if (old) old.replaceWith(this._action(item));
}
// ── preview toggle ────────────────────────────────────────────────────────
async _toggleItem(item) {
// Re-selecting the active item ends the preview.
if (this.previewId === item.id) { this._clearPreview(); this._render(); return; }
this.previewId = item.id;
this._render();
try {
await this.h.onPreview?.(item);
} catch (err) {
log.warn('[shop] preview failed:', err?.message);
}
}
_clearPreview() {
if (!this.previewId) return;
this.previewId = null;
try { this.h.onEndPreview?.(); } catch { /* ignore */ }
}
}