← back to Whatsmystyle
public/js/app.js
2206 lines
// WhatsMyStyle frontend
// Single-page app with large-pill onboarding + optometrist duel loop.
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
function show(id) {
$$('.screen').forEach(s => s.hidden = true);
const el = document.getElementById(id);
if (el) el.hidden = false;
}
function toggleMenu() {
const m = $('#menu');
m.hidden = !m.hidden;
const btn = $('.hamburger');
btn.setAttribute('aria-expanded', String(!m.hidden));
if (!m.hidden) m.querySelector('a')?.focus();
}
// click outside / escape closes the menu
document.addEventListener('click', e => {
const m = $('#menu');
if (!m.hidden && !m.contains(e.target) && !e.target.closest('.hamburger')) m.hidden = true;
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('#menu').hidden) {
$('#menu').hidden = true;
$('.hamburger').focus();
}
});
async function forgetMe() {
if (!confirm('This deletes your account, your twin, every photo, every render, every duel. There is no undo. Continue?')) return;
const r = await fetch('/api/me/forget', { method: 'POST' });
if (r.ok) {
alert('Gone. Refreshing.');
location.reload();
} else {
alert('Could not delete — try again or contact us.');
}
}
window.forgetMe = forgetMe;
$$('.menu a').forEach(a => a.addEventListener('click', e => {
// Admin-config is a top-level page, not a screen — let the browser handle it.
if (a.getAttribute('href') && a.getAttribute('href').startsWith('/')) return;
e.preventDefault();
const id = a.dataset.screen;
$('#menu').hidden = true;
route(id);
}));
// ---------- Three-tier role layer (admin / public / set_decorator) ---------
// Loaded once at boot, refreshed when admin flips view_as. Drives:
// - role banner above the topbar (preview indicator + exit pill)
// - menu item visibility via data-role-only=<role>
// - per-screen feature gates (set_decorator unlocks /productions)
const Role = {
role: 'public',
view_as: null,
effective: 'public',
is_admin: false,
couples_pilot: false,
async refresh() {
try {
const r = await fetch('/api/me');
const me = await r.json();
this.role = me.role || 'public';
this.view_as = me.view_as || null;
this.effective = me.effective_role || this.role;
this.is_admin = !!me.is_admin;
// Read couples_pilot flag from admin config when available (admin only).
if (this.is_admin) {
try {
const cfg = await (await fetch('/api/admin/config')).json();
this.couples_pilot = !!cfg.config?.couples_pilot;
} catch {}
} else {
// Non-admins can't read the flag; rely on backend 503 if they try the
// couple endpoints. Hide the menu item conservatively.
this.couples_pilot = false;
}
this.applyToDom();
// Populate any production-picker selects for set-decorator role.
if (this.effective === 'set_decorator' || this.role === 'set_decorator') {
populateProductionSelects();
}
} catch {}
},
applyToDom() {
// Menu items can be role-gated. data-role-only="admin" shows only when
// user_role is admin. data-role-only="set_decorator" shows when effective
// role is set_decorator OR user_role is set_decorator. data-role-only=
// "couples_pilot" is feature-flag-gated (Role.couples_pilot, refreshed
// from /api/admin/config).
const showFor = (need) => {
if (need === 'admin') return this.is_admin;
if (need === 'set_decorator') return this.effective === 'set_decorator' || this.role === 'set_decorator';
if (need === 'couples_pilot') return !!this.couples_pilot;
return true;
};
$$('.menu a[data-role-only]').forEach(a => {
a.style.display = showFor(a.dataset.roleOnly) ? '' : 'none';
});
// Anything with [data-role-only] outside the menu (Tailor panel, set-
// grade toggle, closet production picker) toggles via the `hidden`
// attribute so layout collapses.
$$('[data-role-only]').forEach(el => {
if (el.matches('.menu a')) return;
el.hidden = !showFor(el.dataset.roleOnly);
});
// Banner: only show when an admin is previewing as something else.
const banner = $('#role-banner');
if (this.is_admin && this.view_as) {
banner.hidden = false;
banner.innerHTML = `
<span class="role-banner-label">Previewing as <strong>${this.view_as === 'public' ? 'public consumer' : 'set decorator'}</strong></span>
<button class="role-banner-exit" type="button">Exit preview →</button>
`;
banner.querySelector('.role-banner-exit').onclick = async () => {
await fetch('/api/admin/view-as', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ view_as: null }) });
await Role.refresh();
};
} else {
banner.hidden = true;
}
},
};
window.Role = Role;
// ---- Floating embed-progress chip (tick 20, admin only) ------------------
// Lives in the lower-right corner across every screen. Polls catalog status
// every 30s. Hides when admin is previewing-as a non-admin role OR when the
// catalog is fully embedded. Surfaces Mac1 state so Steve sees why the
// drainer might be paused.
async function refreshEmbedChip() {
const chip = $('#embed-chip');
if (!chip) return;
// Visibility — admin role only AND there's work to show.
if (!Role.is_admin || Role.view_as) { chip.hidden = true; return; }
try {
const r = await fetch('/api/admin/catalog-status');
if (!r.ok) { chip.hidden = true; return; }
const j = await r.json();
if (!j.total || j.unembedded === 0) { chip.hidden = true; return; }
chip.hidden = false;
const pct = Math.round((j.embedded / j.total) * 100);
$('#embed-chip-pct').textContent = pct;
$('#embed-chip-left').textContent = j.unembedded;
$('#embed-chip-fill').style.width = pct + '%';
// Mac1 status pill
const state = j.mac1?.state || 'unknown';
const label = ({
idle: 'Mac1 idle',
busy: 'Mac1 busy',
unreachable: 'Mac1 down',
unknown: 'Mac1 ?',
})[state];
const dot = $('#embed-chip-dot');
const mac1 = $('#embed-chip-mac1');
mac1.textContent = label;
mac1.title = j.mac1?.detail || '';
dot.className = 'embed-chip-dot embed-chip-dot-' + state;
// If drainer is OFF, show a small "paused" hint.
if (!j.drainer_enabled) {
mac1.textContent = label + ' · paused';
}
// Tick 22: paid-API budget row. Color-code on burn percentage.
if (j.budget) {
const spent = (j.budget.month_cents / 100);
const cap = (j.budget.budget_cents / 100);
const pct = cap > 0 ? (spent / cap) : 0;
$('#embed-chip-spent').textContent = '$' + spent.toFixed(2);
$('#embed-chip-budget-cap').textContent = cap.toFixed(2);
const budgetEl = $('#embed-chip-budget');
budgetEl.classList.toggle('budget-warn', pct >= 0.75 && pct < 1);
budgetEl.classList.toggle('budget-over', pct >= 1);
const top = j.budget.by_provider?.[0];
$('#embed-chip-burn-by').textContent = top ? `${top.provider} $${(top.c/100).toFixed(2)}` : 'no burn yet';
}
} catch {
chip.hidden = true;
}
}
setInterval(refreshEmbedChip, 30 * 1000);
// First refresh after Role boots — small delay so role data is loaded.
setTimeout(refreshEmbedChip, 1500);
// Populate every <select> that's a production picker (closet upload, try-on
// submit, header active-production bar). Idempotent — wipes & re-fills.
// Reads / writes localStorage 'wms.activeProduction' so the choice persists
// across pages and is auto-applied to all writes.
async function populateProductionSelects() {
const selects = $$('select[name="production_id"]');
if (!selects.length) return;
let opts = '<option value="">— untagged —</option>';
try {
const r = await fetch('/api/productions');
if (r.ok) {
const { productions } = await r.json();
opts += productions.map(p =>
`<option value="${p.id}">${(p.title || '').replace(/[<>&"']/g, c => ({'<':'<','>':'>','&':'&','"':'"',"'":'''}[c]))}${p.year ? ' (' + p.year + ')' : ''}</option>`
).join('');
}
} catch {}
const active = localStorage.getItem('wms.activeProduction') || '';
selects.forEach(s => {
s.innerHTML = opts;
s.value = active;
});
}
// Active-production helper — single source of truth for tryOnItem + any form
// that wants the current set-decorator project context.
function getActiveProductionId() {
return localStorage.getItem('wms.activeProduction') || '';
}
// The header bar select drives the global. Other selects mirror via the
// `wms.activeProduction` localStorage value on populate().
document.addEventListener('change', e => {
if (e.target.id !== 'active-prod-select') return;
const v = e.target.value || '';
if (v) localStorage.setItem('wms.activeProduction', v);
else localStorage.removeItem('wms.activeProduction');
// Mirror to any other production-picker selects on the page.
$$('select[name="production_id"]').forEach(s => { if (s !== e.target) s.value = v; });
});
// ---- Set-grade only toggle (tick 16) — set-decorator role ----
$('#set-grade-only')?.addEventListener('change', e => {
localStorage.setItem('wms.proOnly', e.target.checked ? '1' : '0');
loadRecs();
});
// ---- Favorites + brand page + delta toast (tick 24) ---------------------
const Favs = { set: new Set() }; // 'catalog:42', 'closet:7'
async function refreshFavorites() {
try {
const r = await fetch('/api/favorites');
if (r.ok) {
const j = await r.json();
Favs.set = new Set(j.items.map(i => `${i.kind}:${i.item_id}`));
}
} catch {}
}
function isFav(kind, id) { return Favs.set.has(`${kind}:${id}`); }
async function toggleFav(kind, id, btn) {
const key = `${kind}:${id}`;
const wasFav = Favs.set.has(key);
// Optimistic toggle + animation
if (wasFav) {
Favs.set.delete(key);
await fetch(`/api/favorites/${kind}/${id}`, { method: 'DELETE' });
} else {
Favs.set.add(key);
await fetch('/api/favorites', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind, id }) });
btn?.classList.add('star-pop');
setTimeout(() => btn?.classList.remove('star-pop'), 400);
}
if (btn) {
btn.classList.toggle('is-fav', !wasFav);
btn.textContent = !wasFav ? '★' : '☆';
}
}
async function loadFavorites() {
await refreshFavorites();
const r = await fetch('/api/favorites');
const j = r.ok ? await r.json() : { items: [] };
const g = $('#favorites-grid');
if (!j.items.length) {
g.innerHTML = '<p class="muted">No favorites yet. Tap the star on any item card to save it here.</p>';
return;
}
g.innerHTML = j.items.map(it => `
<div class="card">
<img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
<button type="button" class="star-btn is-fav" data-fav-kind="${it.kind}" data-fav-id="${it.item_id}" aria-label="Unfavorite">★</button>
<div class="meta">
<div class="title">${escapeHtml(it.title || it.category || 'piece')}</div>
<div class="brand">${escapeHtml(it.brand || '')}</div>
${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
</div>
</div>
`).join('');
}
// Brand page
async function openBrand(name) {
show('brand');
$('#brand-title').textContent = 'Loading…';
$('#brand-meta').textContent = '';
$('#brand-bio').textContent = '';
$('#brand-grid').innerHTML = '<p class="muted">Loading…</p>';
const r = await fetch(`/api/brand/${encodeURIComponent(name)}`);
if (!r.ok) { $('#brand-grid').innerHTML = '<p class="muted">Brand not found.</p>'; return; }
const j = await r.json();
$('#brand-title').textContent = j.brand;
$('#brand-meta').innerHTML = [
j.sustain_tier ? `<span class="sustain">♻ ${j.sustain_tier}/5</span>` : '',
`<span class="muted">${j.item_count} item${j.item_count === 1 ? '' : 's'}</span>`,
].filter(Boolean).join(' · ');
$('#brand-bio').textContent = j.bio || '';
if (!j.items.length) { $('#brand-grid').innerHTML = '<p class="muted">No items in the catalog for this brand yet.</p>'; return; }
$('#brand-grid').innerHTML = j.items.map(it => `
<a class="card" href="${escapeHtml(it.product_url || '#')}" target="_blank" rel="noopener">
<img src="${escapeHtml(it.image_url || '')}" alt="${escapeHtml(it.title || '')}" loading="lazy">
<button type="button" class="star-btn ${isFav('catalog', it.id) ? 'is-fav' : ''}" data-fav-kind="catalog" data-fav-id="${it.id}" onclick="event.preventDefault();event.stopPropagation();" aria-label="Favorite">${isFav('catalog', it.id) ? '★' : '☆'}</button>
<div class="meta">
<div class="title">${escapeHtml(it.title || '')}</div>
${it.price_cents ? `<div class="price">$${(it.price_cents/100).toFixed(0)}</div>` : ''}
</div>
</a>
`).join('');
}
document.addEventListener('click', e => {
const star = e.target.closest('.star-btn');
if (star) {
e.preventDefault(); e.stopPropagation();
toggleFav(star.dataset.favKind, star.dataset.favId, star);
return;
}
const brandLink = e.target.closest('[data-brand]');
if (brandLink) {
e.preventDefault();
openBrand(brandLink.dataset.brand);
return;
}
// Tick 25: Quick-look modal on tap of catalog card image (preserves the
// affiliate-out path — the outer anchor opens product_url on tap of
// anywhere ELSE in the card).
const qlOn = (localStorage.getItem('wms.quicklook') ?? '1') === '1';
if (qlOn) {
const img = e.target.closest('.card > img, .grid .card a > img');
if (img) {
const card = img.closest('.card');
const data = {
title: card?.querySelector('.title')?.textContent || '',
brand: card?.querySelector('.brand')?.textContent || card?.querySelector('[data-brand]')?.dataset.brand || '',
price: card?.querySelector('.price')?.textContent || '',
image: img.src,
href: card?.tagName === 'A' ? card.getAttribute('href') : (card?.querySelector('a')?.getAttribute('href') || '#'),
};
if (data.href && data.href !== '#') {
e.preventDefault(); e.stopPropagation();
openQuickLook(data);
return;
}
}
}
// Quick-look close
if (e.target.closest('[data-ql-close]')) closeQuickLook();
});
function openQuickLook(data) {
const modal = $('#quicklook-modal');
if (!modal) return;
$('#ql-img').src = data.image;
$('#ql-img').alt = data.title;
$('#ql-title').textContent = data.title;
$('#ql-brand-link').textContent = data.brand;
$('#ql-brand-link').dataset.brand = data.brand;
$('#ql-price').textContent = data.price;
$('#ql-out').href = data.href;
modal.hidden = false;
document.body.style.overflow = 'hidden';
}
function closeQuickLook() {
const modal = $('#quicklook-modal');
if (!modal) return;
modal.hidden = true;
document.body.style.overflow = '';
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('#quicklook-modal')?.hidden) closeQuickLook();
});
$('#brand-back')?.addEventListener('click', e => { e.preventDefault(); route('recs'); });
// Share favorites (tick 25)
$('#fav-share-btn')?.addEventListener('click', async () => {
const r = await fetch('/api/favorites/share', { method: 'POST' });
const j = await r.json();
const url = `${location.origin}${j.url}`;
try { await navigator.clipboard.writeText(url); $('#fav-share-out').textContent = 'Copied: ' + url; }
catch { $('#fav-share-out').innerHTML = `Share URL: <a href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`; }
});
// Delta toast (tick 24) — show top-3 taste movers after a duel pick
function showDeltaToast(deltas) {
if (!deltas?.length) return;
const el = $('#delta-toast');
if (!el) return;
el.innerHTML = deltas.map(d => {
const sign = d.delta >= 0 ? '+' : '−';
const cls = d.delta >= 0 ? 'delta-up' : 'delta-down';
return `<span class="${cls}">${sign}${Math.abs(d.delta).toFixed(3)} ${d.dim}</span>`;
}).join(' ');
el.hidden = false;
el.classList.remove('toast-hide');
clearTimeout(showDeltaToast._t);
showDeltaToast._t = setTimeout(() => {
el.classList.add('toast-hide');
setTimeout(() => { el.hidden = true; }, 400);
}, 3000);
}
window.showDeltaToast = showDeltaToast;
// ---- Facet pills (tick 23) — toggle color/material filter on click -------
document.addEventListener('click', e => {
const pill = e.target.closest('.facet-pill');
if (!pill) return;
const kind = pill.dataset.facetKind;
const value = pill.dataset.facetValue;
const key = `wms.facets.${kind}s`; // colors/materials
const current = JSON.parse(localStorage.getItem(key) || '[]');
const idx = current.indexOf(value);
if (idx >= 0) current.splice(idx, 1); else current.push(value);
localStorage.setItem(key, JSON.stringify(current));
loadRecs();
});
// ---- Couple lifecycle UI (tick 16, flag-gated) ----
async function coupleInvite() {
const out = $('#couple-invite-out');
out.hidden = false; out.textContent = 'Generating…';
const r = await fetch('/api/couple/invite', { method: 'POST', headers: { 'content-type': 'application/json' } });
const j = await r.json();
if (!r.ok) { out.textContent = j.reason || j.error || 'Failed'; return; }
out.innerHTML = `Token: <code>${escapeHtml(j.invite_token || '(reused)')}</code> <button type="button" class="ghost" id="copy-tok">Copy</button>`;
$('#copy-tok')?.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(j.invite_token); $('#copy-tok').textContent = 'Copied'; }
catch { $('#copy-tok').textContent = 'Copy failed'; }
});
}
async function coupleAccept(e) {
e.preventDefault();
const t = $('#couple-token-input').value.trim();
if (!t) return;
const r = await fetch(`/api/couple/accept/${encodeURIComponent(t)}`, { method: 'POST' });
const j = await r.json();
const out = $('#couple-accept-out');
if (r.ok) { out.textContent = `Accepted. Couple #${j.couple_id} is now active.`; out.style.color = '#14572a'; $('#couple-token-input').value = ''; }
else { out.textContent = j.error || j.reason || 'Failed'; out.style.color = '#7a1717'; }
}
async function coupleExit() {
if (!confirm('Exit your couple? This purges the cross-twin training join. Your individual closet stays intact.')) return;
const r = await fetch('/api/couple/exit', { method: 'POST' });
const j = await r.json();
const out = $('#couple-exit-out');
if (r.ok) { out.textContent = `Exited couple #${j.couple_id}.`; out.style.color = '#14572a'; }
else { out.textContent = j.error || j.reason || 'Failed'; out.style.color = '#7a1717'; }
}
$('#couple-invite-btn')?.addEventListener('click', coupleInvite);
$('#couple-accept-form')?.addEventListener('submit', coupleAccept);
$('#couple-exit-btn')?.addEventListener('click', coupleExit);
// ---------- Productions (set-decorator only) -------------------------------
async function loadProductions() {
const r = await fetch('/api/productions');
const { productions } = await r.json();
const list = $('#productions-list');
if (!productions.length) {
list.innerHTML = `<p class="muted">No productions yet. Add one above. Once you have closet pieces or try-ons tagged with a production, they'll show up here with credit counts.</p>`;
return;
}
list.innerHTML = productions.map(p => `
<article class="production-row production-clickable" data-production-id="${p.id}" tabindex="0" role="button" aria-label="Open credits for ${escapeHtml(p.title)}">
<h3 class="production-title">${escapeHtml(p.title)}${p.year ? ` <span class="muted">(${p.year})</span>` : ''}</h3>
<div class="production-meta">
<span>${escapeHtml(p.kind || 'tv-episode')}</span>
<span>${escapeHtml(p.role || 'Set Decoration Buyer')}</span>
<span><strong>${p.total_credits}</strong> credits</span>
<span class="muted">${p.closet_count} closet · ${p.tryon_count} try-on</span>
<span class="production-cta">view résumé →</span>
</div>
</article>
`).join('');
}
async function openProductionCredits(productionId) {
show('credits');
$('#credits-title').textContent = 'Loading résumé…';
$('#credits-brand').textContent = '';
$('#credits-count').textContent = '0';
$('#credits-list').innerHTML = '<p class="muted">Loading…</p>';
try {
const r = await fetch(`/api/production-credits/${encodeURIComponent(productionId)}`);
if (!r.ok) { $('#credits-list').innerHTML = `<p class="muted">Could not load (${r.status}).</p>`; return; }
const data = await r.json();
const p = data.production;
$('#credits-title').textContent = p.title + (p.year ? ` (${p.year})` : '');
$('#credits-brand').textContent = `${p.kind || 'tv-episode'} · ${p.role || 'Set Decoration Buyer'}`;
$('#credits-count').textContent = data.total;
if (!data.credits.length) {
$('#credits-list').innerHTML = `<p class="muted">No closet pieces or try-ons tagged to this production yet. Open any closet item or try-on, tap "tag with production", and pick this one.</p>`;
return;
}
$('#credits-list').innerHTML = data.credits.map(c => `
<article class="credit-row">
<h3 class="credit-role">${escapeHtml(c.role)}</h3>
<h4 class="credit-title">
${c.occasion ? `<span class="credit-occasion">${escapeHtml(c.occasion)}</span>` : '<span class="credit-occasion muted">no occasion tag</span>'}
<span class="credit-year">${fmtCreditDate(c.date)}</span>
</h4>
<div class="credit-meta">
<span class="credit-type">${escapeHtml(c.type)}</span>
${c.provider ? `<span class="credit-provider">via ${escapeHtml(c.provider)}</span>` : ''}
${c.status ? `<span class="credit-status credit-status-${escapeHtml(c.status)}">${escapeHtml(c.status)}</span>` : ''}
${c.brand ? `<span>${escapeHtml(c.brand)}</span>` : ''}
</div>
${c.with && c.with.length
? `<div class="credit-with"><span class="muted">With:</span> ${c.with.map(w => `<span>${escapeHtml(w)}</span>`).join(' · ')}</div>`
: ''}
</article>
`).join('');
} catch (e) {
$('#credits-list').innerHTML = `<p class="muted">Could not load: ${escapeHtml(e.message)}</p>`;
}
}
document.addEventListener('click', e => {
const card = e.target.closest('.production-clickable');
if (card) {
e.preventDefault();
openProductionCredits(card.dataset.productionId);
}
});
document.addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const card = e.target.closest('.production-clickable');
if (card) { e.preventDefault(); openProductionCredits(card.dataset.productionId); }
});
document.addEventListener('submit', async e => {
if (e.target.id !== 'production-add') return;
e.preventDefault();
const body = {
title: $('#prod-title').value.trim(),
year: $('#prod-year').value ? Number($('#prod-year').value) : null,
kind: $('#prod-kind').value,
role: $('#prod-role').value,
};
if (!body.title) return;
const r = await fetch('/api/productions', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (r.ok) {
$('#prod-title').value = ''; $('#prod-year').value = '';
loadProductions();
} else {
alert('Could not add — try again.');
}
});
// ---------- Onboarding -----------------------------------------------------
// Per the 2026-05-12 onboarding-length debate (verdict: modify) — mandatory
// = body-shape, budget, geolocation. Other 8 are optional (skippable).
const onbQuestions = [
{ key: 'shopping_for', optional: true, q: 'Who are you shopping for?',
pills: [['Myself','self'],['Someone else','other'],['Both','both']] },
{ key: 'gender_expr', optional: true, q: 'What section of the store feels right?',
pills: [["Women's",'women'],["Men's",'men'],['Unisex','unisex'],['Fluid — show me everything','fluid']] },
{ key: 'height_cm', optional: true, q: 'How tall are you?', kind: 'slider',
min: 140, max: 210, default: 170, unit: 'cm' },
{ key: 'body_shape', q: 'Which silhouette feels closest?',
pills: [
['Rectangle','rectangle'],['Hourglass','hourglass'],['Pear','pear'],
['Apple','apple'],['Inverted triangle','inv-triangle'],['Athletic','athletic'],
['Skip — I find this question annoying','skip']
]},
{ key: 'hair', optional: true, q: 'Your hair, right now?',
pills: [
['Black, short','black-short'],['Black, long','black-long'],
['Brown, short','brown-short'],['Brown, long','brown-long'],
['Blonde, short','blonde-short'],['Blonde, long','blonde-long'],
['Red','red'],['Gray / silver','gray'],
['Shaved / very short','shaved'],['Other','other'],
]},
{ key: 'age_band', optional: true, q: 'Age band — for fit + cut only, never displayed.',
pills: [['Under 25','u25'],['25–34','25-34'],['35–44','35-44'],['45–54','45-54'],['55–64','55-64'],['65+','65p']] },
{ key: 'undertone', optional: true, q: 'Your skin undertone?',
pills: [
['Warm — gold jewelry suits me','warm'],
['Cool — silver jewelry suits me','cool'],
['Neutral — both work','neutral'],
['No idea — show me both','unknown'],
]},
{ key: 'city', q: 'Where are you shopping from?', kind: 'city' },
{ key: 'budget_band', q: 'Comfortable price per piece?',
pills: [
['Under $20','u20'],['$20–50','20-50'],['$50–150','50-150'],
['$150–500','150-500'],['$500–1500','500-1500'],['$1500+','1500p'],
['No cap','nocap'],
]},
{ key: 'sustainability', optional: true, q: 'How much should sustainability matter?',
pills: [['Top priority','top'],['Nice to have','nice'],["Doesn't matter to me",'no']] },
{ key: '_connect', optional: true, q: 'Want me to learn faster? (You can skip any of these.)', kind: 'connect' },
];
let onbI = 0;
const onbAnswers = {};
function onbRender() {
const total = onbQuestions.length;
$('#onb-bar').style.width = `${Math.round((onbI / total) * 100)}%`;
const q = onbQuestions[onbI];
if (!q) return finishOnboarding();
// Mark optional questions with a hint so the user knows they can skip
$('#onb-q').textContent = q.q + (q.optional ? '' : '');
const opt = q.optional ? ' (optional)' : ' (we need this to dress you well)';
// Hint shown to the side of the Skip button
const skipBtn = document.querySelector('.onb-foot .ghost:nth-child(2)');
if (skipBtn) skipBtn.textContent = q.optional ? 'Skip' : "Can't skip — required";
if (skipBtn) skipBtn.disabled = !q.optional;
const pills = $('#onb-pills');
pills.innerHTML = '';
if (q.pills) {
q.pills.forEach(([label, val]) => {
const b = document.createElement('button');
b.className = 'pill';
b.textContent = label;
b.onclick = () => { onbAnswers[q.key] = val; onbNext(); };
pills.appendChild(b);
});
} else if (q.kind === 'slider') {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:14px;align-items:center;width:100%;';
const out = document.createElement('div');
out.style.cssText = 'font-family: var(--font-display); font-size:42px; min-width:120px;';
const r = document.createElement('input');
r.type = 'range'; r.min = q.min; r.max = q.max; r.value = q.default;
r.style.cssText = 'flex:1;height:6px;';
out.textContent = `${q.default} ${q.unit}`;
r.oninput = () => { out.textContent = `${r.value} ${q.unit}`; };
const ok = document.createElement('button');
ok.className = 'pill'; ok.textContent = "That's me →";
ok.onclick = () => { onbAnswers[q.key] = Number(r.value); onbNext(); };
wrap.append(out, r);
pills.append(wrap, ok);
} else if (q.kind === 'city') {
const wrap = document.createElement('div');
wrap.style.cssText = 'display:flex;gap:10px;flex-direction:column;width:100%;';
const inp = document.createElement('input');
inp.placeholder = 'City, State';
inp.style.cssText = 'padding:18px;border:1.5px solid var(--ink);border-radius:14px;font-size:18px;';
const auto = document.createElement('button');
auto.className = 'pill';
auto.textContent = '📍 Use my location';
auto.onclick = () => {
navigator.geolocation?.getCurrentPosition(p => {
onbAnswers.lat = p.coords.latitude;
onbAnswers.lng = p.coords.longitude;
inp.value = `(${p.coords.latitude.toFixed(2)}, ${p.coords.longitude.toFixed(2)})`;
}, () => alert('Location denied. You can type a city instead.'));
};
const ok = document.createElement('button');
ok.className = 'pill';
ok.textContent = 'Continue →';
ok.onclick = () => { onbAnswers.city = inp.value || null; onbNext(); };
wrap.append(auto, inp, ok);
pills.append(wrap);
} else if (q.kind === 'connect') {
const kinds = [
['Stripe — bank receipts (read-only)', 'stripe_fc'],
['Gmail — order confirmations (read-only)', 'gmail'],
['Camera roll — selected albums only', 'camera_roll'],
['Social photos — manual upload', 'social'],
['Location — for Stores Near Me', 'location'],
];
kinds.forEach(([label, kind]) => {
const b = document.createElement('button');
b.className = 'pill illus';
b.innerHTML = `<span class="swatch" style="background:linear-gradient(135deg,#c0392b,#e3725b)"></span>${label}`;
b.onclick = async () => {
b.textContent = '✓ ' + label;
b.disabled = true;
await fetch(`/api/connect/${kind}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
};
pills.appendChild(b);
});
const done = document.createElement('button');
done.className = 'pill';
done.textContent = "I'm done — show me the duels →";
done.onclick = () => onbNext();
pills.appendChild(done);
}
}
function onbNext() { onbI++; onbRender(); }
function onbBack() { if (onbI > 0) { onbI--; onbRender(); } }
function onbSkip() { onbNext(); }
async function finishOnboarding() {
// map onbAnswers → user fields
const payload = {
gender_expr: onbAnswers.gender_expr,
age_band: onbAnswers.age_band,
height_cm: onbAnswers.height_cm,
body_shape: onbAnswers.body_shape,
hair: onbAnswers.hair,
undertone: onbAnswers.undertone,
city: onbAnswers.city,
lat: onbAnswers.lat,
lng: onbAnswers.lng,
budget_band: onbAnswers.budget_band,
sustainability: onbAnswers.sustainability,
};
await fetch('/api/onboard', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload) });
route('home');
}
// ---------- Duel -----------------------------------------------------------
let currentDuel = null;
let duelsAnswered = 0;
async function loadDuelHero() {
if (document.querySelector('#duel-hero .hero-cell')) return; // only once
try {
const r = await fetch('/api/recommend?limit=8').then(r => r.json());
const cells = (r.items || [])
.filter(it => (it.sustain || 0) >= 4)
.slice(0, 5)
.map(it => `<div class="hero-cell" style="background-image:url('${it.image_url}')" title="${it.brand || ''}"></div>`)
.join('');
if (cells) {
const el = document.getElementById('duel-hero');
el.innerHTML = cells;
el.hidden = false;
}
} catch {}
}
async function loadDuel() {
if (avatarReady === false) await checkAvatarReady(); // first call only
loadDuelHero();
const r = await fetch('/api/duel');
const j = await r.json();
if (j.need_seed || !j.a) {
$('#duel-q').innerHTML = 'No catalog yet — <a href="#" onclick="seedCatalog()">tap to seed sample items</a>.';
return;
}
currentDuel = j;
$('#duel-q').textContent = j.question;
paintCard('a', j.a);
paintCard('b', j.b);
// Tick 27: prefetch next pair's images so the next tap is instant.
if (j.next_pair) {
[j.next_pair.a, j.next_pair.b].forEach(it => {
if (it?.image_url) {
const img = new Image();
img.decoding = 'async';
img.referrerPolicy = 'no-referrer';
img.src = it.image_url;
}
});
}
}
let avatarReady = false;
async function checkAvatarReady() {
const r = await fetch('/api/avatar').then(r => r.json()).catch(() => ({}));
avatarReady = r.avatar?.status === 'ready';
return avatarReady;
}
function paintCard(side, item) {
const img = $(`#img-${side}`);
// Duel-on-me — if the user has a ready twin, try the rendered-on-me URL.
// The server returns the rendered image OR queues + 202s; we fall back to
// the catalog photo while the render lands.
const fallback = item.image_url || `/img/placeholder-${item.category || 'top'}.svg`;
if (avatarReady) {
img.src = `/api/duel/on-me/${item.id}`;
img.onerror = () => { img.onerror = null; img.src = fallback; };
} else {
img.src = fallback;
}
img.alt = `${item.title}${item.brand ? ' by ' + item.brand : ''}`;
$(`#t-${side}`).textContent = item.title;
const brandEl = $(`#b-${side}`);
brandEl.innerHTML = '';
if (item.brand) brandEl.appendChild(document.createTextNode(item.brand));
if (item.sustain) {
const s = document.createElement('span');
s.className = 'sustain';
s.title = `Sustainability ${item.sustain}/5`;
s.setAttribute('aria-label', `Sustainability score ${item.sustain} of 5`);
s.textContent = `♻ ${item.sustain}/5`;
brandEl.appendChild(s);
}
$(`#p-${side}`).textContent = item.price_cents ? `$${(item.price_cents/100).toFixed(0)}` : '';
}
async function pickDuel(choice) {
if (!currentDuel) return;
const r = await fetch('/api/duel', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ duel_id: currentDuel.duel_id, picked: choice })
});
// Tick 24: surface top taste-axis movers as a toast.
try { const j = await r.json(); if (j.deltas?.length) showDeltaToast(j.deltas); } catch {}
duelsAnswered++;
const pct = Math.min(100, Math.round((duelsAnswered / 30) * 100));
$('#taste-fill').style.width = pct + '%';
$('#taste-pct').textContent = pct + '%';
const live = $('#duel-live');
if (live) live.textContent = choice === 'skip' ? 'Skipped. New pair coming up.' : `Picked ${choice}. Your taste lens is ${pct}% sharper.`;
loadDuel();
}
// Tick 28: keyboard cheat sheet — show until first keyboard pick.
const KB_HINT_KEY = 'wms.duel.kb_hint_dismissed';
function showKbHintIfNew() {
const el = $('#duel-kb-hint');
if (!el) return;
if (localStorage.getItem(KB_HINT_KEY) === '1') { el.hidden = true; return; }
// Desktop only — kbd hints make no sense on touch
if (window.matchMedia && window.matchMedia('(hover: none) and (pointer: coarse)').matches) {
el.hidden = true; return;
}
el.hidden = false;
}
function fadeKbHint() {
const el = $('#duel-kb-hint');
if (!el || el.hidden) return;
localStorage.setItem(KB_HINT_KEY, '1');
el.classList.add('is-fading');
setTimeout(() => { el.hidden = true; el.classList.remove('is-fading'); }, 700);
}
window.addEventListener('keydown', e => {
if ($('#home').hidden) return;
let used = false;
if (e.key === 'ArrowLeft' || e.key.toLowerCase() === 'a') { pickDuel('A'); used = true; }
if (e.key === 'ArrowRight' || e.key.toLowerCase() === 'b') { pickDuel('B'); used = true; }
if (e.key === ' ' || e.key.toLowerCase() === 's') { e.preventDefault(); pickDuel('skip'); used = true; }
if (used) fadeKbHint();
});
async function seedCatalog() {
$('#duel-q').textContent = 'Seeding sample catalog…';
await fetch('/api/admin/seed', { method: 'POST' }).catch(() => {});
loadDuel();
}
// ---------- Closet ---------------------------------------------------------
$('#closet-form').addEventListener('submit', async e => {
e.preventDefault();
const fileInput = e.target.querySelector('input[type=file]');
const files = fileInput?.files || [];
const status = $('#closet-upload-status');
if (status) status.textContent = '';
if (files.length === 0) return;
// Tick 27: bulk path when >1 file selected.
if (files.length === 1) {
const fd = new FormData(e.target);
if (status) status.textContent = 'uploading…';
const r = await fetch('/api/closet/photo', { method: 'POST', body: fd });
if (r.ok) { if (status) status.textContent = 'done.'; loadCloset(); }
else if (status) status.textContent = 'failed.';
return;
}
// Multi: re-build FormData with photos[] field name + production_id passthrough.
const fd = new FormData();
for (const f of files) fd.append('photos', f);
const pid = $('#closet-prod-select')?.value;
if (pid) fd.append('production_id', pid);
if (status) status.textContent = `uploading ${files.length}…`;
const r = await fetch('/api/closet/photo/bulk', { method: 'POST', body: fd });
if (r.ok) {
const j = await r.json();
if (status) status.textContent = `uploaded ${j.count}. analyzing in background.`;
loadCloset();
// Tick 28: per-file progress dots — one gray dot per upload, fills green
// as each row's vision-tag (category) lands. Polls /api/closet every 5s.
if (j.closet_ids?.length) startBulkProgress(j.closet_ids);
} else if (status) status.textContent = 'bulk upload failed.';
});
// Tick 28: per-file progress dots for bulk closet uploads.
let bulkProgressTimer = null;
function startBulkProgress(ids) {
if (bulkProgressTimer) { clearInterval(bulkProgressTimer); bulkProgressTimer = null; }
if (!ids?.length) return;
let host = $('#closet-bulk-progress');
if (!host) {
host = document.createElement('div');
host.id = 'closet-bulk-progress';
host.className = 'bulk-progress';
const status = $('#closet-upload-status');
(status?.parentNode || $('#closet-form')).insertBefore(host, status?.nextSibling || null);
}
host.hidden = false;
host.classList.remove('is-dismissed');
host.innerHTML = ids.map(id => `<span class="dot" data-cid="${id}" aria-label="processing ${id}"></span>`).join('') +
`<span class="dot-progress-label" id="bulk-progress-label">0 / ${ids.length} tagged</span>` +
`<button type="button" class="bulk-progress-close" id="bulk-progress-close" aria-label="Dismiss progress strip">×</button>`;
$('#bulk-progress-close')?.addEventListener('click', () => {
if (bulkProgressTimer) { clearInterval(bulkProgressTimer); bulkProgressTimer = null; }
host.classList.add('is-dismissed');
setTimeout(() => { host.hidden = true; }, 250);
});
const idSet = new Set(ids);
async function tick() {
try {
const r = await fetch('/api/closet').then(r => r.json());
const map = new Map((r.items || []).map(i => [i.id, i]));
let tagged = 0;
ids.forEach(id => {
const it = map.get(id);
const dot = host.querySelector(`.dot[data-cid="${id}"]`);
if (!dot) return;
if (it?.category) { dot.classList.add('is-done'); tagged++; }
});
const label = $('#bulk-progress-label');
if (label) label.textContent = `${tagged} / ${ids.length} tagged`;
if (tagged >= ids.length) {
clearInterval(bulkProgressTimer); bulkProgressTimer = null;
// Auto-hide the strip 4s after fully tagged so the closet grid takes the focus.
setTimeout(() => { host.hidden = true; }, 4000);
}
} catch {}
}
tick();
bulkProgressTimer = setInterval(tick, 5000);
}
async function loadCloset() {
const r = await fetch('/api/closet').then(r => r.json());
const g = $('#closet-grid');
g.innerHTML = '';
// Tick 26: hide the "upload a photo…" empty-state and shrink the upload
// form to a compact "+ Add another" button once the closet has ≥3 items.
const count = (r.items || []).length;
const isCompact = count >= 3;
$('#closet-empty-state')?.toggleAttribute('hidden', isCompact);
$('#closet-form')?.classList.toggle('compact', isCompact);
if ($('#closet-upload-btn')) $('#closet-upload-btn').textContent = isCompact ? '+ Add another' : 'Upload';
(r.items || []).forEach(it => {
const c = document.createElement('div');
c.className = 'card';
const isPrivate = (it.privacy || 'shared') === 'private';
// Tick 19: production-credit badge — set-decorator role only, item must have ≥1 credit.
const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
const favOn = isFav('closet', it.id);
const star = `<button type="button" class="star-btn ${favOn ? 'is-fav' : ''}" data-fav-kind="closet" data-fav-id="${it.id}" aria-label="${favOn ? 'Unfavorite' : 'Favorite'}">${favOn ? '★' : '☆'}</button>`;
c.innerHTML = `
<img src="/api/closet/photo/${it.id}" alt="">
${badge}
${star}
<div class="meta">
<div class="title">${it.category || 'analyzing…'}</div>
<div class="brand">${it.color || ''}</div>
<div class="meta-row">
<a class="credits-pill" href="#" data-credits-kind="closet" data-credits-id="${it.id}">credits →</a>
<button type="button" class="privacy-pill ${isPrivate ? 'is-private' : 'is-shared'}" data-closet-id="${it.id}" data-privacy="${isPrivate ? 'private' : 'shared'}" aria-label="Toggle item privacy">${isPrivate ? '🔒 private' : '· shared'}</button>
</div>
</div>`;
g.appendChild(c);
});
}
// Closet privacy pill toggle — tick 15.8. Click flips shared ↔ private.
document.addEventListener('click', async e => {
const btn = e.target.closest('.privacy-pill');
if (!btn) return;
e.preventDefault();
const id = btn.dataset.closetId;
const next = btn.dataset.privacy === 'private' ? 'shared' : 'private';
const r = await fetch(`/api/closet/${id}/privacy`, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ privacy: next }) });
if (r.ok) {
btn.dataset.privacy = next;
btn.className = `privacy-pill ${next === 'private' ? 'is-private' : 'is-shared'}`;
btn.textContent = next === 'private' ? '🔒 private' : '· shared';
}
});
// ---------- Recommendations -----------------------------------------------
// ---- Tailor the view (tick 16) ------------------------------------------
// Four sliders re-rank the recs grid to match the show being decorated.
// Era → era hint match. Palette → saturation bias. Tone → formal/casual bias.
// Density → CSS card-min. Persisted to localStorage; only shown when
// effective_role === 'set_decorator'.
const TAILOR_DEFAULTS = { era: 1975, palette: 93, density: 277, tone: 73 };
function getTailor() {
return {
era: Number(localStorage.getItem('wms.tailor.era') || TAILOR_DEFAULTS.era),
palette: Number(localStorage.getItem('wms.tailor.palette') || TAILOR_DEFAULTS.palette),
density: Number(localStorage.getItem('wms.tailor.density') || TAILOR_DEFAULTS.density),
tone: Number(localStorage.getItem('wms.tailor.tone') || TAILOR_DEFAULTS.tone),
};
}
// Heuristic era extraction from item title/brand. Most catalog items have no
// year, so we map common era words to a representative decade.
const ERA_WORDS = [
[/\b(victorian|edwardian)\b/i, 1900],
[/\b(1920s|art deco|gatsby|jazz age)\b/i, 1925],
[/\b(1930s|depression|hollywood golden)\b/i, 1935],
[/\b(1940s|war[- ]era|forties)\b/i, 1945],
[/\b(1950s|fifties|midcentury|mid[- ]?century|atomic)\b/i, 1955],
[/\b(1960s|sixties|mod|psychedelic|space age)\b/i, 1965],
[/\b(1970s|seventies|disco|bohemian|hippie)\b/i, 1975],
[/\b(1980s|eighties|new wave|preppy revival|power)\b/i, 1985],
[/\b(1990s|nineties|grunge|minimal[- ]?ist)\b/i, 1995],
[/\b(y2k|early 2000s|2000s)\b/i, 2005],
[/\b(2010s)\b/i, 2015],
[/\b(modern|contemporary|current|today)\b/i, 2025],
[/\bvintage\b/i, 1965], // generic vintage → mid-60s
[/\bretro\b/i, 1975], // generic retro → 70s
];
function eraOf(it) {
const text = `${it.title || ''} ${it.brand || ''} ${it.tags || ''}`;
for (const [re, year] of ERA_WORDS) if (re.test(text)) return year;
return null; // unknown — neutral score
}
const PALETTE_SATURATED = /\b(red|crimson|cobalt|emerald|fuchsia|magenta|electric|neon|vibrant|saffron|chartreuse|tangerine|hot pink|royal blue)\b/i;
const PALETTE_NEUTRAL = /\b(beige|stone|ivory|cream|oat|sand|taupe|nude|mushroom|greige|bone|natural)\b/i;
const TONE_FORMAL = /\b(blazer|tuxedo|gown|silk|wool|tweed|cashmere|tailored|suit|oxford|loafer|trouser|evening|cocktail)\b/i;
const TONE_CASUAL = /\b(tee|t-shirt|hoodie|sweatpant|jean|denim|sneaker|track|jogger|cargo|baseball)\b/i;
function tailorScore(it, tailor) {
let s = 0;
// Era — primary axis. Tested 2026-05-12: with weight 1.0, saturated+formal
// items outranked era-exact matches at high palette/tone settings. Era is
// *the* signal for "what show is this", so bump to 1.5 so era-exact wins
// even when palette+tone both hit (max combined = 0.8 + 0.7 = 1.5).
const yr = eraOf(it);
if (yr !== null) {
const delta = Math.abs(yr - tailor.era);
s += Math.max(0, 1 - delta / 25) * 1.5;
}
const text = `${it.title || ''} ${it.brand || ''} ${it.color || ''} ${it.tags || ''}`;
// Palette — high palette → saturated bonus, low palette → neutral bonus
const sat = PALETTE_SATURATED.test(text) ? 1 : 0;
const neu = PALETTE_NEUTRAL.test(text) ? 1 : 0;
const palT = tailor.palette / 100; // 0..1
s += sat * palT * 0.8 + neu * (1 - palT) * 0.8;
// Tone — high tone → formal bonus, low tone → casual bonus
const fml = TONE_FORMAL.test(text) ? 1 : 0;
const csl = TONE_CASUAL.test(text) ? 1 : 0;
const tonT = tailor.tone / 100;
s += fml * tonT * 0.7 + csl * (1 - tonT) * 0.7;
return s;
}
async function loadRecs() {
const sort = localStorage.getItem('wms.sort') || 'taste';
const isSetDec = (Role.effective === 'set_decorator' || Role.role === 'set_decorator');
const tailor = getTailor();
// Density: set-decorator's Tailor slider wins; otherwise the legacy slider.
const minPx = isSetDec ? tailor.density : (Number(localStorage.getItem('wms.density')) || 220);
$('#sort').value = sort;
$('#density').value = Math.min(320, Math.max(120, minPx)); // legacy slider clamped to its range
// Mirror tailor values into the panel inputs.
if (isSetDec) {
const ids = ['era', 'palette', 'density', 'tone'];
ids.forEach(k => {
const inp = $(`#tailor-${k}`);
const out = $(`#tailor-${k}-val`);
if (inp) inp.value = tailor[k];
if (out) out.textContent = tailor[k];
});
}
// Set-grade only: persisted toggle, default ON for set-decorator role.
let proOnly = localStorage.getItem('wms.proOnly');
if (proOnly === null && isSetDec) proOnly = '1';
const proOnlyChecked = proOnly === '1';
if ($('#set-grade-only')) $('#set-grade-only').checked = proOnlyChecked;
const proQS = proOnlyChecked && isSetDec ? '&pro_only=1' : '';
// Tick 23: color + material facet filters from localStorage.
const activeColors = JSON.parse(localStorage.getItem('wms.facets.colors') || '[]');
const activeMaterials = JSON.parse(localStorage.getItem('wms.facets.materials') || '[]');
const colorQS = activeColors.length ? `&colors=${encodeURIComponent(activeColors.join(','))}` : '';
const matQS = activeMaterials.length ? `&materials=${encodeURIComponent(activeMaterials.join(','))}` : '';
// Tick 30: server-side sort for price/sustain/newest. Client-side sort kept for
// brand/title (cheap string sort on the 60-item slice) and 'taste' (default).
const SERVER_SORTS = new Set(['price_asc', 'price_desc', 'sustain', 'newest']);
const serverSort = SERVER_SORTS.has(sort) ? sort : 'best';
const sortQS = `&sort=${encodeURIComponent(serverSort)}`;
// Tick 31: brand filter from localStorage (set by /brands → ?brand= deep link).
const brandFilter = (localStorage.getItem('wms.brand_filter') || '').trim();
const brandQS = brandFilter ? `&brand=${encodeURIComponent(brandFilter)}` : '';
// Render brand-filter pill (clearable).
const bfr = $('#brand-filter-row');
if (bfr) {
if (brandFilter) {
bfr.hidden = false;
bfr.innerHTML = `<button type="button" class="facet-pill is-active" id="clear-brand-filter">Brand: ${escapeHtml(brandFilter)} <span style="margin-left: 6px;">×</span></button>`;
} else {
bfr.hidden = true;
bfr.innerHTML = '';
}
}
const r = await fetch(`/api/recommend?limit=60${proQS}${colorQS}${matQS}${sortQS}${brandQS}`).then(r => r.json());
// Render the facet pill row.
const facetsRow = $('#facets-row');
if (facetsRow && r.facets) {
const pill = (kind, value, count, active) => `
<button type="button" class="facet-pill ${active ? 'is-active' : ''}" data-facet-kind="${kind}" data-facet-value="${value.replace(/"/g, '"')}">
${value} <span class="facet-count">${count}</span>
</button>`;
const html = [
...r.facets.colors.map(c => pill('color', c.value, c.count, activeColors.includes(c.value))),
...r.facets.materials.map(m => pill('material', m.value, m.count, activeMaterials.includes(m.value))),
].join('');
facetsRow.innerHTML = html;
facetsRow.hidden = !html;
}
let items = r.items || [];
// Tick 30: price_asc/price_desc/sustain/newest are now server-sorted; respect that order.
// Keep brand/title as client-side string sort (server doesn't expose those).
if (sort === 'brand') items.sort((a, b) => (a.brand || '').localeCompare(b.brand || ''));
else if (sort === 'title') items.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
// For set decorators on Best Match, apply Tailor re-rank on top of taste order.
if (isSetDec && sort === 'taste') {
items.forEach((it, i) => { it._origIdx = i; it._tailorScore = tailorScore(it, tailor); });
items.sort((a, b) => {
// Tailor score dominates; ties broken by original taste order.
if (b._tailorScore !== a._tailorScore) return b._tailorScore - a._tailorScore;
return a._origIdx - b._origIdx;
});
}
const g = $('#recs-grid');
g.style.setProperty('--card-min', minPx + 'px');
g.innerHTML = '';
// Tick 31: empty-state — if no items, show a friendly message + clear-filters button.
const empty = $('#recs-empty');
const anyFilter = activeColors.length || activeMaterials.length || proOnlyChecked || brandFilter;
if (!items.length && empty) {
empty.hidden = false;
$('#recs-empty-msg').textContent = anyFilter
? 'No matches with your current filters.'
: 'No catalog yet — your duels are seeding it.';
$('#recs-empty-clear').hidden = !anyFilter;
} else if (empty) {
empty.hidden = true;
}
// Tick 32: inline embed-status pill — admin only, surfaces drainer backlog.
refreshRecsEmbedStatus();
items.forEach(it => {
const a = document.createElement('a');
a.className = 'card';
a.href = it.product_url || '#';
a.target = '_blank';
a.rel = 'noopener';
const sustain = it.sustain ? `<span class="sustain" aria-label="Sustainability ${it.sustain} of 5">♻ ${it.sustain}/5</span>` : '';
const showBadge = (Role.effective === 'set_decorator' || Role.role === 'set_decorator') && (it.production_credits || 0) > 0;
const badge = showBadge ? `<span class="prod-credit-badge" title="Used in ${it.production_credits} production${it.production_credits > 1 ? 's' : ''}">★ ${it.production_credits}</span>` : '';
const favOn = isFav('catalog', it.id);
const star = `<button type="button" class="star-btn ${favOn ? 'is-fav' : ''}" data-fav-kind="catalog" data-fav-id="${it.id}" aria-label="${favOn ? 'Unfavorite' : 'Favorite'}">${favOn ? '★' : '☆'}</button>`;
const brandLink = it.brand ? `<a href="#" data-brand="${escapeHtml(it.brand)}" onclick="event.stopPropagation();">${escapeHtml(it.brand)}</a>` : '';
a.innerHTML = `<img src="${it.image_url || ''}" alt="${it.title}${it.brand ? ' by ' + it.brand : ''}" loading="lazy">${badge}${star}<div class="meta"><div class="title">${it.title}</div><div class="brand">${brandLink}${sustain}</div><div class="price">${it.price_cents ? '$' + (it.price_cents/100).toFixed(0) : ''}</div><a class="credits-pill" href="#" data-credits-kind="catalog" data-credits-id="${it.id}" onclick="event.stopPropagation();">view credits →</a></div>`;
g.appendChild(a);
});
}
$('#sort').addEventListener('change', e => { localStorage.setItem('wms.sort', e.target.value); loadRecs(); });
$('#density').addEventListener('input', e => { localStorage.setItem('wms.density', e.target.value); loadRecs(); });
// Tick 31: brand filter on boot — read ?brand= and stash; clear-brand-filter pill resets.
// Tick 32: when ?brand= is set on boot, also force route to /recs so the
// filter is immediately visible (deep-link from /brands → main app).
let __brandDeepLink = false;
(function hydrateBrandFilterFromURL() {
try {
const params = new URLSearchParams(location.search);
const b = (params.get('brand') || '').trim();
if (b) {
localStorage.setItem('wms.brand_filter', b);
__brandDeepLink = true;
params.delete('brand');
const qs = params.toString();
history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
}
} catch {}
})();
// Tick 32: admin-only inline embed-status pill above the recs grid.
async function refreshRecsEmbedStatus() {
const el = $('#recs-embed-status');
if (!el) return;
if (!Role.is_admin || Role.view_as) { el.hidden = true; return; }
try {
const r = await fetch('/api/admin/catalog-status');
if (!r.ok) { el.hidden = true; return; }
const j = await r.json();
if (!j.total) { el.hidden = true; return; }
const pct = Math.round((j.embedded / j.total) * 100);
$('#recs-embed-text').textContent = `${j.embedded.toLocaleString()} embedded of ${j.total.toLocaleString()} catalog · ${pct}%`;
$('#recs-embed-fill').style.width = pct + '%';
el.hidden = false;
} catch { el.hidden = true; }
}
document.addEventListener('click', e => {
if (e.target.closest('#clear-brand-filter')) {
localStorage.removeItem('wms.brand_filter');
loadRecs();
}
if (e.target.closest('#recs-empty-clear')) {
localStorage.removeItem('wms.facets.colors');
localStorage.removeItem('wms.facets.materials');
localStorage.removeItem('wms.proOnly');
localStorage.removeItem('wms.brand_filter');
loadRecs();
}
});
// Tick 31: share-this-list — POST current state, copy URL to clipboard.
$('#recs-share-btn')?.addEventListener('click', async () => {
const out = $('#recs-share-out');
out.textContent = 'Generating…';
const state = {
sort: localStorage.getItem('wms.sort') || 'best',
colors: JSON.parse(localStorage.getItem('wms.facets.colors') || '[]'),
materials: JSON.parse(localStorage.getItem('wms.facets.materials') || '[]'),
proOnly: localStorage.getItem('wms.proOnly') === '1',
brand: (localStorage.getItem('wms.brand_filter') || '').trim(),
};
const SERVER_SORTS = new Set(['price_asc', 'price_desc', 'sustain', 'newest']);
if (!SERVER_SORTS.has(state.sort)) state.sort = 'best';
try {
const r = await fetch('/api/share/recs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state),
});
const j = await r.json();
if (!r.ok) { out.textContent = j.error || 'failed'; return; }
const full = `${location.origin}${j.url}`;
try { await navigator.clipboard.writeText(full); out.innerHTML = `Copied — <a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`; }
catch { out.innerHTML = `<a href="${j.url}" target="_blank" rel="noopener">${j.url}</a>`; }
} catch (e) { out.textContent = 'failed'; }
});
// Tailor sliders — live update output + localStorage + reflow grid.
['era', 'palette', 'density', 'tone'].forEach(k => {
const inp = $(`#tailor-${k}`);
const out = $(`#tailor-${k}-val`);
if (!inp || !out) return;
inp.addEventListener('input', e => {
out.textContent = e.target.value;
localStorage.setItem(`wms.tailor.${k}`, e.target.value);
if (k === 'density') {
// Density-only changes: just update CSS, no need to re-fetch + re-sort.
$('#recs-grid')?.style.setProperty('--card-min', e.target.value + 'px');
} else {
loadRecs();
}
});
});
$('#tailor-reset')?.addEventListener('click', () => {
for (const [k, v] of Object.entries(TAILOR_DEFAULTS)) {
localStorage.setItem(`wms.tailor.${k}`, v);
}
loadRecs();
});
// ---------- Stores ---------------------------------------------------------
async function requestLocation() {
navigator.geolocation?.getCurrentPosition(async p => {
const r = await fetch(`/api/stores?lat=${p.coords.latitude}&lng=${p.coords.longitude}`).then(r => r.json()).catch(() => ({stores: []}));
const list = $('#stores-list');
list.innerHTML = '';
(r.stores || []).forEach(s => {
const d = document.createElement('div');
d.className = 'conn';
d.innerHTML = `<strong>${s.name}</strong><small>${s.address || ''} · ${s.distance_mi?.toFixed?.(1) || ''}mi</small>`;
list.appendChild(d);
});
if (!r.stores?.length) list.innerHTML = '<p class="muted">No stores indexed yet — like a few brands first.</p>';
}, () => alert('Location denied.'));
}
// ---------- Connections ---------------------------------------------------
$$('.conn').forEach(b => b.addEventListener('click', async () => {
const kind = b.dataset.kind;
await fetch(`/api/connect/${kind}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
const cs = await fetch('/api/connect').then(r => r.json());
$('#connect-state').textContent = JSON.stringify(cs.connections, null, 2);
}));
// ---------- Avatar --------------------------------------------------------
// Drag-drop friendly upload widget. Files staged client-side (drag-drop,
// file picker, OR Google Drive) — user clicks Upload to send the batch as
// multipart `photos[]` to POST /api/avatar/photos (unchanged backend contract).
//
// PICKER NEEDS: GOOGLE_PICKER_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_DRIVE_APP_ID
// — see ~/Projects/secrets-manager. If env not configured, GDrive button hides.
const avatarStaged = []; // [{ file: File, url: ObjectURL, source: 'local'|'drive' }]
let gpickerCfg = null;
let gpickerLoaded = false;
let gpickerTokenClient = null;
let gpickerAccessToken = null;
const dz = $('#avatar-dropzone');
const fileInput = $('#avatar-file-input');
const thumbRow = $('#avatar-thumbs');
const counter = $('#avatar-counter');
const uploadBtn = $('#avatar-upload-btn');
const gdriveBtn = $('#avatar-gdrive-btn');
function renderAvatarThumbs() {
thumbRow.innerHTML = '';
avatarStaged.forEach((s, i) => {
const t = document.createElement('div');
t.className = 'thumb';
t.innerHTML = `<img src="${s.url}" alt="staged photo ${i+1}"><button class="x" type="button" aria-label="Remove photo ${i+1}">×</button><span class="src-tag">${s.source === 'drive' ? 'DRIVE' : 'LOCAL'}</span>`;
t.querySelector('.x').onclick = () => {
try { URL.revokeObjectURL(s.url); } catch {}
avatarStaged.splice(i, 1);
renderAvatarThumbs();
};
thumbRow.appendChild(t);
});
const n = avatarStaged.length;
counter.classList.remove('counter-empty', 'counter-ok', 'counter-over');
if (n === 0) { counter.textContent = '0 / 5 photos · drop or pick to add'; counter.classList.add('counter-empty'); }
else if (n < 3) { counter.textContent = `${n} / 5 photos · add ${3-n} more`; counter.classList.add('counter-empty'); }
else if (n <= 5) { counter.textContent = `${n} / 5 photos · click upload to send`; counter.classList.add('counter-ok'); }
else { counter.textContent = `${n} / 5 photos · over the limit — remove ${n-5}`; counter.classList.add('counter-over'); }
uploadBtn.disabled = n === 0;
}
function addAvatarFiles(files, source = 'local') {
for (const f of files) {
if (!f || !f.type || !f.type.startsWith('image/')) continue;
avatarStaged.push({ file: f, url: URL.createObjectURL(f), source });
}
renderAvatarThumbs();
}
// dropzone — click + keyboard open file picker, drag-drop accepts files
dz.addEventListener('click', () => fileInput.click());
dz.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); }
});
fileInput.addEventListener('change', e => {
addAvatarFiles(e.target.files, 'local');
fileInput.value = '';
});
['dragenter','dragover'].forEach(ev => dz.addEventListener(ev, e => {
e.preventDefault(); e.stopPropagation();
dz.classList.add('dragover');
}));
['dragleave','drop'].forEach(ev => dz.addEventListener(ev, e => {
e.preventDefault(); e.stopPropagation();
if (ev === 'dragleave' && dz.contains(e.relatedTarget)) return;
dz.classList.remove('dragover');
}));
dz.addEventListener('drop', e => {
const files = e.dataTransfer?.files || [];
if (files.length) addAvatarFiles(files, 'local');
});
// also catch document-level drag so the browser doesn't navigate away
['dragover','drop'].forEach(ev => window.addEventListener(ev, e => {
if (e.target.closest && e.target.closest('#avatar-dropzone')) return;
e.preventDefault();
}));
// upload button — sends staged set as multipart photos[] (unchanged backend)
uploadBtn.addEventListener('click', async () => {
if (!avatarStaged.length) return;
uploadBtn.disabled = true;
$('#avatar-status').textContent = `Uploading ${avatarStaged.length} photo${avatarStaged.length===1?'':'s'}…`;
const fd = new FormData();
avatarStaged.forEach((s, i) => {
// Server side reads `photos` field (FormData multiple). Use original name where possible.
const name = s.file.name || `photo-${i+1}.jpg`;
fd.append('photos', s.file, name);
});
try {
const r = await fetch('/api/avatar/photos', { method: 'POST', body: fd }).then(r => r.json());
$('#avatar-status').textContent = `${r.photo_count} photo${r.photo_count===1?'':'s'} uploaded. Click "Build my avatar" when ready.`;
// clear staging on success
avatarStaged.forEach(s => { try { URL.revokeObjectURL(s.url); } catch {} });
avatarStaged.length = 0;
renderAvatarThumbs();
} catch (err) {
$('#avatar-status').textContent = 'Upload failed — try again.';
uploadBtn.disabled = false;
}
});
$('#avatar-build-btn').addEventListener('click', async () => {
$('#avatar-status').textContent = 'Building your digital twin… this can take 1–2 min.';
await fetch('/api/avatar/build', { method: 'POST' });
pollAvatar();
});
// ---------- Google Drive Picker ------------------------------------------
// Loads gapi + GIS from CDN on demand. Requires GOOGLE_PICKER_API_KEY +
// GOOGLE_CLIENT_ID + GOOGLE_DRIVE_APP_ID env-vars exposed via /api/public-config.
// If any are missing, the GDrive button stays hidden (graceful degrade).
async function bootGoogleDriveButton() {
try {
const cfg = await fetch('/api/public-config').then(r => r.json());
gpickerCfg = cfg.google_picker || {};
if (gpickerCfg.api_key && gpickerCfg.client_id && gpickerCfg.app_id) {
gdriveBtn.hidden = false;
}
} catch {}
}
bootGoogleDriveButton();
function loadScript(src) {
return new Promise((resolve, reject) => {
if (document.querySelector(`script[src="${src}"]`)) return resolve();
const s = document.createElement('script');
s.src = src; s.async = true; s.defer = true;
s.onload = resolve; s.onerror = () => reject(new Error('script load failed: ' + src));
document.head.appendChild(s);
});
}
async function ensurePickerLoaded() {
if (gpickerLoaded) return;
await loadScript('https://apis.google.com/js/api.js');
await new Promise(resolve => window.gapi.load('picker', { callback: resolve }));
await loadScript('https://accounts.google.com/gsi/client');
gpickerTokenClient = window.google.accounts.oauth2.initTokenClient({
client_id: gpickerCfg.client_id,
scope: 'https://www.googleapis.com/auth/drive.readonly',
callback: () => {}, // overridden per-request
});
gpickerLoaded = true;
}
async function getDriveAccessToken() {
if (gpickerAccessToken) return gpickerAccessToken;
return new Promise((resolve, reject) => {
gpickerTokenClient.callback = (resp) => {
if (resp.error) return reject(new Error(resp.error));
gpickerAccessToken = resp.access_token;
resolve(gpickerAccessToken);
};
gpickerTokenClient.requestAccessToken({ prompt: '' });
});
}
async function openGoogleDrivePicker() {
if (!gpickerCfg || !gpickerCfg.api_key) return;
try {
$('#avatar-status').textContent = 'Opening Google Drive picker…';
await ensurePickerLoaded();
const token = await getDriveAccessToken();
const view = new window.google.picker.View(window.google.picker.ViewId.DOCS_IMAGES);
const picker = new window.google.picker.PickerBuilder()
.enableFeature(window.google.picker.Feature.MULTISELECT_ENABLED)
.setOAuthToken(token)
.setDeveloperKey(gpickerCfg.api_key)
.setAppId(gpickerCfg.app_id)
.addView(view)
.setCallback(async (data) => {
if (data.action !== window.google.picker.Action.PICKED) return;
$('#avatar-status').textContent = `Downloading ${data.docs.length} from Drive…`;
for (const doc of data.docs) {
try {
const r = await fetch(`https://www.googleapis.com/drive/v3/files/${doc.id}?alt=media`, {
headers: { Authorization: `Bearer ${token}` },
});
const blob = await r.blob();
const file = new File([blob], doc.name || `drive-${doc.id}.jpg`, { type: blob.type || 'image/jpeg' });
addAvatarFiles([file], 'drive');
} catch (e) {
console.warn('Drive download failed for', doc.id, e);
}
}
$('#avatar-status').textContent = `Staged ${avatarStaged.length} photo${avatarStaged.length===1?'':'s'} — click Upload when ready.`;
})
.build();
picker.setVisible(true);
} catch (e) {
$('#avatar-status').textContent = 'Drive picker failed: ' + (e.message || e);
}
}
gdriveBtn.addEventListener('click', openGoogleDrivePicker);
async function pollAvatar() {
for (let i = 0; i < 60; i++) {
const r = await fetch('/api/avatar').then(r => r.json());
if (r.avatar?.status === 'ready') {
$('#avatar-status').textContent = '✓ Avatar ready. You can now "Try this on me" anywhere in the app.';
$('#avatar-preview').innerHTML = `<img src="/api/avatar/render?t=${Date.now()}" style="max-width:340px;border-radius:18px;box-shadow:var(--shadow);margin-top:14px;">`;
return;
}
if (r.avatar?.status === 'failed') {
$('#avatar-status').textContent = 'Avatar build failed. Try again with clearer photos.';
return;
}
await new Promise(r => setTimeout(r, 4000));
}
}
// ---------- Try-on (from duel / recs / closet) ----------------------------
async function tryOnItem({ item_id, closet_id, mode = 'avatar', occasion, source_photo_file, production_id }) {
const fd = new FormData();
if (item_id) fd.append('item_id', item_id);
if (closet_id) fd.append('closet_id', closet_id);
fd.append('mode', mode);
if (occasion) fd.append('occasion', occasion);
if (source_photo_file) fd.append('source_photo', source_photo_file);
// Auto-inherit the active production for set-decorator context unless the
// caller passed one explicitly.
const pid = production_id || getActiveProductionId();
if (pid) fd.append('production_id', pid);
const r = await fetch('/api/tryon', { method: 'POST', body: fd }).then(r => r.json());
if (r.error) { alert(r.error + (r.hint ? ' — ' + r.hint : '')); return null; }
return r.job_id;
}
async function pollTryon(jobId, onUpdate) {
for (let i = 0; i < 90; i++) {
const j = await fetch(`/api/tryon/${jobId}`).then(r => r.json());
onUpdate(j.job);
if (j.job.status === 'done' || j.job.status === 'failed') return j.job;
await new Promise(r => setTimeout(r, 3000));
}
}
// ---------- Time-Travel ---------------------------------------------------
let ttClosetPick = null;
let ttPhotoFile = null;
let ttOccasion = null;
async function loadTimeTravel() {
const r = await fetch('/api/closet').then(r => r.json());
const grid = $('#tt-closet');
grid.innerHTML = '';
if (!r.items?.length) {
grid.innerHTML = '<p class="muted">No closet items yet. Upload a closet photo first (in My Closet).</p>';
return;
}
r.items.forEach(it => {
const c = document.createElement('button');
c.className = 'card';
c.style.cursor = 'pointer';
c.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || '—'}</div><div class="brand">${it.color || ''}</div></div>`;
c.onclick = () => {
$$('#tt-closet .card').forEach(x => x.classList.remove('tt-closet-pick'));
c.classList.add('tt-closet-pick');
ttClosetPick = it.id;
checkTTReady();
};
grid.appendChild(c);
});
}
$('#tt-photo').addEventListener('change', e => {
ttPhotoFile = e.target.files[0];
if (ttPhotoFile) {
$('#tt-photo-preview').src = URL.createObjectURL(ttPhotoFile);
$('#tt-photo-preview').style.display = 'block';
}
checkTTReady();
});
$$('#timetravel .pills .pill').forEach(p => p.addEventListener('click', () => {
$$('#timetravel .pills .pill').forEach(x => x.style.background = '');
p.style.background = 'var(--ink)';
p.style.color = 'var(--paper)';
ttOccasion = p.dataset.occ;
$('#tt-occ').value = ttOccasion;
checkTTReady();
}));
$('#tt-occ').addEventListener('input', e => { ttOccasion = e.target.value; checkTTReady(); });
function checkTTReady() {
$('#tt-go').disabled = !(ttClosetPick && ttPhotoFile && ttOccasion);
}
$('#tt-go').addEventListener('click', async () => {
$('#tt-result').innerHTML = '<div class="tryon-pending">Re-dressing past-you… (this can take a minute)</div>';
const jobId = await tryOnItem({
closet_id: ttClosetPick,
mode: 'closet_to_oldphoto',
occasion: ttOccasion,
source_photo_file: ttPhotoFile,
});
if (!jobId) return;
pollTryon(jobId, job => {
if (job.status === 'done') {
$('#tt-result').innerHTML = `<div class="tryon-result"><img src="/api/tryon/${jobId}/image?t=${Date.now()}"><p class="muted">Re-dressed in ${Math.round((Date.parse(job.done_at) - Date.parse(job.created_at))/1000) || '~'}s.</p></div>`;
} else if (job.status === 'failed') {
$('#tt-result').innerHTML = `<div class="tryon-pending">Render failed — try a clearer photo.</div>`;
}
});
});
// ---------- My Try-Ons ----------------------------------------------------
async function loadTryons() {
const r = await fetch('/api/tryon').then(r => r.json());
const grid = $('#tryons-grid');
grid.innerHTML = '';
(r.jobs || []).forEach(j => {
const c = document.createElement('div');
c.className = 'card';
const niceMode = ({ avatar: 'On my twin', photo_swap: 'In my photo', closet_to_oldphoto: 'Time-travel' })[j.mode] || j.mode;
c.innerHTML = j.status === 'done'
? `<img src="/api/tryon/${j.id}/image" alt=""><div class="meta"><div class="title">${niceMode}</div><div class="brand">${j.created_at}</div></div>`
: `<div class="tryon-pending" style="min-height:280px;display:grid;place-items:center;">${j.status}…</div><div class="meta"><div class="title">${niceMode}</div></div>`;
grid.appendChild(c);
});
}
// inject "Try on me" buttons into duel + recs after they render
const origPaint = paintCard;
paintCard = function(side, item) {
origPaint(side, item);
const card = document.querySelector(`.card[data-pick="${side.toUpperCase()}"]`);
if (!card) return;
card.querySelectorAll('.tryon-btn').forEach(b => b.remove());
const b = document.createElement('button');
b.className = 'tryon-btn';
b.textContent = 'Try on me';
b.onclick = (e) => {
e.stopPropagation();
tryOnItem({ item_id: item.id, mode: 'avatar' }).then(jobId => {
if (jobId) alert('Rendering on your avatar — check My Try-Ons in a minute.');
});
};
card.appendChild(b);
};
// ---------- Style this Piece (outfit-builder) ----------------------------
async function loadStylePiece() {
const r = await fetch('/api/closet').then(r => r.json());
const c = $('#sp-closet');
$('#sp-outfits').innerHTML = '';
c.innerHTML = '';
if (!r.items?.length) {
c.innerHTML = '<p class="muted">Upload a closet photo first.</p>';
return;
}
r.items.forEach(it => {
const b = document.createElement('button');
b.className = 'card';
b.innerHTML = `<img src="/api/closet/photo/${it.id}" alt=""><div class="meta"><div class="title">${it.category || '—'}</div><div class="brand">${it.color || ''}</div></div>`;
b.onclick = () => buildOutfits(it);
c.appendChild(b);
});
}
async function buildOutfits(closetItem) {
$('#sp-anchor').innerHTML = `<p class="muted">Building outfits around your <strong>${closetItem.category || 'piece'}</strong>…</p>`;
$('#sp-outfits').innerHTML = '<div class="tryon-pending">Thinking…</div>';
const r = await fetch('/api/outfits', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ closet_id: closetItem.id })
}).then(r => r.json());
if (!r.outfits?.length) { $('#sp-outfits').innerHTML = '<p class="muted">No outfits could be built — catalog too thin.</p>'; return; }
const grid = document.createElement('div');
r.outfits.forEach((o, i) => {
const card = document.createElement('div');
card.className = 'outfit-card';
const slots = Object.entries(o.slots).map(([cat, s]) => `
<div class="slot">
<img src="${s.image_url}" alt="${s.title}" loading="lazy">
<div class="cat">${cat}</div>
<div class="name">${s.brand || ''}</div>
</div>`).join('');
const rationaleHtml = o.rationale ? `<p class="outfit-rationale">${escapeHtml(o.rationale)}</p>` : '';
card.innerHTML = `<h4>Look ${i+1}</h4><div class="outfit-slots">${slots}</div>${rationaleHtml}<button class="pick-this">Wear this</button>`;
card.querySelector('.pick-this').onclick = async () => {
await fetch(`/api/outfits/${r.pick_id}/pick`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ index: i })});
grid.querySelectorAll('.outfit-card').forEach(x => x.classList.remove('picked'));
card.classList.add('picked');
card.querySelector('.pick-this').textContent = '✓ Picked';
};
grid.appendChild(card);
});
$('#sp-outfits').innerHTML = '';
$('#sp-outfits').appendChild(grid);
}
// ---------- Waitlist (on /about) ------------------------------------------
$('#waitlist-form').addEventListener('submit', async e => {
e.preventDefault();
const email = e.target.email.value;
const r = await fetch('/api/waitlist', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, source: 'about-page' }) }).then(r => r.json());
$('#waitlist-msg').textContent = r.ok ? "You're on the list. We'll be in touch." : (r.error || 'Try again.');
if (r.ok) e.target.email.value = '';
});
// ---------- Live puppet (Adobe Character Animator pass-through) -----------
// Wraps getUserMedia. When the selected camera is a virtual cam (Adobe Mercury
// Transmit → NDI → OBS Virtual Camera, or KlakNDI), the live puppet feed shows
// here. Audio is captured + played through (so when you talk, your puppet
// moves and the room hears you).
const live = {
stream: null,
overlayOn: false,
async populate() {
try {
// Ask permission first so labels populate
const tmp = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
tmp.getTracks().forEach(t => t.stop());
} catch (e) {
const cs = $('#live-cam'); cs.innerHTML = '<option>Permission denied — grant camera + mic in browser settings</option>';
return;
}
const devs = await navigator.mediaDevices.enumerateDevices();
const fill = (sel, kind) => {
sel.innerHTML = devs.filter(d => d.kind === kind)
.map(d => `<option value="${d.deviceId}">${d.label || d.kind}</option>`).join('');
};
fill($('#live-cam'), 'videoinput');
fill($('#live-mic'), 'audioinput');
},
async start() {
if (live.stream) live.stop();
const camId = $('#live-cam').value;
const micId = $('#live-mic').value;
try {
live.stream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: camId ? { exact: camId } : undefined, width: 1280, height: 720 },
audio: { deviceId: micId ? { exact: micId } : undefined, echoCancellation: true, noiseSuppression: true },
});
$('#live-video').srcObject = live.stream;
$('#pip-video').srcObject = live.stream;
$('#live-start').disabled = true; $('#live-stop').disabled = false;
} catch (e) {
alert('Could not start camera — check permissions. ' + (e.message || e));
}
},
stop() {
live.stream?.getTracks().forEach(t => t.stop());
live.stream = null;
$('#live-video').srcObject = null;
$('#pip-video').srcObject = null;
$('#duel-pip').hidden = true;
$('#live-overlay-toggle').setAttribute('aria-pressed', 'false');
live.overlayOn = false;
$('#live-start').disabled = false; $('#live-stop').disabled = true;
},
toggleOverlay() {
if (!live.stream) { alert('Start live first.'); return; }
live.overlayOn = !live.overlayOn;
$('#duel-pip').hidden = !live.overlayOn;
$('#live-overlay-toggle').setAttribute('aria-pressed', String(live.overlayOn));
},
snap() {
if (!live.stream) return;
const v = $('#live-video');
const c = $('#live-overlay');
c.width = v.videoWidth; c.height = v.videoHeight;
const ctx = c.getContext('2d');
ctx.translate(c.width, 0); ctx.scale(-1, 1); // mirror to match preview
ctx.drawImage(v, 0, 0);
c.toBlob(blob => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `live-snap-${Date.now()}.png`;
a.click();
}, 'image/png');
},
};
$('#live-start').addEventListener('click', () => live.start());
$('#live-stop').addEventListener('click', () => live.stop());
$('#live-overlay-toggle').addEventListener('click', () => live.toggleOverlay());
$('#live-snap').addEventListener('click', () => live.snap());
// ---------- Studio ---------------------------------------------------------
// LEFT pane: pick a photo of yourself.
// MIDDLE pane: live render + history-strip below.
// RIGHT pane: pick a clothing section, then a catalog piece in that category.
const studio = {
currentSource: null, // server-side path of selected photo
currentSourceUrl: null, // proxied URL we can show in <img>
category: 'top',
poll: null,
async loadPhotos() {
const r = await fetch('/api/studio/photos').then(r => r.json());
const list = $('#studio-photos');
list.innerHTML = '';
if (!r.photos?.length) { list.innerHTML = '<p class="muted">Upload photos in My Avatar first.</p>'; return; }
r.photos.forEach(p => {
const b = document.createElement('button');
b.title = p.path.split('/').pop();
b.innerHTML = `<img src="${p.url}" alt="" loading="lazy">`;
b.onclick = () => { studio.selectPhoto(p); list.querySelectorAll('button').forEach(x => x.classList.remove('sel')); b.classList.add('sel'); };
list.appendChild(b);
});
// auto-select first
if (r.photos.length && !studio.currentSource) {
studio.selectPhoto(r.photos[0]);
list.querySelector('button')?.classList.add('sel');
}
},
selectPhoto(p) {
studio.currentSource = p.path;
studio.currentSourceUrl = p.url;
$('#studio-current').innerHTML = `<img src="${p.url}" alt="">`;
$('#studio-result').innerHTML = '<p class="muted">Pick a category on the right, then a piece.</p>';
studio.loadHistory();
},
setCategory(cat) {
studio.category = cat;
$$('.section-btn').forEach(b => b.classList.toggle('active', b.dataset.category === cat));
studio.loadPicker();
},
async loadPicker() {
const picker = $('#studio-picker');
picker.innerHTML = '<p class="muted">Loading…</p>';
// Pull from /api/recommend filtered by category
const r = await fetch('/api/recommend?limit=60').then(r => r.json());
const items = (r.items || []).filter(it => it.category === studio.category);
if (!items.length) { picker.innerHTML = `<p class="muted">No ${studio.category} items in catalog yet.</p>`; return; }
picker.innerHTML = '';
items.forEach(it => {
const b = document.createElement('button');
b.title = `${it.brand || ''} · ${it.title}`;
b.innerHTML = `<img src="${it.image_url}" alt="" loading="lazy"><div class="label">${it.brand || it.title.slice(0, 18)}</div>`;
b.onclick = () => studio.swap(it);
picker.appendChild(b);
});
},
async swap(item) {
if (!studio.currentSource) { alert('Pick a photo on the left first.'); return; }
$('#studio-result').innerHTML = `<div><p>Rendering you in <strong>${item.brand || item.title}</strong>…</p><p class="muted">~30-60s via FASHN</p></div>`;
const r = await fetch('/api/studio/swap', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source_photo: studio.currentSource, item_id: item.id, category: studio.category }),
}).then(r => r.json());
if (r.error) { $('#studio-result').innerHTML = `<p class="muted">Error: ${r.error}</p>`; return; }
studio.pollResult(r.job_id);
},
async pollResult(jobId) {
clearInterval(studio.poll);
studio.poll = setInterval(async () => {
const j = await fetch(`/api/tryon/${jobId}`).then(r => r.json());
if (j.job?.status === 'done') {
clearInterval(studio.poll);
$('#studio-result').innerHTML = `<img src="/api/tryon/${jobId}/image?t=${Date.now()}" alt="result">`;
studio.loadHistory();
} else if (j.job?.status === 'failed') {
clearInterval(studio.poll);
$('#studio-result').innerHTML = `<p class="muted">Render failed: ${j.job.error || 'unknown'}</p>`;
}
}, 2500);
},
async loadHistory() {
const strip = $('#studio-history');
if (!studio.currentSource) { strip.innerHTML = ''; return; }
const r = await fetch('/api/studio/history?source_photo=' + encodeURIComponent(studio.currentSource)).then(r => r.json());
strip.innerHTML = '';
const done = (r.history || []).filter(h => h.status === 'done');
if (!done.length) { strip.innerHTML = '<p class="muted">No renders on this photo yet — pick a piece on the right.</p>'; return; }
done.forEach(h => {
const t = document.createElement('div');
t.className = 'h-tile';
t.title = `${h.brand || ''} ${h.title || ''}`;
t.innerHTML = `<img src="/api/tryon/${h.id}/image" alt="" loading="lazy"><div class="h-cat">${h.category || h.mode}</div>`;
t.onclick = () => { $('#studio-result').innerHTML = `<img src="/api/tryon/${h.id}/image" alt="">`; };
strip.appendChild(t);
});
},
};
$$('.section-btn[data-category]').forEach(b => b.addEventListener('click', () => studio.setCategory(b.dataset.category)));
// ---- Studio: pull-a-piece (garment extractor) ----------------------------
const extract = {
active: false,
start: null, // {x, y} in img-local coords
end: null,
imgEl: null,
enable() {
if (!studio.currentSource) { alert('Pick a photo first.'); return; }
// Replace whatever's in the result pane with the source photo so user
// can draw on the original (not on a rendered result).
$('#studio-result').innerHTML = `<img src="${studio.currentSourceUrl}" alt="" draggable="false">`;
$('#studio-result').classList.add('extracting');
$('#extract-toggle').classList.add('active');
$('#extract-hint').hidden = false;
extract.active = true;
extract.imgEl = $('#studio-result img');
},
disable() {
$('#studio-result').classList.remove('extracting');
$('#studio-result').querySelector('.extract-rect')?.remove();
$('#extract-toggle').classList.remove('active');
$('#extract-hint').hidden = true;
$('#extract-save').disabled = true;
extract.active = false;
extract.start = extract.end = null;
},
rect() {
if (!extract.start || !extract.end) return null;
const x = Math.min(extract.start.x, extract.end.x);
const y = Math.min(extract.start.y, extract.end.y);
const w = Math.abs(extract.end.x - extract.start.x);
const h = Math.abs(extract.end.y - extract.start.y);
return { x, y, w, h };
},
drawRect() {
const r = extract.rect();
if (!r) return;
let el = $('#studio-result').querySelector('.extract-rect');
if (!el) { el = document.createElement('div'); el.className = 'extract-rect'; $('#studio-result').appendChild(el); }
el.style.left = r.x + 'px'; el.style.top = r.y + 'px';
el.style.width = r.w + 'px'; el.style.height = r.h + 'px';
},
async save() {
const r = extract.rect();
if (!r || !extract.imgEl) return;
const imgRect = extract.imgEl.getBoundingClientRect();
const containerRect = $('#studio-result').getBoundingClientRect();
// r is in container coords; convert to image-local then normalize 0-1
const imgLeft = imgRect.left - containerRect.left;
const imgTop = imgRect.top - containerRect.top;
const bbox = {
x: Math.max(0, (r.x - imgLeft)) / imgRect.width,
y: Math.max(0, (r.y - imgTop)) / imgRect.height,
w: r.w / imgRect.width,
h: r.h / imgRect.height,
};
const category = $('#extract-category').value;
const resp = await fetch('/api/studio/extract-garment', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ source_photo: studio.currentSource, bbox, category, brand_hint: 'Your past wardrobe' }),
}).then(r => r.json());
if (resp.error) { alert('Extract failed: ' + resp.error); return; }
alert(`Extracted! Saved as catalog item #${resp.item_id} — pick a different photo on the left and click "${category}" to swap it on.`);
extract.disable();
// Refresh the picker so the new piece shows up
studio.setCategory(resp.category || category);
},
};
$('#extract-toggle').addEventListener('click', () => extract.active ? extract.disable() : extract.enable());
$('#extract-save').addEventListener('click', () => extract.save());
$('#studio-result').addEventListener('mousedown', e => {
if (!extract.active) return;
const c = $('#studio-result').getBoundingClientRect();
extract.start = { x: e.clientX - c.left, y: e.clientY - c.top };
extract.end = { ...extract.start };
extract.drawRect();
});
$('#studio-result').addEventListener('mousemove', e => {
if (!extract.active || !extract.start) return;
const c = $('#studio-result').getBoundingClientRect();
extract.end = { x: e.clientX - c.left, y: e.clientY - c.top };
extract.drawRect();
});
$('#studio-result').addEventListener('mouseup', () => {
if (!extract.active || !extract.start) return;
const r = extract.rect();
$('#extract-save').disabled = !(r && r.w > 20 && r.h > 20);
});
// ---------- Router ---------------------------------------------------------
async function loadTasteTimeline() {
const r = await fetch('/api/me/taste/history').then(r => r.json());
const container = $('#taste-timeline');
if (!container) return;
container.innerHTML = '';
const snaps = r.snapshots || [];
if (snaps.length < 2) {
container.innerHTML = `<p class="muted">${snaps.length === 0 ? 'No snapshots yet — keep picking duels.' : 'One snapshot so far — pick 5 more for a trend line.'}</p>`;
return;
}
// pick the top-5 dims by absolute value in the final snapshot
const final = snaps[snaps.length - 1].taste_vec;
const topIdx = final.map((v, i) => [i, Math.abs(v)]).sort((a,b) => b[1]-a[1]).slice(0, 5).map(p => p[0]);
// Need BASIS labels — pull from /api/me/taste once
const tasteResp = await fetch('/api/me/taste').then(r => r.json());
const labels = (tasteResp.dims || []).map(d => d.key);
const W = 320, H = 28, PAD = 2;
topIdx.forEach(idx => {
const vals = snaps.map(s => s.taste_vec[idx]);
const minV = Math.min(...vals, 0), maxV = Math.max(...vals, 0);
const range = (maxV - minV) || 1;
const pts = vals.map((v, i) => {
const x = PAD + (i * (W - 2*PAD)) / Math.max(1, vals.length - 1);
const y = H - PAD - ((v - minV) / range) * (H - 2*PAD);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
const zeroY = H - PAD - ((0 - minV) / range) * (H - 2*PAD);
const neg = final[idx] < 0 ? ' neg' : '';
const row = document.createElement('div');
row.className = 'taste-timeline-row';
row.innerHTML = `<div class="label">${(labels[idx] || `dim ${idx}`).replace(/_/g, ' ')}</div>
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none">
<line class="zero" x1="0" y1="${zeroY.toFixed(1)}" x2="${W}" y2="${zeroY.toFixed(1)}"/>
<polyline class="spark${neg}" points="${pts}"/>
</svg>`;
container.appendChild(row);
});
}
async function loadTaste() {
loadTasteTimeline();
const r = await fetch('/api/me/taste').then(r => r.json());
const summary = $('#taste-summary');
if (!r.duels_answered) {
summary.textContent = 'No duels yet — pick a few pairs and your taste lens will start sharpening.';
} else {
const top = (r.top || []).map(d => d.key.replace(/_/g, ' ')).slice(0, 3);
summary.textContent = `${r.duels_answered} duel${r.duels_answered === 1 ? '' : 's'} answered. You lean ${top.join(', ') || 'no direction yet'}.`;
}
const top = $('#taste-bars');
top.innerHTML = '';
(r.top || []).forEach(d => top.appendChild(renderBar(d, false)));
(r.bottom || []).forEach(d => top.appendChild(renderBar(d, true)));
const all = $('#taste-all');
all.innerHTML = '';
(r.dims || []).forEach(d => all.appendChild(renderBar(d, false)));
function renderBar(d, isNeg) {
const row = document.createElement('div');
row.className = 'taste-bar';
const v = d.value;
const pct = Math.min(50, Math.abs(v) * 100);
const fillCls = v < 0 ? 'fill neg' : 'fill';
const fillStyle = v < 0
? `width:${pct}%;left:${50 - pct}%;transform:none;`
: `width:${pct}%;left:50%;`;
row.innerHTML = `<div class="label">${d.key.replace(/_/g, ' ')}</div>
<div class="track"><div class="${fillCls}" style="${fillStyle}"></div></div>
<div class="val">${v > 0 ? '+' : ''}${v}</div>`;
return row;
}
}
async function loadSupport() {
const r = await fetch('/api/support').then(r => r.json());
const cta = $('#support-cta');
const fallback = $('#support-cta-fallback');
if (r.donation_url) {
cta.href = r.donation_url;
cta.style.display = 'inline-block';
if (fallback) fallback.style.display = 'none';
}
}
async function loadDebates() {
const r = await fetch('/api/debates').then(r => r.json());
const list = $('#debates-list');
list.innerHTML = '';
(r.debates || []).forEach(d => {
const div = document.createElement('article');
div.className = 'debate';
// strip the "VERDICT: " prefix for a cleaner card; keep full text in detail
const headline = (d.verdict || '').split('\n')[0].replace(/^VERDICT:\s*/i, '');
div.innerHTML = `
<h3>${d.topic}</h3>
<div class="verdict">${headline}</div>
<details><summary>Full panel verdict</summary><pre>${(d.verdict || '').replace(/</g,'<')}</pre></details>
<div class="muted small">${d.created_at}</div>`;
list.appendChild(div);
});
if (!r.debates?.length) list.innerHTML = '<p class="muted">No debates yet.</p>';
}
function route(name) {
show(name);
if (name === 'home') { loadDuel(); showKbHintIfNew(); }
if (name === 'closet') loadCloset();
if (name === 'stylepiece') loadStylePiece();
if (name === 'recs') loadRecs();
if (name === 'avatar') pollAvatar();
if (name === 'timetravel') loadTimeTravel();
if (name === 'tryons') loadTryons();
if (name === 'studio') { studio.loadPhotos(); studio.setCategory(studio.category); }
if (name === 'live') live.populate();
if (name === 'taste') loadTaste();
if (name === 'support') loadSupport();
if (name === 'debates') loadDebates();
if (name === 'productions') loadProductions();
if (name === 'favorites') loadFavorites();
if (name === 'brand') { /* loadBrand called by openBrand() with arg */ }
if (name === 'couple') {
// Reset any leftover panels when re-entering the screen.
if ($('#couple-invite-out')) $('#couple-invite-out').hidden = true;
if ($('#couple-accept-out')) $('#couple-accept-out').textContent = '';
if ($('#couple-exit-out')) $('#couple-exit-out').textContent = '';
}
if (name === 'connect') fetch('/api/connect').then(r => r.json()).then(c => $('#connect-state').textContent = JSON.stringify(c.connections, null, 2));
}
// ---------- Item credits (shotonwhat-style résumé per garment) -------------
function fmtCreditDate(s) {
if (!s) return '';
const d = new Date(s.replace(' ', 'T') + 'Z');
if (isNaN(d)) return s;
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
async function openCredits(kind, id) {
show('credits');
$('#credits-title').textContent = 'Loading credits…';
$('#credits-brand').textContent = '';
$('#credits-count').textContent = '0';
$('#credits-list').innerHTML = '<p class="muted">Loading…</p>';
try {
const r = await fetch(`/api/item-credits/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`);
if (!r.ok) { $('#credits-list').innerHTML = `<p class="muted">No credits found (${r.status}).</p>`; return; }
const data = await r.json();
$('#credits-title').textContent = data.item.title || `${kind} ${data.item.id}`;
$('#credits-brand').textContent = data.item.brand || '';
$('#credits-count').textContent = data.total;
// If the current viewer is a set decorator, prepend a "Tag with production"
// affordance so closet pieces / catalog items can be assigned to a project.
if (kind === 'closet' && (Role.effective === 'set_decorator' || Role.role === 'set_decorator')) {
await injectProductionTagger(kind, id);
}
if (!data.credits.length) {
$('#credits-list').innerHTML = `<p class="muted">No appearances yet. Try it on, build an outfit with it, or time-travel it into an old photo — every render lands here.</p>`;
return;
}
$('#credits-list').innerHTML = data.credits.map(c => `
<article class="credit-row">
<h3 class="credit-role">${escapeHtml(c.role)}</h3>
<h4 class="credit-title">
${c.occasion ? `<span class="credit-occasion">${escapeHtml(c.occasion)}</span>` : '<span class="credit-occasion muted">no occasion tag</span>'}
<span class="credit-year">${fmtCreditDate(c.date)}</span>
</h4>
<div class="credit-meta">
<span class="credit-type">${escapeHtml(c.type)}</span>
${c.provider ? `<span class="credit-provider">via ${escapeHtml(c.provider)}</span>` : ''}
${c.status ? `<span class="credit-status credit-status-${escapeHtml(c.status)}">${escapeHtml(c.status)}</span>` : ''}
${c.slot ? `<span class="credit-slot">slot: ${escapeHtml(c.slot)}</span>` : ''}
</div>
${c.with && c.with.length
? `<div class="credit-with"><span class="muted">With:</span> ${c.with.map(w => `<span>${escapeHtml(w)}</span>`).join(' · ')}</div>`
: ''}
</article>
`).join('');
} catch (e) {
$('#credits-list').innerHTML = `<p class="muted">Could not load credits: ${escapeHtml(e.message)}</p>`;
}
}
function escapeHtml(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
async function injectProductionTagger(kind, id) {
// Renders a small "tag with production" pill row above the credits list.
// Only meaningful for set_decorator role. Bails silently if the user has
// zero productions — the empty case nudges them to /productions instead.
try {
const r = await fetch('/api/productions');
if (!r.ok) return;
const { productions } = await r.json();
const header = document.querySelector('.credits-header');
if (!header || header.querySelector('.tag-prod')) return;
const div = document.createElement('div');
div.className = 'tag-prod';
if (!productions.length) {
div.innerHTML = `<span class="muted">Tag this piece with a production?</span> <a href="#" data-screen="productions">Add a production first →</a>`;
} else {
div.innerHTML = `
<label class="muted" for="tag-prod-select">Tag with production:</label>
<select id="tag-prod-select">
<option value="">— none —</option>
${productions.map(p => `<option value="${p.id}">${escapeHtml(p.title)}${p.year ? ' (' + p.year + ')' : ''}</option>`).join('')}
</select>
<button type="button" class="ghost" data-tag-prod-kind="${kind}" data-tag-prod-id="${id}">Tag</button>
<span class="tag-prod-status muted"></span>
`;
}
header.appendChild(div);
} catch {}
}
document.addEventListener('click', async e => {
const btn = e.target.closest('button[data-tag-prod-kind][data-tag-prod-id]');
if (!btn) return;
const pid = $('#tag-prod-select').value || '';
const kind = btn.dataset.tagProdKind;
const id = btn.dataset.tagProdId;
const endpoint = kind === 'closet' ? `/api/closet/${id}/production` : null;
if (!endpoint) return;
const r = await fetch(endpoint, { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ production_id: pid || null }) });
const j = await r.json().catch(() => ({}));
const status = btn.parentElement.querySelector('.tag-prod-status');
if (r.ok) { status.textContent = pid ? 'Tagged.' : 'Cleared.'; status.style.color = '#14572a'; }
else { status.textContent = j.error || 'Failed'; status.style.color = '#7a1717'; }
});
document.addEventListener('click', e => {
const tgt = e.target.closest('[data-credits-kind][data-credits-id]');
if (tgt) {
e.preventDefault();
openCredits(tgt.dataset.creditsKind, tgt.dataset.creditsId);
}
if (e.target.id === 'credits-back') {
e.preventDefault();
route('closet');
}
});
// ---------- Boot -----------------------------------------------------------
(async function boot() {
await Role.refresh(); // tick 15.5 — populate role + banner before first paint
await refreshFavorites(); // tick 24 — populate star state before card renders
const me = await fetch('/api/me').then(r => r.json());
if (!me.onboarded) { show('onboard'); onbRender(); }
// Tick 32: if user came in via /brands → ?brand= deep link, jump straight
// to the recs grid (which renders the filter pill) instead of duel.
else if (__brandDeepLink) route('recs');
else route('home');
})();