// 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 (
);
}
// 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) => (
))}
{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 && (
)}
{secondary && {secondary.label}}
{tertiary && {tertiary.label}}
{primary.label}
);
}
function RewardTile({ icon, tone, value, label, style }) {
return (
);
}
// 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 · 오늘의 라인
몸을 깨우는 하루
{/* transit line */}
{stations.map((s, i) => (
))}
{/* swap */}
컨디션이 별로면 더 쉬운 라인으로 바꿔도 점수는 그대로예요.
);
}
Object.assign(window, { QuestPlayer, CompletionView, QuestTab, TimerRing, Confetti, RewardTile });