Files
jaengseung-made/docs/superpowers/plans/2026-05-16-contour-pmf-survey.md
gahusb ae10bdc0b9 docs(plan): CONTOUR PMF 설문 사이트 implementation plan — 19 task, 5 phase
5 phase 구성:
- A (4): supabase migration + lib types/questions/storage
- B (8): UI 컴포넌트 — Intro/Q1-Q7/Thanks/ProgressBar/QuestionLayout
- C (4): page+layout 통합 + /api/survey POST + standalone shell + /api/admin/survey
- D (2): /admin/survey 대시보드 + AdminSidebar 메뉴
- E (1): build/lint/시각 회귀/CEO 운영 안내 (메모리 갱신 선택)

핵심 패턴:
- 단일 페이지 + step state (URL 불변, localStorage 진행 저장)
- /gyeol standalone (TopNav/푸터/카카오 모두 숨김)
- DB RLS — anon INSERT만, admin SELECT
- Resend 즉시 확인 메일 (이메일 입력 시만)
- UTM·referrer 자동 수집 → 9 채널 CPM 분석
- 각 task 마지막에 git log -3 직접 검증 (Phase 2 sandbox 이슈 대비)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 05:07:09 +09:00

74 KiB
Raw Blame History

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 파일 작성

-- 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: 커밋
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) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3 직접 확인
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: 작성

/**
 * 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: 린트
npx eslint lib/survey/types.ts

Expected: exit 0.

  • Step 3: 커밋
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) <noreply@anthropic.com>
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: 작성

/**
 * 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<string, { title: string; subtitle?: string }> = {
  q1: {
    title: '안녕하세요. 짧게 자기 소개부터.',
    subtitle: '나이대와 지금 상황을 알려주세요.',
  },
  q2: {
    title: "최근 1년 안에 '내가 뭘 원하는지 모르겠다'고 느낀 적 있어요?",
  },
  q3: {
    title: '그럴 때 어떻게 풀어가시나요?',
    subtitle: '해본 거 모두 골라주세요. (복수 선택)',
  },
  q4: {
    title: '지난 1년간 자기 이해·심리 영역에 돈 쓴 거 다 합쳐서 얼마쯤?',
  },
  q5: {
    title: '그중 가장 도움 됐던 거 + 만족도',
  },
  q6: {
    title: "혹시 '내가 진짜 알고 싶었던 건 이런 거였는데...' 하는 게 있나요?",
    subtitle: '자유롭게 적어주세요. 안 적으셔도 괜찮아요.',
  },
  q7: {
    title: '이런 도구가 나오면 알려드릴까요?',
    subtitle: '결과를 받고 싶으시면 이메일을 남겨주세요.',
  },
};
  • Step 2: 린트
npx eslint lib/survey/questions.ts
  • Step 3: 커밋
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) <noreply@anthropic.com>
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: 작성

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: 린트
npx eslint lib/survey/storage.ts
  • Step 3: 커밋
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) <noreply@anthropic.com>
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: 작성

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 (
    <div className="w-full max-w-md mx-auto mb-8">
      <div className="flex items-center justify-between mb-2 text-white/60 text-xs font-mono tracking-widest">
        <span>{current}/{TOTAL_QUESTIONS}</span>
      </div>
      <div className="h-[2px] bg-white/10 rounded-full overflow-hidden">
        <div
          className="h-full transition-all duration-500 ease-out"
          style={{
            width: `${percent}%`,
            background: 'linear-gradient(90deg, #cc97ff 0%, #53ddfc 100%)',
          }}
        />
      </div>
    </div>
  );
}
  • Step 2: 린트
npx eslint app/gyeol/components/ProgressBar.tsx
  • Step 3: 커밋
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) <noreply@anthropic.com>
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: 작성

'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 (
    <div className="min-h-screen flex flex-col px-6 py-8 text-white">
      <ProgressBar step={step} />

      <div className="flex-1 flex flex-col items-center justify-center max-w-md mx-auto w-full">
        {/* 질문 헤더 */}
        <div className="text-center mb-10 w-full">
          <h2
            className="kx-display text-2xl md:text-3xl font-bold mb-3 leading-snug"
            style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
          >
            {header?.title}
          </h2>
          {header?.subtitle && (
            <p className="text-sm md:text-base text-white/60">{header.subtitle}</p>
          )}
        </div>

        {/* 본문 */}
        <div className="w-full mb-12">{children}</div>

        {/* 네비게이션 */}
        <div className="w-full flex gap-3">
          {onPrev && (
            <button
              type="button"
              onClick={onPrev}
              disabled={submitting}
              className="flex-1 py-3 rounded-full text-sm font-medium border border-white/15 text-white/60 hover:text-white hover:border-white/40 transition disabled:opacity-50"
            >
               이전
            </button>
          )}
          <button
            type="button"
            onClick={onNext}
            disabled={nextDisabled || submitting}
            className="kx-btn-primary flex-[2] py-3 rounded-full text-sm font-bold disabled:opacity-50 disabled:cursor-not-allowed"
          >
            {submitting ? '전송 중...' : nextLabel}
          </button>
        </div>
      </div>
    </div>
  );
}
  • Step 2: 린트
npx eslint app/gyeol/components/QuestionLayout.tsx
  • Step 3: 커밋
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) <noreply@anthropic.com>
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: 작성

'use client';

interface Props {
  onStart: () => void;
}

/**
 * 인트로 step — CONTOUR 로고 + 한글 부제 + 시작 버튼.
 * spec design PNG 1번째 화면 참조.
 */
export default function IntroStep({ onStart }: Props) {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center px-6 text-center text-white">
      {/* 로고 */}
      <div className="mb-10">
        <h1
          className="kx-display text-5xl md:text-7xl font-black tracking-[0.15em] mb-4"
          style={{
            background: 'linear-gradient(135deg, #cc97ff 0%, #53ddfc 100%)',
            WebkitBackgroundClip: 'text',
            WebkitTextFillColor: 'transparent',
            backgroundClip: 'text',
          }}
        >
          CONTOUR
        </h1>
        <p className="text-base md:text-lg text-white/70 leading-relaxed">
          나를  선명하게 이해하는 3
        </p>
      </div>

      {/* 시작 버튼 */}
      <button
        type="button"
        onClick={onStart}
        className="kx-btn-primary px-10 py-3 rounded-full text-base font-bold"
      >
        시작하기
      </button>

      <p className="mt-6 text-xs text-white/40 font-mono">7 질문 ·  3</p>
    </div>
  );
}
  • Step 2: 린트
