/** * 정서 설문 7종 (PRD §3.2) — 4지/5지 선택 + EP 감정 슬라이더 * * 핵심 로직: * - 각 카테고리 완료 시 백엔드에 즉시 저장 (PUT /api/cntdata/survey) * - localStorage는 중간 저장용 (브라우저 종료/새로고침 대비) * - 홈 진행상황은 백엔드 기준 (각 카테고리 저장 시마다 갱신) * - 마지막 카테고리 완료 시 /home 로 이동 * - 수정사항-037: 백엔드 설문 데이터를 로드하여 localStorage와 병합 (기기 간 동기화) * Phase D: MD3 디자인 토큰 + 공용 컴포넌트(PillButton, InfoBanner) 적용 */ import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import styled from 'styled-components'; import { SURVEY_CATEGORIES, FOUR_CHOICE_LABELS, FIVE_CHOICE_LABELS, EP_SLIDER_LABELS, EP_SURVEY_NEUTRAL_IMAGES, SurveyCategory, } from '../data/surveys'; import { http } from '../lib/api'; import { useWakeLock } from '../hooks/useWakeLock'; import PillButton from '../components/PillButton'; import { getSurveyKey } from '../lib/storage'; import { useAuthStore } from '../store/auth'; const BACKEND_KEYS: Record = { phq: 'PHQ', oci: 'OCI', kaq: 'KAQ', gad7: 'GAD', kspin: 'KSPIN', asrs: 'ASRS', ep: 'EPS', }; type Answer = { value?: string; happy?: number; sad?: number; angry?: number }; type Answers = Record; type BackendCntData = Partial>; function shuffle(arr: T[]): T[] { const a = [...arr]; for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; } function loadAnswersFromLocal(userId: number): Answers { try { const raw = localStorage.getItem(getSurveyKey(userId)); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } /** * 백엔드 설문 데이터를 로드 (기기 간 동기화) * localStorage 데이터가 있으면 localStorage 우선, 없으면 백엔드 데이터 사용 */ async function loadAnswersFromBackend(): Promise { try { const res = await http.get<{ cntData: BackendCntData | null }>('/api/cntdata/current'); if (res.cntData) { return res.cntData; } } catch { // 백엔드에서 데이터를 가져오지 못해도 계속 진행 } return {}; } /** * localStorage와 백엔드 설문 데이터 병합 * - 백엔드(현재 세션) 데이터 우선 * - localStorage는 백엔드에 데이터가 없을 때만 복구용으로 사용 * (이전 세션 데이터가 현재 세션에混入되는 문제 방지) */ function mergeAnswers(local: Answers, backend: BackendCntData): Answers { const merged: Answers = {}; // 백엔드 키(PHQ 등)를 프론트 키(phq 등)로 매핑 const BACKEND_TO_FRONT: Record = { PHQ: 'phq', OCI: 'oci', KAQ: 'kaq', GAD: 'gad7', KSPIN: 'kspin', ASRS: 'asrs', EP: 'ep', }; for (const [backendKey, answers] of Object.entries(backend)) { const frontKey = BACKEND_TO_FRONT[backendKey]; if (!frontKey) continue; if (Array.isArray(answers) && answers.length > 0) { // 백엔드 데이터 우선 merged[frontKey] = answers; } } // localStorage는 백엔드에 데이터가 없을 때만 복구용으로 사용 for (const [frontKey, answers] of Object.entries(local)) { if (Array.isArray(answers) && answers.length > 0) { // 백엔드에 없으면 localStorage 사용 (복구) const BE_KEYS = ['phq', 'oci', 'kaq', 'gad7', 'kspin', 'asrs', 'ep']; if (BE_KEYS.includes(frontKey) && !merged[frontKey]) { merged[frontKey] = answers; } } } return merged; } /* ── styled ────────────────────────────────────── */ const Bar = styled.div` position: sticky; top: 0; z-index: 10; background: var(--md3-background); padding: 8px 0 12px; border-bottom: 1px solid var(--md3-outline-variant); `; const TabRow = styled.div` display: flex; justify-content: center; gap: 4px; overflow-x: auto; padding-bottom: 4px; scrollbar-width: none; &::-webkit-scrollbar { display: none; } `; const Tab = styled.button<{ $active?: boolean }>` flex: 0 0 auto; min-width: 40px; height: 32px; border-radius: 16px; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 13px; font-weight: 600; background: ${(p) => (p.$active ? 'var(--md3-primary)' : 'var(--md3-surface-variant)')}; color: ${(p) => (p.$active ? 'var(--md3-on-primary)' : 'var(--md3-on-surface-variant)')}; border: 1px solid ${(p) => (p.$active ? 'var(--md3-primary)' : 'var(--md3-outline-variant)')}; text-align: center; transition: all 0.15s; `; const Progress = styled.div` height: 4px; border-radius: 2px; background: var(--md3-surface-variant); overflow: hidden; margin-top: 8px; div { height: 100%; background: var(--md3-primary); transition: width 0.3s ease-out; border-radius: 2px; } `; const Card = styled.div` background: var(--md3-surface); border-radius: 16px; padding: 24px 20px; margin-top: 16px; display: flex; flex-direction: column; border: 1px solid var(--md3-outline-variant); box-shadow: 0 2px 8px rgba(15, 23, 42, 0.06); `; const DescArea = styled.div` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 13px; color: var(--md3-on-surface-variant); margin-bottom: 12px; margin-top: 4px; min-height: 64px; max-height: 120px; line-height: 1.6; display: flex; align-items: flex-start; `; const Question = styled.div` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 17px; font-weight: 600; line-height: 1.5; color: var(--md3-on-surface); min-height: 96px; display: flex; align-items: flex-start; `; const Option = styled.button<{ $selected?: boolean }>` width: 100%; padding: 14px 16px; margin-bottom: 10px; text-align: left; background: ${(p) => (p.$selected ? 'var(--md3-primary-container)' : 'var(--md3-surface-variant)')}; border: 1px solid ${(p) => (p.$selected ? 'var(--md3-primary)' : 'var(--md3-outline-variant)')}; border-radius: 12px; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 15px; font-weight: ${(p) => (p.$selected ? 600 : 500)}; color: ${(p) => (p.$selected ? 'var(--md3-on-primary-container)' : 'var(--md3-on-surface)')}; transition: all 0.15s; &:hover:not(:disabled) { background: ${(p) => (p.$selected ? 'var(--md3-primary)' : 'var(--md3-surface-variant)')}; border-color: var(--md3-primary); color: ${(p) => (p.$selected ? 'var(--md3-on-primary)' : 'var(--md3-on-surface)')}; } &:active:not(:disabled) { transform: scale(0.98); } &:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--md3-primary-container); } @media (hover: none) and (pointer: coarse) { &:hover:not(:disabled) { background: ${(p) => (p.$selected ? 'var(--md3-primary-container)' : 'var(--md3-surface-variant)')}; border-color: ${(p) => (p.$selected ? 'var(--md3-primary)' : 'var(--md3-outline-variant)')}; } } `; const SliderCard = styled(Card)` align-items: center; text-align: center; padding: 20px; `; const Face = styled.img` width: 200px; height: 200px; object-fit: cover; border-radius: 50%; background: var(--md3-surface-variant); margin-bottom: 16px; `; const EPPrompt = styled.div` font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 14px; font-weight: 600; color: var(--md3-on-surface); line-height: 1.5; margin-bottom: 16px; `; const SliderRow = styled.div` width: 100%; margin-bottom: 12px; label { display: flex; justify-content: space-between; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 13px; font-weight: 600; color: var(--md3-on-surface-variant); margin-bottom: 6px; } input[type='range'] { width: 100%; accent-color: var(--md3-primary); height: 6px; border-radius: 3px; } `; const NavBtns = styled.div` margin-top: auto; display: flex; gap: 10px; padding-top: 8px; button { flex: 1; padding: 14px; border-radius: 12px; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-weight: 600; } .back { background: var(--md3-surface); border: 1px solid var(--md3-outline); color: var(--md3-on-surface); } .next { background: var(--md3-primary); color: var(--md3-on-primary); border: none; } .next:disabled { background: var(--md3-outline-variant); color: var(--md3-on-surface-variant); } `; const ErrorModal = styled.div` position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.45); `; const ErrorBox = styled.div` background: var(--md3-surface); border-radius: 16px; padding: 28px 24px; width: 85%; max-width: 320px; text-align: center; box-shadow: 0 8px 32px rgba(15, 23, 42, 0.15); h3 { font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 17px; font-weight: 600; color: var(--md3-on-surface); margin-bottom: 8px; } p { font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-size: 13px; color: var(--md3-on-surface-variant); margin-bottom: 20px; line-height: 1.5; white-space: pre-line; } .btns { display: flex; gap: 10px; } .btns button { flex: 1; padding: 12px; border-radius: 10px; font-family: 'Inter', 'Pretendard', system-ui, sans-serif; font-weight: 600; font-size: 14px; } .retry { background: var(--md3-primary); color: var(--md3-on-primary); border: none; } .home { background: var(--md3-surface); border: 1px solid var(--md3-outline); color: var(--md3-on-surface); } `; /* ── component ────────────────────────────────── */ export default function Survey() { const nav = useNavigate(); const user = useAuthStore((s) => s.user); useWakeLock(true); const epImages = useMemo(() => shuffle(EP_SURVEY_NEUTRAL_IMAGES).slice(0, 10), []); // 수정사항-037: 초기값은 빈 객체, useEffect에서 localStorage + 백엔드 병합 데이터 로드 const [answers, setAnswers] = useState({}); const [catIdx, setCatIdx] = useState(0); const [qIdx, setQIdx] = useState(0); const [submitting] = useState(false); const [finished, setFinished] = useState(null); const [error, setError] = useState(null); const sessionReady = useRef(false); const [initialized, setInitialized] = useState(false); // ── localStorage 자동 저장 (사용자별 키) ── useEffect(() => { if (user && initialized) { localStorage.setItem(getSurveyKey(user.id), JSON.stringify(answers)); } }, [answers, user, initialized]); // ── 수정사항-037: 마운트 시 localStorage + 백엔드 데이터 병합 로드 ── useEffect(() => { const loadData = async () => { // 1. localStorage에서 로드 const localData = user ? loadAnswersFromLocal(user.id) : {}; // 2. 백엔드에서 현재 설문 데이터 로드 (기기 간 동기화) const backendData = await loadAnswersFromBackend(); // 3. 병합: localStorage 우선, 없으면 백엔드 데이터 const merged = mergeAnswers(localData, backendData); setAnswers(merged); setInitialized(true); }; loadData(); }, [user]); // ── 마운트 시: 완료 여부 확인 + 세션은 조용히 시작 ── useEffect(() => { if (!initialized) return; // 데이터 로드 완료 후에만 진행 http .get<{ done: Record }>('/api/cntdata/status') .then((s) => { const doneFields = ['PHQ', 'OCI', 'KAQ', 'GAD', 'KSPIN', 'ASRS', 'EPS']; const allDone = doneFields.every((k) => s.done?.[k]); setFinished(allDone); // 세션 시작 실패해도 설문 진행은 허용 (저장 시 재시도) if (!allDone) ensureSession(); }) .catch(() => { setFinished(false); ensureSession(); }); }, [initialized]); /** 세션 시작 — 저장 시 실패하면 예외 전달 */ const ensureSession = async (throwOnError = false) => { if (sessionReady.current) return; try { await http.post('/api/cntdata/start'); sessionReady.current = true; } catch (err) { if (throwOnError) throw err; } }; // ── 첫 미완료 카테고리로 이동 ── useEffect(() => { if (!initialized) return; const idx = SURVEY_CATEGORIES.findIndex((c) => { const cnt = c.key === 'ep' ? 10 : c.items.length; return (answers[c.key] ?? []).length < cnt; }); if (idx >= 0) setCatIdx(idx); }, [initialized, answers]); const category: SurveyCategory = SURVEY_CATEGORIES[catIdx]; const isEp = category.key === 'ep'; const items = isEp ? epImages : category.items; const catAnswers = answers[category.key] ?? []; const answeredCount = isEp ? catAnswers.filter((a) => (a.happy ?? 0) > 0 || (a.sad ?? 0) > 0 || (a.angry ?? 0) > 0).length : catAnswers.filter((a) => a.value).length; const progress = ((catIdx + answeredCount / items.length) / SURVEY_CATEGORIES.length) * 100; /** 카테고리 하나를 백엔드에 즉시 저장 */ const saveCategoryToBackend = async (catKey: string, data: Answer[]) => { // 세션이 없으면 생성 재시도 sessionReady.current = false; await ensureSession(true); const payload: Record = {}; payload[BACKEND_KEYS[catKey]] = data; await http.put('/api/cntdata/survey', payload); }; const saveAnswer = async (value: Answer, advance = true) => { const next = { ...answers, [category.key]: [...catAnswers] }; next[category.key][qIdx] = value; setAnswers(next); if (!advance) return; if (qIdx < items.length - 1) { setQIdx(qIdx + 1); return; } // 카테고리 완료 → 백엔드에 즉시 저장 try { await saveCategoryToBackend(category.key, next[category.key]); } catch (err: any) { const msg = err?.message ?? '알 수 없는 오류'; if (msg.includes('세션이 만료') || msg.includes('토큰이 만료')) { setError('로그인 세션이 만료되었습니다.\n다시 로그인해주세요.'); } else { setError(`설문 저장에 실패했습니다.\n(${msg})\n\n[다시 시도]를 눌러주세요.`); } return; } // 다음 미완료 카테고리 or 완료 const nextIdx = SURVEY_CATEGORIES.findIndex((c, i) => { if (i <= catIdx) return false; const cnt = c.key === 'ep' ? 10 : c.items.length; return (next[c.key] ?? []).length < cnt; }); if (nextIdx >= 0) { setCatIdx(nextIdx); setQIdx(0); } else { // 모든 카테고리 완료 → 홈으로 (사용자별 키 삭제) setSubmitting(true); if (user) { localStorage.removeItem(getSurveyKey(user.id)); } nav('/home'); } }; /** 에러 모달에서 "다시 시도" — 현재 카테고리 저장 재시도 */ const retrySave = async () => { const data = answers[category.key] ?? []; try { await saveCategoryToBackend(category.key, data); } catch (err: any) { const msg = err?.message ?? '알 수 없는 오류'; setError(`설문 저장에 실패했습니다.\n(${msg})\n\n[다시 시도]를 눌러주세요.`); return; } // 성공 → 다음 카테고리 or 완료 const nextIdx = SURVEY_CATEGORIES.findIndex((c, i) => { if (i <= catIdx) return false; const cnt = c.key === 'ep' ? 10 : c.items.length; return (answers[c.key] ?? []).length < cnt; }); if (nextIdx >= 0) { setCatIdx(nextIdx); setQIdx(0); } else { if (user) { localStorage.removeItem(getSurveyKey(user.id)) }; nav('/home'); } }; const item = isEp ? { text: epImages[qIdx] } : category.items[qIdx]; const answered = catAnswers[qIdx]; if (finished === true) { return (
🎉

설문을 모두 완료하였습니다.

24시간이 지난 후 다시 설문을 시작할 수 있습니다.

); } return (
{error && ( setError(null)}> e.stopPropagation()}>

알림

{error}

)} {SURVEY_CATEGORIES.map((c, i) => ( {i + 1} ))}
{category.description.join(' ')} {!isEp && ( {qIdx + 1}. {item.text} {(category.key === 'phq' || category.key === 'gad7' ? FOUR_CHOICE_LABELS : FIVE_CHOICE_LABELS).map((opt) => ( ))} )} {isEp && ( 이 사진의 감정을 선택하고 정도를 체크해주세요. {EP_SLIDER_LABELS.map((s) => { const v = (answered?.[s.key as keyof Answer] as number) ?? 0; return ( { const val = Number(e.target.value); saveAnswer( { happy: s.key === 'happy' ? val : (answered?.happy ?? 0), sad: s.key === 'sad' ? val : (answered?.sad ?? 0), angry: s.key === 'angry' ? val : (answered?.angry ?? 0), }, false, ); }} /> ); })} )} {!isEp && ( qIdx > 0 && setQIdx(qIdx - 1)} disabled={qIdx === 0} > 이전 )} {isEp && ( saveAnswer(answered ?? { happy: 0, sad: 0, angry: 0 }, true)} disabled={ !(answered && ((answered.happy ?? 0) > 0 || (answered.sad ?? 0) > 0 || (answered.angry ?? 0) > 0)) || submitting } > {qIdx === items.length - 1 ? '완료' : '다음'} )} {!isEp && ( saveAnswer({ value: answered?.value ?? 'never' })} disabled={!answered || submitting} > {qIdx === items.length - 1 ? '완료' : '다음'} )}
); }