/*
* 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).
'
' +
'
' +
'
Each path levels up by completing challenges — together they make up your Mastery Rank. You can add more later.
' +
'
' +
// Step 3 — calibration offer (first-run only).
'
' +
'
' +
'
Prove your setup: play the Slopsmith Diagnostic with note detection and finish at 100% accuracy to reach Mastery Rank 1.
' +
'
Not ready? Skip it and you’ll start at Rank 1 anyway — you can still play it later from the Progress screen.
' +
'
' +
'
' +
(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();
}
})();