/* * 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 '
' + picks.map((hex) => '').join('') + '
'; } function frameDemo(item) { const style = String((item.payload && item.payload.frame_style) || '').replace(/[{}<>"]/g, ''); return '
'; } function itemCard(item, balance) { const affordable = balance >= item.cost; let actions = ''; if (item.equipped) { actions = '' + 'Equipped'; } else if (item.owned) { actions = ''; } else { actions = ''; } if (item.slot === 'theme') { actions = '' + actions; } return '
' + '
' + '

' + esc(item.name) + '

' + '

' + esc(item.description) + '

' + (item.slot === 'theme' ? swatches(item) : frameDemo(item)) + '
' + (!item.owned ? '' + fmtDb(item.cost) + '' : '') + '
' + '
' + actions + '
'; } function section(title, items, balance) { if (!items.length) return ''; return '

' + title + '

' + '
' + items.map((i) => itemCard(i, balance)).join('') + '
'; } function render() { const root = document.getElementById(SCREEN_ID); if (!root) return; if (!_data) { root.innerHTML = '

Loading shop…

'; return; } const wallet = _data.wallet || { balance: 0, lifetime_db: 0 }; const items = _data.items || []; root.innerHTML = '
' + '
' + '
Your Decibels
' + '
' + fmtDb(wallet.balance) + '
' + '

Earn dB by playing songs, FeedBarcade rounds, and quests. Cosmetics only — never purchasable with money.

' + '
' + '' + section('Themes', items.filter((i) => i.slot === 'theme'), wallet.balance) + section('Avatar frames', items.filter((i) => i.slot === 'avatar_frame'), wallet.balance) + '
'; root.querySelectorAll('[data-shop-buy]').forEach((b) => b.addEventListener('click', () => buy(b.getAttribute('data-shop-buy')))); root.querySelectorAll('[data-shop-equip]').forEach((b) => b.addEventListener('click', () => equip(b.getAttribute('data-slot'), b.getAttribute('data-shop-equip')))); root.querySelectorAll('[data-shop-unequip]').forEach((b) => b.addEventListener('click', () => equip(b.getAttribute('data-shop-unequip'), null))); root.querySelectorAll('[data-shop-preview]').forEach((b) => b.addEventListener('click', () => { const item = (_data.items || []).find((i) => i.id === b.getAttribute('data-shop-preview')); if (item) previewTheme(item); })); } async function refresh() { await load(); render(); } window.v3Shop = { refresh }; function boot() { render(); if (sm && typeof sm.on === 'function') { sm.on('screen:changed', (e) => { const id = e && e.detail && e.detail.id; if (id === SCREEN_ID) refresh(); else stopPreview(); // leaving the shop restores the equipped look }); sm.on('progression:db-changed', () => { if (document.getElementById(SCREEN_ID)?.classList.contains('active')) refresh(); }); } } // `defer` runs this at readyState 'interactive' — later scripts have not // evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html). if (document.readyState !== 'complete') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();