npx eslint app/gyeol/components/IntroStep.tsx
  • Step 3: 커밋
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) <noreply@anthropic.com>
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개)

'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<SurveyResponse>;
  onPrev: () => void;
  onNext: (partial: Partial<SurveyResponse>) => 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 (
    <QuestionLayout
      step="q1"
      onPrev={onPrev}
      onNext={() => onNext({ age_range: age, status })}
      nextDisabled={!valid}
    >
      <div className="space-y-4">
        <div>
          <label className="block text-xs text-white/60 mb-2 font-mono tracking-widest uppercase">
            나이대
          </label>
          <select
            value={age}
            onChange={(e) => setAge(e.target.value)}
            className="w-full px-4 py-3 rounded-xl bg-white/[0.04] border border-white/15 text-white focus:border-white/40 focus:outline-none"
          >
            <option value="" disabled>선택해주세요</option>
            {AGE_RANGES.map((a) => (
              <option key={a} value={a} className="bg-black">{a}</option>
            ))}
          </select>
        </div>

        <div>
          <label className="block text-xs text-white/60 mb-2 font-mono tracking-widest uppercase">
            지금 상황
          </label>
          <select
            value={status}
            onChange={(e) => setStatus(e.target.value)}
            className="w-full px-4 py-3 rounded-xl bg-white/[0.04] border border-white/15 text-white focus:border-white/40 focus:outline-none"
          >
            <option value="" disabled>선택해주세요</option>
            {STATUSES.map((s) => (
              <option key={s} value={s} className="bg-black">{s}</option>
            ))}
          </select>
        </div>
      </div>
    </QuestionLayout>
  );
}
  • Step 2: Q2Step (라디오 5개)
'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<SurveyResponse>;
  onPrev: () => void;
  onNext: (partial: Partial<SurveyResponse>) => void;
}

export default function Q2Step({ initial, onPrev, onNext }: Props) {
  const [value, setValue] = useState(initial.awareness_freq ?? '');

  return (
    <QuestionLayout
      step="q2"
      onPrev={onPrev}
      onNext={() => onNext({ awareness_freq: value })}
      nextDisabled={!value}
    >
      <div className="space-y-2">
        {AWARENESS_FREQS.map((option) => (
          <label
            key={option}
            className={`flex items-center gap-3 px-4 py-3.5 rounded-xl border cursor-pointer transition ${
              value === option
                ? 'border-violet-400 bg-violet-400/10'
                : 'border-white/15 bg-white/[0.02] hover:border-white/30'
            }`}
          >
            <input
              type="radio"
              name="awareness_freq"
              value={option}
              checked={value === option}
              onChange={() => setValue(option)}
              className="sr-only"
            />
            <span
              className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
                value === option ? 'border-violet-400' : 'border-white/30'
              }`}
            >
              {value === option && <span className="w-2 h-2 rounded-full bg-violet-400" />}
            </span>
            <span className="text-sm text-white">{option}</span>
          </label>
        ))}
      </div>
    </QuestionLayout>
  );
}
  • Step 3: 린트
npx eslint app/gyeol/components/Q1Step.tsx app/gyeol/components/Q2Step.tsx
  • Step 4: 커밋
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) <noreply@anthropic.com>
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: 작성

'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<SurveyResponse>;
  onPrev: () => void;
  onNext: (partial: Partial<SurveyResponse>) => void;
}

export default function Q3Step({ initial, onPrev, onNext }: Props) {
  const [selected, setSelected] = useState<string[]>(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 (
    <QuestionLayout
      step="q3"
      onPrev={onPrev}
      onNext={() =>
        onNext({
          tools_used: selected,
          tools_other: other.trim() || undefined,
        })
      }
      nextDisabled={!valid}
    >
      <div className="space-y-2">
        {TOOLS_OPTIONS.map((option) => {
          const checked = selected.includes(option);
          return (
            <label
              key={option}
              className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition ${
                checked
                  ? 'border-violet-400 bg-violet-400/10'
                  : 'border-white/15 bg-white/[0.02] hover:border-white/30'
              }`}
            >
              <input
                type="checkbox"
                checked={checked}
                onChange={() => toggle(option)}
                className="sr-only"
              />
              <span
                className={`w-4 h-4 rounded border-2 flex items-center justify-center flex-shrink-0 ${
                  checked ? 'border-violet-400 bg-violet-400' : 'border-white/30'
                }`}
              >
                {checked && (
                  <svg className="w-3 h-3 text-black" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={3}>
                    <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
                  </svg>
                )}
              </span>
              <span className="text-sm text-white">{option}</span>
            </label>
          );
        })}

        <div className="pt-2">
          <label className="block text-xs text-white/60 mb-2 font-mono tracking-widest uppercase">
            기타 (직접 입력)
          </label>
          <input
            type="text"
            value={other}
            onChange={(e) => 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"
          />
        </div>
      </div>
    </QuestionLayout>
  );
}
  • Step 2: 린트
npx eslint app/gyeol/components/Q3Step.tsx
  • Step 3: 커밋
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) <noreply@anthropic.com>
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개)

'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<SurveyResponse>;
  onPrev: () => void;
  onNext: (partial: Partial<SurveyResponse>) => void;
}

