/* * fee[dB]ack v0.3.0 — player profile: first-run onboarding overlay, the topbar * profile badge, and the #v3-profile screen. * * Vanilla JS (constitution P-II). Reads /api/profile + /api/profile/progress * (one call for the whole badge). Degrades gracefully when stats endpoints * (prompt 14) aren't present yet. window.v3Onboarding is defined synchronously * so the shell boot (shell.js) can call it regardless of script order. */ (function () { 'use strict'; let _profile = null; // {display_name, avatar_url, player_hash, onboarded} let _progress = null; // {level, xp, xp_in_level, xp_to_next, current_streak, best_streak} const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); function avatarImg(url, sizeCls, shape) { shape = shape || 'rounded-full'; if (url) { return ''; } // Neutral fallback glyph. return '' + '' + ''; } // ── API ────────────────────────────────────────────────────────────────── async function fetchProfile() { try { const r = await fetch('/api/profile'); if (r.ok) _profile = await r.json(); } catch (e) { /* P15 */ } return _profile; } async function fetchProgress() { try { const r = await fetch('/api/profile/progress'); if (r.ok) _progress = await r.json(); } catch (e) { /* P15 */ } return _progress; } // ── Topbar profile badge ───────────────────────────────────────────────-- function renderBadge() { const host = document.getElementById('v3-badge-profile'); if (!host) return; if (!_profile || !_profile.onboarded) { host.innerHTML = ''; return; } const p = _progress || { current_streak: 0 }; // Progression (spec 010): the badge shows Mastery Rank + Decibels. // Layout still matches the Google Stitch "Profile Card Component" // (dark rounded card, white avatar tile, flame + "N DAYS"), but the // 6-bar equalizer now meters CURRENT CHALLENGE-SET progress (completed // challenges across all paths' active sets / required total) and the // big number is the Mastery Rank, with the spendable dB balance beside. const prog = (window.v3Progression && window.v3Progression.get()) || null; const rank = prog ? prog.mastery_rank : 0; const balance = prog && prog.wallet ? prog.wallet.balance : 0; let challengesDone = 0, challengesRequired = 0; ((prog && prog.paths) || []).forEach((path) => { if (path.next) { challengesDone += Math.min(path.next.completed, path.next.required); challengesRequired += path.next.required; } }); const flame = ''; const HEIGHTS = ['h-2', 'h-4', 'h-8', 'h-6', 'h-10', 'h-12']; const FILL = ['#3B82F6', '#22C55E', '#FACC15', '#F97316', '#D1D5DB', '#22C55E']; const filled = challengesRequired > 0 ? Math.max(0, Math.min(6, Math.round((challengesDone / challengesRequired) * 6))) : 0; const bars = HEIGHTS.map((h, i) => '
').join(''); host.innerHTML = ''; // Equipped avatar frame (spec 010 cosmetics). if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(host.querySelector('[data-v3-avatar-tile]')); } const btn = host.querySelector('[data-v3-open-profile]'); if (btn) btn.addEventListener('click', () => window.showScreen && window.showScreen('v3-profile')); } // ── Profile screen (#v3-profile) ────────────────────────────────────────- function renderProfileScreen() { const root = document.getElementById('v3-profile'); if (!root) return; const p = _progress || { current_streak: 0, best_streak: 0 }; const name = (_profile && _profile.display_name) || 'Player'; // Progression (spec 010): the profile header shows Mastery Rank, // per-path levels, and Decibels (balance + lifetime) — the old // XP-level meter is replaced by the rank/challenge system. const prog = (window.v3Progression && window.v3Progression.get()) || null; const rank = prog ? prog.mastery_rank : 0; const wallet = (prog && prog.wallet) || { balance: 0, lifetime_db: 0 }; const pathChips = ((prog && prog.paths) || []).map((path) => '' + esc(path.name) + ' Lv ' + path.level + '').join(' '); root.innerHTML = '
' + // Header card '
' + '' + avatarImg(_profile && _profile.avatar_url, 'w-24 h-24') + '' + '
' + '

' + esc(name) + '

' + '
' + 'Mastery Rank ' + rank + '' + '' + Number(wallet.balance).toLocaleString() + ' dB' + '' + Number(wallet.lifetime_db).toLocaleString() + ' dB lifetime' + '🔥 ' + (p.current_streak || 0) + '-day streak' + 'Best: ' + (p.best_streak || 0) + '
' + (pathChips ? '
' + pathChips + '
' : '') + '
' + '' + '' + '
' + // Per-song bests (filled by prompt 14's song_stats; placeholder here) '
' + '

Your best scores

' + '

Play a song to start tracking your accuracy and best scores.

' + '
' + (_profile && _profile.player_hash ? '

player id ' + esc(_profile.player_hash.slice(0, 12)) + '

' : '') + '
'; const edit = root.querySelector('[data-v3-edit-profile]'); if (edit) edit.addEventListener('click', () => show(_profile, { editing: true })); const openProgress = root.querySelector('[data-v3-open-progress]'); if (openProgress) openProgress.addEventListener('click', () => window.showScreen && window.showScreen('v3-progress')); if (window.v3Theme && typeof window.v3Theme.applyFrame === 'function') { window.v3Theme.applyFrame(root.querySelector('[data-v3-avatar-frame]')); } } // ── First-run onboarding (and edit) overlay ─────────────────────────────── // First-run is a 3-step flow (spec 010): 1) name + avatar, 2) pick one or // more instrument paths, 3) the calibration challenge offer (play the // diagnostic sloppak at 100% — or skip and reach Mastery Rank 1 anyway). // The profile POST always lands before the step-3 choice so onboarded=1 is // never blocked by the calibration decision. Editing keeps the single form. function show(profile, opts) { opts = opts || {}; const editing = !!opts.editing; document.getElementById('v3-onboarding')?.remove(); const stepDots = editing ? '' : '
' + [1, 2, 3].map((n) => '').join('') + '
'; const overlay = document.createElement('div'); overlay.id = 'v3-onboarding'; overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; overlay.innerHTML = '
' + '
' + '
' + (window.fbBrand ? window.fbBrand.wordmarkHTML({ size: 'text-2xl' }) : 'fee[dB]ack') + '
' + '

' + (editing ? 'Edit your player profile' : 'Set up your player profile') + '

' + stepDots + '
' + // Step 1 — name + avatar (the original form, unchanged DOM). '
' + '
' + '
' + '
' + '
' + '
' + '' + '' + '
' + // Step 2 — instrument paths (first-run only; tiles filled on entry). '' + // Step 3 — calibration offer (first-run only). '' + '' + '
' + (editing ? '' : '') + '' + '
'; document.body.appendChild(overlay); const nameEl = overlay.querySelector('#v3-ob-name'); const grid = overlay.querySelector('#v3-ob-avatars'); const submit = overlay.querySelector('#v3-ob-submit'); const errEl = overlay.querySelector('#v3-ob-error'); const fileEl = overlay.querySelector('#v3-ob-upload'); const preview = overlay.querySelector('#v3-ob-preview'); const skipBtn = overlay.querySelector('#v3-ob-skip'); let selected = null; // { type:'default', value } | { type:'upload', value:url } let step = 1; // first-run wizard step (editing stays on 1) let selectedPaths = []; // step-2 picks let pathsAvailable = false; // any tiles rendered? (false → don't strand the user) let diagnosticFilename = null; // from /api/progression (step-3 "Play it now") if (editing && profile) { nameEl.value = profile.display_name || ''; } function refreshSubmit() { if (editing || step === 1) { // First-run onboarding requires picking an avatar. When editing an // existing profile, a name-only change is allowed: leaving `selected` // null omits `avatar` from the POST, and the server keeps the current // one — including a custom upload that isn't in the bundled grid (so // Save no longer stays disabled for those). const haveAvatar = !!selected || (editing && profile && !!profile.avatar_url); submit.disabled = !(nameEl.value.trim().length >= 1 && haveAvatar); } else if (step === 2) { // ≥1 path required — unless none could be offered (offline / // empty content), where blocking would strand onboarding. submit.disabled = pathsAvailable && selectedPaths.length < 1; } else { submit.disabled = false; } } function setStep(n) { step = n; errEl.classList.add('hidden'); for (let i = 1; i <= 3; i++) { overlay.querySelector('#v3-ob-step' + i).classList.toggle('hidden', i !== n); } overlay.querySelectorAll('#v3-ob-dots [data-dot]').forEach((d) => { d.classList.toggle('bg-fb-primary', Number(d.getAttribute('data-dot')) <= n); d.classList.toggle('bg-fb-border', Number(d.getAttribute('data-dot')) > n); }); const subtitle = overlay.querySelector('#v3-ob-subtitle'); if (subtitle) { subtitle.textContent = n === 1 ? 'Set up your player profile' : n === 2 ? 'Choose your instrument paths' : 'One last thing — calibrate your setup'; } submit.textContent = n === 3 ? 'Play it now' : 'Next'; skipBtn.classList.toggle('hidden', n !== 3); refreshSubmit(); } async function loadPathTiles() { const host = overlay.querySelector('#v3-ob-paths'); let available = []; try { const r = await fetch('/api/progression'); if (r.ok) { const data = await r.json(); diagnosticFilename = (data.onboarding || {}).diagnostic_filename || null; available = (data.available_paths || []).concat( (data.paths || []).map((p) => ({ id: p.id, name: p.name }))); } } catch (e) { /* offline — leave empty, Next stays disabled */ } pathsAvailable = available.length > 0; host.innerHTML = available.map((p) => '').join('') || '

Couldn’t load instrument paths — you can pick them later on the Progress screen.

'; refreshSubmit(); host.querySelectorAll('[data-ob-path]').forEach((b) => { b.addEventListener('click', () => { const id = b.getAttribute('data-ob-path'); const idx = selectedPaths.indexOf(id); if (idx >= 0) selectedPaths.splice(idx, 1); else selectedPaths.push(id); b.classList.toggle('ring-2', idx < 0); b.classList.toggle('ring-fb-primary', idx < 0); refreshSubmit(); }); }); } nameEl.addEventListener('input', refreshSubmit); // Reflect the pre-filled name + existing avatar immediately so an edit // with no changes can still Save (button doesn't stay disabled until // the user touches the form). refreshSubmit(); function selectTile(el, choice) { selected = choice; grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary')); if (el) el.classList.add('ring-2', 'ring-fb-primary'); refreshSubmit(); } // Load bundled defaults. The default avatar's stored value is its // bundled FILENAME (e.g. "pick.svg"), which the server validates // against the bundled set. fetch('/api/profile/avatars').then((r) => r.ok ? r.json() : []).then((list) => { grid.innerHTML = (list || []).map((a) => '').join(''); grid.querySelectorAll('[data-av]').forEach((b) => { b.addEventListener('click', () => selectTile(b, { type: 'default', value: b.dataset.name })); }); // Pre-select the current avatar when editing. if (editing && profile && profile.avatar_url) { const match = Array.from(grid.querySelectorAll('[data-av]')).find((b) => b.dataset.url === profile.avatar_url); if (match) selectTile(match, { type: 'default', value: match.dataset.name }); } }).catch(() => { /* offline / server restart — leave the avatar grid empty rather than throw */ }); // Upload handler — base64 to /api/profile/avatar (mirrors art upload). overlay.querySelector('#v3-ob-upload-btn').addEventListener('click', () => fileEl.click()); fileEl.addEventListener('change', () => { const f = fileEl.files && fileEl.files[0]; if (!f) return; if (f.size > 6 * 1024 * 1024) { showErr('Image too large (max 6 MB).'); return; } const reader = new FileReader(); reader.onload = async () => { try { const res = await fetch('/api/profile/avatar', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: reader.result }), }); const body = await res.json(); if (!res.ok) { showErr(body.error || 'Upload failed.'); return; } preview.innerHTML = ''; grid.querySelectorAll('[data-av]').forEach((t) => t.classList.remove('ring-2', 'ring-fb-primary')); selected = { type: 'upload', value: body.url }; refreshSubmit(); } catch (e) { showErr('Upload failed.'); } }; reader.readAsDataURL(f); }); function showErr(msg) { errEl.textContent = msg; errEl.classList.remove('hidden'); } if (editing) overlay.querySelector('#v3-ob-cancel')?.addEventListener('click', () => overlay.remove()); async function postProfile() { const res = await fetch('/api/profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: nameEl.value.trim(), avatar: selected || undefined }), }); const body = await res.json(); if (!res.ok) throw new Error(body.error || 'Could not save profile.'); return body; } async function finish() { overlay.remove(); await fetchProgress(); if (window.v3Progression && typeof window.v3Progression.refresh === 'function') { await window.v3Progression.refresh(); } renderBadge(); renderProfileScreen(); if (window.slopsmith && window.slopsmith.emit) window.slopsmith.emit('v3:profile-updated', _profile); } submit.addEventListener('click', async () => { errEl.classList.add('hidden'); if (editing) { submit.disabled = true; try { _profile = await postProfile(); await finish(); } catch (e) { showErr(e.message || 'Could not save profile.'); submit.disabled = false; } return; } if (step === 1) { setStep(2); loadPathTiles(); return; } if (step === 2) { // Create the profile (onboarded=1) BEFORE the calibration choice // so closing the overlay at step 3 can never lose the profile. submit.disabled = true; try { _profile = await postProfile(); if (selectedPaths.length) { // A failed path save must NOT advance — step 3's skip // requires ≥1 selected path (spec invariant) and would // otherwise leave a pathless rank-1 profile. const res = await fetch('/api/progression/paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ add: selectedPaths }), }); if (!res.ok) { let msg = 'Could not save your instrument paths — try again.'; try { msg = (await res.json()).error || msg; } catch (e) { /* keep default */ } throw new Error(msg); } } setStep(3); } catch (e) { showErr(e.message || 'Could not save profile.'); refreshSubmit(); } return; } // Step 3 — "Play it now": leave calibration pending (it completes // through the normal scored-stats path) and launch the diagnostic. const target = diagnosticFilename; await finish(); if (target && typeof window.playSong === 'function') window.playSong(target); }); skipBtn.addEventListener('click', async () => { // Step 3 — skip: Mastery Rank 1 immediately, calibration stays // replayable from the Progress screen. skipBtn.disabled = true; try { const res = await fetch('/api/progression/onboarding', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'skip' }), }); if (!res.ok) { skipBtn.disabled = false; return; } } catch (e) { /* offline — skippable later from Progress */ } await finish(); }); if (!editing) setStep(1); setTimeout(() => nameEl.focus(), 50); } window.v3Onboarding = { show: show }; window.v3Profile = { refresh: async function () { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); }, get: () => _profile, }; async function boot() { await fetchProfile(); await fetchProgress(); renderBadge(); renderProfileScreen(); // Rank / dB / frames re-render whenever progression state or equipped // cosmetics move (progression-core.js / theme-core.js own the fetches). if (window.slopsmith && typeof window.slopsmith.on === 'function') { window.slopsmith.on('progression:updated', () => { renderBadge(); renderProfileScreen(); }); window.slopsmith.on('v3:cosmetics-applied', () => { renderBadge(); renderProfileScreen(); }); } } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot, { once: true }); } else { boot(); } })();