// quest.jsx — reusable QuestPlayer + CompletionView + Quest tab const { useState: useStateQ, useEffect: useEffectQ, useRef: useRefQ } = React; // Circular countdown ring function TimerRing({ progress, label, sub, tone = T.coral, size = 230 }) { const r = size / 2 - 12, c = 2 * Math.PI * r; return (
{label}
{sub &&
{sub}
}
); } // Quest Player — used by FTUE + main app function QuestPlayer({ title, seconds, steps = [], avaLine, onDone, onClose, firstRun, tone = T.coral }) { const needCount = !firstRun && seconds >= 30; const [phase, setPhase] = useStateQ(needCount ? 'count' : 'run'); const [count, setCount] = useStateQ(3); const [left, setLeft] = useStateQ(seconds); const [paused, setPaused] = useStateQ(false); useEffectQ(() => { if (phase !== 'count') return; if (count === 0) { setPhase('run'); return; } const t = setTimeout(() => setCount(c => c - 1), 700); return () => clearTimeout(t); }, [phase, count]); useEffectQ(() => { if (phase !== 'run' || paused) return; if (left <= 0) { const t = setTimeout(onDone, 500); return () => clearTimeout(t); } const t = setTimeout(() => setLeft(l => l - 1), 1000); return () => clearTimeout(t); }, [phase, left, paused]); if (phase === 'count') { return (
{count === 0 ? '시작' : count}
); } const mm = String(Math.floor(left / 60)).padStart(2, '0'); const ss = String(left % 60).padStart(2, '0'); return (
}/>
{/* steps */}
{steps.map((s, i) => (
{i + 1}
{s}
))}
{avaLine && (
“{avaLine}”
)}
setPaused(p => !p)} icon={}>{paused ? '재개' : '일시정지'} 완료
); } // Completion + XP reward (game-feel celebration) function CompletionView({ xp, streakDay, avaLine, primary, secondary, tertiary, firstRun, levelUp, pro, tone = T.coral }) { const [shownXp, setShownXp] = useStateQ(0); const [barFill, setBarFill] = useStateQ(false); const [shown, setShown] = useStateQ(false); const isCalm = tone === T.calm; useEffectQ(() => { const m = setTimeout(() => setShown(true), 40); const steps = 24, dur = 750; let i = 0; const iv = setInterval(() => { i++; setShownXp(Math.round((i / steps) * xp)); if (i >= steps) clearInterval(iv); }, dur / steps); const b = setTimeout(() => setBarFill(true), 450); return () => { clearInterval(iv); clearTimeout(b); clearTimeout(m); }; }, []); const rise = (delay) => ({ opacity: shown ? 1 : 0, transform: shown ? 'translateY(0) scale(1)' : 'translateY(14px) scale(0.92)', transition: `opacity .5s ${delay}s, transform .55s ${delay}s cubic-bezier(.2,1.3,.4,1)` }); const headline = firstRun ? '첫 Quest 완료!' : isCalm ? '회복 완료' : levelUp ? 'LEVEL UP!' : '완벽해요!'; const accent = isCalm ? T.calm : T.good; return (
{/* Ava celebrating */}
{headline}
{firstRun ? '오늘의 첫 건강 행동이 기록됐어요' : '오늘 건강 행동이 기록됐어요'}
{/* reward tiles */}
{/* XP bar */}
Lv.2 Daily Mover{barFill ? 120 : 80} / 200 XP
{avaLine && (
“{avaLine}”
)}
{secondary && {secondary.label}} {tertiary && {tertiary.label}} {primary.label}
); } function RewardTile({ icon, tone, value, label, style }) { return (
{value}
{label}
); } // celebratory confetti function Confetti() { const bits = Array.from({ length: 44 }); const cols = [T.coral, T.amber, T.calm, T.teal, T.good, T.pink]; return (
{bits.map((_, i) => { const round = i % 3 === 0; return (
); })}
); } // ── "오늘의 라인" — transit-map journey (original, multi-color) ── function TransitStation({ st, first, last, onStart }) { const c = catColor(st.cat); const isActive = st.state === 'active', isDone = st.state === 'done', isReward = st.state === 'reward', locked = st.state === 'locked'; const dotColor = isDone ? T.good : isReward ? T.amber : isActive ? c.c : T.surface2; const dotLip = isDone ? '#1E7A4F' : isReward ? T.amberDark : isActive ? c.d : '#D9D5CD'; return (
{/* rail */}
{!first &&
} {!last &&
}
{isDone ? : isReward ? : isActive ? : }
{/* station card */}
); } function QuestTab({ go, data, startQuest }) { const done = data.completedToday; const stations = data.questStations || [ { id: 'a', cat: 'mobility', icon: 'wind', title: '목·어깨 리셋', meta: '30초 · 앉아서', state: done ? 'done' : 'active', quest: { title: '어깨 리셋', seconds: 30, steps: ['어깨를 귀 쪽으로 올려요', '뒤로 천천히 크게 돌려요', '숨을 길게 내쉬어요'], avaLine: '속도보다 부드러움이 중요해요', xp: 10 } }, { id: 'b', cat: 'strength', icon: 'dumbbell', title: '코어 세우기', meta: '20초 · 앉아서', state: done ? 'active' : 'locked', quest: { title: '코어 세우기', seconds: 20, tone: T.violet, steps: ['등을 곧게 세워요', '배에 힘을 줘요', '천천히 호흡해요'], avaLine: '허리가 아니라 배에 힘을', xp: 12 } }, { id: 'c', cat: 'breath', icon: 'star', title: '보너스 상자', meta: '연속 2개 완료 시 열림', state: 'locked', reward: true }, { id: 'd', cat: 'recovery', icon: 'leaf', title: '회복 호흡', meta: '30초 · 침대에서', state: 'locked', quest: { title: '회복 호흡', seconds: 30, tone: T.calm, steps: ['편하게 누워요', '4초 들이마셔요', '6초 내쉬어요'], avaLine: '회복도 훈련이에요', xp: 12 } }, { id: 'e', cat: 'cardio', icon: 'bolt', title: '가볍게 흐르기', meta: '60초 · 서서', state: 'locked', quest: { title: '플로우', seconds: 60, tone: T.teal, steps: ['팔을 크게 돌려요', '제자리 걷기', '깊게 호흡해요'], avaLine: '천천히 리듬을 타요', xp: 18 } }, ]; const startStation = (s) => { if (s.reward) return; startQuest(s.quest); }; return ( {/* season banner — multi-hue */}
SEASON 2 · 오늘의 라인
몸을 깨우는 하루
완료
{done ? 1 : 0}/1
{/* transit line */}
{stations.map((s, i) => ( ))}
{/* swap */}
컨디션이 별로면 더 쉬운 라인으로 바꿔도 점수는 그대로예요.
); } Object.assign(window, { QuestPlayer, CompletionView, QuestTab, TimerRing, Confetti, RewardTile });