export default function Q4Step({ initial, onPrev, onNext }: Props) {
  const [value, setValue] = useState(initial.cost_range ?? '');

  return (
    <QuestionLayout
      step="q4"
      onPrev={onPrev}
      onNext={() => onNext({ cost_range: value })}
      nextDisabled={!value}
    >
      <div className="space-y-2">
        {COST_RANGES.map((option) => (
          <label
            key={option}
            className={`flex items-center gap-3 px-4 py-3.5 rounded-xl border cursor-pointer transition ${
              value === option
                ? 'border-violet-400 bg-violet-400/10'
                : 'border-white/15 bg-white/[0.02] hover:border-white/30'
            }`}
          >
            <input
              type="radio"
              name="cost_range"
              value={option}
              checked={value === option}
              onChange={() => setValue(option)}
              className="sr-only"
            />
            <span
              className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
                value === option ? 'border-violet-400' : 'border-white/30'
              }`}
            >
              {value === option && <span className="w-2 h-2 rounded-full bg-violet-400" />}
            </span>
            <span className="text-sm text-white">{option}</span>
          </label>
        ))}
      </div>
    </QuestionLayout>
  );
}
  • Step 2: Q5Step (최고 도구 + 만족도 1-5)
'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<SurveyResponse>;
  onPrev: () => void;
  onNext: (partial: Partial<SurveyResponse>) => void;
}

export default function Q5Step({ initial, onPrev, onNext }: Props) {
  const [tool, setTool] = useState(initial.best_tool ?? '');
  const [satisfy, setSatisfy] = useState<number | null>(initial.best_satisfy ?? null);

  const valid = tool && satisfy !== null;

  return (
    <QuestionLayout
      step="q5"
      onPrev={onPrev}
      onNext={() => onNext({ best_tool: tool, best_satisfy: satisfy ?? undefined })}
      nextDisabled={!valid}
    >
      <div className="space-y-6">
        <div>
          <p className="text-xs text-white/60 mb-2 font-mono tracking-widest uppercase">가장 도움 됐던 </p>
          <div className="space-y-2">
            {BEST_TOOLS.map((option) => (
              <label
                key={option}
                className={`flex items-center gap-3 px-4 py-3 rounded-xl border cursor-pointer transition ${
                  tool === option
                    ? 'border-violet-400 bg-violet-400/10'
                    : 'border-white/15 bg-white/[0.02] hover:border-white/30'
                }`}
              >
                <input
                  type="radio"
                  name="best_tool"
                  value={option}
                  checked={tool === option}
                  onChange={() => setTool(option)}
                  className="sr-only"
                />
                <span
                  className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
                    tool === option ? 'border-violet-400' : 'border-white/30'
                  }`}
                >
                  {tool === option && <span className="w-2 h-2 rounded-full bg-violet-400" />}
                </span>
                <span className="text-sm text-white">{option}</span>
              </label>
            ))}
          </div>
        </div>

        <div>
          <p className="text-xs text-white/60 mb-2 font-mono tracking-widest uppercase">만족도 (5 만점)</p>
          <div className="flex gap-2">
            {SATISFY_SCALE.map((n) => (
              <button
                key={n}
                type="button"
                onClick={() => setSatisfy(n)}
                className={`flex-1 py-3 rounded-xl border text-sm font-bold transition ${
                  satisfy === n
                    ? 'border-violet-400 bg-violet-400/10 text-white'
                    : 'border-white/15 bg-white/[0.02] text-white/60 hover:border-white/30'
                }`}
              >
                {n}
              </button>
            ))}
          </div>
        </div>
      </div>
    </QuestionLayout>
  );
}
  • Step 3: 린트
npx eslint app/gyeol/components/Q4Step.tsx app/gyeol/components/Q5Step.tsx
  • Step 4: 커밋
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) <noreply@anthropic.com>
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)

'use client';

import { useState } from 'react';
import type { SurveyResponse } from '@/lib/survey/types';
import QuestionLayout from './QuestionLayout';

interface Props {
  initial: Partial<SurveyResponse>;
  onPrev: () => void;
  onNext: (partial: Partial<SurveyResponse>) => void;
}

export default function Q6Step({ initial, onPrev, onNext }: Props) {
  const [text, setText] = useState(initial.free_opinion ?? '');

  // 빈 칸 허용 (skippable)
  return (
    <QuestionLayout
      step="q6"
      onPrev={onPrev}
      onNext={() => onNext({ free_opinion: text.trim() || undefined })}
    >
      <textarea
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="자유롭게 적어주세요. (선택)"
        maxLength={1000}
        rows={6}
        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 resize-none"
      />
      <p className="mt-2 text-xs text-white/40 text-right font-mono">{text.length}/1000</p>
    </QuestionLayout>
  );
}
  • Step 2: Q7Step (이메일 옵션)
'use client';

import { useState } from 'react';
import type { SurveyResponse } from '@/lib/survey/types';
import QuestionLayout from './QuestionLayout';

interface Props {
  initial: Partial<SurveyResponse>;
  onPrev: () => void;
  onSubmit: (partial: Partial<SurveyResponse>) => void;
  submitting: boolean;
}

