/* * fee[dB]ack v0.3.0 — Shop screen (spec 010). * * Cosmetics catalog (themes + avatar frames) bought with Decibels earned by * playing — there is deliberately NO real-money path anywhere in this screen. * Buy/Equip dogfood the `progression` capability domain (buy-item/equip-item * with authorization:'user-action'), falling back to direct fetch when the * capability runtime is unavailable. Themes support a live preview via * window.v3Theme.apply; leaving the screen restores the equipped look. * * Vanilla JS, no framework (constitution P-II). */ (function () { 'use strict'; const sm = window.feedBack; const SCREEN_ID = 'v3-shop'; let _data = null; // last GET /api/shop payload let _previewing = null; // item id currently previewed (theme slot only) const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const fmtDb = (n) => Number(n || 0).toLocaleString() + ' dB'; async function load() { try { const r = await fetch('/api/shop'); if (r.ok) _data = await r.json(); } catch (e) { /* offline — keep stale */ } return _data; } // ── Capability-first actions (fetch fallback) ──────────────────────────── async function _viaCapability(command, payload) { const capabilities = sm && sm.capabilities; if (capabilities && capabilities.version === 1) { const result = await capabilities.command('progression', command, { requester: 'core.shop-screen', origin: 'user', authorization: 'user-action', reason: 'Shop screen user action', payload, }); return { ok: result.outcome === 'handled', reason: result.reason, payload: result.payload }; } const url = command === 'buy-item' ? '/api/shop/buy' : '/api/shop/equip'; const body = command === 'buy-item' ? { item_id: payload.item_id } : { slot: payload.slot, item_id: payload.item_id == null ? null : payload.item_id }; try { const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const data = await r.json().catch(() => ({})); return { ok: r.ok, reason: data.error, payload: data }; } catch (e) { return { ok: false, reason: 'offline' }; } } async function buy(itemId) { const r = await _viaCapability('buy-item', { item_id: itemId }); if (!r.ok && r.reason) toast(r.reason); await refresh(); } async function equip(slot, itemId) { _previewing = null; const r = await _viaCapability('equip-item', { slot, item_id: itemId }); if (!r.ok && r.reason) toast(r.reason); if (window.v3Theme) window.v3Theme.refresh(); if (window.v3Profile) window.v3Profile.refresh(); await refresh(); } function toast(msg) { const root = document.getElementById(SCREEN_ID); const el = root && root.querySelector('[data-shop-toast]'); if (!el) return; el.textContent = msg; el.classList.remove('hidden'); setTimeout(() => el.classList.add('hidden'), 4000); } function previewTheme(item) { if (!window.v3Theme) return; if (_previewing === item.id) { _previewing = null; window.v3Theme.refresh(); // restore equipped look } else { _previewing = item.id; window.v3Theme.apply(item.payload); } render(); } function stopPreview() { if (_previewing && window.v3Theme) { _previewing = null; window.v3Theme.refresh(); } } // ── Rendering ──────────────────────────────────────────────────────────── function swatches(item) { const c = (item.payload && item.payload.colors) || {}; const picks = [c.bg, c.card, c.primary, c.accent, c.gold].filter(Boolean); if (!picks.length) return ''; return '
' + esc(item.description) + '
' + (item.slot === 'theme' ? swatches(item) : frameDemo(item)) + 'Loading shop…
Earn dB by playing songs, FeedBarcade rounds, and quests. Cosmetics only — never purchasable with money.
' + '