Compare commits

...

135 Commits

Author SHA1 Message Date
6a67a9d812 @
chore(music): /music 카피 무료 스토리→음악 포지셔닝으로 정리 — 구 유료 팩 카피·packs 카드 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-04 01:26:09 +09:00
677012a9c8 fix(phase3a): music generate — recordUsage 집계 실패가 성공 생성을 502로 가리지 않게 (일일제한 우회 방지)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:40:30 +09:00
468ee84687 feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md
- TopNav LINKS에 /music(음악) 추가 — 외주/소프트웨어/제작사례/사주/타로/음악 6링크
- mypage AI 기록 탭에 음악 트랙 병합: MusicTrackRow 타입 + /api/studio/tracks 로드
  + MusicAiCard(제목·스토리 요약·<audio controls>) + 빈 상태 CTA에 /music 추가
  (기존 사주·타로 렌더·로직은 미변경)
- CLAUDE.md: /music 공개 전환 반영(숨김 서비스 표에서 제거), api/studio/{story,tracks,callback}
  · lib/music/story-prompt.ts 파일 구조 반영, /mypage 5탭 서술(AI 기록: 사주·타로·음악)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:29:51 +09:00
39025fc57b feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름
- 다크/gradient/violet/purple/blur/이모지 전부 제거, --jsm 토큰 기반 라이트 UI로 재구성
  (폼 필드 bg-white+jsm-line 보더+jsm-accent 포커스, navy 없이 flat 카드)
- 스토리 우선 흐름 신설: 이야기 textarea → "가사 만들기"(POST /api/studio/story) →
  제목/가사/스타일 편집 가능 미리보기 → "음악 만들기"(POST /api/studio/generate, custom 모드)
- 401/429/503 각각 로그인 CTA·제한 안내·서비스 준비중 메시지로 분기 처리
- 기존 simple/custom 직접 입력 모드는 "직접 입력" 탭으로 보존, taskId 폴링 로직 그대로 유지
- 생성 완료 시 오디오 플레이어 노출 + 로그인 사용자는 POST /api/studio/tracks로 best-effort
  자동 저장(세션 내 생성 트랙만 대상, 실패해도 재생에는 영향 없음)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:21:05 +09:00
7cd63a3868 feat(phase3a): 음악 랜딩·샘플 라이트 재스킨
app/music/page.tsx·app/music/samples/page.tsx 순수 시각 변경(className/style만).
다크 히어로(bg-black/gradient)와 샘플 카드의 violet 그라데이션·블러 글래스
CTA 밴드를 --jsm-navy/accent/accent-soft/surface-alt/line/ink 토큰으로 치환해
사주·showcase 재스킨과 동일한 navy 밴드 무테두리 flat + 흰 CTA 관용구로 정렬.
영상 이모지는 인라인 SVG 아이콘으로 교체. 데이터·구조는 변경 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:09:27 +09:00
895b33d83d feat(phase3a): 음악 트랙 저장·조회 API (user_id + RLS)
- POST: 로그인 필수, createAdminClient로 music_tracks insert
- GET: 세션 클라이언트로 본인 것만(RLS music_select_own) 조회, 최신순
- JSON 파싱 try/catch(400), 필드 검증(str 헬퍼)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 13:02:22 +09:00
2aa424f3ce feat(phase3a): 스토리→가사(Gemini) + generate 인증·일일제한 + callback 정리
- lib/music/story-prompt.ts: MusicStory 스키마 + Gemini 응답 파싱/검증(타로 prompt.ts 방어 패턴 포팅)
- app/api/studio/story/route.ts: 로그인 인증 후 Gemini 모델 폴백(2.5-pro→2.5-flash→2.0-flash)으로 가사 JSON 생성. 일일 사용량은 미집계(생성 확정 전 초안 단계)
- app/api/studio/callback/route.ts: Suno webhook 수신용 최소 200 응답 엔드포인트
- app/api/studio/generate/route.ts: 인증(401) + 일일 제한(429, MUSIC_DAILY_LIMIT) 추가, Suno 생성 성공 시에만 recordUsage('music') 기록

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:56:58 +09:00
0742059db2 feat(phase3a): 음악 서비스 공개화 — 가드·HideableService·DEFAULT_SERVICES 정리
- app/music/layout.tsx: isServiceVisible/notFound 제거, metadata 유지
- lib/service-visibility.ts: HideableService type에서 'music' 제거 (gyeol|lotto만 유지)
- app/api/admin/services/route.ts: DEFAULT_SERVICES에서 music 행 제거

/music* 라우트가 이제 공개(static) 상태로 노출됨.
service_settings music DELETE는 Task 1 마이그레이션이 담당.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 12:51:54 +09:00
7100842179 feat(phase3a): ai-usage에 music 추가 + music_tracks·CHECK 마이그레이션
- lib/ai-usage.ts: MUSIC_DAILY_LIMIT=1 추가, AiService 타입에 'music' 포함
- lib/__tests__/ai-usage.test.ts: MUSIC_DAILY_LIMIT 상수 검증 테스트 추가
- supabase/migrations/2026-07-03-phase3a-music.sql: music_tracks 테이블, CHECK 확장, service_settings 정리

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 12:48:42 +09:00
da33254076 @
docs(phase3a): 음악 공개화 구현 플랜 (7 Task)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-03 12:47:16 +09:00
a1a281d059 docs(phase3a): 음악 서비스 공개화 설계 — 스토리→음악·무료·회원저장 (WS1~5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 12:33:55 +09:00
a5b47a0278 docs(phase2.6): CLAUDE.md — 사주 전 화면 라이트 재스킨 반영
- 사주 시스템 섹션: 결과 화면 → 전 화면(랜딩·입력·결과)
- 2026-07-03 라이트 재스킨 완료 명시

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:32:25 +09:00
d5be617eb2 feat(phase2.6): 사주 입력 화면·폼 라이트 재스킨
app/work/saju/input/page.tsx: 다크 히어로(#04102b + repeating-linear
-gradient 텍스처) → bg-[var(--jsm-navy)] 플랫 밴드, violet 배지 →
navy 위 accent-soft, 하드코드 hex(#dbe8ff/#1a56db/#7c3aed/#04102b) →
jsm-line/jsm-accent/jsm-ink 토큰.

SajuForm.tsx: 제출 버튼 gradient(#1a56db→#7c3aed) → 플랫
bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)]. 폼 필드
보더·포커스·선택 버튼·체크박스의 하드코드 hex를 동일 역할의
--jsm-line/--jsm-accent/--jsm-ink 토큰으로 통일.

useSajuForm 상태·핸들러·submit·라우팅 로직은 라인 단위로 100% 동일
(className/style만 변경). grep gradient|violet|purple|blur 게이트
0건, npm run build 성공, npm test 30/30 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:25:53 +09:00
abec100a73 feat(phase2.6): 사주 랜딩 라이트 재스킨 — gradient/보라→--jsm, 텍스처 제거
app/work/saju/page.tsx 순수 시각 변경(className/style만). 히어로·MY RECORDS·
바로시작 CTA·PRICING 비교표·FAQ 전 구간의 #04102b/violet/gradient 하드코드를
--jsm-navy/ink/accent/accent-soft/line/surface-alt 토큰으로 치환하고
repeating-linear-gradient 텍스처와 다크 카드 테두리(#1a3a7a)를 제거해
result 페이지(Phase 2.5)와 동일한 navy 밴드 무테두리 flat 관용구로 정렬.
데이터 조회·상태·JSX 구조는 변경 없음.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 11:18:43 +09:00
fc55e6a928 @
docs(phase2.6): 사주 랜딩·입력 재스킨 구현 플랜 (3 Task)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
@
2026-07-03 11:13:36 +09:00
4f41f09a8c docs(phase2.6): 사주 랜딩·입력 라이트 재스킨 설계 — 2.5 패턴 연속 (WS1~3)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 11:11:05 +09:00
5ace251b58 feat(phase2.5): 사주 result page 라이트 재스킨 + CLAUDE.md 반영
app/work/saju/result/page.tsx의 히어로 다크 그라디언트/violet 배지, 사이드바
다크 카드, 4기둥 표, 대운 카드를 --jsm 토큰(navy/surface/surface-alt/line/
ink/accent)으로 순수 시각 치환. 서버 로직(사주 계산·hasPaid·로또 구독 조회)과
JSX 구조는 무변경. CLAUDE.md 사주 시스템 섹션에 재스킨 완료 이력 반영.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 10:51:22 +09:00
15825616a3 refactor(phase2.5): SajuFortuneSection 중복 Glyph 제거 — SajuIcons 재사용
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:39:13 +09:00
fa9cda4f50 feat(phase2.5): SajuFortuneSection 라이트 재스킨 — 로또 아이콘 SVG, 라디얼 제거
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:32:02 +09:00
5e79ea9233 feat(phase2.5): SajuAISection 라이트 재스킨 — 이모지→SVG, gradient/보라→--jsm
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:19:39 +09:00
57a95dee16 feat(phase2.5): 사주 섹션 인라인 SVG 아이콘 세트(SajuIcons)
- SajuIcon 컴포넌트 및 12개 stroke 기반 SVG 아이콘 정의
- SECTION_ICON_ORDER 배열로 섹션 순서 관리(기질·오행·지지·신살·재물·직업·애정·건강·대운·세운·황금기·종합)
- LottoIcon 추가 export
- currentColor 사용으로 색상 커스터마이징 가능

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 10:10:50 +09:00
e50b5a6dc9 docs(phase2.5): 사주 결과 화면 라이트 재스킨 설계 — --jsm 전환·이모지→SVG (WS1~4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-03 10:06:54 +09:00
65ff294e89 feat(phase2): TopNav 사주·타로 진입점 + CLAUDE.md 정합화
- TopNav LINKS에 /work/saju, /tarot 추가 (5링크, 모바일 드로어 자동 반영)
- CLAUDE.md: 핵심 IA에 사주/타로 공개 라우트 추가, 숨김 서비스 표에서
  /work/saju* 제거(공개 전환), 사주 시스템 안내문 갱신, 파일 구조에
  tarot/·api/tarot/·lib/tarot/·lib/ai-usage.ts 반영

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:55:44 +09:00
124478e3d6 feat(phase2): 마이페이지 AI 기록 탭 — 사주·타로 결과 통합
saju_records와 GET /api/tarot/readings를 병합 조회해 마이페이지 5번째
탭으로 노출한다. 사주는 결과 페이지로 바로 돌아갈 수 있는 링크를,
타로는 뽑은 카드·요약·조언/주의 접이식을 제공한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:50:12 +09:00
96a0b06706 fix(phase2): 사주 무료화 잔재 정리 — 결제완료 배지→AI해석완료, 잉여 표현·stale 주석
- SajuAISection.tsx 415행: "결제 완료" 배지 → "AI 해석 완료"로 변경 (로그인 회원 무료 정책에 맞춤)
- page.tsx 86행: hasPaid = !!user → hasPaid = true 단순화 (if (user) 블록 내 중복)
- page.tsx 540행: 주석 "(사주 결제 시 표시)" → "(로그인 시 표시)" 갱신 (현행 정책 반영)

Tests: 30 PASS ✓  Build: success ✓

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 21:43:09 +09:00
26fef53174 feat(phase2): 사주 AI 해석 무료화 — 결제 게이트 → 로그인 게이트
- page.tsx: hasPaid를 orders 'saju_detail' paid 조회 대신 로그인 여부(!!user)로 산출
- SajuAISection: 미로그인 시 "개편 준비 중" 안내를 /login?next= 유도 CTA로 교체
- analyze fetch가 429(일일 무료 횟수 초과)를 받으면 전용 에러 메시지 표시(재시도 버튼 숨김)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:36:13 +09:00
5fd7ab8872 feat(phase2): 사주 공개 전환 + analyze 로그인·일일제한(서버 강제)
- app/work/saju/layout.tsx: isServiceVisible 가드 제거, 사주 서비스 공개 전환
- lib/service-visibility.ts: HideableService에서 saju 제거
- app/api/admin/services/route.ts: DEFAULT_SERVICES에서 saju 행 제거
- app/api/saju/analyze/route.ts: saju_detail 결제 게이트(403) 제거,
  로그인(401) + 서버측 일일 1회 제한(429, ai_usage_log 기반)으로 교체.
  recordUsage는 실제 Gemini 해석 성공 반환 직전에만 호출(MOCK 폴백 제외)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:28:24 +09:00
a9f5d8cee6 feat(phase2): 타로 UI(3카드 리딩) + 카드 이미지 78종
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:17:52 +09:00
b3d845a532 feat(phase2): 타로 저장·조회 API (user_id + RLS 본인 조회)
- POST /api/tarot/readings: 로그인 필수, interpretation_json 검증 후 insert
- GET /api/tarot/readings: 세션 클라이언트로 본인 것만 조회 (RLS tarot_select_own), 최신순
- Task 6·9가 소비

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 21:04:48 +09:00
10a60300ae fix(phase2): 타로 interpret 견고성 — maxOutputTokens 8192 + wall-clock 가드로 호출 상한 축소
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 21:01:43 +09:00
3acc1dbbe6 feat(phase2): 타로 interpret API — Gemini strict JSON + 인증·일일제한·reroll
lib/tarot/prompt.ts에 TarotInterpretation 스키마·시스템 프롬프트·JSON
파싱/검증 유틸을 추가하고, app/api/tarot/interpret/route.ts에서 사주
analyze와 동일한 Gemini 모델 폴백(getGenerativeModel systemInstruction +
generationConfig) 패턴을 재사용해 인증(401)→일일제한(429)→입력검증(400)
→API키(503)→호출 순서로 처리한다. GEMINI_API_KEY 미설정 시 예시 데이터
대신 503을 반환해 실데이터 오염을 막고, 스키마 검증 실패 시 사유를
주입해 1회 reroll한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:53:53 +09:00
84b36267bf feat(phase2): 일일 사용량 유틸(KST) + tarot_readings·ai_usage_log 마이그레이션
- kstDayStartISO: KST 자정을 UTC ISO로 변환
- getTodayUsage, recordUsage: AI 사용량 조회·기록
- DB: tarot_readings, ai_usage_log 테이블 생성
- saju service_settings 삭제 (숨김 해제)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 20:46:35 +09:00
53e8b592f0 feat(phase2): 타로 셔플·reference 순수 유틸 + 테스트
Fisher-Yates 셔플, 카드 픽 생성, 참고 블록/메타데이터 빌더 구현.
Task 4(interpret API)·Task 6(UI)에서 소비됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:40:30 +09:00
1752e68d55 feat(phase2): 타로 78장 카드 데이터 TS 포팅 + 무결성 테스트
web-ui(src/pages/tarot/data/cards.js)의 메이저 22장·마이너 56장 데이터와
buildMinor/buildMinorDetails 생성 로직을 lib/tarot/cards.ts로 값 변경 없이
포팅. TarotCard/Spread 타입 부여, SPREADS는 three_card만 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 20:35:14 +09:00
19a5559899 docs(phase2): 사주 재활성 + 타로 신규 구현 플랜 (10 Task)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 20:27:32 +09:00
878c0fbf49 docs(phase2): 사주 재활성 + 타로 신규 설계 — 공개·무료화·일일제한·web-ui 포팅 (WS1~4)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 20:22:27 +09:00
57f6eb6684 docs(phase1): CLAUDE.md — showcase·발주 탭·광고 관리·packs 정리 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:36:28 +09:00
d5f194e7b1 chore(phase1): admin/packs 레거시 페이지 제거 (API는 products·mypage 공유로 유지)
- DELETE: app/admin/packs 페이지 디렉토리 전체 제거
- MODIFY: AdminSidebar.tsx에서 '팩 자료' 네비게이션 항목 제거
- VERIFY: /api/admin/packs API 엔드포인트는 유지 (products·mypage 공유)
- VERIFY: npm test (4 passed) + npm run build (success, no errors)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:32:05 +09:00
a85758566a feat(phase1): admin 광고 관리 — 채널·캠페인 CRUD 탭 + 에셋 탭 재편
admin/marketing을 2탭(광고 채널/마케팅 에셋)으로 재구성하고 ad-channels
API(GET/POST/PATCH/DELETE)를 소비하는 CRUD UI를 신규 추가. 기존 에셋
그리드·체크리스트·PNG 변환 기능은 손실 없이 assets 탭으로 이동. 사이드바
라벨을 '마케팅 에셋' → '광고 관리'로 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:27:34 +09:00
f693c4c5b4 fix(phase1): ad-channels API 입력 견고성 — JSON 파싱 try/catch + 문자열 타입 가드
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:22:17 +09:00
3e031a1c80 feat(phase1): ad_channels 테이블 + admin CRUD API
- Migration: ad_channels table (uuid, name, url, status, memo)
- Routes: GET/POST /api/admin/ad-channels (list/create)
- Routes: PATCH/DELETE /api/admin/ad-channels/[id] (update/delete)
- Auth: admin_token verification via verifyAdminTokenNode
- RLS: service_role only, no additional policies

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:19:09 +09:00
90be0d6316 feat(phase1): admin 견적 리스트 발주 뱃지 + 진행 상태 라벨 확장
- status 타입에 'in_progress' | 'completed' | 'delivered' 추가
- STATUS 맵 확장: 3개 신규 상태 추가 및 accepted 라벨 변경
  * accepted: '수락 · 발주' (기존 녹색)
  * in_progress: '진행중 · 발주' (파란색)
  * completed: '완료 · 발주' (에메랄드)
  * delivered: '납품 완료 · 발주' (틸)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:16:49 +09:00
976511df44 feat(phase1): mypage 발주·진행 섹션 — projects API 배선 + 견적코드 연결
견적 수락 시 발주서로 전환되는 프로젝트를 마이페이지에 표면화.
GET /api/projects로 quotes+milestones를 조회해 총액·마일스톤 타임라인을 표시하고,
POST /api/projects/link로 공개 견적 코드를 계정에 연결하는 폼을 추가했다.
기존 requests 탭의 의뢰 카드 리스트는 그대로 유지(탭 key 호환), 라벨만 발주·진행으로 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:11:58 +09:00
3db3d91a40 feat(phase1): /showcase 제작 사례 허브 + TopNav 제작 사례 + robots 죽은 경로 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:05:31 +09:00
e5ff5ec84f feat(phase1): showcase 데모 메타 단일 소스 + 무결성 테스트
- /showcase 제작 사례 허브가 소비할 데모 8종 메타 모듈화
- slug/title/description/tags 필수 필드 검증 테스트 3건
- Task 2(showcase 라우트)가 SHOWCASE_SAMPLES import 가능

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 15:01:43 +09:00
6234f4277a docs(phase1): 외주 코어 구현 플랜 (8 Task)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 15:00:13 +09:00
559134100d docs(phase1): 외주 코어 설계 — 발주서 통합·제작 사례 허브·광고 관리 (WS1~5)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 14:55:57 +09:00
1b75b27188 chore(phase0): DB 마이그레이션(DROP 3테이블+packages 행) + CLAUDE.md 정합화
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:27:50 +09:00
7366c18692 chore(phase0): 고아 API 제거 — track/[token](페이지 직접조회로 대체됨)·saju/lotto
- app/api/track/[token]: 페이지에서 Supabase 직접 조회로 대체됨
- app/api/saju/lotto: 프론트 fetch 0회, 외부 saju-engine 전용

참조: Task 6 (Phase 0 cleanup)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:25:05 +09:00
8c5858b350 chore(phase0): deepfield 파티클 잔재 3파일 + three/@types/three 의존성 제거
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:22:01 +09:00
592b3fcf4e chore(phase0): redirect에 가린 죽은 페이지 4종 + 전이 고아(ContactForm·freelance-portfolio) 제거
- /work → /outsourcing 리다이렉트에 가려진 app/work/page.tsx 제거
- /work/freelance → /outsourcing 리다이렉트에 가려진 freelance/ 디렉토리 제거
- /work/website → /portfolio 리다이렉트에 가려진 app/work/website/page.tsx 제거
- /music/packs → /products 리다이렉트에 가려진 app/music/packs/ 디렉토리 제거
- 유일 소비처(freelance/)가 삭제된 ContactForm.tsx 제거
- 유일 소비처(/work, /work/freelance)가 삭제된 freelance-portfolio.ts 제거

samples/** 8종과 layout.tsx는 유지됨.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:15:30 +09:00
1e926fcb19 chore(phase0): PortOne 잔재 제거 — 계좌이체 단일 소스 확정, saju 결제 CTA 제거
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:12:38 +09:00
8e1cf9b4e1 chore(phase0): packages·subscription 제거 — 페이지/API/cron/vercel.json + 파급(stats·members·saju) 수정
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:07:56 +09:00
88fe56163d chore(phase0): eBay 세트 제거 — 문진·문서 admin/API/lib/CONTENT + cheerio
Delete:
- app/api/questionnaire/ (submit/route.ts)
- app/admin/questionnaire/ (page.tsx)
- app/api/admin/questionnaire/ (route.ts + [id]/route.ts)
- app/admin/documents/ (page.tsx)
- app/api/admin/documents/ ([filename]/route.ts)
- lib/ebay-tools/ (crawler.ts·pricing.ts·ai-analyzer.ts·types.ts)
- CONTENT/ebay-tool-{questionnaire,proposal}.html
- CONTENT/ARCHITECTURE_EBAY_PARTS_TOOL.md

Modify:
- app/admin/components/AdminSidebar.tsx: Remove NAV_ITEMS for /admin/documents & /admin/questionnaire
- package.json: Remove cheerio dependency

Verify: npm test (4 files, 20 tests PASS), npm run build OK

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 14:02:47 +09:00
0c6ebb2eaa docs(phase0): 정리·삭제 구현 플랜 (7 Task)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 14:00:40 +09:00
9241eac4e1 docs(refactor): SaaS 운영 리팩토링 로드맵 + Phase 0 정리·삭제 설계
비전 재정의(외주 메인 + 사주·타로·음악 별도 서비스) 기반 Phase 0~3 로드맵.
Phase 0: eBay 세트·packages/subscription·PortOne 잔재·죽은 페이지·고아 코드 삭제 설계.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AAtcmKKtqDUe4NyVgy1aLQ
2026-07-02 13:47:29 +09:00
gahusb
65f0a6bb41 Merge PR #2: 라이트 고craft 재설계 (홈·외주·제품 3면)
Deep Field 다크 → 라이트 단일 시스템 재설계. 검증 통과(test 20/20, build 86/86).
2026-06-30 16:15:35 +09:00
7e1105f574 fix(redesign): ScrollReveal reduced-motion 시 transition까지 생략(정적 표시)
기존엔 스크롤 스태거만 건너뛰고 700ms 전환은 남았음 → instant 분기로 완전 정지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:53:23 +09:00
f4fd0f60c9 chore(redesign): 재설계가 죽인 다크/스크롤큐 CSS 제거 + 연결선 gradient 제거
globals.css: --jsm-dark-* 토큰, --jsm-accent-bright, .jsm-dark-form,
.df-scroll-dot/@keyframes df-scroll-cue 제거 (전부 소비처 0).
홈 PROCESS 연결선 linear-gradient → solid 인셋 라인.

유지: --kx-*/.kx-*(/, packages·work·music 사용), .gradient-text(/portfolio/[token] 사용)
— 숨김·레거시 라우트라 이번 범위 밖.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:52:22 +09:00
37465701af feat(redesign): 제품 페이지 craft 정렬(공통 언어)
max-w-5xl→6xl, 타입 스케일·여백 리듬·카드 스펙(rounded-2xl/hover)을
홈·외주와 통일. surface↔surface-alt 교차 4섹션.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:49:10 +09:00
c3be57ea1f feat(redesign): 외주 페이지 + 의뢰폼 라이트 전환
페이지: 다크 캔버스/HeroField/스크림 제거, surface↔surface-alt 교차 8섹션.
HERO 비대칭 2단(우 FeedMock 목업). 앵커(#showcase/#portfolio/#process/#contact) 유지.
폼: --jsm-dark-* 전량 라이트 치환, jsm-dark-form 제거. 흰 카드 위 surface-alt 입력으로 가독성 확보.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:48:00 +09:00
897e37f14e feat(redesign): 홈 라이트 재구성 + 2축 복원 + 히어로 제품 목업
다크 캔버스/HeroField/스크림 폐기. surface↔surface-alt 교차 7섹션.
히어로 비대칭 2단(좌 텍스트 / 우 MockWindow=DashboardMock).
누락됐던 "2축 소개"(외주/완성SW) 섹션 복원. CTA 평면 navy(radial 제거).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:44:15 +09:00
7c6238508b feat(redesign): TopNav 다크 라우트 분기 제거 → 단일 라이트 네비
DARK_ROUTES/isDark 및 다크 팔레트 삼항 전량 제거.
전 라우트 동일 라이트 셸 (스크롤 시 surface+line+shadow).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:41:40 +09:00
989cc25465 feat(redesign): 쇼케이스 그래디언트 타일 → 라이트 MockWindow 카드
lib/showcase.ts를 mock 키 기반으로 교체(보라 4슬롯 제거, 목업 6종 다양화).
ShowcaseCard 캔버스/시드/그래디언트 제거 → surface-alt 스테이지 + 흰 MockWindow.
키 목록을 JSX-free keys.ts로 분리해 vitest 가드레일 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:40:56 +09:00
c1afb58bcd feat(redesign): MockWindow 라이트 목업 시스템(프레임+6스크린+레지스트리)
파티클 대체 craft 핵심. 실데이터 0, --jsm-* 라이트 토큰만.
dashboard/feed/match/commerce/site/booking 6종 + 레지스트리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:38:07 +09:00
b2bd7b1b31 docs(redesign): 라이트 재설계 구현 계획 (7 Task)
MockWindow 목업 시스템 → 쇼케이스 전환 → TopNav 단일화 →
홈/외주/제품 3면 라이트 재작성 → 죽은 CSS 제거·검증.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:35:40 +09:00
e5b907dc38 docs(redesign): 라이트 고craft 재설계 설계 문서 확정
홈·외주·제품 3면을 라이트 --jsm-* 단일 시스템으로 통일.
Deep Field 다크/파티클 폐기, 히어로에 코드 UI 목업(MockWindow) 도입,
가짜 그래디언트 쇼케이스 → 실화면 느낌 목업 그리드, 죽은 CSS 정리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2N6SziVSPfavx1j5rAs52
2026-06-30 14:32:18 +09:00
d10fe981f0 fix(deepfield): 히어로 텍스트 대비 복구 — 좌측 앵커 스크림 + 파티클 블룸 완화
세로 중앙이 투명한 스크림 위에 헤드라인이 놓여(items-center) 글자 뒤
받침이 없었고, AdditiveBlending 파티클 3000개가 텍스트 뒤를 밝게 씻어내
흰 글씨가 안 보이던 문제 수정.

- page.tsx: 스크림을 좌측 앵커 다크(좌→우 0.94→0) + 상하 비네트 2겹으로 교체
- HeroField: StaticField radial 광원 밝기 완화(0.45→0.30, 0.16→0.10) + 우측 이동
- HeroField: 파티클 수 3000→1600(lite 800→500), 셰이더 알파 0.45+0.25→0.28+0.18

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YQNcycdLJVtoSKN1tHZU6Q
2026-06-26 18:21:39 +09:00
b705f35c2d feat(outsourcing): Deep Field 재스킨 + 쇼케이스 풀 그리드 + 운영 실증 카피
- 라이트 → 다크 캔버스 전환 (메인과 동일 비주얼 언어: 다크 루트 div + -mt-16 hero + border-t 섹션 리듬 + 모노 라벨 헤더)
- Hero 축약 ~60vh + HeroField 배경
- #showcase 섹션 ShowcaseGrid variant="full"(8슬롯), #portfolio 하위호환 앵커 유지
- 구 SAMPLES(/work/website/samples) 노출 링크 제거 — 쇼케이스가 대체
- 운영 실사례/제공분야/프로세스/FAQ 다크 카드 + ScrollReveal 스태거
- OutsourcingRequestForm 다크 스킨(스타일 값만, 로직 diff 0) + placeholder dark-soft
- "7년차"·"대기업" 잔존 카피 전부 운영 실증 톤으로 교체 (metadata 포함)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 01:10:54 +09:00
4cd4a50869 feat(home): Deep Field 다크 캔버스 재조립 + 운영 실증 카피
- HERO/SHOWCASE/PROCESS/PROOF/SOFTWARE+CTA 5섹션 다크(--jsm-dark-bg) 재구성
- HeroField WebGL 배경 + -mt-16/pt-16로 상단 라이트 띠 제거 (PublicShell 무수정)
- "생각을 동작하는 소프트웨어로." 거대 타이포(clamp, -0.04em)
- 경력·소속 표현 전면 제거 → "24시간 돌아가는 실서비스 직접 설계·운영" 신뢰 축
- CountUp 카운트업 스탯 + 스크롤 큐 keyframes(motion-safe 가드)
- layout metadata·jsonLd 카피 동일 톤 교체 (jobTitle "소프트웨어 엔지니어")

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:57:38 +09:00
01c31e3e5d feat(nav): 다크 라우트 인지형 네비게이션 2026-06-13 00:40:20 +09:00
e22622d36d fix(deepfield): home 그리드 지그재그 3-wide 배치(빈 칸 제거) + 데드 CSS 정리
- ShowcaseGrid: index 0·3·4 → feature/col-span-2, 1·2·5 → standard
  wide 3장+standard 3장 = 9셀(3×3) 완전 충전, Row 2 col3 빈 칸 제거
- ShowcaseCard: ring-1(인라인 boxShadow에 덮이는 데드 클래스) 제거
  transition-[...]에서 미사용 border-color 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 00:36:37 +09:00
186ae546f2 feat(deepfield): 쇼케이스 카드·그리드 (시드 제너러티브 타일 + 호버 시차)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:29:12 +09:00
eb1ecf0021 feat(deepfield): HeroField WebGL 파티클 필드 (full/lite/static + 커서 자기장) 2026-06-13 00:19:16 +09:00
4b85c52cfe refactor(deepfield): ScrollReveal variant별 복원 클래스 명시 2026-06-13 00:14:27 +09:00
4223004c24 feat(deepfield): ScrollReveal 스크롤 연출 컴포넌트 2026-06-13 00:06:09 +09:00
bd13641f5e feat(deepfield): 렌더 모드 판정(TDD) + useFieldMode 훅 2026-06-13 00:05:17 +09:00
5cfa124d38 feat(deepfield): three.js + 다크 토큰 + 쇼케이스 8슬롯 데이터 2026-06-13 00:03:43 +09:00
64259a85b5 docs(plan): Deep Field 랜딩 구현 계획 — WebGL 히어로·쇼케이스·다크 재조립 9태스크
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:47:58 +09:00
70068ff3d7 docs(spec): Deep Field 랜딩 경험 — 다크 캔버스 + WebGL 쇼케이스 설계
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 23:42:55 +09:00
055469a2d5 fix(outsourcing): 의뢰 폼 [다음] 무반응 — goNext 스테일 클로저 제거
useCallback deps([step])에 stepValid가 읽는 state가 없어 마운트 시점
빈 상태 기준으로 검증되던 회귀(b4f57c8) 수정. 일반 함수로 전환.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 07:33:10 +09:00
76fb722a27 fix(docs): CLAUDE.md 사실 정정
- Next.js 버전 15 → 16 (package.json ^16.2.6 기준)
- GET /api/packs/sign-link → POST (실제 route.ts export async function POST)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 05:56:38 +09:00
7f5c7fcb20 chore: music 구매 고아 경로 차단(→/products) + CLAUDE.md 현행화 2026-06-12 05:54:18 +09:00
dbd4bbf21b feat(mypage): 내 의뢰 타임라인 + 추적 링크 2026-06-12 05:47:12 +09:00
5e90295d26 fix(admin): 추적링크 복사 상태 리셋 + 견적 뱃지 색 정리 2026-06-12 05:43:00 +09:00
32b07e31fa feat(admin): 의뢰 관리 8종 상태 머신 + 견적 연결·추적 링크 표시
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 05:38:40 +09:00
d62653e834 feat(quote): 거절 액션 + 의뢰 상태 동기화 + 관리자 알림 2026-06-12 05:31:25 +09:00
5ceae7e90b fix(admin): 견적 재발송 방어 + title 타입 검증
- POST /api/admin/quotes: title을 typeof + trim() 검증으로 falsy 문자열 방어
- POST /api/admin/quotes/[id]/send: sent/accepted/rejected 상태면 200 조기 반환(alreadySent: true)으로 중복 발송 차단
- 견적 편집 UI: isSentStatus 플래그로 발송 버튼 비활성화·라벨 "발송됨" 표시, alreadySent 응답 시 안내 alert 처리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 05:28:22 +09:00
70abad31b7 feat(admin): 의뢰→견적 연결 생성 + 견적 발송(메일·상태 동기화) 2026-06-12 05:23:01 +09:00
f5cfb8bd6f feat(portal): /track/[token] 비회원 의뢰 추적 페이지
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 05:13:58 +09:00
b4f57c85ec refactor(outsourcing): 입력 스타일 상수화 + goNext 방어적 재검증
1. 반복되는 INPUT_STYLE 객체를 파일 상단 상수로 추출하여 5곳에서 재사용
   - textarea (단계③)
   - input[name] (단계④)
   - input[email] (단계④)
   - input[phone] (단계④)
   - button.prev (네비게이션)

2. goNext 함수 첫 줄에 방어적 재검증 추가
   - if (!stepValid(step)) return; 추가
   - step dependency 복원 (useCallback 의존성 배열)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 05:10:03 +09:00
429780d65d feat(outsourcing): 4단계 의뢰 폼 + 접수 완료 추적 안내
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 05:00:46 +09:00
8e820760e2 feat(contact): 구조화 필드 + 추적 토큰 + 고객 접수 확인 메일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 01:32:31 +09:00
146836f56b fix(portal): 토큰 DEFAULT·UNIQUE 인덱스 보장 + 메일 제목 이스케이프 제거
- contact_requests.public_token: 인라인 UNIQUE 제거, 백필 UPDATE 직후 SET DEFAULT + CREATE UNIQUE INDEX IF NOT EXISTS 패턴으로 교체 (라이브 DB 멱등성 보장)
- quotes.public_token: ADD COLUMN IF NOT EXISTS + SET DEFAULT + 백필 UPDATE + CREATE UNIQUE INDEX IF NOT EXISTS 4줄 구조로 교체 (인라인 UNIQUE NO-OP 문제 해소)
- sendQuoteSentEmail / sendQuoteDecisionEmail subject에서 escapeHtml() 제거 — 메일 제목은 평문, HTML 본문 이스케이프는 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 01:29:36 +09:00
f7d26c4c3f feat(portal): 의뢰 상태 머신(TDD) + 의뢰/견적 메일 2026-06-12 01:21:30 +09:00
5077f6ad17 feat(db): 고객 포털 — contact_requests 상태머신·토큰 + quotes FK 2026-06-12 01:18:51 +09:00
5751cddcea docs(plan): 리뉴얼 Phase 3 구현 계획 — 외주 고객 포털 + 레거시 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:16:46 +09:00
a3933c1081 feat(home): 소프트웨어 진열 섹션 products 동적 연동
getListedProducts(createAdminClient()) try/catch로 상위 3개 카드 렌더,
0개이면 기존 출시 준비 중 폴백 유지. force-dynamic으로 항상 최신 목록 노출.
2026-06-12 00:26:03 +09:00
d2a20c5cb7 feat(admin): 제품 관리 — CRUD + 파일 업로드·제품 배정
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 00:20:48 +09:00
e713ebceee docs(plan): Phase 2 재개 체크포인트 — P2-8까지 완료, P2-9 미커밋 중단 2026-06-11 13:21:01 +09:00
dc5e9d431c fix(admin): 주문 취소에도 confirm 확인 추가 2026-06-11 09:17:57 +09:00
7b02e28f6c feat(admin): 주문 관리 페이지 — 입금 확인 원클릭 + 다운로드 활성화 메일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:13:34 +09:00
8dafb98f47 fix(products): 모달 401 세션만료 처리 + callback open redirect 방어 + 초기 포커스
- BankTransferModal: POST /api/orders 401 응답 시 setAuthState('guest')로 전환 (에러 텍스트 대신 로그인 유도 UI 복귀)
- BankTransferModal: 모달 열릴 때 closeBtnRef.current?.focus() 호출 (접근성 초기 포커스)
- auth/callback: next 파라미터를 safeNext 패턴으로 검증 — startsWith('/') && !startsWith('//') && !startsWith('/\') 미충족 시 /mypage 폴백

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:11:05 +09:00
199dae0ee5 feat(products): 동적 카탈로그·상세 페이지 + 계좌이체 구매 모달
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 09:07:00 +09:00
f151af89f3 feat(downloads): 다운로드 검증을 orders 단일 소스로 교체 + 내 제품 제품별 그룹핑
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 08:47:32 +09:00
3fa865a6e7 fix(orders): user 기준 rate limit + 상품 조회 예외 처리
- checkRateLimit('orders:{user.id}', 60_000, 5) 인증 직후 적용 → 429 반환
- getProductById try/catch 추가 → DB 장애 시 500 '상품 조회에 실패했습니다'
- lib/order-emails.ts sendOrderPaidEmail HTML 이스케이프 대상 없음 (해당 없음)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:41:42 +09:00
1d5e7254ed feat(orders): 계좌이체 주문 생성 API + 접수/입금확인 메일
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:36:22 +09:00
692fb504d9 feat(products): orders 기반 제품/파일 조회 헬퍼 2026-06-11 08:32:44 +09:00
e86ca27831 feat(db): 음악 팩 구매 이력 contact_requests → orders 멱등 이관 2026-06-11 08:28:34 +09:00
5d90ac310e feat(db): products 카탈로그 확장 + 음악 제품 시드 + pack_files.product_id 백필 2026-06-11 08:28:31 +09:00
cf89e8cbdb feat(products): vitest 도입 + 제품 접근 확장 로직 (music tier 하위 호환)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 08:24:33 +09:00
fe055fd0d0 docs(plan): 리뉴얼 Phase 2 구현 계획 — orders 단일 소스 제품 판매 시스템
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 08:21:39 +09:00
gahusb
0580fe8f5a Merge pull request #1 from gahusb/feature/renewal-phase1
리뉴얼 Phase 1: 외주+소프트웨어 2축 풀 리디자인 + 레거시 서비스 숨김
2026-06-11 03:13:35 +09:00
a25b645933 fix(renewal): mypage 숨김 링크 교체 + /products 스텁 + sitemap 정리
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 03:04:06 +09:00
c447294c84 fix(quote): 보라 잔재·navy 리터럴을 jsm 토큰으로 정리 2026-06-11 02:49:19 +09:00
c2d7455f65 refactor(design): 노출 페이지 잔여 글래스·다크 스타일 정리
- layout.tsx: GlassFilter 렌더·import 제거 (LiquidGlass.tsx 파일은 숨김 페이지용으로 유지)
- payment/success,fail: #04102b 다크 헤더 → jsm-navy 토큰, h2 색상 jsm-ink 토큰으로 교체
- legal/terms,privacy,refund: h1 text-[#04102b] → jsm-ink CSS 변수
- portfolio/[token]: bg-slate-950 + 다크 radial-gradient → jsm-bg/surface/navy 라이트 테마
- quote/[token]: #0a0f1e/#0f172a 전체 다크 테마 → jsm-* 라이트 토큰 기반으로 전면 교체 (print CSS·수락/거절 로직 무수정)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:44:36 +09:00
4bd5400406 feat(mypage): 4탭 재구성 + 전문 톤 리디자인 (데이터·다운로드 로직 무수정)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:31:40 +09:00
76994c28f1 fix(login): 포커스 링·type=button·disabled 대비 접근성 보완
- 이메일/비밀번호 input에 focus-visible:ring-2 추가
- Google 로그인 버튼에 type=\"button\" 명시
- disabled 상태 배경 jsm-line → jsm-ink-faint로 변경 (대비 개선)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:18:37 +09:00
cd1f67d076 feat(login): 로그인 페이지 전문 톤 리디자인 (인증 로직 무수정)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 02:14:43 +09:00
e0b6120bb6 fix(outsourcing): 착수금 표기를 기존 공표 조건(30%)으로 정정 2026-06-11 02:12:29 +09:00
a11006fab5 feat(outsourcing): 외주 의뢰 페이지 신설 + work 라우트 리다이렉트
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 02:04:38 +09:00
b846a713c1 fix(seo): jsonLd Service URL을 /outsourcing으로 정렬
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:56:58 +09:00
be3cc3752e feat(home): 외주+소프트웨어 2축 메인 페이지 풀 리디자인 + 메타데이터 교체 2026-06-11 01:53:08 +09:00
89dc5364d1 fix(nav): 모바일 드로어 접근성 (aria-expanded·Esc·dialog role) + hover 클래스화
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:48:24 +09:00
6d6d6f353a feat(nav): 외주·소프트웨어 2축 네비게이션 + 푸터 리뉴얼
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:42:37 +09:00
b13ddd3841 feat(visibility): 사주·음악·설문·패키지 라우트 숨김 가드 적용 2026-06-11 01:35:46 +09:00
281edd9a52 fix(visibility): 기존 시드 행도 숨김 갱신되도록 DO UPDATE + 재사용 경고 JSDoc
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:29:23 +09:00
f6df890297 feat(visibility): service_settings 기반 서비스 숨김 가드 + 레거시 서비스 시드
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:24:07 +09:00
776985eca8 fix(design): 미정의 --font-pretendard 변수 간접참조 제거
var(--font-pretendard, ...) 패턴은 --font-pretendard가 어디에도
정의되지 않아 항상 fallback으로 동작합니다. npm CSS import 방식에서
next/font 제거 후 변수 주입이 안되므로 직접 'Pretendard Variable'로
단순화하여 의도 명확화합니다. 모든 fallback stack은 유지됩니다.

변경 위치:
- @theme inline (font-sans, font-mono)
- body (전역 폰트)
- .font-mono 유틸리티
- .kx-section, .kx-display, .kx-label (Kinetic Ether)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:22:13 +09:00
e14e527e28 feat(design): JSM 전문 토큰 체계 + Pretendard 도입, kx 토큰 재매핑
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 01:15:11 +09:00
a496c2244b docs(plan): 리뉴얼 Phase 1 구현 계획 — 디자인 토큰·숨김 가드·메인/외주/로그인/마이페이지
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 01:06:48 +09:00
d46acc43e3 docs(spec): 사이트 리뉴얼 설계 — 외주+소프트웨어 판매 2축 재구성
- 계좌이체 중심 결제, 기존 서비스 숨김(admin 토글), 고객 포털, 풀 리디자인
- orders 단일 소스 구매 식별, pack 인프라 범용 제품 시스템 확장

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 00:59:36 +09:00
3e0d8bcf88 docs(plan): Phase 6 ① 완료 + 재개 체크포인트(③ DNS 전환 직전) 기록
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 03:16:21 +09:00
0aa4da7143 docs(plan): Phase 5 완료 — 앱 NAS 배포(Gitea registry) + app.jaengseung-made.com 검증
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 17:20:22 +09:00
38fe9dec3f fix(docker): 빌드타임 더미 RESEND_API_KEY로 standalone 빌드 통과
- /api/survey 등 모듈 레벨 new Resend(process.env.RESEND_API_KEY)가
  .env 없는 docker 빌드에서 throw → 빌드타임 더미로 통과(런타임은 env_file 실제값)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 16:58:28 +09:00
251 changed files with 18783 additions and 11463 deletions

212
CLAUDE.md
View File

@@ -2,7 +2,7 @@
## 프로젝트 개요
7년차 대기업 백엔드 개발자 **박재오**가 운영하는 개발 부업 사이트.
고객 맞춤형 서비스를 개발·판매하거나, 이미 완성된 솔루션을 구독 형태로 제공한다.
고객 맞춤형 서비스를 외주 개발하거나, 이미 완성된 솔루션을 계좌이체 구매 형태로 제공한다.
## 운영자 정보
- 이름: 박재오
@@ -11,51 +11,174 @@
- 연락처: 010-3907-1392
- NAS 개인 서버: 로또 랩, 주식 자동매매 프로그램 등 실제 서비스 운영 중
## 핵심 서비스
| 서비스 | 경로 | 설명 |
|--------|------|------|
| 로또 번호 추천 | `/services/lotto` | 빅데이터/통계 기반 로또 번호 분석 제공 |
| 주식 자동 매매 | `/services/stock` | 텔레그램 연동 주식 자동 매매 프로그램 |
| 프롬프트 엔지니어링 | `/services/prompt` | 업무 특화 AI 프롬프트 설계 서비스 |
| 업무 자동화 | `/services/automation` | RPA·엑셀·이메일 등 일상 업무 자동화 개발 |
| 외주 개발 | `/freelance` | 맞춤형 소프트웨어 외주 (포트폴리오 + 문의) |
## 핵심 IA (공개 라우트)
| 경로 | 설명 |
|------|------|
| `/` | 메인 — 외주 개발 + 완성 소프트웨어 2축 소개 |
| `/outsourcing` | 외주 개발 — 4단계 의뢰 폼 · 프로세스 · 포트폴리오 · FAQ |
| `/products` | 완성 소프트웨어 목록 — 계좌이체 구매 |
| `/products/[id]` | 제품 상세 — 구매 신청·결제 안내 |
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
| `/track/[token]` | 비회원 의뢰 진행 추적 |
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
| `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
## 숨김 서비스 (admin_token 세션 전용)
`service_settings` 테이블 토글 + `lib/service-visibility.ts` 가드로 접근 제한.
admin/services 패널에서 ON/OFF 전환 가능.
| 경로 | 서비스 |
|------|--------|
| `/gyeol` | CONTOUR PMF 설문 |
## 기술 스택
- **Framework**: Next.js 16 (App Router, TypeScript)
- **Styling**: Tailwind CSS v4
- **Email**: Resend (API key: 환경변수 `RESEND_API_KEY`)
- **DB**: Supabase (클라우드 + NAS self-host 이중 운영)
- **Email**: Resend (`RESEND_API_KEY`) — 문의 접수·주문 확인·견적 발송 메일
- **Analytics**: Google Analytics G-WG77RNHXRK
- **Deployment**: Vercel
- **Test**: vitest (`npm test`) — lib 단위 테스트
- **Deployment**: Vercel (NAS self-host 전환 진행 중, 컷오버 전 Vercel 운영)
## 디자인 시스템
- **Primary**: Blue (`#1d4ed8` blue-700, `#2563eb` blue-600)
- **Secondary**: Violet/Purple (`#7c3aed` violet-600, `#8b5cf6` violet-500)
- **Layout**: 대시보드형 — 왼쪽 고정 사이드바 + 오른쪽 스크롤 콘텐츠
- **Sidebar bg**: `#0f172a` (slate-900)
- **Main bg**: `#f1f5f9` (slate-100)
- **Cards**: white + 그림자
## 디자인 시스템 (`--jsm-*` 토큰)
### CSS 변수
| 토큰 | 값 | 역할 |
|------|----|------|
| `--jsm-bg` | `#f8fafc` | 페이지 배경 |
| `--jsm-surface` | `#ffffff` | 카드·패널 배경 |
| `--jsm-ink` | `#0f172a` | 본문 텍스트 |
| `--jsm-line` | `#e2e8f0` | 구분선·테두리 |
| `--jsm-navy` | `#0b1f3a` | 헤더·강조 배경 |
| `--jsm-accent` | `#1d4ed8` | 단일 포인트 컬러 (버튼·링크) |
### 레이아웃
- 상단 네비(`TopNav`) + 푸터 포함 `PublicShell` 기업형 레이아웃
- Pretendard 폰트
### 금지 가이드레일
- gradient / blur / 보라(violet/purple) 계열 색상 사용 금지
- 이모지 사용 금지 (UI 내)
- `--jsm-*` 토큰 외 임의 색상 변수 추가 금지
## 파일 구조
```
app/
layout.tsx — 루트 레이아웃 (메타데이터, 폰트, GA, DashboardShell 래핑)
page.tsx — 홈 대시보드 (서비스 카드 그리드)
globals.css — 전역 스타일 + CSS 변수
components/
DashboardShell.tsx — 클라이언트: 사이드바 + 메인 영역 레이아웃 래퍼
Sidebar.tsx — 클라이언트: 왼쪽 사이드바 내비게이션
ContactForm.tsx — 클라이언트: 문의 폼 (Resend 연동)
services/
lotto/page.tsx — 로또 번호 추천 서비스 상세
stock/page.tsx — 주식 자동 매매 서비스 상세
prompt/page.tsx — 프롬프트 엔지니어링 서비스 상세
automation/page.tsx — 업무 자동화 서비스 상세
freelance/
page.tsx — 외주 개발 포트폴리오 + 문의 폼
layout.tsx — 루트 레이아웃 (메타데이터·폰트·GA·PublicShell)
page.tsx — 메인 (2축 랜딩)
globals.css — 전역 스타일 + --jsm-* CSS 변수
components/ — 공용 UI (TopNav, PublicShell, ContactForm 등)
outsourcing/page.tsx — 외주 의뢰 페이지
products/
page.tsx — 완성 소프트웨어 목록
[id]/page.tsx — 제품 상세 + 구매 신청
track/[token]/page.tsx — 비회원 의뢰 추적
quote/[token]/page.tsx — 공개 견적 수락/거절
login/page.tsx — 로그인 (?next= 지원)
mypage/page.tsx — 마이페이지 4탭
legal/ — privacy / terms / refund
showcase/page.tsx — 제작 사례 허브 (웹 데모 8종 + 실서비스 운영 사례)
admin/ — 관리자 전용 (dashboard·members·services·orders·products·contacts·quotes·marketing(광고 관리: 채널 CRUD + 에셋)·...)
api/
contact/route.ts — POST: 문의 이메일 발송 (Resend)
contact/route.ts — POST: 의뢰 접수 (public_token 발급 + 고객 메일)
orders/route.ts — POST: 주문 생성(pending)
quote/[token]/route.ts — GET/POST: 견적 조회·수락/거절
admin/quotes/[id]/send/route.ts — 견적 발송 (메일 + 'quoted' 상태 동기화)
admin/ad-channels/ — 광고 채널 CRUD (ad_channels 테이블)
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
studio/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만)
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
gyeol/ — 숨김: CONTOUR PMF 설문
lib/
service-visibility.ts — 숨김 서비스 접근 가드
product-access.ts — orders→제품 접근 확장 (music tier 하위 호환)
request-status.ts — 의뢰 상태 머신 단일 소스
order-emails.ts — 주문 관련 Resend 메일
request-emails.ts — 의뢰 관련 Resend 메일
supabase/
product-files.ts — 제품·파일 조회
pack-files.ts — 레거시 팩 파일
saju-calculator.ts — 사주팔자 계산 (검증 완료)
solar-terms.ts — 절기 계산
ai-interpretation.ts — 사주 AI 해석·용신 추정
ai-usage.ts — AI 기능 일일 사용량 제한 (ai_usage_log 테이블)
tarot/
cards.ts — 타로 78장 카드 데이터
shuffle.ts — 셔플·3카드 드로우 로직
reference.ts — 카드 의미 레퍼런스
prompt.ts — AI 해석 프롬프트
music/
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
```
---
## 외주 플로우 (의뢰 상태 머신)
```
고객 의뢰 (/api/contact)
→ public_token 발급 + 고객 접수 메일
→ admin/contacts 수신
pending → reviewing → quoted ──→ accepted ──→ in_progress → completed
↓ ↓
on_hold on_hold
cancelled (어느 단계에서도 가능)
```
| 전환 | 트리거 |
|------|--------|
| `pending → reviewing` | 관리자 확인 |
| `reviewing → quoted` | 관리자 견적 작성 + `/api/admin/quotes/[id]/send` 발송 (메일 + 상태 동기화) |
| `quoted → accepted` | 고객 `/quote/[token]` 수락 (관리자 메일 알림) |
| `quoted → on_hold` | 고객 `/quote/[token]` 거절 |
| `accepted → in_progress` | 관리자 착수 처리 |
| `in_progress → completed` | 관리자 완료 처리 |
---
## 결제 플로우 (계좌이체 단일 소스)
```
고객 구매 신청 (/products/[id])
→ POST /api/orders → orders 레코드 생성 (status: pending)
→ 입금 안내 메일 발송 (케이뱅크 100-116-337157 박재오)
관리자 입금 확인 (/admin/orders)
→ orders.status: pending → paid
→ 다운로드 링크 메일 발송
고객 다운로드 (/mypage → 내 제품 탭)
→ POST /api/packs/sign-link → DSM 서명 링크 (4시간 TTL)
```
- `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환
---
## 개발 규칙
- 서비스 페이지 공통 구조: Hero → Features → Pricing → FAQ → CTA
- 구매/신청 CTA는 `/outsourcing#contact` 또는 `/products/[id]` 구매 버튼으로 연결
- 가드레일 준수: gradient·blur·보라·이모지 금지, `--jsm-*` 토큰만 사용
- 숨김 서비스 접근: `lib/service-visibility.ts` 가드 → admin_token 세션 없으면 404 반환
- 새 라우트 추가 시 공개/숨김 여부를 `service_settings`에 명시
- DB 마이그레이션은 클라우드 Supabase + NAS self-host **양쪽** 적용 필수
---
## 쟁승메이드 Co. — AI 에이전트 팀 (`.claude/commands/`)
쟁승메이드는 **회사 단위 AI 팀**으로 운영됩니다.
@@ -104,16 +227,10 @@ app/
---
## 개발 규칙
- 서비스 페이지 공통 구조: Hero → Features → Pricing → FAQ → CTA
- 구매/신청 CTA는 `/freelance` 페이지 ContactForm으로 연결 (service 파라미터로 pre-fill)
- 사이드바는 `usePathname`으로 활성 경로 감지
- 모바일: 햄버거 메뉴로 사이드바 토글 (overlay 포함)
- 이미지 없이 아이콘·그래디언트·SVG로 시각적 완성도 유지
## 사주 시스템 (`/app/work/saju`, `/lib/saju-*.ts`)
---
## 사주 시스템 (`/app/saju`, `/lib/saju-*.ts`)
> **공개 서비스 — 로그인 시 AI 해석 무료(1회/일)**
> 전 화면(랜딩·입력·결과) `--jsm` 라이트 재스킨 완료(2026-07-03) — 디자인 가드레일 준수
### AI 연동 (`app/api/saju/analyze/route.ts`)
- **AI**: Google Gemini (`@google/generative-ai`)
@@ -124,7 +241,7 @@ app/
- **Vercel 타임아웃**: `export const maxDuration = 60` (Pro 플랜 기준)
- **Mock 감지**: `isMockInterpretation()` 함수로 DB에 캐시된 예시 데이터 판별
- `SajuAISection.tsx`에서 mock이면 `validSaved = null`로 처리 → API 재호출
- 재생성 버튼(🔄)으로 수동 재생성 가능
- 재생성 버튼으로 수동 재생성 가능
### 사주팔자 계산 원칙 (검증 완료)
@@ -159,3 +276,12 @@ const stemIndex = (startStem + (branchIndex - 2 + 12) % 12) % 10;
년주: 壬申 월주: 壬子 일주: 癸酉 시주: 庚申
```
이 결과가 나오면 계산 로직 정상. 다른 값이면 위 원칙 재확인.
---
## 운영 주의사항
- **`.env` 파일 절대 커밋 금지**
- **DB 마이그레이션**: 클라우드 Supabase + NAS self-host **양쪽** 적용 필수
- **`2026-06-12-products-extend.sql`의 pack_files 백필 UPDATE는 재실행 금지** (중복 데이터 발생)
- **NAS self-host 전환 진행 중**: 컷오버 전까지 Vercel 운영 유지
- **music/packs 고아 경로**: `/music/packs``/products` 308 리다이렉트 (next.config.ts 처리)

View File

@@ -1,969 +0,0 @@
# 이베이 자동차 부품 리스팅 AI 자동화 툴 — 기술 아키텍처 설계서
> 작성일: 2026-04-02
> 작성자: Developer Agent (쟁승메이드)
> 버전: v1.0 Draft
---
## 목차
1. [시스템 아키텍처 설계](#1-시스템-아키텍처-설계)
2. [기술 스택 선정 및 근거](#2-기술-스택-선정-및-근거)
3. [핵심 모듈별 상세 설계](#3-핵심-모듈별-상세-설계)
4. [DB 스키마 설계](#4-db-스키마-설계)
5. [API 엔드포인트 설계](#5-api-엔드포인트-설계)
6. [계정 안전성 설계](#6-계정-안전성-설계)
7. [리스크 & 트레이드오프](#7-리스크--트레이드오프)
---
## 1. 시스템 아키텍처 설계
### 1.1 전체 시스템 구성도
```
┌──────────────────────────────────────────────────────────────────────┐
│ 클라이언트 (PC 브라우저) │
│ Next.js App Router — Tailwind CSS │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 부품 검색 │ │ 결과 대시 │ │ 리스팅 │ │ 히스토리 │ │
│ │ 입력 폼 │ │ 보드 │ │ 편집기 │ │ /설정 │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
└──────────────────────────┬───────────────────────────────────────────┘
│ HTTPS (Vercel Edge)
┌──────────────────────────────────────────────────────────────────────┐
│ Next.js API Routes (Vercel Serverless) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ /api/ │ │ /api/ │ │ /api/ │ │ /api/ │ │
│ │ search │ │ analyze │ │ listing │ │ price │ │
│ └─────┬────┘ └─────┬────┘ └─────┬────┘ └─────┬────┘ │
└────────┼────────────┼────────────┼────────────┼─────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 크롤러 워커 (별도 서버 — Docker/VPS) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Playwright 엔진 │ │ 사이트별 어댑터 │ │ 프록시 로테이터 │ │
│ │ (브라우저 풀) │ │ RockAuto/Amazon │ │ + User-Agent 풀 │ │
│ │ │ │ PartsGeek/eBay │ │ │ │
│ └─────────────────┘ └─────────────────┘ └──────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BullMQ 큐 관리 │ │ Redis │ │
│ │ (작업 스케줄링) │ │ (캐시/큐 백엔드) │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ AI 분석 파이프라인 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │
│ │ Claude API │ │ 구조화 출력 파서 │ │ Fitment 검증기 │ │
│ │ (주 분석 엔진) │ │ (JSON Schema) │ │ (Cross-ref) │ │
│ └─────────────────┘ └─────────────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────┐
│ 데이터 저장 │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Supabase │ │ Redis Cache │ │
│ │ (PostgreSQL) │ │ (TTL 기반) │ │
│ │ - 검색 히스토리 │ │ - 크롤링 결과 │ │
│ │ - 부품 데이터 │ │ - 환율 캐시 │ │
│ │ - 리스팅 초안 │ │ - 세션 상태 │ │
│ │ - 사용자 설정 │ │ │ │
│ └─────────────────┘ └─────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
```
### 1.2 데이터 흐름도 (메인 파이프라인)
```
[사용자 입력]
품번: "16610-0H040"
품명: "Fuel Pump Assembly"
[1단계: 초기 검색 — 2~5초]
├─ Supabase 캐시 조회 (동일 품번 24시간 이내 검색 존재?)
│ ├─ HIT → 캐시 결과 즉시 반환 + 백그라운드 갱신 옵션 제공
│ └─ MISS → 크롤링 작업 생성
[2단계: 크롤링 큐 등록 — 즉시]
├─ BullMQ에 작업 등록
├─ 클라이언트에 jobId 반환 → SSE/Polling으로 진행률 추적
[3단계: 병렬 크롤링 — 15~45초]
├─ [Worker 1] RockAuto 검색 → 가격, 호환 차종, 이미지
├─ [Worker 2] PartsGeek 검색 → 가격, 리뷰 수
├─ [Worker 3] Amazon 검색 → 가격, 판매량 추정
├─ [Worker 4] eBay 기존 리스팅 검색 → 경쟁 가격, 판매량
├─ [Worker 5] OEM DB 검색 (partsouq) → 순정 번호, 호환 번호
└─ 각 Worker: 성공/실패 개별 보고, 부분 실패 허용
[4단계: AI 분석 — 5~15초]
├─ 수집 데이터 정규화 + 병합
├─ Claude API 호출 (구조화 출력 요청)
│ ├─ Fitment 매칭 (차종별 연도/모델/엔진)
│ ├─ 최적 리스팅 제목 생성 (80자 이내)
│ ├─ Item Specifics 추출
│ └─ 가격 추천 (시장가 분석 기반)
├─ 정확도 검증 (Cross-reference 체크)
[5단계: 가격 계산 — 1초]
├─ 환율 API (KRW/USD)
├─ 원가 + 관세(8%) + 국제배송비 + 이베이 수수료(13%) + 마진
├─ 경쟁 가격 대비 포지셔닝
[6단계: 리스팅 생성 — 즉시]
├─ eBay 리스팅 템플릿 조립
├─ Fitment Chart (Year/Make/Model/Engine 테이블)
├─ Supabase에 초안 저장
└─ 사용자에게 최종 결과 반환 (편집 가능)
```
### 1.3 배포 아키텍처
```
┌─────────────────────────────────┐
│ Vercel (프론트 + API) │
│ Next.js App Router │
│ - SSR 페이지 │
│ - API Routes (오케스트레이터) │
│ - Edge Functions (경량 API) │
│ maxDuration: 60s (Pro) │
└──────────────┬──────────────────┘
│ HTTPS
┌─────────────────────────────────┐
│ VPS (크롤러 전용 서버) │
│ Docker Compose │
│ │
│ ┌───────────┐ ┌──────────┐ │
│ │ crawler │ │ Redis │ │
│ │ (Node.js │ │ 7.x │ │
│ │ +Playwright│ │ │ │
│ │ +BullMQ) │ │ │ │
│ └───────────┘ └──────────┘ │
│ │
│ 비용: ~$10~20/월 (Contabo/ │
│ Hetzner 2vCPU/4GB) │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Supabase (DB + Auth) │
│ PostgreSQL + Row Level Security│
│ Free tier → Pro 필요 시 전환 │
└─────────────────────────────────┘
```
**배포 분리 이유:**
- Vercel Serverless는 최대 60초 타임아웃 (Pro). 크롤링은 45초 이상 소요 가능
- Playwright는 ~400MB 브라우저 바이너리 필요. Vercel 함수 크기 제한(50MB) 초과
- 크롤러 서버를 분리하면 IP 관리, 프록시 설정, 브라우저 풀 관리가 자유로움
- Vercel API Routes는 오케스트레이터 역할만 수행 (크롤러 서버에 작업 위임)
---
## 2. 기술 스택 선정 및 근거
### 2.1 프론트엔드
| 항목 | 선택 | 근거 |
|------|------|------|
| 프레임워크 | **Next.js 16 App Router** | 기존 jaengseung-made 스택 동일. SSR/ISR, API Routes 통합 |
| 스타일링 | **Tailwind CSS v4** | 기존 스택. 빠른 프로토타이핑, 일관된 디자인 시스템 |
| 상태 관리 | **React 19 내장 (useState/useReducer)** | 복잡한 글로벌 상태 불필요. 폼 + 결과 뷰 중심 |
| 실시간 갱신 | **SSE (Server-Sent Events)** | 크롤링 진행률 실시간 표시. WebSocket 대비 구현 단순 |
### 2.2 백엔드 (오케스트레이터)
| 항목 | 선택 | 근거 |
|------|------|------|
| API | **Next.js API Routes** | 별도 FastAPI 불필요. 크롤러만 분리하면 API Routes로 충분 |
| 인증 | **Supabase Auth** | 기존 jaengseung-made 인증 체계 재사용 |
| 비동기 통신 | **HTTP + SSE** | Vercel API → 크롤러 서버 HTTP 호출, 클라이언트에는 SSE로 진행률 전달 |
**FastAPI 별도 서버 검토 결과: 불채택**
- 크롤러 서버가 이미 분리되므로, API 오케스트레이션만 하는 레이어에 FastAPI를 또 세우면 인프라 복잡도만 증가
- Next.js API Routes + Vercel Serverless로 오케스트레이션 충분
- 단, 향후 사용량 급증 시 API 레이어 분리 고려 가능
### 2.3 크롤러 엔진
| 항목 | Playwright | Puppeteer |
|------|-----------|-----------|
| **브라우저 지원** | Chromium, Firefox, WebKit | Chromium only |
| **Anti-bot 우회** | stealth 플러그인 생태계 넓음 | puppeteer-extra-stealth 있음 |
| **안정성** | Microsoft 관리, 업데이트 빠름 | Google Chrome팀, 안정적 |
| **멀티 컨텍스트** | 브라우저 하나에 격리된 컨텍스트 다수 생성 가능 | 유사하나 API 덜 직관적 |
| **Docker 지원** | 공식 Docker 이미지 제공 | 수동 설정 필요 |
| **선택** | **Playwright** | - |
**Playwright 선택 이유:**
1. `browser.newContext()`로 사이트별 격리된 세션 관리 용이 (쿠키/스토리지 분리)
2. `playwright-extra` + `stealth` 플러그인으로 headless 탐지 우회 성숙
3. 자동 대기(`waitForSelector`, `waitForLoadState`) API가 크롤링에 최적화
4. Firefox 컨텍스트를 섞어 쓸 수 있어 fingerprint 다양화 가능
### 2.4 AI 엔진
| 항목 | Claude API (Anthropic) | OpenAI API |
|------|----------------------|------------|
| **구조화 출력** | Tool Use로 JSON Schema 강제 가능 | JSON Mode / Function Calling |
| **긴 컨텍스트** | 200K 토큰 (크롤링 데이터 대량 입력에 유리) | 128K (GPT-4o) |
| **정확도** | 복잡한 추론/분류에 강점 | 범용적으로 우수 |
| **비용 (입/출력)** | Sonnet: $3/$15 per 1M tok | GPT-4o: $2.5/$10 per 1M tok |
| **기존 의존성** | jaengseung-made에 `@anthropic-ai/sdk` 이미 설치 | `openai` 패키지도 설치됨 |
| **선택** | **Claude API (주)** + OpenAI (폴백) | - |
**Claude 선택 이유:**
1. 200K 컨텍스트 윈도우 — 5개 사이트 크롤링 결과를 한 번에 분석 가능
2. Tool Use 기반 구조화 출력 — Fitment 테이블, Item Specifics 등 복잡한 JSON 구조 강제
3. 자동차 부품 도메인의 정밀한 분류/추론에서 강점 (호환 차종 판단은 환각 최소화 중요)
4. 기존 프로젝트에 SDK 설치됨 — 추가 의존성 없음
**비용 추정 (건당):**
- 입력: ~8K 토큰 (5개 사이트 크롤링 결과 요약) = ~$0.024
- 출력: ~2K 토큰 (구조화된 리스팅 정보) = ~$0.030
- **건당 약 $0.05~0.06 (약 70~80원)**
### 2.5 큐/비동기 처리
| 항목 | 선택 | 근거 |
|------|------|------|
| 작업 큐 | **BullMQ** | Node.js 네이티브, Redis 기반, 재시도/우선순위/스케줄링 내장 |
| 큐 백엔드 | **Redis 7** | BullMQ 필수. 크롤링 결과 TTL 캐시 겸용 |
| 대안 검토 | ~~RabbitMQ~~ | 오버스펙. Node.js 단일 언어 환경에서 BullMQ가 최적 |
| 대안 검토 | ~~Vercel Queue~~ | 아직 베타, 커스텀 재시도 로직 제한적 |
**BullMQ 작업 흐름:**
```
Vercel API → HTTP POST → 크롤러 서버 /jobs 엔드포인트
→ BullMQ 큐에 작업 등록
→ Worker가 Playwright로 크롤링 실행
→ 완료 시 Redis에 결과 저장 + Webhook/SSE로 Vercel에 통지
→ Vercel API가 클라이언트 SSE로 결과 전달
```
### 2.6 기술 스택 종합표
| 레이어 | 기술 | 비용 |
|--------|------|------|
| 프론트엔드 | Next.js 16 + Tailwind v4 + React 19 | Vercel Free/Pro |
| API 오케스트레이터 | Next.js API Routes (Vercel Serverless) | Vercel에 포함 |
| 크롤러 서버 | Node.js + Playwright + BullMQ | VPS $10~20/월 |
| 캐시/큐 | Redis 7 (Docker) | VPS에 포함 |
| AI | Claude API (Anthropic) | ~$0.05/건 |
| DB | Supabase (PostgreSQL) | Free → Pro |
| 환율 | ExchangeRate-API 또는 한국은행 API | 무료 |
| 배포 | Vercel (프론트) + Docker Compose (크롤러) | 합계 ~$15~25/월 |
---
## 3. 핵심 모듈별 상세 설계
### 3.1 크롤러 모듈
#### 아키텍처: 어댑터 패턴
각 대상 사이트를 독립된 어댑터로 구현. 공통 인터페이스를 통해 결과를 정규화.
```
CrawlerOrchestrator
├── RockAutoAdapter (가격, 호환차종, 이미지)
├── PartsGeekAdapter (가격, 리뷰)
├── AmazonAdapter (가격, 판매량)
├── EbaySearchAdapter (경쟁 리스팅, 판매량, 가격)
├── PartsouqAdapter (OEM 번호, 호환 번호, 차종)
└── (확장 가능: AutoZone, 7zap 등)
```
#### 사이트별 크롤링 전략
| 사이트 | 방식 | 난이도 | 핵심 데이터 | 비고 |
|--------|------|--------|------------|------|
| **RockAuto** | Playwright (동적 렌더링) | 중 | 가격, Fitment, 이미지 URL | 카테고리 네비게이션 필요 |
| **PartsGeek** | HTTP + HTML 파싱 | 하 | 가격, 리뷰 수 | 정적 HTML, 단순 파싱 가능 |
| **Amazon** | Playwright (봇 감지 강함) | 상 | 가격, BSR, 리뷰 | CAPTCHA 빈번, 폴백 필요 |
| **eBay** | **eBay Browse API (공식)** | 하 | 경쟁가, 판매량, 카테고리 | API 우선, 크롤링 최소화 |
| **partsouq** | HTTP + JSON API | 중 | OEM 번호, 호환 번호 | 내부 API 엔드포인트 활용 |
#### 공통 어댑터 인터페이스
```
Input:
- partNumber: string (품번)
- partName: string (품명, 영문)
- options?: { timeout, proxy, userAgent }
Output (정규화):
- source: string (사이트명)
- status: "success" | "partial" | "failed"
- products: Array<{
title: string
price: { amount: number, currency: "USD" | "KRW" }
imageUrls: string[]
brand: string
oemNumbers: string[] (호환 품번)
fitment: Array<{ year: string, make: string, model: string, engine?: string }>
url: string
reviews?: { count: number, rating: number }
salesRank?: number
}>
- metadata: { crawledAt: ISO8601, responseTime: number }
- error?: string
```
#### Rate Limiting
| 사이트 | 요청 간격 | 일일 한도 | 근거 |
|--------|----------|----------|------|
| RockAuto | 3~5초 (랜덤) | 200회 | 공격적 봇 감지 |
| PartsGeek | 1~2초 | 500회 | 상대적 관대 |
| Amazon | 5~10초 (랜덤) | 100회 | CAPTCHA 트리거 방지 |
| eBay | API Rate Limit 준수 | 5000 calls/day | 공식 API 사용 |
| partsouq | 2~3초 | 300회 | 내부 API 부하 방지 |
#### 캐싱 전략
- **Redis TTL 캐시**: 동일 품번 크롤링 결과를 24시간 캐시
- **캐시 키**: `crawl:{site}:{partNumber}` (예: `crawl:rockauto:16610-0H040`)
- **캐시 히트 시**: 즉시 반환 + "갱신" 버튼으로 수동 리크롤 가능
- **Supabase 장기 캐시**: 30일간 부품 마스터 데이터 (OEM 번호, Fitment) 보관
#### 차단 대응 (폴백 계층)
```
1차: Playwright + Stealth 플러그인 (기본)
↓ 차단 감지 시
2차: 프록시 로테이션 (주거용 프록시 풀)
↓ 차단 지속 시
3차: 해당 사이트 스킵 + 나머지 사이트 결과로 분석 진행
↓ 핵심 사이트(eBay) 차단 시
4차: eBay 공식 API로 폴백 (Browse API / Finding API)
```
#### 에러 처리
- 각 어댑터는 독립 실행. 1개 사이트 실패해도 나머지 정상 진행
- 최소 2개 사이트 성공 시 AI 분석 진행 가능
- 전체 실패 시: 사용자에게 수동 입력 폼 제공 (URL 붙여넣기)
---
### 3.2 AI 분석 모듈
#### 프롬프트 설계 방향
**System Instruction (고정)**:
```
역할: 자동차 부품 이베이 리스팅 전문가
- 입력된 크롤링 데이터를 분석하여 이베이 리스팅 정보를 생성
- Fitment 정보는 반드시 크롤링 데이터에서 확인된 차종만 포함 (추측 금지)
- 이베이 Title은 80자 이내, 핵심 키워드 우선 배치
- Item Specifics는 eBay Motors Parts & Accessories 카테고리 기준
```
**User Message (동적 — 크롤링 결과 포함)**:
```
품번: {partNumber}
품명: {partName}
[크롤링 결과]
--- RockAuto ---
{rockAutoData}
--- eBay 경쟁 리스팅 ---
{ebayData}
--- OEM DB ---
{oemData}
위 데이터를 분석하여 다음을 생성해주세요:
1. 이베이 최적 제목 (3개 후보)
2. Item Specifics
3. Fitment Chart
4. 가격 추천
```
#### 구조화 출력 (Tool Use Schema)
Claude API의 Tool Use를 활용하여 JSON 구조를 강제:
```
Tool Name: generate_ebay_listing
Parameters Schema:
{
titles: string[3] // 제목 후보 3개
recommendedTitle: string // 추천 제목 (80자 이내)
category: {
id: number // eBay 카테고리 ID
name: string // 카테고리명
}
itemSpecifics: {
brand: string
manufacturerPartNumber: string
interchangePartNumber: string // OE/OEM 호환 번호
placement: string // "Front", "Rear" 등
type: string // 부품 유형
material: string
color: string
warranty: string
country: string // 제조국
[key: string]: string // 추가 Specifics
}
fitment: Array<{
year: string // "2007-2012" 범위 가능
make: string // "Toyota"
model: string // "Camry"
engine: string // "2.4L L4"
trim?: string // "LE, SE, XLE"
notes?: string // 특이사항
}>
priceAnalysis: {
competitorAvg: number // 경쟁 평균가 (USD)
competitorRange: [number, number] // 최저~최고
recommendedPrice: number // 추천 판매가
reasoning: string // 가격 근거
}
description: string // HTML 상품 설명
confidence: {
fitment: "high" | "medium" | "low"
pricing: "high" | "medium" | "low"
overall: "high" | "medium" | "low"
}
warnings: string[] // 주의사항 (불확실한 정보 등)
}
```
#### 정확도 검증 (Multi-source Cross-reference)
1. **Fitment 교차 검증**: 2개 이상 소스에서 확인된 차종만 "high confidence"
2. **OEM 번호 검증**: partsouq/7zap 데이터와 크롤링 결과 대조
3. **가격 이상치 감지**: 경쟁 평균 대비 +-50% 이상 차이나면 경고
4. **confidence 레벨**:
- `high`: 3개 이상 소스 일치
- `medium`: 2개 소스 일치
- `low`: 1개 소스만 확인 → 사용자에게 수동 확인 요청
#### AI 폴백 전략
```
1차: Claude Sonnet 4 (비용 효율 + 정확도 밸런스)
↓ 실패/타임아웃 시
2차: Claude Haiku (빠른 응답, 약간의 정확도 트레이드오프)
↓ Anthropic API 장애 시
3차: OpenAI GPT-4o (폴백)
```
---
### 3.3 가격 계산 모듈
#### Input/Output
```
Input:
- sourcePrices: Array<{ source, price, currency }> // 크롤링된 가격들
- competitorPrices: Array<{ price, soldCount }> // eBay 경쟁 가격
- userSettings: { marginPercent, shippingMethod, customsRate }
Output:
- costBreakdown: {
purchasePrice: number (USD) // 구매가 (최저가 기준)
exchangeRate: number // 적용 환율
purchasePriceKRW: number // 원화 구매가
customsDuty: number (KRW) // 관세 (8% 기본)
customsTax: number (KRW) // 부가세 (10%)
domesticShipping: number (KRW) // 국내 배송비
intlShipping: number (USD) // 국제 배송비
ebayFee: number (USD) // eBay 수수료 (13.25%)
paypalFee: number (USD) // PayPal 수수료 (3.49% + $0.49)
totalCost: number (USD) // 총 원가
}
- pricing: {
breakEvenPrice: number (USD) // 손익분기점
recommendedPrice: number (USD) // 추천가 (마진 반영)
competitorAvg: number (USD) // 경쟁 평균
marginPercent: number // 예상 마진율
profitPerUnit: number (USD) // 건당 예상 수익
}
- comparison: Array<{ source, price, diff }> // 소스별 가격 비교표
```
#### 환율 처리
- **주 API**: ExchangeRate-API (무료 1,500회/월) 또는 한국은행 Open API
- **캐시**: Redis에 1시간 TTL로 환율 캐시
- **폴백**: 캐시 만료 + API 장애 시 최근 캐시값 사용 (24시간 이내)
- **사용자 수동 입력**: 환율 직접 입력 옵션 제공
#### 관세/수수료 테이블
| 항목 | 기본값 | 사용자 조정 가능 | 비고 |
|------|--------|----------------|------|
| 관세율 | 8% | O | 자동차 부품 HS Code 기준 |
| 부가세 | 10% | X | 고정 |
| eBay Final Value Fee | 13.25% | O | 카테고리별 상이 |
| PayPal/Managed Payments | 3.49% + $0.49 | O | 결제 방식별 상이 |
| 국제 배송비 | 무게 기반 계산 | O | EMS/K-Packet/FedEx 선택 |
| 이베이 프로모션 할인 | 0% | O | Promoted Listings 비용 |
---
### 3.4 리스팅 생성 모듈
#### eBay 카테고리 매핑
주요 자동차 부품 카테고리 매핑 테이블 (DB 저장):
| 부품 유형 | eBay Category ID | Category Path |
|-----------|-----------------|---------------|
| Fuel Pump | 33554 | eBay Motors > Parts > Fuel System > Fuel Pumps |
| Brake Pad | 33560 | eBay Motors > Parts > Brakes > Pads & Shoes |
| Air Filter | 33548 | eBay Motors > Parts > Air Intake > Filters |
| ... | ... | 약 200개 주요 카테고리 사전 매핑 |
- AI가 품명 기반으로 1차 카테고리 추천
- 사전 매핑 테이블과 교차 검증
- 사용자가 최종 선택/수정 가능
#### Item Specifics 템플릿
카테고리별 필수/선택 Item Specifics 템플릿:
```
[공통 필수]
- Brand
- Manufacturer Part Number
- Interchange Part Number
- Placement on Vehicle
- Warranty
- Country/Region of Manufacture
- UPC (없으면 "Does Not Apply")
[카테고리별 추가]
- Fuel Pump: Fuel Type, Number of Outlets, Voltage
- Brake Pad: Position (Front/Rear), Pad Material, Thickness
```
#### Fitment 테이블 출력 형식
eBay Parts Compatibility 형식에 맞춘 CSV/테이블:
```
Year | Make | Model | Trim | Engine | Notes
2007 | Toyota | Camry | LE, SE, XLE | 2.4L L4 DOHC |
2008 | Toyota | Camry | LE, SE, XLE | 2.4L L4 DOHC |
2008 | Toyota | Camry | SE, XLE | 3.5L V6 DOHC |
...
```
- eBay의 ePID (Product ID) 매칭 시도 (정확한 Fitment 보장)
- CSV 다운로드 기능 (eBay Bulk Upload용)
- 수동 행 추가/삭제 편집 기능
#### 최종 출력 형태
사용자에게 보여지는 리스팅 프리뷰:
```
[복사 가능 영역]
Title: [편집 가능]
Category: [드롭다운 선택]
Item Specifics: [테이블 형태, 각 필드 편집 가능]
Price: [입력 필드, 원가 계산기 연동]
Fitment Chart: [테이블, 행 추가/삭제 가능]
Description: [HTML 프리뷰 + 편집]
[액션 버튼]
- "전체 복사" (클립보드)
- "CSV 다운로드" (Fitment)
- "초안 저장" (Supabase)
- "히스토리에서 불러오기"
```
---
## 4. DB 스키마 설계 (Supabase / PostgreSQL)
### 4.1 테이블 구조
```sql
-- 사용자 설정
CREATE TABLE user_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
default_margin_percent DECIMAL(5,2) DEFAULT 30.00,
default_shipping_method TEXT DEFAULT 'k-packet',
default_customs_rate DECIMAL(5,2) DEFAULT 8.00,
ebay_fee_percent DECIMAL(5,2) DEFAULT 13.25,
preferred_currency TEXT DEFAULT 'USD',
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id)
);
-- 검색 히스토리
CREATE TABLE search_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
part_number TEXT NOT NULL,
part_name TEXT NOT NULL,
status TEXT DEFAULT 'pending', -- pending, crawling, analyzing, completed, failed
crawl_sources JSONB, -- 어떤 사이트를 크롤링했는지
result_summary JSONB, -- 요약 정보 (가격 범위, 호환 차종 수 등)
created_at TIMESTAMPTZ DEFAULT now(),
completed_at TIMESTAMPTZ
);
CREATE INDEX idx_search_history_user ON search_history(user_id, created_at DESC);
CREATE INDEX idx_search_history_part ON search_history(part_number);
-- 부품 캐시 (크롤링 결과 장기 보관)
CREATE TABLE parts_cache (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_number TEXT NOT NULL,
source TEXT NOT NULL, -- 'rockauto', 'partsgeek', 'amazon', 'ebay', 'partsouq'
raw_data JSONB NOT NULL, -- 크롤링 원본 데이터
normalized_data JSONB NOT NULL, -- 정규화된 데이터
crawled_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ DEFAULT (now() + INTERVAL '30 days'),
UNIQUE(part_number, source)
);
CREATE INDEX idx_parts_cache_lookup ON parts_cache(part_number, source, expires_at);
-- OEM 번호 매핑 (장기 캐시, 잘 변하지 않는 데이터)
CREATE TABLE oem_mappings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
part_number TEXT NOT NULL,
oem_numbers TEXT[] NOT NULL, -- 호환 OEM 번호 배열
brands TEXT[], -- 관련 브랜드
fitment JSONB, -- 호환 차종 데이터
source TEXT NOT NULL, -- 데이터 출처
verified BOOLEAN DEFAULT false, -- 교차 검증 완료 여부
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_oem_part ON oem_mappings(part_number);
CREATE INDEX idx_oem_numbers ON oem_mappings USING GIN(oem_numbers);
-- 리스팅 초안
CREATE TABLE listing_drafts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
search_id UUID REFERENCES search_history(id) ON DELETE SET NULL,
part_number TEXT NOT NULL,
title TEXT NOT NULL,
category_id INTEGER,
category_name TEXT,
item_specifics JSONB NOT NULL, -- { brand, mpn, ... }
fitment JSONB, -- [{ year, make, model, engine, trim }]
price_data JSONB, -- 가격 계산 결과 전체
description_html TEXT, -- HTML 상품 설명
ai_confidence JSONB, -- { fitment, pricing, overall }
ai_warnings TEXT[], -- AI가 제시한 경고사항
status TEXT DEFAULT 'draft', -- draft, published, archived
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX idx_drafts_user ON listing_drafts(user_id, created_at DESC);
-- eBay 카테고리 매핑 (사전 정의)
CREATE TABLE ebay_categories (
id SERIAL PRIMARY KEY,
category_id INTEGER UNIQUE NOT NULL,
category_name TEXT NOT NULL,
category_path TEXT NOT NULL,
required_specifics TEXT[], -- 필수 Item Specifics 필드명
optional_specifics TEXT[], -- 선택 Item Specifics 필드명
keywords TEXT[] -- 매칭용 키워드
);
CREATE INDEX idx_ebay_cat_keywords ON ebay_categories USING GIN(keywords);
-- 환율 캐시
CREATE TABLE exchange_rates (
id SERIAL PRIMARY KEY,
base_currency TEXT NOT NULL DEFAULT 'USD',
target_currency TEXT NOT NULL DEFAULT 'KRW',
rate DECIMAL(12,4) NOT NULL,
fetched_at TIMESTAMPTZ DEFAULT now()
);
```
### 4.2 RLS (Row Level Security) 정책
```sql
-- 사용자별 데이터 격리
ALTER TABLE user_settings ENABLE ROW LEVEL SECURITY;
ALTER TABLE search_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE listing_drafts ENABLE ROW LEVEL SECURITY;
-- 본인 데이터만 접근
CREATE POLICY "users_own_settings" ON user_settings
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "users_own_searches" ON search_history
FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "users_own_drafts" ON listing_drafts
FOR ALL USING (auth.uid() = user_id);
-- 캐시 데이터는 모든 인증 사용자 읽기 가능
ALTER TABLE parts_cache ENABLE ROW LEVEL SECURITY;
CREATE POLICY "authenticated_read_cache" ON parts_cache
FOR SELECT USING (auth.role() = 'authenticated');
ALTER TABLE oem_mappings ENABLE ROW LEVEL SECURITY;
CREATE POLICY "authenticated_read_oem" ON oem_mappings
FOR SELECT USING (auth.role() = 'authenticated');
```
---
## 5. API 엔드포인트 설계
### 5.1 Vercel API Routes (오케스트레이터)
#### 검색/크롤링
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/parts/search` | 부품 검색 시작 | `{ partNumber, partName, sources?: string[] }` | `{ jobId, status: "queued", estimatedTime }` |
| GET | `/api/parts/search/[jobId]` | 검색 상태 조회 | - | `{ status, progress: { total, completed, failed }, partialResults? }` |
| GET | `/api/parts/search/[jobId]/stream` | SSE 실시간 진행률 | - | SSE: `{ event, data: { source, status, result? } }` |
#### AI 분석
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/parts/analyze` | AI 분석 실행 | `{ jobId }` 또는 `{ crawlResults }` | `{ listing, confidence, warnings }` |
| POST | `/api/parts/analyze/regenerate` | AI 재분석 (특정 섹션) | `{ jobId, sections: ["title", "fitment"] }` | 해당 섹션만 재생성 |
#### 가격 계산
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/parts/price/calculate` | 가격 계산 | `{ purchasePrice, currency, weight?, settings? }` | `{ costBreakdown, pricing, comparison }` |
| GET | `/api/exchange-rate` | 현재 환율 | `?base=USD&target=KRW` | `{ rate, fetchedAt }` |
#### 리스팅 관리
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| POST | `/api/listings/drafts` | 초안 저장 | `{ ...listingData }` | `{ id, createdAt }` |
| GET | `/api/listings/drafts` | 초안 목록 | `?page=1&limit=20` | `{ drafts[], total }` |
| GET | `/api/listings/drafts/[id]` | 초안 상세 | - | `{ ...listingData }` |
| PUT | `/api/listings/drafts/[id]` | 초안 수정 | `{ ...updates }` | `{ ...updated }` |
| DELETE | `/api/listings/drafts/[id]` | 초안 삭제 | - | `{ success }` |
| GET | `/api/listings/drafts/[id]/csv` | Fitment CSV 다운로드 | - | CSV 파일 |
#### 히스토리
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| GET | `/api/parts/history` | 검색 히스토리 | `?page=1&limit=20` | `{ searches[], total }` |
| DELETE | `/api/parts/history/[id]` | 히스토리 삭제 | - | `{ success }` |
#### 설정
| Method | Path | 설명 | 요청 | 응답 |
|--------|------|------|------|------|
| GET | `/api/settings` | 사용자 설정 조회 | - | `{ ...settings }` |
| PUT | `/api/settings` | 사용자 설정 수정 | `{ marginPercent?, shippingMethod?, ... }` | `{ ...updated }` |
### 5.2 크롤러 서버 내부 API (VPS)
Vercel -> 크롤러 서버 간 내부 통신. API Key 인증.
| Method | Path | 설명 |
|--------|------|------|
| POST | `/jobs` | 크롤링 작업 등록 |
| GET | `/jobs/[id]` | 작업 상태 조회 |
| GET | `/jobs/[id]/result` | 작업 결과 조회 |
| DELETE | `/jobs/[id]` | 작업 취소 |
| GET | `/health` | 헬스체크 |
인증: `Authorization: Bearer {CRAWLER_API_KEY}` (환경변수)
---
## 6. 계정 안전성 설계
### 6.1 기본 원칙
```
"크롤링은 탐지되지 않는 것이 아니라, 사람처럼 보이는 것이 목표"
```
### 6.2 계층별 방어 전략
#### Layer 1: 브라우저 핑거프린트 위장
| 대책 | 구현 |
|------|------|
| Stealth 플러그인 | `playwright-extra` + `stealth` 플러그인 (WebGL, WebRTC, Navigator 위장) |
| User-Agent 로테이션 | 실제 Chrome/Firefox UA 풀 (50개+), 세션 단위 고정 |
| Viewport 다양화 | 1920x1080, 1366x768, 1440x900 등 실제 해상도 랜덤 선택 |
| 언어/타임존 | `en-US`, `America/New_York` 등 일관된 프로필 |
| WebDriver 플래그 | `navigator.webdriver = false` 강제 |
#### Layer 2: 행동 패턴 모방
| 대책 | 구현 |
|------|------|
| 요청 간격 | 가우시안 분포 랜덤 딜레이 (평균 3초, 표준편차 1.5초) |
| 스크롤 시뮬레이션 | 페이지 로드 후 자연스러운 스크롤 (즉시 파싱 방지) |
| 마우스 무브먼트 | 클릭 전 마우스 이동 궤적 시뮬레이션 |
| 세션 관리 | 쿠키 유지, 세션 간 일관된 행동 |
| 접속 패턴 | 업무 시간대(미국 EST 9-17시) 집중, 심야 크롤링 최소화 |
#### Layer 3: IP/네트워크 관리
| 대책 | 구현 |
|------|------|
| 프록시 풀 | 주거용(Residential) 프록시 10개+ (Bright Data 또는 Oxylabs) |
| IP 로테이션 | 사이트별 세션 단위로 IP 고정 (세션 중 변경 금지) |
| 지역 설정 | 미국 IP만 사용 (부품 사이트 타겟 시장) |
| 프록시 비용 | 약 $15~30/월 (트래픽 기반 과금) |
#### Layer 4: eBay 특별 보호
```
[최우선 원칙] eBay는 크롤링 최소화. 공식 API 최대 활용.
- eBay Browse API: 리스팅 검색, 가격 조회 (공식)
- eBay Finding API: 카테고리 검색 (공식)
- 크롤링은 API로 불가능한 데이터만 (판매 완료 건수 등)
- eBay 크롤링 시 별도 IP + 최소 빈도 (일 50회 이하)
- 이베이 셀러 계정과 크롤링 IP를 절대 동일하게 사용하지 않음
```
#### Layer 5: 차단 감지 및 자동 중단
```
감지 신호:
- HTTP 403/429 응답
- CAPTCHA 페이지 감지 (특정 DOM 요소)
- CloudFlare Challenge 페이지
- 비정상적으로 빈 응답
대응:
1. 즉시 해당 사이트 크롤링 중단
2. 30분 쿨다운 (해당 사이트만)
3. 다른 프록시로 재시도 (1회)
4. 실패 시 해당 사이트 24시간 차단 + 관리자 알림
```
---
## 7. 리스크 & 트레이드오프
### 7.1 기술 선택 트레이드오프
| 선택 | 장점 | 단점 | 대안 |
|------|------|------|------|
| **크롤러 별도 VPS** | 타임아웃 제약 없음, IP 관리 자유 | 인프라 비용 + 관리 부담 | Vercel에서 직접 크롤링 (불가, 60초 제한) |
| **BullMQ + Redis** | 재시도/우선순위 내장, 모니터링 UI | Redis 추가 인프라 | DB 폴링 방식 (단순하지만 비효율) |
| **Playwright** | 다양한 브라우저, 스텔스 생태계 | 메모리 사용량 높음 (~400MB/인스턴스) | Puppeteer (더 가벼우나 Chromium only) |
| **Claude AI** | 긴 컨텍스트, 정밀한 추론 | OpenAI 대비 약간 비쌈 | GPT-4o (더 저렴, 컨텍스트 128K) |
| **SSE** | 단방향 실시간, 구현 단순 | 양방향 불가 | WebSocket (오버스펙), Polling (지연) |
| **Supabase** | 기존 스택, RLS, Auth 통합 | 고빈도 쓰기 시 비용 증가 | 자체 PostgreSQL (관리 부담) |
### 7.2 크롤링 차단 시 폴백 전략
```
[시나리오별 대응]
1. 단일 사이트 일시 차단 (가장 빈번)
→ 해당 사이트 스킵, 나머지로 분석 진행
→ AI가 "데이터 불충분" 경고 출력
2. 다수 사이트 동시 차단
→ 사용자에게 수동 URL 입력 폼 제공
→ 사용자가 브라우저에서 직접 검색한 URL을 붙여넣으면 파싱
3. eBay API 쿼터 소진
→ 일일 5000회 제한 모니터링
→ 90% 도달 시 캐시 우선 정책으로 전환
→ 100% 시 eBay 검색 링크만 제공 (수동 조회)
4. 장기 차단 (IP 블랙리스트)
→ 프록시 풀 교체
→ 최악의 경우 해당 사이트 어댑터 비활성화
→ 비크롤링 대안: 공식 API가 있는 사이트로 점진적 전환
```
### 7.3 AI 비용 추정
| 사용량 | 월 검색 건수 | AI 비용 | 크롤러 VPS | 프록시 | 합계 |
|--------|------------|---------|-----------|--------|------|
| 초기 (테스트) | 50건 | ~$3 | $10 | $0 (무프록시 테스트) | ~$13/월 |
| 소규모 운영 | 300건 | ~$18 | $15 | $15 | ~$48/월 |
| 중규모 운영 | 1,000건 | ~$60 | $20 | $30 | ~$110/월 |
| 대규모 | 3,000건+ | ~$180 | $40 | $50 | ~$270/월 |
### 7.4 개발 우선순위 제안 (MVP → 풀 버전)
#### Phase 1 — MVP (2~3주)
- 품번 입력 → RockAuto + eBay API만 크롤링
- Claude AI 분석 → 리스팅 제목 + Item Specifics 생성
- 가격 계산기 (수동 입력 기반)
- 크롤러: Vercel 자체 실행 (단순 HTTP 파싱 위주, Playwright 불필요)
- DB: Supabase에 검색 히스토리만
#### Phase 2 — 크롤러 분리 (2주)
- VPS에 Playwright + BullMQ 크롤러 서버 구축
- PartsGeek, Amazon, partsouq 어댑터 추가
- Redis 캐싱 도입
- SSE 실시간 진행률
#### Phase 3 — 고도화 (2주)
- Fitment 교차 검증 + confidence 시스템
- 프록시 로테이션 + 스텔스 강화
- 리스팅 초안 저장/편집/히스토리
- CSV 다운로드 (eBay Bulk Upload)
#### Phase 4 — 확장 (지속)
- eBay Listing API 직접 연동 (리스팅 자동 등록)
- 가격 모니터링 (경쟁 가격 변동 알림)
- 대량 처리 (CSV 품번 목록 일괄 검색)
- 사용자 통계 대시보드
### 7.5 핵심 리스크 목록
| 리스크 | 확률 | 영향 | 대응 |
|--------|------|------|------|
| 크롤링 대상 사이트 구조 변경 | 높음 (분기 1회) | 중 | 어댑터 패턴으로 격리, 모니터링 알림 |
| eBay 계정 제재 (잘못된 Fitment) | 중 | 상 | AI confidence 시스템, 수동 확인 권고 |
| AI 환각 (존재하지 않는 차종 생성) | 중 | 상 | Multi-source 교차 검증, low confidence 경고 |
| 크롤링 IP 차단 | 높음 | 중 | 프록시 풀, API 우선 전략 |
| AI API 비용 초과 | 낮음 | 중 | 캐시 적극 활용, Haiku 폴백 |
| Vercel 타임아웃 (60초) | 중 | 중 | 크롤러 서버 분리 (Phase 2) |
---
## 부록: 쟁승메이드 서비스 연계
이 프로젝트는 jaengseung-made.com의 **외주 개발 포트폴리오****업무 자동화 서비스** 레퍼런스로 활용:
- `/freelance` 포트폴리오에 "이베이 자동화 툴" 케이스 추가
- `/services/automation` 페이지에서 "해외 이커머스 자동화" 사례로 소개
- 동일 기술 스택(Next.js + Supabase + AI)으로 일관된 개발 역량 시연
- 향후 SaaS화 시 쟁승메이드 구독 서비스로 편입 가능
---
> 이 문서는 초안이며, CEO(박재오) 리뷰 후 Phase 1 착수 전에 확정합니다.

File diff suppressed because it is too large Load Diff

View File

@@ -1,973 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>이베이 자동차 부품 AI 리스팅 자동화 — 사전 요구사항 질문지</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap');
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
color: #1e293b;
background: #f8fafc;
line-height: 1.7;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.page {
max-width: 800px;
margin: 0 auto;
background: #ffffff;
padding: 48px 56px;
}
/* ── Header ── */
.header {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 3px solid #1a56db;
padding-bottom: 20px;
margin-bottom: 12px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
}
.brand-icon {
width: 44px;
height: 44px;
background: #1a56db;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.brand-icon svg {
width: 26px;
height: 26px;
fill: #ffffff;
}
.brand-name {
font-size: 22px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.5px;
}
.brand-sub {
font-size: 12px;
font-weight: 400;
color: #64748b;
}
.doc-date {
font-size: 13px;
color: #94a3b8;
text-align: right;
}
.doc-title {
font-size: 19px;
font-weight: 700;
color: #0f172a;
margin-bottom: 4px;
line-height: 1.4;
}
.doc-subtitle {
font-size: 14px;
color: #64748b;
margin-bottom: 32px;
}
/* ── Client Info ── */
.client-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 24px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 36px;
}
.client-info .field {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.client-info .field-label {
font-weight: 600;
color: #475569;
white-space: nowrap;
}
.client-info .field-value {
flex: 1;
border-bottom: 1px solid #cbd5e1;
min-height: 24px;
padding-bottom: 2px;
}
.client-info .field-input {
flex: 1;
border: none;
border-bottom: 1px solid #cbd5e1;
min-height: 24px;
padding: 2px 4px;
font-family: inherit;
font-size: 14px;
color: #1e293b;
background: transparent;
outline: none;
transition: border-color 0.2s;
}
.client-info .field-input:focus {
border-bottom-color: #1a56db;
}
.client-info .field-input::placeholder {
color: #cbd5e1;
}
/* ── Submit Section ── */
.submit-section {
margin-top: 32px;
text-align: center;
}
.submit-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 14px 48px;
background: #1a56db;
color: #ffffff;
border: none;
border-radius: 8px;
font-family: inherit;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover {
background: #1e40af;
}
.submit-btn:disabled {
background: #94a3b8;
cursor: not-allowed;
}
.submit-msg {
margin-top: 12px;
font-size: 14px;
line-height: 1.6;
}
.submit-msg.success {
color: #16a34a;
}
.submit-msg.error {
color: #dc2626;
}
.save-draft-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 24px;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
margin-right: 12px;
transition: background 0.2s;
}
.save-draft-btn:hover {
background: #e2e8f0;
}
/* ── Section ── */
.section {
margin-bottom: 32px;
}
.section-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.section-badge {
display: inline-flex;
align-items: center;
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
flex-shrink: 0;
}
.badge-required {
background: #1a56db;
color: #ffffff;
}
.badge-optional {
background: #e2e8f0;
color: #475569;
}
.section-title {
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
.section-desc {
font-size: 13px;
color: #64748b;
margin-bottom: 16px;
padding-left: 2px;
}
/* ── Question Card ── */
.question {
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 20px 24px;
margin-bottom: 16px;
page-break-inside: avoid;
}
.question:last-child {
margin-bottom: 0;
}
.q-top {
display: flex;
align-items: flex-start;
gap: 12px;
margin-bottom: 10px;
}
.q-num {
width: 28px;
height: 28px;
background: #1a56db;
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
margin-top: 1px;
}
.badge-optional + .section-title ~ .question .q-num,
.section.optional .q-num {
background: #64748b;
}
.q-text {
font-size: 15px;
font-weight: 600;
color: #1e293b;
line-height: 1.5;
}
.q-hint {
font-size: 13px;
color: #94a3b8;
margin-top: 4px;
padding-left: 40px;
font-weight: 400;
}
.q-options {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
padding-left: 40px;
}
.q-option {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 13px;
color: #475569;
cursor: pointer;
}
.q-option input[type="checkbox"],
.q-option input[type="radio"] {
accent-color: #1a56db;
}
.answer-area {
margin-top: 12px;
padding-left: 40px;
}
.answer-area textarea {
width: 100%;
min-height: 64px;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 10px 14px;
font-family: inherit;
font-size: 14px;
color: #1e293b;
resize: vertical;
background: #fafbfc;
transition: border-color 0.2s;
}
.answer-area textarea:focus {
outline: none;
border-color: #1a56db;
background: #ffffff;
}
.answer-area textarea.large {
min-height: 96px;
}
.answer-line {
margin-top: 12px;
padding-left: 40px;
border-bottom: 1px solid #cbd5e1;
min-height: 28px;
}
/* ── Footer ── */
.footer {
margin-top: 40px;
border-top: 2px solid #e2e8f0;
padding-top: 24px;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.footer-left {
font-size: 13px;
color: #64748b;
line-height: 1.8;
}
.footer-left strong {
color: #0f172a;
}
.footer-right {
text-align: right;
font-size: 12px;
color: #94a3b8;
line-height: 1.8;
}
.footer-notice {
margin-top: 20px;
background: #f1f5f9;
border-radius: 8px;
padding: 16px 20px;
font-size: 13px;
color: #64748b;
line-height: 1.7;
}
.footer-notice strong {
color: #475569;
}
/* ── Print Styles ── */
@media print {
body {
background: #ffffff;
}
.page {
padding: 24px 32px;
max-width: none;
box-shadow: none;
}
.question {
border: 1px solid #d1d5db;
break-inside: avoid;
}
.answer-area textarea {
border: none;
border-bottom: 1px solid #999;
border-radius: 0;
background: transparent;
min-height: 48px;
}
.q-option {
border-color: #999;
}
.header {
border-bottom-color: #1a56db;
}
.q-num {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.section-badge {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.footer-notice {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
@page {
margin: 16mm 12mm;
}
</style>
</head>
<body>
<div class="page">
<!-- Header -->
<div class="header">
<div class="brand">
<div class="brand-icon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<div>
<div class="brand-name">쟁승메이드</div>
<div class="brand-sub">JaengseungMade Co.</div>
</div>
</div>
<div class="doc-date">문서 작성일: 2026. 04. 02.</div>
</div>
<div style="height: 24px;"></div>
<div class="doc-title">이베이 자동차 부품 AI 리스팅 자동화 — 사전 요구사항 질문지</div>
<div class="doc-subtitle">프로젝트 착수 전, 아래 질문에 답변해 주시면 최적의 솔루션을 설계할 수 있습니다.</div>
<!-- Client Info -->
<div class="client-info">
<div class="field">
<span class="field-label">고객명 <span style="color:#ef4444">*</span></span>
<input type="text" id="clientName" class="field-input" placeholder="홍길동" required>
</div>
<div class="field">
<span class="field-label">연락처</span>
<input type="tel" id="clientPhone" class="field-input" placeholder="010-0000-0000">
</div>
<div class="field">
<span class="field-label">이메일 <span style="color:#ef4444">*</span></span>
<input type="email" id="clientEmail" class="field-input" placeholder="example@email.com" required>
</div>
<div class="field">
<span class="field-label">작성일</span>
<span class="field-value" id="fillDate"></span>
</div>
</div>
<!-- Section 1: Required -->
<div class="section">
<div class="section-header">
<span class="section-badge badge-required">REQUIRED</span>
<span class="section-title">필수 항목 (착수 전 반드시 필요)</span>
</div>
<div class="section-desc">아래 9개 항목은 개발 범위 확정과 견적 산출에 필수적인 정보입니다. 빠짐없이 작성 부탁드립니다.</div>
<!-- Q1 -->
<div class="question">
<div class="q-top">
<span class="q-num">1</span>
<span class="q-text">주로 사용하시는 자동차 부품 사이트 URL을 알려주세요 (최소 3개)</span>
</div>
<div class="q-hint">예: RockAuto, AutoZone, PartsGeek, partsouq.com 등</div>
<div class="answer-area">
<textarea class="large" placeholder="사이트명과 URL을 함께 작성해주세요"></textarea>
</div>
</div>
<!-- Q2 -->
<div class="question">
<div class="q-top">
<span class="q-num">2</span>
<span class="q-text">주요 취급 부품 카테고리는 무엇인가요?</span>
</div>
<div class="q-hint">예: 브레이크, 엔진, 서스펜션, 전기장치, 외장 등</div>
<div class="answer-area">
<textarea placeholder="취급하시는 부품 카테고리를 나열해주세요"></textarea>
</div>
</div>
<!-- Q3 -->
<div class="question">
<div class="q-top">
<span class="q-num">3</span>
<span class="q-text">테스트용 샘플 품번(Part Number) 10~20개를 작성해주세요</span>
</div>
<div class="q-hint">예: 16610-0H040, 04465-33471 등 — 실제 리스팅에 사용하실 품번이면 더 좋습니다</div>
<div class="answer-area">
<textarea class="large" placeholder="품번을 줄바꿈 또는 쉼표로 구분하여 작성해주세요"></textarea>
</div>
</div>
<!-- Q4 -->
<div class="question">
<div class="q-top">
<span class="q-num">4</span>
<span class="q-text">현재 운영 중인 eBay 리스팅 URL을 3~5개 공유해주세요</span>
</div>
<div class="q-hint">현재 리스팅 스타일과 구조를 파악하여 최적화 방향을 설정합니다</div>
<div class="answer-area">
<textarea class="large" placeholder="eBay 리스팅 URL을 줄바꿈으로 구분하여 작성해주세요"></textarea>
</div>
</div>
<!-- Q5 -->
<div class="question">
<div class="q-top">
<span class="q-num">5</span>
<span class="q-text">eBay 셀러 계정 등급은 무엇인가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q5"> Basic</label>
<label class="q-option"><input type="radio" name="q5"> Premium</label>
<label class="q-option"><input type="radio" name="q5"> Anchor</label>
<label class="q-option"><input type="radio" name="q5"> Enterprise</label>
<label class="q-option"><input type="radio" name="q5"> 잘 모르겠음</label>
</div>
</div>
<!-- Q6 -->
<div class="question">
<div class="q-top">
<span class="q-num">6</span>
<span class="q-text">주 판매 카테고리를 알려주세요</span>
</div>
<div class="q-hint">eBay Motors &gt; Parts &amp; Accessories 하위 카테고리 기준</div>
<div class="answer-area">
<textarea placeholder="예: Car & Truck Parts > Brakes & Brake Parts"></textarea>
</div>
</div>
<!-- Q7 -->
<div class="question">
<div class="q-top">
<span class="q-num">7</span>
<span class="q-text">예상 월간 리스팅 건수는 몇 건인가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q7"> 100건 미만</label>
<label class="q-option"><input type="radio" name="q7"> 100~500건</label>
<label class="q-option"><input type="radio" name="q7"> 500~1,000건</label>
<label class="q-option"><input type="radio" name="q7"> 1,000~5,000건</label>
<label class="q-option"><input type="radio" name="q7"> 5,000건 이상</label>
</div>
</div>
<!-- Q8 -->
<div class="question">
<div class="q-top">
<span class="q-num">8</span>
<span class="q-text">Fitment(호환 차종) 데이터의 정확도 기대치는?</span>
</div>
<div class="q-hint">정확도 요구 수준에 따라 데이터 소스와 검증 로직의 설계가 달라집니다</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q8"> (A) 참고용이면 충분</label>
<label class="q-option"><input type="radio" name="q8"> (B) 정확해야 함 — 틀리면 eBay 계정에 영향</label>
</div>
<div class="answer-area">
<textarea placeholder="추가 의견이 있으시면 작성해주세요 (선택)" style="min-height: 48px;"></textarea>
</div>
</div>
<!-- Q9 -->
<div class="question">
<div class="q-top">
<span class="q-num">9</span>
<span class="q-text">타겟 마켓은 어디인가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="checkbox"> US (미국)</label>
<label class="q-option"><input type="checkbox"> UK (영국)</label>
<label class="q-option"><input type="checkbox"> DE (독일)</label>
<label class="q-option"><input type="checkbox"> AU (호주)</label>
<label class="q-option"><input type="checkbox"> 기타</label>
</div>
<div class="answer-area">
<textarea placeholder="기타를 선택하신 경우 국가명을 작성해주세요" style="min-height: 48px;"></textarea>
</div>
</div>
</div>
<!-- Section 2: Optional -->
<div class="section optional">
<div class="section-header">
<span class="section-badge badge-optional">OPTIONAL</span>
<span class="section-title">권장 항목 (있으면 도움)</span>
</div>
<div class="section-desc">아래 항목은 필수는 아니지만, 답변해 주시면 더 정밀한 솔루션 설계에 도움이 됩니다.</div>
<!-- Q10 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">10</span>
<span class="q-text">현재 리스팅 1건 작성에 소요되는 시간은?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q10"> 5분 미만</label>
<label class="q-option"><input type="radio" name="q10"> 5~15분</label>
<label class="q-option"><input type="radio" name="q10"> 15~30분</label>
<label class="q-option"><input type="radio" name="q10"> 30분 이상</label>
</div>
</div>
<!-- Q11 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">11</span>
<span class="q-text">기존 리스팅 관리 방식은?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q11"> 수동 (eBay 웹에서 직접)</label>
<label class="q-option"><input type="radio" name="q11"> CSV 파일 업로드</label>
<label class="q-option"><input type="radio" name="q11"> eBay Seller Hub</label>
<label class="q-option"><input type="radio" name="q11"> 서드파티 툴</label>
</div>
<div class="answer-area">
<textarea placeholder="서드파티 툴을 사용 중이시면 이름을 알려주세요" style="min-height: 48px;"></textarea>
</div>
</div>
<!-- Q12 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">12</span>
<span class="q-text">관세/통관 계산 시 참고하는 사이트나 방식은?</span>
</div>
<div class="answer-area">
<textarea placeholder="사용 중인 관세 계산 방식이나 참고 사이트가 있으면 작성해주세요"></textarea>
</div>
</div>
<!-- Q13 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">13</span>
<span class="q-text">eBay Developer Program API 키를 보유하고 계신가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q13"> 예, 보유하고 있음</label>
<label class="q-option"><input type="radio" name="q13"> 아니오, 없음</label>
<label class="q-option"><input type="radio" name="q13"> 잘 모르겠음</label>
</div>
</div>
<!-- Q14 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">14</span>
<span class="q-text">선호하는 AI 모델이 있나요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q14"> OpenAI (GPT)</label>
<label class="q-option"><input type="radio" name="q14"> Anthropic (Claude)</label>
<label class="q-option"><input type="radio" name="q14"> 상관없음</label>
</div>
</div>
<!-- Q15 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">15</span>
<span class="q-text">현재 사용 중인 자동화 도구가 있나요?</span>
</div>
<div class="answer-area">
<textarea placeholder="사용 중인 도구명과 용도를 작성해주세요 (없으면 '없음')"></textarea>
</div>
</div>
<!-- Q16 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">16</span>
<span class="q-text">OpenAI 또는 Anthropic API 키를 보유하고 계신가요?</span>
</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q16"> 예, OpenAI</label>
<label class="q-option"><input type="radio" name="q16"> 예, Anthropic</label>
<label class="q-option"><input type="radio" name="q16"> 둘 다 보유</label>
<label class="q-option"><input type="radio" name="q16"> 없음</label>
</div>
</div>
<!-- Q17 -->
<div class="question">
<div class="q-top">
<span class="q-num" style="background: #64748b;">17</span>
<span class="q-text">완성된 프로젝트를 쟁승메이드 포트폴리오(사례)로 활용해도 괜찮으신가요?</span>
</div>
<div class="q-hint">고객사명은 비공개 처리 가능합니다</div>
<div class="q-options">
<label class="q-option"><input type="radio" name="q17"> 괜찮습니다</label>
<label class="q-option"><input type="radio" name="q17"> 고객사명 비공개 시 가능</label>
<label class="q-option"><input type="radio" name="q17"> 불가합니다</label>
</div>
</div>
</div>
<!-- Additional Notes -->
<div class="section">
<div class="section-header">
<span class="section-title">추가 요청사항</span>
</div>
<div class="question" style="border-color: #cbd5e1;">
<div class="answer-area" style="padding-left: 0;">
<textarea class="large" placeholder="위 항목 외에 추가로 전달하고 싶은 내용이 있으시면 자유롭게 작성해주세요" style="min-height: 120px;"></textarea>
</div>
</div>
</div>
<!-- Submit Section -->
<div class="submit-section">
<button type="button" class="save-draft-btn" onclick="saveDraft()">
임시 저장
</button>
<button type="button" class="submit-btn" id="submitBtn" onclick="submitQuestionnaire()">
질문지 제출하기
</button>
<div id="submitMsg" class="submit-msg"></div>
</div>
<!-- Footer Notice -->
<div class="footer-notice">
<strong>안내사항</strong><br>
본 질문지는 프로젝트 범위 확정 및 정확한 견적 산출을 위한 자료입니다.<br>
작성하신 정보는 프로젝트 진행 목적 외에 사용되지 않으며, 프로젝트 종료 후 안전하게 폐기됩니다.<br>
작성 후 이메일(<strong>bgg8988@gmail.com</strong>)로 회신하시거나, 인쇄 후 스캔본을 보내주셔도 됩니다.
</div>
<!-- Footer -->
<div class="footer">
<div class="footer-left">
<strong>쟁승메이드 (JaengseungMade Co.)</strong><br>
담당: 박재오<br>
이메일: bgg8988@gmail.com<br>
연락처: 010-3907-1392
</div>
<div class="footer-right">
본 문서는 고객 맞춤형 프로젝트<br>
사전 조사를 위해 작성되었습니다.<br>
&copy; 2026 JaengseungMade
</div>
</div>
</div>
<script>
// 작성일 자동 채우기
document.getElementById('fillDate').textContent = new Date().toLocaleDateString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit'
});
// 모든 응답 수집
function collectResponses() {
const responses = {};
// 텍스트 질문 (textarea)
document.querySelectorAll('.question').forEach((q, idx) => {
const num = idx + 1;
const textarea = q.querySelector('textarea');
const radios = q.querySelectorAll('input[type="radio"]');
const checkboxes = q.querySelectorAll('input[type="checkbox"]');
if (radios.length > 0) {
const checked = q.querySelector('input[type="radio"]:checked');
if (checked) {
responses['q' + num] = checked.closest('.q-option').textContent.trim();
}
}
if (checkboxes.length > 0) {
const selected = [];
checkboxes.forEach(cb => {
if (cb.checked) selected.push(cb.closest('.q-option').textContent.trim());
});
if (selected.length > 0) {
responses['q' + num + '_selected'] = selected;
}
}
if (textarea) {
const val = textarea.value.trim();
if (val) {
responses['q' + num + (radios.length > 0 || checkboxes.length > 0 ? '_detail' : '')] = val;
}
}
});
// 추가 요청사항 (마지막 textarea)
const lastSection = document.querySelectorAll('.section');
const additionalTextarea = lastSection[lastSection.length - 1]?.querySelector('textarea');
if (additionalTextarea && additionalTextarea.value.trim()) {
responses['additional'] = additionalTextarea.value.trim();
}
return responses;
}
// 유효성 검사
function validate() {
const name = document.getElementById('clientName').value.trim();
const email = document.getElementById('clientEmail').value.trim();
if (!name) {
alert('고객명을 입력해주세요.');
document.getElementById('clientName').focus();
return false;
}
if (!email) {
alert('이메일을 입력해주세요.');
document.getElementById('clientEmail').focus();
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
alert('올바른 이메일 형식을 입력해주세요.');
document.getElementById('clientEmail').focus();
return false;
}
return true;
}
// 임시 저장 (로컬)
function saveDraft() {
const data = {
clientName: document.getElementById('clientName').value,
clientEmail: document.getElementById('clientEmail').value,
clientPhone: document.getElementById('clientPhone').value,
responses: collectResponses(),
savedAt: new Date().toISOString()
};
localStorage.setItem('questionnaire_draft_ebay', JSON.stringify(data));
const msg = document.getElementById('submitMsg');
msg.className = 'submit-msg success';
msg.textContent = '임시 저장 완료! (브라우저에 저장됨)';
setTimeout(() => { msg.textContent = ''; }, 3000);
}
// 임시 저장 복원
function loadDraft() {
const saved = localStorage.getItem('questionnaire_draft_ebay');
if (!saved) return;
try {
const data = JSON.parse(saved);
if (data.clientName) document.getElementById('clientName').value = data.clientName;
if (data.clientEmail) document.getElementById('clientEmail').value = data.clientEmail;
if (data.clientPhone) document.getElementById('clientPhone').value = data.clientPhone;
} catch (e) {
// 복원 실패 시 무시
}
}
// 제출
async function submitQuestionnaire() {
if (!validate()) return;
const btn = document.getElementById('submitBtn');
const msg = document.getElementById('submitMsg');
btn.disabled = true;
btn.textContent = '제출 중...';
msg.textContent = '';
const payload = {
clientName: document.getElementById('clientName').value.trim(),
clientEmail: document.getElementById('clientEmail').value.trim(),
clientPhone: document.getElementById('clientPhone').value.trim() || null,
responses: collectResponses(),
type: 'ebay-tool'
};
try {
const res = await fetch('/api/questionnaire/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
if (res.ok && result.success) {
msg.className = 'submit-msg success';
msg.innerHTML = '질문지가 성공적으로 제출되었습니다!<br>담당자가 확인 후 연락드리겠습니다. 감사합니다.';
btn.textContent = '제출 완료';
localStorage.removeItem('questionnaire_draft_ebay');
} else {
throw new Error(result.error || '제출에 실패했습니다.');
}
} catch (err) {
msg.className = 'submit-msg error';
msg.textContent = err.message || '서버 오류가 발생했습니다. 이메일(bgg8988@gmail.com)로 직접 보내주세요.';
btn.disabled = false;
btn.textContent = '질문지 제출하기';
}
}
// 페이지 로드 시 임시 저장 복원
loadDraft();
</script>
</body>
</html>

View File

@@ -14,6 +14,9 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
ENV NEXT_TELEMETRY_DISABLED=1
# 빌드타임에만 필요한 더미(일부 route가 모듈 로드 시 env로 SDK 초기화 — 예: new Resend()).
# 런타임에는 env_file의 실제값이 사용되므로 무해.
ENV RESEND_API_KEY=re_build_dummy
RUN npm run build
FROM node:20-alpine AS runner

View File

@@ -36,6 +36,26 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: '/admin/orders',
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="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
),
},
{
href: '/admin/products',
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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
href: '/admin/contacts',
label: '문의 내역',
@@ -56,41 +76,9 @@ const NAV_ITEMS = [
</svg>
),
},
{
href: '/admin/documents',
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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13 3v5a2 2 0 002 2h4M9 13h6M9 17h4" />
</svg>
),
},
{
href: '/admin/packs',
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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
),
},
{
href: '/admin/questionnaire',
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-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
},
{
href: '/admin/marketing',
label: '마케팅 에셋',
label: '광고 관리',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}

View File

@@ -1,6 +1,14 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { REQUEST_STATUS, RequestStatus } from '@/lib/request-status';
interface QuoteSummary {
id: string;
title: string;
status: string;
}
interface Contact {
id: string;
@@ -8,16 +16,35 @@ interface Contact {
name: string | null;
service: string;
message: string;
status: 'pending' | 'in_progress' | 'completed';
status: string;
created_at: string;
public_token?: string;
project_type?: string;
budget?: string;
timeline?: string;
quotes?: QuoteSummary[];
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '미처리', color: 'bg-yellow-900/40 text-yellow-400' },
in_progress: { label: '처리중', color: 'bg-blue-900/40 text-blue-400' },
completed: { label: '완료', color: 'bg-green-900/40 text-green-400' },
/** 상태별 색상 매핑 — admin 다크 톤 bg-*-900/40 text-*-400 */
const STATUS_COLORS: Record<string, string> = {
pending: 'bg-yellow-900/40 text-yellow-400',
reviewing: 'bg-sky-900/40 text-sky-400',
quoted: 'bg-blue-900/40 text-blue-400',
accepted: 'bg-green-900/40 text-green-400',
in_progress: 'bg-blue-900/40 text-blue-400',
completed: 'bg-green-900/40 text-green-400',
on_hold: 'bg-slate-700/60 text-slate-400',
cancelled: 'bg-red-900/40 text-red-400',
};
function getStatusColor(status: string): string {
return STATUS_COLORS[status] ?? 'bg-slate-700/60 text-slate-400';
}
function getStatusLabel(status: string): string {
return (REQUEST_STATUS as Record<string, { label: string }>)[status]?.label ?? status;
}
const SERVICE_LABELS: Record<string, string> = {
lotto: '로또 추천',
stock: '주식 자동매매',
@@ -28,12 +55,68 @@ const SERVICE_LABELS: Record<string, string> = {
general: '일반 문의',
};
/** 필터 탭 정의 */
const FILTER_TABS: { val: string; label: string }[] = [
{ val: 'all', label: '전체' },
{ val: 'pending', label: '접수' },
{ val: 'reviewing', label: '검토중' },
{ val: 'quoted', label: '견적 발송' },
{ val: 'accepted', label: '수주 확정' },
{ val: 'in_progress', label: '진행중' },
{ val: 'completed', label: '완료' },
{ val: '__other', label: '기타' },
];
const OTHER_STATUSES = new Set(['on_hold', 'cancelled']);
function matchFilter(status: string, filterVal: string): boolean {
if (filterVal === 'all') return true;
if (filterVal === '__other') return OTHER_STATUSES.has(status);
return status === filterVal;
}
function filterCount(contacts: Contact[], filterVal: string): number {
if (filterVal === 'all') return contacts.length;
return contacts.filter((c) => matchFilter(c.status, filterVal)).length;
}
export default function AdminContactsPage() {
const router = useRouter();
const [contacts, setContacts] = useState<Contact[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<Contact | null>(null);
const [updating, setUpdating] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all');
const [creatingQuote, setCreatingQuote] = useState(false);
const [copied, setCopied] = useState(false);
async function createQuote(contact: Contact) {
setCreatingQuote(true);
try {
const title = `${SERVICE_LABELS[contact.service] ?? contact.service ?? '외주 문의'}${contact.name ?? ''}`.trim();
const res = await fetch('/api/admin/quotes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title,
contact_request_id: contact.id,
client_name: contact.name ?? '',
client_email: contact.email,
}),
});
const d = await res.json();
if (res.ok && d.quote?.id) {
router.push('/admin/quotes/' + d.quote.id);
} else {
alert(d.error || '견적서 생성에 실패했습니다');
}
} catch (e) {
console.error(e);
alert('견적서 생성 중 오류가 발생했습니다');
} finally {
setCreatingQuote(false);
}
}
useEffect(() => {
fetch('/api/admin/contacts')
@@ -53,10 +136,10 @@ export default function AdminContactsPage() {
});
if (res.ok) {
setContacts((prev) =>
prev.map((c) => (c.id === id ? { ...c, status: status as Contact['status'] } : c))
prev.map((c) => (c.id === id ? { ...c, status } : c))
);
if (selected?.id === id) {
setSelected((prev) => prev ? { ...prev, status: status as Contact['status'] } : null);
setSelected((prev) => prev ? { ...prev, status } : null);
}
}
} catch (e) {
@@ -66,7 +149,14 @@ export default function AdminContactsPage() {
}
}
const filtered = contacts.filter((c) => filterStatus === 'all' || c.status === filterStatus);
function copyTrackingLink(token: string) {
navigator.clipboard.writeText(location.origin + '/track/' + token).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}
const filtered = contacts.filter((c) => matchFilter(c.status, filterStatus));
const pendingCount = contacts.filter((c) => c.status === 'pending').length;
return (
@@ -84,8 +174,8 @@ export default function AdminContactsPage() {
</div>
{/* 필터 탭 */}
<div className="flex gap-2 mb-4">
{[['all', '전체'], ['pending', '미처리'], ['in_progress', '처리중'], ['completed', '완료']].map(([val, label]) => (
<div className="flex gap-2 mb-4 flex-wrap">
{FILTER_TABS.map(({ val, label }) => (
<button
key={val}
onClick={() => setFilterStatus(val)}
@@ -98,7 +188,7 @@ export default function AdminContactsPage() {
{label}
{val !== 'all' && (
<span className="ml-1.5 text-xs opacity-70">
{contacts.filter((c) => c.status === val).length}
{filterCount(contacts, val)}
</span>
)}
</button>
@@ -121,7 +211,10 @@ export default function AdminContactsPage() {
filtered.map((contact) => (
<button
key={contact.id}
onClick={() => setSelected(contact)}
onClick={() => {
setSelected(contact);
setCopied(false);
}}
className={`w-full text-left bg-slate-900 rounded-xl p-4 border transition-all hover:border-slate-600 ${
selected?.id === contact.id ? 'border-red-500/50' : 'border-slate-700/50'
}`}
@@ -139,8 +232,8 @@ export default function AdminContactsPage() {
<p className="text-slate-400 text-xs truncate">{contact.message}</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[contact.status]?.color}`}>
{STATUS_LABELS[contact.status]?.label}
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(contact.status)}`}>
{getStatusLabel(contact.status)}
</span>
<span className="text-slate-600 text-xs">
{new Date(contact.created_at).toLocaleDateString('ko-KR')}
@@ -189,27 +282,85 @@ export default function AdminContactsPage() {
</div>
</dl>
{/* 상태 변경 */}
<div>
<p className="text-slate-500 text-xs mb-2"> </p>
<div className="flex gap-2">
{(['pending', 'in_progress', 'completed'] as const).map((s) => (
<button
key={s}
onClick={() => updateStatus(selected.id, s)}
disabled={selected.status === s || updating === selected.id}
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
selected.status === s
? STATUS_LABELS[s].color + ' opacity-100'
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
} disabled:opacity-50`}
>
{STATUS_LABELS[s].label}
</button>
))}
{/* 프로젝트 정보 */}
{(selected.project_type || selected.budget || selected.timeline) && (
<div className="mb-4 p-3 bg-slate-800 rounded-lg text-sm space-y-1.5">
<p className="text-slate-400 font-medium mb-2"> </p>
{selected.project_type && (
<div className="flex gap-2">
<span className="text-slate-500 w-16 flex-shrink-0"></span>
<span className="text-slate-200">{selected.project_type}</span>
</div>
)}
{selected.budget && (
<div className="flex gap-2">
<span className="text-slate-500 w-16 flex-shrink-0"></span>
<span className="text-slate-200">{selected.budget}</span>
</div>
)}
{selected.timeline && (
<div className="flex gap-2">
<span className="text-slate-500 w-16 flex-shrink-0"></span>
<span className="text-slate-200">{selected.timeline}</span>
</div>
)}
</div>
)}
{/* 상태 변경 — 8종 select */}
<div className="mb-3">
<p className="text-slate-500 text-xs mb-2"> </p>
<select
value={selected.status}
onChange={(e) => updateStatus(selected.id, e.target.value)}
disabled={updating === selected.id}
className="w-full bg-slate-800 text-white text-sm rounded-lg px-3 py-2 border border-slate-700 focus:outline-none focus:border-slate-500 disabled:opacity-50"
>
{(Object.entries(REQUEST_STATUS) as [RequestStatus, { label: string }][]).map(([key, { label }]) => (
<option key={key} value={key}>{label}</option>
))}
{/* 레거시 값 폴백 — REQUEST_STATUS에 없는 경우 표시 */}
{!(selected.status in REQUEST_STATUS) && (
<option value={selected.status}>{selected.status}</option>
)}
</select>
</div>
{/* 추적 링크 복사 */}
{selected.public_token && (
<button
onClick={() => copyTrackingLink(selected.public_token!)}
className="mb-2 w-full flex items-center justify-center gap-2 py-2 bg-slate-700/60 text-slate-300 rounded-lg text-xs hover:bg-slate-700 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{copied ? '복사됨!' : '추적 링크 복사'}
</button>
)}
{/* 연결된 견적 */}
{selected.quotes && selected.quotes.length > 0 && (
<div className="mb-2">
<p className="text-slate-500 text-xs mb-2"> </p>
<div className="space-y-1">
{selected.quotes.map((q) => (
<a
key={q.id}
href={`/admin/quotes/${q.id}`}
className="flex items-center justify-between bg-slate-800 rounded-lg px-3 py-2 text-xs hover:bg-slate-700 transition"
>
<span className="text-slate-200 truncate flex-1 mr-2">{q.title}</span>
<span className="flex-shrink-0 px-2 py-0.5 rounded-full bg-blue-900/40 text-blue-400">
{q.status}
</span>
</a>
))}
</div>
</div>
)}
{/* 이메일 바로 보내기 링크 */}
<a
href={`mailto:${selected.email}?subject=[쟁승메이드] 문의 답변&body=안녕하세요, 쟁승메이드입니다.%0A%0A`}
@@ -221,6 +372,23 @@ export default function AdminContactsPage() {
</svg>
</a>
{/* 견적서 작성 (연결 견적이 있으면 라벨 변경) */}
<button
onClick={() => createQuote(selected)}
disabled={creatingQuote}
className="mt-2 w-full flex items-center justify-center gap-2 py-2 bg-violet-600/20 text-violet-300 rounded-lg text-xs hover:bg-violet-600/30 transition disabled:opacity-50"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{creatingQuote
? '생성 중...'
: selected.quotes && selected.quotes.length > 0
? '추가 견적서 작성'
: '견적서 작성'}
</button>
</div>
)}
</div>

View File

@@ -7,7 +7,6 @@ interface Stats {
totalOrders: number;
totalRevenue: number;
pendingContacts: number;
activeSubscribers: number;
monthlyChart: Array<{ month: string; revenue: number }>;
}
@@ -157,17 +156,6 @@ export default function AdminDashboard() {
</svg>
}
/>
<StatCard
label="활성 구독자"
value={`${stats?.activeSubscribers ?? 0}`}
color="bg-amber-500/20 text-amber-400"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />
</svg>
}
/>
<StatCard
label="미처리 문의"
value={`${stats?.pendingContacts ?? 0}`}

View File

@@ -1,160 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface Document {
id: string;
title: string;
description: string;
category: '제안서' | '질문지' | '계약서';
fileName: string;
updatedAt: string;
status: 'draft' | 'sent' | 'accepted';
}
const documents: Document[] = [
{
id: 'ebay-proposal',
title: '이베이 부품 AI 자동화 — 제안서',
description: '프로젝트 개요, 3단 패키지 견적(120/198/330만원), 기술 스택, 진행 절차',
category: '제안서',
fileName: 'ebay-tool-proposal.html',
updatedAt: '2026-04-02',
status: 'draft',
},
{
id: 'ebay-questionnaire',
title: '이베이 부품 AI 자동화 — 요구사항 질문지',
description: '고객 사전 확인 17항목 (타겟 사이트, 샘플 품번, eBay 셀러 티어 등)',
category: '질문지',
fileName: 'ebay-tool-questionnaire.html',
updatedAt: '2026-04-02',
status: 'draft',
},
];
const CATEGORY_COLORS: Record<string, string> = {
'제안서': 'bg-blue-900/40 text-blue-400 border-blue-500/30',
'질문지': 'bg-amber-900/40 text-amber-400 border-amber-500/30',
'계약서': 'bg-green-900/40 text-green-400 border-green-500/30',
};
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
draft: { label: '초안', color: 'bg-slate-700/60 text-slate-300' },
sent: { label: '발송', color: 'bg-blue-900/40 text-blue-400' },
accepted: { label: '수락', color: 'bg-green-900/40 text-green-400' },
};
export default function AdminDocumentsPage() {
const [previewDoc, setPreviewDoc] = useState<Document | null>(null);
const [previewHtml, setPreviewHtml] = useState<string>('');
const [previewLoading, setPreviewLoading] = useState(false);
// iframe src 대신 fetch + srcdoc 방식으로 X-Frame-Options 우회
useEffect(() => {
if (!previewDoc) { setPreviewHtml(''); return; }
setPreviewLoading(true);
fetch(`/api/admin/documents/${previewDoc.fileName}`)
.then(res => res.ok ? res.text() : Promise.reject('문서를 불러올 수 없습니다'))
.then(html => setPreviewHtml(html))
.catch(() => setPreviewHtml('<p style="padding:2rem;color:red;">문서를 불러올 수 없습니다.</p>'))
.finally(() => setPreviewLoading(false));
}, [previewDoc]);
return (
<div className="p-6 max-w-6xl mx-auto">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
, ,
</p>
</div>
{/* 문서 카드 그리드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
{documents.map((doc) => (
<div
key={doc.id}
className="bg-slate-900 rounded-2xl border border-slate-700/50 p-5 flex flex-col"
>
{/* 카테고리 + 상태 뱃지 */}
<div className="flex items-center gap-2 mb-3">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${CATEGORY_COLORS[doc.category]}`}>
{doc.category}
</span>
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium ${STATUS_CONFIG[doc.status].color}`}>
{STATUS_CONFIG[doc.status].label}
</span>
</div>
{/* 제목 + 설명 */}
<h3 className="text-white font-semibold text-sm mb-1.5">{doc.title}</h3>
<p className="text-slate-400 text-xs leading-relaxed mb-4 flex-1">{doc.description}</p>
{/* 수정일 + 버튼 */}
<div className="flex items-center justify-between">
<span className="text-slate-600 text-xs">: {doc.updatedAt}</span>
<div className="flex gap-2">
<button
onClick={() => setPreviewDoc(doc)}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-600/20 text-red-400 hover:bg-red-600/30 transition border border-red-500/20"
>
</button>
<button
onClick={() => window.open(`/api/admin/documents/${doc.fileName}`, '_blank')}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-300 hover:bg-slate-600 hover:text-white transition"
>
</button>
</div>
</div>
</div>
))}
</div>
{/* 미리보기 섹션 */}
{previewDoc && (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
{/* 미리보기 헤더 */}
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700/50">
<div className="flex items-center gap-3">
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<span className="text-white text-sm font-medium">{previewDoc.title}</span>
</div>
<button
onClick={() => setPreviewDoc(null)}
className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800 transition"
aria-label="미리보기 닫기"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 문서 미리보기 (fetch + srcdoc 방식) */}
{previewLoading ? (
<div className="flex items-center justify-center bg-white" style={{ height: '80vh' }}>
<div className="text-slate-400 text-sm"> ...</div>
</div>
) : (
<iframe
srcDoc={previewHtml}
className="w-full bg-white"
style={{ height: '80vh' }}
title={previewDoc.title}
sandbox="allow-same-origin"
/>
)}
</div>
)}
</div>
);
}

View File

@@ -2,6 +2,18 @@
import { useState, useEffect, useCallback } from 'react';
type AdminTab = 'channels' | 'assets';
interface AdChannel {
id: string;
name: string;
url: string | null;
status: 'active' | 'paused';
memo: string | null;
created_at: string;
updated_at: string;
}
const ASSETS = [
{
file: '/marketing/thumb-homepage-A.svg',
@@ -133,6 +145,18 @@ const CHECKLIST_ITEMS = {
type CheckKey = string;
export default function MarketingPage() {
const [section, setSection] = useState<AdminTab>('channels');
// 광고 채널 상태
const [channels, setChannels] = useState<AdChannel[]>([]);
const [channelsLoading, setChannelsLoading] = useState(true);
const [channelsError, setChannelsError] = useState<string | null>(null);
const [newChannel, setNewChannel] = useState({ name: '', url: '', memo: '' });
const [creatingChannel, setCreatingChannel] = useState(false);
const [channelMutating, setChannelMutating] = useState<string | null>(null);
const [editingMemoId, setEditingMemoId] = useState<string | null>(null);
const [memoDraft, setMemoDraft] = useState('');
const [preview, setPreview] = useState<typeof ASSETS[0] | null>(null);
const [copied, setCopied] = useState<string | null>(null);
const [checks, setChecks] = useState<Record<CheckKey, boolean>>({});
@@ -140,6 +164,105 @@ export default function MarketingPage() {
const [activeTab, setActiveTab] = useState<'design' | 'pm' | 'quality' | 'marketing'>('design');
const [convertingPng, setConvertingPng] = useState<string | null>(null);
async function loadChannels() {
setChannelsLoading(true);
setChannelsError(null);
try {
const res = await fetch('/api/admin/ad-channels');
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 로드 실패');
setChannels(data.channels ?? []);
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 로드 실패');
} finally {
setChannelsLoading(false);
}
}
useEffect(() => {
if (section === 'channels') loadChannels();
}, [section]);
async function createChannel(e: React.FormEvent) {
e.preventDefault();
if (!newChannel.name.trim()) {
setChannelsError('채널명을 입력해주세요.');
return;
}
setCreatingChannel(true);
setChannelsError(null);
try {
const res = await fetch('/api/admin/ad-channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: newChannel.name.trim(),
url: newChannel.url.trim() || undefined,
memo: newChannel.memo.trim() || undefined,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 등록 실패');
setNewChannel({ name: '', url: '', memo: '' });
await loadChannels();
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 등록 실패');
} finally {
setCreatingChannel(false);
}
}
async function patchChannel(id: string, patch: Partial<Pick<AdChannel, 'name' | 'url' | 'status' | 'memo'>>) {
setChannelMutating(id);
setChannelsError(null);
try {
const res = await fetch(`/api/admin/ad-channels/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 수정 실패');
await loadChannels();
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 수정 실패');
} finally {
setChannelMutating(null);
}
}
async function toggleChannelStatus(channel: AdChannel) {
await patchChannel(channel.id, { status: channel.status === 'active' ? 'paused' : 'active' });
}
function startEditMemo(channel: AdChannel) {
setEditingMemoId(channel.id);
setMemoDraft(channel.memo ?? '');
}
async function saveMemo(id: string) {
await patchChannel(id, { memo: memoDraft.trim() || null });
setEditingMemoId(null);
setMemoDraft('');
}
async function deleteChannel(id: string, name: string) {
const ok = confirm(`"${name}" 채널을 삭제하시겠습니까?`);
if (!ok) return;
setChannelMutating(id);
setChannelsError(null);
try {
const res = await fetch(`/api/admin/ad-channels/${id}`, { method: 'DELETE' });
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '채널 삭제 실패');
await loadChannels();
} catch (e) {
setChannelsError(e instanceof Error ? e.message : '채널 삭제 실패');
} finally {
setChannelMutating(null);
}
}
useEffect(() => {
const saved = localStorage.getItem('marketing_checks');
if (saved) setChecks(JSON.parse(saved));
@@ -235,11 +358,202 @@ export default function MarketingPage() {
return (
<div className="p-8 max-w-[1400px]">
{/* 헤더 */}
<div className="mb-6">
<h1 className="text-2xl font-bold text-white mb-1"> </h1>
<p className="text-slate-400 text-sm"> · .</p>
</div>
{/* 탭 스위처 */}
<div className="flex gap-2 mb-8 border-b border-slate-800">
{([
{ key: 'channels', label: '광고 채널' },
{ key: 'assets', label: '마케팅 에셋' },
] as const).map(({ key, label }) => (
<button
key={key}
onClick={() => setSection(key)}
className={`px-4 py-2.5 text-sm font-semibold border-b-2 transition-all ${
section === key
? 'text-white border-red-500'
: 'text-slate-500 border-transparent hover:text-slate-300'
}`}
>
{label}
</button>
))}
</div>
{section === 'channels' && (
<div>
{channelsError && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-900/20 border border-red-500/30 text-red-400 text-sm">
{channelsError}
</div>
)}
{/* 신규 채널 추가 폼 */}
<form
onSubmit={createChannel}
className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-6 grid grid-cols-1 md:grid-cols-[1.2fr_1.5fr_2fr_auto] gap-3 items-end"
>
<div>
<label className="text-slate-400 text-xs block mb-1"> *</label>
<input
type="text"
value={newChannel.name}
onChange={(e) => setNewChannel({ ...newChannel, name: e.target.value })}
disabled={creatingChannel}
placeholder="예: 크몽 홈페이지 제작"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1">URL</label>
<input
type="text"
value={newChannel.url}
onChange={(e) => setNewChannel({ ...newChannel, url: e.target.value })}
disabled={creatingChannel}
placeholder="https://kmong.com/gig/..."
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"></label>
<input
type="text"
value={newChannel.memo}
onChange={(e) => setNewChannel({ ...newChannel, memo: e.target.value })}
disabled={creatingChannel}
placeholder="비고"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
disabled={creatingChannel}
className="bg-red-600 hover:bg-red-500 disabled:opacity-60 text-white font-bold px-4 py-2 rounded text-sm whitespace-nowrap"
>
{creatingChannel ? '추가 중...' : '+ 채널 추가'}
</button>
</form>
{/* 채널 테이블 */}
{channelsLoading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : channels.length === 0 ? (
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
</div>
) : (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3">URL</th>
<th className="text-center px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
{channels.map((channel) => (
<tr key={channel.id} className="border-t border-slate-800">
<td className="px-4 py-3 text-white font-medium">{channel.name}</td>
<td className="px-4 py-3">
{channel.url ? (
<a
href={channel.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 underline truncate max-w-[220px] inline-block align-bottom"
>
{channel.url}
</a>
) : (
<span className="text-slate-600">-</span>
)}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleChannelStatus(channel)}
disabled={channelMutating === channel.id}
className={`px-2 py-1 rounded text-xs font-medium disabled:opacity-50 ${
channel.status === 'active'
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
: 'bg-slate-700 text-slate-400'
}`}
>
{channel.status === 'active' ? '운영중' : '중지'}
</button>
</td>
<td className="px-4 py-3 text-slate-300 max-w-[240px]">
{editingMemoId === channel.id ? (
<div className="flex items-center gap-1.5">
<input
type="text"
value={memoDraft}
onChange={(e) => setMemoDraft(e.target.value)}
autoFocus
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-2 py-1 text-xs"
/>
<button
onClick={() => saveMemo(channel.id)}
disabled={channelMutating === channel.id}
className="text-emerald-400 hover:text-emerald-300 text-xs px-1.5 disabled:opacity-50"
>
</button>
<button
onClick={() => { setEditingMemoId(null); setMemoDraft(''); }}
className="text-slate-500 hover:text-slate-300 text-xs px-1.5"
>
</button>
</div>
) : (
<button
onClick={() => startEditMemo(channel)}
className="text-left w-full truncate hover:text-white transition-all"
title="클릭하여 편집"
>
{channel.memo || <span className="text-slate-600">- ( )</span>}
</button>
)}
</td>
<td className="px-4 py-3 text-slate-500 text-xs whitespace-nowrap">
{new Date(channel.created_at).toLocaleDateString('ko-KR')}
</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button
onClick={() => deleteChannel(channel.id, channel.name)}
disabled={channelMutating === channel.id}
className="text-red-400 hover:text-red-300 px-2 text-xs font-medium disabled:opacity-50"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{section === 'assets' && (
<>
{/* 헤더 */}
<div className="mb-8">
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-white mb-1"> </h1>
<h2 className="text-lg font-bold text-white mb-1"> </h2>
<p className="text-slate-400 text-sm">· 4 </p>
</div>
<button
@@ -594,6 +908,8 @@ export default function MarketingPage() {
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -9,15 +9,8 @@ interface Member {
created_at: string;
orderCount: number;
totalPaid: number;
activeSub: { product_id: string; status: string; expires_at: string } | null;
}
const PLAN_LABELS: Record<string, string> = {
lotto_gold: '🥇 골드',
lotto_platinum: '💎 플래티넘',
lotto_diamond: '👑 다이아',
};
export default function AdminMembersPage() {
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
@@ -77,7 +70,6 @@ export default function AdminMembersPage() {
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-left px-5 py-3 text-slate-400 font-medium"></th>
<th className="text-right px-5 py-3 text-slate-400 font-medium"> </th>
<th className="text-right px-5 py-3 text-slate-400 font-medium"> </th>
</tr>
@@ -90,16 +82,6 @@ export default function AdminMembersPage() {
<td className="px-5 py-3 text-slate-400">
{new Date(m.created_at).toLocaleDateString('ko-KR')}
</td>
<td className="px-5 py-3">
{m.activeSub ? (
<div>
<span className="text-xs font-semibold text-amber-400">{PLAN_LABELS[m.activeSub.product_id] ?? m.activeSub.product_id}</span>
<div className="text-xs text-slate-500">{new Date(m.activeSub.expires_at).toLocaleDateString('ko-KR')} </div>
</div>
) : (
<span className="text-xs text-slate-600">-</span>
)}
</td>
<td className="px-5 py-3 text-right">
<span className={`px-2 py-0.5 rounded-full text-xs ${m.orderCount > 0 ? 'bg-green-900/40 text-green-400' : 'bg-slate-700 text-slate-500'}`}>
{m.orderCount}
@@ -124,11 +106,6 @@ export default function AdminMembersPage() {
<p className="text-white text-sm font-semibold truncate">{m.email ?? '-'}</p>
<p className="text-slate-400 text-xs mt-0.5">{m.full_name ?? '이름 없음'}</p>
</div>
{m.activeSub && (
<span className="ml-2 flex-shrink-0 text-xs font-semibold text-amber-400 bg-amber-900/20 px-2 py-0.5 rounded-full">
{PLAN_LABELS[m.activeSub.product_id] ?? m.activeSub.product_id}
</span>
)}
</div>
{/* 상세 정보 그리드 */}
@@ -150,12 +127,6 @@ export default function AdminMembersPage() {
</p>
</div>
</div>
{m.activeSub && (
<p className="text-slate-600 text-xs mt-2">
: {new Date(m.activeSub.expires_at).toLocaleDateString('ko-KR')}
</p>
)}
</div>
))}
</div>

222
app/admin/orders/page.tsx Normal file
View File

@@ -0,0 +1,222 @@
'use client';
import { useEffect, useState } from 'react';
interface Order {
id: string;
user_id: string | null;
product_id: string | null;
amount: number;
status: 'pending' | 'paid' | 'cancelled';
metadata: Record<string, unknown> | null;
created_at: string;
product_name: string | null;
customer_email: string | null;
}
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' },
paid: { label: '완료', color: 'bg-green-900/40 text-green-400' },
cancelled: { label: '취소', color: 'bg-slate-700/60 text-slate-500' },
};
const FILTER_TABS = [
{ val: 'all', label: '전체' },
{ val: 'pending', label: '입금 대기' },
{ val: 'paid', label: '완료' },
{ val: 'cancelled', label: '취소' },
] as const;
export default function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filterStatus, setFilterStatus] = useState<string>('all');
const [updating, setUpdating] = useState<string | null>(null);
async function loadOrders() {
try {
const res = await fetch('/api/admin/orders');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json();
setOrders(d.orders ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : '불러오기 실패');
} finally {
setLoading(false);
}
}
useEffect(() => {
loadOrders();
}, []);
async function updateStatus(id: string, status: 'paid' | 'cancelled' | 'pending') {
if (status === 'paid') {
const ok = confirm('입금을 확인하셨습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.');
if (!ok) return;
}
if (status === 'cancelled') {
const ok = confirm('이 주문을 취소하시겠습니까?');
if (!ok) return;
}
setUpdating(id);
try {
const res = await fetch('/api/admin/orders', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, status }),
});
if (res.ok) {
setOrders((prev) =>
prev.map((o) => (o.id === id ? { ...o, status } : o))
);
} else {
alert('상태 변경에 실패했습니다.');
}
} catch (e) {
console.error(e);
alert('네트워크 오류가 발생했습니다.');
} finally {
setUpdating(null);
}
}
const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus);
const pendingCount = orders.filter((o) => o.status === 'pending').length;
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5"> </p>
</div>
{pendingCount > 0 && (
<span className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-3 py-1 rounded-full text-sm font-medium">
{pendingCount}
</span>
)}
</div>
{/* 필터 탭 */}
<div className="flex gap-2 mb-4">
{FILTER_TABS.map(({ val, label }) => (
<button
key={val}
onClick={() => setFilterStatus(val)}
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
filterStatus === val
? 'bg-red-600/30 text-red-300 border border-red-500/30'
: 'bg-slate-800 text-slate-400 hover:text-white'
}`}
>
{label}
{val !== 'all' && (
<span className="ml-1.5 text-xs opacity-70">
{orders.filter((o) => o.status === val).length}
</span>
)}
</button>
))}
</div>
{loading ? (
<div className="flex items-center justify-center h-48">
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
</div>
) : error ? (
<div className="bg-red-900/20 border border-red-500/30 rounded-2xl p-10 text-center">
<p className="text-red-400 font-medium">{error}</p>
<button
onClick={() => { setLoading(true); setError(null); loadOrders(); }}
className="mt-3 text-sm text-slate-400 hover:text-white transition"
>
</button>
</div>
) : filtered.length === 0 ? (
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
</div>
) : (
<div className="space-y-2">
{filtered.map((order) => {
const depositorName =
typeof order.metadata?.depositor_name === 'string'
? order.metadata.depositor_name
: null;
return (
<div
key={order.id}
className={`bg-slate-900 rounded-xl p-4 border transition-all ${
order.status === 'cancelled'
? 'border-slate-800/50 opacity-50'
: 'border-slate-700/50 hover:border-slate-600'
}`}
>
<div className="flex items-center gap-4">
{/* 상품명 + 이메일 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-white font-medium text-sm truncate">
{order.product_name ?? '(상품 없음)'}
</span>
<span className="text-blue-400 font-semibold text-sm flex-shrink-0">
{order.amount.toLocaleString()}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span className="truncate">
{order.customer_email ?? order.user_id ?? '이메일 없음'}
</span>
{depositorName && (
<span className="flex-shrink-0 bg-slate-700 text-slate-300 px-2 py-0.5 rounded-full">
: {depositorName}
</span>
)}
<span className="flex-shrink-0">
{new Date(order.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
</div>
{/* 상태 뱃지 + 액션 버튼 */}
<div className="flex items-center gap-2 flex-shrink-0">
{order.status === 'paid' ? (
<span className="text-green-400 text-xs font-medium"> </span>
) : null}
<span
className={`px-2.5 py-1 rounded-full text-xs font-medium ${STATUS_LABELS[order.status]?.color}`}
>
{STATUS_LABELS[order.status]?.label}
</span>
{order.status === 'pending' && (
<>
<button
onClick={() => updateStatus(order.id, 'paid')}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 transition disabled:opacity-50"
>
{updating === order.id ? '처리중...' : '입금 확인'}
</button>
<button
onClick={() => updateStatus(order.id, 'cancelled')}
disabled={updating === order.id}
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-400 hover:bg-slate-600 hover:text-white transition disabled:opacity-50"
>
</button>
</>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -1,252 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
type PackTier = 'starter' | 'pro' | 'master';
interface PackFile {
id: string;
min_tier: PackTier;
label: string;
filename: string;
size_bytes: number;
sort_order: number;
uploaded_at: string;
}
const TIER_LABEL: Record<PackTier, string> = {
starter: '입문',
pro: '프로',
master: '마스터',
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export default function AdminPacksPage() {
const [files, setFiles] = useState<PackFile[]>([]);
const [loading, setLoading] = useState(true);
// 업로드 form state
const [tier, setTier] = useState<PackTier>('starter');
const [label, setLabel] = useState('');
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
async function loadFiles() {
setLoading(true);
try {
const res = await fetch('/api/admin/packs');
const data = await res.json();
setFiles(data.files ?? []);
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
useEffect(() => { loadFiles(); }, []);
async function handleUpload(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (!file || !label) return;
setUploading(true);
setProgress(0);
try {
// 1) Vercel API에서 일회성 토큰 발급
const tokenRes = await fetch('/api/admin/packs/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier,
label,
filename: file.name,
sizeBytes: file.size,
}),
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
throw new Error(err.error ?? '토큰 발급 실패');
}
const { token, uploadUrl } = await tokenRes.json();
// 2) 브라우저가 web-backend에 직접 multipart POST (XHR로 진행률 추적)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
setProgress(Math.round((ev.loaded / ev.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else {
try {
const { detail } = JSON.parse(xhr.responseText);
reject(new Error(detail ?? `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
}
}
};
xhr.onerror = () => reject(new Error('네트워크 오류'));
const fd = new FormData();
fd.append('file', file);
xhr.send(fd);
});
// 3) 리스트 갱신
setFile(null);
setLabel('');
setProgress(0);
await loadFiles();
} catch (e) {
setError(e instanceof Error ? e.message : '업로드 실패');
} finally {
setUploading(false);
}
}
async function handleDelete(id: string, label: string) {
if (!confirm(`"${label}" 자료를 삭제하시겠습니까?`)) return;
try {
const res = await fetch(`/api/admin/packs?id=${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error('삭제 실패');
await loadFiles();
} catch (e) {
alert(e instanceof Error ? e.message : '삭제 실패');
}
}
async function handlePatch(id: string, updates: Partial<PackFile>) {
try {
await fetch('/api/admin/packs', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ...updates }),
});
await loadFiles();
} catch (e) {
console.error(e);
}
}
const grouped: Record<PackTier, PackFile[]> = {
starter: files.filter((f) => f.min_tier === 'starter'),
pro: files.filter((f) => f.min_tier === 'pro'),
master: files.filter((f) => f.min_tier === 'master'),
};
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
NAS + . 5GB / 4 .
</p>
</div>
{/* 업로드 폼 */}
<form onSubmit={handleUpload} className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-8">
<h2 className="text-white font-bold mb-4"> </h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
<select
value={tier}
onChange={(e) => setTier(e.target.value as PackTier)}
disabled={uploading}
className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
>
<option value="starter">{TIER_LABEL.starter}</option>
<option value="pro">{TIER_LABEL.pro}</option>
<option value="master">{TIER_LABEL.master}</option>
</select>
<input
type="text"
value={label}
onChange={(e) => setLabel(e.target.value)}
disabled={uploading}
placeholder="자료 라벨 (예: Suno 프롬프트 북 PDF)"
className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 md:col-span-2"
/>
</div>
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
disabled={uploading}
className="text-slate-300 mb-3 block"
/>
{file && (
<p className="text-slate-400 text-xs mb-3">
: {file.name} ({formatSize(file.size)})
</p>
)}
{uploading && (
<div className="mb-3">
<div className="bg-slate-800 rounded h-2 overflow-hidden">
<div className="bg-violet-500 h-full transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-slate-400 text-xs mt-1">{progress}% ... </p>
</div>
)}
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
<button
type="submit"
disabled={uploading || !file || !label}
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white font-bold px-5 py-2 rounded"
>
{uploading ? '업로드 중...' : '업로드'}
</button>
</form>
{/* 자료 리스트 */}
{loading ? (
<p className="text-slate-400"> ...</p>
) : (
(['starter', 'pro', 'master'] as PackTier[]).map((t) => (
<div key={t} className="mb-6">
<h3 className="text-white font-bold mb-2">
{TIER_LABEL[t]} ({grouped[t].length})
</h3>
{grouped[t].length === 0 ? (
<p className="text-slate-500 text-sm pl-2"> </p>
) : (
<div className="space-y-2">
{grouped[t].map((f) => (
<div key={f.id} className="bg-slate-900 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
<input
type="text"
defaultValue={f.label}
onBlur={(e) => {
if (e.target.value !== f.label) handlePatch(f.id, { label: e.target.value });
}}
className="flex-1 bg-transparent text-white border-b border-transparent focus:border-slate-500 px-1 py-1"
/>
<span className="text-slate-400 text-xs">{f.filename}</span>
<span className="text-slate-500 text-xs">{formatSize(f.size_bytes)}</span>
<button
onClick={() => handleDelete(f.id, f.label)}
className="text-red-400 hover:text-red-300 text-sm px-2"
>
</button>
</div>
))}
</div>
)}
</div>
))
)}
</div>
);
}

560
app/admin/products/page.tsx Normal file
View File

@@ -0,0 +1,560 @@
'use client';
import { useEffect, useState } from 'react';
interface Product {
id: string;
name: string;
description: string | null;
description_long: string | null;
price: number;
features: string[] | null;
is_listed: boolean;
is_active: boolean;
sort_order: number;
}
interface PackFile {
id: string;
product_id: string | null;
label: string;
filename: string;
size_bytes: number;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
const EMPTY_FORM = {
id: '',
name: '',
price: 0,
description: '',
description_long: '',
featuresText: '',
is_listed: false,
sort_order: 0,
};
export default function AdminProductsPage() {
const [products, setProducts] = useState<Product[]>([]);
const [files, setFiles] = useState<PackFile[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// 폼 상태
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null); // null = 신규
const [form, setForm] = useState({ ...EMPTY_FORM });
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
// 파일 관리 선택 제품
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
// 업로드 상태
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadLabel, setUploadLabel] = useState('');
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
async function loadAll() {
setLoading(true);
setLoadError(null);
try {
const [pRes, fRes] = await Promise.all([
fetch('/api/admin/products'),
fetch('/api/admin/packs'),
]);
const pData = await pRes.json();
const fData = await fRes.json();
if (!pRes.ok) throw new Error(pData.error ?? '제품 로드 실패');
setProducts(pData.products ?? []);
setFiles(fData.files ?? []);
} catch (e) {
setLoadError(e instanceof Error ? e.message : '로드 실패');
} finally {
setLoading(false);
}
}
// 파일 목록만 재조회 후 반환 (자동 배정 매칭용)
async function reloadFiles(): Promise<PackFile[]> {
const res = await fetch('/api/admin/packs');
const data = await res.json();
const list: PackFile[] = data.files ?? [];
setFiles(list);
return list;
}
useEffect(() => { loadAll(); }, []);
function openNew() {
setEditingId(null);
setForm({ ...EMPTY_FORM });
setFormError(null);
setShowForm(true);
}
function openEdit(p: Product) {
setEditingId(p.id);
setForm({
id: p.id,
name: p.name,
price: p.price,
description: p.description ?? '',
description_long: p.description_long ?? '',
featuresText: (p.features ?? []).join('\n'),
is_listed: p.is_listed,
sort_order: p.sort_order,
});
setFormError(null);
setShowForm(true);
}
async function submitForm(e: React.FormEvent) {
e.preventDefault();
setFormError(null);
setSaving(true);
try {
const features = form.featuresText
.split('\n')
.map((s) => s.trim())
.filter((s) => s.length > 0);
const payload = {
id: form.id,
name: form.name,
price: Number(form.price),
description: form.description,
description_long: form.description_long,
features,
is_listed: form.is_listed,
sort_order: Number(form.sort_order),
};
const method = editingId ? 'PATCH' : 'POST';
const res = await fetch('/api/admin/products', {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? '저장 실패');
setShowForm(false);
await loadAll();
} catch (e) {
setFormError(e instanceof Error ? e.message : '저장 실패');
} finally {
setSaving(false);
}
}
async function toggleListed(p: Product) {
try {
await fetch('/api/admin/products', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: p.id, is_listed: !p.is_listed }),
});
await loadAll();
} catch (e) {
console.error(e);
}
}
async function patchFileProduct(fileId: string, productId: string | null) {
await fetch('/api/admin/packs', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: fileId, product_id: productId }),
});
await reloadFiles();
}
async function handleUpload(e: React.FormEvent) {
e.preventDefault();
setUploadError(null);
setUploadMsg(null);
if (!uploadFile || !uploadLabel || !selectedProductId) return;
setUploading(true);
setProgress(0);
const targetName = uploadFile.name;
const targetSize = uploadFile.size;
try {
// 1) 토큰 발급 (tier는 starter 고정)
const tokenRes = await fetch('/api/admin/packs/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tier: 'starter',
label: uploadLabel,
filename: uploadFile.name,
sizeBytes: uploadFile.size,
}),
});
if (!tokenRes.ok) {
const err = await tokenRes.json();
throw new Error(err.error ?? '토큰 발급 실패');
}
const { token, uploadUrl } = await tokenRes.json();
// 2) 브라우저가 web-backend에 직접 multipart POST (XHR 진행률)
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', uploadUrl);
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
xhr.upload.onprogress = (ev) => {
if (ev.lengthComputable) {
setProgress(Math.round((ev.loaded / ev.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else {
try {
const { detail } = JSON.parse(xhr.responseText);
reject(new Error(detail ?? `HTTP ${xhr.status}`));
} catch {
reject(new Error(`HTTP ${xhr.status}`));
}
}
};
xhr.onerror = () => reject(new Error('네트워크 오류'));
const fd = new FormData();
fd.append('file', uploadFile);
xhr.send(fd);
});
// 3) 방금 생성된 행을 filename+size로 찾아 자동 배정
const fresh = await reloadFiles();
const candidates = fresh.filter(
(f) => f.filename === targetName && f.size_bytes === targetSize && f.product_id === null,
);
if (candidates.length === 1) {
await patchFileProduct(candidates[0].id, selectedProductId);
setUploadMsg('업로드 + 제품 배정 완료');
} else {
setUploadMsg(
'업로드 완료. 자동 배정에 실패했습니다(동명 파일 등). 아래 미배정 목록에서 수동으로 배정하세요.',
);
}
setUploadFile(null);
setUploadLabel('');
setProgress(0);
} catch (e) {
setUploadError(e instanceof Error ? e.message : '업로드 실패');
} finally {
setUploading(false);
}
}
const selectedProduct = products.find((p) => p.id === selectedProductId) ?? null;
const productFiles = selectedProductId
? files.filter((f) => f.product_id === selectedProductId)
: [];
const otherFiles = selectedProductId
? files.filter((f) => f.product_id !== selectedProductId)
: [];
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
· · .
</p>
</div>
<button
onClick={openNew}
className="bg-violet-600 hover:bg-violet-500 text-white font-bold px-4 py-2 rounded"
>
+
</button>
</div>
{/* 폼 */}
{showForm && (
<form onSubmit={submitForm} className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-8">
<h2 className="text-white font-bold mb-4">{editingId ? `제품 편집: ${editingId}` : '새 제품 등록'}</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<div>
<label className="text-slate-400 text-xs block mb-1"> id (//_)</label>
<input
type="text"
value={form.id}
onChange={(e) => setForm({ ...form, id: e.target.value })}
disabled={!!editingId || saving}
placeholder="예: lotto_pro"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 disabled:opacity-60"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"></label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"> (, )</label>
<input
type="number"
min={0}
value={form.price}
onChange={(e) => setForm({ ...form, price: Number(e.target.value) })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div>
<label className="text-slate-400 text-xs block mb-1"> </label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
</div>
<div className="mb-3">
<label className="text-slate-400 text-xs block mb-1"> (1)</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
disabled={saving}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div className="mb-3">
<label className="text-slate-400 text-xs block mb-1"> </label>
<textarea
value={form.description_long}
onChange={(e) => setForm({ ...form, description_long: e.target.value })}
disabled={saving}
rows={3}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<div className="mb-3">
<label className="text-slate-400 text-xs block mb-1"> ( )</label>
<textarea
value={form.featuresText}
onChange={(e) => setForm({ ...form, featuresText: e.target.value })}
disabled={saving}
rows={3}
placeholder={'텔레그램 연동\n실시간 알림\n백테스트'}
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
/>
</div>
<label className="flex items-center gap-2 mb-4 text-slate-300 text-sm">
<input
type="checkbox"
checked={form.is_listed}
onChange={(e) => setForm({ ...form, is_listed: e.target.checked })}
disabled={saving}
/>
(is_listed)
</label>
{formError && <p className="text-red-400 text-sm mb-3">{formError}</p>}
<div className="flex gap-2">
<button
type="submit"
disabled={saving}
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 text-white font-bold px-5 py-2 rounded"
>
{saving ? '저장 중...' : editingId ? '수정 저장' : '제품 생성'}
</button>
<button
type="button"
onClick={() => setShowForm(false)}
disabled={saving}
className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded"
>
</button>
</div>
</form>
)}
{/* 제품 목록 */}
{loading ? (
<p className="text-slate-400"> ...</p>
) : loadError ? (
<p className="text-red-400">{loadError}</p>
) : products.length === 0 ? (
<p className="text-slate-500"> . [+ ] .</p>
) : (
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden mb-8">
<table className="w-full text-sm">
<thead className="bg-slate-800 text-slate-400">
<tr>
<th className="text-left px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-center px-4 py-3"></th>
<th className="text-right px-4 py-3"></th>
</tr>
</thead>
<tbody>
{products.map((p) => (
<tr key={p.id} className="border-t border-slate-800">
<td className="px-4 py-3 text-white">
{p.name}
<span className="text-slate-500 text-xs ml-2">{p.id}</span>
</td>
<td className="px-4 py-3 text-right text-slate-300">{p.price.toLocaleString()}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => toggleListed(p)}
className={`px-2 py-1 rounded text-xs font-medium ${
p.is_listed
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
: 'bg-slate-700 text-slate-400'
}`}
>
{p.is_listed ? '노출' : '숨김'}
</button>
</td>
<td className="px-4 py-3 text-center text-slate-400">{p.sort_order}</td>
<td className="px-4 py-3 text-right whitespace-nowrap">
<button
onClick={() => openEdit(p)}
className="text-violet-400 hover:text-violet-300 px-2"
>
</button>
<button
onClick={() => { setSelectedProductId(p.id); setUploadMsg(null); setUploadError(null); }}
className="text-blue-400 hover:text-blue-300 px-2"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 파일 관리 섹션 */}
{selectedProduct && (
<div className="bg-slate-900 border border-slate-700 rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-white font-bold">
{selectedProduct.name}
</h2>
<button
onClick={() => setSelectedProductId(null)}
className="text-slate-400 hover:text-white text-sm"
>
</button>
</div>
{/* 현재 제품 파일 */}
<h3 className="text-slate-300 font-semibold text-sm mb-2"> ({productFiles.length})</h3>
{productFiles.length === 0 ? (
<p className="text-slate-500 text-sm mb-4"> .</p>
) : (
<div className="space-y-2 mb-4">
{productFiles.map((f) => (
<div key={f.id} className="bg-slate-800 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
<span className="flex-1 text-white">{f.label}</span>
<span className="text-slate-400 text-xs">{f.filename}</span>
<span className="text-slate-500 text-xs">{formatSize(f.size_bytes)}</span>
<button
onClick={() => patchFileProduct(f.id, null)}
className="text-red-400 hover:text-red-300 text-sm px-2"
>
</button>
</div>
))}
</div>
)}
{/* 업로드 */}
<form onSubmit={handleUpload} className="border-t border-slate-800 pt-4 mb-4">
<h3 className="text-slate-300 font-semibold text-sm mb-2"> </h3>
<input
type="text"
value={uploadLabel}
onChange={(e) => setUploadLabel(e.target.value)}
disabled={uploading}
placeholder="파일 라벨 (예: 설치 가이드 PDF)"
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 mb-3"
/>
<input
type="file"
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
disabled={uploading}
className="text-slate-300 mb-3 block"
/>
{uploadFile && (
<p className="text-slate-400 text-xs mb-3">
: {uploadFile.name} ({formatSize(uploadFile.size)})
</p>
)}
{uploading && (
<div className="mb-3">
<div className="bg-slate-800 rounded h-2 overflow-hidden">
<div className="bg-violet-500 h-full transition-all" style={{ width: `${progress}%` }} />
</div>
<p className="text-slate-400 text-xs mt-1">{progress}% ... </p>
</div>
)}
{uploadMsg && <p className="text-emerald-400 text-sm mb-3">{uploadMsg}</p>}
{uploadError && <p className="text-red-400 text-sm mb-3">{uploadError}</p>}
<button
type="submit"
disabled={uploading || !uploadFile || !uploadLabel}
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white font-bold px-5 py-2 rounded"
>
{uploading ? '업로드 중...' : '업로드 + 자동 배정'}
</button>
</form>
{/* 미배정/타제품 파일 배정 */}
<div className="border-t border-slate-800 pt-4">
<h3 className="text-slate-300 font-semibold text-sm mb-2"> ({otherFiles.length})</h3>
{otherFiles.length === 0 ? (
<p className="text-slate-500 text-sm"> .</p>
) : (
<div className="space-y-2">
{otherFiles.map((f) => (
<div key={f.id} className="bg-slate-800 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
<span className="flex-1 text-white">{f.label}</span>
<span className="text-slate-400 text-xs">{f.filename}</span>
<span className="text-slate-500 text-xs">
{f.product_id ? `현재: ${f.product_id}` : '미배정'}
</span>
<button
onClick={() => patchFileProduct(f.id, selectedProduct.id)}
className="text-blue-400 hover:text-blue-300 text-sm px-2"
>
</button>
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,256 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
interface QuestionnaireResponse {
id: string;
questionnaire_type: string;
client_name: string;
client_email: string;
client_phone: string | null;
responses: Record<string, unknown>;
status: string;
admin_notes: string | null;
created_at: string;
reviewed_at: string | null;
}
const STATUS_CONFIG: Record<string, { label: string; color: string }> = {
submitted: { label: '접수', color: 'bg-blue-900/40 text-blue-400 border-blue-500/30' },
reviewed: { label: '검토완료', color: 'bg-green-900/40 text-green-400 border-green-500/30' },
archived: { label: '보관', color: 'bg-slate-700/60 text-slate-400 border-slate-500/30' },
};
const QUESTION_LABELS: Record<string, string> = {
q1: '주 사용 부품 사이트 URL',
q2: '주요 취급 부품 카테고리',
q3: '샘플 품번 목록',
q4: '현재 eBay 리스팅 URL',
q5: 'eBay 셀러 계정 등급',
q6: '주 판매 카테고리',
q7: '예상 월간 리스팅 건수',
q8: 'Fitment 정확도 기대치',
q8_detail: 'Fitment 추가 의견',
q9_selected: '타겟 마켓',
q9_detail: '타겟 마켓 기타',
q10: '리스팅 1건 소요 시간',
q11: '기존 리스팅 관리 방식',
q11_detail: '서드파티 툴 이름',
q12: '관세/통관 계산 방식',
q13: 'eBay Developer API 키 보유',
q14: '선호 AI 모델',
q15: '현재 자동화 도구',
q16: 'AI API 키 보유 여부',
q17: '포트폴리오 활용 동의',
additional: '추가 요청사항',
};
export default function AdminQuestionnairePage() {
const [responses, setResponses] = useState<QuestionnaireResponse[]>([]);
const [loading, setLoading] = useState(true);
const [selected, setSelected] = useState<QuestionnaireResponse | null>(null);
const [adminNotes, setAdminNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
fetchResponses();
}, []);
async function fetchResponses() {
try {
const res = await fetch('/api/admin/questionnaire');
if (!res.ok) throw new Error();
const json = await res.json();
setResponses(json.data || []);
} catch {
setResponses([]);
} finally {
setLoading(false);
}
}
function openDetail(item: QuestionnaireResponse) {
setSelected(item);
setAdminNotes(item.admin_notes || '');
}
async function updateStatus(id: string, status: string) {
setSaving(true);
try {
await fetch(`/api/admin/questionnaire/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status, admin_notes: adminNotes }),
});
await fetchResponses();
if (selected?.id === id) {
setSelected(prev => prev ? { ...prev, status, admin_notes: adminNotes } : null);
}
} finally {
setSaving(false);
}
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
});
}
return (
<div className="p-6 max-w-6xl mx-auto">
<div className="mb-6">
<h1 className="text-white text-2xl font-bold"> </h1>
<p className="text-slate-400 text-sm mt-0.5">
</p>
</div>
{loading ? (
<div className="text-slate-400 text-sm py-12 text-center"> ...</div>
) : responses.length === 0 ? (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 p-12 text-center">
<svg className="w-12 h-12 text-slate-600 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
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 2" />
</svg>
<p className="text-slate-400 text-sm"> </p>
</div>
) : (
<div className="space-y-4">
{/* 목록 */}
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700/50">
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-left text-slate-400 font-medium px-5 py-3"></th>
<th className="text-right text-slate-400 font-medium px-5 py-3"></th>
</tr>
</thead>
<tbody>
{responses.map((item) => {
const st = STATUS_CONFIG[item.status] || STATUS_CONFIG.submitted;
return (
<tr
key={item.id}
className={`border-b border-slate-800/50 hover:bg-slate-800/30 cursor-pointer transition ${
selected?.id === item.id ? 'bg-slate-800/50' : ''
}`}
onClick={() => openDetail(item)}
>
<td className="px-5 py-3 text-white font-medium">{item.client_name}</td>
<td className="px-5 py-3 text-slate-300">{item.client_email}</td>
<td className="px-5 py-3 text-slate-400">{item.questionnaire_type}</td>
<td className="px-5 py-3">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-medium border ${st.color}`}>
{st.label}
</span>
</td>
<td className="px-5 py-3 text-slate-400">{formatDate(item.created_at)}</td>
<td className="px-5 py-3 text-right">
<svg className="w-4 h-4 text-slate-500 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
{/* 상세 패널 */}
{selected && (
<div className="bg-slate-900 rounded-2xl border border-slate-700/50 overflow-hidden">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/50">
<div>
<h3 className="text-white font-semibold">
{selected.client_name}
</h3>
<p className="text-slate-400 text-xs mt-0.5">
{selected.client_email}
{selected.client_phone && ` · ${selected.client_phone}`}
{' · '}: {formatDate(selected.created_at)}
</p>
</div>
<button
onClick={() => setSelected(null)}
className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-slate-800 transition"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
{Object.entries(selected.responses).map(([key, value]) => (
<div key={key} className="bg-slate-800/50 rounded-lg p-4">
<div className="text-xs text-slate-400 font-medium mb-1.5">
{QUESTION_LABELS[key] || key}
</div>
<div className="text-white text-sm whitespace-pre-wrap">
{Array.isArray(value) ? (value as string[]).join(', ') : String(value)}
</div>
</div>
))}
{Object.keys(selected.responses).length === 0 && (
<p className="text-slate-500 text-sm text-center py-4"> </p>
)}
</div>
{/* 관리자 메모 + 상태 변경 */}
<div className="px-5 py-4 border-t border-slate-700/50 space-y-3">
<div>
<label className="text-slate-400 text-xs font-medium block mb-1.5"> </label>
<textarea
value={adminNotes}
onChange={(e) => setAdminNotes(e.target.value)}
className="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm resize-none focus:outline-none focus:border-red-500/50"
rows={2}
placeholder="내부 참고용 메모..."
/>
</div>
<div className="flex gap-2">
{selected.status !== 'reviewed' && (
<button
onClick={() => updateStatus(selected.id, 'reviewed')}
disabled={saving}
className="px-4 py-2 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 hover:bg-green-600/30 transition border border-green-500/20 disabled:opacity-50"
>
{saving ? '저장 중...' : '검토 완료'}
</button>
)}
{selected.status !== 'archived' && (
<button
onClick={() => updateStatus(selected.id, 'archived')}
disabled={saving}
className="px-4 py-2 rounded-lg text-xs font-medium bg-slate-700 text-slate-300 hover:bg-slate-600 transition disabled:opacity-50"
>
</button>
)}
{selected.status !== 'submitted' && (
<button
onClick={() => updateStatus(selected.id, 'submitted')}
disabled={saving}
className="px-4 py-2 rounded-lg text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 transition border border-blue-500/20 disabled:opacity-50"
>
</button>
)}
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -64,6 +64,7 @@ export default function QuoteEditorPage() {
const [copied, setCopied] = useState(false);
const [milestones, setMilestones] = useState<Milestone[]>([]);
const [mileSaving, setMileSaving] = useState<string | null>(null);
const [sending, setSending] = useState(false);
useEffect(() => {
fetch(`/api/admin/quotes/${id}`)
@@ -125,6 +126,39 @@ export default function QuoteEditorPage() {
setMileSaving(null);
}
// ── 고객에게 발송 ───────────────────────
const SENT_STATUSES = ['sent', 'accepted', 'rejected'];
const isSentStatus = SENT_STATUSES.includes(form.status);
async function sendToClient() {
if (!form.client_email || isSentStatus) return;
if (!confirm("고객에게 견적 메일을 발송하고 상태를 '발송됨'으로 변경합니다.")) return;
setSending(true);
try {
const res = await fetch(`/api/admin/quotes/${id}/send`, { method: 'POST' });
const d = await res.json();
if (res.ok && d.success) {
if (d.alreadySent) {
alert('이미 발송된 견적입니다');
return;
}
setField('status', 'sent');
if (d.emailSent === false) {
alert('상태는 변경됐으나 메일 발송에 실패했습니다 — 수동 발송이 필요합니다');
} else {
alert('발송 완료');
}
} else {
alert(d.error || '발송에 실패했습니다');
}
} catch (e) {
console.error(e);
alert('발송 중 오류가 발생했습니다');
} finally {
setSending(false);
}
}
// ── helpers ────────────────────────────
const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
@@ -255,6 +289,27 @@ export default function QuoteEditorPage() {
PDF
</a>
)}
{/* 고객에게 발송 */}
<button
onClick={sendToClient}
disabled={sending || !form.client_email || isSentStatus}
title={
isSentStatus ? '이미 발송된 견적입니다' :
!form.client_email ? '고객 이메일을 먼저 입력하세요' :
'고객에게 견적 메일 발송'
}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 disabled:cursor-not-allowed">
{sending ? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
)}
{isSentStatus ? '발송됨' : '고객에게 발송'}
</button>
{!form.client_email && !isSentStatus && (
<span className="text-xs text-amber-400/80"> </span>
)}
{/* 저장 */}
<button onClick={() => save()} disabled={saving}
className={`flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold transition-all ${saved ? 'bg-green-600 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'} disabled:opacity-60`}>

View File

@@ -9,7 +9,7 @@ interface Quote {
title: string;
client_name: string;
client_email: string;
status: 'draft' | 'sent' | 'accepted' | 'rejected';
status: 'draft' | 'sent' | 'accepted' | 'rejected' | 'in_progress' | 'completed' | 'delivered';
valid_until: string | null;
public_token: string;
items: { unitPrice: number; quantity: number; optional: boolean }[];
@@ -17,10 +17,13 @@ interface Quote {
}
const STATUS = {
draft: { label: '초안', color: 'bg-slate-700 text-slate-300' },
sent: { label: '발송됨', color: 'bg-blue-900/50 text-blue-400' },
accepted: { label: '수락', color: 'bg-green-900/50 text-green-400' },
rejected: { label: '거절됨', color: 'bg-red-900/50 text-red-400' },
draft: { label: '초안', color: 'bg-slate-700 text-slate-300' },
sent: { label: '발송됨', color: 'bg-blue-900/50 text-blue-400' },
accepted: { label: '수락 · 발주', color: 'bg-green-900/50 text-green-400' },
rejected: { label: '거절됨', color: 'bg-red-900/50 text-red-400' },
in_progress: { label: '진행중 · 발주', color: 'bg-blue-900/50 text-blue-400' },
completed: { label: '완료 · 발주', color: 'bg-emerald-900/50 text-emerald-400' },
delivered: { label: '납품 완료 · 발주', color: 'bg-teal-900/50 text-teal-400' },
};
function calcTotal(items: Quote['items']) {

View File

@@ -0,0 +1,53 @@
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);
}
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
}
const patch: Record<string, unknown> = { updated_at: new Date().toISOString() };
if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim();
if ('url' in body) patch.url = typeof body.url === 'string' && body.url.trim() ? body.url.trim() : null;
if ('memo' in body) patch.memo = typeof body.memo === 'string' && body.memo.trim() ? body.memo.trim() : null;
if (body.status === 'active' || body.status === 'paused') patch.status = body.status;
const supabase = createAdminClient();
const { error } = await supabase.from('ad_channels').update(patch).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}
export async function DELETE(_request: Request, { params }: { params: Promise<{ id: string }> }) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const supabase = createAdminClient();
const { error } = await supabase.from('ad_channels').delete().eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}

View File

@@ -0,0 +1,60 @@
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);
}
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase
.from('ad_channels')
.select('*')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ channels: data ?? [] });
}
export async function POST(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
}
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : null;
if (!name) {
return NextResponse.json({ error: '채널명을 입력해주세요.' }, { status: 400 });
}
const supabase = createAdminClient();
const { data, error } = await supabase
.from('ad_channels')
.insert({
name,
url: typeof body.url === 'string' && body.url.trim() ? body.url.trim() : null,
memo: typeof body.memo === 'string' && body.memo.trim() ? body.memo.trim() : null,
})
.select()
.single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ channel: data });
}

View File

@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
import { isRequestStatus } from '@/lib/request-status';
export const runtime = 'nodejs';
@@ -18,7 +19,7 @@ export async function GET() {
const supabase = createAdminClient();
const { data, error } = await supabase
const { data: contacts, error } = await supabase
.from('contact_requests')
.select('*')
.order('created_at', { ascending: false })
@@ -28,7 +29,35 @@ export async function GET() {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ contacts: data ?? [] });
if (!contacts || contacts.length === 0) {
return NextResponse.json({ contacts: [] });
}
// 2-쿼리 머지: 연결 견적 부착 (컬럼 부재 등 오류는 빈 배열 폴백)
const ids = contacts.map((c) => c.id).filter(Boolean) as string[];
let quotesMap: Record<string, { id: string; title: string; status: string }[]> = {};
try {
const { data: quotesData } = await supabase
.from('quotes')
.select('id, title, status, contact_request_id')
.in('contact_request_id', ids);
if (quotesData) {
for (const q of quotesData) {
if (!q.contact_request_id) continue;
if (!quotesMap[q.contact_request_id]) quotesMap[q.contact_request_id] = [];
quotesMap[q.contact_request_id].push({ id: q.id, title: q.title, status: q.status });
}
}
} catch {
// 컬럼 부재 등 — 빈 배열 폴백
}
const enriched = contacts.map((c) => ({
...c,
quotes: quotesMap[c.id] ?? [],
}));
return NextResponse.json({ contacts: enriched });
}
export async function PATCH(request: Request) {
@@ -37,11 +66,16 @@ export async function PATCH(request: Request) {
}
const { id, status } = await request.json();
if (typeof id !== 'string' || !isRequestStatus(status)) {
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
}
const supabase = createAdminClient();
const { error } = await supabase
.from('contact_requests')
.update({ status })
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id);
if (error) {

View File

@@ -1,44 +0,0 @@
import { NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import path from 'path';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
const ALLOWED_FILES = [
'ebay-tool-proposal.html',
'ebay-tool-questionnaire.html',
];
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { filename } = await params;
if (!ALLOWED_FILES.includes(filename)) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
try {
const filePath = path.join(process.cwd(), 'CONTENT', filename);
const content = await readFile(filePath, 'utf-8');
return new NextResponse(content, {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
} catch {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
}

View File

@@ -27,14 +27,12 @@ export async function GET() {
// 각 회원의 주문 수 + 결제 금액 집계
const enriched = await Promise.all(
(profiles ?? []).map(async (p: { id: string; email: string; full_name: string; created_at: string }) => {
const [ordersRes, paymentsRes, subsRes] = await Promise.all([
const [ordersRes, paymentsRes] = await Promise.all([
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('user_id', p.id).eq('status', 'paid'),
supabase.from('payments').select('amount').eq('user_id', p.id).eq('status', 'paid'),
supabase.from('subscriptions').select('product_id, status, expires_at').eq('user_id', p.id).eq('status', 'active').order('created_at', { ascending: false }).limit(1),
]);
const totalPaid = (paymentsRes.data ?? []).reduce((s: number, x: { amount: number }) => s + x.amount, 0);
const activeSub = subsRes.data?.[0] ?? null;
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid, activeSub };
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid };
})
);

View File

@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { getProductById } from '@/lib/supabase/product-files';
import { sendOrderPaidEmail } from '@/lib/order-emails';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
// GET: 주문 목록 (최근 200건) — 상품명 + 주문자 이메일 포함
export async function GET() {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const supabase = createAdminClient();
// 2-쿼리 방식: FK 관계 중첩 select 대신 명시적 조인으로 안전하게
const { data: orders, error } = await supabase
.from('orders')
.select('id, user_id, product_id, amount, status, metadata, created_at')
.order('created_at', { ascending: false })
.limit(200);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
if (!orders || orders.length === 0) {
return NextResponse.json({ orders: [] });
}
// 상품명 조회
const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[];
const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[];
const [productsRes, profilesRes] = await Promise.all([
productIds.length > 0
? supabase.from('products').select('id, name').in('id', productIds)
: Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }),
userIds.length > 0
? supabase.from('profiles').select('id, email').in('id', userIds)
: Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }),
]);
const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name]));
const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email]));
const enriched = orders.map((o) => ({
...o,
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
}));
return NextResponse.json({ orders: enriched });
}
// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일)
export async function PATCH(request: Request) {
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id, status } = await request.json();
if (typeof id !== 'string' || !['pending', 'paid', 'cancelled'].includes(status)) {
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
}
const supabase = createAdminClient();
const { data: order, error } = await supabase
.from('orders')
.update({ status, updated_at: new Date().toISOString() })
.eq('id', id)
.select('id, product_id, user_id')
.single();
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
if (status === 'paid' && order.product_id && order.user_id) {
try {
const product = await getProductById(supabase, order.product_id);
const { data: profile } = await supabase
.from('profiles')
.select('email')
.eq('id', order.user_id)
.maybeSingle();
if (product && profile?.email) {
await sendOrderPaidEmail({ product, customerEmail: profile.email });
}
} catch (e) {
console.error('paid email failed', e);
}
}
return NextResponse.json({ success: true });
}

View File

@@ -34,7 +34,7 @@ export async function PATCH(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id, label, sort_order, min_tier } = await request.json();
const { id, label, sort_order, min_tier, product_id } = await request.json();
if (!id) return NextResponse.json({ error: 'id 필요' }, { status: 400 });
const updates: Record<string, unknown> = {};
@@ -43,6 +43,7 @@ export async function PATCH(request: Request) {
if (typeof min_tier === 'string' && VALID_TIERS.has(min_tier as PackTier)) {
updates.min_tier = min_tier;
}
if (typeof product_id === 'string' || product_id === null) updates.product_id = product_id;
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
}

View File

@@ -0,0 +1,111 @@
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);
}
const ID_RE = /^[a-z0-9_]{2,40}$/;
function sanitizeFeatures(input: unknown): string[] | undefined {
if (!Array.isArray(input)) return undefined;
return input.filter((v): v is string => typeof v === 'string');
}
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const supabase = createAdminClient();
const { data, error } = await supabase
.from('products')
.select('*')
.order('sort_order')
.order('id');
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ products: data ?? [] });
}
export async function POST(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id, name, description, description_long, price, features, is_listed, sort_order } = body;
if (typeof id !== 'string' || !ID_RE.test(id)) {
return NextResponse.json({ error: 'id는 영소문자/숫자/언더스코어 2-40자' }, { status: 400 });
}
if (typeof name !== 'string' || name.trim().length === 0) {
return NextResponse.json({ error: 'name 필요' }, { status: 400 });
}
if (typeof price !== 'number' || !Number.isInteger(price) || price < 0) {
return NextResponse.json({ error: 'price는 0 이상의 정수' }, { status: 400 });
}
const insert: Record<string, unknown> = {
id,
name: name.trim(),
price,
category: 'software',
pay_method: 'bank_transfer',
is_active: true,
};
if (typeof description === 'string') insert.description = description;
if (typeof description_long === 'string') insert.description_long = description_long;
const feats = sanitizeFeatures(features);
if (feats !== undefined) insert.features = feats;
if (typeof is_listed === 'boolean') insert.is_listed = is_listed;
if (typeof sort_order === 'number') insert.sort_order = sort_order;
const supabase = createAdminClient();
const { data, error } = await supabase.from('products').insert(insert).select().single();
if (error) {
if (error.code === '23505') {
return NextResponse.json({ error: '이미 존재하는 제품 id' }, { status: 409 });
}
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({ product: data });
}
export async function PATCH(request: Request) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const { id } = body;
if (typeof id !== 'string' || !id) {
return NextResponse.json({ error: 'id 필요' }, { status: 400 });
}
const updates: Record<string, unknown> = {};
if (typeof body.name === 'string') updates.name = body.name.trim();
if (typeof body.description === 'string') updates.description = body.description;
if (typeof body.description_long === 'string') updates.description_long = body.description_long;
if (typeof body.price === 'number' && Number.isInteger(body.price) && body.price >= 0) {
updates.price = body.price;
}
const feats = sanitizeFeatures(body.features);
if (feats !== undefined) updates.features = feats;
if (typeof body.is_listed === 'boolean') updates.is_listed = body.is_listed;
if (typeof body.is_active === 'boolean') updates.is_active = body.is_active;
if (typeof body.sort_order === 'number') updates.sort_order = body.sort_order;
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
}
const supabase = createAdminClient();
const { error } = await supabase.from('products').update(updates).eq('id', id);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ success: true });
}

View File

@@ -1,69 +0,0 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
// 질문지 응답 상세 조회
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const admin = createAdminClient();
const { data, error } = await admin
.from('questionnaire_responses')
.select('*')
.eq('id', id)
.single();
if (error) {
console.error('[Admin Questionnaire] DB error:', error);
return NextResponse.json({ error: '조회 실패' }, { status: 500 });
}
return NextResponse.json({ data });
}
// 상태/메모 업데이트
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const body = await request.json();
const { status, admin_notes } = body;
const updates: Record<string, unknown> = {};
if (status) updates.status = status;
if (admin_notes !== undefined) updates.admin_notes = admin_notes;
if (status === 'reviewed') updates.reviewed_at = new Date().toISOString();
const admin = createAdminClient();
const { error } = await admin
.from('questionnaire_responses')
.update(updates)
.eq('id', id);
if (error) {
console.error('[Admin Questionnaire] Update error:', error);
return NextResponse.json({ error: '업데이트 실패' }, { status: 500 });
}
return NextResponse.json({ success: true });
}

View File

@@ -1,32 +0,0 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
// 질문지 응답 목록 조회
export async function GET() {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const admin = createAdminClient();
const { data, error } = await admin
.from('questionnaire_responses')
.select('*')
.order('created_at', { ascending: false });
if (error) {
console.error('[Admin Questionnaire] DB error:', error);
return NextResponse.json({ error: '데이터 조회 실패' }, { status: 500 });
}
return NextResponse.json({ data });
}

View File

@@ -0,0 +1,89 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { verifyAdminTokenNode } from '@/lib/admin-auth';
import { sendQuoteSentEmail } from '@/lib/request-emails';
import { cookies } from 'next/headers';
export const runtime = 'nodejs';
async function checkAuth() {
const cookieStore = await cookies();
const token = cookieStore.get('admin_token')?.value;
return token && verifyAdminTokenNode(token);
}
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
if (!(await checkAuth())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await params;
const supabase = createAdminClient();
// 1. 견적서 조회
const { data: quote, error: fetchError } = await supabase
.from('quotes')
.select('*')
.eq('id', id)
.single();
if (fetchError || !quote) {
return NextResponse.json({ error: '견적서를 찾을 수 없습니다' }, { status: 404 });
}
// 2. 이미 발송/수락/거절된 견적은 재발송 차단
if (['sent', 'accepted', 'rejected'].includes(quote.status)) {
return NextResponse.json({ success: true, emailSent: false, alreadySent: true });
}
// 3. 고객 이메일 필수
if (!quote.client_email) {
return NextResponse.json({ error: '고객 이메일을 먼저 입력하세요' }, { status: 400 });
}
// 4. public_token 보장
const quoteToken: string = quote.public_token || crypto.randomUUID();
const nowIso = new Date().toISOString();
// 5. 견적 상태 업데이트
const updatePayload: Record<string, unknown> = { status: 'sent', updated_at: nowIso };
if (!quote.public_token) updatePayload.public_token = quoteToken;
const { error: updateError } = await supabase
.from('quotes')
.update(updatePayload)
.eq('id', id);
if (updateError) {
console.error('[Quote Send] update error:', updateError.message);
return NextResponse.json({ error: '견적 상태 업데이트 실패' }, { status: 500 });
}
// 6. 연결된 의뢰 상태 동기화 (실패해도 진행)
if (quote.contact_request_id) {
const { error: syncError } = await supabase
.from('contact_requests')
.update({ status: 'quoted', updated_at: nowIso })
.eq('id', quote.contact_request_id);
if (syncError) {
console.error('[Quote Send] contact sync error:', syncError.message);
}
}
// 7. 견적 메일 발송 (실패해도 상태 변경은 유지)
let emailSent = true;
try {
await sendQuoteSentEmail({
clientName: quote.client_name || '고객',
clientEmail: quote.client_email,
quoteTitle: quote.title,
quoteToken,
validUntil: quote.valid_until ?? null,
});
} catch (e) {
emailSent = false;
console.error('[Quote Send] email error:', e);
}
return NextResponse.json({ success: true, emailSent });
}

View File

@@ -34,19 +34,25 @@ export async function POST(request: Request) {
const body = await request.json();
const supabase = createAdminClient();
// 의뢰(contact_requests) 연결용 필드 — string만 허용
const insertData: Record<string, unknown> = {
title: typeof body.title === 'string' && body.title.trim() ? body.title : '새 견적서',
client_name: typeof body.client_name === 'string' ? body.client_name : '',
client_email: typeof body.client_email === 'string' ? body.client_email : '',
valid_until: body.valid_until || null,
wbs: body.wbs || [],
items: body.items || [],
maintenance: body.maintenance || [],
notes: body.notes || '',
status: 'draft',
};
if (typeof body.contact_request_id === 'string' && body.contact_request_id) {
insertData.contact_request_id = body.contact_request_id;
}
const { data, error } = await supabase
.from('quotes')
.insert({
title: body.title || '새 견적서',
client_name: body.client_name || '',
client_email: body.client_email || '',
valid_until: body.valid_until || null,
wbs: body.wbs || [],
items: body.items || [],
maintenance: body.maintenance || [],
notes: body.notes || '',
status: 'draft',
})
.insert(insertData)
.select()
.single();

View File

@@ -51,10 +51,6 @@ export async function PATCH(request: Request) {
}
const DEFAULT_SERVICES = [
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 서비스', is_active: true, order_index: 1 },
{ id: 'lotto', name: '로또 번호 추천', description: '빅데이터 기반 로또 번호 분석', is_active: true, order_index: 2 },
{ id: 'stock', name: '주식 자동매매', description: '텔레그램 연동 자동매매 프로그램', is_active: true, order_index: 3 },
{ id: 'automation', name: '업무 자동화 RPA', description: '반복 업무 자동화 개발', is_active: true, order_index: 4 },
{ id: 'prompt', name: '프롬프트 엔지니어링', description: 'AI 프롬프트 설계 서비스', is_active: true, order_index: 5 },
{ id: 'freelance', name: '외주 개발', description: '맞춤형 소프트웨어 개발', is_active: true, order_index: 6 },
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
];

View File

@@ -15,20 +15,18 @@ export async function GET() {
const supabase = createAdminClient();
// 병렬 쿼리
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes, subsRes] = await Promise.all([
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes] = await Promise.all([
supabase.from('profiles').select('id', { count: 'exact', head: true }),
supabase.from('orders').select('id', { count: 'exact', head: true }).eq('status', 'paid'),
supabase.from('payments').select('amount').eq('status', 'paid'),
supabase.from('contact_requests').select('id', { count: 'exact', head: true }).eq('status', 'pending'),
supabase.from('payments').select('amount, created_at').eq('status', 'paid').order('created_at', { ascending: true }),
supabase.from('subscriptions').select('id', { count: 'exact', head: true }).eq('status', 'active'),
]);
const totalMembers = profilesRes.count ?? 0;
const totalOrders = ordersRes.count ?? 0;
const totalRevenue = (paymentsRes.data ?? []).reduce((sum: number, p: { amount: number }) => sum + p.amount, 0);
const pendingContacts = contactsRes.count ?? 0;
const activeSubscribers = subsRes.count ?? 0;
// 최근 6개월 월별 수익 집계
const monthly: Record<string, number> = {};
@@ -49,5 +47,5 @@ export async function GET() {
const monthlyChart = Object.entries(monthly).map(([month, revenue]) => ({ month, revenue }));
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, activeSubscribers, monthlyChart });
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, monthlyChart });
}

View File

@@ -10,6 +10,7 @@ import {
} from '@/lib/security';
import { createAdminClient } from '@/lib/supabase/admin';
import { createClient } from '@/lib/supabase/server';
import { sendRequestReceivedEmail } from '@/lib/request-emails';
const resend = new Resend(process.env.RESEND_API_KEY);
@@ -31,11 +32,15 @@ export async function POST(request: Request) {
const body = await request.json();
// ── 입력 정제 + 길이 제한 ─────────────────────────────────
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE);
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE);
// 구조화 필드 (선택값 — 미전송 시 빈 문자열)
const projectType = sanitizeStr(body.projectType, 100);
const budget = sanitizeStr(body.budget, 100);
const timeline = sanitizeStr(body.timeline, 100);
// ── 필수값 검증 ───────────────────────────────────────────
if (!name || !email || !message) {
@@ -99,21 +104,74 @@ export async function POST(request: Request) {
emailSent = false;
}
// ── 추적 토큰 생성 ────────────────────────────────────────
let publicToken: string;
try {
publicToken = globalThis.crypto.randomUUID();
} catch {
const { randomUUID } = await import('crypto');
publicToken = randomUUID();
}
// ── DB 저장 (이메일 성공/실패 무관) ──────────────────────
// 신규 컬럼 포함 insert 시도 → 컬럼 부재(42703) 시 기존 필드만으로 재시도
let tokenStored = false;
try {
const admin = createAdminClient();
await admin.from('contact_requests').insert({
const { error: insertError } = await admin.from('contact_requests').insert({
name,
email,
phone: phone || null,
service: service || null,
message,
user_id: userId,
public_token: publicToken,
project_type: projectType || null,
budget: budget || null,
timeline: timeline || null,
});
if (insertError) {
// PostgreSQL undefined_column (42703) — 마이그레이션 미적용 환경 폴백
const pgCode = (insertError as { code?: string }).code;
if (pgCode === '42703') {
console.warn('[Contact] 신규 컬럼 없음(42703) — 기존 필드만으로 재시도');
const { error: fallbackError } = await admin.from('contact_requests').insert({
name,
email,
phone: phone || null,
service: service || null,
message,
user_id: userId,
});
if (fallbackError) {
console.error('[Contact] DB fallback insert error:', fallbackError);
}
// tokenStored는 false 유지 (공개 토큰이 DB에 없음)
} else {
console.error('[Contact] DB insert error:', insertError);
}
} else {
tokenStored = true;
}
} catch (dbError) {
console.error('[Contact] DB insert error:', dbError);
}
// ── 고객 접수 확인 메일 (신규 컬럼 insert 성공 시에만) ──
if (tokenStored) {
try {
await sendRequestReceivedEmail({
name,
email,
service: service || '외주 문의',
publicToken,
});
} catch (confirmEmailError) {
console.error('[Contact] 고객 확인 메일 발송 오류:', confirmEmailError);
}
}
if (!emailSent) {
return NextResponse.json(
{ error: '메일 전송에 실패했습니다. 다시 시도해주세요.' },
@@ -122,7 +180,11 @@ export async function POST(request: Request) {
}
return NextResponse.json(
{ success: true, message: '문의가 성공적으로 전송되었습니다!' },
{
success: true,
message: '문의가 성공적으로 전송되었습니다!',
trackUrl: tokenStored ? `/track/${publicToken}` : null,
},
{ status: 200 }
);
} catch (error) {

View File

@@ -1,78 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { sendMessage } from '@/lib/telegram';
/**
* GET /api/cron/subscription-expiry
* Vercel Cron: 매일 01:00 KST (UTC 16:00) 실행
* - 만료된 구독 → status='expired'
* - 3일 후 만료 예정 구독 → 텔레그램 알림 발송
*/
export async function GET(req: NextRequest) {
// Vercel Cron 인증
const authHeader = req.headers.get('authorization');
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const supabase = createAdminClient();
const now = new Date().toISOString();
const in3days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
// 1. 만료된 구독 처리
const { data: expired, error: expireError } = await supabase
.from('subscriptions')
.update({ status: 'expired' })
.eq('status', 'active')
.lt('expires_at', now)
.select('id, user_id, product_id');
if (expireError) {
console.error('subscription expiry error:', expireError);
}
// 2. 3일 후 만료 예정 → 텔레그램 알림
const { data: expiringSoon } = await supabase
.from('subscriptions')
.select('id, user_id, product_id, expires_at, profiles!inner(telegram_chat_id)')
.eq('status', 'active')
.eq('auto_renew', false)
.lt('expires_at', in3days)
.gt('expires_at', now);
const PLAN_NAMES: Record<string, string> = {
lotto_gold: '🥇 골드',
lotto_platinum: '💎 플래티넘',
lotto_diamond: '👑 다이아',
};
let notified = 0;
if (expiringSoon) {
for (const sub of expiringSoon) {
const profile = sub.profiles as unknown as { telegram_chat_id: string | null };
const chatId = profile?.telegram_chat_id;
if (!chatId) continue;
const expiresAt = new Date(sub.expires_at).toLocaleDateString('ko-KR');
const planName = PLAN_NAMES[sub.product_id] ?? sub.product_id;
await sendMessage(
chatId,
`⏰ *구독 만료 안내*\n\n` +
`로또 번호 추천 *${planName}* 플랜이\n` +
`*${expiresAt}*에 만료됩니다.\n\n` +
`지속적인 번호 추천을 받으시려면\n` +
`마이페이지에서 구독을 갱신해 주세요.\n\n` +
`👉 https://jaengseung-made.com/mypage`
);
notified++;
}
}
return NextResponse.json({
ok: true,
expired_count: expired?.length ?? 0,
notified_count: notified,
processed_at: now,
});
}

123
app/api/orders/route.ts Normal file
View File

@@ -0,0 +1,123 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById } from '@/lib/supabase/product-files';
import { sanitizeStr, checkRateLimit } from '@/lib/security';
import { sendOrderReceivedEmails } from '@/lib/order-emails';
export const runtime = 'nodejs';
export async function POST(request: Request) {
// 1) 인증 확인 (SSR 쿠키 클라이언트)
const cookieStore = await cookies();
const supabase = createSSRClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: () => {},
},
},
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
// 1-b) Rate Limit: user 기준 분당 5회
const rl = checkRateLimit(`orders:${user.id}`, 60_000, 5);
if (!rl.allowed) {
return NextResponse.json(
{ error: '요청이 너무 잦습니다. 잠시 후 다시 시도해주세요' },
{
status: 429,
headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
},
);
}
// 2) body 검증
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청입니다' }, { status: 400 });
}
const rawProductId = (body as Record<string, unknown>).productId;
const rawDepositorName = (body as Record<string, unknown>).depositorName;
const productId = sanitizeStr(rawProductId, 64);
const depositorName = sanitizeStr(rawDepositorName, 40);
if (!productId || !depositorName) {
return NextResponse.json({ error: 'productId와 depositorName이 필요합니다' }, { status: 400 });
}
// 3) 상품 조회 및 활성 상태 확인
const admin = createAdminClient();
let product;
try {
product = await getProductById(admin, productId);
} catch (dbErr) {
console.error('[Orders] product lookup error:', dbErr);
return NextResponse.json({ error: '상품 조회에 실패했습니다' }, { status: 500 });
}
if (!product || !product.is_active) {
return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 });
}
// 4) 중복 pending 방지
const { data: existing } = await admin
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('product_id', productId)
.eq('status', 'pending')
.maybeSingle();
if (existing) {
return NextResponse.json({ orderId: existing.id, reused: true });
}
// 5) 주문 생성 (가격은 DB 소스)
const { data: order, error: insertError } = await admin
.from('orders')
.insert({
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: {
method: 'bank_transfer',
depositor_name: depositorName,
},
})
.select('id')
.single();
if (insertError || !order) {
console.error('[Orders] insert error:', insertError);
return NextResponse.json({ error: '주문 생성에 실패했습니다' }, { status: 500 });
}
const orderId = order.id as string;
// 6) 메일 발송 (실패해도 주문 유효)
try {
await sendOrderReceivedEmails({
orderId,
product,
customerEmail: user.email ?? '',
depositorName,
});
} catch (mailError) {
console.error('[Orders] email send error:', mailError);
}
// 7) 응답
return NextResponse.json({ orderId });
}

View File

@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFilesForTiers } from '@/lib/supabase/pack-files';
import { getUserAccessibleProductIds, getFilesByProductIds } from '@/lib/supabase/product-files';
export const runtime = 'nodejs';
@@ -20,21 +19,25 @@ export async function GET() {
},
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ files: [] });
if (!user) return NextResponse.json({ products: [] });
const admin = createAdminClient();
const { data: orders } = await admin
.from('contact_requests')
.select('service, status')
.eq('user_id', user.id)
.eq('status', 'completed');
const productIds = await getUserAccessibleProductIds(admin, user.id);
if (productIds.length === 0) return NextResponse.json({ products: [] });
const tiers = new Set<PackTier>();
for (const o of (orders ?? [])) {
const t = extractPackTier(o.service);
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
const [files, { data: products }] = await Promise.all([
getFilesByProductIds(admin, productIds),
admin.from('products').select('id, name').in('id', productIds),
]);
const nameMap = new Map((products ?? []).map((p) => [p.id, p.name as string]));
const grouped = new Map<string, { id: string; name: string; files: typeof files }>();
for (const f of files) {
if (!f.product_id) continue;
if (!grouped.has(f.product_id)) {
grouped.set(f.product_id, { id: f.product_id, name: nameMap.get(f.product_id) ?? f.product_id, files: [] });
}
grouped.get(f.product_id)!.files.push(f);
}
const files = await getPackFilesForTiers(admin, Array.from(tiers));
return NextResponse.json({ files });
return NextResponse.json({ products: Array.from(grouped.values()) });
}

View File

@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createServerClient as createSSRClient } from '@supabase/ssr';
import { createAdminClient } from '@/lib/supabase/admin';
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
import { tierIncludes, getPackFileById } from '@/lib/supabase/pack-files';
import { getUserAccessibleProductIds, getFileById } from '@/lib/supabase/product-files';
import { signLink } from '@/lib/web-backend';
export const runtime = 'nodejs';
@@ -33,33 +32,18 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// 2) orders 조회 — completed Music 팩 구매 확인
// 2) orders(paid) 단일 소스로 접근 가능한 product_id 확인
const admin = createAdminClient();
const { data: orders } = await admin
.from('contact_requests')
.select('service, status')
.eq('user_id', user.id)
.eq('status', 'completed');
const tiers = new Set<PackTier>();
for (const o of (orders ?? [])) {
const t = extractPackTier(o.service);
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
const accessible = await getUserAccessibleProductIds(admin, user.id);
if (accessible.length === 0) {
return NextResponse.json({ error: '구매 내역이 없거나 입금 확인 전입니다' }, { status: 403 });
}
if (tiers.size === 0) {
return NextResponse.json({ error: '구매 내역이 없거나 결제 미완료입니다' }, { status: 403 });
const file = await getFileById(admin, fileId);
if (!file || file.deleted_at || !file.product_id || !accessible.includes(file.product_id)) {
return NextResponse.json({ error: '구매한 제품의 파일이 아닙니다' }, { status: 403 });
}
// 3) 파일 조회 + tier 매칭
const file = await getPackFileById(admin, fileId);
if (!file) {
return NextResponse.json({ error: '파일을 찾을 수 없습니다' }, { status: 404 });
}
if (!tiers.has(file.min_tier)) {
return NextResponse.json({ error: '구매 등급에서 접근할 수 없는 파일입니다' }, { status: 403 });
}
// 4) web-backend 호출 → DSM 공유 링크
// 3) web-backend 호출 → DSM 공유 링크
try {
const { url, expires_at } = await signLink({
file_path: file.file_path,

View File

@@ -1,135 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { checkRateLimit, getClientIp } from '@/lib/security';
export async function POST(request: NextRequest) {
try {
// ── Rate Limit: IP당 1분 10회 (결제 재시도 남용 방지) ─────
const ip = getClientIp(request);
const rl = checkRateLimit(`payment:${ip}`, 60_000, 10);
if (!rl.allowed) {
return NextResponse.json(
{ error: '요청이 너무 많습니다. 잠시 후 다시 시도해주세요.' },
{ status: 429 }
);
}
const body = await request.json();
const { paymentId } = body;
// ── 기본 파라미터 검증 ────────────────────────────────────
if (!paymentId || typeof paymentId !== 'string' || paymentId.length > 200) {
return NextResponse.json({ error: '필수 파라미터 누락' }, { status: 400 });
}
// ── 로그인 사용자 확인 ────────────────────────────────────
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
// ── DB에서 주문 확인 ──────────────────────────────────────
const { data: order, error: orderFetchError } = await supabase
.from('orders')
.select('*')
.eq('id', paymentId)
.single();
if (orderFetchError || !order) {
return NextResponse.json({ error: '주문을 찾을 수 없습니다' }, { status: 404 });
}
if (order.user_id !== user.id) {
return NextResponse.json({ error: '접근 권한이 없습니다' }, { status: 403 });
}
if (order.status === 'paid') {
return NextResponse.json({ error: '이미 처리된 주문입니다' }, { status: 400 });
}
// ── 포트원 V2 결제 조회 API ───────────────────────────────
const apiSecret = process.env.PORTONE_API_SECRET;
if (!apiSecret) {
console.error('[Payment] PORTONE_API_SECRET 미설정');
return NextResponse.json({ error: '결제 서비스 설정 오류' }, { status: 500 });
}
const portoneRes = await fetch(
`https://api.portone.io/payments/${encodeURIComponent(paymentId)}`,
{
method: 'GET',
headers: {
Authorization: `PortOne ${apiSecret}`,
'Content-Type': 'application/json',
},
}
);
if (!portoneRes.ok) {
const err = await portoneRes.json().catch(() => ({}));
console.error(`[Payment] 포트원 조회 실패 paymentId=${paymentId} status=${portoneRes.status}`, err);
return NextResponse.json(
{ error: '결제 확인에 실패했습니다. 고객센터에 문의해주세요.' },
{ status: 400 }
);
}
const paymentData = await portoneRes.json();
// ── 결제 상태 & 금액 검증 ─────────────────────────────────
if (paymentData.status !== 'PAID') {
console.warn(`[Payment] 미완료 결제 paymentId=${paymentId} status=${paymentData.status}`);
return NextResponse.json(
{ error: '결제가 완료되지 않았습니다.' },
{ status: 400 }
);
}
// 서버 DB 금액과 포트원 결제 금액 비교 (위조 방어)
const paidAmount = paymentData.amount?.total;
if (paidAmount !== order.amount) {
console.warn(`[Payment] 금액 불일치 paymentId=${paymentId} db=${order.amount} paid=${paidAmount} user=${user.id}`);
return NextResponse.json({ error: '결제 금액이 올바르지 않습니다' }, { status: 400 });
}
// ── orders 상태 업데이트 ──────────────────────────────────
const { error: updateError } = await supabase
.from('orders')
.update({ status: 'paid' })
.eq('id', paymentId);
if (updateError) {
console.error('[Payment] Order update error:', updateError.message);
return NextResponse.json({ error: '주문 상태 업데이트 실패' }, { status: 500 });
}
// ── payments 레코드 생성 ──────────────────────────────────
const pgPaymentId = paymentData.pgResponse?.pgTxId ?? paymentData.paymentId ?? paymentId;
const { error: paymentError } = await supabase.from('payments').insert({
user_id: order.user_id,
order_id: paymentId,
product_name: order.metadata?.product_name ?? order.product_id,
amount: order.amount,
status: 'paid',
pg_provider: 'portone_kcp',
pg_payment_key: pgPaymentId,
});
if (paymentError) {
console.error('[Payment] Payment insert error:', paymentError.message);
return NextResponse.json({ error: '결제 내역 저장 실패' }, { status: 500 });
}
return NextResponse.json({
success: true,
data: {
paymentId,
orderName: paymentData.orderName,
amount: paidAmount,
status: paymentData.status,
},
});
} catch (error: unknown) {
console.error('[Payment] Unexpected error:', error);
return NextResponse.json({ error: '서버 오류가 발생했습니다' }, { status: 500 });
}
}

View File

@@ -1,41 +0,0 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
export async function POST(request: Request) {
try {
const body = await request.json();
const { clientName, clientEmail, clientPhone, responses, type } = body;
if (!responses || typeof responses !== 'object') {
return NextResponse.json({ error: '응답 데이터가 없습니다.' }, { status: 400 });
}
if (!clientName || !clientEmail) {
return NextResponse.json({ error: '이름과 이메일은 필수입니다.' }, { status: 400 });
}
const admin = createAdminClient();
const { data, error } = await admin
.from('questionnaire_responses')
.insert({
questionnaire_type: type || 'ebay-tool',
client_name: clientName,
client_email: clientEmail,
client_phone: clientPhone || null,
responses,
status: 'submitted',
})
.select('id')
.single();
if (error) {
console.error('[Questionnaire] DB insert error:', error);
return NextResponse.json({ error: '저장에 실패했습니다.' }, { status: 500 });
}
return NextResponse.json({ success: true, id: data.id });
} catch (err) {
console.error('[Questionnaire] Submit error:', err);
return NextResponse.json({ error: '서버 오류가 발생했습니다.' }, { status: 500 });
}
}

View File

@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { sendQuoteDecisionEmail } from '@/lib/request-emails';
export const runtime = 'nodejs';
@@ -24,31 +25,79 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
return NextResponse.json({ quote: data, expired });
}
// 고객이 견적 수락
// 고객이 견적 수락/거절
export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
const { token } = await params;
const body = await request.json(); // { selectedItems, selectedMaintenance }
const body = await request.json(); // { action?, selectedItems, selectedMaintenance, total }
const action: 'accept' | 'reject' = body.action === 'reject' ? 'reject' : 'accept';
const supabase = createAdminClient();
const { data: quote, error: findErr } = await supabase
.from('quotes')
.select('id, title, client_name, client_email')
.select('id, title, client_name, client_email, status, contact_request_id')
.eq('public_token', token)
.single();
if (findErr || !quote) return NextResponse.json({ error: 'Not found' }, { status: 404 });
// 상태를 accepted로 변경
await supabase
.from('quotes')
.update({
status: 'accepted',
accepted_items: body.selectedItems,
accepted_maintenance: body.selectedMaintenance,
accepted_total: body.total,
updated_at: new Date().toISOString(),
})
.eq('id', quote.id);
// 이미 처리된 견적 중복 처리 방지
if (quote.status === 'accepted' || quote.status === 'rejected') {
return NextResponse.json({ error: '이미 처리된 견적입니다' }, { status: 409 });
}
const now = new Date().toISOString();
if (action === 'accept') {
// 상태를 accepted로 변경 (기존 로직 유지)
await supabase
.from('quotes')
.update({
status: 'accepted',
accepted_items: body.selectedItems,
accepted_maintenance: body.selectedMaintenance,
accepted_total: body.total,
updated_at: now,
})
.eq('id', quote.id);
} else {
// 상태를 rejected로 변경 (accepted_* 미기록)
await supabase
.from('quotes')
.update({
status: 'rejected',
updated_at: now,
})
.eq('id', quote.id);
}
// 연결된 의뢰 상태 동기화 (실패 시 무시)
if (quote.contact_request_id) {
try {
const crStatus = action === 'accept' ? 'accepted' : 'on_hold';
await supabase
.from('contact_requests')
.update({ status: crStatus, updated_at: now })
.eq('id', quote.contact_request_id);
} catch (e) {
console.error('[quote POST] contact_request sync failed:', e);
}
}
// 관리자 알림 메일 (실패 시 무시)
try {
const decision = action === 'accept' ? 'accepted' : 'rejected';
const totalValue = action === 'accept' && typeof body.total === 'number' && Number.isFinite(body.total)
? body.total
: undefined;
await sendQuoteDecisionEmail({
decision,
quoteTitle: quote.title,
clientName: quote.client_name || '고객',
total: totalValue,
});
} catch (e) {
console.error('[quote POST] sendQuoteDecisionEmail failed:', e);
}
return NextResponse.json({ success: true });
}

View File

@@ -64,29 +64,23 @@ const MODELS = [
export async function POST(request: Request) {
try {
// ── 결제 사용자 인증 (Gemini API 무단 호출 방지) ──────────
// ── 로그인 인증 + 서버측 일일 사용량 제한 (Gemini API 무단 호출 방지) ──────────
const { createClient } = await import('@/lib/supabase/server');
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (user) {
// 로그인된 경우: saju_detail 결제 여부 확인
const { data: paidOrder } = await supabase
.from('orders')
.select('id')
.eq('user_id', user.id)
.eq('product_id', 'saju_detail')
.eq('status', 'paid')
.maybeSingle();
if (!paidOrder) {
return NextResponse.json({ error: '사주 리포트를 구매한 사용자만 이용할 수 있습니다' }, { status: 403 });
}
} else {
if (!user) {
// 비로그인 사용자는 AI 호출 불가
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
}
const { createAdminClient } = await import('@/lib/supabase/admin');
const { getTodayUsage, recordUsage, SAJU_DAILY_LIMIT } = await import('@/lib/ai-usage');
const admin = createAdminClient();
if ((await getTodayUsage(admin, user.id, 'saju')) >= SAJU_DAILY_LIMIT) {
return NextResponse.json({ error: `오늘 AI 사주 해석을 모두 사용했습니다. (${SAJU_DAILY_LIMIT}회/일)` }, { status: 429 });
}
// ── 입력 길이 검증 (DoS / 프롬프트 인젝션 기초 방어) ──────
const raw = await request.json();
if (JSON.stringify(raw).length > 50_000) {
@@ -182,6 +176,9 @@ export async function POST(request: Request) {
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
}
// 실제 Gemini 해석 성공 시에만 일일 사용량 카운트 (MOCK 폴백 경로는 카운트하지 않음)
await recordUsage(admin, user.id, 'saju');
return NextResponse.json({ interpretation, analysis });
} catch (error: any) {

View File

@@ -1,41 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
export async function POST(request: NextRequest) {
if (!SAJU_ENGINE_URL) {
return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
}
try {
const body = await request.json();
const response = await fetch(`${SAJU_ENGINE_URL}/saju/lotto`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.detail || '로또 번호 생성 실패' },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error: unknown) {
if (error instanceof Error && error.name === 'TimeoutError') {
return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
}
console.error('로또 번호 생성 프록시 오류:', error);
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
}
}

View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
// Suno webhook 수신용 최소 엔드포인트.
// 트랙 저장은 폴링 + 클라이언트 트리거(/api/studio/tracks)가 담당하므로 여기서는 200만 반환한다.
export async function POST() {
return NextResponse.json({ ok: true });
}

View File

@@ -1,4 +1,7 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
export const runtime = 'nodejs';
@@ -13,6 +16,23 @@ type GenerateBody = {
};
export async function POST(request: Request) {
// 1) 인증 — 로그인 사용자만 (Suno API 무단 호출 방지)
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
}
// 2) 일일 제한
const admin = createAdminClient();
const used = await getTodayUsage(admin, user.id, 'music');
if (used >= MUSIC_DAILY_LIMIT) {
return NextResponse.json(
{ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` },
{ status: 429 }
);
}
const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org';
const apiKey = process.env.SUNO_API_KEY;
@@ -69,6 +89,11 @@ export async function POST(request: Request) {
{ status: res.ok ? 502 : res.status },
);
}
try {
await recordUsage(admin, user.id, 'music');
} catch {
/* 집계 실패는 무시 — 생성은 이미 성공 */
}
return NextResponse.json({ ok: true, data });
} catch (e) {
return NextResponse.json(

View File

@@ -0,0 +1,115 @@
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import {
STORY_SYSTEM_PROMPT,
buildStoryUserMessage,
parseStoryJson,
validateStory,
} from '@/lib/music/story-prompt';
import { config as loadDotenv } from 'dotenv';
import { resolve } from 'path';
export const runtime = 'nodejs';
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
export const maxDuration = 60;
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
// 모델 우선순위 — 사주 analyze·타로 interpret와 동일 폴백 목록(이 API 키로 접근 가능한 모델만)
const MODELS = [
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
] as const;
// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환
const TIME_BUDGET_MS = 45_000;
// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음
const MAX_ATTEMPTS = 3;
export async function POST(request: Request) {
// 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지)
// 일일 사용량 집계·제한은 generate 단계에서만 수행 — story는 가사 초안 생성일 뿐이라 미집계.
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
}
// 2) 입력 검증
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 });
}
const story = typeof body.story === 'string' ? body.story.trim() : '';
if (!story) {
return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
}
// 3) API 키
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.warn('[스튜디오] GEMINI_API_KEY 미설정 — 503 반환 (예시 가사 반환 금지, 데이터 오염 방지)');
return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
}
const genAI = new GoogleGenerativeAI(apiKey);
const userMessage = buildStoryUserMessage(story);
// 4) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll
// wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502)
const startedAt = Date.now();
let feedback = '';
let attempts = 0;
modelLoop:
for (const { id: modelId, maxTokens } of MODELS) {
// retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll
for (let retry = 0; retry < 2; retry += 1) {
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) {
break modelLoop;
}
attempts += 1;
try {
const model = genAI.getGenerativeModel({
model: modelId,
systemInstruction: STORY_SYSTEM_PROMPT,
generationConfig: {
temperature: 0.9,
topP: 0.95,
maxOutputTokens: maxTokens,
},
});
const prompt = feedback
? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.`
: userMessage;
const result = await model.generateContent(prompt);
const text = result.response.text();
const parsed = parseStoryJson(text);
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
if (parsed && !invalid) {
return NextResponse.json({ story: parsed });
}
// 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속)
feedback = invalid ?? 'JSON 파싱 실패';
} catch (modelError) {
// 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백
feedback = modelError instanceof Error ? modelError.message : 'model error';
break;
}
}
}
return NextResponse.json(
{ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' },
{ status: 502 }
);
}

View File

@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
}
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
const admin = createAdminClient();
const { data, error } = await admin.from('music_tracks').insert({
user_id: user.id,
title: str('title'),
story: str('story'),
lyrics: str('lyrics'),
style: str('style'),
audio_url: str('audio_url'),
task_id: str('task_id'),
}).select('id, created_at').single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data);
}
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
// 세션 클라이언트로 본인 것만(RLS music_select_own)
const { data, error } = await supabase
.from('music_tracks')
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ tracks: data ?? [] });
}

View File

@@ -1,87 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
/**
* PATCH /api/subscription/[id]
* action: 'cancel' | 'toggle_autorenew'
*
* cancel — 구독 즉시 해지 (status='cancelled', auto_renew=false)
* toggle_autorenew — 자동갱신 on/off 전환
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
const { id } = await params;
let body: { action?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
}
const { action } = body;
// 본인 구독인지 확인
const { data: sub, error: fetchError } = await supabase
.from('subscriptions')
.select('id, status, auto_renew, expires_at')
.eq('id', id)
.eq('user_id', user.id)
.maybeSingle();
if (fetchError || !sub) {
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
}
if (action === 'cancel') {
if (sub.status === 'cancelled') {
return NextResponse.json({ error: 'ALREADY_CANCELLED' }, { status: 400 });
}
const { error } = await supabase
.from('subscriptions')
.update({
status: 'cancelled',
auto_renew: false,
cancelled_at: new Date().toISOString(),
})
.eq('id', id);
if (error) {
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
}
return NextResponse.json({
ok: true,
message: '구독이 해지되었습니다. 만료일까지는 서비스를 계속 이용할 수 있습니다.',
expires_at: sub.expires_at,
});
}
if (action === 'toggle_autorenew') {
if (sub.status === 'cancelled' || sub.status === 'expired') {
return NextResponse.json({ error: 'SUBSCRIPTION_NOT_ACTIVE' }, { status: 400 });
}
const newValue = !sub.auto_renew;
const { error } = await supabase
.from('subscriptions')
.update({ auto_renew: newValue })
.eq('id', id);
if (error) {
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
}
return NextResponse.json({ ok: true, auto_renew: newValue });
}
return NextResponse.json({ error: 'INVALID_ACTION' }, { status: 400 });
}

View File

@@ -1,31 +0,0 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
/**
* GET /api/subscription
* 내 활성/만료 구독 목록 조회
* - auth 검증은 anon client, DB 조회는 admin client (RLS 우회)
*/
export async function GET() {
const supabase = await createClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
}
// admin client로 RLS 우회 (subscriptions 테이블 SELECT 정책 없을 때도 동작)
const admin = createAdminClient();
const { data, error } = await admin
.from('subscriptions')
.select('id, product_id, status, auto_renew, started_at, expires_at, cancelled_at')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
if (error) {
return NextResponse.json({ error: 'DB_ERROR', detail: error.message }, { status: 500 });
}
return NextResponse.json({ ok: true, subscriptions: data ?? [] });
}

View File

@@ -0,0 +1,133 @@
import { NextResponse } from 'next/server';
import { GoogleGenerativeAI } from '@google/generative-ai';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
import { getTodayUsage, recordUsage, TAROT_DAILY_LIMIT } from '@/lib/ai-usage';
import { TAROT_SYSTEM_PROMPT, buildTarotUserMessage, parseTarotJson, validateTarot } from '@/lib/tarot/prompt';
import { config as loadDotenv } from 'dotenv';
import { resolve } from 'path';
export const runtime = 'nodejs';
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
export const maxDuration = 60;
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
// 모델 우선순위 — 강력한 순서 (이 API 키로 접근 가능한 모델만) — 사주 analyze와 동일 폴백 목록
const MODELS = [
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
] as const;
// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환
const TIME_BUDGET_MS = 45_000;
// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음
const MAX_ATTEMPTS = 3;
export async function POST(request: Request) {
// 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지)
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
}
// 2) 일일 제한
const admin = createAdminClient();
const used = await getTodayUsage(admin, user.id, 'tarot');
if (used >= TAROT_DAILY_LIMIT) {
return NextResponse.json(
{ error: `오늘 타로 AI 해석을 모두 사용했습니다. (${TAROT_DAILY_LIMIT}회/일)` },
{ status: 429 }
);
}
// 3) 입력 검증
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 });
}
const spread_type = typeof body.spread_type === 'string' && body.spread_type ? body.spread_type : 'three_card';
const cards_reference = typeof body.cards_reference === 'string' ? body.cards_reference : '';
if (!cards_reference) {
return NextResponse.json({ error: 'cards_reference가 필요합니다.' }, { status: 400 });
}
const category = typeof body.category === 'string' ? body.category : null;
const question = typeof body.question === 'string' ? body.question : null;
const context_meta = body.context_meta ?? {};
// 4) API 키
const apiKey = process.env.GEMINI_API_KEY;
if (!apiKey) {
console.warn('[타로] GEMINI_API_KEY 미설정 — 503 반환 (예시 해석 반환 금지, 데이터 오염 방지)');
return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
}
const genAI = new GoogleGenerativeAI(apiKey);
const userMessage = buildTarotUserMessage({
spread_type,
category,
question,
cards_reference,
context_meta,
});
// 5) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll
// wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502)
const startedAt = Date.now();
let feedback = '';
let attempts = 0;
modelLoop:
for (const { id: modelId, maxTokens } of MODELS) {
// retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll
for (let retry = 0; retry < 2; retry += 1) {
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) {
break modelLoop;
}
attempts += 1;
try {
const model = genAI.getGenerativeModel({
model: modelId,
systemInstruction: TAROT_SYSTEM_PROMPT,
generationConfig: {
temperature: 0.8,
topP: 0.95,
maxOutputTokens: maxTokens,
},
});
const prompt = feedback
? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.`
: userMessage;
const result = await model.generateContent(prompt);
const text = result.response.text();
const parsed = parseTarotJson(text);
const invalid = parsed ? validateTarot(parsed, spread_type) : 'JSON 파싱 실패';
if (parsed && !invalid) {
await recordUsage(admin, user.id, 'tarot');
return NextResponse.json({ interpretation_json: parsed, model: modelId });
}
// 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속)
feedback = invalid ?? 'JSON 파싱 실패';
} catch (modelError) {
// 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백
feedback = modelError instanceof Error ? modelError.message : 'model error';
break;
}
}
}
return NextResponse.json(
{ error: '해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' },
{ status: 502 }
);
}

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import { createClient } from '@/lib/supabase/server';
import { createAdminClient } from '@/lib/supabase/admin';
export const runtime = 'nodejs';
export async function POST(request: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
let body: Record<string, unknown>;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
}
const interp = body.interpretation_json as { summary?: string } | undefined;
if (!interp) return NextResponse.json({ error: 'interpretation_json 필요' }, { status: 400 });
const admin = createAdminClient();
const { data, error } = await admin.from('tarot_readings').insert({
user_id: user.id,
spread_type: (body.spread_type as string) ?? 'three_card',
category: (body.category as string) ?? null,
question: (body.question as string) ?? null,
cards: body.cards ?? [],
interpretation: interp,
summary: interp.summary ?? null,
}).select('id, created_at').single();
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data);
}
export async function GET() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
// 세션 클라이언트로 본인 것만(RLS tarot_select_own)
const { data, error } = await supabase
.from('tarot_readings')
.select('id, spread_type, category, question, cards, interpretation, summary, created_at')
.order('created_at', { ascending: false });
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json({ readings: data ?? [] });
}

View File

@@ -4,7 +4,11 @@ import { createClient } from '@/lib/supabase/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/mypage';
const rawNext = searchParams.get('next') ?? '/mypage';
const next =
rawNext.startsWith('/') && !rawNext.startsWith('//') && !rawNext.startsWith('/\\')
? rawNext
: '/mypage';
// 리다이렉트 기준 URL 결정
// - dev: 항상 현재 request의 origin (localhost) → NEXT_PUBLIC_SITE_URL 무시

View File

@@ -0,0 +1,374 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
// 계좌이체 구매 모달.
// - 열릴 때 세션 확인 → 미로그인이면 로그인 유도(구매 폼 미노출)
// - 로그인 상태: 입금자명 + 약관 동의 → POST /api/orders
// - 주문 금액은 서버가 DB price로 확정한다. 아래 표시 금액은 안내용일 뿐이다.
// 접근성: role="dialog" aria-modal, Esc/backdrop 닫기, TopNav 드로어 패턴 차용.
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const BANK = { name: '케이뱅크', account: '100-116-337157', holder: '박재오' };
interface Props {
product: { id: string; name: string; price: number };
isOpen: boolean;
onClose: () => void;
}
type AuthState = 'checking' | 'guest' | 'user';
interface SuccessInfo {
orderId: string;
depositorName: string;
reused: boolean;
}
export default function BankTransferModal({ product, isOpen, onClose }: Props) {
const [authState, setAuthState] = useState<AuthState>('checking');
const [depositorName, setDepositorName] = useState('');
const [agreed, setAgreed] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState<SuccessInfo | null>(null);
const closeBtnRef = useRef<HTMLButtonElement>(null);
const priceLabel = `${product.price.toLocaleString('ko-KR')}`;
const loginHref = `/login?next=${encodeURIComponent(`/products/${product.id}`)}`;
// 열릴 때마다 상태 초기화 + 세션 확인
useEffect(() => {
if (!isOpen) return;
let mounted = true;
setAuthState('checking');
setDepositorName('');
setAgreed(false);
setSubmitting(false);
setError('');
setSuccess(null);
const supabase = createClient();
supabase.auth
.getSession()
.then(({ data }) => {
if (mounted) setAuthState(data.session?.user ? 'user' : 'guest');
})
.catch(() => {
if (mounted) setAuthState('guest');
});
return () => {
mounted = false;
};
}, [isOpen]);
// Esc 닫기 + body 스크롤 잠금
useEffect(() => {
if (!isOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
window.removeEventListener('keydown', onKey);
document.body.style.overflow = prevOverflow;
};
}, [isOpen, onClose]);
// 초기 포커스: 모달 열릴 때 닫기 버튼으로 포커스 이동
useEffect(() => {
if (isOpen) closeBtnRef.current?.focus();
}, [isOpen]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const name = depositorName.trim();
if (!name || !agreed || submitting) return;
setSubmitting(true);
setError('');
try {
const res = await fetch('/api/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId: product.id, depositorName: name }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (res.status === 401) {
setSubmitting(false);
setAuthState('guest');
return;
}
setError(data?.error || '주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
setSubmitting(false);
return;
}
setSuccess({
orderId: data.orderId as string,
depositorName: name,
reused: Boolean(data.reused),
});
} catch {
setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
} finally {
setSubmitting(false);
}
},
[depositorName, agreed, submitting, product.id],
);
if (!isOpen) return null;
const canSubmit = depositorName.trim().length > 0 && agreed && !submitting;
return (
<div
className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center p-0 sm:p-4"
style={{ background: 'rgba(15,23,42,0.45)' }}
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label={success ? '주문 접수 완료' : `${product.name} 구매`}
onClick={(e) => e.stopPropagation()}
className="w-full sm:max-w-md max-h-[92vh] overflow-y-auto rounded-t-2xl sm:rounded-2xl shadow-xl"
style={{ background: 'var(--jsm-surface)' }}
>
{/* 헤더 */}
<div
className="sticky top-0 flex items-center justify-between px-6 h-16 border-b"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h2
className="text-base font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{success ? '주문 접수 완료' : '계좌이체 구매'}
</h2>
<button
ref={closeBtnRef}
onClick={onClose}
aria-label="닫기"
className="p-2 -mr-2 rounded-lg transition-colors duration-150"
style={{ color: 'var(--jsm-ink-soft)' }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="px-6 py-6">
{/* 상품 요약 */}
{!success && (
<div
className="rounded-lg border px-4 py-3.5 mb-6 flex items-center justify-between gap-3"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-sm font-semibold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{product.name}
</span>
<span
className="text-base font-bold shrink-0"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{priceLabel}
</span>
</div>
)}
{/* ── 세션 확인 중 ── */}
{authState === 'checking' && !success && (
<div className="flex items-center justify-center py-8">
<div
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
/>
</div>
)}
{/* ── 미로그인 ── */}
{authState === 'guest' && !success && (
<div className="text-center py-2">
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
<Link
href={loginHref}
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
>
</Link>
</div>
)}
{/* ── 로그인 상태: 구매 폼 ── */}
{authState === 'user' && !success && (
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label
htmlFor="depositor-name"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>*</span>
</label>
<input
id="depositor-name"
type="text"
value={depositorName}
onChange={(e) => setDepositorName(e.target.value)}
placeholder="입금하실 분의 성함"
required
maxLength={40}
disabled={submitting}
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
}}
/>
<p className="mt-1.5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
.
</p>
</div>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
checked={agreed}
onChange={(e) => setAgreed(e.target.checked)}
disabled={submitting}
className="mt-0.5 w-4 h-4 shrink-0 accent-[var(--jsm-accent)]"
/>
<span className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<Link
href="/legal/terms"
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
{' '}
<Link
href="/legal/refund"
target="_blank"
rel="noopener noreferrer"
className="underline"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
.
</span>
</label>
{error && (
<div
className="px-3.5 py-3 rounded-lg text-sm break-keep"
style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626', ...KOR_BODY }}
>
{error}
</div>
)}
<button
type="submit"
disabled={!canSubmit}
className="w-full py-3 rounded-lg text-sm font-semibold transition-colors"
style={{
background: canSubmit ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
color: '#ffffff',
cursor: canSubmit ? 'pointer' : 'not-allowed',
...KOR_BODY,
}}
>
{submitting ? '처리 중...' : '주문하기'}
</button>
</form>
)}
{/* ── 성공 화면 ── */}
{success && (
<div>
<p
className="text-lg font-bold mb-2 break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{success.reused ? '이미 접수된 주문이 있습니다' : '주문이 접수되었습니다'}
</p>
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
<dl
className="rounded-lg border divide-y mb-5"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}
>
{[
{ k: '입금 계좌', v: `${BANK.name} ${BANK.account}` },
{ k: '예금주', v: BANK.holder },
{ k: '입금 금액', v: priceLabel },
{ k: '입금자명', v: success.depositorName },
].map((row) => (
<div
key={row.k}
className="flex items-center justify-between gap-3 px-4 py-3"
style={{ borderColor: 'var(--jsm-line)' }}
>
<dt className="text-xs shrink-0" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{row.k}
</dt>
<dd
className="text-sm font-semibold text-right break-all"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
{row.v}
</dd>
</div>
))}
</dl>
<p
className="text-xs leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
>
. 24 .
</p>
<Link
href="/mypage?tab=products"
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
>
</Link>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,175 +0,0 @@
'use client';
import { useState, useEffect, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { trackEvent } from '../../lib/gtag';
function ContactFormInner() {
const searchParams = useSearchParams();
const [formData, setFormData] = useState({
name: '',
phone: '',
email: '',
service: '외주 개발 문의',
message: '',
});
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const serviceParam = searchParams.get('service');
if (serviceParam) {
setFormData((prev) => ({ ...prev, service: serviceParam }));
}
}, [searchParams]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus('loading');
setErrorMessage('');
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || '문의 전송에 실패했습니다.');
setStatus('success');
trackEvent('generate_lead', {
event_category: 'contact',
event_label: formData.service,
});
setFormData({ name: '', phone: '', email: '', service: '외주 개발 문의', message: '' });
setTimeout(() => setStatus('idle'), 5000);
} catch (error) {
setStatus('error');
setErrorMessage(error instanceof Error ? error.message : '문의 전송에 실패했습니다.');
}
};
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
) => {
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<span className="text-red-500">*</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
disabled={status === 'loading'}
placeholder="홍길동"
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"></label>
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
disabled={status === 'loading'}
placeholder="010-0000-0000"
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<span className="text-red-500">*</span>
</label>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
disabled={status === 'loading'}
placeholder="example@email.com"
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5"> </label>
<select
name="service"
value={formData.service}
onChange={handleChange}
disabled={status === 'loading'}
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
>
<option> </option>
<option>AI - </option>
<option> - </option>
<option> - </option>
<option> - / </option>
<option> - </option>
<option> - </option>
<option> - </option>
<option> </option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
<span className="text-red-500">*</span>
</label>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
required
rows={5}
disabled={status === 'loading'}
placeholder="문의하실 내용을 자유롭게 작성해주세요. 프로젝트 목적, 원하시는 기능, 예산 등을 적어주시면 더 정확한 답변이 가능합니다."
className="w-full px-3.5 py-2.5 text-sm border border-slate-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none bg-white disabled:bg-slate-50"
/>
</div>
{status === 'success' && (
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm px-4 py-3 rounded-xl">
! 24 .
</div>
)}
{status === 'error' && (
<div className="bg-red-50 border border-red-200 text-red-800 text-sm px-4 py-3 rounded-xl">
{errorMessage}
</div>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full bg-[#1a56db] hover:bg-[#1e4fc2] text-white py-3 rounded-xl text-sm font-bold transition shadow-lg shadow-blue-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{status === 'loading' ? '전송 중...' : '문의 보내기'}
</button>
<p className="text-slate-400 text-xs text-center">
24 ·
</p>
</form>
);
}
export default function ContactForm() {
return (
<Suspense fallback={<div className="text-slate-400 text-sm"> ...</div>}>
<ContactFormInner />
</Suspense>
);
}

View File

@@ -0,0 +1,626 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
import { trackEvent } from '@/lib/gtag';
// 외주 의뢰용 4단계 폼.
// ① 프로젝트 유형 → ② 예산·일정 → ③ 상세 내용 → ④ 연락처
// 각 단계 검증을 통과해야 다음으로 진행한다. 마지막에 POST /api/contact.
// 마운트 시 로그인 사용자면 이메일을 자동 채운다(수정 가능).
// 기존 ContactForm.tsx는 보존하고, 이 폼이 /outsourcing #contact에서 대체한다.
// 디자인: --jsm-* 토큰만 사용. gradient/blur/보라/이모지 금지.
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const INPUT_STYLE = {
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
} as const;
const PROJECT_TYPES = [
'웹 서비스',
'웹사이트',
'업무 자동화',
'API·백엔드',
'봇 개발',
'AI 연동',
'기타',
] as const;
const BUDGETS = [
'100만원 미만',
'100~300만원',
'300~1,000만원',
'1,000만원 이상',
'미정',
] as const;
const TIMELINES = ['1개월 내', '1~3개월', '3개월 이상', '미정'] as const;
const STEPS = [
{ n: 1, label: '프로젝트 유형' },
{ n: 2, label: '예산·일정' },
{ n: 3, label: '상세 내용' },
{ n: 4, label: '연락처' },
] as const;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
interface SuccessInfo {
trackUrl: string | null;
}
export default function OutsourcingRequestForm() {
const [step, setStep] = useState(1);
const [projectType, setProjectType] = useState('');
const [budget, setBudget] = useState('');
const [timeline, setTimeline] = useState('');
const [message, setMessage] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [phone, setPhone] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState<SuccessInfo | null>(null);
const headingRef = useRef<HTMLElement | null>(null);
const setHeadingRef = useCallback((el: HTMLElement | null) => {
headingRef.current = el;
}, []);
const firstRender = useRef(true);
// 로그인 사용자 이메일 자동 채움 (BankTransferModal 세션 확인 패턴)
useEffect(() => {
let mounted = true;
const supabase = createClient();
supabase.auth
.getUser()
.then(({ data }) => {
const userEmail = data?.user?.email;
if (mounted && userEmail) {
setEmail((prev) => (prev ? prev : userEmail));
}
})
.catch(() => {
/* 비로그인 — 무시 */
});
return () => {
mounted = false;
};
}, []);
// 단계 전환 시 헤딩으로 포커스 이동 (초기 마운트는 제외)
useEffect(() => {
if (firstRender.current) {
firstRender.current = false;
return;
}
headingRef.current?.focus();
}, [step, success]);
const trimmedMessage = message.trim();
const trimmedName = name.trim();
const trimmedEmail = email.trim();
const stepValid = (s: number): boolean => {
switch (s) {
case 1:
return projectType !== '';
case 2:
return budget !== '' && timeline !== '';
case 3:
return trimmedMessage.length >= 10;
case 4:
return trimmedName !== '' && EMAIL_RE.test(trimmedEmail);
default:
return false;
}
};
// 주의: useCallback 금지 — stepValid가 매 렌더 갱신되는 state를 캡처하므로
// 메모이즈하면 스테일 클로저로 항상 초기(빈) 상태 기준 검증이 됨 (실제 운영 버그였음)
const goNext = () => {
if (!stepValid(step)) return;
setError('');
setStep((s) => Math.min(s + 1, STEPS.length));
};
const goPrev = useCallback(() => {
setError('');
setStep((s) => Math.max(s - 1, 1));
}, []);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!stepValid(4) || submitting) return;
setSubmitting(true);
setError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: trimmedName,
phone: phone.trim(),
email: trimmedEmail,
service: `외주 개발 문의 — ${projectType}`,
message: trimmedMessage,
projectType,
budget,
timeline,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(
data?.error || '의뢰 전송 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
);
setSubmitting(false);
return;
}
trackEvent('generate_lead', {
event_category: 'contact',
event_label: `외주 개발 문의 — ${projectType}`,
});
setSuccess({ trackUrl: typeof data?.trackUrl === 'string' ? data.trackUrl : null });
} catch {
setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
setSubmitting(false);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
submitting,
trimmedName,
trimmedEmail,
trimmedMessage,
phone,
projectType,
budget,
timeline,
]
);
// ── 완료 화면 ──────────────────────────────────────────────
if (success) {
return (
<div>
<h3
ref={setHeadingRef}
tabIndex={-1}
className="text-xl font-bold break-keep outline-none"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h3>
<p
className="mt-3 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
2 .
</p>
{success.trackUrl ? (
<div className="mt-7">
<Link
href={success.trackUrl}
className="inline-flex items-center justify-center gap-2 w-full py-3 rounded-lg text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<Arrow />
</Link>
<p
className="mt-3 text-xs leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
</div>
) : null}
</div>
);
}
const isLast = step === STEPS.length;
const canAdvance = stepValid(step);
return (
<div>
{/* 진행 표시기 */}
<ol className="flex items-center gap-2 mb-7" aria-label="진행 단계">
{STEPS.map((s, i) => {
const state =
s.n < step ? 'done' : s.n === step ? 'current' : 'upcoming';
return (
<li key={s.n} className="flex items-center gap-2 min-w-0">
<span
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0 transition-colors"
style={
state === 'upcoming'
? { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }
: { background: 'var(--jsm-accent)', color: '#ffffff' }
}
aria-current={state === 'current' ? 'step' : undefined}
>
{s.n}
</span>
<span
className="text-xs font-semibold truncate hidden sm:inline"
style={{
color:
state === 'upcoming' ? 'var(--jsm-ink-soft)' : 'var(--jsm-ink)',
...KOR_BODY,
}}
>
{s.label}
</span>
{i < STEPS.length - 1 && (
<span
className="w-4 sm:w-6 h-px shrink-0"
style={{ background: 'var(--jsm-line)' }}
aria-hidden
/>
)}
</li>
);
})}
</ol>
<form onSubmit={handleSubmit}>
{/* ── 단계 ① 프로젝트 유형 ── */}
{step === 1 && (
<fieldset>
<legend
ref={setHeadingRef}
tabIndex={-1}
className="text-lg font-bold break-keep outline-none mb-1"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
?
</legend>
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{PROJECT_TYPES.map((t) => {
const selected = projectType === t;
return (
<button
type="button"
key={t}
onClick={() => setProjectType(t)}
aria-pressed={selected}
className="px-4 py-3.5 rounded-lg text-sm font-semibold text-center break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
border: selected
? '1px solid var(--jsm-accent)'
: '1px solid var(--jsm-line)',
background: selected
? 'var(--jsm-accent-soft)'
: 'var(--jsm-surface-alt)',
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
...KOR_BODY,
}}
>
{t}
</button>
);
})}
</div>
</fieldset>
)}
{/* ── 단계 ② 예산·일정 ── */}
{step === 2 && (
<div>
<h3
ref={setHeadingRef}
tabIndex={-1}
className="text-lg font-bold break-keep outline-none mb-1"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h3>
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
<fieldset className="mb-6">
<legend
className="text-sm font-semibold mb-2.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
</legend>
<div className="flex flex-wrap gap-2.5">
{BUDGETS.map((b) => (
<Chip
key={b}
label={b}
selected={budget === b}
onClick={() => setBudget(b)}
/>
))}
</div>
</fieldset>
<fieldset>
<legend
className="text-sm font-semibold mb-2.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
</legend>
<div className="flex flex-wrap gap-2.5">
{TIMELINES.map((t) => (
<Chip
key={t}
label={t}
selected={timeline === t}
onClick={() => setTimeline(t)}
/>
))}
</div>
</fieldset>
</div>
)}
{/* ── 단계 ③ 상세 내용 ── */}
{step === 3 && (
<div>
<h3
ref={setHeadingRef}
tabIndex={-1}
className="text-lg font-bold break-keep outline-none mb-1"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h3>
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. 10 .
</p>
<label htmlFor="req-message" className="sr-only">
</label>
<textarea
id="req-message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={7}
maxLength={5000}
placeholder="만들고 싶은 것, 참고 서비스, 현재 상황을 자유롭게 적어주세요. 기획이 정리되지 않았어도 괜찮습니다."
className="w-full px-3.5 py-3 rounded-lg text-sm leading-relaxed resize-none outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
...INPUT_STYLE,
...KOR_BODY,
}}
/>
<p
className="mt-1.5 text-xs"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{trimmedMessage.length}/10
</p>
</div>
)}
{/* ── 단계 ④ 연락처 ── */}
{step === 4 && (
<div>
<h3
ref={setHeadingRef}
tabIndex={-1}
className="text-lg font-bold break-keep outline-none mb-1"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
?
</h3>
<p
className="text-sm leading-relaxed break-keep mb-5"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
2 .
</p>
<div className="space-y-4">
<div>
<label
htmlFor="req-name"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>*</span>
</label>
<input
id="req-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
required
maxLength={40}
disabled={submitting}
placeholder="홍길동"
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={INPUT_STYLE}
/>
</div>
<div>
<label
htmlFor="req-email"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>*</span>
</label>
<input
id="req-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
maxLength={120}
disabled={submitting}
placeholder="example@email.com"
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={INPUT_STYLE}
/>
</div>
<div>
<label
htmlFor="req-phone"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
</label>
<input
id="req-phone"
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
maxLength={40}
disabled={submitting}
placeholder="010-0000-0000 (선택)"
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={INPUT_STYLE}
/>
</div>
</div>
</div>
)}
{/* 에러 */}
{error && (
<div
className="mt-5 px-3.5 py-3 rounded-lg text-sm break-keep"
style={{
background: '#fef2f2',
border: '1px solid #fecaca',
color: '#dc2626',
...KOR_BODY,
}}
role="alert"
>
{error}
</div>
)}
{/* 내비게이션 */}
<div className="mt-8 flex items-center gap-3">
{step > 1 && (
<button
type="button"
onClick={goPrev}
disabled={submitting}
className="px-5 py-3 rounded-lg text-sm font-semibold border transition-colors hover:bg-[var(--jsm-surface-alt)] disabled:opacity-50 disabled:cursor-not-allowed"
style={{
...INPUT_STYLE,
borderColor: 'var(--jsm-line)',
...KOR_BODY,
}}
>
</button>
)}
{isLast ? (
<button
type="submit"
disabled={!canAdvance || submitting}
className="flex-1 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
style={{
background: !canAdvance || submitting
? 'var(--jsm-line)'
: 'var(--jsm-accent)',
cursor: !canAdvance || submitting ? 'not-allowed' : 'pointer',
...KOR_BODY,
}}
>
{submitting ? '보내는 중...' : '의뢰 보내기'}
</button>
) : (
<button
type="button"
onClick={goNext}
disabled={!canAdvance}
className="flex-1 inline-flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
style={{
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-line)',
cursor: canAdvance ? 'pointer' : 'not-allowed',
...KOR_BODY,
}}
>
<Arrow />
</button>
)}
</div>
</form>
</div>
);
}
// ── 칩 버튼 (예산·일정 단일 선택) ──────────────────────────────
function Chip({
label,
selected,
onClick,
}: {
label: string;
selected: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className="px-4 py-2.5 rounded-lg text-sm font-semibold break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
border: selected ? '1px solid var(--jsm-accent)' : '1px solid var(--jsm-line)',
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
...KOR_BODY,
}}
>
{label}
</button>
);
}
function Arrow() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}

View File

@@ -1,202 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { createClient } from '@/lib/supabase/client';
import { PRODUCTS } from '@/lib/products';
import { getActiveChannels, type PaymentChannel } from '@/lib/payment-channels';
import PortOne from '@portone/browser-sdk/v2';
interface PaymentButtonProps {
productId: string;
className?: string;
style?: React.CSSProperties;
children: React.ReactNode;
returnUrl?: string;
}
export default function PaymentButton({ productId, className, style, children, returnUrl }: PaymentButtonProps) {
const [loading, setLoading] = useState(false);
const [showMethodPicker, setShowMethodPicker] = useState(false);
const router = useRouter();
const supabase = createClient();
const product = PRODUCTS[productId];
const channels = getActiveChannels();
const processPayment = async (channel: PaymentChannel) => {
setShowMethodPicker(false);
setLoading(true);
try {
// 1. 로그인 확인
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login?next=' + encodeURIComponent(window.location.pathname));
return;
}
// 2. 프로필 없으면 생성
await supabase.from('profiles').upsert({ id: user.id, email: user.email }, { onConflict: 'id' });
// 3. Supabase에 order 생성
const paymentId = crypto.randomUUID();
const { error: orderError } = await supabase
.from('orders')
.insert({
id: paymentId,
user_id: user.id,
product_id: productId,
amount: product.price,
status: 'pending',
metadata: { product_name: product.name, pay_channel: channel.id },
});
if (orderError) throw new Error('주문 생성 실패: ' + orderError.message);
// 4. 포트원 V2 결제 요청
const response = await PortOne.requestPayment({
storeId: process.env.NEXT_PUBLIC_PORTONE_STORE_ID ?? '',
channelKey: channel.channelKey,
paymentId,
orderName: product.name,
totalAmount: product.price,
currency: 'CURRENCY_KRW',
payMethod: channel.payMethod,
customer: {
email: user.email ?? undefined,
},
});
// 5. 결제 결과 처리
if (!response || response.code != null) {
if (response?.code === 'FAILURE_TYPE_PG' || response?.message?.includes('cancel')) {
return;
}
throw new Error(response?.message ?? '결제 요청 실패');
}
// 6. 서버에서 결제 검증
const confirmRes = await fetch('/api/payment/confirm', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paymentId }),
});
const confirmData = await confirmRes.json();
if (!confirmRes.ok || !confirmData.success) {
throw new Error(confirmData.error || '결제 검증에 실패했습니다.');
}
// 7. 결제 성공
if (returnUrl) {
router.push(returnUrl);
} else {
router.push(`/payment/success?paymentId=${paymentId}`);
}
} catch (err: unknown) {
const error = err as { code?: string; message?: string };
if (error?.code === 'USER_CANCEL' || error?.message?.includes('cancel')) {
return;
}
alert('결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
console.error(err);
} finally {
setLoading(false);
}
};
const handleClick = () => {
if (channels.length === 0) {
alert('결제 서비스가 준비 중입니다.');
return;
}
// 채널이 1개면 바로 결제, 여러 개면 선택 UI
if (channels.length === 1) {
processPayment(channels[0]);
} else {
setShowMethodPicker(true);
}
};
if (!product) return null;
const isTestMode = !process.env.NEXT_PUBLIC_PORTONE_STORE_ID
|| process.env.NODE_ENV === 'development';
return (
<>
<div style={{ display: style ? 'block' : 'inline-block', position: 'relative' }}>
<button
onClick={handleClick}
disabled={loading}
className={className}
style={style}
>
{loading ? '결제 처리 중...' : children}
</button>
{isTestMode && (
<span style={{
position: 'absolute', top: -8, right: -8,
background: '#f59e0b', color: '#fff',
fontSize: 9, fontWeight: 800, letterSpacing: '0.05em',
padding: '2px 6px', borderRadius: 4,
pointerEvents: 'none', userSelect: 'none',
}}>
TEST
</span>
)}
</div>
{/* 결제수단 선택 모달 */}
{showMethodPicker && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onClick={() => setShowMethodPicker(false)}
>
<div
className="bg-white rounded-2xl shadow-2xl w-full max-w-sm mx-4 overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-[#04102b] px-5 py-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-md bg-[#1a56db] flex items-center justify-center text-white font-bold text-[10px]">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
<button
onClick={() => setShowMethodPicker(false)}
className="text-white/60 hover:text-white transition text-lg leading-none"
>
</button>
</div>
<div className="p-4">
<p className="text-slate-500 text-xs mb-3">
{product.name} · {product.price.toLocaleString()}
</p>
<div className="space-y-2">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => processPayment(channel)}
className="w-full flex items-center gap-3 px-4 py-3.5 rounded-xl border border-slate-200 hover:border-[#1a56db] hover:bg-blue-50/50 transition text-left group"
>
<span className="text-xl">{channel.icon}</span>
<span className="text-sm font-semibold text-slate-700 group-hover:text-[#1a56db]">
{channel.label}
</span>
<svg className="w-4 h-4 text-slate-300 group-hover:text-[#1a56db] ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
))}
</div>
</div>
</div>
</div>
)}
</>
);
}

View File

@@ -7,111 +7,147 @@ export default function PublicShell({ children }: { children: React.ReactNode })
<>
<TopNav />
<main
className="min-h-screen pt-20"
className="min-h-screen pt-16"
style={{
background: 'var(--kx-surface)',
color: 'var(--kx-on-surface)',
background: 'var(--jsm-bg)',
color: 'var(--jsm-ink)',
}}
>
{children}
<footer className="bg-black text-white/70 px-6 lg:px-12 py-14 text-sm border-t border-white/10">
<footer
className="text-white/70 px-6 lg:px-12 py-14 text-sm"
style={{ background: 'var(--jsm-navy)' }}
>
<div className="max-w-7xl mx-auto">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-12 md:gap-8">
{/* 좌 — JSM + social */}
{/* 좌 — JSM + 연락처 */}
<div>
<p
className="kx-display font-bold text-2xl mb-5 text-white tracking-tight"
style={{ letterSpacing: '0.02em' }}
>
JSM
</p>
<div className="flex items-center gap-3">
<a
href="https://www.youtube.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="YouTube"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
<div className="flex items-baseline gap-2 mb-4">
<span
className="font-black text-2xl text-white"
style={{ letterSpacing: '-0.02em' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1c.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.6 15.6V8.4l6.2 3.6-6.2 3.6z" />
</svg>
</a>
<a
href="https://x.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="X (Twitter)"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
</a>
<a
href="https://www.instagram.com/"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
</svg>
</a>
<a
href="mailto:bgg8988@gmail.com"
aria-label="Email"
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
</a>
JSM
</span>
<span className="text-sm text-white/50" style={{ letterSpacing: '-0.01em' }}>
</span>
</div>
<a
href="mailto:bgg8988@gmail.com"
className="flex items-center gap-2 text-white/50 hover:text-white transition-colors duration-150 text-sm"
style={{ letterSpacing: '-0.01em' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
<rect x="3" y="5" width="18" height="14" rx="2" />
<path d="m3 7 9 6 9-6" />
</svg>
bgg8988@gmail.com
</a>
</div>
{/* 우 — Link groups */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-10">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-10">
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">SaaS </p>
<p
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
style={{ fontFamily: 'monospace' }}
>
</p>
<ul className="space-y-2.5">
<li><Link href="/packages" className="hover:text-white transition"> </Link></li>
<li><Link href="/packages" className="hover:text-white transition"> </Link></li>
<li>
<Link
href="/outsourcing"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
<li>
<Link
href="/products"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
</ul>
</div>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">AI </p>
<p
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
style={{ fontFamily: 'monospace' }}
>
</p>
<ul className="space-y-2.5">
<li><Link href="/music/packs" className="hover:text-white transition"> </Link></li>
<li><Link href="/music/samples" className="hover:text-white transition"> </Link></li>
<li><Link href="/music/packs#pricing" className="hover:text-white transition"></Link></li>
<li>
<a
href="mailto:bgg8988@gmail.com"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</a>
</li>
<li>
<Link
href="/outsourcing#process"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
</ul>
</div>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4"> </p>
<p
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
style={{ fontFamily: 'monospace' }}
>
Legal
</p>
<ul className="space-y-2.5">
<li><Link href="/work/freelance" className="hover:text-white transition"> </Link></li>
<li><Link href="/work/website" className="hover:text-white transition"> </Link></li>
<li><Link href="/work/saju" className="hover:text-white transition">AI </Link></li>
<li><a href="mailto:bgg8988@gmail.com" className="hover:text-white transition"></a></li>
</ul>
</div>
<div>
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">Legal</p>
<ul className="space-y-2.5">
<li><Link href="/legal/terms" className="hover:text-white transition"></Link></li>
<li><Link href="/legal/privacy" className="hover:text-white transition"></Link></li>
<li><Link href="/legal/refund" className="hover:text-white transition"> </Link></li>
<li>
<Link
href="/legal/terms"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
<li>
<Link
href="/legal/privacy"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
<li>
<Link
href="/legal/refund"
className="hover:text-white transition-colors duration-150"
style={{ letterSpacing: '-0.01em' }}
>
</Link>
</li>
</ul>
</div>
</div>
</div>
<div className="mt-12 pt-6 border-t border-white/10 flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40 leading-relaxed">
<div
className="mt-12 pt-6 border-t flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40 leading-relaxed"
style={{ borderColor: 'rgba(255,255,255,0.08)' }}
>
<span>대표자: 박재오</span>
<span>사업자등록번호: 267-53-00822</span>
<span> 22 22, 1 109</span>

View File

@@ -7,9 +7,12 @@ import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
const LINKS = [
{ href: '/packages', label: 'SaaS 제품' },
{ href: '/music', label: 'AI 음악' },
{ href: '/work', label: '커스텀 외주' },
{ href: '/outsourcing', label: '외주 개발' },
{ href: '/products', label: '소프트웨어' },
{ href: '/showcase', label: '제작 사례' },
{ href: '/work/saju', label: '사주' },
{ href: '/tarot', label: '타로' },
{ href: '/music', label: '음악' },
];
export default function TopNav() {
@@ -59,6 +62,21 @@ export default function TopNav() {
}
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open]);
// 단일 라이트 팔레트 (전 라우트 동일 — 라우트 분기 제거)
const ink = 'var(--jsm-ink)';
const inkSoft = 'var(--jsm-ink-soft)';
const surface = 'var(--jsm-surface)';
const line = 'var(--jsm-line)';
const accent = 'var(--jsm-accent)';
const accentBg = 'var(--jsm-accent-soft)';
const isActive = (href: string) => {
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(href + '/');
@@ -67,187 +85,224 @@ export default function TopNav() {
return (
<>
<header
className={[
'fixed left-1/2 -translate-x-1/2 z-50 w-full border-b border-transparent',
'md:rounded-full md:border transition-all duration-300 ease-out',
scrolled
? 'top-4 max-w-3xl md:shadow-[0_10px_40px_rgba(0,0,0,0.35)] md:border-white/10'
: 'top-0 max-w-none',
].join(' ')}
className="fixed top-0 left-0 right-0 z-50 w-full transition-all duration-300"
style={{
background: scrolled ? 'rgba(10,10,12,0.6)' : 'transparent',
backdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
WebkitBackdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
background: scrolled ? 'var(--jsm-surface)' : 'transparent',
borderBottom: scrolled
? `1px solid ${line}`
: '1px solid transparent',
boxShadow: scrolled ? '0 1px 8px rgba(15,23,42,0.06)' : 'none',
}}
>
<nav
className={[
'flex w-full items-center justify-between transition-all duration-300 ease-out',
scrolled ? 'h-14 px-4 md:px-3' : 'h-20 px-6 lg:px-12',
].join(' ')}
>
<Link
href="/"
className="kx-display text-2xl font-black tracking-tight kx-gradient-text"
style={{ textDecoration: 'none', letterSpacing: '0.02em' }}
>
JSM
</Link>
<div className="hidden md:flex items-center gap-8">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="text-sm font-medium transition-colors"
style={{
color: isActive(l.href) ? '#fff' : 'var(--kx-on-variant)',
borderBottom: isActive(l.href) ? '2px solid var(--kx-primary)' : '2px solid transparent',
paddingBottom: 4,
textDecoration: 'none',
}}
>
{l.label}
</Link>
))}
</div>
<div className="flex items-center gap-3">
{user ? (
<>
<Link
href="/mypage"
className="hidden sm:inline-block text-sm font-medium px-4 py-2 transition-colors"
style={{ color: 'var(--kx-on-variant)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary hidden sm:inline-flex items-center px-5 py-2 rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
<button
onClick={handleLogout}
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium transition-colors"
style={{ color: 'var(--kx-on-variant)', background: 'transparent' }}
>
</button>
</>
) : (
<>
<Link
href="/login"
className="hidden sm:inline-block text-sm font-medium px-4 py-2 transition-colors"
style={{ color: 'var(--kx-on-variant)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary hidden sm:inline-flex items-center px-5 py-2 rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
</>
)}
<button
onClick={() => setOpen(true)}
aria-label="메뉴 열기"
className="md:hidden p-2 rounded-lg"
style={{ color: 'var(--kx-on-surface)' }}
<nav className="max-w-7xl mx-auto flex w-full items-center justify-between h-16 px-6 lg:px-8">
{/* 로고 */}
<Link
href="/"
className="flex items-baseline gap-2"
style={{ textDecoration: 'none' }}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</nav>
</header>
{/* 모바일 오버레이 */}
{open && (
<div
className="fixed inset-0 z-[60] md:hidden flex flex-col"
style={{ background: 'rgba(6,14,32,0.98)', backdropFilter: 'blur(16px)' }}
>
<div className="flex items-center justify-between px-6 h-20">
<span className="kx-display text-2xl font-black kx-gradient-text" style={{ letterSpacing: '0.02em' }}>JSM</span>
<button
onClick={() => setOpen(false)}
aria-label="메뉴 닫기"
className="p-2"
style={{ color: 'var(--kx-on-surface)' }}
<span
className="text-xl font-black tracking-tight"
style={{ color: ink, letterSpacing: '-0.02em' }}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="flex-1 flex flex-col gap-2 px-6 pt-6">
JSM
</span>
<span
className="hidden sm:inline text-sm font-medium"
style={{ color: inkSoft, letterSpacing: '-0.01em' }}
>
</span>
</Link>
{/* 데스크탑 링크 */}
<div className="hidden md:flex items-center gap-1">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="kx-display text-2xl font-bold py-3"
className="text-sm font-medium px-4 py-2 rounded-md transition-colors duration-150"
style={{
color: isActive(l.href) ? 'var(--kx-primary)' : 'var(--kx-on-surface)',
color: isActive(l.href) ? accent : inkSoft,
background: isActive(l.href) ? accentBg : 'transparent',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
{l.label}
</Link>
))}
<div className="mt-6 flex flex-col gap-2">
</div>
{/* 데스크탑 CTA + auth */}
<div className="flex items-center gap-2">
{user ? (
<>
<Link
href="/mypage"
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
<button
onClick={handleLogout}
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150"
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
>
</button>
</>
) : (
<Link
href="/login"
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
)}
<Link
href="/outsourcing#contact"
className="hidden sm:inline-flex items-center px-4 py-2 rounded-lg text-sm font-semibold transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
style={{
background: 'var(--jsm-accent)',
color: '#ffffff',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
</Link>
{/* 모바일 햄버거 */}
<button
onClick={() => setOpen(true)}
aria-label="메뉴 열기"
aria-expanded={open}
className="md:hidden p-2 rounded-lg transition-colors duration-150"
style={{ color: ink }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
</div>
</nav>
</header>
{/* 모바일 드로어 */}
{open && (
<div
className="fixed inset-0 z-[60] md:hidden"
style={{ background: 'rgba(15,23,42,0.4)' }}
onClick={() => setOpen(false)}
>
<div
className="absolute top-0 right-0 h-full w-72 flex flex-col shadow-xl"
style={{ background: surface }}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="메뉴"
>
{/* 드로어 헤더 */}
<div
className="flex items-center justify-between px-6 h-16 border-b"
style={{ borderColor: line }}
>
<div className="flex items-baseline gap-2">
<span
className="text-lg font-black tracking-tight"
style={{ color: ink, letterSpacing: '-0.02em' }}
>
JSM
</span>
<span
className="text-xs font-medium"
style={{ color: inkSoft }}
>
</span>
</div>
<button
onClick={() => setOpen(false)}
aria-label="메뉴 닫기"
className="p-2 rounded-lg transition-colors duration-150"
style={{ color: inkSoft }}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* 드로어 링크 */}
<div className="flex-1 flex flex-col px-4 pt-4 gap-1">
{LINKS.map((l) => (
<Link
key={l.href}
href={l.href}
className="text-base font-semibold px-3 py-3 rounded-lg transition-colors duration-150"
style={{
color: isActive(l.href) ? accent : ink,
background: isActive(l.href) ? accentBg : 'transparent',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
{l.label}
</Link>
))}
<div
className="my-4 border-t"
style={{ borderColor: line }}
/>
{user ? (
<>
<div className="flex gap-3">
<Link
href="/mypage"
className="flex-1 py-3 text-center rounded-full text-sm font-bold"
style={{ border: '1px solid rgba(255,255,255,0.15)', color: 'var(--kx-on-surface)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary flex-1 py-3 text-center rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
</div>
<Link
href="/mypage"
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
<button
onClick={handleLogout}
className="w-full py-3 text-center text-sm font-medium transition-colors"
style={{ color: 'var(--kx-on-variant)', background: 'transparent' }}
className="text-left text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
>
</button>
</>
) : (
<div className="flex gap-3">
<Link
href="/login"
className="flex-1 py-3 text-center rounded-full text-sm font-bold"
style={{ border: '1px solid rgba(255,255,255,0.15)', color: 'var(--kx-on-surface)', textDecoration: 'none' }}
>
</Link>
<Link
href="/music"
className="kx-btn-primary flex-1 py-3 text-center rounded-full text-sm"
style={{ textDecoration: 'none' }}
>
Try now
</Link>
</div>
<Link
href="/login"
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
>
</Link>
)}
</div>
{/* 드로어 하단 CTA */}
<div className="px-4 pb-6">
<Link
href="/outsourcing#contact"
className="flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors duration-150"
style={{
background: 'var(--jsm-accent)',
color: '#ffffff',
textDecoration: 'none',
letterSpacing: '-0.01em',
}}
>
</Link>
</div>
</div>
</div>
)}

View File

@@ -0,0 +1,76 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface Props {
/** 카운트업 목표 숫자 */
to: number;
/** 숫자 앞에 붙는 고정 텍스트 (예: 없음) */
prefix?: string;
/** 숫자 뒤에 붙는 고정 텍스트 (예: '+') */
suffix?: string;
/** 애니메이션 길이(ms) — 기본 600 */
duration?: number;
className?: string;
}
/**
* IntersectionObserver 진입 시 0 → to 로 카운트업.
* prefers-reduced-motion이면 즉시 최종값 표시(연출 생략).
* transform/opacity가 아닌 textContent 변경이라 레이아웃 안정 위해 tabular-nums 권장.
*/
export default function CountUp({ to, prefix = '', suffix = '', duration = 600, className }: Props) {
const ref = useRef<HTMLSpanElement>(null);
const [value, setValue] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
let rafId = 0;
let started = false;
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const run = () => {
// reduced-motion: 즉시 최종값 (연출 생략)
if (reduced) {
setValue(to);
return;
}
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
// easeOutCubic — 끝에서 부드럽게 안착
const eased = 1 - Math.pow(1 - t, 3);
setValue(Math.round(eased * to));
if (t < 1) rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && !started) {
started = true;
run();
io.disconnect();
}
},
{ threshold: 0.4 },
);
io.observe(el);
return () => {
io.disconnect();
if (rafId) cancelAnimationFrame(rafId);
};
}, [to, duration]);
return (
<span ref={ref} className={className} style={{ fontVariantNumeric: 'tabular-nums' }}>
{prefix}
{value.toLocaleString('ko-KR')}
{suffix}
</span>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import { useEffect, useRef, useState } from 'react';
interface Props {
children: React.ReactNode;
/** 등장 지연(ms) — 연속 항목 스태거용 */
delay?: number;
/** 'fade-up'(기본) | 'fade' | 'draw'(선 그리기용 — width 확장) */
variant?: 'fade-up' | 'fade' | 'draw';
className?: string;
}
export default function ScrollReveal({ children, delay = 0, variant = 'fade-up', className }: Props) {
const ref = useRef<HTMLDivElement>(null);
const [shown, setShown] = useState(false);
// reduced-motion: transition까지 생략하고 정적으로 표시
const [instant, setInstant] = useState(false);
useEffect(() => {
// reduced-motion: 즉시 표시 (연출·전환 생략)
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setInstant(true);
setShown(true);
return;
}
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setShown(true);
io.disconnect();
}
},
{ threshold: 0.2 },
);
io.observe(el);
return () => io.disconnect();
}, []);
const hidden =
variant === 'fade' ? 'opacity-0' :
variant === 'draw' ? 'opacity-0 [transform:scaleX(0)] origin-left' :
'opacity-0 translate-y-6';
const visible =
variant === 'draw' ? 'opacity-100 [transform:scaleX(1)]' :
variant === 'fade' ? 'opacity-100' :
'opacity-100 translate-y-0';
// reduced-motion이면 transition/transform 없이 정적 표시
if (instant) {
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
return (
<div
ref={ref}
className={`${className ?? ''} transition-all duration-700 ease-out ${shown ? visible : hidden}`}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import Link from 'next/link';
import type { ShowcaseSlot } from '@/lib/showcase';
import MockWindow from '@/app/components/mock/MockWindow';
import { MOCK_REGISTRY } from '@/app/components/mock/registry';
interface Props {
slot: ShowcaseSlot;
size?: 'feature' | 'standard';
index: number;
}
// 라이트 쇼케이스 카드 — surface-alt 스테이지 위에 흰 MockWindow가 떠 있는 "framed screen".
// 서버 컴포넌트 (캔버스/시드/그래디언트 전량 제거).
export default function ShowcaseCard({ slot, size = 'standard' }: Props) {
const Mock = MOCK_REGISTRY[slot.mock];
const isFeature = size === 'feature';
const isLink = Boolean(slot.href);
const body = (
<div
className={[
'group/card flex h-full flex-col rounded-2xl border p-5 lg:p-6',
'transition-[transform,box-shadow,border-color] duration-300',
'[transition-timing-function:cubic-bezier(0.16,1,0.3,1)]',
'motion-safe:hover:-translate-y-1 hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)]',
isLink ? 'cursor-pointer' : '',
].join(' ')}
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<MockWindow title={`${slot.slug}.app`} className="group-hover/card:border-[var(--jsm-accent-soft)]">
<Mock />
</MockWindow>
<div className="mt-5">
<span
className="font-mono text-[11px] uppercase tracking-[0.18em]"
style={{ color: 'var(--jsm-accent)' }}
>
{slot.label}
</span>
<h3
className={[
'mt-1.5 font-bold [word-break:keep-all]',
isFeature ? 'text-xl' : 'text-lg',
].join(' ')}
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
>
{slot.title}
</h3>
<p
className="mt-1.5 text-sm leading-relaxed [word-break:keep-all]"
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
>
{slot.desc}
</p>
{isLink && (
<span
className="mt-3 inline-flex items-center gap-1.5 text-[13px] font-semibold transition-transform duration-300 group-hover/card:translate-x-1"
style={{ color: 'var(--jsm-accent)' }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
<path
d="M5 12h14M13 6l6 6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
)}
</div>
</div>
);
if (isLink) {
return (
<Link href={slot.href!} aria-label={slot.title} className="block h-full">
{body}
</Link>
);
}
return body;
}

View File

@@ -0,0 +1,61 @@
import type { ShowcaseSlot } from '@/lib/showcase';
import ScrollReveal from './ScrollReveal';
import ShowcaseCard from './ShowcaseCard';
interface Props {
slots: ShowcaseSlot[];
variant: 'home' | 'full';
}
/**
* home: 6슬롯 지그재그 — wide(col-span-2) 3장 + standard 3장 = 9셀(3×3 완전 충전)
* row1: [0 feature span2][1 std]
* row2: [2 std][3 feature span2]
* row3: [4 feature span2][5 std]
* 모바일은 1col 전부 standard.
* full: 8슬롯 데스크톱 2col 균등(standard), 모바일 1col.
*/
export default function ShowcaseGrid({ slots, variant }: Props) {
if (variant === 'full') {
return (
<div className="grid grid-cols-1 gap-5 md:grid-cols-2 md:gap-6">
{slots.slice(0, 8).map((slot, i) => (
<ScrollReveal key={slot.slug} delay={i * 80}>
<ShowcaseCard slot={slot} size="standard" index={i} />
</ScrollReveal>
))}
</div>
);
}
// home — 6슬롯 (3col 그리드)
const items = slots.slice(0, 6);
// 데스크톱 흐름 (3col) — wide(span-2) 3장 + standard 3장 = 9셀, 빈 칸 없음
// row1: [0 feature span2 좌][1 std 우] → 2+1 = 3
// row2: [2 std 좌][3 feature span2 우] → 1+2 = 3
// row3: [4 feature span2 좌][5 std 우] → 2+1 = 3
// 자동 흐름(auto-placement)이 위 순서를 보장하므로 col-start 불필요.
const layout: Array<{ span: string; size: 'feature' | 'standard' }> = [
{ span: 'md:col-span-2', size: 'feature' }, // 0 — row1 좌 와이드
{ span: 'md:col-span-1', size: 'standard' }, // 1 — row1 우 1칸
{ span: 'md:col-span-1', size: 'standard' }, // 2 — row2 좌 1칸
{ span: 'md:col-span-2', size: 'feature' }, // 3 — row2 우 와이드
{ span: 'md:col-span-2', size: 'feature' }, // 4 — row3 좌 와이드
{ span: 'md:col-span-1', size: 'standard' }, // 5 — row3 우 1칸
];
return (
<div className="grid grid-cols-1 gap-5 md:grid-cols-3 md:gap-6">
{items.map((slot, i) => {
const cfg = layout[i] ?? { span: 'md:col-span-1', size: 'standard' as const };
return (
<ScrollReveal key={slot.slug} delay={i * 80} className={cfg.span}>
<ShowcaseCard slot={slot} size={cfg.size} index={i} />
</ScrollReveal>
);
})}
</div>
);
}

View File

@@ -0,0 +1,51 @@
// 라이트 UI 목업의 공용 크롬 프레임 (서버 컴포넌트).
// 실데이터 없이 "운영 중인 화면" 인상을 주는 craft 요소. --jsm-* 토큰만 사용.
import type { ReactNode } from 'react';
interface MockWindowProps {
/** 타이틀바 텍스트 — 파일/서비스명 느낌 (예: 'stock-report', 'realestate-match') */
title: string;
children: ReactNode;
className?: string;
}
export default function MockWindow({ title, children, className }: MockWindowProps) {
return (
<div
className={`overflow-hidden rounded-xl border shadow-[0_24px_60px_-30px_rgba(15,23,42,0.35)] ${className ?? ''}`}
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
{/* 타이틀바 — 신호등 + 모노 파일명 + 라이브 점 */}
<div
className="flex items-center gap-2 border-b px-3.5 py-2.5"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<span className="flex gap-1.5" aria-hidden>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#cbd5e1' }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#d8e0ea' }} />
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#e2e8f0' }} />
</span>
<span
className="ml-1 font-mono text-[11px]"
style={{ color: 'var(--jsm-ink-faint)', letterSpacing: '-0.01em' }}
>
{title}
</span>
<span className="ml-auto flex items-center gap-1.5" aria-hidden>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ background: 'var(--jsm-accent)' }}
/>
<span
className="font-mono text-[10px] uppercase tracking-[0.14em]"
style={{ color: 'var(--jsm-ink-faint)' }}
>
live
</span>
</span>
</div>
{/* 본문 슬롯 */}
<div className="p-4">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
// 목업 키 — JSX를 끌어오지 않는 순수 모듈 (vitest/showcase가 안전하게 참조).
export type MockKey =
| 'dashboard'
| 'feed'
| 'match'
| 'commerce'
| 'site'
| 'booking';
export const MOCK_KEYS: MockKey[] = [
'dashboard',
'feed',
'match',
'commerce',
'site',
'booking',
];

View File

@@ -0,0 +1,24 @@
// 목업 스크린 레지스트리 — showcase 슬롯의 mock 키를 컴포넌트로 해석.
import type { ComponentType } from 'react';
import type { MockKey } from './keys';
import {
DashboardMock,
FeedMock,
MatchMock,
CommerceMock,
SiteMock,
BookingMock,
} from './screens';
export type { MockKey } from './keys';
export { MOCK_KEYS } from './keys';
export const MOCK_REGISTRY: Record<MockKey, ComponentType> = {
dashboard: DashboardMock,
feed: FeedMock,
match: MatchMock,
commerce: CommerceMock,
site: SiteMock,
booking: BookingMock,
};

View File

@@ -0,0 +1,250 @@
// 라이트 UI 목업 스크린 6종 (서버 컴포넌트, props 없음, 정적 마크업).
// MockWindow 본문에 들어가 "운영 중인 화면" 인상을 만든다. 실데이터 0, --jsm-* 만.
const ACCENT = 'var(--jsm-accent)';
const INK = 'var(--jsm-ink)';
const SOFT = 'var(--jsm-ink-soft)';
const FAINT = 'var(--jsm-ink-faint)';
const LINE = 'var(--jsm-line)';
const ALT = 'var(--jsm-surface-alt)';
const SOFTBG = 'var(--jsm-accent-soft)';
/** 1. 대시보드 — 주식 리포트 톤: 스탯 3 + 막대 차트 */
export function DashboardMock() {
const bars = [38, 54, 30, 62, 46, 72, 58];
return (
<div className="space-y-3.5">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-lg p-2.5" style={{ background: ALT }}>
<p className="font-mono text-[10px]" style={{ color: FAINT }}>
</p>
<p className="mt-1 text-sm font-bold" style={{ color: ACCENT, letterSpacing: '-0.02em' }}>
+2.4%
</p>
</div>
<div className="rounded-lg p-2.5" style={{ background: ALT }}>
<p className="font-mono text-[10px]" style={{ color: FAINT }}>
</p>
<p className="mt-1 text-sm font-bold" style={{ color: INK, letterSpacing: '-0.02em' }}>
12
</p>
</div>
<div className="rounded-lg p-2.5" style={{ background: ALT }}>
<p className="font-mono text-[10px]" style={{ color: FAINT }}>
</p>
<p className="mt-1 text-sm font-bold" style={{ color: INK, letterSpacing: '-0.02em' }}>
68%
</p>
</div>
</div>
<div
className="flex h-20 items-end gap-1.5 rounded-lg border p-2.5"
style={{ borderColor: LINE }}
>
{bars.map((h, i) => (
<span
key={i}
className="flex-1 rounded-sm"
style={{
height: `${h}%`,
background: i === 5 ? ACCENT : '#dbe3ee',
}}
/>
))}
</div>
</div>
);
}
/** 2. 피드 — 텔레그램 봇 톤: 메시지 버블 3 */
export function FeedMock() {
const rows = [
{ t: '09:01', m: '매수 체결 · 삼성전자 12,400', tag: '체결', on: true },
{ t: '11:24', m: '목표가 도달 — 익절 알림', tag: '알림', on: false },
{ t: '15:30', m: '일일 손익 리포트 전송 완료', tag: '리포트', on: false },
];
return (
<div className="space-y-2">
{rows.map((r) => (
<div
key={r.t}
className="flex items-start gap-2.5 rounded-lg p-2.5"
style={{ background: ALT }}
>
<span className="mt-0.5 font-mono text-[10px]" style={{ color: FAINT }}>
{r.t}
</span>
<p className="flex-1 text-[12px] leading-snug" style={{ color: INK, letterSpacing: '-0.01em' }}>
{r.m}
</p>
<span
className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold"
style={
r.on
? { color: ACCENT, background: SOFTBG }
: { color: SOFT, background: 'var(--jsm-surface)' }
}
>
{r.tag}
</span>
</div>
))}
</div>
);
}
/** 3. 매칭 — 부동산 청약 톤: 필터칩 + 매칭률 리스트 3 */
export function MatchMock() {
const chips = ['강남구', '85㎡↑', '신축'];
const rows = [
{ n: '래미안 원베일리', s: '92%' },
{ n: '디에이치 퍼스티어', s: '88%' },
{ n: '아크로 포레스트', s: '81%' },
];
return (
<div className="space-y-3">
<div className="flex gap-1.5">
{chips.map((c, i) => (
<span
key={c}
className="rounded-full px-2.5 py-1 text-[11px] font-medium"
style={
i === 0
? { color: ACCENT, background: SOFTBG }
: { color: SOFT, background: ALT }
}
>
{c}
</span>
))}
</div>
<div className="space-y-2">
{rows.map((r) => (
<div
key={r.n}
className="flex items-center justify-between rounded-lg border px-3 py-2.5"
style={{ borderColor: LINE }}
>
<span className="text-[12px] font-medium" style={{ color: INK, letterSpacing: '-0.01em' }}>
{r.n}
</span>
<span
className="rounded px-1.5 py-0.5 font-mono text-[11px] font-bold"
style={{ color: ACCENT, background: SOFTBG }}
>
{r.s}
</span>
</div>
))}
</div>
</div>
);
}
/** 4. 커머스 — 상품 그리드 4 + 장바구니 바 */
export function CommerceMock() {
const items = [
{ p: '₩28,000' },
{ p: '₩45,000' },
{ p: '₩19,000' },
{ p: '₩36,000' },
];
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2">
{items.map((it, i) => (
<div key={i} className="rounded-lg border p-2" style={{ borderColor: LINE }}>
<div className="h-9 rounded-md" style={{ background: ALT }} />
<p className="mt-1.5 text-[11px] font-bold" style={{ color: INK, letterSpacing: '-0.02em' }}>
{it.p}
</p>
</div>
))}
</div>
<div
className="flex items-center justify-between rounded-lg px-3 py-2.5"
style={{ background: INK }}
>
<span className="text-[11px] font-medium text-white/80"> 3 · 128,000</span>
<span
className="rounded px-2 py-1 text-[11px] font-semibold"
style={{ background: ACCENT, color: '#fff' }}
>
</span>
</div>
</div>
);
}
/** 5. 사이트 — 기업/포트폴리오 와이어: 네비 + 헤드라인 + 카드 3 */
export function SiteMock() {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="h-2.5 w-2.5 rounded-full" style={{ background: ACCENT }} />
<div className="flex gap-3">
<span className="h-1.5 w-6 rounded-full" style={{ background: LINE }} />
<span className="h-1.5 w-6 rounded-full" style={{ background: LINE }} />
<span className="h-1.5 w-6 rounded-full" style={{ background: LINE }} />
</div>
<span className="h-4 w-10 rounded" style={{ background: ALT }} />
</div>
<div className="space-y-1.5 py-1">
<span className="block h-3 w-3/4 rounded" style={{ background: '#cbd5e1' }} />
<span className="block h-3 w-1/2 rounded" style={{ background: ACCENT }} />
</div>
<div className="grid grid-cols-3 gap-2">
{[0, 1, 2].map((i) => (
<div key={i} className="rounded-lg border p-2" style={{ borderColor: LINE }}>
<div className="h-6 rounded" style={{ background: ALT }} />
<span className="mt-1.5 block h-1.5 w-full rounded-full" style={{ background: LINE }} />
</div>
))}
</div>
</div>
);
}
/** 6. 예약 — 로컬 매장 톤: 주간 캘린더 + 슬롯 그리드 */
export function BookingMock() {
const days = ['월', '화', '수', '목', '금', '토', '일'];
// 0=빈 1=예약됨(accent) 2=불가(alt)
const slots = [
1, 0, 0, 1, 0, 2, 2,
0, 1, 0, 0, 1, 1, 2,
0, 0, 1, 0, 0, 1, 0,
];
return (
<div className="space-y-2.5">
<div className="grid grid-cols-7 gap-1.5">
{days.map((d) => (
<span key={d} className="text-center font-mono text-[10px]" style={{ color: FAINT }}>
{d}
</span>
))}
</div>
<div className="grid grid-cols-7 gap-1.5">
{slots.map((s, i) => (
<span
key={i}
className="aspect-square rounded"
style={{
background: s === 1 ? ACCENT : s === 2 ? ALT : 'var(--jsm-surface)',
boxShadow: s === 0 ? `inset 0 0 0 1px ${LINE}` : undefined,
}}
/>
))}
</div>
<div
className="rounded-lg py-2 text-center text-[11px] font-semibold"
style={{ background: SOFTBG, color: ACCENT }}
>
· 19:00
</div>
</div>
);
}

View File

@@ -36,19 +36,32 @@
--card-bg: #ffffff;
--border: #dbe8ff;
/* ─── Kinetic Ether Tokens (다크 테마 섹션 전용) ─── */
--kx-surface: #060e20;
--kx-surface-low: #091328;
--kx-surface-mid: #0f1930;
--kx-surface-high: #141f38;
--kx-surface-bright: #1f2b49;
--kx-on-surface: #dee5ff;
--kx-on-variant: #a3aac4;
--kx-primary: #cc97ff;
--kx-primary-dim: #9c48ea;
--kx-secondary: #53ddfc;
--kx-secondary-dim: #40ceed;
--kx-outline: rgba(64, 72, 93, 0.15);
/* === JSM Professional tokens (2026-06 renewal) === */
--jsm-bg: #f8fafc; /* slate-50 본문 배경 */
--jsm-surface: #ffffff; /* 카드 */
--jsm-surface-alt: #f1f5f9; /* slate-100 섹션 교차 배경 */
--jsm-ink: #0f172a; /* slate-900 본문 텍스트 */
--jsm-ink-soft: #475569; /* slate-600 보조 텍스트 */
--jsm-ink-faint: #94a3b8; /* slate-400 캡션 */
--jsm-line: #e2e8f0; /* slate-200 보더 */
--jsm-navy: #0b1f3a; /* 딥네이비 — 푸터/다크 섹션 */
--jsm-accent: #1d4ed8; /* blue-700 포인트 (단일 포인트 컬러) */
--jsm-accent-hover: #1e40af; /* blue-800 */
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
/* 기존 kx 변수 재매핑 (레거시·숨김 라우트 /packages·/work·/music 호환용) */
--kx-surface: var(--jsm-bg);
--kx-surface-low: var(--jsm-surface-alt);
--kx-surface-mid: var(--jsm-surface);
--kx-surface-high: var(--jsm-surface);
--kx-surface-bright: var(--jsm-surface-alt);
--kx-on-surface: var(--jsm-ink);
--kx-on-variant: var(--jsm-ink-soft);
--kx-primary: var(--jsm-accent);
--kx-primary-dim: var(--jsm-accent-hover);
--kx-secondary: var(--jsm-accent);
--kx-secondary-dim: var(--jsm-accent-hover);
--kx-outline: var(--jsm-line);
}
@theme inline {
@@ -56,8 +69,8 @@
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--font-sans: var(--font-jua), 'Jua', -apple-system, system-ui, sans-serif;
--font-mono: var(--font-jua), 'Jua', ui-monospace, monospace;
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'Pretendard Variable', Pretendard, ui-monospace, monospace;
}
* {
@@ -69,16 +82,16 @@ html {
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-jua), 'Jua', -apple-system, system-ui, sans-serif;
background: var(--jsm-bg);
color: var(--jsm-ink);
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* font-mono utility → Jua 통일 */
/* font-mono utility → Pretendard 통일 */
.font-mono, code, pre, kbd, samp {
font-family: var(--font-jua), 'Jua', ui-monospace, monospace;
font-family: 'Pretendard Variable', Pretendard, ui-monospace, monospace;
}
/* Dashboard layout */
@@ -86,7 +99,7 @@ body {
display: flex;
height: 100dvh;
overflow: hidden;
background: var(--background);
background: var(--jsm-bg);
}
.main-content {
@@ -107,18 +120,19 @@ body {
.kx-section {
background: var(--kx-surface);
color: var(--kx-on-surface);
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
}
.kx-section p, .kx-section li, .kx-section span:not(.kx-label) {
color: var(--kx-on-variant);
}
.kx-display {
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
letter-spacing: -0.01em;
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
color: inherit;
}
.kx-label {
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
@@ -147,15 +161,14 @@ body {
0 0 80px 0 rgba(83, 221, 252, 0.08);
}
.kx-btn-primary {
background: linear-gradient(135deg, #cc97ff 0%, #c284ff 100%);
color: #0b0113;
background: var(--jsm-accent);
color: #ffffff;
font-weight: 700;
box-shadow: 0 0 20px 0 rgba(168, 85, 247, 0.4);
transition: transform 0.15s ease, box-shadow 0.15s ease;
transition: background 0.15s ease, transform 0.15s ease;
}
.kx-btn-primary:hover {
background: var(--jsm-accent-hover);
transform: translateY(-1px);
box-shadow: 0 0 28px 0 rgba(168, 85, 247, 0.55);
}
.kx-btn-ghost {
color: var(--kx-secondary);
@@ -166,10 +179,8 @@ body {
background: var(--kx-surface-bright);
}
.kx-gradient-text {
background: linear-gradient(135deg, #cc97ff 0%, #53ddfc 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--jsm-ink);
-webkit-text-fill-color: var(--jsm-ink);
}
.kx-orb {
position: absolute;

View File

@@ -1,4 +1,6 @@
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
import { isServiceVisible } from '@/lib/service-visibility';
export const metadata: Metadata = {
title: 'CONTOUR — 나를 더 선명하게 이해하는 3분',
@@ -22,7 +24,8 @@ export const metadata: Metadata = {
},
};
export default function GyeolLayout({ children }: { children: React.ReactNode }) {
export default async function GyeolLayout({ children }: { children: React.ReactNode }) {
if (!(await isServiceVisible('gyeol'))) notFound();
return (
<div
className="min-h-screen"

View File

@@ -1,33 +1,23 @@
import type { Metadata } from "next";
import Script from "next/script";
import { Jua } from "next/font/google";
import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
import "./globals.css";
import DashboardShell from "./components/DashboardShell";
import { GlassFilter } from "./components/LiquidGlass";
const jua = Jua({
weight: "400",
subsets: ["latin"],
variable: "--font-jua",
display: "swap",
});
export const metadata: Metadata = {
title: {
default: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
default: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
template: "%s | 쟁승메이드",
},
description:
"Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 팩. AI로 음악과 뮤비를 1시간 만에 완성하는 4단계 크리에이터 툴킷. ₩39,000부터.",
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
keywords: [
"AI 음악",
"AI 작곡",
"Suno 프롬프트",
"AI 뮤직비디오",
"유튜브 쇼츠 음악",
"AI 뮤비",
"음악 프롬프트",
"AI 사주",
"외주 개발",
"소프트웨어 개발",
"웹사이트 제작",
"업무 자동화",
"백엔드 개발자",
"프리랜서 개발자",
],
authors: [{ name: "박재오", url: "https://jaengseung-made.com" }],
creator: "박재오",
@@ -36,22 +26,23 @@ export const metadata: Metadata = {
locale: "ko_KR",
url: "https://jaengseung-made.com",
siteName: "쟁승메이드",
title: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
description:
"Suno 프롬프트 + 뮤비 워크플로우 + 유튜브 SEO 템플릿 팩. AI로 음악·뮤비를 1시간에 완성하는 4단계 크리에이터 툴킷.",
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
images: [
{
url: "https://jaengseung-made.com/og-image.png",
width: 1200,
height: 630,
alt: "쟁승메이드 — AI 프롬프트 · 자동화 스토어",
alt: "쟁승메이드 — 외주 개발 · 완성 소프트웨어",
},
],
},
twitter: {
card: "summary_large_image",
title: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
description: "AI로 음악과 뮤비를 1시간 만에. Suno 프롬프트 + 뮤비 워크플로우 + 유튜브 SEO 템플릿.",
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
description:
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
},
robots: {
index: true,
@@ -68,40 +59,35 @@ const jsonLd = {
'@id': 'https://jaengseung-made.com/#person',
name: '박재오',
url: 'https://jaengseung-made.com',
jobTitle: '백엔드 개발자 · AI 자동화 전문가',
worksFor: { '@type': 'Organization', name: '대기업 재직 중' },
jobTitle: '소프트웨어 엔지니어',
email: 'bgg8988@gmail.com',
telephone: '010-3907-1392',
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', 'AI 프롬프트', 'AI 자동화', '업무 자동화', 'ChatGPT', 'Claude'],
description: '현직 엔지니어. AI 음악 생성 개발 가이드 패키지, AI 사주 분석 등 AI 크리에이티브 도구를 직접 개발·운영합니다.',
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', '외주 개발', '웹사이트 제작', '업무 자동화', 'API 설계'],
description: '24시간 돌아가는 실서비스를 직접 설계·운영합니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공합니다.',
},
{
'@type': 'LocalBusiness',
'@id': 'https://jaengseung-made.com/#business',
name: '쟁승메이드',
url: 'https://jaengseung-made.com',
description: 'AI 음악 생성 개발 가이드 패키지, AI 사주 분석. 현직 엔지니어가 직접 설계·운영하는 AI 크리에이티브 스토어.',
description: '24시간 돌아가는 실서비스를 직접 설계·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
email: 'bgg8988@gmail.com',
telephone: '010-3907-1392',
priceRange: '₩',
areaServed: '대한민국',
hasOfferCatalog: {
'@type': 'OfferCatalog',
name: '쟁승메이드 AI 도구 · 서비스',
name: '쟁승메이드 개발 서비스',
itemListElement: [
{ '@type': 'Offer', price: '39000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (입문)', url: 'https://jaengseung-made.com/music/packs', description: 'Suno 프롬프트 조합법 + MV 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트. AI 음악 생성 개발 가이드 (1회 결제).' } },
{ '@type': 'Offer', price: '99000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (프로)', url: 'https://jaengseung-made.com/music/packs', description: '입문 전체 + 샘플 프로젝트 1개(.prj · 영상 포함). 1회 결제.' } },
{ '@type': 'Offer', price: '149000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (마스터)', url: 'https://jaengseung-made.com/music/packs', description: '프로 전체 + 샘플 다수 + 우선 업데이트·베타 선공개. 1회 결제.' } },
{ '@type': 'Offer', price: '0', priceCurrency: 'KRW', url: 'https://jaengseung-made.com/work/saju', itemOffered: { '@type': 'Service', name: 'AI 사주 분석', url: 'https://jaengseung-made.com/work/saju', description: '생년월일 기반 AI 사주팔자 분석. 무료 체험 가능.' } },
{
'@type': 'Offer',
url: 'https://jaengseung-made.com/work/freelance',
url: 'https://jaengseung-made.com/outsourcing',
availability: 'https://schema.org/InStock',
itemOffered: {
'@type': 'Service',
name: '외주 개발',
url: 'https://jaengseung-made.com/work/freelance',
description: '7년차 백엔드 개발자의 1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
url: 'https://jaengseung-made.com/outsourcing',
description: '1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
serviceType: 'Custom Software Development',
provider: { '@id': 'https://jaengseung-made.com/#business' },
areaServed: '대한민국',
@@ -109,12 +95,12 @@ const jsonLd = {
},
{
'@type': 'Offer',
url: 'https://jaengseung-made.com/work/website',
url: 'https://jaengseung-made.com/outsourcing',
availability: 'https://schema.org/InStock',
itemOffered: {
'@type': 'Service',
name: '웹사이트 제작',
url: 'https://jaengseung-made.com/work/website',
url: 'https://jaengseung-made.com/outsourcing',
description: 'Next.js 기반 기업·브랜드 웹사이트 맞춤 제작. 반응형 + SEO + 배포 포함.',
serviceType: 'Web Development',
provider: { '@id': 'https://jaengseung-made.com/#business' },
@@ -133,7 +119,7 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="ko" data-scroll-behavior="smooth" className={jua.variable}>
<html lang="ko" data-scroll-behavior="smooth">
<head>
<script
type="application/ld+json"
@@ -156,7 +142,6 @@ export default function RootLayout({
</Script>
</head>
<body className="antialiased">
<GlassFilter />
<DashboardShell>{children}</DashboardShell>
</body>
</html>

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
export default function PrivacyPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"></h1>
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}></h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<p>

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
export default function RefundPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"> </h1>
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}> </h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<p>

View File

@@ -8,7 +8,7 @@ export const metadata: Metadata = {
export default function TermsPage() {
return (
<div className="max-w-3xl mx-auto px-6 py-12">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8"></h1>
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}></h1>
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
<section>

View File

@@ -6,6 +6,13 @@ import Link from 'next/link';
import { createClient } from '@/lib/supabase/client';
import { Suspense } from 'react';
// next 파라미터가 안전한 내부 경로(`/`로 시작, `//`·`/\` 프로토콜-상대 아님)일 때만 허용.
function safeNext(raw: string | null): string {
if (!raw) return '/mypage';
if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return '/mypage';
return raw;
}
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
@@ -15,6 +22,7 @@ function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const supabase = createClient();
const next = safeNext(searchParams.get('next'));
useEffect(() => {
if (searchParams.get('error')) {
@@ -22,7 +30,7 @@ function LoginForm() {
}
// 이미 로그인된 경우 리다이렉트
supabase.auth.getUser().then(({ data }) => {
if (data.user) router.push('/mypage');
if (data.user) router.push(next);
});
}, []);
@@ -52,7 +60,7 @@ function LoginForm() {
if (error) {
setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
} else {
router.push('/mypage');
router.push(next);
router.refresh();
}
}
@@ -66,124 +74,86 @@ function LoginForm() {
process.env.NODE_ENV === 'development'
? window.location.origin
: (process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin);
// next는 /auth/callback에서 read해 로그인 후 목적지로 리다이렉트 (기본 /mypage)
const callbackUrl = `${base}/auth/callback?next=${encodeURIComponent(next)}`;
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${base}/auth/callback` },
options: { redirectTo: callbackUrl },
});
if (error) setMessage('Google 로그인 오류: ' + error.message);
};
return (
<div className="min-h-screen bg-[#04102b] flex items-center justify-center p-4">
{/* 배경 장식 */}
<div
className="absolute inset-0 pointer-events-none"
style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}
/>
const isSuccess =
message.includes('완료') || message.includes('확인해주세요');
<div className="relative w-full max-w-md">
{/* 로고 */}
return (
<div
className="min-h-screen flex items-center justify-center px-4 py-12"
style={{ background: 'var(--jsm-bg)' }}
>
<div className="w-full max-w-sm">
{/* 워드마크 */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3 group">
<div className="w-12 h-12 rounded-xl bg-[#1a56db] flex items-center justify-center text-white font-bold text-xl">
</div>
<div className="text-left">
<div className="text-white font-bold text-xl leading-tight"></div>
<div className="text-blue-400 text-xs font-medium"> </div>
</div>
<Link
href="/"
className="inline-block"
style={{
fontWeight: 800,
fontSize: '1.375rem',
letterSpacing: '-0.03em',
color: 'var(--jsm-ink)',
transition: 'color 0.15s',
}}
>
</Link>
<p
className="mt-2 text-sm break-keep"
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
>
{isSignUp
? '가입 후 의뢰 현황과 구매 내역을 관리하세요'
: '로그인하고 의뢰 현황과 구매 내역을 확인하세요'}
</p>
</div>
{/* 카드 */}
<div className="bg-white/5 border border-white/10 backdrop-blur rounded-2xl p-8 shadow-2xl">
<div className="text-center mb-7">
<h1 className="text-2xl font-extrabold text-white mb-1">
{isSignUp ? '회원가입' : '로그인'}
</h1>
<p className="text-blue-300/60 text-sm">
{isSignUp
? '가입 후 사주 기록, 결제 내역을 관리하세요'
: '사주 기록·결제·의뢰 내역을 확인하세요'}
</p>
</div>
{/* 오류/성공 메시지 */}
{message && (
<div className={`mb-4 px-4 py-3 rounded-xl text-sm font-medium ${
message.includes('완료') || message.includes('확인해주세요')
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-300'
: 'bg-red-500/10 border border-red-500/30 text-red-300'
}`}>
{message}
</div>
)}
{/* 이메일/비밀번호 폼 */}
<form onSubmit={handleAuth} className="space-y-4 mb-5">
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<div>
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
</label>
<input
type="password"
placeholder="6자 이상"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-[#1a56db] hover:bg-[#1e4fc2] text-white font-bold py-3 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
</button>
</form>
{/* 전환 링크 */}
<div className="text-center mb-5">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
className="text-sm text-blue-400 hover:text-blue-300 transition"
>
{isSignUp ? '이미 계정이 있으신가요? 로그인 →' : '아직 계정이 없으신가요? 회원가입 →'}
</button>
</div>
{/* 구분선 */}
<div className="relative mb-5">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<div className="relative flex justify-center">
<span className="px-3 bg-transparent text-slate-500 text-xs"> </span>
</div>
</div>
{/* 구글 로그인 */}
<button
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition text-white font-medium text-sm"
<div
className="rounded-xl p-8"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
boxShadow: '0 1px 4px 0 rgba(15,23,42,0.06), 0 4px 16px 0 rgba(15,23,42,0.04)',
}}
>
{/* 카드 헤더 */}
<h1
className="text-xl font-bold mb-6 text-center"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
>
<svg className="w-5 h-5" viewBox="0 0 24 24">
{isSignUp ? '회원가입' : '로그인'}
</h1>
{/* Google 로그인 */}
<button
type="button"
onClick={handleGoogleLogin}
className="w-full flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-lg text-sm font-medium mb-5"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
transition: 'background 0.15s, border-color 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-surface-alt)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-surface)';
}}
>
{/* Google G 로고 */}
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" aria-hidden>
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
@@ -191,12 +161,169 @@ function LoginForm() {
</svg>
Google로
</button>
{/* 구분선 */}
<div className="relative mb-5">
<div
className="absolute inset-0 flex items-center"
aria-hidden
>
<div className="w-full" style={{ borderTop: '1px solid var(--jsm-line)' }} />
</div>
<div className="relative flex justify-center">
<span
className="px-3 text-xs"
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-faint)' }}
>
</span>
</div>
</div>
{/* 오류/성공 메시지 */}
{message && (
<div
className="mb-4 px-3.5 py-3 rounded-lg text-sm"
style={{
background: isSuccess ? '#f0fdf4' : '#fef2f2',
border: `1px solid ${isSuccess ? '#bbf7d0' : '#fecaca'}`,
color: isSuccess ? '#15803d' : '#dc2626',
}}
>
{message}
</div>
)}
{/* 이메일/비밀번호 폼 */}
<form onSubmit={handleAuth} className="space-y-4">
<div>
<label
htmlFor="login-email"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.01em' }}
>
</label>
<input
id="login-email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
transition: 'border-color 0.15s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-accent)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-line)';
}}
/>
</div>
<div>
<label
htmlFor="login-password"
className="block text-sm font-medium mb-1.5"
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.01em' }}
>
</label>
<input
id="login-password"
type="password"
placeholder="6자 이상 입력해주세요"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
background: 'var(--jsm-surface)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
transition: 'border-color 0.15s',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-accent)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'var(--jsm-line)';
}}
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 rounded-lg text-sm font-semibold mt-1"
style={{
background: loading ? 'var(--jsm-ink-faint)' : 'var(--jsm-accent)',
color: loading ? '#ffffff' : '#ffffff',
border: 'none',
cursor: loading ? 'not-allowed' : 'pointer',
transition: 'background 0.15s, transform 0.15s',
letterSpacing: '-0.01em',
}}
onMouseEnter={(e) => {
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-accent-hover)';
}}
onMouseLeave={(e) => {
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-accent)';
}}
>
{loading ? '처리 중...' : isSignUp ? '가입하기' : '로그인'}
</button>
</form>
{/* 가입/로그인 전환 */}
<div className="mt-5 text-center">
<button
type="button"
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
className="text-sm"
style={{
color: 'var(--jsm-accent)',
background: 'none',
border: 'none',
cursor: 'pointer',
letterSpacing: '-0.01em',
transition: 'color 0.15s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--jsm-accent-hover)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.color = 'var(--jsm-accent)';
}}
>
{isSignUp
? '이미 계정이 있으신가요? 로그인'
: '계정이 없으신가요? 회원가입'}
</button>
</div>
</div>
{/* 홈으로 */}
<div className="text-center mt-6">
<Link href="/" className="text-slate-500 hover:text-slate-300 text-sm transition">
<Link
href="/"
className="text-sm"
style={{ color: 'var(--jsm-ink-faint)', transition: 'color 0.15s', letterSpacing: '-0.01em' }}
onMouseEnter={(e) => {
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--jsm-ink-soft)';
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--jsm-ink-faint)';
}}
>
</Link>
</div>
</div>
@@ -207,8 +334,14 @@ function LoginForm() {
export default function LoginPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-[#04102b] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<div
className="min-h-screen flex items-center justify-center"
style={{ background: 'var(--jsm-bg)' }}
>
<div
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
/>
</div>
}>
<LoginForm />

View File

@@ -1,8 +1,8 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'AI 음악 제품',
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.',
title: '나의 이야기를 음악으로',
description: '당신의 이야기를 AI가 가사와 음악으로. 스토리를 들려주면 나만의 노래가 완성됩니다. 로그인 무료.',
};
export default function MusicLayout({ children }: { children: React.ReactNode }) {

View File

@@ -1,28 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'AI 음악 생성 개발 가이드 패키지 | Suno · MV · 유튜브 쇼츠',
description:
'엔지니어가 설계한 AI 음악 생성 개발 가이드. Suno 프롬프트 조합법 + MV 비디오 생성 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트. 1회 결제 · 입문 ₩39k / 프로 ₩99k / 마스터 ₩149k.',
keywords: [
'AI 음악 만들기',
'Suno 프롬프트',
'AI 뮤직비디오',
'AI 커버곡',
'유튜브 쇼츠 음악',
'AI 작곡',
'크리에이터 이코노미',
'Lyria 프롬프트',
'Runway AI 비디오',
],
openGraph: {
title: 'AI 음악 생성 개발 가이드 패키지 | 쟁승메이드',
description:
'네 사연을 노래로. 쇼츠까지 한 번에. AI 음악 생성 개발 가이드 · Suno Pro 검증 · 평생 업데이트.',
url: 'https://jaengseung-made.com/music/packs',
},
};
export default function MusicPacksLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,301 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import PurchaseAgreementModal from '@/app/components/PurchaseAgreementModal';
import { SparklesOverlay } from '@/components/ui/sparkles-text';
import { CardBody, CardContainer, CardItem } from '@/components/ui/3d-card-effect';
type Tier = 'starter' | 'pro' | 'master';
const TIERS: Record<Tier, { name: string; price: string; priceNum: string; desc: string; features: string[]; highlight?: boolean }> = {
starter: {
name: '입문',
price: '₩39,000',
priceNum: '39,000',
desc: 'AI 음악 생성을 처음 시작하는 개발 가이드',
features: [
'Suno 프롬프트 조합법 20종',
'구조 템플릿 PDF 40p',
'저작권 가이드 기본판',
'12개월 무료 업데이트',
],
},
pro: {
name: '프로',
price: '₩99,000',
priceNum: '99,000',
desc: '쇼츠 업로드까지 반복 가능한 워크플로우 가이드',
highlight: true,
features: [
'입문 전체 포함',
'MV 워크플로우 (Runway · Luma · Pika)',
'샘플 프로젝트 1개 (.prj · 영상)',
'1:1 Q&A 1회 + 유튜브 SEO 템플릿',
],
},
master: {
name: '마스터',
price: '₩149,000',
priceNum: '149,000',
desc: '여러 장르·포맷을 커버하는 마스터 가이드',
features: [
'프로 전체 포함',
'샘플 프로젝트 장르별 3종',
'저작권 심화판 + 상업 이용 체크리스트',
'우선 업데이트 · 제작 레시피 영상',
],
},
};
const PROCESS = [
{ num: '01', subtitle: 'Concept & Lyrics', title: '크리에이티브 디렉팅', result: 'AI 최적화 가사 · 메타데이터 시트' },
{ num: '02', subtitle: 'Music Generation', title: '오디오 엔지니어링', result: '고품질 완곡 (Full Track, 스템 분리본)' },
{ num: '03', subtitle: 'AI MV Generation', title: '비주얼 마스터링', result: '쇼츠(9:16) 또는 유튜브(16:9) 고화질 영상' },
{ num: '04', subtitle: 'Viral Optimization', title: '퍼블리싱 가이드', result: '즉시 업로드 가능한 유튜브 배포 패키지' },
];
const FAQS = [
{
q: 'Suno 유료 플랜 가입이 꼭 필요한가요?',
a: 'Suno 무료 플랜은 상업적 이용이 제한됩니다. 본인 결과물을 유튜브·SNS에 업로드해 수익화하려면 Suno Pro 이상 권장. 팩 구매 후 가입 전 플랜 선택 가이드가 포함됩니다.',
},
{
q: '제가 만든 결과물의 상업 이용·저작권은?',
a: '결과물의 상업권은 고객이 가입한 AI 서비스의 이용약관을 따릅니다. 팩에는 Suno·Runway·Luma 각 서비스의 최신 약관 요약과 상업 이용 체크리스트가 포함되어 있습니다. (법률 자문이 아닌 참고용 가이드입니다.)',
},
{
q: '결과물 품질을 보장하나요?',
a: 'AI 생성물은 모델 버전·프롬프트 입력에 따라 달라지므로 결과물 자체를 보장하지 않습니다. 다만 팩은 동일 프롬프트로 반복 가능한 고품질 구간을 설계하는 방법을 제공합니다. 샘플 쇼츠·프로젝트로 품질 기대치를 사전 확인하세요.',
},
{
q: '환불이 가능한가요?',
a: '전자상거래법 제17조 제2항 제5호에 따라 디지털 콘텐츠는 제공 시작 후 청약철회가 제한됩니다. 무료 샘플로 사전 확인을 제공하므로 충분히 검토 후 구매해주세요. 파일 손상·전달 불량 등 회사 귀책은 즉시 재전달 또는 환불됩니다.',
},
{
q: '업데이트는 어떻게 받나요?',
a: '구매자 전용 Notion 페이지에서 변경 이력과 최신 파일을 제공. 12개월간 무료 업데이트가 기본, 마스터는 우선 업데이트·베타 선공개가 포함됩니다.',
},
];
export default function MusicServicePage() {
const [selectedTier, setSelectedTier] = useState<Tier | null>(null);
const [openFaq, setOpenFaq] = useState<number | null>(0);
return (
<div className="min-h-full bg-black text-white">
{/* PRICING */}
<section id="pricing" className="px-6 py-14 lg:px-14 bg-black">
<div className="max-w-6xl mx-auto">
<div className="flex items-end justify-between flex-wrap gap-3 mb-8">
<div>
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-1">Pricing · 1 </p>
<h2 className="text-2xl md:text-3xl font-extrabold">3 , </h2>
</div>
<Link href="/music/samples" className="text-sm text-white/80 hover:text-white underline underline-offset-4">
</Link>
</div>
<div className="grid md:grid-cols-3 gap-5 items-stretch">
{(Object.keys(TIERS) as Tier[]).map((key) => {
const t = TIERS[key];
return (
<CardContainer key={key} containerClassName="w-full py-0" className="w-full h-full">
<CardBody
className={`relative w-full h-full rounded-2xl p-8 flex flex-col border transition-all ${
t.highlight
? 'border-white bg-white text-black md:scale-[1.03] md:-translate-y-2'
: 'border-white/15 bg-white/[0.02] hover:border-white/40 text-white'
}`}
>
{t.highlight && (
<SparklesOverlay
sparklesCount={20}
colors={{ first: '#9E7AFF', second: '#FE8BBB' }}
className="rounded-2xl"
/>
)}
{t.highlight && (
<CardItem translateZ={60} className="absolute -top-3 left-1/2 -translate-x-1/2 z-20">
<span className="inline-flex items-center bg-black text-white text-[10px] font-extrabold px-3 py-1.5 rounded-full uppercase tracking-wider border border-white">
</span>
</CardItem>
)}
<CardItem translateZ={40} as="h3" className="font-extrabold text-2xl mb-1 relative z-10">
{t.name}
</CardItem>
<CardItem translateZ={20} as="p" className={`text-sm mb-6 ${t.highlight ? 'text-black/60' : 'text-white/60'}`}>
{t.desc}
</CardItem>
<CardItem translateZ={50} className="mb-6">
<span className="text-4xl font-extrabold font-mono">{t.price}</span>
<span className={`text-xs ml-2 ${t.highlight ? 'text-black/50' : 'text-white/50'}`}>1 </span>
</CardItem>
<CardItem
translateZ={20}
as="ul"
className={`space-y-3 text-sm mb-8 flex-1 ${t.highlight ? 'text-black/80' : 'text-white/80'}`}
>
{t.features.map((f) => (
<li key={f} className="flex gap-2.5">
<span className="flex-shrink-0 mt-0.5">·</span>
<span className="leading-relaxed">{f}</span>
</li>
))}
</CardItem>
<CardItem
translateZ={40}
as="button"
onClick={() => setSelectedTier(key)}
className={`w-full py-4 rounded-xl font-extrabold text-sm transition-colors ${
t.highlight
? 'bg-black hover:bg-black/85 text-white'
: 'bg-white/10 hover:bg-white/20 text-white border border-white/20'
}`}
>
{t.name}
</CardItem>
</CardBody>
</CardContainer>
);
})}
</div>
<p className="text-xs text-white/50 text-center mt-8">
<Link href="/legal/refund" className="underline hover:text-white"> </Link> .
.
</p>
</div>
</section>
{/* 팩 구성품 */}
<section className="px-6 py-16 lg:px-14 bg-black border-t border-white/10">
<div className="max-w-6xl mx-auto">
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-2">What&apos;s Included</p>
<h2 className="text-2xl md:text-3xl font-extrabold mb-8"> </h2>
<div className="grid md:grid-cols-2 gap-4">
{[
{ title: 'Suno 프롬프트 북', desc: '장르·무드·보컬 톤 조합법 20+종. 복붙해서 바로 사용하는 PDF.' },
{ title: 'MV 워크플로우', desc: 'Midjourney·Runway·Luma로 비트 싱크 영상 만드는 단계별 가이드.' },
{ title: '저작권 & 상업 이용', desc: 'Suno·Runway 약관 요약 + 수익화 전 안전 체크리스트.' },
{ title: '샘플 프로젝트 파일', desc: '완성된 가사·프롬프트·영상 세트. 그대로 수정해 재사용 가능.' },
].map((item) => (
<div
key={item.title}
className="p-6 rounded-2xl border border-white/15 bg-white/[0.02]"
>
<h3 className="font-bold text-white mb-1">{item.title}</h3>
<p className="text-sm text-white/60 leading-relaxed">{item.desc}</p>
</div>
))}
</div>
</div>
</section>
{/* PROCESS */}
<section className="px-6 py-16 lg:px-14 bg-black border-t border-white/10">
<div className="max-w-6xl mx-auto">
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-2">Process</p>
<h2 className="text-2xl md:text-3xl font-extrabold mb-10" style={{ wordBreak: 'keep-all' }}>
</h2>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
{PROCESS.map((step) => (
<div
key={step.num}
className="rounded-2xl p-6 border border-white/15 bg-white/[0.02] hover:border-white/40 transition-colors"
>
<p className="font-mono text-xs text-white/50 mb-3">{step.num}</p>
<p className="font-mono text-[10px] text-white/50 uppercase tracking-widest mb-1">
{step.subtitle}
</p>
<h3 className="text-lg font-extrabold text-white mb-2">{step.title}</h3>
<p className="text-sm text-white/60 leading-relaxed" style={{ wordBreak: 'keep-all' }}>
{step.result}
</p>
</div>
))}
</div>
</div>
</section>
{/* SAMPLES */}
<section id="samples" className="px-6 py-12 lg:px-14 bg-black border-t border-white/10">
<div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div>
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-1">Samples</p>
<h2 className="text-xl md:text-2xl font-extrabold"> </h2>
</div>
<Link
href="/music/samples"
className="inline-flex items-center px-6 py-3 rounded-xl border border-white/30 hover:bg-white hover:text-black text-sm font-semibold text-white transition whitespace-nowrap"
>
</Link>
</div>
</section>
{/* FAQ */}
<section className="px-6 py-20 lg:px-14 bg-black border-t border-white/10">
<div className="max-w-3xl mx-auto">
<h2 className="text-2xl md:text-3xl font-extrabold text-center mb-10">
</h2>
<div className="space-y-3">
{FAQS.map((f, i) => (
<div key={i} className="border border-white/15 rounded-2xl overflow-hidden bg-white/[0.02]">
<button
onClick={() => setOpenFaq(openFaq === i ? null : i)}
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-white/5 transition-colors"
>
<span className="font-bold text-white text-sm">{f.q}</span>
<span className={`text-white text-xl transition-transform ${openFaq === i ? 'rotate-45' : ''}`}>
+
</span>
</button>
{openFaq === i && (
<div className="px-5 pb-5 text-sm text-white/70 leading-relaxed" style={{ wordBreak: 'keep-all' }}>
{f.a}
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Sticky CTA */}
<div
className="fixed bottom-0 inset-x-0 z-40 border-t border-white/15 backdrop-blur-md"
style={{ background: 'rgba(0,0,0,0.85)' }}
>
<div className="max-w-6xl mx-auto px-5 py-3 flex items-center justify-between gap-4">
<div className="min-w-0">
<p className="text-[11px] font-mono text-white/50 tracking-widest uppercase">From</p>
<p className="text-white font-extrabold text-lg leading-tight">
39,000 <span className="text-xs text-white/50 font-medium">· 1 </span>
</p>
</div>
<a
href="#pricing"
className="inline-flex items-center bg-white hover:bg-white/90 text-black px-6 py-3 rounded-xl font-extrabold text-sm transition-colors whitespace-nowrap"
>
</a>
</div>
</div>
<div className="h-20" aria-hidden />
{selectedTier && (
<PurchaseAgreementModal
isOpen={!!selectedTier}
onClose={() => setSelectedTier(null)}
productName={`AI 음악 생성 개발 가이드 · ${TIERS[selectedTier].name}`}
price={TIERS[selectedTier].price}
/>
)}
</div>
);
}

View File

@@ -2,67 +2,60 @@ import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Music — AI 음악 제품',
title: '음악 — 나의 이야기를 음악으로',
};
const CARDS = [
{
href: '/music/packs',
label: '팩 상세',
desc: '입문 ₩39,000부터 — Suno 프롬프트북 + 뮤비 워크플로우 + SEO 템플릿',
key: 'packs',
href: '/music/studio',
label: 'AI 스튜디오',
desc: '스토리를 입력하면 가사·음악을 자동 생성 — 로그인 무료',
key: 'studio',
},
{
href: '/music/samples',
label: '샘플 갤러리',
desc: '실제 결과물 — 장르별 데모 + 가사 + 영상 미리보기',
desc: '실제 결과물 — 장르별 데모 가사',
key: 'samples',
},
{
href: '/music/studio',
label: 'AI 스튜디오',
desc: 'Suno API 연동 — 직접 트랙 생성 (베타)',
key: 'studio',
},
];
export default function MusicHub() {
return (
<div className="min-h-screen bg-black text-white">
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10">
<div className="absolute inset-0 bg-gradient-to-b from-[#060e20] to-black pointer-events-none" />
<div className="min-h-screen bg-[var(--jsm-bg)]">
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 bg-[var(--jsm-navy)]">
<div className="relative z-10 max-w-3xl mx-auto text-center">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
<p className="font-mono text-[11px] tracking-widest uppercase text-[var(--jsm-accent-soft)] mb-4">
Music
</p>
<h1
className="kx-display text-4xl md:text-6xl font-bold mb-5"
className="kx-display text-4xl md:text-6xl font-bold mb-5 text-white"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
AI
</h1>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
Suno + + SEO 릿. 4 1 .
AI가 . .
</p>
</div>
</section>
<section className="py-20 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
{CARDS.map((c) => (
<Link
key={c.key}
href={c.href}
className="group rounded-2xl border border-white/15 bg-white/[0.02] p-7 hover:border-white/40 hover:bg-white/[0.05] transition flex flex-col"
className="rounded-2xl border border-[var(--jsm-line)] bg-[var(--jsm-surface)] p-7 transition-colors hover:border-[var(--jsm-accent)] hover:bg-[var(--jsm-surface-alt)] flex flex-col"
style={{ textDecoration: 'none' }}
>
<h2 className="kx-display text-xl md:text-2xl font-bold text-white mb-3">
<h2 className="kx-display text-xl md:text-2xl font-bold text-[var(--jsm-ink)] mb-3">
{c.label}
</h2>
<p className="text-sm md:text-base text-white/60 leading-relaxed flex-1">
<p className="text-sm md:text-base text-[var(--jsm-ink-soft)] leading-relaxed flex-1">
{c.desc}
</p>
<span aria-hidden="true" className="mt-4 text-white/40 text-xs"></span>
<span aria-hidden="true" className="mt-4 text-[var(--jsm-ink-faint)] text-xs"></span>
</Link>
))}
</div>

View File

@@ -27,17 +27,17 @@ const SAMPLES: Sample[] = [
export default function MusicSamplesPage() {
return (
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--kx-surface)' }}>
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<span className="kx-label">SAMPLE GALLERY</span>
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--kx-on-surface)' }}>
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--jsm-ink)' }}>
AI ·
</h1>
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--kx-on-variant)' }}>
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--jsm-ink-soft)' }}>
. .
<br className="hidden md:block" />
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
.
</span>
</p>
@@ -47,30 +47,36 @@ export default function MusicSamplesPage() {
{SAMPLES.map((s) => (
<div
key={s.id}
className={`group relative aspect-[9/16] rounded-2xl overflow-hidden border ${
s.featured ? 'border-violet-400/50 shadow-2xl shadow-violet-900/40' : 'border-white/10'
className={`relative aspect-[9/16] rounded-2xl overflow-hidden border ${
s.featured ? 'border-[var(--jsm-accent)] shadow-lg' : 'border-[var(--jsm-line)]'
}`}
style={{ background: 'linear-gradient(135deg, #1a0840 0%, #061228 100%)' }}
style={{ background: 'var(--jsm-surface-alt)' }}
>
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/15 to-cyan-500/10 group-hover:from-violet-500/25 group-hover:to-cyan-500/20 transition-all" />
{s.featured && (
<span
className="absolute top-3 left-3 z-10 text-[10px] px-2 py-1 rounded-full font-semibold tracking-widest"
style={{ background: 'rgba(204,151,255,0.2)', color: 'var(--kx-primary)', border: '1px solid rgba(204,151,255,0.5)' }}
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', border: '1px solid var(--jsm-accent)' }}
>
TOP
</span>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-5">
<div className="text-5xl mb-3 opacity-80 group-hover:scale-110 transition-transform">🎬</div>
<p className="text-[10px] md:text-xs font-mono tracking-widest text-violet-300/80 mb-1">{s.genre.toUpperCase()}</p>
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--kx-on-surface)' }}>
<svg
className="w-10 h-10 mb-3 opacity-80"
fill="none"
stroke="var(--jsm-accent)"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 10.5l4.72-2.72a.75.75 0 011.28.53v9.38a.75.75 0 01-1.28.53l-4.72-2.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
</svg>
<p className="text-[10px] md:text-xs font-mono tracking-widest mb-1" style={{ color: 'var(--jsm-accent)' }}>{s.genre.toUpperCase()}</p>
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--jsm-ink)' }}>
{s.title}
</p>
<p className="text-xs mt-1" style={{ color: 'var(--kx-on-variant)' }}>{s.duration}</p>
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--kx-on-variant)' }}>
<p className="text-xs mt-1" style={{ color: 'var(--jsm-ink-soft)' }}>{s.duration}</p>
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--jsm-ink-soft)' }}>
{s.embedId ? '영상 재생' : '영상 준비 중'}
</p>
</div>
@@ -79,19 +85,20 @@ export default function MusicSamplesPage() {
</div>
<div
className="mt-16 text-center p-10 kx-glass"
style={{ border: '1px solid rgba(204,151,255,0.12)', borderRadius: '0.75rem 0.75rem 0.125rem 0.125rem' }}
className="mt-16 text-center px-8 py-16 rounded-3xl"
style={{ background: 'var(--jsm-navy)' }}
>
<span className="kx-label">NEXT</span>
<h2 className="kx-display text-2xl md:text-3xl font-bold mt-2 mb-3" style={{ color: 'var(--kx-on-surface)' }}>
<span className="text-[var(--jsm-accent-soft)] text-xs font-bold uppercase tracking-widest">NEXT</span>
<h2 className="text-2xl md:text-3xl font-bold mt-3 mb-3 text-white">
</h2>
<p className="text-sm mb-6" style={{ color: 'var(--kx-on-variant)' }}>
<p className="text-sm mb-6 text-white/70">
39,000.
</p>
<Link
href="/music/packs#pricing"
className="kx-btn-primary px-8 py-3.5 rounded-full text-sm inline-flex"
className="inline-flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
style={{ color: 'var(--jsm-navy)' }}
>
</Link>

View File

@@ -1,8 +1,11 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
type Mode = 'simple' | 'custom';
type FlowTab = 'story' | 'manual';
type StoryStage = 'input' | 'preview';
type SunoClip = {
id: string;
@@ -23,6 +26,20 @@ type TaskState = {
updatedAt: number;
};
type TrackMeta = {
title?: string;
story?: string;
lyrics?: string;
style?: string;
};
type MusicStory = {
title: string;
lyrics: string;
style: string;
mood: string;
};
const MODELS = [
{ id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' },
{ id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' },
@@ -35,11 +52,16 @@ const TAG_PRESETS = [
];
const LS_KEY = 'jsm_studio_task_ids_v2';
const LOGIN_HREF = '/login?next=/music/studio';
const isDone = (s: string) => s === 'SUCCESS' || s === 'FIRST_SUCCESS';
const isFailed = (s: string) => s.includes('FAILED') || s === 'SENSITIVE_WORD_ERROR';
const FIELD_INPUT =
'w-full rounded-xl border border-[var(--jsm-line)] bg-white px-4 py-3 text-base text-[var(--jsm-ink)] outline-none transition focus:border-[var(--jsm-accent)]';
export default function StudioPage() {
const [flowTab, setFlowTab] = useState<FlowTab>('story');
const [mode, setMode] = useState<Mode>('simple');
const [model, setModel] = useState('V4');
const [prompt, setPrompt] = useState('');
@@ -48,11 +70,28 @@ export default function StudioPage() {
const [tags, setTags] = useState('');
const [instrumental, setInstrumental] = useState(false);
// 스토리 흐름 상태
const [storyText, setStoryText] = useState('');
const [storyStage, setStoryStage] = useState<StoryStage>('input');
const [storyLoading, setStoryLoading] = useState(false);
const [storyError, setStoryError] = useState<string | null>(null);
const [storyAuthRequired, setStoryAuthRequired] = useState(false);
const [mood, setMood] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [authRequired, setAuthRequired] = useState(false);
const [tasks, setTasks] = useState<TaskState[]>([]);
const pollRef = useRef<number | null>(null);
// 생성 요청 시점의 원본(스토리/가사/스타일)을 taskId에 매핑 — 완료 후 자동 저장에 사용
const metaRef = useRef<Map<string, TrackMeta>>(new Map());
// 자동 저장 완료(또는 시도) 표시 — 중복 저장 방지
const savedRef = useRef<Set<string>>(new Set());
// 이번 세션에서 새로 생성한 taskId만 자동 저장 대상으로 삼는다
// (새로고침 시 localStorage에서 복원된 과거 완료 트랙까지 재저장하는 것 방지)
const sessionTaskIdsRef = useRef<Set<string>>(new Set());
const saveToLS = useCallback((ids: string[]) => {
if (typeof window === 'undefined') return;
try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ }
@@ -105,10 +144,38 @@ export default function StudioPage() {
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
}, [tasks, refreshAll]);
const onSubmit = async () => {
// 완료된 트랙 자동 저장 (best-effort) — 실패해도 재생에는 영향 없음
useEffect(() => {
tasks.forEach((task) => {
if (!isDone(task.status)) return;
if (!sessionTaskIdsRef.current.has(task.taskId)) return;
if (savedRef.current.has(task.taskId)) return;
const clip = task.clips.find((c) => c.audioUrl || c.streamAudioUrl);
if (!clip) return;
savedRef.current.add(task.taskId);
const meta = metaRef.current.get(task.taskId);
const audioUrl = clip.audioUrl || clip.streamAudioUrl || '';
fetch('/api/studio/tracks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: meta?.title || clip.title || null,
story: meta?.story || null,
lyrics: meta?.lyrics || null,
style: meta?.style || clip.tags || null,
audio_url: audioUrl,
task_id: task.taskId,
}),
}).catch(() => { /* 비로그인·오류 등 — 무시(best-effort) */ });
});
}, [tasks]);
const runGenerate = useCallback(async (forcedMode: Mode, meta: TrackMeta) => {
setError(null);
if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
if (mode === 'custom') {
setAuthRequired(false);
if (forcedMode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
if (forcedMode === 'custom') {
if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; }
if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; }
if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; }
@@ -119,7 +186,7 @@ export default function StudioPage() {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mode, model,
mode: forcedMode, model,
prompt: prompt.trim(),
title: title.trim(),
lyrics: lyrics.trim(),
@@ -127,7 +194,16 @@ export default function StudioPage() {
make_instrumental: instrumental,
}),
});
const json = await res.json();
const json = await res.json().catch(() => ({}));
if (res.status === 401) {
setAuthRequired(true);
setError('로그인이 필요합니다.');
return;
}
if (res.status === 429) {
setError(typeof json.error === 'string' ? json.error : '오늘 생성 가능 횟수를 모두 사용했습니다.');
return;
}
if (!res.ok || !json.ok) {
setError(typeof json.error === 'string' ? json.error : '생성 실패');
return;
@@ -137,6 +213,8 @@ export default function StudioPage() {
setError('응답에서 taskId를 찾지 못했습니다.');
return;
}
metaRef.current.set(taskId, meta);
sessionTaskIdsRef.current.add(taskId);
setTasks((prev) => {
const next: TaskState[] = [
{ taskId, status: 'PENDING', clips: [], updatedAt: Date.now() },
@@ -150,6 +228,65 @@ export default function StudioPage() {
} finally {
setSubmitting(false);
}
}, [prompt, title, lyrics, tags, instrumental, model, saveToLS]);
const onManualSubmit = () => {
runGenerate(mode, {
title: mode === 'custom' ? title.trim() : undefined,
lyrics: mode === 'custom' ? lyrics.trim() : undefined,
style: mode === 'custom' ? tags.trim() : undefined,
story: mode === 'simple' ? prompt.trim() : undefined,
});
};
const onStoryGenerate = () => {
runGenerate('custom', {
title: title.trim(),
lyrics: lyrics.trim(),
style: tags.trim(),
story: storyText.trim(),
});
};
const onMakeLyrics = async () => {
setStoryError(null);
setStoryAuthRequired(false);
if (!storyText.trim()) {
setStoryError('이야기를 먼저 입력해주세요.');
return;
}
setStoryLoading(true);
try {
const res = await fetch('/api/studio/story', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ story: storyText.trim() }),
});
const json = await res.json().catch(() => ({}));
if (res.status === 401) {
setStoryAuthRequired(true);
setStoryError('로그인이 필요합니다.');
return;
}
if (res.status === 503 || res.status === 502) {
setStoryError(typeof json.error === 'string' ? json.error : 'AI 서비스가 잠시 준비 중입니다. 잠시 후 다시 시도해주세요.');
return;
}
if (!res.ok || !json.story) {
setStoryError(typeof json.error === 'string' ? json.error : '가사 생성에 실패했습니다.');
return;
}
const s = json.story as MusicStory;
setTitle(s.title);
setLyrics(s.lyrics);
setTags(s.style);
setMood(s.mood);
setStoryStage('preview');
} catch (e) {
setStoryError(e instanceof Error ? e.message : String(e));
} finally {
setStoryLoading(false);
}
};
const addTag = (t: string) => {
@@ -161,203 +298,344 @@ export default function StudioPage() {
return (
<div
className="min-h-screen px-4 md:px-8 lg:px-12 py-10"
style={{
background:
'radial-gradient(1200px 600px at 20% -10%, rgba(156,72,234,0.18), transparent 60%), radial-gradient(1000px 500px at 110% 10%, rgba(83,221,252,0.12), transparent 55%), var(--kx-surface)',
color: 'var(--kx-on-surface)',
}}
style={{ background: 'var(--jsm-bg)', color: 'var(--jsm-ink)' }}
>
<div className="max-w-7xl mx-auto">
<div className="flex items-end justify-between flex-wrap gap-4 mb-8">
<div>
<span className="kx-label">JAENGSEUNG STUDIO</span>
<h1 className="kx-display text-3xl md:text-5xl font-extrabold mt-2" style={{ letterSpacing: '-0.02em' }}>
</h1>
<p className="mt-2 text-sm" style={{ color: 'var(--kx-on-variant)' }}>
Suno · Custom ··
<p className="mt-2 text-sm" style={{ color: 'var(--jsm-ink-soft)' }}>
AI가 · . .
</p>
</div>
<div
className="text-xs px-3 py-1.5 rounded-full border"
className="text-xs px-3 py-1.5 rounded-full font-semibold tracking-wide"
style={{
borderColor: 'rgba(204,151,255,0.35)',
background: 'rgba(204,151,255,0.1)',
color: 'var(--kx-primary)',
border: '1px solid var(--jsm-accent)',
background: 'var(--jsm-accent-soft)',
color: 'var(--jsm-accent)',
}}
>
v1 Studio · Live
STUDIO · LIVE
</div>
</div>
<div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] gap-6">
{/* 좌측: 제어판 */}
<div
className="rounded-2xl p-6 md:p-8"
style={{
background: 'rgba(12,22,45,0.7)',
border: '1px solid rgba(255,255,255,0.06)',
backdropFilter: 'blur(16px)',
}}
>
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
{(['simple', 'custom'] as Mode[]).map((m) => (
<div className="rounded-2xl p-6 md:p-8 bg-white border border-[var(--jsm-line)]">
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
{(['story', 'manual'] as FlowTab[]).map((t) => (
<button
key={m}
onClick={() => setMode(m)}
key={t}
onClick={() => setFlowTab(t)}
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
style={
mode === m
? {
background: 'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))',
color: '#fff',
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
}
: { color: 'var(--kx-on-variant)' }
flowTab === t
? { background: 'var(--jsm-accent)', color: '#fff' }
: { color: 'var(--jsm-ink-soft)' }
}
>
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
{t === 'story' ? '스토리로 만들기' : '직접 입력'}
</button>
))}
</div>
{mode === 'simple' ? (
{flowTab === 'story' ? (
<div className="space-y-5">
<Field label="프롬프트" hint="무드·장르·가사 아이디어를 한 줄로">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={5}
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
className="w-full bg-transparent outline-none resize-none text-base"
style={{ color: 'var(--kx-on-surface)' }}
/>
</Field>
</div>
) : (
<div className="space-y-5">
<Field label="트랙 제목">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 새벽 세 시의 도시"
className="w-full bg-transparent outline-none text-base"
style={{ color: 'var(--kx-on-surface)' }}
/>
</Field>
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
rows={8}
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
className="w-full bg-transparent outline-none resize-none font-mono text-sm leading-relaxed"
style={{ color: 'var(--kx-on-surface)' }}
/>
</Field>
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
className="w-full bg-transparent outline-none text-base"
style={{ color: 'var(--kx-on-surface)' }}
/>
<div className="flex flex-wrap gap-1.5 mt-3">
{TAG_PRESETS.map((t) => (
{storyStage === 'input' ? (
<>
<Field label="나의 이야기" hint="추억·순간·감정을 편하게 적어주세요">
<textarea
value={storyText}
onChange={(e) => setStoryText(e.target.value)}
rows={7}
placeholder="예: 대학 시절 자취방에서 혼자 라면을 끓여 먹으며 창밖 비 오는 거리를 보던 밤, 외로웠지만 이상하게 평온했던 기억"
className={`${FIELD_INPUT} resize-none`}
/>
</Field>
<button
onClick={onMakeLyrics}
disabled={storyLoading}
className="w-full py-4 rounded-xl font-bold text-base transition disabled:opacity-60"
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
>
{storyLoading ? '가사 만드는 중…' : '가사 만들기'}
</button>
{storyError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{storyError}
{storyAuthRequired && (
<Link
href={LOGIN_HREF}
className="ml-2 font-semibold underline underline-offset-2"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
)}
</div>
)}
</>
) : (
<>
<div className="flex items-center justify-between">
<span
className="text-xs px-3 py-1 rounded-full font-semibold"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
>
· {mood || '미정'}
</span>
<button
key={t}
onClick={() => addTag(t)}
className="text-xs px-2.5 py-1 rounded-full transition"
onClick={() => setStoryStage('input')}
className="text-xs underline underline-offset-4"
style={{ color: 'var(--jsm-ink-soft)' }}
>
</button>
</div>
<Field label="트랙 제목">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 새벽 세 시의 도시"
className={FIELD_INPUT}
/>
</Field>
<Field label="가사" hint="AI가 제안한 가사입니다 — 자유롭게 수정 가능">
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
rows={8}
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
/>
</Field>
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
className={FIELD_INPUT}
/>
<div className="flex flex-wrap gap-1.5 mt-3">
{TAG_PRESETS.map((t) => (
<button
key={t}
onClick={() => addTag(t)}
className="text-xs px-2.5 py-1 rounded-full transition"
style={{
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink-soft)',
}}
>
+ {t}
</button>
))}
</div>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="모델">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className={`${FIELD_INPUT} text-sm`}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.desc}
</option>
))}
</select>
</Field>
<Field label="Instrumental (가사 없음)">
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
</Field>
</div>
<div className="flex gap-3">
<button
onClick={onMakeLyrics}
disabled={storyLoading}
className="flex-1 py-3.5 rounded-xl font-semibold text-sm transition disabled:opacity-60"
style={{
background: 'rgba(255,255,255,0.04)',
border: '1px solid rgba(255,255,255,0.08)',
color: 'var(--kx-on-variant)',
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
}}
>
+ {t}
{storyLoading ? '다시 만드는 중…' : '가사 다시 만들기'}
</button>
))}
</div>
</Field>
<button
onClick={onStoryGenerate}
disabled={submitting}
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition disabled:opacity-60"
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
>
{submitting ? '생성 요청 중…' : '음악 만들기'}
</button>
</div>
{storyError && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{storyError}
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
{authRequired && (
<Link
href={LOGIN_HREF}
className="ml-2 font-semibold underline underline-offset-2"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
)}
</div>
)}
<p className="text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
Suno . · .
</p>
</>
)}
</div>
)}
<div className="grid grid-cols-2 gap-4 mt-6">
<Field label="모델">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-transparent outline-none text-sm"
style={{ color: 'var(--kx-on-surface)' }}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id} style={{ background: '#0b1428' }}>
{m.label} {m.desc}
</option>
) : (
<>
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
{(['simple', 'custom'] as Mode[]).map((m) => (
<button
key={m}
onClick={() => setMode(m)}
className="flex-1 py-2 text-xs font-semibold rounded-full transition-all"
style={
mode === m
? { background: 'var(--jsm-accent)', color: '#fff' }
: { color: 'var(--jsm-ink-soft)' }
}
>
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
</button>
))}
</select>
</Field>
<Field label="Instrumental (가사 없음)">
<label className="flex items-center gap-3 cursor-pointer">
<span
className="relative inline-block w-11 h-6 rounded-full transition"
style={{ background: instrumental ? 'rgba(204,151,255,0.6)' : 'rgba(255,255,255,0.1)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
style={{ left: instrumental ? '22px' : '2px' }}
/>
</span>
<input
type="checkbox"
checked={instrumental}
onChange={(e) => setInstrumental(e.target.checked)}
className="sr-only"
/>
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
{instrumental ? 'ON' : 'OFF'}
</span>
</label>
</Field>
</div>
</div>
<div className="mt-8">
<button
onClick={onSubmit}
disabled={submitting}
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
style={{
background: submitting
? 'rgba(204,151,255,0.2)'
: 'linear-gradient(135deg, #cc97ff 0%, #7c3aed 50%, #53ddfc 100%)',
color: '#0b1428',
boxShadow: submitting ? 'none' : '0 12px 40px -12px rgba(204,151,255,0.6)',
letterSpacing: '0.01em',
}}
>
{submitting ? '생성 요청 중…' : '▶ Generate Track'}
</button>
{error && (
<p className="mt-3 text-xs px-3 py-2 rounded-lg" style={{ background: 'rgba(215,51,87,0.12)', color: '#ff8ba7' }}>
{error}
</p>
)}
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--kx-on-variant)' }}>
Suno . · .
</p>
</div>
{mode === 'simple' ? (
<div className="space-y-5">
<Field label="프롬프트" hint="무드·장르·가사 아이디어를 한 줄로">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
rows={5}
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
className={`${FIELD_INPUT} resize-none`}
/>
</Field>
</div>
) : (
<div className="space-y-5">
<Field label="트랙 제목">
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="예: 새벽 세 시의 도시"
className={FIELD_INPUT}
/>
</Field>
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
<textarea
value={lyrics}
onChange={(e) => setLyrics(e.target.value)}
rows={8}
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
/>
</Field>
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
<input
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
className={FIELD_INPUT}
/>
<div className="flex flex-wrap gap-1.5 mt-3">
{TAG_PRESETS.map((t) => (
<button
key={t}
onClick={() => addTag(t)}
className="text-xs px-2.5 py-1 rounded-full transition"
style={{
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink-soft)',
}}
>
+ {t}
</button>
))}
</div>
</Field>
</div>
)}
<div className="grid grid-cols-2 gap-4 mt-6">
<Field label="모델">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className={`${FIELD_INPUT} text-sm`}
>
{MODELS.map((m) => (
<option key={m.id} value={m.id}>
{m.label} {m.desc}
</option>
))}
</select>
</Field>
<Field label="Instrumental (가사 없음)">
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
</Field>
</div>
<div className="mt-8">
<button
onClick={onManualSubmit}
disabled={submitting}
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
>
{submitting ? '생성 요청 중…' : '트랙 생성하기'}
</button>
{error && (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
{error}
{authRequired && (
<Link
href={LOGIN_HREF}
className="ml-2 font-semibold underline underline-offset-2"
style={{ color: 'var(--jsm-accent)' }}
>
</Link>
)}
</div>
)}
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
Suno . · .
</p>
</div>
</>
)}
</div>
{/* 우측: 결과 */}
<div
className="rounded-2xl p-6 md:p-7"
style={{
background: 'rgba(9,17,36,0.7)',
border: '1px solid rgba(255,255,255,0.06)',
backdropFilter: 'blur(16px)',
}}
>
<div className="rounded-2xl p-6 md:p-7 bg-white border border-[var(--jsm-line)]">
<div className="flex items-center justify-between mb-4">
<div>
<span className="kx-label">RECENT TRACKS</span>
@@ -367,7 +645,7 @@ export default function StudioPage() {
<button
onClick={() => { setTasks([]); saveToLS([]); }}
className="text-[11px] underline underline-offset-4"
style={{ color: 'var(--kx-on-variant)' }}
style={{ color: 'var(--jsm-ink-soft)' }}
>
</button>
@@ -376,35 +654,31 @@ export default function StudioPage() {
{tasks.length === 0 ? (
<div
className="rounded-xl p-8 text-center text-sm"
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }}
className="rounded-xl p-8 text-center text-sm border border-dashed"
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-soft)' }}
>
.
<br /> Generate .
<br /> .
</div>
) : (
<ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1">
{tasks.map((task) => (
<li
key={task.taskId}
className="rounded-xl p-4"
style={{
background: 'rgba(20,31,56,0.6)',
border: '1px solid rgba(255,255,255,0.05)',
}}
className="rounded-xl p-4 border"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<div className="flex items-center justify-between gap-3 mb-3">
<span className="text-[11px] font-mono opacity-60">task: {task.taskId.slice(0, 10)}</span>
<span className="text-[11px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
task: {task.taskId.slice(0, 10)}
</span>
<StatusBadge status={task.status} />
</div>
{task.clips.length === 0 ? (
<div
className="h-9 rounded-md flex items-center justify-center text-xs"
style={{
background: 'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
color: 'var(--kx-on-variant)',
}}
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-soft)' }}
>
{isFailed(task.status)
? (task.errorMessage ?? '생성 실패')
@@ -417,8 +691,8 @@ export default function StudioPage() {
return (
<div
key={c.id}
className="rounded-lg p-3"
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.04)' }}
className="rounded-lg p-3 bg-white border"
style={{ borderColor: 'var(--jsm-line)' }}
>
<div className="flex items-center gap-3">
{c.imageUrl && (
@@ -430,17 +704,17 @@ export default function StudioPage() {
/>
)}
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm truncate" style={{ color: 'var(--kx-on-surface)' }}>
<p className="font-semibold text-sm truncate" style={{ color: 'var(--jsm-ink)' }}>
{c.title || '제목 없음'}
</p>
{c.tags && (
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--jsm-ink-soft)' }}>
{c.tags}
</p>
)}
</div>
{c.duration && (
<span className="text-[10px] font-mono opacity-60">
<span className="text-[10px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
{Math.round(c.duration)}s
</span>
)}
@@ -449,8 +723,13 @@ export default function StudioPage() {
<audio controls src={src} className="w-full mt-2" style={{ height: 36 }} />
) : null}
{c.audioUrl && (
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
<a href={c.audioUrl} download className="underline underline-offset-4 hover:text-white">
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--jsm-ink-soft)' }}>
<a
href={c.audioUrl}
download
className="underline underline-offset-4"
style={{ color: 'var(--jsm-accent)' }}
>
MP3
</a>
</div>
@@ -467,9 +746,9 @@ export default function StudioPage() {
</div>
</div>
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--kx-on-variant)' }}>
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." />
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
<Tip title="① 스토리 모드" body="이야기를 적으면 AI가 제목·가사·스타일을 자동으로 제안합니다." />
<Tip title="② 직접 입력 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
<Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." />
</div>
</div>
@@ -487,41 +766,60 @@ function Field({
children: React.ReactNode;
}) {
return (
<div
className="rounded-xl p-4"
style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div>
<div className="flex items-baseline justify-between mb-2">
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--kx-primary)' }}>
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--jsm-accent)' }}>
{label}
</span>
{hint && <span className="text-[10px]" style={{ color: 'var(--kx-on-variant)' }}>{hint}</span>}
{hint && <span className="text-[10px]" style={{ color: 'var(--jsm-ink-soft)' }}>{hint}</span>}
</div>
{children}
</div>
);
}
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex items-center gap-3 cursor-pointer">
<span
className="relative inline-block w-11 h-6 rounded-full transition"
style={{ background: checked ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
style={{ left: checked ? '22px' : '2px' }}
/>
</span>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="sr-only"
/>
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
{checked ? 'ON' : 'OFF'}
</span>
</label>
);
}
function StatusBadge({ status }: { status: string }) {
const map: Record<string, { bg: string; fg: string; label: string }> = {
SUCCESS: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
FIRST_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '첫 트랙 준비' },
TEXT_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '가사 완료' },
PENDING: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
const map: Record<string, { bg: string; fg: string; border: string; label: string }> = {
SUCCESS: { bg: '#ecfdf5', fg: '#047857', border: '#a7f3d0', label: '완료' },
FIRST_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '첫 트랙 준비' },
TEXT_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '가사 완료' },
PENDING: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: '대기' },
};
let entry = map[status];
if (!entry) {
entry = isFailed(status)
? { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '실패' }
: { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status };
? { bg: '#fef2f2', fg: '#b91c1c', border: '#fecaca', label: '실패' }
: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: status };
}
return (
<span
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
style={{ background: entry.bg, color: entry.fg }}
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap border"
style={{ background: entry.bg, color: entry.fg, borderColor: entry.border }}
>
{entry.label}
</span>
@@ -530,11 +828,8 @@ function StatusBadge({ status }: { status: string }) {
function Tip({ title, body }: { title: string; body: string }) {
return (
<div
className="rounded-xl p-4"
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
>
<p className="font-semibold mb-1" style={{ color: 'var(--kx-on-surface)' }}>
<div className="rounded-xl p-4 bg-white border" style={{ borderColor: 'var(--jsm-line)' }}>
<p className="font-semibold mb-1" style={{ color: 'var(--jsm-ink)' }}>
{title}
</p>
<p className="leading-relaxed">{body}</p>

File diff suppressed because it is too large Load Diff

316
app/outsourcing/page.tsx Normal file
View File

@@ -0,0 +1,316 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
import ShowcaseGrid from '@/app/components/deepfield/ShowcaseGrid';
import ScrollReveal from '@/app/components/deepfield/ScrollReveal';
import MockWindow from '@/app/components/mock/MockWindow';
import { FeedMock } from '@/app/components/mock/screens';
import { SHOWCASE_SLOTS } from '@/lib/showcase';
// 외주 개발 의뢰 페이지 (서버 컴포넌트) — 라이트 고craft.
// PublicShell의 단일 라이트 셸을 따르며, 메인(/)과 동일한 비주얼 언어
// (surface↔surface-alt 교차 + accent 모노 라벨 헤더 + 카드 스펙)를 공유한다.
export const metadata: Metadata = {
title: '외주 개발',
description:
'24시간 돌아가는 실서비스를 직접 설계·운영하는 손으로, 맞춤 소프트웨어를 만들어 드립니다. 웹 서비스·업무 자동화·API·백엔드·봇·AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
};
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const FIELDS = [
{ t: '웹 서비스 개발', d: '회원·결제·관리자까지, 실제로 굴러가는 서비스를 기획부터 배포까지 만들어 드립니다.' },
{ t: '웹사이트 제작', d: '기업 소개·포트폴리오·랜딩 페이지를 반응형·SEO까지 갖춰 제작합니다.' },
{ t: '업무 자동화', d: 'RPA·엑셀 집계·웹 크롤링으로 반복 업무를 사람 손에서 떼어냅니다.' },
{ t: 'API·백엔드', d: '데이터 모델 설계부터 인증·외부 연동까지 안정적인 서버를 구축합니다.' },
{ t: '텔레그램·디스코드 봇', d: '알림·명령·자동 응답 봇으로 운영과 커뮤니티 관리를 자동화합니다.' },
{ t: 'AI 연동 개발', d: 'LLM·생성형 AI를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.' },
];
const PROCESS = [
{ n: '01', t: '무료 상담', d: '요구사항을 함께 정리하고 실현 가능성을 점검합니다. 기획이 안 잡혔어도 괜찮습니다.' },
{ n: '02', t: '견적·범위 확정', d: '기능 범위와 일정을 정리해 영업일 2일 내 견적으로 회신드립니다.' },
{ n: '03', t: '계약·착수', d: '계약서 체결 후 착수금 30%를 받고 개발을 시작합니다.' },
{ n: '04', t: '개발·중간 공유', d: '주 1회 이상 진행 상황을 공유하며 방향을 맞춰 갑니다.' },
{ n: '05', t: '납품·검수', d: '완성본을 인도하고 함께 검수합니다. 전체 소스와 배포 문서를 전달합니다.' },
{ n: '06', t: '무상 하자보수 30일', d: '납품 후 30일간 결함·수정을 무상으로 대응해 안정화까지 책임집니다.' },
];
const CASES = [
{ t: '주식 자동매매 시스템', cat: '실시간 트레이딩 · 직접 운영 중', live: true, d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.', tags: ['Python', 'Telegram Bot', '실시간 주문'] },
{ t: '부동산 청약 자동 수집·매칭', cat: '크롤링 · 직접 운영 중', live: true, d: '공고를 주기적으로 크롤링해 조건에 맞는 매물만 골라내고, 신규 매칭을 즉시 푸시합니다.', tags: ['Python', '크롤링', '조건 매칭'] },
{ t: 'AI 콘텐츠 자동화 파이프라인', cat: 'AI 연동 · 직접 운영 중', live: true, d: '생성부터 검수, 발행까지 사람이 개입할 지점만 남기고 전 과정을 자동으로 연결합니다.', tags: ['AI 연동', '검수 워크플로우', '자동 발행'] },
{ t: 'Gmail 자동화 RPA', cat: 'RPA · 납품 완료', live: false, d: '거래처 이메일 수신 시 자동 분류, 답장 초안 작성, 담당자 알림을 전송합니다.', tags: ['Python', 'Gmail API'] },
{ t: '쇼핑몰 가격 모니터링 봇', cat: '웹 스크래핑 · 납품 완료', live: false, d: '경쟁사 상품 가격을 매일 모니터링해 변동 시 텔레그램으로 즉시 알립니다.', tags: ['Python', 'Selenium', 'Telegram Bot'] },
{ t: '영업 일보 자동화 시스템', cat: '엑셀 자동화 · 납품 완료', live: false, d: '엑셀 데이터를 자동 집계해 일·주·월별 보고서 PDF를 생성하고 매일 09시 발송합니다.', tags: ['Python', 'OpenPyXL', 'ReportLab'] },
];
const FAQ = [
{ q: '견적은 어떻게 산정되나요?', a: '기능 범위와 구현 난이도를 기준으로 산정합니다. 상담에서 필요한 기능을 함께 정리한 뒤, 영업일 2일 내에 범위·일정·금액을 명시한 견적으로 회신드립니다. 추측으로 부풀리지 않고 실제 작업량 기준으로 잡습니다.' },
{ q: '수정 요청은 몇 번까지 가능한가요?', a: '합의한 범위 안에서는 2회까지 무상으로 수정해 드립니다. 범위를 벗어나는 기능 추가나 방향 전환은 별도로 협의해 진행합니다. 무엇이 범위 안/밖인지는 착수 전 견적에 미리 명시합니다.' },
{ q: '소스코드도 제공되나요?', a: '제공됩니다. 잔금 완납 시 전체 소스코드와 배포·실행 문서를 함께 전달합니다. 직접 운영하시거나 다른 개발자에게 이어 맡기셔도 문제없도록 인도합니다.' },
{ q: '납품 후 유지보수는요?', a: '납품일로부터 30일간 결함·오류를 무상으로 하자보수합니다. 이후 기능 추가나 지속 운영이 필요하면 월 단위 유지보수 계약으로 이어갈 수 있습니다.' },
];
function ArrowRight() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
function Eyebrow({ children }: { children: React.ReactNode }) {
return (
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
{children}
</p>
);
}
export default function OutsourcingPage() {
return (
<>
{/* ─────────────────── 1. HERO ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto grid max-w-6xl items-center gap-12 px-6 pt-20 pb-16 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:pt-28 lg:pb-24">
<div>
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
outsourcing
</span>
<h1
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
<br />
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p className="mt-7 max-w-xl break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. .
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<Link
href="#contact"
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 font-semibold text-white transition-colors duration-200 hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
<Link
href="#showcase"
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink)', borderColor: 'var(--jsm-line)', ...KOR_BODY }}
>
</Link>
</div>
</div>
<div className="lg:pl-4">
<MockWindow title="telegram-bot.log">
<FeedMock />
</MockWindow>
</div>
</div>
</section>
{/* ─────────────────── 2. SHOWCASE (풀 그리드) ─────────────────── */}
<section id="showcase" className="scroll-mt-20" style={{ background: 'var(--jsm-surface-alt)' }}>
{/* 하위 호환: 기존 /outsourcing#portfolio 링크 앵커 유지 */}
<div id="portfolio" className="scroll-mt-20" />
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>showcase</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
</ScrollReveal>
<div className="mt-12">
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />
</div>
</div>
</section>
{/* ─────────────────── 3. 운영 실사례 ─────────────────── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>in production</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
,
</h2>
<p className="mt-4 max-w-xl break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. .
</p>
</ScrollReveal>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{CASES.map((c, i) => (
<ScrollReveal key={c.t} delay={i * 80}>
<div className="flex h-full flex-col rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<span
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
style={c.live ? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' } : { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }}
>
{c.live && <span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />}
{c.cat}
</span>
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{c.t}
</h3>
<p className="mt-2.5 flex-1 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{c.d}
</p>
<div className="mt-5 flex flex-wrap gap-1.5">
{c.tags.map((tag) => (
<span key={tag} className="rounded px-2.5 py-1 text-xs" style={{ color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)', ...KOR_BODY }}>
{tag}
</span>
))}
</div>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─────────────────── 4a. 제공 분야 ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>scope</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
</ScrollReveal>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{FIELDS.map((f, i) => (
<ScrollReveal key={f.t} delay={i * 80}>
<div className="h-full rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{f.t}
</h3>
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{f.d}
</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─────────────────── 4b. 진행 프로세스 ─────────────────── */}
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>process</Eyebrow>
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
,
</h2>
</ScrollReveal>
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{PROCESS.map((s, i) => (
<ScrollReveal key={s.n} delay={i * 80}>
<div className="relative h-full rounded-2xl border p-7 lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<span
className="relative z-10 inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
>
{s.n}
</span>
<h3 className="mt-5 break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{s.t}
</h3>
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{s.d}
</p>
</div>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─────────────────── 5. FAQ ─────────────────── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-3xl px-6 py-20 lg:px-8 lg:py-28">
<ScrollReveal>
<Eyebrow>faq</Eyebrow>
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
</ScrollReveal>
<div className="mt-12 space-y-3">
{FAQ.map((item, i) => (
<ScrollReveal key={item.q} delay={i * 80}>
<details className="group overflow-hidden rounded-2xl border" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 break-keep px-6 py-5 font-semibold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{item.q}
<svg className="shrink-0 transition-transform duration-200 group-open:rotate-45" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden style={{ color: 'var(--jsm-ink-soft)' }}>
<path d="M12 5v14M5 12h14" />
</svg>
</summary>
<p className="break-keep px-6 pb-5 text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{item.a}
</p>
</details>
</ScrollReveal>
))}
</div>
</div>
</section>
{/* ─────────────────── 6. 의뢰 폼 ─────────────────── */}
<section id="contact" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
<div className="grid gap-10 lg:grid-cols-5 lg:gap-12">
{/* 안내 */}
<div className="lg:col-span-2">
<ScrollReveal>
<Eyebrow>contact</Eyebrow>
<h2 className="break-keep text-3xl font-bold leading-tight lg:text-[2.4rem]" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<p className="mt-5 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
2 . .
</p>
<div className="mt-8 space-y-3 border-t pt-8" style={{ borderColor: 'var(--jsm-line)' }}>
<a href="mailto:bgg8988@gmail.com" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Mail</span>
bgg8988@gmail.com
</a>
<a href="tel:010-3907-1392" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Tel</span>
010-3907-1392
</a>
</div>
</ScrollReveal>
</div>
{/* 폼 */}
<div className="lg:col-span-3">
<ScrollReveal delay={100}>
<div className="rounded-2xl border p-6 shadow-[0_24px_60px_-32px_rgba(15,23,42,0.3)] lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<OutsourcingRequestForm />
</div>
</ScrollReveal>
</div>
</div>
</div>
</section>
</>
);
}

View File

@@ -1,18 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'SaaS 제품 · 월 구독 패키지',
description:
'현직 엔지니어가 실제 운영하며 검증한 자동화를 월 구독 SaaS 제품으로 제공합니다. 첫 제품 준비 중 — 출시 알림을 신청하세요.',
keywords: ['SaaS', '자동화 구독', '월 구독 자동화', 'AI 자동화 제품', '쟁승메이드'],
openGraph: {
title: 'SaaS 제품 · 월 구독 패키지 | 쟁승메이드',
description:
'검증된 자동화를 SaaS로. 현직 엔지니어가 직접 운영·검증한 자동화 제품 카탈로그.',
url: 'https://jaengseung-made.com/packages',
},
};
export default function PackagesLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,173 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '@/app/components/ContactModal';
import { trackCTAClick } from '@/lib/gtag';
import {
getAvailablePackages,
getComingSoonPackages,
type SaasCatalogItem,
} from '@/lib/saas-catalog';
const WAITLIST_SERVICE = 'SaaS 출시 알림 신청';
function PackageCard({ pkg, dimmed }: { pkg: SaasCatalogItem; dimmed?: boolean }) {
const inner = (
<>
<div className="flex items-center justify-between mb-3">
<p className="font-mono text-[10px] uppercase tracking-widest text-white/50">
{pkg.category}
</p>
{pkg.badge && (
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border border-white/30 text-white/80">
{pkg.badge}
</span>
)}
{dimmed && !pkg.badge && (
<span className="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded-full border border-white/20 text-white/50">
Coming Soon
</span>
)}
</div>
<h3 className="kx-display text-xl font-bold text-white mb-1.5">{pkg.name}</h3>
<p className="text-sm text-white/70 mb-3">{pkg.tagline}</p>
<p className="text-xs text-white/55 leading-relaxed mb-4 flex-1">{pkg.description}</p>
<ul className="space-y-2 mb-5">
{pkg.features.map((f) => (
<li key={f} className="flex gap-2 text-xs text-white/70">
<span className="text-white/40">·</span>
<span className="leading-relaxed">{f}</span>
</li>
))}
</ul>
<div className="mt-auto flex items-center justify-between">
{pkg.priceLabel ? (
<span className="font-mono text-sm text-white">{pkg.priceLabel}</span>
) : (
<span className="font-mono text-xs text-white/40"> </span>
)}
{!dimmed && <span aria-hidden className="text-white/50 text-sm"></span>}
</div>
</>
);
const base =
'group rounded-2xl border p-6 flex flex-col transition';
if (dimmed) {
return (
<div className={`${base} border-white/10 bg-white/[0.01] opacity-60`}>{inner}</div>
);
}
return (
<Link
href={pkg.href ?? '#'}
onClick={() => trackCTAClick(`packages_card_${pkg.slug}`)}
className={`${base} border-white/15 bg-white/[0.02] hover:border-white/40 hover:bg-white/[0.05]`}
style={{ textDecoration: 'none' }}
>
{inner}
</Link>
);
}
export default function PackagesPage() {
const [modalOpen, setModalOpen] = useState(false);
const available = getAvailablePackages();
const comingSoon = getComingSoonPackages();
const isEmpty = available.length === 0;
return (
<div className="min-h-screen bg-black text-white">
<ContactModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
service={WAITLIST_SERVICE}
checklist={['관심 있는 업무·자동화 분야', '연락받을 이메일', '현재 겪는 반복 업무(선택)']}
/>
{/* Hero */}
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10">
<div className="absolute inset-0 bg-gradient-to-b from-[#0a0618] to-black pointer-events-none" />
<div className="relative z-10 max-w-3xl mx-auto text-center">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
SaaS Products
</p>
<h1
className="kx-display text-4xl md:text-6xl font-bold mb-5"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
<br />SaaS로 .
</h1>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
.
{isEmpty ? ' 첫 제품을 준비하고 있습니다.' : ''}
</p>
{isEmpty && (
<button
onClick={() => {
trackCTAClick('packages_waitlist_hero');
setModalOpen(true);
}}
className="kx-btn-primary inline-flex items-center px-7 py-3 rounded-full text-sm mt-8"
>
</button>
)}
</div>
</section>
{/* Available 카탈로그 */}
{available.length > 0 && (
<section className="py-20 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{available.map((pkg) => (
<PackageCard key={pkg.slug} pkg={pkg} />
))}
</div>
</section>
)}
{/* Coming Soon 예고 */}
{comingSoon.length > 0 && (
<section className="py-20 px-6 bg-white/[0.02] border-t border-white/10">
<div className="max-w-6xl mx-auto">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4 text-center">
Coming Soon
</p>
<h2 className="kx-display text-2xl md:text-3xl font-bold text-center mb-10">
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{comingSoon.map((pkg) => (
<PackageCard key={pkg.slug} pkg={pkg} dimmed />
))}
</div>
</div>
</section>
)}
{/* 출시 알림 CTA — 항상 노출(빈 상태 아닐 때도 대기자 수집) */}
<section className="py-20 px-6 border-t border-white/10">
<div className="max-w-3xl mx-auto text-center">
<h2 className="kx-display text-2xl md:text-4xl font-bold mb-5">
?
</h2>
<p className="text-base text-white/70 mb-8">
. .
</p>
<button
onClick={() => {
trackCTAClick('packages_waitlist_cta');
setModalOpen(true);
}}
className="kx-btn-primary inline-flex items-center px-7 py-3 rounded-full text-sm"
>
</button>
</div>
</section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,62 +0,0 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
function FailContent() {
const params = useSearchParams();
const message = params.get('message') ?? '결제가 취소되었거나 실패했습니다.';
const code = params.get('code') ?? '';
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-slate-100 border-2 border-slate-200 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
<div className="inline-block bg-slate-100 border border-slate-200 text-slate-600 text-xs font-bold px-3 py-1 rounded-full mb-4">
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
</div>
<h2 className="text-xl font-bold text-[#04102b] mb-2">
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
</h2>
<p className="text-slate-500 text-sm mb-8 max-w-xs mx-auto leading-relaxed">{message}</p>
<div className="flex justify-center gap-3 flex-wrap">
<button
onClick={() => window.history.back()}
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
>
</button>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentFailPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="bg-[#04102b] px-6 py-4" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#1a56db] flex items-center justify-center text-white font-bold text-xs">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={<div className="py-20 text-center text-slate-400 text-sm"> ...</div>}>
<FailContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -1,68 +0,0 @@
'use client';
import { Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import Link from 'next/link';
function SuccessContent() {
const params = useSearchParams();
const paymentId = params.get('paymentId');
return (
<div className="text-center py-20 px-6">
<div className="w-16 h-16 rounded-full bg-emerald-50 border-2 border-emerald-400 flex items-center justify-center mx-auto mb-5">
<svg className="w-8 h-8 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<div className="inline-block bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-bold px-3 py-1 rounded-full mb-4">
</div>
<h2 className="text-2xl font-extrabold text-[#04102b] mb-2"> !</h2>
{paymentId && (
<p className="text-slate-400 text-xs mb-1">: {paymentId}</p>
)}
<p className="text-slate-500 text-sm mb-8">
.
</p>
<div className="flex justify-center gap-3 flex-wrap">
<Link
href="/mypage?tab=payments"
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
>
</Link>
<Link
href="/"
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
>
</Link>
</div>
</div>
);
}
export default function PaymentSuccessPage() {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
<div className="bg-[#04102b] px-6 py-4" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded-lg bg-[#1a56db] flex items-center justify-center text-white font-bold text-xs">
</div>
<span className="text-white font-bold text-sm"> </span>
</div>
</div>
<Suspense fallback={
<div className="py-20 text-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
</div>
}>
<SuccessContent />
</Suspense>
</div>
</div>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import PaymentButton from '@/app/components/PaymentButton';
import { PRODUCTS } from '@/lib/products';
// DB products 테이블에 등록된 상품만 테스트 가능
const TEST_PRODUCTS = [
'saju_detail', // 1,000원
];
export default function PaymentTestPage() {
return (
<div className="max-w-2xl mx-auto px-6 py-12">
<div className="mb-8">
<h1 className="text-2xl font-extrabold text-[#04102b] mb-2"> </h1>
<p className="text-slate-500 text-sm">
V2 .
</p>
<div className="mt-3 bg-amber-50 border border-amber-200 text-amber-800 text-xs px-4 py-2.5 rounded-xl">
. .
</div>
</div>
<div className="space-y-4">
{TEST_PRODUCTS.map((id) => {
const product = PRODUCTS[id];
if (!product) return null;
return (
<div
key={id}
className="flex items-center justify-between bg-white border border-slate-200 rounded-xl px-5 py-4"
>
<div>
<p className="font-semibold text-sm text-slate-800">{product.name}</p>
<p className="text-xs text-slate-400 mt-0.5">
{product.price.toLocaleString()}
{product.type === 'monthly' && ' / 월'}
<span className="ml-2 text-slate-300">({id})</span>
</p>
</div>
<PaymentButton
productId={id}
className="bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-5 py-2.5 rounded-xl text-sm font-bold transition shadow-lg shadow-blue-600/20"
>
</PaymentButton>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -23,69 +23,78 @@ export default async function PortfolioGateway({ params }: Props) {
const expires = new Date(payload.exp).toLocaleDateString('ko-KR');
return (
<div className="min-h-screen bg-slate-950 text-white">
<section
className="relative overflow-hidden px-6 py-20 lg:px-14 lg:py-28"
style={{
background:
'radial-gradient(circle at 30% 20%, #1e293b 0%, #020617 55%)',
}}
>
<div className="min-h-screen" style={{ background: 'var(--jsm-bg)' }}>
{/* 헤더 배너 — jsm-navy 사용 (푸터/다크 섹션 전용 토큰) */}
<div className="px-6 py-4" style={{ background: 'var(--jsm-navy)' }}>
<div className="max-w-4xl mx-auto flex items-center gap-3">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-sm" style={{ background: 'var(--jsm-accent)' }}>
</div>
<span className="text-white font-bold text-sm"></span>
<span className="ml-auto font-mono text-xs tracking-[0.2em] uppercase px-3 py-1 rounded-full border" style={{ color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)' }}>
Private · {payload.memo || 'Confidential'}
</span>
</div>
</div>
<section className="px-6 py-16 lg:px-14 lg:py-24">
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-3 mb-8">
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="font-mono text-xs text-emerald-300/80 tracking-[0.25em] uppercase">
Private Portfolio · {payload.memo || 'Confidential'}
<div className="flex items-center gap-2 mb-6">
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-xs font-semibold uppercase tracking-widest" style={{ color: 'var(--jsm-ink-soft)' }}>
</span>
</div>
<h1 className="text-4xl md:text-6xl font-extrabold leading-[1.05] mb-6" style={{ wordBreak: 'keep-all' }}>
<h1 className="text-4xl md:text-5xl font-extrabold leading-tight mb-4" style={{ color: 'var(--jsm-ink)', wordBreak: 'keep-all' }}>
<br />
<span className="bg-gradient-to-r from-sky-300 via-blue-200 to-cyan-300 bg-clip-text text-transparent">
</span>
<span className="gradient-text"> </span>
</h1>
<p className="text-slate-300 text-lg leading-relaxed max-w-2xl mb-10" style={{ wordBreak: 'keep-all' }}>
<p className="text-lg leading-relaxed max-w-2xl mb-4" style={{ color: 'var(--jsm-ink-soft)', wordBreak: 'keep-all' }}>
· · · 100% .
{expires} .
</p>
<p className="text-sm mb-10 font-mono px-3 py-2 rounded-lg inline-block" style={{ color: 'var(--jsm-ink-faint)', background: 'var(--jsm-surface-alt)', border: '1px solid var(--jsm-line)' }}>
{expires}
</p>
<div className="grid sm:grid-cols-2 gap-4">
<Link
href="/freelance"
className="group border border-white/10 hover:border-sky-400/50 rounded-2xl p-6 bg-white/[0.02] hover:bg-white/[0.05] transition-all"
className="group rounded-2xl p-6 transition-all hover:-translate-y-1"
style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}
>
<p className="font-mono text-xs text-sky-300/70 uppercase tracking-widest mb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: 'var(--jsm-accent)' }}>
Freelance
</p>
<h3 className="text-xl font-extrabold mb-2"> · </h3>
<p className="text-sm text-slate-400 leading-relaxed">
<h3 className="text-xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}> · </h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
, , , .
</p>
<span className="inline-block mt-4 text-sm font-bold text-sky-300 group-hover:underline">
<span className="inline-block mt-4 text-sm font-bold group-hover:underline" style={{ color: 'var(--jsm-accent)' }}>
</span>
</Link>
<Link
href="/services/website"
className="group border border-white/10 hover:border-violet-400/50 rounded-2xl p-6 bg-white/[0.02] hover:bg-white/[0.05] transition-all"
className="group rounded-2xl p-6 transition-all hover:-translate-y-1"
style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}
>
<p className="font-mono text-xs text-violet-300/70 uppercase tracking-widest mb-2">
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: '#7c3aed' }}>
Website
</p>
<h3 className="text-xl font-extrabold mb-2">· </h3>
<p className="text-sm text-slate-400 leading-relaxed">
<h3 className="text-xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}>· </h3>
<p className="text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
Next.js , SEO , 3 .
</p>
<span className="inline-block mt-4 text-sm font-bold text-violet-300 group-hover:underline">
<span className="inline-block mt-4 text-sm font-bold group-hover:underline" style={{ color: '#7c3aed' }}>
</span>
</Link>
</div>
<div className="mt-10 text-xs text-slate-500 font-mono">
<div className="mt-10 text-xs font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
© · 010-3907-1392 · bgg8988@gmail.com
</div>
</div>

View File

@@ -0,0 +1,36 @@
'use client';
import { useState } from 'react';
import BankTransferModal from '@/app/components/BankTransferModal';
// 상세 페이지의 구매 버튼 + 모달 트리거 (클라이언트 경계).
// 서버 페이지가 product 요약만 넘겨주고, 주문 금액은 서버(API)가 DB로 확정한다.
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
interface Props {
product: { id: string; name: string; price: number };
}
export default function BuySection({ product }: Props) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="inline-flex items-center justify-center w-full sm:w-auto px-8 py-3.5 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
>
</button>
<BankTransferModal
product={product}
isOpen={open}
onClose={() => setOpen(false)}
/>
</>
);
}

171
app/products/[id]/page.tsx Normal file
View File

@@ -0,0 +1,171 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { createAdminClient } from '@/lib/supabase/admin';
import { getProductById, type ProductRow } from '@/lib/supabase/product-files';
import BuySection from './BuySection';
// 완성 소프트웨어 상세 (서버 컴포넌트).
// 비노출/비활성/존재하지 않음/DB 예외 → notFound() 로 일관 처리해 500을 내지 않는다.
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
interface Props {
params: Promise<{ id: string }>;
}
async function loadProduct(id: string): Promise<ProductRow | null> {
try {
return await getProductById(createAdminClient(), id);
} catch (err) {
// DB 장애·마이그레이션 미적용 등 — 상세 페이지는 404로 폴백
console.error('[ProductDetail] getProductById failed:', err);
return null;
}
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const product = await loadProduct(id);
if (!product || !product.is_listed || !product.is_active) {
return { title: '완성 소프트웨어' };
}
return {
title: product.name,
description:
product.description ??
`${product.name} — 쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어. 입금 확인 후 마이페이지에서 즉시 다운로드.`,
};
}
function CheckMark() {
return (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0 mt-0.5"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
export default async function ProductDetailPage({ params }: Props) {
const { id } = await params;
const product = await loadProduct(id);
if (!product || !product.is_listed || !product.is_active) {
notFound();
}
const features = product.features ?? [];
const longText = product.description_long ?? product.description ?? '';
return (
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-14 lg:py-20">
{/* 브레드크럼 */}
<nav className="mb-8" aria-label="breadcrumb">
<Link
href="/products"
className="inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-[var(--jsm-accent)]"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="m15 18-6-6 6-6" />
</svg>
</Link>
</nav>
{/* 제품명 · 가격 */}
<header className="pb-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<h1
className="text-2xl sm:text-3xl lg:text-4xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{product.name}
</h1>
<p
className="mt-4 text-2xl font-bold"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{product.price.toLocaleString('ko-KR')}
</p>
</header>
{/* 상세 설명 */}
{longText && (
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<p
className="text-base leading-relaxed break-keep whitespace-pre-line"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{longText}
</p>
</div>
)}
{/* 기능 리스트 */}
{features.length > 0 && (
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<h2
className="text-sm font-semibold mb-4 uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
</h2>
<ul className="space-y-3">
{features.map((f) => (
<li
key={f}
className="flex items-start gap-2.5 text-sm sm:text-base break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
<span style={{ color: 'var(--jsm-accent)' }}>
<CheckMark />
</span>
<span>{f}</span>
</li>
))}
</ul>
</div>
)}
{/* 구매 안내 + CTA */}
<div className="pt-8">
<div
className="rounded-lg border px-4 py-3.5 mb-6"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
( ).
</p>
</div>
<BuySection
product={{ id: product.id, name: product.name, price: product.price }}
/>
<p className="mt-5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{' '}
<Link href="/legal/refund" className="underline" style={{ color: 'var(--jsm-ink-soft)' }}>
</Link>
.
</p>
</div>
</div>
</section>
);
}

203
app/products/page.tsx Normal file
View File

@@ -0,0 +1,203 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import { createAdminClient } from '@/lib/supabase/admin';
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
// 완성 소프트웨어 동적 카탈로그 (서버 컴포넌트). 라이트 고craft — 홈·외주와 동일 언어.
// DB 장애·마이그레이션 미적용 시 빈 배열로 폴백해 페이지가 항상 200으로 생존한다.
export const metadata: Metadata = {
title: '완성 소프트웨어',
description:
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
};
export const dynamic = 'force-dynamic';
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const HOW = [
{ n: '01', t: '계좌이체 신청', d: '구매할 도구를 고르고 입금자명과 함께 신청합니다.' },
{ n: '02', t: '입금 확인', d: '입금이 확인되면 승인합니다. 최대 24시간 내 처리됩니다.' },
{ n: '03', t: '마이페이지 다운로드', d: '마이페이지의 내 제품에서 파일을 바로 내려받습니다.' },
];
function ArrowRight() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
function CheckMark() {
return (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 shrink-0" aria-hidden>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
async function loadProducts(): Promise<ProductRow[]> {
try {
return await getListedProducts(createAdminClient());
} catch (err) {
console.error('[Products] getListedProducts failed, falling back to empty:', err);
return [];
}
}
export default async function ProductsPage() {
const products = await loadProducts();
const hasProducts = products.length > 0;
return (
<>
{/* ─── Hero ─── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 pt-20 pb-16 lg:px-8 lg:pt-28 lg:pb-20">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
software
</span>
<h1
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
<br />
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
.
</p>
</div>
</div>
</section>
{/* ─── 카탈로그 / 준비 중 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
{hasProducts ? (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{products.map((p) => {
const features = (p.features ?? []).slice(0, 3);
return (
<Link
key={p.id}
href={`/products/${p.id}`}
className="group flex flex-col rounded-2xl border p-7 transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-[var(--jsm-accent)] hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)] lg:p-8"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h2 className="break-keep text-xl font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{p.name}
</h2>
{p.description && (
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{p.description}
</p>
)}
{features.length > 0 && (
<ul className="mt-5 space-y-2">
{features.map((f) => (
<li key={f} className="flex items-start gap-2 break-keep text-sm" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
<span style={{ color: 'var(--jsm-accent)' }}>
<CheckMark />
</span>
<span>{f}</span>
</li>
))}
</ul>
)}
<div className="mt-6 flex items-center justify-between border-t pt-5" style={{ borderColor: 'var(--jsm-line)' }}>
<span className="text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
&#8361;{p.price.toLocaleString('ko-KR')}
</span>
<span className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]" style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}>
<ArrowRight />
</span>
</div>
</Link>
);
})}
</div>
) : (
<div className="rounded-2xl border px-8 py-14 text-center lg:py-16" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
coming soon
</p>
<h2 className="break-keep text-2xl font-bold lg:text-3xl" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<p className="mx-auto mt-4 max-w-md break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
,
.
.
</p>
</div>
)}
</div>
</section>
{/* ─── 구매 방식 안내 ─── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
how to buy
</p>
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-3">
{HOW.map((step) => (
<div key={step.n} className="rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
<span
className="inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
>
{step.n}
</span>
<p className="mt-5 break-keep font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{step.t}
</p>
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{step.d}
</p>
</div>
))}
</div>
</div>
</section>
{/* ─── CTA ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-20">
<div className="flex flex-col gap-4 sm:flex-row">
<Link
href="/outsourcing#contact"
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
{hasProducts ? '맞춤 개발 문의' : '출시 소식 받기'}
<ArrowRight />
</Link>
<Link
href="/outsourcing"
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 text-sm font-semibold transition-colors hover:bg-[var(--jsm-surface)]"
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink)', background: 'var(--jsm-surface)', ...KOR_BODY }}
>
</Link>
</div>
</div>
</section>
</>
);
}

View File

@@ -20,8 +20,8 @@ interface Quote {
}
const CATEGORY_COLORS: Record<string, string> = {
: '#60a5fa', : '#f472b6', : '#34d399', : '#fb923c',
: '#a78bfa', : '#94a3b8',
: '#2563eb', : '#db2777', : '#059669', : '#ea580c',
: '#7c3aed', : '#64748b',
};
export default function QuotePage() {
@@ -37,6 +37,8 @@ export default function QuotePage() {
const [activeTab, setActiveTab] = useState<'overview' | 'wbs' | 'quote' | 'maintenance'>('overview');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [rejected, setRejected] = useState(false);
const [alreadyProcessed, setAlreadyProcessed] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
@@ -89,21 +91,37 @@ export default function QuotePage() {
if (!quote) return;
setSubmitting(true);
const selectedItems = quote.items.filter((i) => !i.optional || checkedOptional[i.id]).map((i) => i.id);
await fetch(`/api/quote/${token}`, {
const res = await fetch(`/api/quote/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectedItems, selectedMaintenance, total: grandTotal }),
});
setSubmitting(false);
if (res.status === 409) { setAlreadyProcessed(true); return; }
setSubmitted(true);
}
async function handleReject() {
if (!quote) return;
const confirmed = window.confirm('견적을 거절하시겠습니까? 조건 조정이 필요하시면 회신으로 말씀해 주세요.');
if (!confirmed) return;
setSubmitting(true);
const res = await fetch(`/api/quote/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject' }),
});
setSubmitting(false);
if (res.status === 409) { setAlreadyProcessed(true); return; }
setRejected(true);
}
if (loading) {
return (
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ width: 40, height: 40, border: '3px solid rgba(99,102,241,0.3)', borderTopColor: '#6366f1', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px' }} />
<p style={{ color: '#475569', fontFamily: 'sans-serif' }}> ...</p>
<div style={{ width: 40, height: 40, border: '3px solid var(--jsm-accent-soft)', borderTopColor: 'var(--jsm-accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px' }} />
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif' }}> ...</p>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
@@ -112,35 +130,59 @@ export default function QuotePage() {
if (notFound || !quote) {
return (
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16 }}>
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16 }}>
<div style={{ fontSize: 64 }}>🔍</div>
<h1 style={{ color: 'white', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif' }}> </h1>
<p style={{ color: '#475569', fontFamily: 'sans-serif' }}> </p>
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif' }}> </h1>
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif' }}> </p>
</div>
);
}
if (submitted) {
return (
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
<style>{`@keyframes pop { 0% { transform: scale(0.5); opacity: 0; } 70% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }`}</style>
<div style={{ fontSize: 80, animation: 'pop 0.5s ease forwards' }}>🎉</div>
<h1 style={{ color: 'white', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}> !</h1>
<p style={{ color: '#94a3b8', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}> !</h1>
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
.<br />
.
</p>
<div style={{ background: '#0f172a', border: '1px solid rgba(99,102,241,0.3)', borderRadius: 16, padding: '24px 32px', textAlign: 'center' }}>
<div style={{ color: '#94a3b8', fontSize: 14, fontFamily: 'sans-serif', marginBottom: 8 }}> </div>
<div style={{ color: 'white', fontSize: 36, fontWeight: 800, fontFamily: 'sans-serif' }}>{grandTotal.toLocaleString()}</div>
<div style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-accent-soft)', borderRadius: 16, padding: '24px 32px', textAlign: 'center', boxShadow: '0 4px 20px rgba(29,78,216,0.08)' }}>
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, fontFamily: 'sans-serif', marginBottom: 8 }}> </div>
<div style={{ color: 'var(--jsm-ink)', fontSize: 36, fontWeight: 800, fontFamily: 'sans-serif' }}>{grandTotal.toLocaleString()}</div>
{maintenanceTotal > 0 && (
<div style={{ color: '#6366f1', fontSize: 14, fontFamily: 'sans-serif', marginTop: 6 }}>+ {maintenanceTotal.toLocaleString()}/</div>
<div style={{ color: 'var(--jsm-accent)', fontSize: 14, fontFamily: 'sans-serif', marginTop: 6 }}>+ {maintenanceTotal.toLocaleString()}/</div>
)}
</div>
</div>
);
}
if (rejected) {
return (
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
<style>{`@keyframes pop { 0% { transform: scale(0.5); opacity: 0; } 70% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }`}</style>
<div style={{ fontSize: 80, animation: 'pop 0.5s ease forwards' }}>🙏</div>
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}> </h1>
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
.<br />
.
</p>
</div>
);
}
if (alreadyProcessed) {
return (
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16, padding: 24 }}>
<div style={{ fontSize: 64 }}>📋</div>
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif', textAlign: 'center' }}> </h1>
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center' }}> .</p>
</div>
);
}
const tabs = [
{ key: 'overview', label: '개요' },
{ key: 'wbs', label: 'WBS', show: quote.wbs.length > 0 },
@@ -149,13 +191,12 @@ export default function QuotePage() {
].filter((t) => t.show !== false);
return (
<div style={{ background: '#0a0f1e', minHeight: '100vh', color: 'white', fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif" }}>
<div style={{ background: 'var(--jsm-bg)', minHeight: '100vh', color: 'var(--jsm-ink)', fontFamily: "'Pretendard Variable', Pretendard, 'Noto Sans KR', sans-serif" }}>
<style>{`
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
@keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
* { box-sizing: border-box; }
input[type=checkbox] { accent-color: #6366f1; width: 18px; height: 18px; cursor: pointer; }
input[type=radio] { accent-color: #6366f1; width: 18px; height: 18px; cursor: pointer; }
input[type=checkbox] { accent-color: var(--jsm-accent); width: 18px; height: 18px; cursor: pointer; }
input[type=radio] { accent-color: var(--jsm-accent); width: 18px; height: 18px; cursor: pointer; }
@media print {
html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; background: white !important; color: #1e293b !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
* { overflow: visible !important; }
@@ -173,17 +214,17 @@ export default function QuotePage() {
`}</style>
{/* 헤더 */}
<div style={{ background: 'linear-gradient(180deg, #0f172a 0%, #0a0f1e 100%)', borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '32px 24px 0' }}>
<div style={{ background: 'var(--jsm-navy)', borderBottom: '1px solid rgba(255,255,255,0.08)', padding: '32px 24px 0' }}>
<div style={{ maxWidth: 900, margin: '0 auto' }}>
{/* 브랜드 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 32 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700 }}></div>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--jsm-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700, color: 'white' }}></div>
<div>
<div style={{ color: 'white', fontWeight: 700, fontSize: 14 }}></div>
<div style={{ color: '#475569', fontSize: 11 }}>jaengseung-made.com</div>
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>jaengseung-made.com</div>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.3)', color: '#818cf8', fontSize: 11, fontWeight: 600, padding: '4px 12px', borderRadius: 100 }}>
<span style={{ background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.2)', color: 'rgba(255,255,255,0.85)', fontSize: 11, fontWeight: 600, padding: '4px 12px', borderRadius: 100 }}>
</span>
<button
@@ -197,13 +238,13 @@ export default function QuotePage() {
}}
style={{
display: 'flex', alignItems: 'center', gap: 6,
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.12)',
color: '#cbd5e1', fontSize: 13, fontWeight: 600,
background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.18)',
color: 'rgba(255,255,255,0.8)', fontSize: 13, fontWeight: 600,
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.14)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = '#cbd5e1'; }}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.18)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; e.currentTarget.style.color = 'rgba(255,255,255,0.8)'; }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
@@ -220,29 +261,29 @@ export default function QuotePage() {
</h1>
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
{quote.client_name && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
<span>👤</span> {quote.client_name}
</div>
)}
{quote.valid_until && (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
<span>📅</span> : {quote.valid_until.slice(0, 10)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
<span>📄</span> : {new Date(quote.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
</div>
{/* 탭 */}
<div className="no-print" style={{ display: isPrinting ? 'none' : 'flex', gap: 0, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<div className="no-print" style={{ display: isPrinting ? 'none' : 'flex', gap: 0, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
{tabs.map((t) => (
<button key={t.key} onClick={() => setActiveTab(t.key as typeof activeTab)}
style={{
padding: '12px 20px', fontSize: 14, fontWeight: 500, border: 'none', cursor: 'pointer',
background: 'none', color: activeTab === t.key ? '#818cf8' : '#64748b',
borderBottom: `2px solid ${activeTab === t.key ? '#6366f1' : 'transparent'}`,
background: 'none', color: activeTab === t.key ? 'white' : 'rgba(255,255,255,0.5)',
borderBottom: `2px solid ${activeTab === t.key ? 'var(--jsm-accent)' : 'transparent'}`,
transition: 'all 0.2s', marginBottom: -1,
}}>
{t.label}
@@ -255,10 +296,10 @@ export default function QuotePage() {
{/* 만료 배너 */}
{isExpired && (
<div style={{ maxWidth: 900, margin: '0 auto', padding: '16px 24px 0' }}>
<div style={{ background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.3)', borderRadius: 12, padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.3)', borderRadius: 12, padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 18 }}>&#9888;</span>
<div>
<div style={{ color: '#f59e0b', fontWeight: 700, fontSize: 14 }}> </div>
<div style={{ color: '#b45309', fontWeight: 700, fontSize: 14 }}> </div>
<div style={{ color: '#92400e', fontSize: 13 }}>({quote.valid_until?.slice(0, 10)}) . .</div>
</div>
</div>
@@ -271,16 +312,16 @@ export default function QuotePage() {
{/* ── 개요 ── */}
{(isPrinting || activeTab === 'overview') && (
<div style={{ animation: 'fadeUp 0.4s ease' }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}></h2>}
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}></h2>}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
<StatCard label="총 필수 항목" value={requiredItems.length + '개'} sub="반드시 포함" color="#60a5fa" />
<StatCard label="총 선택 항목" value={optionalItems.length + '개'} sub="고객 선택 가능" color="#a78bfa" />
<StatCard label="필수 견적 합계" value={requiredTotal.toLocaleString() + '원'} sub={'정가 ' + requiredOriginal.toLocaleString() + '원 → 40% 할인'} color="#34d399" />
<StatCard label="총 필수 항목" value={requiredItems.length + '개'} sub="반드시 포함" color="#2563eb" />
<StatCard label="총 선택 항목" value={optionalItems.length + '개'} sub="고객 선택 가능" color="#7c3aed" />
<StatCard label="필수 견적 합계" value={requiredTotal.toLocaleString() + '원'} sub={'정가 ' + requiredOriginal.toLocaleString() + '원 → 40% 할인'} color="#059669" />
<StatCard
label="선택 포함 합계"
value={grandTotal.toLocaleString() + '원'}
sub={optionalItems.filter(i => checkedOptional[i.id]).length + '개 선택됨'}
color="#f59e0b"
color="#d97706"
/>
</div>
</div>
@@ -289,16 +330,16 @@ export default function QuotePage() {
{/* ── WBS ── */}
{(isPrinting || activeTab === 'wbs') && quote.wbs.length > 0 && (
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}>WBS ( )</h2>}
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}>WBS ( )</h2>}
{quote.wbs.map((phase, pi) => (
<div key={phase.id} style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--jsm-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0, color: 'white' }}>
{pi + 1}
</div>
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'white' }}>{phase.phase}</h3>
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'var(--jsm-ink)' }}>{phase.phase}</h3>
</div>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
<colgroup>
<col style={{ width: '28%' }} />
@@ -306,7 +347,7 @@ export default function QuotePage() {
<col style={{ width: '60%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -314,10 +355,10 @@ export default function QuotePage() {
</thead>
<tbody>
{phase.tasks.map((task) => (
<tr key={task.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<tr key={task.id} style={{ borderBottom: '1px solid var(--jsm-line)' }}>
<td style={{ ...tdStyle, wordBreak: 'keep-all' }}>{task.name}</td>
<td style={{ ...tdStyle, color: '#818cf8', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
<td style={{ ...tdStyle, color: '#64748b' }}>{task.description || '—'}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-accent)', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{task.description || '—'}</td>
</tr>
))}
</tbody>
@@ -331,16 +372,16 @@ export default function QuotePage() {
{/* ── 견적 항목 ── */}
{(isPrinting || activeTab === 'quote') && (
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}> </h2>}
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}> </h2>}
{/* 필수 항목 */}
{requiredItems.length > 0 && (
<section style={{ marginBottom: 32 }}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#60a5fa', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#60a5fa', display: 'inline-block' }} />
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#2563eb', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#2563eb', display: 'inline-block' }} />
<span style={{ background: 'linear-gradient(135deg, #ef4444, #f97316)', color: 'white', fontSize: 11, fontWeight: 700, padding: '2px 10px', borderRadius: 100, marginLeft: 4 }}>40% </span>
</h3>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflowX: 'auto' }}>
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
<colgroup>
<col style={{ width: '10%' }} />
@@ -351,7 +392,7 @@ export default function QuotePage() {
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -362,17 +403,17 @@ export default function QuotePage() {
</thead>
<tbody>
{requiredItems.map((item) => (
<tr key={item.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
<tr key={item.id} style={{ borderBottom: '1px solid var(--jsm-line)' }}>
<td style={tdStyle}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#64748b') + '18', color: CATEGORY_COLORS[item.category] || '#64748b', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
{item.category}
</span>
</td>
<td style={{ ...tdStyle, fontWeight: 600, color: 'white' }}>{item.name}</td>
<td style={{ ...tdStyle, color: '#64748b' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'white', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}</td>
<td style={{ ...tdStyle, fontWeight: 600, color: 'var(--jsm-ink)' }}>{item.name}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-soft)', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-soft)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'var(--jsm-ink)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}</td>
</tr>
))}
</tbody>
@@ -380,9 +421,9 @@ export default function QuotePage() {
</div>
{/* 필수 항목 할인 소계 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12, gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ color: '#64748b', fontSize: 13, textDecoration: 'line-through', fontFamily: 'monospace' }}> {requiredOriginal.toLocaleString()}</span>
<span style={{ color: 'var(--jsm-ink-faint)', fontSize: 13, textDecoration: 'line-through', fontFamily: 'monospace' }}> {requiredOriginal.toLocaleString()}</span>
<span style={{ color: '#ef4444', fontSize: 13, fontWeight: 600 }}>{(requiredOriginal - requiredTotal).toLocaleString()} </span>
<span style={{ color: '#34d399', fontSize: 15, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
<span style={{ color: '#059669', fontSize: 15, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
</div>
</section>
)}
@@ -390,12 +431,12 @@ export default function QuotePage() {
{/* 선택 항목 */}
{optionalItems.length > 0 && (
<section style={{ marginBottom: 32 }}>
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#a78bfa', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#a78bfa', display: 'inline-block' }} />
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#7c3aed', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7c3aed', display: 'inline-block' }} />
</h3>
<p style={{ color: '#475569', fontSize: 13, marginBottom: 12 }}> </p>
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(167,139,250,0.2)', overflowX: 'auto' }}>
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 13, marginBottom: 12 }}> </p>
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid rgba(29,78,216,0.2)', overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
<colgroup>
<col style={{ width: '6%' }} />
@@ -406,7 +447,7 @@ export default function QuotePage() {
<col style={{ width: '12%' }} />
</colgroup>
<thead>
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
<th style={thStyle}></th>
<th style={thStyle}></th>
<th style={thStyle}></th>
@@ -419,19 +460,19 @@ export default function QuotePage() {
{optionalItems.map((item) => (
<tr key={item.id}
onClick={() => setCheckedOptional((prev) => ({ ...prev, [item.id]: !prev[item.id] }))}
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', background: checkedOptional[item.id] ? 'rgba(167,139,250,0.05)' : 'transparent', transition: 'background 0.2s' }}>
style={{ borderBottom: '1px solid var(--jsm-line)', cursor: 'pointer', background: checkedOptional[item.id] ? 'rgba(29,78,216,0.06)' : 'transparent', transition: 'background 0.2s' }}>
<td style={{ ...tdStyle, textAlign: 'center' }}>
<input type="checkbox" checked={!!checkedOptional[item.id]} onChange={() => {}} />
</td>
<td style={tdStyle}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
<span style={{ background: (CATEGORY_COLORS[item.category] || '#64748b') + '18', color: CATEGORY_COLORS[item.category] || '#64748b', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
{item.category}
</span>
</td>
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'white' : '#64748b' }}>{item.name}</td>
<td style={{ ...tdStyle, color: '#475569' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: '#64748b', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#a78bfa' : '#475569', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'var(--jsm-ink)' : 'var(--jsm-ink-soft)' }}>{item.name}</td>
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{item.description || '—'}</td>
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-faint)', whiteSpace: 'nowrap' }}>{item.quantity}</td>
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#7c3aed' : 'var(--jsm-ink-faint)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
{(item.unitPrice * item.quantity).toLocaleString()}
</td>
</tr>
@@ -444,24 +485,24 @@ export default function QuotePage() {
{/* 합계 */}
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ background: '#0f172a', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, padding: '24px 28px', width: 360 }}>
<div style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', borderRadius: 16, padding: '24px 28px', width: 360, boxShadow: '0 4px 16px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ color: '#64748b', fontSize: 14 }}> </span>
<span style={{ color: '#64748b', fontSize: 13, fontFamily: 'monospace', textDecoration: 'line-through' }}>{requiredOriginal.toLocaleString()}</span>
<span style={{ color: 'var(--jsm-ink-soft)', fontSize: 14 }}> </span>
<span style={{ color: 'var(--jsm-ink-faint)', fontSize: 13, fontFamily: 'monospace', textDecoration: 'line-through' }}>{requiredOriginal.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ color: '#ef4444', fontSize: 13, fontWeight: 600 }}>40% </span>
<span style={{ color: '#34d399', fontSize: 14, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
<span style={{ color: '#059669', fontSize: 14, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}</span>
</div>
{optionalTotal > 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ color: '#64748b', fontSize: 14 }}> </span>
<span style={{ color: '#a78bfa', fontSize: 14, fontFamily: 'monospace' }}>+{optionalTotal.toLocaleString()}</span>
<span style={{ color: 'var(--jsm-ink-soft)', fontSize: 14 }}> </span>
<span style={{ color: '#7c3aed', fontSize: 14, fontFamily: 'monospace' }}>+{optionalTotal.toLocaleString()}</span>
</div>
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ color: 'white', fontWeight: 700, fontSize: 16 }}> (VAT )</span>
<span style={{ color: 'white', fontWeight: 800, fontSize: 24, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}</span>
<div style={{ borderTop: '1px solid var(--jsm-line)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{ color: 'var(--jsm-ink)', fontWeight: 700, fontSize: 16 }}> (VAT )</span>
<span style={{ color: 'var(--jsm-ink)', fontWeight: 800, fontSize: 24, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}</span>
</div>
</div>
</div>
@@ -471,35 +512,36 @@ export default function QuotePage() {
{/* ── 향후 관리 ── */}
{(isPrinting || activeTab === 'maintenance') && quote.maintenance.length > 0 && (
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}> </h2>}
<p style={{ color: '#64748b', fontSize: 14, marginBottom: 20 }}> ( )</p>
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}> </h2>}
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, marginBottom: 20 }}> ( )</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16 }}>
{quote.maintenance.map((plan) => {
const isSelected = selectedMaintenance === plan.id;
return (
<div key={plan.id} onClick={() => setSelectedMaintenance(isSelected ? null : plan.id)}
style={{
background: isSelected ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.1))' : '#0f172a',
border: `1px solid ${isSelected ? '#6366f1' : 'rgba(255,255,255,0.06)'}`,
background: isSelected ? 'rgba(29,78,216,0.06)' : 'var(--jsm-surface)',
border: `1px solid ${isSelected ? 'var(--jsm-accent)' : 'var(--jsm-line)'}`,
borderRadius: 16, padding: 24, cursor: 'pointer', transition: 'all 0.25s', position: 'relative',
boxShadow: isSelected ? '0 4px 16px rgba(29,78,216,0.1)' : '0 2px 8px rgba(0,0,0,0.04)',
}}>
{plan.recommended && (
<div style={{ position: 'absolute', top: 16, right: 16, background: '#6366f1', color: 'white', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 100 }}></div>
<div style={{ position: 'absolute', top: 16, right: 16, background: 'var(--jsm-accent)', color: 'white', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 100 }}></div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
<input type="radio" checked={isSelected} onChange={() => {}} />
<div>
<div style={{ color: 'white', fontWeight: 700, fontSize: 16 }}>{plan.name}</div>
<div style={{ color: '#475569', fontSize: 13 }}>{plan.period}</div>
<div style={{ color: 'var(--jsm-ink)', fontWeight: 700, fontSize: 16 }}>{plan.name}</div>
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 13 }}>{plan.period}</div>
</div>
</div>
<div style={{ fontSize: 24, fontWeight: 800, color: isSelected ? '#818cf8' : 'white', marginBottom: 16, fontFamily: 'monospace' }}>
<div style={{ fontSize: 24, fontWeight: 800, color: isSelected ? 'var(--jsm-accent)' : 'var(--jsm-ink)', marginBottom: 16, fontFamily: 'monospace' }}>
{plan.monthlyFee === 0 ? '무료' : plan.monthlyFee.toLocaleString() + '원/월'}
</div>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ borderTop: '1px solid var(--jsm-line)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
{plan.includes.map((inc, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: '#94a3b8' }}>
<span style={{ color: '#6366f1', flexShrink: 0, marginTop: 1 }}></span>
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: 'var(--jsm-ink-soft)' }}>
<span style={{ color: 'var(--jsm-accent)', flexShrink: 0, marginTop: 1 }}></span>
{inc}
</div>
))}
@@ -513,44 +555,64 @@ export default function QuotePage() {
{/* 특이사항 */}
{quote.notes && (
<div style={{ marginTop: 40, background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', padding: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: '#475569', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}> </h3>
<p style={{ color: '#64748b', fontSize: 14, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{quote.notes}</p>
<div style={{ marginTop: 40, background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', padding: 24 }}>
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--jsm-ink-soft)', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}> </h3>
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{quote.notes}</p>
</div>
)}
</div>
{/* 하단 고정 바 — 견적 수락 */}
{quote.status !== 'accepted' && quote.status !== 'rejected' && !isExpired && (
<div className="no-print" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(10,15,30,0.95)', backdropFilter: 'blur(12px)', borderTop: '1px solid rgba(255,255,255,0.08)', padding: '16px 24px' }}>
<div className="no-print" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--jsm-navy)', borderTop: '1px solid rgba(255,255,255,0.1)', padding: '16px 24px' }}>
<div style={{ maxWidth: 900, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
<div>
<div style={{ color: '#64748b', fontSize: 13 }}> </div>
<div style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}> </div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
<span style={{ color: 'white', fontSize: 24, fontWeight: 800, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}</span>
{maintenanceTotal > 0 && selectedPlan && (
<span style={{ color: '#6366f1', fontSize: 13 }}>+ {maintenanceTotal.toLocaleString()}/ ({selectedPlan.name})</span>
<span style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>+ {maintenanceTotal.toLocaleString()}/ ({selectedPlan.name})</span>
)}
</div>
</div>
<button onClick={handleAccept} disabled={submitting}
style={{
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
boxShadow: '0 8px 32px rgba(99,102,241,0.4)',
opacity: submitting ? 0.7 : 1,
}}>
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
</button>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button onClick={handleReject} disabled={submitting}
style={{
padding: '14px 24px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.25)', cursor: 'pointer',
background: 'transparent',
color: 'rgba(255,255,255,0.75)', fontSize: 15, fontWeight: 600, transition: 'all 0.2s',
opacity: submitting ? 0.5 : 1,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.75)'; }}>
</button>
<button onClick={handleAccept} disabled={submitting}
style={{
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
background: 'var(--jsm-accent)',
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
boxShadow: '0 8px 32px rgba(29,78,216,0.4)',
opacity: submitting ? 0.7 : 1,
}}>
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
</button>
</div>
</div>
</div>
)}
{/* 수락된 경우 */}
{quote.status === 'accepted' && (
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(16,185,129,0.1)', backdropFilter: 'blur(12px)', borderTop: '1px solid rgba(16,185,129,0.3)', padding: '16px 24px', textAlign: 'center' }}>
<p style={{ color: '#34d399', fontWeight: 600, fontSize: 16 }}> </p>
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(5,150,105,0.08)', borderTop: '1px solid rgba(5,150,105,0.3)', padding: '16px 24px', textAlign: 'center' }}>
<p style={{ color: '#059669', fontWeight: 600, fontSize: 16 }}> </p>
</div>
)}
{/* 거절된 경우 */}
{quote.status === 'rejected' && (
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(100,116,139,0.08)', borderTop: '1px solid rgba(100,116,139,0.3)', padding: '16px 24px', textAlign: 'center' }}>
<p style={{ color: '#64748b', fontWeight: 600, fontSize: 16 }}> </p>
</div>
)}
@@ -562,13 +624,13 @@ export default function QuotePage() {
function StatCard({ label, value, sub, color }: { label: string; value: string; sub: string; color: string }) {
return (
<div style={{ background: '#0f172a', border: `1px solid ${color}20`, borderRadius: 16, padding: 24 }}>
<div style={{ color: '#475569', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 10 }}>{label}</div>
<div style={{ background: 'var(--jsm-surface)', border: `1px solid ${color}28`, borderRadius: 16, padding: 24, boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 10 }}>{label}</div>
<div style={{ color, fontSize: 28, fontWeight: 800, fontFamily: 'monospace', marginBottom: 4 }}>{value}</div>
<div style={{ color: '#374151', fontSize: 12 }}>{sub}</div>
<div style={{ color: 'var(--jsm-ink-faint)', fontSize: 12 }}>{sub}</div>
</div>
);
}
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.08em' };
const tdStyle: React.CSSProperties = { padding: '14px 16px', fontSize: 14, color: '#94a3b8' };
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: 'var(--jsm-ink-soft)', textTransform: 'uppercase', letterSpacing: '0.08em' };
const tdStyle: React.CSSProperties = { padding: '14px 16px', fontSize: 14, color: 'var(--jsm-ink-soft)' };

View File

@@ -6,7 +6,7 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/mypage/', '/payment/', '/freelance', '/services/website', '/portfolio/'],
disallow: ['/admin/', '/api/', '/mypage/', '/portfolio/'],
},
],
sitemap: 'https://jaengseung-made.com/sitemap.xml',

166
app/showcase/page.tsx Normal file
View File

@@ -0,0 +1,166 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { SHOWCASE_SAMPLES } from '@/lib/showcase-samples';
// 제작 사례 허브 — 웹사이트 데모 8종 + 실서비스 운영 사례. 홈·외주·제품과 동일한 라이트 카드 언어.
export const metadata: Metadata = {
title: '제작 사례 | 쟁승메이드',
description: '직접 설계·개발한 웹사이트 데모와 실서비스 운영 사례.',
};
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
// 실운영 서비스(개인 NAS 실서비스 — 외부 링크 없음, 실증 서술만)
const LIVE_SERVICES = [
{ title: '로또 분석 랩', desc: '회차 수집·통계 분석·리포트 자동 생성까지 무인 운영' },
{ title: '주식 자동매매 대시보드', desc: '시세 수집·스크리너·자동 주문을 하나의 콘솔로 운영' },
{ title: 'AI 미디어 파이프라인', desc: '음악·영상·이미지 생성 워커를 큐 기반으로 상시 가동' },
{ title: '여행 사진 갤러리', desc: '수천 장 사진의 지역 분류·썸네일·지도 탐색 자동화' },
];
function ArrowRight() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
export default function ShowcasePage() {
return (
<>
{/* ─── Hero ─── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 pt-20 pb-16 lg:px-8 lg:pt-28 lg:pb-20">
<div className="max-w-2xl">
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
showcase
</span>
<h1
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
.
</p>
</div>
</div>
</section>
{/* ─── 웹사이트 데모 ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
website demo
</p>
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
<div className="mt-12 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{SHOWCASE_SAMPLES.map((s) => (
<div
key={s.slug}
className="flex flex-col rounded-2xl border p-6 transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-[var(--jsm-accent)] hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)]"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{s.title}
</h3>
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{s.description}
</p>
{s.tags.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{s.tags.map((tag) => (
<span
key={tag}
className="rounded-full px-2.5 py-1 text-xs font-medium"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{tag}
</span>
))}
</div>
)}
<div className="mt-6 flex items-center justify-between border-t pt-5" style={{ borderColor: 'var(--jsm-line)' }}>
<Link
href={`/work/website/samples/${s.slug}`}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)]"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
))}
</div>
</div>
</section>
{/* ─── 실서비스 운영 ─── */}
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
live services
</p>
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
</h2>
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
{LIVE_SERVICES.map((svc) => (
<div
key={svc.title}
className="rounded-2xl border p-6"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<p className="break-keep font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{svc.title}
</p>
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{svc.desc}
</p>
</div>
))}
</div>
<p className="mt-8 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
.
</p>
</div>
</section>
{/* ─── CTA ─── */}
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-20">
<h2 className="break-keep text-2xl font-bold lg:text-3xl" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<div className="mt-8 flex flex-col gap-4 sm:flex-row">
<Link
href="/outsourcing#contact"
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
</section>
</>
);
}

View File

@@ -6,9 +6,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: base, lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
{ url: `${base}/packages`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${base}/services/music`, lastModified: now, changeFrequency: 'weekly', priority: 0.95 },
{ url: `${base}/saju`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
{ url: `${base}/outsourcing`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
{ url: `${base}/products`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${base}/legal/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/legal/refund`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
{ url: `${base}/legal/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },

View File

@@ -0,0 +1,719 @@
'use client';
import { useEffect, useState } from 'react';
import type { CSSProperties } from 'react';
import Link from 'next/link';
import { TAROT_DECK, SPREADS, CATEGORIES } from '@/lib/tarot/cards';
import type { TarotCard } from '@/lib/tarot/cards';
import { buildShuffle } from '@/lib/tarot/shuffle';
import type { Pick } from '@/lib/tarot/shuffle';
import { buildReferenceBlock, buildContextMeta } from '@/lib/tarot/reference';
import type { TarotInterpretation } from '@/lib/tarot/prompt';
// 타로 3카드 리딩 클라이언트 — web-ui Reading.jsx의 구조·상태머신을 참고해
// 이 저장소의 라이트(--jsm-*) 디자인 언어로 새로 작성.
// 3-step: setup(질문+카테고리) → pick(20장 부채꼴에서 3장 선택) → result(3장 + 2탭).
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
const INPUT_STYLE = {
background: 'var(--jsm-surface-alt)',
border: '1px solid var(--jsm-line)',
color: 'var(--jsm-ink)',
} as const;
const SPREAD = SPREADS[0];
const DEFAULT_CATEGORY = CATEGORIES[CATEGORIES.length - 1];
const QUESTION_MAX = 200;
const DECK_SIZE = 20;
type DeckCard = TarotCard & { reversed: boolean };
type Step = 'setup' | 'pick' | 'result';
type ResultTab = 'meaning' | 'ai';
type AiStatus = 'idle' | 'loading' | 'done' | 'auth' | 'limit' | 'error';
const STEP_LABELS: { key: Step; label: string }[] = [
{ key: 'setup', label: '질문 설정' },
{ key: 'pick', label: '카드 선택' },
{ key: 'result', label: '리딩 결과' },
];
// ── 카드 칩(카테고리) ────────────────────────────────────────────────
function Chip({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-pressed={selected}
className="rounded-lg px-4 py-2.5 text-sm font-semibold break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{
border: selected ? '1px solid var(--jsm-accent)' : '1px solid var(--jsm-line)',
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
...KOR_BODY,
}}
>
{label}
</button>
);
}
// ── 단계 인디케이터 ──────────────────────────────────────────────────
function StepIndicator({ step }: { step: Step }) {
const idx = STEP_LABELS.findIndex((s) => s.key === step);
return (
<div className="mb-8 flex items-center">
{STEP_LABELS.map((s, i) => (
<div key={s.key} className="flex flex-1 items-center last:flex-none">
<div className="flex items-center gap-2">
<span
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold"
style={{
background: i <= idx ? 'var(--jsm-accent)' : 'var(--jsm-surface-alt)',
color: i <= idx ? '#ffffff' : 'var(--jsm-ink-faint)',
border: i <= idx ? 'none' : '1px solid var(--jsm-line)',
}}
>
{i + 1}
</span>
<span
className="hidden text-xs font-semibold whitespace-nowrap sm:inline"
style={{ color: i <= idx ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)', ...KOR_BODY }}
>
{s.label}
</span>
</div>
{i < STEP_LABELS.length - 1 && (
<span
className="mx-3 h-px flex-1"
style={{ background: i < idx ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
/>
)}
</div>
))}
</div>
);
}
// ── 카드 앞면 — 이미지 실패 시 카드명·영문명 텍스트 폴백, 역방향은 180도 회전 ──
function TarotFrontFace({ card, reversed, sizeClass }: { card: TarotCard; reversed: boolean; sizeClass: string }) {
const [broken, setBroken] = useState(false);
return (
<div
className={`relative flex-shrink-0 overflow-hidden rounded-xl border ${sizeClass}`}
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}
>
{!broken ? (
<img
src={card.image}
alt={card.name}
draggable={false}
onError={() => setBroken(true)}
className="h-full w-full object-cover"
style={{ transform: reversed ? 'rotate(180deg)' : undefined }}
/>
) : (
<div
className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-center"
style={{ background: 'var(--jsm-surface-alt)' }}
>
<span className="text-xs font-bold break-keep" style={{ color: 'var(--jsm-ink)' }}>
{card.name}
</span>
<span className="text-[10px]" style={{ color: 'var(--jsm-ink-faint)' }}>
{card.nameEn}
</span>
</div>
)}
</div>
);
}
// ── 부채꼴 배치용 트랜스폼 계산 ──────────────────────────────────────
function fanCardStyle(index: number, total: number): CSSProperties {
const mid = (total - 1) / 2;
const offset = index - mid;
const rotate = offset * 3.4;
const lift = Math.abs(offset) * 2.2;
return {
transform: `rotate(${rotate}deg) translateY(${lift}px)`,
transformOrigin: 'bottom center',
marginLeft: index === 0 ? 0 : -34,
zIndex: index,
};
}
// ── 탭 1: 카드 해석(항상 표시, 정역 반영 로컬 데이터) ─────────────────
function MeaningTab({ picks }: { picks: Pick[] }) {
return (
<div className="space-y-5">
{picks.map((p) => {
const c = p.card;
const keywords = p.reversed ? c.reversedKeywords : c.keywords;
const meaning = p.reversed ? c.meaningReversed : c.meaningUpright;
return (
<div
key={p.position}
className="rounded-2xl border p-5"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}
>
<div className="mb-3 flex flex-wrap items-center gap-2">
<span
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{p.position}
</span>
<h3 className="text-sm font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{c.name} · {c.nameEn} ({p.reversed ? '역방향' : '정방향'})
</h3>
</div>
<div className="mb-3 flex flex-wrap gap-1.5">
{keywords.map((k) => (
<span
key={k}
className="rounded-full px-2 py-0.5 text-xs"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{k}
</span>
))}
</div>
<p className="mb-4 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{meaning}
</p>
<div className="space-y-1.5">
{c.symbols.map((s) => (
<p key={s.label} className="text-xs leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-faint)' }}>
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
{s.label}
</span>{' '}
{s.meaning}
</p>
))}
</div>
</div>
);
})}
</div>
);
}
const CONFIDENCE_LABEL: Record<TarotInterpretation['confidence'], string> = {
high: '높음',
medium: '보통',
low: '낮음',
};
const CONFIDENCE_COLOR: Record<TarotInterpretation['confidence'], string> = {
high: 'var(--jsm-accent)',
medium: 'var(--jsm-ink-soft)',
low: '#b45309',
};
const INTERACTION_LABEL: Record<TarotInterpretation['interactions'][number]['type'], string> = {
synergy: '시너지',
conflict: '충돌',
transition: '전환',
};
// ── 탭 2: AI 인사이트 — idle/loading/auth/limit/error/done ───────────
function AiInsightTab({
status,
errorMessage,
interpretation,
onStart,
}: {
status: AiStatus;
errorMessage: string;
interpretation: TarotInterpretation | null;
onStart: () => void;
}) {
const panelStyle = { borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' } as const;
if (status === 'idle') {
return (
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
AI가 3 . 3 .
</p>
<button
type="button"
onClick={onStart}
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
AI
</button>
</div>
);
}
if (status === 'loading') {
return (
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
<div
className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2"
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
/>
<p className="text-sm font-medium" style={{ color: 'var(--jsm-ink-soft)' }}>
AI가 ...
</p>
<p className="mt-1 text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
45 .
</p>
</div>
);
}
if (status === 'auth') {
return (
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
AI . ( 3)
</p>
<Link
href="/login?next=/tarot"
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
</Link>
</div>
);
}
if (status === 'limit') {
return (
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
{errorMessage || '오늘의 무료 AI 해석 횟수를 모두 사용했습니다.'}
</p>
<p className="mt-2 text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
.
</p>
</div>
);
}
if (status === 'error') {
return (
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
<p className="mb-4 text-sm font-medium break-keep" style={{ color: '#b91c1c' }}>
{errorMessage || 'AI 해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'}
</p>
<button type="button" onClick={onStart} className="text-sm font-semibold underline" style={{ color: 'var(--jsm-accent)' }}>
</button>
</div>
);
}
if (!interpretation) return null;
return (
<div className="space-y-5">
<div className="rounded-2xl border p-5" style={panelStyle}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h3>
<span
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: CONFIDENCE_COLOR[interpretation.confidence], ...KOR_BODY }}
>
{CONFIDENCE_LABEL[interpretation.confidence]}
</span>
</div>
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{interpretation.summary}
</p>
</div>
<div className="space-y-4">
{interpretation.cards.map((c) => (
<div key={c.position} className="rounded-2xl border p-5" style={panelStyle}>
<div className="mb-2 flex flex-wrap items-center gap-2">
<span
className="rounded-full px-2.5 py-1 text-xs font-semibold"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{c.position}
</span>
<h4 className="text-sm font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{c.card} ({c.reversed ? '역방향' : '정방향'})
</h4>
</div>
<p className="mb-3 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{c.interpretation}
</p>
<div className="mb-3 space-y-1 text-xs leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-faint)' }}>
<p>
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
·
</span>{' '}
{c.evidence.card_meaning_used}
</p>
<p>
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
·
</span>{' '}
{c.evidence.position_logic}
</p>
<p>
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
·
</span>{' '}
{c.evidence.category_lens}
</p>
</div>
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
{c.advice}
</p>
</div>
))}
</div>
{interpretation.interactions.length > 0 && (
<div className="rounded-2xl border p-5" style={panelStyle}>
<h3 className="mb-3 text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h3>
<div className="space-y-2.5">
{interpretation.interactions.map((it, i) => (
<p key={i} className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
<span
className="mr-2 rounded-full px-2 py-0.5 text-xs font-semibold"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink)' }}
>
{INTERACTION_LABEL[it.type]}
</span>
{it.between.join(' · ')} {it.explanation}
</p>
))}
</div>
</div>
)}
<div className="rounded-2xl border p-5" style={panelStyle}>
<h3 className="mb-2 text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h3>
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{interpretation.advice}
</p>
{interpretation.warning && (
<p className="mt-3 text-sm leading-relaxed break-keep" style={{ color: '#b45309', ...KOR_BODY }}>
{interpretation.warning}
</p>
)}
</div>
</div>
);
}
// ── 메인 컴포넌트 ──────────────────────────────────────────────────
export default function TarotReadingClient() {
// hydration mismatch 방지 — 최초 렌더는 빈 배열, 마운트 후 클라에서만 셔플
const [deck, setDeck] = useState<DeckCard[]>([]);
useEffect(() => {
setDeck(buildShuffle(TAROT_DECK, DECK_SIZE));
}, []);
const [step, setStep] = useState<Step>('setup');
const [question, setQuestion] = useState('');
const [category, setCategory] = useState<string>(DEFAULT_CATEGORY);
const [picks, setPicks] = useState<Pick[]>([]);
const [resultTab, setResultTab] = useState<ResultTab>('meaning');
const [aiStatus, setAiStatus] = useState<AiStatus>('idle');
const [aiErrorMessage, setAiErrorMessage] = useState('');
const [interpretation, setInterpretation] = useState<TarotInterpretation | null>(null);
const availableDeck = deck.filter((c) => !picks.some((p) => p.card.slug === c.slug));
const currentPosition = SPREAD.positions[picks.length];
function startPicking() {
setPicks([]);
setResultTab('meaning');
setAiStatus('idle');
setAiErrorMessage('');
setInterpretation(null);
setStep('pick');
}
function handlePick(card: DeckCard) {
if (picks.length >= SPREAD.positions.length) return;
const position = SPREAD.positions[picks.length];
const next: Pick[] = [...picks, { card, position, reversed: card.reversed }];
setPicks(next);
if (next.length === SPREAD.positions.length) setStep('result');
}
function restart() {
setDeck(buildShuffle(TAROT_DECK, DECK_SIZE));
setPicks([]);
setResultTab('meaning');
setAiStatus('idle');
setAiErrorMessage('');
setInterpretation(null);
setStep('setup');
}
async function handleInterpret() {
if (picks.length < SPREAD.positions.length) return;
setAiStatus('loading');
setAiErrorMessage('');
const cards = picks.map((p) => ({ position: p.position, card_id: p.card.slug, reversed: p.reversed }));
const payload = {
spread_type: 'three_card',
category,
question: question.trim() || null,
cards,
cards_reference: buildReferenceBlock(picks),
context_meta: buildContextMeta(picks),
};
try {
const res = await fetch('/api/tarot/interpret', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
let body: { interpretation_json?: TarotInterpretation; model?: string; error?: string } = {};
try {
body = await res.json();
} catch {
body = {};
}
if (res.status === 401) {
setAiStatus('auth');
return;
}
if (res.status === 429) {
setAiErrorMessage(body.error ?? '오늘의 무료 AI 해석 횟수를 모두 사용했습니다.');
setAiStatus('limit');
return;
}
if (!res.ok || !body.interpretation_json) {
setAiErrorMessage(body.error ?? 'AI 해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.');
setAiStatus('error');
return;
}
setInterpretation(body.interpretation_json);
setAiStatus('done');
// 리딩 저장은 best-effort — 실패해도 이미 렌더된 해석은 유지한다.
fetch('/api/tarot/readings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
spread_type: 'three_card',
category,
question: question.trim() || null,
cards,
interpretation_json: body.interpretation_json,
}),
}).catch(() => {});
} catch {
setAiErrorMessage('네트워크 오류로 해석을 가져오지 못했습니다.');
setAiStatus('error');
}
}
return (
<section style={{ background: 'var(--jsm-surface-alt)' }}>
<div className="mx-auto max-w-5xl px-6 py-14 lg:px-8 lg:py-20">
<div className="rounded-2xl border p-6 sm:p-10" style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}>
<StepIndicator step={step} />
{/* ── setup: 질문 + 카테고리 ── */}
{step === 'setup' && (
<div>
<h2 className="mb-1 text-lg font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. .
</p>
<label htmlFor="tarot-question" className="sr-only">
</label>
<textarea
id="tarot-question"
value={question}
onChange={(e) => setQuestion(e.target.value.slice(0, QUESTION_MAX))}
rows={4}
maxLength={QUESTION_MAX}
placeholder="예: 지금 준비 중인 이직, 시도해도 괜찮을까요?"
className="w-full resize-none rounded-lg px-3.5 py-3 text-sm leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{ ...INPUT_STYLE, ...KOR_BODY }}
/>
<p className="mt-1.5 text-right text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
{question.length}/{QUESTION_MAX}
</p>
<div className="mt-6">
<p className="mb-2.5 text-sm font-semibold" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
</p>
<div className="flex flex-wrap gap-2.5">
{CATEGORIES.map((c) => (
<Chip key={c} label={c} selected={category === c} onClick={() => setCategory(c)} />
))}
</div>
</div>
<button
type="button"
onClick={startPicking}
className="mt-8 inline-flex w-full items-center justify-center gap-2 rounded-lg py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
</button>
</div>
)}
{/* ── pick: 20장 부채꼴에서 3장 선택 ── */}
{step === 'pick' && (
<div>
<h2 className="mb-1 text-lg font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{currentPosition}
</h2>
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
. ({picks.length}/{SPREAD.positions.length})
</p>
<div className="grid grid-cols-3 gap-3">
{SPREAD.positions.map((pos, i) => {
const pick = picks[i];
return (
<div
key={pos}
className="flex flex-col items-center gap-2 rounded-xl border p-3"
style={{
borderColor: pick ? 'var(--jsm-accent)' : 'var(--jsm-line)',
background: pick ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
}}
>
<span className="text-xs font-bold" style={{ color: pick ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{pos}
</span>
{pick ? (
<TarotFrontFace card={pick.card} reversed={pick.reversed} sizeClass="h-20 w-14" />
) : (
<span
className="flex h-20 w-14 items-center justify-center rounded-lg border border-dashed text-[10px]"
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-faint)' }}
>
</span>
)}
</div>
);
})}
</div>
{deck.length === 0 ? (
<p className="mt-10 text-center text-sm" style={{ color: 'var(--jsm-ink-faint)' }}>
...
</p>
) : (
<div className="mt-8 overflow-x-auto pt-4 pb-6">
<div className="flex justify-center px-8" style={{ minWidth: 'max-content' }}>
{availableDeck.map((card, i) => (
<button
key={card.slug}
type="button"
onClick={() => handlePick(card)}
aria-label={`카드 ${i + 1} 선택`}
className="relative h-24 w-16 flex-shrink-0 rounded-lg border transition-shadow duration-150 hover:shadow-md focus-visible:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)', ...fanCardStyle(i, availableDeck.length) }}
>
<img
src="/images/tarot/card_back.png"
alt=""
aria-hidden
draggable={false}
className="h-full w-full rounded-lg object-cover"
/>
</button>
))}
</div>
</div>
)}
</div>
)}
{/* ── result: 3장 공개 + 2탭 ── */}
{step === 'result' && (
<div>
<h2 className="mb-1 text-lg font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
</h2>
<p className="mb-6 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
·· .
</p>
<div className="grid grid-cols-3 gap-4 sm:gap-6">
{picks.map((p) => (
<div key={p.position} className="flex flex-col items-center gap-3">
<TarotFrontFace card={p.card} reversed={p.reversed} sizeClass="h-40 w-28 sm:h-52 sm:w-36" />
<div className="text-center">
<p className="text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{p.position}
</p>
<p className="text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)' }}>
{p.card.name}
{p.reversed ? ' (역방향)' : ''}
</p>
</div>
</div>
))}
</div>
<div className="mt-6 flex justify-center">
<button type="button" onClick={restart} className="text-sm font-semibold underline" style={{ color: 'var(--jsm-accent)' }}>
</button>
</div>
<div className="mt-10 flex gap-1 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
{(['meaning', 'ai'] as const).map((t) => {
const active = resultTab === t;
return (
<button
key={t}
type="button"
onClick={() => setResultTab(t)}
className="border-b-2 px-4 py-3 text-sm font-semibold transition-colors duration-150"
style={{ color: active ? 'var(--jsm-ink)' : 'var(--jsm-ink-soft)', borderColor: active ? 'var(--jsm-accent)' : 'transparent', ...KOR_BODY }}
>
{t === 'meaning' ? '카드 해석' : 'AI 인사이트'}
</button>
);
})}
</div>
<div className="mt-6">
{resultTab === 'meaning' ? (
<MeaningTab picks={picks} />
) : (
<AiInsightTab status={aiStatus} errorMessage={aiErrorMessage} interpretation={interpretation} onStart={handleInterpret} />
)}
</div>
</div>
)}
</div>
</div>
</section>
);
}

14
app/tarot/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '타로 리딩 | 쟁승메이드',
description: '3카드(과거·현재·미래) 타로 스프레드. AI가 카드 상징을 근거로 해석합니다.',
openGraph: {
title: '타로 리딩 | 쟁승메이드',
url: 'https://jaengseung-made.com/tarot',
},
};
export default function TarotLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

37
app/tarot/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
import TarotReadingClient from './TarotReadingClient';
// 타로 리딩 공개 라우트 — 서버 Hero(라이트 관용구, app/showcase 참고) + 클라이언트 리딩 마운트.
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
export default function TarotPage() {
return (
<>
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 pt-20 pb-16 lg:px-8 lg:pt-28 lg:pb-20">
<div className="max-w-2xl">
<span
className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]"
style={{ color: 'var(--jsm-accent)' }}
>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
tarot reading
</span>
<h1
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
3 ·· .
</p>
</div>
</div>
</section>
<TarotReadingClient />
</>
);
}

434
app/track/[token]/page.tsx Normal file
View File

@@ -0,0 +1,434 @@
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { createAdminClient } from '@/lib/supabase/admin';
import {
REQUEST_STATUS,
TIMELINE_STEPS,
timelineIndex,
isRequestStatus,
type RequestStatus,
} from '@/lib/request-status';
// 비회원 의뢰 추적 페이지 (서버 컴포넌트).
// 고객이 이메일의 추적 링크로 로그인 없이 의뢰 진행 상태를 확인한다.
// PublicShell(TopNav+푸터) 안에서 렌더되므로 여기서는 콘텐츠 섹션만 그린다.
// API(app/api/track/[token])와 동일한 조회를 페이지에서 직접 수행한다.
// PII(이메일·전화·메시지 본문)는 select에서 제외하며, 모든 DB 예외는 notFound()로 폴백한다.
export const dynamic = 'force-dynamic';
export const metadata: Metadata = {
title: '의뢰 진행 상태',
robots: { index: false, follow: false },
};
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
interface Props {
params: Promise<{ token: string }>;
}
interface TrackRequest {
id: string;
name: string | null;
service: string | null;
status: string;
project_type: string | null;
budget: string | null;
timeline: string | null;
created_at: string;
updated_at: string | null;
}
interface TrackQuote {
public_token: string;
title: string | null;
status: string;
valid_until: string | null;
}
const QUOTE_BADGE: Record<string, { label: string; tone: 'accent' | 'muted' | 'danger' }> = {
sent: { label: '확인 대기', tone: 'accent' },
accepted: { label: '수락됨', tone: 'muted' },
rejected: { label: '거절됨', tone: 'danger' },
};
async function loadTrack(
token: string,
): Promise<{ request: TrackRequest; quote: TrackQuote | null } | null> {
if (!token || token.length > 64) return null;
try {
const admin = createAdminClient();
const { data: request, error } = await admin
.from('contact_requests')
.select('id, name, service, status, project_type, budget, timeline, created_at, updated_at')
.eq('public_token', token)
.maybeSingle();
if (error || !request) return null;
const { data: quote } = await admin
.from('quotes')
.select('public_token, title, status, valid_until')
.eq('contact_request_id', request.id)
.in('status', ['sent', 'accepted', 'rejected'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
return { request: request as TrackRequest, quote: (quote as TrackQuote) ?? null };
} catch (err) {
// DB 장애·마이그레이션 미적용(42703 등) — 추적 페이지는 404로 폴백
console.error('[Track] loadTrack failed:', err);
return null;
}
}
function fmtDate(value: string | null): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
}
function CheckIcon() {
return (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
);
}
function ArrowRight() {
return (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M5 12h14" />
<path d="m13 5 7 7-7 7" />
</svg>
);
}
/** 진행 단계 타임라인 — 모바일 세로 / 데스크톱 가로 */
function Timeline({ current }: { current: number }) {
return (
<ol className="flex flex-col md:flex-row">
{TIMELINE_STEPS.map((step, i) => {
const isDone = i < current;
const isCurrent = i === current;
const isLast = i === TIMELINE_STEPS.length - 1;
const label = REQUEST_STATUS[step].label;
// 이 단계로 들어오는 연결선이 채워졌는지(이전 단계가 지났는지)
const lineFilled = i <= current;
return (
<li
key={step}
className="flex md:flex-col md:flex-1 md:items-center md:text-center"
>
{/* 모바일: 세로 마커+연결선 / 데스크톱: 가로 */}
<div className="flex flex-col items-center md:flex-row md:w-full md:items-center">
{/* 데스크톱 좌측 연결선 (가로) */}
{i > 0 && (
<span
className="hidden md:block h-0.5 flex-1"
style={{ background: lineFilled ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
aria-hidden
/>
)}
{/* 마커 원 */}
<span
className="relative z-10 flex items-center justify-center rounded-full shrink-0 transition-colors"
style={{
width: 32,
height: 32,
background: isDone
? 'var(--jsm-accent)'
: isCurrent
? 'var(--jsm-surface)'
: 'var(--jsm-surface)',
border: isCurrent
? '2px solid var(--jsm-accent)'
: isDone
? '2px solid var(--jsm-accent)'
: '2px solid var(--jsm-line)',
color: isDone ? '#ffffff' : 'transparent',
boxShadow: isCurrent ? '0 0 0 4px var(--jsm-accent-soft)' : 'none',
}}
aria-hidden
>
{isDone ? (
<CheckIcon />
) : (
<span
className="rounded-full"
style={{
width: 8,
height: 8,
background: isCurrent ? 'var(--jsm-accent)' : 'var(--jsm-line)',
}}
/>
)}
</span>
{/* 데스크톱 우측 연결선 (가로) */}
{!isLast && (
<span
className="hidden md:block h-0.5 flex-1"
style={{ background: i < current ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
aria-hidden
/>
)}
{/* 모바일 세로 연결선 */}
{!isLast && (
<span
className="md:hidden w-0.5 flex-1 my-1"
style={{
minHeight: 28,
background: i < current ? 'var(--jsm-accent)' : 'var(--jsm-line)',
}}
aria-hidden
/>
)}
</div>
{/* 라벨 */}
<div className="pl-4 pb-6 md:pl-0 md:pb-0 md:mt-3">
<span
className="text-sm break-keep"
style={{
color: isDone || isCurrent ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)',
fontWeight: isCurrent ? 700 : 500,
...KOR_BODY,
}}
>
{label}
</span>
{isCurrent && (
<span
className="block text-xs mt-0.5"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
</span>
)}
</div>
</li>
);
})}
</ol>
);
}
export default async function TrackPage({ params }: Props) {
const { token } = await params;
const data = await loadTrack(token);
if (!data) notFound();
const { request, quote } = data;
const status: RequestStatus = isRequestStatus(request.status) ? request.status : 'pending';
const current = timelineIndex(status);
const receivedAt = fmtDate(request.created_at);
const info: { label: string; value: string }[] = [];
if (request.project_type) info.push({ label: '프로젝트 유형', value: request.project_type });
if (request.budget) info.push({ label: '예산', value: request.budget });
if (request.timeline) info.push({ label: '희망 일정', value: request.timeline });
const quoteBadge = quote ? QUOTE_BADGE[quote.status] ?? null : null;
const quoteValidUntil = quote ? fmtDate(quote.valid_until) : null;
return (
<section style={{ background: 'var(--jsm-bg)' }}>
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-14 lg:py-20">
{/* ─── 헤더 ─── */}
<header className="pb-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<span
className="inline-block text-xs font-semibold mb-4 px-2.5 py-1 rounded"
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)', ...KOR_BODY }}
>
</span>
<h1
className="text-2xl sm:text-3xl font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{request.service ?? '의뢰하신 프로젝트'}
</h1>
{receivedAt && (
<p className="mt-3 text-sm" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
{receivedAt}
</p>
)}
</header>
{/* ─── 진행 상태 ─── */}
<div className="py-10 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
{status === 'cancelled' ? (
<div
className="rounded-2xl border px-6 py-8 text-center"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<h2
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
</h2>
<p
className="mt-2 text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
. .
</p>
</div>
) : (
<>
{status === 'on_hold' && (
<div
className="mb-8 rounded-xl border px-4 py-3.5"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<p
className="text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
.
</p>
</div>
)}
<Timeline current={current} />
</>
)}
</div>
{/* ─── 의뢰 정보 ─── */}
{info.length > 0 && (
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<h2
className="text-sm font-semibold mb-4 uppercase tracking-wider"
style={{ color: 'var(--jsm-accent)' }}
>
</h2>
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-4">
{info.map((item) => (
<div key={item.label}>
<dt
className="text-xs mb-1"
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
>
{item.label}
</dt>
<dd
className="text-sm font-medium break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
>
{item.value}
</dd>
</div>
))}
</dl>
</div>
)}
{/* ─── 견적 카드 ─── */}
{quote && (
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
<div
className="rounded-2xl border p-6 lg:p-7"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-accent)' }}
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-xs font-semibold uppercase tracking-wider mb-2"
style={{ color: 'var(--jsm-accent)' }}
>
</p>
<h2
className="text-lg font-bold break-keep"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{quote.title ?? '프로젝트 견적서'}
</h2>
</div>
{quoteBadge && (
<span
className="shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full"
style={
quoteBadge.tone === 'accent'
? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }
: quoteBadge.tone === 'danger'
? { color: '#b91c1c', background: '#fee2e2' }
: { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }
}
>
{quoteBadge.label}
</span>
)}
</div>
{quoteValidUntil && (
<p className="mt-3 text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{quoteValidUntil}
</p>
)}
<Link
href={`/quote/${quote.public_token}`}
className="mt-5 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-lg font-semibold text-white transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
>
<ArrowRight />
</Link>
</div>
</div>
)}
{/* ─── 하단 안내 ─── */}
<div className="pt-8">
<p
className="text-sm leading-relaxed break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{' '}
<a
href="mailto:bgg8988@gmail.com"
className="font-medium underline"
style={{ color: 'var(--jsm-accent)' }}
>
bgg8988@gmail.com
</a>{' '}
.
</p>
</div>
</div>
</section>
);
}

View File

@@ -1,27 +0,0 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '외주 개발 의뢰',
description:
'계약서 먼저, 납기 지키고, 소스코드 100% 인도. 47건 납품 완료. 현직 실무 엔지니어에게 외주 개발을 맡겨보세요. 납기 지연 시 하루 10만 원 패널티.',
keywords: [
'외주 개발',
'프리랜서 개발자',
'웹 개발 외주',
'앱 개발 외주',
'RPA 개발',
'업무 자동화 외주',
'소프트웨어 개발',
],
openGraph: {
title: '외주 개발 의뢰 | 쟁승메이드',
description:
'47건 납품 완료. 계약서 먼저, 납기 패널티, 소스코드 100% 인도. 연락 두절 없는 개발자.',
url: 'https://jaengseung-made.com/work/freelance',
},
robots: { index: false, follow: false },
};
export default function FreelanceLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -1,644 +0,0 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import ContactForm from '@/app/components/ContactForm';
import { PORTFOLIO as portfolio } from '@/lib/freelance-portfolio';
/* ─── Data ─── */
const testimonials = [
{
name: '이서준',
role: '온라인 쇼핑몰 운영자',
project: '경쟁사 가격 모니터링 봇',
content: '경쟁사 10곳 가격을 매일 수동으로 확인했는데 이제 텔레그램으로 자동 알림 받습니다. 납기도 정확히 지켜주셨고, 완료 후에도 작은 수정 요청에 빠르게 응답해주셔서 믿음이 갔습니다.',
result: '가격 모니터링 시간 → 0분/일',
accentColor: 'bg-emerald-500',
borderColor: 'border-emerald-200',
tagColor: 'text-emerald-700 bg-emerald-50 border-emerald-200',
},
{
name: '박하은',
role: '스타트업 운영팀장',
project: 'Excel 보고서 자동화 시스템',
content: '매주 월요일 아침 2시간씩 쓰던 Excel 집계 작업을 자동화했습니다. 처음엔 반신반의했는데 계약서부터 작성해주셔서 진짜 전문가구나 싶었고, 결과물도 기대 이상이었습니다.',
result: '주간 보고 작업 2시간 → 5분',
accentColor: 'bg-blue-500',
borderColor: 'border-blue-200',
tagColor: 'text-blue-700 bg-blue-50 border-blue-200',
},
{
name: '김도윤',
role: '프리랜서 디자이너',
project: '포트폴리오 웹사이트 제작',
content: '이전에 다른 개발자한테 맡겼다가 중간에 연락이 끊겼던 경험이 있어서 많이 걱정했는데, 주 1회 진행 보고를 꼬박꼬박 해주시고 최종 소스코드까지 전달해주셔서 정말 만족했습니다.',
result: '2주 납품 약속 정확히 이행',
accentColor: 'bg-violet-500',
borderColor: 'border-violet-200',
tagColor: 'text-violet-700 bg-violet-50 border-violet-200',
},
];
const process = [
{
num: '01',
title: '무료 상담',
desc: '전화 또는 이메일로 요구사항 파악 (30분 이내)',
sub: '비용 없음 · 부담 없음',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
),
},
{
num: '02',
title: '견적 제안',
desc: '개발 범위, 일정, 비용 상세 견적서 제공',
sub: '1~3일 이내 발송',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
num: '03',
title: '계약 체결',
desc: '계약서 작성 및 계약금(30%) 입금 후 개발 시작',
sub: '계약서 포함 · 안전 거래',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
{
num: '04',
title: '개발 진행',
desc: '주 1회 이상 진행 상황 공유 및 중간 검수',
sub: '투명한 진행 보고',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
highlight: true,
},
{
num: '05',
title: '최종 납품',
desc: '완성본 인도 + 사용 교육 + 소스코드 전달',
sub: '소스코드 전체 제공',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 13l4 4L19 7" />
</svg>
),
},
{
num: '06',
title: 'AS 지원',
desc: '1개월 무상 기술 지원 및 평생 유지보수 가능',
sub: '1개월 무상 + 평생 AS',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
),
},
];
const guarantees = [
{
label: '계약서 필수',
detail: '구두 약속 없음 — 착수 전 계약서 발송',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
accentText: 'text-sky-400',
accentBorder: 'border-sky-400/20',
},
{
label: '납기 지연 패널티',
detail: '하루 지연 = 10만원 감면 — 그래서 안 늦습니다',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
accentText: 'text-amber-400',
accentBorder: 'border-amber-400/20',
},
{
label: '소스코드 100% 인도',
detail: '납품 후 전체 소스코드 + 배포 가이드 제공',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
accentText: 'text-emerald-400',
accentBorder: 'border-emerald-400/20',
},
{
label: '1개월 무상 AS',
detail: '납품 후 한 달 — 버그·수정 무상 대응',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M18.364 5.636l-3.536 3.536m0 5.656l3.536 3.536M9.172 9.172L5.636 5.636m3.536 9.192l-3.536 3.536M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-5 0a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
accentText: 'text-violet-400',
accentBorder: 'border-violet-400/20',
},
{
label: '실시간 진행 현황',
detail: '마이페이지에서 7단계 진행 상황 직접 확인',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
accentText: 'text-cyan-400',
accentBorder: 'border-cyan-400/20',
},
];
/* ─── Scroll Reveal ─── */
function useScrollReveal() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
},
{ threshold: 0.1, rootMargin: '0px 0px -40px 0px' }
);
el.querySelectorAll('.reveal').forEach((child) => observer.observe(child));
return () => observer.disconnect();
}, []);
return ref;
}
/* ─── Main Page ─── */
export default function FreelancePage() {
const [_contactPreset] = useState('');
const containerRef = useScrollReveal();
return (
<div ref={containerRef} className="min-h-full bg-[#f0f5ff]">
<style>{`
.reveal {
opacity: 0;
transform: translateY(1.5rem);
transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
.reveal-d1 { transition-delay: 80ms; }
.reveal-d2 { transition-delay: 160ms; }
.reveal-d3 { transition-delay: 240ms; }
.reveal-d4 { transition-delay: 320ms; }
`}</style>
{/* ─── Hero ─── */}
<div
className="relative overflow-hidden bg-[#04102b] px-6 py-14 lg:px-12"
style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px), repeating-linear-gradient(45deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}
>
<div className="relative max-w-5xl mx-auto">
<div className="mb-10">
<div className="inline-flex items-center gap-2 bg-emerald-400/10 border border-emerald-400/20 text-emerald-300 text-xs font-semibold px-4 py-2 rounded-full mb-5">
<span className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
</div>
<h1 className="text-4xl md:text-5xl font-extrabold text-white tracking-tight leading-tight mb-4">
? .<br />
<span className="text-[#5ba4ff]"> , </span>
</h1>
<p className="text-blue-200/60 text-base md:text-lg max-w-xl leading-relaxed mb-2">
?<br />
, , .
</p>
</div>
{/* Developer tag */}
<div className="flex items-center gap-4 bg-white/5 border border-white/10 rounded-2xl px-6 py-3 mb-8 w-fit">
<div className="w-10 h-10 rounded-full bg-[#1a56db] flex items-center justify-center text-white font-extrabold text-sm flex-shrink-0">
</div>
<div>
<div className="text-white font-bold text-sm"> ()</div>
<div className="text-blue-300/50 text-xs"> · Python / Java / Next.js</div>
</div>
<div className="flex flex-wrap gap-2">
{['Python', 'Java', 'Next.js', 'Docker'].map(t => (
<span key={t} className="bg-[#1a56db]/20 border border-[#1a56db]/30 text-[#5ba4ff] text-xs px-2 py-0.5 rounded-md font-mono">{t}</span>
))}
</div>
</div>
{/* 보증 카드 4개 */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{guarantees.map((g) => (
<div key={g.label} className={`bg-[#04102b]/60 border ${g.accentBorder} rounded-xl p-4`}>
<div className={`${g.accentText} mb-2`}>{g.icon}</div>
<div className="text-white font-bold text-sm mb-1">{g.label}</div>
<div className="text-blue-300/40 text-xs leading-relaxed">{g.detail}</div>
</div>
))}
</div>
</div>
</div>
{/* ─── 포트폴리오 ─── */}
<div id="automation" className="px-6 py-12 lg:px-12 scroll-mt-20">
<div className="max-w-5xl mx-auto">
<div className="reveal text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">PORTFOLIO</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]"> </h2>
<p className="text-slate-500 text-sm mt-2"> </p>
</div>
<div className="reveal grid sm:grid-cols-2 lg:grid-cols-3 gap-5">
{portfolio.map((item) => (
<div
key={item.title}
className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden hover:shadow-xl hover:shadow-blue-100 hover:-translate-y-1 transition-all duration-200 group"
>
{/* card header */}
<div className={`px-5 pt-5 pb-8 ${item.accentBg}`}>
<div className="flex items-start justify-between">
<div>
<div className={`text-xs font-bold mb-2 uppercase tracking-wider ${item.accentColor}`}>{item.category}</div>
<h3 className="text-white font-extrabold text-sm leading-snug">{item.title}</h3>
</div>
{item.statusType === 'live' ? (
<div className="flex items-center gap-1.5 bg-emerald-400/20 border border-emerald-400/30 text-emerald-300 text-xs font-bold px-2.5 py-1 rounded-full flex-shrink-0 ml-2">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
</div>
) : (
<div className="flex items-center gap-1.5 bg-blue-400/20 border border-blue-400/30 text-blue-300 text-xs font-bold px-2.5 py-1 rounded-full flex-shrink-0 ml-2">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</div>
{/* card body */}
<div className="px-5 py-4 -mt-3 relative">
<p className="text-slate-600 text-xs leading-relaxed mb-3">{item.desc}</p>
{item.result && (
<div className="flex items-start gap-1.5 bg-emerald-50 border border-emerald-200 rounded-lg px-3 py-2 mb-3">
<svg className="w-3.5 h-3.5 text-emerald-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
<span className="text-emerald-700 text-xs font-semibold leading-snug">{item.result}</span>
</div>
)}
<div className="flex flex-wrap gap-1.5">
{item.tags.map((tag) => (
<span key={tag} className="bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] text-xs font-mono px-2 py-0.5 rounded-md">
{tag}
</span>
))}
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-100">
<span className="text-xs text-blue-600 font-semibold bg-blue-50 px-2 py-0.5 rounded-full">{item.priceRange}</span>
<a href="#contact-form" className="inline-flex items-center gap-1 text-sm text-blue-600 hover:text-blue-800 font-medium transition">
</a>
</div>
</div>
</div>
))}
</div>
{/* 추가 문구 */}
<div className="reveal mt-6 text-center">
<p className="text-slate-400 text-sm">
·{' '}
<a href="mailto:bgg8988@gmail.com" className="text-[#1a56db] hover:underline font-medium"> </a>
</p>
</div>
</div>
</div>
{/* ─── 고객 후기 ─── */}
<div className="px-6 pb-12 lg:px-12">
<div className="max-w-5xl mx-auto">
<div className="reveal text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">REVIEWS</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]"> </h2>
<p className="text-slate-500 text-sm mt-2" style={{ wordBreak: 'keep-all' }}> </p>
</div>
<div className="reveal grid sm:grid-cols-2 md:grid-cols-3 gap-5">
{testimonials.map((t) => (
<div
key={t.name}
className={`bg-white rounded-2xl border-2 ${t.borderColor} p-6 flex flex-col hover:shadow-lg hover:-translate-y-0.5`}
style={{ transition: 'all 0.4s cubic-bezier(0.16, 1, 0.3, 1)' }}
>
{/* 별점 */}
<div className="flex items-center gap-0.5 mb-4">
{[1,2,3,4,5].map((n) => (
<svg key={n} className="w-4 h-4 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
{/* 후기 내용 */}
<p className="text-slate-600 text-sm leading-relaxed flex-1 mb-5" style={{ wordBreak: 'keep-all' }}>
&ldquo;{t.content}&rdquo;
</p>
{/* 결과 뱃지 */}
<div className={`text-xs font-bold px-3 py-1.5 rounded-lg border mb-4 ${t.tagColor}`} style={{ wordBreak: 'keep-all' }}>
{t.result}
</div>
{/* 의뢰인 */}
<div className="flex items-center gap-3 pt-4 border-t border-slate-100">
<div className={`w-9 h-9 rounded-full ${t.accentColor} flex items-center justify-center text-white font-extrabold text-sm flex-shrink-0`}>
{t.name[0]}
</div>
<div>
<div className="font-bold text-[#04102b] text-sm">{t.name}</div>
<div className="text-slate-400 text-xs">{t.role} · {t.project}</div>
</div>
</div>
</div>
))}
</div>
<p className="text-center text-slate-400 text-xs mt-5">
* . .
</p>
<div className="reveal text-center py-6">
<a href="#contact-form" className="inline-flex items-center gap-2 px-6 py-3 bg-[#1a56db] text-white font-semibold rounded-xl hover:bg-blue-700 transition shadow-sm">
</a>
<p className="text-sm text-slate-400 mt-2">24 · </p>
</div>
</div>
</div>
{/* ─── 진행 프로세스 ─── */}
<div className="px-6 pb-12 lg:px-12">
<div className="max-w-3xl mx-auto">
<div className="reveal text-center mb-10">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">PROCESS</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]"> </h2>
<p className="text-slate-500 text-sm mt-2"> 6 </p>
</div>
{/* Vertical timeline */}
<div className="reveal relative">
{/* connecting line */}
<div className="absolute left-6 top-6 bottom-6 w-px bg-[#dbe8ff]" />
<div className="space-y-4">
{process.map((p) => (
<div key={p.num} className="relative flex gap-5">
{/* step circle */}
<div className={`relative z-10 w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 shadow-lg ${
p.highlight
? 'bg-[#1a56db] shadow-blue-500/30 border border-[#1a56db]/50'
: 'bg-white border-2 border-[#dbe8ff]'
}`}>
<span className={p.highlight ? 'text-white' : 'text-[#1a56db]'}>{p.icon}</span>
</div>
{/* content */}
<div
className={`flex-1 rounded-2xl border p-5 mb-0 ${
p.highlight
? 'border-[#1a56db]/40'
: 'bg-white border-[#dbe8ff]'
}`}
style={p.highlight ? {
background: '#04102b',
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 30px)',
} : {}}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-bold font-mono ${p.highlight ? 'text-[#5ba4ff]' : 'text-slate-400'}`}>STEP {p.num}</span>
{p.highlight && (
<span className="bg-[#1a56db]/30 border border-[#1a56db]/40 text-[#5ba4ff] text-xs font-bold px-2 py-0.5 rounded-md"> </span>
)}
</div>
<h3 className={`font-extrabold text-sm mb-1 ${p.highlight ? 'text-white' : 'text-[#04102b]'}`}>{p.title}</h3>
<p className={`text-xs leading-relaxed ${p.highlight ? 'text-blue-200/60' : 'text-slate-500'}`}>{p.desc}</p>
</div>
<div className={`text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap flex-shrink-0 ${
p.highlight
? 'bg-[#1a56db]/30 text-[#5ba4ff]'
: 'bg-[#f0f5ff] text-[#1a56db] border border-[#dbe8ff]'
}`}>
{p.sub}
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* ─── 기술 스택 & 신뢰 ─── */}
<div className="px-6 pb-12 lg:px-12">
<div className="max-w-5xl mx-auto grid md:grid-cols-2 gap-5">
{/* Tech Stack */}
<div className="reveal reveal-d1 bg-white rounded-2xl border border-[#dbe8ff] p-6">
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-5 bg-[#1a56db] rounded-full" />
<h3 className="font-bold text-[#04102b] text-sm"> </h3>
</div>
<div className="space-y-3">
{[
{ label: 'Backend', techs: ['Python', 'Java', 'Spring Boot', 'FastAPI', 'Node.js'] },
{ label: 'Frontend', techs: ['Next.js', 'React', 'TypeScript', 'Tailwind CSS'] },
{ label: 'Database', techs: ['PostgreSQL', 'MySQL', 'Redis', 'SQLite'] },
{ label: 'Infra / API', techs: ['Docker', 'AWS', 'Telegram API', '공공 API'] },
].map((group) => (
<div key={group.label}>
<div className="text-xs font-bold text-slate-400 mb-1.5 uppercase tracking-wider">{group.label}</div>
<div className="flex flex-wrap gap-1.5">
{group.techs.map((t) => (
<span key={t} className="bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] text-xs font-mono px-2.5 py-1 rounded-lg">
{t}
</span>
))}
</div>
</div>
))}
</div>
</div>
{/* 신뢰 포인트 */}
<div
className="reveal reveal-d2 rounded-2xl border border-[#1a3a7a] p-6"
style={{
background: '#04102b',
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 30px)',
}}
>
<div className="flex items-center gap-2 mb-4">
<div className="w-1 h-5 bg-[#5ba4ff] rounded-full" />
<h3 className="font-bold text-white text-sm"> </h3>
</div>
<ul className="space-y-3.5">
{[
{
title: '지금 URL로 직접 확인',
desc: 'jaengseung-made.com — 로또 분석, 주식 자동매매 지금도 운영 중',
icon: (
<svg className="w-4 h-4 text-[#5ba4ff] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
},
{
title: '계약서 먼저, 개발 나중',
desc: '구두 약속 없음 — 견적서·계약서 발송 후 착수',
icon: (
<svg className="w-4 h-4 text-[#5ba4ff] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
title: '납품 전 전액 환불 보장',
desc: '마음에 안 드시면 이유 불문 전액 환불',
icon: (
<svg className="w-4 h-4 text-[#5ba4ff] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
},
{
title: '소스코드 100% 인도',
desc: '완성 후 전체 소스코드 + 배포 가이드 제공',
icon: (
<svg className="w-4 h-4 text-[#5ba4ff] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
},
{
title: '납기 지연 시 패널티',
desc: '하루 늦을 때마다 10만원 감면 — 그래서 안 늦습니다',
icon: (
<svg className="w-4 h-4 text-[#5ba4ff] flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
].map((item) => (
<li key={item.title} className="flex items-start gap-3">
{item.icon}
<div>
<div className="text-white text-sm font-bold">{item.title}</div>
<div className="text-blue-300/50 text-xs">{item.desc}</div>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
{/* ─── 문의 폼 ─── */}
<div id="contact-form" className="px-6 pb-14 lg:px-12">
<div className="max-w-5xl mx-auto">
<div className="reveal text-center mb-8">
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">CONTACT</p>
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b]"> </h2>
<p className="text-slate-500 text-sm mt-2"> ? .</p>
</div>
<div className="reveal grid md:grid-cols-5 gap-6">
{/* 왼쪽: 간단 안내 */}
<div className="md:col-span-2 space-y-4">
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-5">
<h3 className="font-bold text-[#04102b] text-sm mb-4"> </h3>
<ul className="space-y-2.5">
{[
'어떤 업무를 자동화/개발하고 싶은지',
'현재 사용 중인 시스템 (엑셀, ERP 등)',
'희망하는 완성 일정',
'예산 범위 (대략적으로도 OK)',
].map((item, i) => (
<li key={item} className="flex items-start gap-2.5 text-xs text-slate-600">
<span className="w-5 h-5 rounded-full bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] font-bold text-xs flex items-center justify-center flex-shrink-0">{i + 1}</span>
{item}
</li>
))}
</ul>
</div>
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-5">
<h3 className="font-bold text-[#04102b] text-sm mb-3"> </h3>
<div className="space-y-2.5">
<a href="mailto:bgg8988@gmail.com" className="flex items-center gap-2.5 text-sm text-slate-600 hover:text-[#1a56db] transition group">
<div className="w-8 h-8 rounded-lg bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center group-hover:bg-[#1a56db] group-hover:border-[#1a56db] transition">
<svg className="w-4 h-4 text-[#1a56db] group-hover:text-white transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
bgg8988@gmail.com
</a>
<a href="tel:010-3907-1392" className="flex items-center gap-2.5 text-sm text-slate-600 hover:text-[#1a56db] transition group">
<div className="w-8 h-8 rounded-lg bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center group-hover:bg-[#1a56db] group-hover:border-[#1a56db] transition">
<svg className="w-4 h-4 text-[#1a56db] group-hover:text-white transition" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
</div>
010-3907-1392
</a>
</div>
</div>
<div
className="rounded-2xl border border-[#1a3a7a] p-5 text-center"
style={{ background: '#04102b' }}
>
<div className="text-2xl font-extrabold text-white mb-0.5">24h</div>
<div className="text-[#5ba4ff] text-xs font-bold mb-1"> </div>
<div className="text-blue-300/40 text-xs"> · </div>
</div>
</div>
{/* 오른쪽: 폼 */}
<div className="md:col-span-3 bg-white rounded-2xl border border-[#dbe8ff] p-6">
<ContactForm />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,134 +0,0 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import ContactModal from '@/app/components/ContactModal';
import { PORTFOLIO } from '@/lib/freelance-portfolio';
import { trackCTAClick } from '@/lib/gtag';
const CARDS = [
{
href: '/work/freelance',
label: '외주 개발',
desc: '맞춤 솔루션 외주 · RPA·API 연동·자동화 포함',
key: 'freelance',
},
{
href: '/work/website',
label: '웹사이트 제작',
desc: '기업·브랜드 사이트 · Next.js + SEO + 배포',
key: 'website',
},
{
href: '/work/saju',
label: 'AI 사주',
desc: 'AI 사주팔자 + 12개 항목 해석 (무료)',
key: 'saju',
},
];
export default function WorkHub() {
const [modalOpen, setModalOpen] = useState(false);
const [modalService, setModalService] = useState('외주 개발 문의');
const openContact = (service: string) => {
setModalService(service);
setModalOpen(true);
};
return (
<div className="min-h-screen bg-black text-white">
<ContactModal
isOpen={modalOpen}
onClose={() => {
setModalOpen(false);
setModalService('외주 개발 문의');
}}
service={modalService}
checklist={['연락처/이메일', '원하는 작업 범위', '희망 일정']}
/>
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10">
<div className="absolute inset-0 bg-gradient-to-b from-[#060e20] to-black pointer-events-none" />
<div className="relative z-10 max-w-3xl mx-auto text-center">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
Custom Work
</p>
<h1
className="kx-display text-4xl md:text-6xl font-bold mb-5"
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
>
</h1>
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
7 ··. , , AI .
</p>
</div>
</section>
<section className="py-20 px-6">
<div className="max-w-6xl mx-auto grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{CARDS.map((c) => (
<Link
key={c.key}
href={c.href}
onClick={() => trackCTAClick(`work_hub_card_${c.key}`)}
className="group rounded-2xl border border-white/15 bg-white/[0.02] p-5 hover:border-white/40 hover:bg-white/[0.05] transition flex flex-col"
style={{ textDecoration: 'none' }}
>
<p className="font-bold text-white text-sm mb-1.5">{c.label}</p>
<p className="text-xs text-white/60 leading-relaxed flex-1">{c.desc}</p>
<span aria-hidden="true" className="mt-3 text-white/40 text-xs"></span>
</Link>
))}
</div>
</section>
<section className="py-20 px-6 bg-white/[0.02] border-t border-white/10">
<div className="max-w-6xl mx-auto">
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4 text-center">
Recent Deliveries
</p>
<h2 className="kx-display text-2xl md:text-3xl font-bold text-center mb-10">
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-3">
{PORTFOLIO.map((p) => (
<div
key={p.title}
className={`p-5 rounded-2xl border ${p.borderAccent} ${p.accentBg} flex flex-col`}
>
<p className={`font-mono text-[10px] uppercase tracking-widest ${p.accentColor} mb-2`}>
{p.category}
</p>
<h3 className="font-bold text-white text-sm leading-tight mb-2">{p.title}</h3>
<p className="text-xs text-white/60 line-clamp-3 flex-1">{p.result}</p>
<p className="text-xs text-white/40 mt-3">{p.priceRange}</p>
</div>
))}
</div>
</div>
</section>
<section className="py-20 px-6 border-t border-white/10">
<div className="max-w-3xl mx-auto text-center">
<h2 className="kx-display text-2xl md:text-4xl font-bold mb-5">
?
</h2>
<p className="text-base text-white/70 mb-8">
+ + 24 .
</p>
<button
onClick={() => {
trackCTAClick('work_hub_cta');
openContact('외주 개발 문의');
}}
className="kx-btn-primary inline-flex items-center px-7 py-3 rounded-full text-sm"
>
</button>
</div>
</section>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More