export default function Q7Step({ initial, onPrev, onSubmit, submitting }: Props) {
  const [wantEmail, setWantEmail] = useState<'yes' | 'no' | ''>(
    initial.email ? 'yes' : ''
  );
  const [email, setEmail] = useState(initial.email ?? '');

  // 'no' 선택 시 즉시 전송 가능. 'yes' 선택 시 이메일 유효성 필요.
  const emailValid = /\S+@\S+\.\S+/.test(email);
  const canSubmit =
    wantEmail === 'no' || (wantEmail === 'yes' && emailValid);

  function handleSubmit() {
    onSubmit({
      email: wantEmail === 'yes' && emailValid ? email.trim() : undefined,
    });
  }

  return (
    <QuestionLayout
      step="q7"
      onPrev={onPrev}
      onNext={handleSubmit}
      nextDisabled={!canSubmit}
      nextLabel="전송"
      submitting={submitting}
    >
      <div className="space-y-3">
        <label
          className={`flex items-center gap-3 px-4 py-3.5 rounded-xl border cursor-pointer transition ${
            wantEmail === 'yes'
              ? 'border-violet-400 bg-violet-400/10'
              : 'border-white/15 bg-white/[0.02] hover:border-white/30'
          }`}
        >
          <input
            type="radio"
            name="want_email"
            checked={wantEmail === 'yes'}
            onChange={() => setWantEmail('yes')}
            className="sr-only"
          />
          <span
            className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
              wantEmail === 'yes' ? 'border-violet-400' : 'border-white/30'
            }`}
          >
            {wantEmail === 'yes' && <span className="w-2 h-2 rounded-full bg-violet-400" />}
          </span>
          <span className="text-sm text-white">, 알려주세요</span>
        </label>

        {wantEmail === 'yes' && (
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            placeholder="your@email.com"
            maxLength={200}
            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"
          />
        )}

        <label
          className={`flex items-center gap-3 px-4 py-3.5 rounded-xl border cursor-pointer transition ${
            wantEmail === 'no'
              ? 'border-violet-400 bg-violet-400/10'
              : 'border-white/15 bg-white/[0.02] hover:border-white/30'
          }`}
        >
          <input
            type="radio"
            name="want_email"
            checked={wantEmail === 'no'}
            onChange={() => {
              setWantEmail('no');
              setEmail('');
            }}
            className="sr-only"
          />
          <span
            className={`w-4 h-4 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
              wantEmail === 'no' ? 'border-violet-400' : 'border-white/30'
            }`}
          >
            {wantEmail === 'no' && <span className="w-2 h-2 rounded-full bg-violet-400" />}
          </span>
          <span className="text-sm text-white">됐어요</span>
        </label>
      </div>
    </QuestionLayout>
  );
}
  • Step 3: 린트
npx eslint app/gyeol/components/Q6Step.tsx app/gyeol/components/Q7Step.tsx
  • Step 4: 커밋
git add app/gyeol/components/Q6Step.tsx app/gyeol/components/Q7Step.tsx
git commit -m "$(cat <<'EOF'
feat(gyeol): Q6Step (자유 의견 textarea) + Q7Step (이메일 옵션)

- Q6: 1000자 textarea, 빈 칸 허용 (skippable)
- Q7: yes/no 라디오 + yes 선택 시 이메일 입력 노출 + 형식 validation
- Q7 onSubmit = 최종 제출 트리거 (page.tsx에서 POST /api/survey)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 5: ⚠️ git log -3

Task B8: ThanksStep

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\components\ThanksStep.tsx

  • Step 1: 작성

'use client';

import Link from 'next/link';

interface Props {
  emailEntered: boolean;
}

export default function ThanksStep({ emailEntered }: Props) {
  return (
    <div className="min-h-screen flex flex-col items-center justify-center px-6 text-center text-white">
      <div className="mb-10">
        <h2
          className="kx-display text-3xl md:text-5xl font-bold mb-5"
          style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
        >
          감사합니다.
        </h2>
        <p className="text-base md:text-lg text-white/70 leading-relaxed max-w-md">
          답변이  도움이 됐어요.
          {emailEntered && (
            <>
              <br />
              결과는 추후 입력하신 이메일로 공유드릴게요.
            </>
          )}
        </p>
      </div>

      <Link
        href="/"
        className="text-sm text-white/50 hover:text-white underline underline-offset-4 transition"
      >
        쟁승메이드 홈으로 
      </Link>
    </div>
  );
}
  • Step 2: 린트
npx eslint app/gyeol/components/ThanksStep.tsx
  • Step 3: 커밋
git add app/gyeol/components/ThanksStep.tsx
git commit -m "$(cat <<'EOF'
feat(gyeol): ThanksStep — 감사 메시지 + 사이트 돌아가기

이메일 입력 여부에 따라 "결과 추후 공유" 안내 분기.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3

Phase C — 통합 + API

Task C1: app/gyeol/page.tsx + layout.tsx — 단일 페이지 통합

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\layout.tsx

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\gyeol\page.tsx

  • Step 1: layout.tsx

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>
  );
}
  • Step 2: page.tsx — 단일 페이지 통합
'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 ?? '제출 실패');
      }
      // 성공: localStorage 정리 + 응답에 partial 반영 (이메일 진입 여부 ThanksStep에 전달)
      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>
      )}
    </>
  );
}
  • Step 3: 린트 + 빌드
npx eslint app/gyeol/page.tsx app/gyeol/layout.tsx
npm run build 2>&1 | tail -10

빌드 통과 필수. /gyeol 정적/dynamic 라우트로 등록됨 (client component이므로 dynamic 가능성).

  • Step 4: 커밋
git add app/gyeol/
git commit -m "$(cat <<'EOF'
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>
EOF
)"
  • Step 5: ⚠️ git log -3

Task C2: /api/survey POST 라우트

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\api\survey\route.ts

  • Step 1: 작성 (/api/contact 패턴 차용)

import { NextResponse } from 'next/server';
import { Resend } from 'resend';
import { createAdminClient } from '@/lib/supabase/admin';
import { isValidEmail, sanitizeStr, checkRateLimit, getClientIp, INPUT_LIMITS } from '@/lib/security';

export const runtime = 'nodejs';

const resend = new Resend(process.env.RESEND_API_KEY);

export async function POST(request: Request) {
  try {
    // Rate Limit: IP당 1분 5회
    const ip = getClientIp(request);
    const rl = checkRateLimit(`survey:${ip}`, 60_000, 5);
    if (!rl.allowed) {
      return NextResponse.json(
        { error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
        {
          status: 429,
          headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
        }
      );
    }

    const body = await request.json();

    // 기본 validation — Q1, Q2는 필수
    if (!body.age_range || !body.status || !body.awareness_freq) {
      return NextResponse.json(
        { error: '필수 응답이 누락되었습니다.' },
        { status: 400 }
      );
    }

    // 입력 정제
    const tools_other = body.tools_other ? sanitizeStr(body.tools_other, 200) : null;
    const free_opinion = body.free_opinion ? sanitizeStr(body.free_opinion, 2000) : null;
    const email = body.email ? sanitizeStr(body.email, INPUT_LIMITS.EMAIL) : null;
    if (email && !isValidEmail(email)) {
      return NextResponse.json(
        { error: '올바른 이메일 형식이 아닙니다.' },
        { status: 400 }
      );
    }

    // DB INSERT (service role — RLS 우회)
    const supabase = createAdminClient();
    const { data, error } = await supabase
      .from('survey_responses')
      .insert({
        age_range: body.age_range,
        status: body.status,
        awareness_freq: body.awareness_freq,
        tools_used: Array.isArray(body.tools_used) ? body.tools_used : null,
        tools_other,
        cost_range: body.cost_range ?? null,
        best_tool: body.best_tool ?? null,
        best_satisfy: typeof body.best_satisfy === 'number' ? body.best_satisfy : null,
        free_opinion,
        email,
        user_agent: body.user_agent ? sanitizeStr(body.user_agent, 500) : null,
        referrer: body.referrer ? sanitizeStr(body.referrer, 500) : null,
        utm_source: body.utm_source ? sanitizeStr(body.utm_source, 100) : null,
        utm_medium: body.utm_medium ? sanitizeStr(body.utm_medium, 100) : null,
        utm_campaign: body.utm_campaign ? sanitizeStr(body.utm_campaign, 100) : null,
        completion_seconds: typeof body.completion_seconds === 'number' ? body.completion_seconds : null,
      })
      .select()
      .single();

    if (error) {
      console.error('[Survey] DB insert error:', error);
      return NextResponse.json({ error: '저장에 실패했습니다.' }, { status: 500 });
    }

    // Resend 즉시 확인 메일 (이메일 입력 시만)
    if (email) {
      try {
        await resend.emails.send({
          from: '쟁승메이드 <noreply@jaengseung-made.com>',
          to: email,
          subject: 'CONTOUR 설문 참여 감사드립니다',
          html: `<p>안녕하세요,</p>
                 <p>설문에 참여해주셔서 감사합니다. 결과는 추후 공유드리겠습니다.</p>
                 <p>— 쟁승메이드</p>`,
        });
        await supabase
          .from('survey_responses')
          .update({ email_confirmation_sent: true })
          .eq('id', data.id);
      } catch (mailErr) {
        console.error('[Survey] Resend error:', mailErr);
        // 메일 실패는 응답 저장 성공에 영향 X
      }
    }

    return NextResponse.json({ ok: true, id: data.id });
  } catch (e) {
    console.error('[Survey] Unexpected error:', e);
    return NextResponse.json({ error: '제출 처리 중 오류가 발생했습니다.' }, { status: 500 });
  }
}
  • Step 2: 린트 + 빌드
npx eslint app/api/survey/route.ts
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/api/survey/route.ts
git commit -m "$(cat <<'EOF'
feat(api): /api/survey POST — DB 저장 + Resend 확인 메일

- Rate limit: IP당 1분 5회 (기존 contact 패턴)
- 필수 validation: age_range, status, awareness_freq
- 입력 정제(sanitizeStr) + 이메일 형식 검증
- supabase INSERT (service role, RLS 우회)
- 이메일 입력 시: Resend 즉시 확인 메일 + email_confirmation_sent 마킹
- 메일 실패는 응답 저장 성공에 영향 X

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3

Task C3: DashboardShell standalone에 /gyeol 추가

Files:

  • Modify: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\components\DashboardShell.tsx

  • Step 1: STANDALONE_PATHS 변경

현재 (line 6):

const STANDALONE_PATHS = ['/login', '/signup', '/admin'];

변경 후:

const STANDALONE_PATHS = ['/login', '/signup', '/admin', '/gyeol'];

(1줄 변경)

  • Step 2: 린트 + 빌드
npx eslint app/components/DashboardShell.tsx
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/components/DashboardShell.tsx
git commit -m "$(cat <<'EOF'
feat(shell): DashboardShell STANDALONE_PATHS에 /gyeol 추가

CONTOUR 설문 페이지는 자체 시각 정체성 — TopNav/푸터/카카오 모두 숨김.
풀스크린 설문 UI 집중도 보장.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3

Task C4: /api/admin/survey GET + CSV

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\api\admin\survey\route.ts

  • Step 1: 작성

import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';

export const runtime = 'nodejs';

async function checkAuth() {
  const cookieStore = await cookies();
  const token = cookieStore.get('admin_token')?.value;
  return token && verifyAdminTokenNode(token);
}

interface SurveyRow {
  id: string;
  created_at: string;
  age_range: string | null;
  status: string | null;
  awareness_freq: string | null;
  tools_used: string[] | null;
  tools_other: string | null;
  cost_range: string | null;
  best_tool: string | null;
  best_satisfy: number | null;
  free_opinion: string | null;
  email: string | null;
  user_agent: string | null;
  referrer: string | null;
  utm_source: string | null;
  utm_medium: string | null;
  utm_campaign: string | null;
  completion_seconds: number | null;
}

export async function GET(request: Request) {
  if (!(await checkAuth())) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const url = new URL(request.url);
  const range = url.searchParams.get('range') ?? 'all';
  const format = url.searchParams.get('format') ?? 'json';

  const supabase = createAdminClient();
  let query = supabase
    .from('survey_responses')
    .select('*')
    .order('created_at', { ascending: false });

  if (range === 'today') {
    const today = new Date();
    today.setHours(0, 0, 0, 0);
    query = query.gte('created_at', today.toISOString());
  } else if (range === 'week') {
    const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
    query = query.gte('created_at', weekAgo.toISOString());
  }

  const { data, error } = await query;
  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 });
  }

  const rows: SurveyRow[] = (data ?? []) as SurveyRow[];

  if (format === 'csv') {
    const csv = toCsv(rows);
    return new Response(csv, {
      headers: {
        'Content-Type': 'text/csv; charset=utf-8',
        'Content-Disposition': `attachment; filename="contour-survey-${range}-${new Date().toISOString().slice(0, 10)}.csv"`,
      },
    });
  }

  return NextResponse.json({
    total: rows.length,
    stats: computeStats(rows),
    responses: rows,
  });
}

function toCsv(rows: SurveyRow[]): string {
  if (rows.length === 0) return 'id,created_at\n';
  const headers: (keyof SurveyRow)[] = [
    'id',
    'created_at',
    'age_range',
    'status',
    'awareness_freq',
    'tools_used',
    'tools_other',
    'cost_range',
    'best_tool',
    'best_satisfy',
    'free_opinion',
    'email',
    'user_agent',
    'referrer',
    'utm_source',
    'utm_medium',
    'utm_campaign',
    'completion_seconds',
  ];
  // BOM for Excel UTF-8 호환
  const bom = '';
  const lines = [headers.join(',')];
  for (const r of rows) {
    lines.push(
      headers
        .map((h) => {
          const v = r[h];
          if (v == null) return '';
          if (Array.isArray(v)) return `"${v.join('|').replace(/"/g, '""')}"`;
          return `"${String(v).replace(/"/g, '""').replace(/\r?\n/g, ' ')}"`;
        })
        .join(',')
    );
  }
  return bom + lines.join('\n');
}

