feat(gyeol): /gyeol 단일 페이지 통합 — 9 step state, localStorage 복구
- 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) <noreply@anthropic.com>
This commit is contained in:
36
app/gyeol/layout.tsx
Normal file
36
app/gyeol/layout.tsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
className="min-h-screen"
|
||||||
|
style={{
|
||||||
|
background: 'radial-gradient(ellipse at top, rgba(204,151,255,0.15) 0%, transparent 50%), linear-gradient(180deg, #060e20 0%, #000000 100%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
164
app/gyeol/page.tsx
Normal file
164
app/gyeol/page.tsx
Normal file
@@ -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<SurveyStep>('intro');
|
||||||
|
const [response, setResponse] = useState<Partial<SurveyResponse>>({});
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
|
const startedAtRef = useRef<number | null>(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<SurveyResponse>, nextStep: SurveyStep) {
|
||||||
|
setResponse((prev) => ({ ...prev, ...partial }));
|
||||||
|
setStep(nextStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack(prevStep: SurveyStep) {
|
||||||
|
setStep(prevStep);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFinalSubmit(partial: Partial<SurveyResponse>) {
|
||||||
|
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' && <IntroStep onStart={handleStart} />}
|
||||||
|
{step === 'q1' && (
|
||||||
|
<Q1Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => setStep('intro')}
|
||||||
|
onNext={(p) => applyPartialAndAdvance(p, 'q2')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'q2' && (
|
||||||
|
<Q2Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => goBack('q1')}
|
||||||
|
onNext={(p) => applyPartialAndAdvance(p, 'q3')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'q3' && (
|
||||||
|
<Q3Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => goBack('q2')}
|
||||||
|
onNext={(p) => applyPartialAndAdvance(p, 'q4')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'q4' && (
|
||||||
|
<Q4Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => goBack('q3')}
|
||||||
|
onNext={(p) => applyPartialAndAdvance(p, 'q5')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'q5' && (
|
||||||
|
<Q5Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => goBack('q4')}
|
||||||
|
onNext={(p) => applyPartialAndAdvance(p, 'q6')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'q6' && (
|
||||||
|
<Q6Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => goBack('q5')}
|
||||||
|
onNext={(p) => applyPartialAndAdvance(p, 'q7')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'q7' && (
|
||||||
|
<Q7Step
|
||||||
|
initial={response}
|
||||||
|
onPrev={() => goBack('q6')}
|
||||||
|
onSubmit={handleFinalSubmit}
|
||||||
|
submitting={submitting}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{step === 'thanks' && <ThanksStep emailEntered={!!response.email} />}
|
||||||
|
|
||||||
|
{submitError && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-4 py-3 rounded-xl bg-red-500/20 border border-red-400/40 text-red-200 text-sm">
|
||||||
|
{submitError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user