# CONTOUR — PMF 인터뷰 설문 사이트 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** PMF 검증용 7-질문 설문 사이트 `/gyeol` 구축 + DB 저장 + Resend 즉시 확인 메일 + admin 대시보드(`/admin/survey`). 불특정 다수 익명 응답, 단일 페이지 + step state. **Architecture:** Next.js 16 App Router 클라이언트 컴포넌트로 단일 페이지 안에 step 9개(intro/q1-q7/thanks) state 관리. localStorage progress 저장. Supabase RLS — anon INSERT만, SELECT는 service role(admin) 전용. POST /api/survey가 보존 + Resend 확인 메일. Admin은 기존 admin HMAC 패턴. **Tech Stack:** Next.js 16 App Router + TS + Tailwind v4, Supabase Auth (`@supabase/ssr`), Resend(`resend` 패키지), `lib/security.ts` 의 sanitizeStr/isValidEmail/checkRateLimit/getClientIp 차용. **Spec:** `docs/superpowers/specs/2026-05-16-contour-pmf-survey-design.md` **⚠️ Subagent commit 주의**: Phase 2에서 일부 subagent의 commit이 sandboxing으로 git에 반영 안 되는 이슈 있었음. 모든 task 마지막 step에 `git log --oneline -3` 직접 확인 필수. HEAD가 본인 commit 아니면 BLOCKED 보고. --- ## File Structure ### 신규 생성 | 파일 | 책임 | |---|---| | `supabase/migrations/2026-05-16-create-survey-responses.sql` | DB 테이블 + RLS + index | | `lib/survey/types.ts` | TypeScript types: SurveyStep, SurveyResponse | | `lib/survey/questions.ts` | 7 질문 옵션 정의 (UI 데이터 SSOT) | | `lib/survey/storage.ts` | localStorage progress save/restore | | `app/gyeol/page.tsx` | 단일 페이지 (step state, 9 step 전환) | | `app/gyeol/layout.tsx` | metadata + OG + robots: noindex | | `app/gyeol/components/IntroStep.tsx` | step 'intro' — CONTOUR 로고 + 시작 버튼 | | `app/gyeol/components/QuestionLayout.tsx` | 질문 단계 공통 wrapper (progress + 헤더 + 본문 + 이전/다음) | | `app/gyeol/components/ProgressBar.tsx` | 진행률 (현재/7) | | `app/gyeol/components/Q1Step.tsx` | Q1 식별 (드롭다운 2개) | | `app/gyeol/components/Q2Step.tsx` | Q2 자각 빈도 (라디오 5) | | `app/gyeol/components/Q3Step.tsx` | Q3 도구 사용 (멀티 체크 + 기타) | | `app/gyeol/components/Q4Step.tsx` | Q4 비용 (라디오 6) | | `app/gyeol/components/Q5Step.tsx` | Q5 만족도 (라디오 8 + 1-5점) | | `app/gyeol/components/Q6Step.tsx` | Q6 자유 의견 (textarea) | | `app/gyeol/components/Q7Step.tsx` | Q7 이메일 (옵션 + 입력) | | `app/gyeol/components/ThanksStep.tsx` | step 'thanks' — 감사 메시지 | | `app/api/survey/route.ts` | POST 응답 저장 + Resend 확인 메일 | | `app/admin/survey/page.tsx` | admin 대시보드 (목록 + 카운트 + CSV) | | `app/api/admin/survey/route.ts` | GET 목록(JSON) + CSV | ### 수정 | 파일 | 변경 | |---|---| | `app/components/DashboardShell.tsx` | `STANDALONE_PATHS = ['/login', '/signup', '/admin']` → `[..., '/gyeol']` 1줄 추가 | | `app/admin/components/AdminSidebar.tsx` | NAV_ITEMS 배열 끝에 "설문 응답" 추가 | --- ## Task 순서 + 의존성 ``` Phase A — 기반 (4 task) A1 (migration) → A2 (types) → A3 (questions) → A4 (storage) Phase B — UI 컴포넌트 (8 task) B1 (ProgressBar) → B2 (QuestionLayout) → B3 (IntroStep) → B4 (Q1+Q2 묶음, 라디오/드롭다운 패턴 정착) → B5 (Q3, 멀티 체크 + 기타) → B6 (Q4+Q5 묶음, 라디오 패턴 재사용) → B7 (Q6+Q7 묶음, textarea + 이메일 옵션) → B8 (ThanksStep) Phase C — 통합 + API (4 task) C1 (app/gyeol/page.tsx, layout.tsx — 단일 페이지 통합) → C2 (/api/survey POST) → C3 (DashboardShell standalone 추가) → C4 (/api/admin/survey GET + CSV) Phase D — Admin UI (2 task) D1 (/admin/survey/page.tsx) → D2 (AdminSidebar 메뉴 추가) Phase E — 검증 (1 task) E1 (build + lint + 시각 회귀 + CEO 운영 안내) ``` 총 19 task. 각 task 작음 (10~80 LOC). UI 컴포넌트 묶음으로 task 압축. --- ## Phase A — 기반 ## Task A1: Supabase migration — survey_responses 테이블 **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\supabase\migrations\2026-05-16-create-survey-responses.sql` - [ ] **Step 1: SQL 파일 작성** ```sql -- CONTOUR PMF 설문 응답 저장. -- 불특정 다수 익명 응답: anon INSERT 허용, SELECT는 service role(admin)만. create table public.survey_responses ( id uuid primary key default gen_random_uuid(), created_at timestamptz not null default now(), -- Q1 식별 age_range text, status text, -- Q2 자각 빈도 awareness_freq text, -- Q3 도구 사용 (멀티) tools_used text[], tools_other text, -- Q4 비용 cost_range text, -- Q5 만족도 best_tool text, best_satisfy int, -- Q6 자유 의견 (핵심 자발 발화) free_opinion text, -- Q7 이메일 (옵션) email text, email_confirmation_sent boolean default false, -- 메타 user_agent text, referrer text, utm_source text, utm_medium text, utm_campaign text, -- 분석용 completion_seconds int ); create index idx_survey_created on public.survey_responses(created_at desc); create index idx_survey_email on public.survey_responses(email) where email is not null; -- RLS alter table public.survey_responses enable row level security; -- anon insert 허용 (불특정 다수 응답 받기) create policy "anon insert survey" on public.survey_responses for insert to anon with check (true); -- SELECT 정책 없음 → service role(admin)만 조회 가능 ``` - [ ] **Step 2: 적용은 운영자 수동 (CEO Supabase 콘솔 SQL Editor)** 이 task는 SQL 파일 commit만. 실제 supabase 적용은 CEO 수동 단계 (E1에서 안내). - [ ] **Step 3: 커밋** ```bash git add supabase/migrations/2026-05-16-create-survey-responses.sql git commit -m "$(cat <<'EOF' feat(db): survey_responses 테이블 마이그레이션 — CONTOUR PMF 설문 - anon INSERT 허용 (불특정 다수 응답) - SELECT 정책 없음 → service role(admin)만 조회 가능 - index: created_at desc + email partial - 메타: user_agent, referrer, utm_*, completion_seconds Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3 직접 확인** ```bash git log --oneline -3 ``` 기대: HEAD = 본인 commit, 직전 = `82feb14` (CONTOUR spec). --- ## Task A2: lib/survey/types.ts **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\lib\survey\types.ts` - [ ] **Step 1: 작성** ```ts /** * CONTOUR 설문 타입. * survey_responses 테이블 스키마와 1:1 대응. */ export type SurveyStep = | 'intro' | 'q1' | 'q2' | 'q3' | 'q4' | 'q5' | 'q6' | 'q7' | 'thanks'; export const QUESTION_STEPS: SurveyStep[] = ['q1', 'q2', 'q3', 'q4', 'q5', 'q6', 'q7']; export const TOTAL_QUESTIONS = QUESTION_STEPS.length; // 7 export interface SurveyResponse { // Q1 age_range?: string; status?: string; // Q2 awareness_freq?: string; // Q3 tools_used?: string[]; tools_other?: string; // Q4 cost_range?: string; // Q5 best_tool?: string; best_satisfy?: number; // Q6 free_opinion?: string; // Q7 email?: string; // 메타 (제출 시 자동 채워짐) user_agent?: string; referrer?: string; utm_source?: string; utm_medium?: string; utm_campaign?: string; completion_seconds?: number; } export interface SavedProgress { step: SurveyStep; response: SurveyResponse; startedAt: number; // ms epoch } ``` - [ ] **Step 2: 린트** ```bash npx eslint lib/survey/types.ts ``` Expected: exit 0. - [ ] **Step 3: 커밋** ```bash git add lib/survey/types.ts git commit -m "$(cat <<'EOF' feat(survey): lib/survey/types — SurveyStep, SurveyResponse, SavedProgress 7 질문 step 정의 + 응답 객체 타입. survey_responses 테이블과 1:1 대응. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3 직접 확인** --- ## Task A3: lib/survey/questions.ts — 7 질문 옵션 정의 **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\lib\survey\questions.ts` - [ ] **Step 1: 작성** ```ts /** * CONTOUR 설문 7 질문 옵션 SSOT. * UI 컴포넌트(Q1Step ~ Q7Step)가 이 데이터를 import. * spec markdown의 7 질문과 1:1 대응. */ export const AGE_RANGES = ['10대', '20대', '30대', '40대', '50대+'] as const; export const STATUSES = [ '직장인', '학생', '자영업', '프리랜서', '취업준비', '휴식 중', '기타', ] as const; export const AWARENESS_FREQS = [ '거의 매일 그래요', '자주 그래요', '가끔 그래요', '별로 없어요', '한 번도 없어요', ] as const; export const TOOLS_OPTIONS = [ '사주 / 타로', 'MBTI / 성격 검사', '심리 상담', '자기계발 책 / 강의', '친구·가족과 대화', '일기 / 글쓰기', '검색 / 유튜브', '그냥 시간이 풀어줌', '아무것도 안 함', ] as const; export const COST_RANGES = [ '0원', '1만원 이하', '1~5만원', '5~10만원', '10~30만원', '30만원 이상', ] as const; export const BEST_TOOLS = [ '사주 / 타로', 'MBTI', '심리 상담', '책 / 강의', '대화', '일기 / 글쓰기', '시간', '도움 된 게 없음', ] as const; export const SATISFY_SCALE = [1, 2, 3, 4, 5] as const; // 질문 헤더 카피 export const QUESTION_HEADERS: Record = { q1: { title: '안녕하세요. 짧게 자기 소개부터.', subtitle: '나이대와 지금 상황을 알려주세요.', }, q2: { title: "최근 1년 안에 '내가 뭘 원하는지 모르겠다'고 느낀 적 있어요?", }, q3: { title: '그럴 때 어떻게 풀어가시나요?', subtitle: '해본 거 모두 골라주세요. (복수 선택)', }, q4: { title: '지난 1년간 자기 이해·심리 영역에 돈 쓴 거 다 합쳐서 얼마쯤?', }, q5: { title: '그중 가장 도움 됐던 거 + 만족도', }, q6: { title: "혹시 '내가 진짜 알고 싶었던 건 이런 거였는데...' 하는 게 있나요?", subtitle: '자유롭게 적어주세요. 안 적으셔도 괜찮아요.', }, q7: { title: '이런 도구가 나오면 알려드릴까요?', subtitle: '결과를 받고 싶으시면 이메일을 남겨주세요.', }, }; ``` - [ ] **Step 2: 린트** ```bash npx eslint lib/survey/questions.ts ``` - [ ] **Step 3: 커밋** ```bash git add lib/survey/questions.ts git commit -m "$(cat <<'EOF' feat(survey): lib/survey/questions — 7 질문 옵션 SSOT 각 질문의 라디오/체크/드롭다운 옵션 배열 + 헤더 카피. spec markdown의 7 질문 그대로 반영 (단어 '결' 등 한글 컨셉어 제거 — CONTOUR 영문 단독). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3** --- ## Task A4: lib/survey/storage.ts — localStorage helpers **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\lib\survey\storage.ts` - [ ] **Step 1: 작성** ```ts import type { SavedProgress } from './types'; const STORAGE_KEY = 'gyeol_survey_progress_v1'; /** * Progress 저장. 새로고침 후 복구용. * SSR 환경에서는 noop. */ export function saveProgress(progress: SavedProgress): void { if (typeof window === 'undefined') return; try { localStorage.setItem(STORAGE_KEY, JSON.stringify(progress)); } catch { // storage quota exceeded 등 — 무시 (응답 자체에 영향 X) } } /** * Progress 복구. 없거나 파싱 실패 시 null. */ export function loadProgress(): SavedProgress | null { if (typeof window === 'undefined') return null; try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; return JSON.parse(raw) as SavedProgress; } catch { return null; } } /** * 제출 성공 시 progress 삭제. */ export function clearProgress(): void { if (typeof window === 'undefined') return; try { localStorage.removeItem(STORAGE_KEY); } catch { /* noop */ } } ``` - [ ] **Step 2: 린트** ```bash npx eslint lib/survey/storage.ts ``` - [ ] **Step 3: 커밋** ```bash git add lib/survey/storage.ts git commit -m "$(cat <<'EOF' feat(survey): lib/survey/storage — localStorage progress save/load/clear 새로고침 시 step + response 복구. SSR safe (window 체크). Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3** --- # Phase B — UI 컴포넌트 (8 task) ## Task B1: ProgressBar 컴포넌트 **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\ProgressBar.tsx` - [ ] **Step 1: 작성** ```tsx import { QUESTION_STEPS, TOTAL_QUESTIONS, type SurveyStep } from '@/lib/survey/types'; interface Props { step: SurveyStep; } /** * 상단 진행률 바. * intro/thanks에서는 렌더링 안 됨 (질문 step 만 표시). */ export default function ProgressBar({ step }: Props) { const idx = QUESTION_STEPS.indexOf(step as 'q1'); if (idx < 0) return null; const current = idx + 1; const percent = (current / TOTAL_QUESTIONS) * 100; return (
{current}/{TOTAL_QUESTIONS}
); } ``` - [ ] **Step 2: 린트** ```bash npx eslint app/gyeol/components/ProgressBar.tsx ``` - [ ] **Step 3: 커밋** ```bash git add app/gyeol/components/ProgressBar.tsx git commit -m "$(cat <<'EOF' feat(gyeol): ProgressBar — 진행률 (보라/시안 그라데이션 라인) intro/thanks step에서는 미렌더. q1~q7만 표시. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3** --- ## Task B2: QuestionLayout 공통 wrapper **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\QuestionLayout.tsx` - [ ] **Step 1: 작성** ```tsx 'use client'; import type { ReactNode } from 'react'; import ProgressBar from './ProgressBar'; import type { SurveyStep } from '@/lib/survey/types'; import { QUESTION_HEADERS } from '@/lib/survey/questions'; interface Props { step: SurveyStep; children: ReactNode; // 본문 (옵션 입력 컴포넌트) onPrev?: () => void; // 이전 (없으면 미렌더) onNext: () => void; // 다음 nextLabel?: string; // 기본 '다음' nextDisabled?: boolean; submitting?: boolean; // Q7 전송 중 표시 } export default function QuestionLayout({ step, children, onPrev, onNext, nextLabel = '다음', nextDisabled = false, submitting = false, }: Props) { const header = QUESTION_HEADERS[step]; return (
{/* 질문 헤더 */}

