From 262d6c3ed1631f36d3356af0bc6661630175a0cb Mon Sep 17 00:00:00 2001 From: gahusb Date: Sat, 16 May 2026 05:31:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(gyeol):=20/gyeol=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=86=B5=ED=95=A9=20=E2=80=94=20?= =?UTF-8?q?9=20step=20state,=20localStorage=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - layout: radial 그라데이션 배경 + metadata (robots noindex) - page: step state + Q1~Q7 컴포넌트 조합 - 진입 시 localStorage 복구 + step 변경 시 저장 + 제출 시 clear - 최종 제출: completion_seconds, user_agent, referrer, utm_* 자동 수집 - 에러 토스트 표시 Co-Authored-By: Claude Opus 4.7 (1M context) --- app/gyeol/layout.tsx | 36 ++++++++++ app/gyeol/page.tsx | 164 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 app/gyeol/layout.tsx create mode 100644 app/gyeol/page.tsx diff --git a/app/gyeol/layout.tsx b/app/gyeol/layout.tsx new file mode 100644 index 0000000..12d040f --- /dev/null +++ b/app/gyeol/layout.tsx @@ -0,0 +1,36 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'CONTOUR — 나를 더 선명하게 이해하는 3분', + description: '7 질문, 3분. 자기 이해·심리 영역 짧은 설문에 참여해주세요.', + openGraph: { + title: 'CONTOUR — 나를 더 선명하게 이해하는 3분', + description: '7 질문, 3분. 짧은 설문에 답해주세요.', + url: 'https://jaengseung-made.com/gyeol', + images: [ + { + url: 'https://jaengseung-made.com/og-image.png', + width: 1200, + height: 630, + alt: 'CONTOUR', + }, + ], + }, + robots: { + index: false, + follow: false, + }, +}; + +export default function GyeolLayout({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} diff --git a/app/gyeol/page.tsx b/app/gyeol/page.tsx new file mode 100644 index 0000000..e08e851 --- /dev/null +++ b/app/gyeol/page.tsx @@ -0,0 +1,164 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import IntroStep from './components/IntroStep'; +import Q1Step from './components/Q1Step'; +import Q2Step from './components/Q2Step'; +import Q3Step from './components/Q3Step'; +import Q4Step from './components/Q4Step'; +import Q5Step from './components/Q5Step'; +import Q6Step from './components/Q6Step'; +import Q7Step from './components/Q7Step'; +import ThanksStep from './components/ThanksStep'; +import type { SurveyResponse, SurveyStep } from '@/lib/survey/types'; +import { loadProgress, saveProgress, clearProgress } from '@/lib/survey/storage'; + +export default function GyeolPage() { + const [step, setStep] = useState('intro'); + const [response, setResponse] = useState>({}); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const startedAtRef = useRef(null); + + // 진입 시 localStorage 복구 + useEffect(() => { + const saved = loadProgress(); + if (saved) { + setStep(saved.step); + setResponse(saved.response); + startedAtRef.current = saved.startedAt; + } + }, []); + + // step 변경 시 진행 상태 저장 (질문 step만) + useEffect(() => { + if (step !== 'intro' && step !== 'thanks' && startedAtRef.current) { + saveProgress({ + step, + response, + startedAt: startedAtRef.current, + }); + } + }, [step, response]); + + function handleStart() { + if (!startedAtRef.current) { + startedAtRef.current = Date.now(); + } + setStep('q1'); + } + + function applyPartialAndAdvance(partial: Partial, nextStep: SurveyStep) { + setResponse((prev) => ({ ...prev, ...partial })); + setStep(nextStep); + } + + function goBack(prevStep: SurveyStep) { + setStep(prevStep); + } + + async function handleFinalSubmit(partial: Partial) { + const finalResponse: SurveyResponse = { + ...response, + ...partial, + completion_seconds: startedAtRef.current + ? Math.floor((Date.now() - startedAtRef.current) / 1000) + : undefined, + user_agent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined, + referrer: typeof document !== 'undefined' ? (document.referrer || undefined) : undefined, + utm_source: typeof window !== 'undefined' + ? (new URLSearchParams(window.location.search).get('utm_source') ?? undefined) + : undefined, + utm_medium: typeof window !== 'undefined' + ? (new URLSearchParams(window.location.search).get('utm_medium') ?? undefined) + : undefined, + utm_campaign: typeof window !== 'undefined' + ? (new URLSearchParams(window.location.search).get('utm_campaign') ?? undefined) + : undefined, + }; + + setSubmitting(true); + setSubmitError(null); + try { + const res = await fetch('/api/survey', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(finalResponse), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error ?? '제출 실패'); + } + clearProgress(); + setResponse(finalResponse); + setStep('thanks'); + } catch (e) { + setSubmitError(e instanceof Error ? e.message : '제출 실패'); + } finally { + setSubmitting(false); + } + } + + return ( + <> + {step === 'intro' && } + {step === 'q1' && ( + setStep('intro')} + onNext={(p) => applyPartialAndAdvance(p, 'q2')} + /> + )} + {step === 'q2' && ( + goBack('q1')} + onNext={(p) => applyPartialAndAdvance(p, 'q3')} + /> + )} + {step === 'q3' && ( + goBack('q2')} + onNext={(p) => applyPartialAndAdvance(p, 'q4')} + /> + )} + {step === 'q4' && ( + goBack('q3')} + onNext={(p) => applyPartialAndAdvance(p, 'q5')} + /> + )} + {step === 'q5' && ( + goBack('q4')} + onNext={(p) => applyPartialAndAdvance(p, 'q6')} + /> + )} + {step === 'q6' && ( + goBack('q5')} + onNext={(p) => applyPartialAndAdvance(p, 'q7')} + /> + )} + {step === 'q7' && ( + goBack('q6')} + onSubmit={handleFinalSubmit} + submitting={submitting} + /> + )} + {step === 'thanks' && } + + {submitError && ( +
+ {submitError} +
+ )} + + ); +}