function counts(rows: SurveyRow[], key: keyof SurveyRow): Record<string, number> {
  return rows.reduce((acc, r) => {
    const v = r[key];
    if (v != null && typeof v === 'string') {
      acc[v] = (acc[v] ?? 0) + 1;
    }
    return acc;
  }, {} as Record<string, number>);
}

function computeStats(rows: SurveyRow[]) {
  const satisfyValues = rows
    .map((r) => r.best_satisfy)
    .filter((n): n is number => typeof n === 'number');
  const satisfyAvg =
    satisfyValues.length > 0
      ? (satisfyValues.reduce((s, n) => s + n, 0) / satisfyValues.length).toFixed(2)
      : '0';

  const completionValues = rows
    .map((r) => r.completion_seconds)
    .filter((n): n is number => typeof n === 'number');
  const completionMedian = median(completionValues);

  return {
    age_range: counts(rows, 'age_range'),
    status: counts(rows, 'status'),
    awareness_freq: counts(rows, 'awareness_freq'),
    cost_range: counts(rows, 'cost_range'),
    best_tool: counts(rows, 'best_tool'),
    satisfy_avg: satisfyAvg,
    email_rate: rows.length === 0 ? '0' : ((rows.filter((r) => r.email).length / rows.length) * 100).toFixed(1),
    completion_seconds_median: completionMedian,
  };
}