{header?.title}

{header?.subtitle && (

{header.subtitle}

)}
{/* 본문 */}
{children}
{/* 네비게이션 */}
{onPrev && ( )}
); } ``` - [ ] **Step 2: 린트** ```bash npx eslint app/gyeol/components/QuestionLayout.tsx ``` - [ ] **Step 3: 커밋** ```bash git add app/gyeol/components/QuestionLayout.tsx git commit -m "$(cat <<'EOF' feat(gyeol): QuestionLayout — 질문 단계 공통 wrapper ProgressBar + 헤더 + 본문 slot + 이전/다음 네비게이션. nextDisabled로 validation 제어. submitting 시 버튼 비활성. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3** --- ## Task B3: IntroStep **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\IntroStep.tsx` - [ ] **Step 1: 작성** ```tsx 'use client'; interface Props { onStart: () => void; } /** * 인트로 step — CONTOUR 로고 + 한글 부제 + 시작 버튼. * spec design PNG 1번째 화면 참조. */ export default function IntroStep({ onStart }: Props) { return (
{/* 로고 */}

CONTOUR

나를 더 선명하게 이해하는 3분

{/* 시작 버튼 */}

7 질문 · 약 3분

); } ``` - [ ] **Step 2: 린트** ```bash npx eslint app/gyeol/components/IntroStep.tsx ``` - [ ] **Step 3: 커밋** ```bash git add app/gyeol/components/IntroStep.tsx git commit -m "$(cat <<'EOF' feat(gyeol): IntroStep — CONTOUR 로고 그라데이션 + 부제 + 시작 버튼 영문 단독 브랜드, 한글 부제 "나를 더 선명하게 이해하는 3분". 보라/시안 그라데이션 텍스트, 7 질문 안내. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3** --- ## Task B4: Q1Step + Q2Step (드롭다운 + 라디오 패턴 정착) **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q1Step.tsx` - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q2Step.tsx` - [ ] **Step 1: Q1Step (드롭다운 2개)** ```tsx 'use client'; import { useState } from 'react'; import { AGE_RANGES, STATUSES } from '@/lib/survey/questions'; import type { SurveyResponse } from '@/lib/survey/types'; import QuestionLayout from './QuestionLayout'; interface Props { initial: Partial; onPrev: () => void; onNext: (partial: Partial) => void; } export default function Q1Step({ initial, onPrev, onNext }: Props) { const [age, setAge] = useState(initial.age_range ?? ''); const [status, setStatus] = useState(initial.status ?? ''); const valid = age && status; return ( onNext({ age_range: age, status })} nextDisabled={!valid} >
); } ``` - [ ] **Step 2: Q2Step (라디오 5개)** ```tsx 'use client'; import { useState } from 'react'; import { AWARENESS_FREQS } from '@/lib/survey/questions'; import type { SurveyResponse } from '@/lib/survey/types'; import QuestionLayout from './QuestionLayout'; interface Props { initial: Partial; onPrev: () => void; onNext: (partial: Partial) => void; } export default function Q2Step({ initial, onPrev, onNext }: Props) { const [value, setValue] = useState(initial.awareness_freq ?? ''); return ( onNext({ awareness_freq: value })} nextDisabled={!value} >
{AWARENESS_FREQS.map((option) => ( ))}
); } ``` - [ ] **Step 3: 린트** ```bash npx eslint app/gyeol/components/Q1Step.tsx app/gyeol/components/Q2Step.tsx ``` - [ ] **Step 4: 커밋** ```bash git add app/gyeol/components/Q1Step.tsx app/gyeol/components/Q2Step.tsx git commit -m "$(cat <<'EOF' feat(gyeol): Q1Step (드롭다운) + Q2Step (라디오 5) - Q1: 나이대 + 상황 두 드롭다운, 둘 다 선택 시 활성 - Q2: 자각 빈도 5 라디오, 보라 활성 스타일 - 라디오 패턴이 이후 Q4/Q5에서 재사용됨 Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 5: ⚠️ git log -3** --- ## Task B5: Q3Step (멀티 체크 + 기타 입력) **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q3Step.tsx` - [ ] **Step 1: 작성** ```tsx 'use client'; import { useState } from 'react'; import { TOOLS_OPTIONS } from '@/lib/survey/questions'; import type { SurveyResponse } from '@/lib/survey/types'; import QuestionLayout from './QuestionLayout'; interface Props { initial: Partial; onPrev: () => void; onNext: (partial: Partial) => void; } export default function Q3Step({ initial, onPrev, onNext }: Props) { const [selected, setSelected] = useState(initial.tools_used ?? []); const [other, setOther] = useState(initial.tools_other ?? ''); function toggle(option: string) { setSelected((prev) => prev.includes(option) ? prev.filter((x) => x !== option) : [...prev, option] ); } // validation: 최소 1개 체크 또는 기타 입력 있음 const valid = selected.length > 0 || other.trim().length > 0; return ( onNext({ tools_used: selected, tools_other: other.trim() || undefined, }) } nextDisabled={!valid} >
{TOOLS_OPTIONS.map((option) => { const checked = selected.includes(option); return ( ); })}
setOther(e.target.value)} placeholder="예: 명상, 운동" maxLength={100} className="w-full px-4 py-3 rounded-xl bg-white/[0.04] border border-white/15 text-white placeholder:text-white/30 focus:border-white/40 focus:outline-none" />
); } ``` - [ ] **Step 2: 린트** ```bash npx eslint app/gyeol/components/Q3Step.tsx ``` - [ ] **Step 3: 커밋** ```bash git add app/gyeol/components/Q3Step.tsx git commit -m "$(cat <<'EOF' feat(gyeol): Q3Step — 멀티 체크 9개 + 기타 자유 입력 validation: 최소 1개 체크 또는 기타 입력 있어야 다음 활성. 체크박스 패턴 + 활성 시 보라 + 흰 체크마크. Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 4: ⚠️ git log -3** --- ## Task B6: Q4Step + Q5Step (라디오 패턴 재사용) **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q4Step.tsx` - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q5Step.tsx` - [ ] **Step 1: Q4Step (비용 라디오 6개)** ```tsx 'use client'; import { useState } from 'react'; import { COST_RANGES } from '@/lib/survey/questions'; import type { SurveyResponse } from '@/lib/survey/types'; import QuestionLayout from './QuestionLayout'; interface Props { initial: Partial; onPrev: () => void; onNext: (partial: Partial) => void; } export default function Q4Step({ initial, onPrev, onNext }: Props) { const [value, setValue] = useState(initial.cost_range ?? ''); return ( onNext({ cost_range: value })} nextDisabled={!value} >
{COST_RANGES.map((option) => ( ))}
); } ``` - [ ] **Step 2: Q5Step (최고 도구 + 만족도 1-5)** ```tsx 'use client'; import { useState } from 'react'; import { BEST_TOOLS, SATISFY_SCALE } from '@/lib/survey/questions'; import type { SurveyResponse } from '@/lib/survey/types'; import QuestionLayout from './QuestionLayout'; interface Props { initial: Partial; onPrev: () => void; onNext: (partial: Partial) => void; } export default function Q5Step({ initial, onPrev, onNext }: Props) { const [tool, setTool] = useState(initial.best_tool ?? ''); const [satisfy, setSatisfy] = useState(initial.best_satisfy ?? null); const valid = tool && satisfy !== null; return ( onNext({ best_tool: tool, best_satisfy: satisfy ?? undefined })} nextDisabled={!valid} >

가장 도움 됐던 거

{BEST_TOOLS.map((option) => ( ))}

만족도 (5점 만점)

{SATISFY_SCALE.map((n) => ( ))}
); } ``` - [ ] **Step 3: 린트** ```bash npx eslint app/gyeol/components/Q4Step.tsx app/gyeol/components/Q5Step.tsx ``` - [ ] **Step 4: 커밋** ```bash git add app/gyeol/components/Q4Step.tsx app/gyeol/components/Q5Step.tsx git commit -m "$(cat <<'EOF' feat(gyeol): Q4Step (비용 라디오 6) + Q5Step (도구 라디오 8 + 만족도 1-5) - Q4: 라디오 패턴 재사용 (Q2와 동일 스타일) - Q5: 두 입력 한 화면 — 도구 라디오 + 만족도 1-5 버튼 그리드 - 둘 다 선택 시 다음 활성 Co-Authored-By: Claude Opus 4.7 (1M context) EOF )" ``` - [ ] **Step 5: ⚠️ git log -3** --- ## Task B7: Q6Step + Q7Step (textarea + 이메일 옵션) **Files:** - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q6Step.tsx` - Create: `C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\Q7Step.tsx` - [ ] **Step 1: Q6Step (자유 의견 textarea)** ```tsx 'use client'; import { useState } from 'react'; import type { SurveyResponse } from '@/lib/survey/types'; import QuestionLayout from './QuestionLayout'; interface Props { initial: Partial; onPrev: () => void; onNext: (partial: Partial) => void; } export default function Q6Step({ initial, onPrev, onNext }: Props) { const [text, setText] = useState(initial.free_opinion ?? ''); // 빈 칸 허용 (skippable) return ( onNext({ free_opinion: text.trim() || undefined })} >