// app.jsx — router, global state, mount (load LAST) const { useState: useStateApp, useRef: useRefApp, useEffect: useEffectApp } = React; // Self-report quest player (hydration etc.) function SelfReport({ quest, onDone, onClose }) { return (
}/>

{quest.title}

완료했나요? 정직하게 체크하면 돼요.

마셨어요 나중에
); } function App() { const [phase, setPhase] = useStateApp('ftue'); const initTheme = (() => { try { return localStorage.getItem('fb_theme') || 'light'; } catch (e) { return 'light'; } })(); const [theme, setTheme] = useStateApp(initTheme); const [data, setData] = useStateApp({ level: 2, xp: 120, streak: 3, energy: 3, maxEnergy: 5, pro: false, nextSec: 1140, homeVariant: 'default', completedToday: false }); const [backend, setBackend] = useStateApp(null); const [route, setRoute] = useStateApp({ name: 'home', params: {} }); const hist = useRefApp([]); // apply theme synchronously on every render so it survives navigation window.applyTheme(theme); const toggleTheme = () => setTheme(t => t === 'dark' ? 'light' : 'dark'); useEffectApp(() => { let mounted = true; const api = window.FitbodyBackend; if (!api) return undefined; api.bootstrap().then(snapshot => { if (!mounted) return; setBackend(snapshot); setData(d => api.dataFromSnapshot ? api.dataFromSnapshot(snapshot, d) : d); }).catch(() => { if (!mounted) return; const snapshot = api.snapshot(); setBackend(snapshot); setData(d => api.dataFromSnapshot ? api.dataFromSnapshot(snapshot, d) : d); }); return () => { mounted = false; }; }, []); // energy recharge timer useEffectApp(() => { const iv = setInterval(() => { setData(d => { if (d.pro || d.energy >= d.maxEnergy) return d; if (d.nextSec <= 1) return { ...d, energy: Math.min(d.maxEnergy, d.energy + 1), nextSec: 1500 }; return { ...d, nextSec: d.nextSec - 1 }; }); }, 1000); return () => clearInterval(iv); }, []); const go = (name, params = {}) => { hist.current.push(route); setRoute({ name, params }); }; const back = () => { const h = hist.current; setRoute(h.length ? h.pop() : { name: 'home', params: {} }); }; const navTab = (tab) => { hist.current = []; setPhase('app'); setRoute({ name: tab, params: {} }); }; window.__nav = navTab; // spend 1 energy for an extra quest; free===true bypasses (daily core quest) const startQuest = (q, free) => { if (free || data.pro || data.energy > 0) { if (!free && !data.pro) setData(d => ({ ...d, energy: Math.max(0, d.energy - 1) })); if (window.FitbodyBackend) window.FitbodyBackend.beginQuest(q).catch(() => {}); go('player', { quest: q }); } else { go('energy', { quest: q }); } }; const applyCompletion = (questOrXp) => { const quest = typeof questOrXp === 'object' ? questOrXp : null; const xp = quest ? (quest.xp || 10) : (questOrXp || 0); if (quest && window.FitbodyBackend) { window.FitbodyBackend.completeQuest(quest).catch(() => {}); } setData(d => ({ ...d, xp: d.xp + xp, energy: d.pro ? d.energy : Math.min(d.maxEnergy, d.energy + 1), // completing tops up 1 streak: d.completedToday ? d.streak : d.streak + 1, completedToday: true, homeVariant: 'completed', })); }; if (phase === 'ftue') { return { setData(d => ({ ...d, completedToday: false, homeVariant: 'default' })); setPhase('app'); navTab('home'); }}/>; } const { name, params } = route; if (name === 'player') { const q = params.quest; if (q.selfReport) return go('completion', { quest: q })}/>; return go('completion', { quest: q })} onClose={back}/>; } if (name === 'completion') { const q = params.quest || {}; const xp = q.xp || 10; return { applyCompletion(q); navTab('home'); } }} secondary={{ label: 'Progress 보기', onClick: () => { applyCompletion(q); navTab('progress'); } }} />; } if (name === 'energy') return ; if (name === 'ad') return { setData(d => ({ ...d, energy: Math.min(d.maxEnergy, d.energy + 1) })); back(); }}/>; if (name === 'subscribe') return { if (window.FitbodyBackend) window.FitbodyBackend.subscribe('pro').then(url => { if (url) window.location.href = url; }).catch(() => {}); setData(d => ({ ...d, pro: true })); back(); }} back={back}/>; if (name === 'academy') return navTab('home')} startQuest={startQuest} data={data} spendEnergy={() => setData(d => ({ ...d, energy: d.pro ? d.energy : Math.max(0, d.energy - 1) }))} onReward={(xp) => setData(d => ({ ...d, xp: d.xp + (xp || 0) }))}/>; if (name === 'academyAt') return navTab('home')} startQuest={startQuest} data={data} spendEnergy={() => setData(d => ({ ...d, energy: d.pro ? d.energy : Math.max(0, d.energy - 1) }))} onReward={(xp) => setData(d => ({ ...d, xp: d.xp + (xp || 0) }))}/>; if (name === 'bodycheck') return startQuest(q)}/>; if (name === 'flow') return ; if (name === 'quest') return ; if (name === 'league') return ; if (name === 'invite') return ; if (name === 'progress') return ; if (name === 'profile') return ; if (name === 'settings') return ; if (name === 'notifications') return ; return ; } // scale phone to fit viewport function fitPhone() { const stage = document.getElementById('stage'); if (!stage) return; const pad = 32; const s = Math.min((window.innerWidth - pad) / PHONE_W, (window.innerHeight - pad) / PHONE_H); stage.style.transform = `scale(${Math.min(s, 1)})`; } window.addEventListener('resize', fitPhone); ReactDOM.createRoot(document.getElementById('phone-root')).render(); setTimeout(fitPhone, 50);