function median(arr: number[]): number {
  if (arr.length === 0) return 0;
  const sorted = [...arr].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
}
  • Step 2: 린트 + 빌드
npx eslint app/api/admin/survey/route.ts
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/api/admin/survey/route.ts
git commit -m "$(cat <<'EOF'
feat(api): /api/admin/survey GET — 목록 + 통계 + CSV export

- ?range=all|today|week 필터
- ?format=csv → BOM 포함 UTF-8 CSV 다운로드 (Excel 호환)
- 통계: 각 질문별 카운트 분포 + 만족도 평균 + 이메일률 + 완료시간 중간값
- admin HMAC cookie 인증 (verifyAdminTokenNode)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3

Phase D — Admin UI

Task D1: /admin/survey 페이지

Files:

  • Create: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\admin\survey\page.tsx

  • Step 1: 작성

'use client';

import { useEffect, useState } from 'react';

interface SurveyRow {
  id: string;
  created_at: string;
  age_range: string | null;
  status: string | null;
  awareness_freq: string | null;
  tools_used: string[] | null;
  tools_other: string | null;
  cost_range: string | null;
  best_tool: string | null;
  best_satisfy: number | null;
  free_opinion: string | null;
  email: string | null;
  user_agent: string | null;
  referrer: string | null;
  utm_source: string | null;
  completion_seconds: number | null;
}

interface Stats {
  age_range: Record<string, number>;
  status: Record<string, number>;
  awareness_freq: Record<string, number>;
  cost_range: Record<string, number>;
  best_tool: Record<string, number>;
  satisfy_avg: string;
  email_rate: string;
  completion_seconds_median: number;
}

type Range = 'all' | 'today' | 'week';

export default function AdminSurveyPage() {
  const [range, setRange] = useState<Range>('all');
  const [total, setTotal] = useState(0);
  const [stats, setStats] = useState<Stats | null>(null);
  const [rows, setRows] = useState<SurveyRow[]>([]);
  const [loading, setLoading] = useState(true);
  const [selected, setSelected] = useState<SurveyRow | null>(null);

  async function load(r: Range) {
    setLoading(true);
    try {
      const res = await fetch(`/api/admin/survey?range=${r}`);
      const data = await res.json();
      setTotal(data.total ?? 0);
      setStats(data.stats ?? null);
      setRows(data.responses ?? []);
    } catch (e) {
      console.error(e);
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    load(range);
  }, [range]);

  function downloadCsv() {
    window.location.href = `/api/admin/survey?range=${range}&format=csv`;
  }

  function fmtCount(counts: Record<string, number> | undefined): string {
    if (!counts) return '';
    return Object.entries(counts)
      .sort((a, b) => b[1] - a[1])
      .map(([k, v]) => `${k} ${v}`)
      .join(' · ');
  }

  return (
    <div className="p-6 max-w-7xl mx-auto">
      <div className="mb-6 flex items-end justify-between gap-3 flex-wrap">
        <div>
          <h1 className="text-white text-2xl font-bold">설문 응답</h1>
          <p className="text-slate-400 text-sm mt-0.5">
            CONTOUR PMF 설문   {total}
          </p>
        </div>
        <div className="flex items-center gap-2">
          {(['all', 'today', 'week'] as Range[]).map((r) => (
            <button
              key={r}
              onClick={() => setRange(r)}
              className={`px-3 py-1.5 rounded-lg text-sm font-bold transition ${
                range === r
                  ? 'bg-violet-600 text-white'
                  : 'bg-slate-800 text-slate-300 hover:bg-slate-700'
              }`}
            >
              {r === 'all' ? '전체' : r === 'today' ? '오늘' : '이번 주'}
            </button>
          ))}
          <button
            onClick={downloadCsv}
            className="px-3 py-1.5 rounded-lg text-sm font-bold bg-emerald-600 hover:bg-emerald-500 text-white transition"
          >
            📥 CSV
          </button>
        </div>
      </div>

      {/* 통계 카드 */}
      {stats && (
        <div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-6">
          <div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
            <p className="text-xs text-slate-400 mb-2">Q2 자각 빈도</p>
            <p className="text-sm text-white">{fmtCount(stats.awareness_freq) || '데이터 없음'}</p>
          </div>
          <div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
            <p className="text-xs text-slate-400 mb-2">Q4 비용</p>
            <p className="text-sm text-white">{fmtCount(stats.cost_range) || '데이터 없음'}</p>
          </div>
          <div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
            <p className="text-xs text-slate-400 mb-2">Q5 만족도 평균</p>
            <p className="text-xl text-violet-400 font-bold">{stats.satisfy_avg} / 5</p>
          </div>
          <div className="bg-slate-900 border border-slate-700 rounded-xl p-4">
            <p className="text-xs text-slate-400 mb-2">Q7 이메일률 / 완료 시간 (중간값)</p>
            <p className="text-sm text-white">
              {stats.email_rate}% · {stats.completion_seconds_median}s
            </p>
          </div>
        </div>
      )}

      {/* 응답 리스트 */}
      {loading ? (
        <p className="text-slate-400">불러오는 ...</p>
      ) : rows.length === 0 ? (
        <p className="text-slate-500">응답이 없습니다.</p>
      ) : (
        <div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden overflow-x-auto">
          <table className="w-full text-sm">
            <thead className="bg-slate-800 text-slate-400 text-xs uppercase tracking-widest">
              <tr>
                <th className="text-left px-4 py-3">시각</th>
                <th className="text-left px-4 py-3">나이/상황</th>
                <th className="text-left px-4 py-3">Q4 비용</th>
                <th className="text-left px-4 py-3">Q5 만족</th>
                <th className="text-left px-4 py-3">Q6 자유의견 (미리보기)</th>
                <th className="text-left px-4 py-3">이메일</th>
                <th className="text-left px-4 py-3"></th>
              </tr>
            </thead>
            <tbody>
              {rows.map((r) => (
                <tr key={r.id} className="border-t border-slate-800 hover:bg-slate-800/50 transition">
                  <td className="px-4 py-2 text-slate-300">{new Date(r.created_at).toLocaleString('ko-KR')}</td>
                  <td className="px-4 py-2 text-slate-300">{r.age_range} · {r.status}</td>
                  <td className="px-4 py-2 text-slate-300">{r.cost_range ?? '-'}</td>
                  <td className="px-4 py-2 text-slate-300">{r.best_satisfy ?? '-'}</td>
                  <td className="px-4 py-2 text-slate-400 max-w-xs truncate">
                    {r.free_opinion ?? <span className="text-slate-600"></span>}
                  </td>
                  <td className="px-4 py-2 text-slate-300">{r.email ?? '-'}</td>
                  <td className="px-4 py-2">
                    <button
                      onClick={() => setSelected(r)}
                      className="text-violet-400 hover:text-violet-300 text-xs font-bold"
                    >
                      상세
                    </button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      )}

      {/* 상세 modal */}
      {selected && (
        <div
          className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4"
          onClick={() => setSelected(null)}
        >
          <div
            className="bg-slate-900 border border-slate-700 rounded-2xl max-w-2xl w-full p-6 max-h-[90vh] overflow-y-auto"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="flex items-start justify-between mb-4">
              <div>
                <h2 className="text-white font-bold">응답 상세</h2>
                <p className="text-xs text-slate-400 mt-1">{new Date(selected.created_at).toLocaleString('ko-KR')}</p>
              </div>
              <button onClick={() => setSelected(null)} className="text-slate-400 hover:text-white text-2xl leading-none">×</button>
            </div>
            <dl className="space-y-3 text-sm">
              {[
                ['Q1 나이대', selected.age_range],
                ['Q1 상황', selected.status],
                ['Q2 자각 빈도', selected.awareness_freq],
                ['Q3 도구', selected.tools_used?.join(', ')],
                ['Q3 기타', selected.tools_other],
                ['Q4 비용', selected.cost_range],
                ['Q5 최고 도구', selected.best_tool],
                ['Q5 만족도', selected.best_satisfy != null ? `${selected.best_satisfy} / 5` : null],
                ['Q6 자유 의견', selected.free_opinion],
                ['Q7 이메일', selected.email],
                ['user_agent', selected.user_agent],
                ['referrer', selected.referrer],
                ['utm_source', selected.utm_source],
                ['완료 시간', selected.completion_seconds != null ? `${selected.completion_seconds}초` : null],
              ].map(([k, v]) => (
                <div key={k as string} className="flex gap-3 border-b border-slate-800 pb-2">
                  <dt className="w-32 text-slate-400 flex-shrink-0">{k}</dt>
                  <dd className="text-white whitespace-pre-wrap break-words flex-1">{(v as string) || <span className="text-slate-600"></span>}</dd>
                </div>
              ))}
            </dl>
          </div>
        </div>
      )}
    </div>
  );
}
  • Step 2: 린트 + 빌드
npx eslint app/admin/survey/page.tsx
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/admin/survey/
git commit -m "$(cat <<'EOF'
feat(admin): /admin/survey 대시보드 — 목록 + 통계 + CSV + 상세 modal

- 필터: 전체/오늘/이번 주
- 통계: Q2/Q4/Q5 분포 + 만족도 평균 + 이메일률 + 완료 시간 중간값
- 응답 테이블 (시각/나이상황/Q4/Q5/Q6 미리보기/이메일/상세)
- 상세 modal: 7 질문 + 메타 14 필드 모두 표시
- CSV 다운로드 (BOM UTF-8, Excel 호환)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3

Task D2: AdminSidebar 메뉴 추가

Files:

  • Modify: C:\Users\jaeoh\Desktop\workspace\jaengseung-made\app\admin\components\AdminSidebar.tsx

  • Step 1: NAV_ITEMS 배열 끝에 추가

app/admin/components/AdminSidebar.tsx 의 NAV_ITEMS 배열 마지막 (현재 "팩 자료" 항목 다음 또는 배열 끝)에 다음 항목 추가:

{
  href: '/admin/survey',
  label: '설문 응답',
  icon: (
    <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
        d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
    </svg>
  ),
},

(체크리스트 모티브 SVG)

  • Step 2: 린트 + 빌드
npx eslint app/admin/components/AdminSidebar.tsx
npm run build 2>&1 | tail -10
  • Step 3: 커밋
git add app/admin/components/AdminSidebar.tsx
git commit -m "$(cat <<'EOF'
feat(admin): AdminSidebar에 "설문 응답" 메뉴 추가

NAV_ITEMS 배열 끝에 /admin/survey 항목 (체크리스트 아이콘).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
  • Step 4: ⚠️ git log -3

Phase E — 검증

Task E1: 통합 빌드/린트/CEO 안내

Files: 코드 변경 없음.

  • Step 1: 전체 빌드
npm run build 2>&1 | tail -15

기대: 모든 라우트 빌드 success. 새 라우트:

  • /gyeol (client, prerender 또는 dynamic)

  • /admin/survey (client)

  • /api/survey (dynamic)

  • /api/admin/survey (dynamic)

  • Step 2: 변경 핵심 파일 lint

npx eslint \
  app/gyeol/ \
  app/admin/survey/ \
  app/api/survey/route.ts \
  app/api/admin/survey/route.ts \
  app/components/DashboardShell.tsx \
  app/admin/components/AdminSidebar.tsx \
  lib/survey/ \
  supabase/migrations/2026-05-16-create-survey-responses.sql

기대: 사전 존재 경고만.

  • Step 3: CEO 수동 작업 안내 (UI text — 코드 변경 X)

다음 운영 절차를 사용자에게 안내:

A. Supabase migration 적용 (필수)

  1. Supabase 대시보드 → SQL Editor
  2. supabase/migrations/2026-05-16-create-survey-responses.sql 내용 복사·Run
  3. 검증: pack_files 가 아닌 survey_responses 테이블이 생성됨 + RLS enable

B. Vercel env 확인 (기존 변수, 추가 없음)

  • RESEND_API_KEY 이미 설정됨 (/api/contact에서 사용 중)
  • SUPABASE_URL, SUPABASE_SERVICE_KEY 이미 설정됨

C. push & 배포

git push origin main  # Vercel 자동 배포

D. 시각 회귀

  • /gyeol 진입 → CONTOUR 로고 + 시작 버튼 → 7 step 모두 동작 → 제출 → thanks 페이지
  • 모바일 viewport 확인 (PNG 디자인 톤)
  • localStorage 새로고침 복구 검증
  • /admin/survey 진입 → 응답 0건 / 일부 응답 후 → 목록 + 통계 + CSV 동작
  • 옛 라우트 redirect, 다른 페이지 회귀 미발생

E. 채널 공유 시작 (spec markdown의 9 채널)

  • 인스타·카톡·유튜브 쇼츠·블로그·LinkedIn (Tier 1)

  • UTM 파라미터 부착:

    • https://jaengseung-made.com/gyeol?utm_source=instagram&utm_medium=story&utm_campaign=v1
    • https://jaengseung-made.com/gyeol?utm_source=kakao&utm_medium=direct
  • Step 4: 메모리 갱신 (선택, 별도 commit X)

C:\Users\jaeoh\.claude\projects\C--Users-jaeoh-Desktop-workspace-jaengseung-made\memory\MEMORY.md 에 추가:

- [CONTOUR PMF 설문](./project_contour_pmf.md) — /gyeol 7질문 설문, supabase + Resend 메일

project_contour_pmf.md 신규:

---
name: CONTOUR PMF 설문 (2026-05-16)
description: /gyeol 7질문 PMF 검증 설문, 불특정 다수 익명, supabase + Resend
type: project
---

# CONTOUR PMF 설문

- spec: docs/superpowers/specs/2026-05-16-contour-pmf-survey-design.md
- plan: docs/superpowers/plans/2026-05-16-contour-pmf-survey.md
- URL: /gyeol (영문 브랜드 단독, '결' 한글 제거)
- DB: survey_responses (anon INSERT만, admin SELECT)
- Admin: /admin/survey
- 백로그: 차트 시각화, Q6 자발어 워드클라우드, NAS Synology Mail 전환

**Why:** PMF 검증 — Pull/Push 신호 (Q2 자각, Q4 비용, Q5 만족도, Q6 자발어).
**How to apply:** 채널 공유 시 UTM 파라미터 부착, admin 대시보드에서 모니터링.

이 task는 코드 변경 없음. commit X.


부록 A. 검증 인프라

이 프로젝트는 jest/vitest/playwright 미설치. 각 task 검증:

  1. npx eslint <변경 파일> — TS + ESLint
  2. npm run build — Next 빌드 통과
  3. 마지막 E1에서 시각/manual 회귀

부록 B. Subagent commit sandboxing 우려

Phase 2 에서 일부 subagent의 commit이 git에 반영 안 되는 sandboxing 이슈 있었음. 본 plan의 모든 task에 git log --oneline -3 직접 검증 step 포함. HEAD가 본인 commit 아니면 BLOCKED + sandbox 의심 보고.

부록 C. P3+ 후속

이 plan 종료 후 자연 follow-up:

  1. Recharts 차트 시각화 — admin 대시보드 응답 50건 누적 후
  2. Q6 자발어 키워드 추출 + 워드 클라우드 — GPT API 호출 또는 한글 형태소 분석기
  3. Pull/Push 게이트 자동 판정 UI — spec markdown의 기준치 자동 판정
  4. Rate limit 강화 — 현재 메모리 기반, Redis/upstash 등 영구화
  5. Cloudflare Turnstile invisible captcha — spam 발생 시
  6. 이메일 batch 발송 UI — 결과 알림 시점에 admin 메뉴 추가
  7. NAS Synology Mail Server 자체 호스팅 — Resend 의존 제거
  8. /gyeol → /contour rebrand — 정식 출시 시 영문 단어 URL로 + 301 redirect