Compare commits
80 Commits
055469a2d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a67a9d812 | |||
| 677012a9c8 | |||
| 468ee84687 | |||
| 39025fc57b | |||
| 7cd63a3868 | |||
| 895b33d83d | |||
| 2aa424f3ce | |||
| 0742059db2 | |||
| 7100842179 | |||
| da33254076 | |||
| a1a281d059 | |||
| a5b47a0278 | |||
| d5be617eb2 | |||
| abec100a73 | |||
| fc55e6a928 | |||
| 4f41f09a8c | |||
| 5ace251b58 | |||
| 15825616a3 | |||
| fa9cda4f50 | |||
| 5e79ea9233 | |||
| 57a95dee16 | |||
| e50b5a6dc9 | |||
| 65ff294e89 | |||
| 124478e3d6 | |||
| 96a0b06706 | |||
| 26fef53174 | |||
| 5fd7ab8872 | |||
| a9f5d8cee6 | |||
| b3d845a532 | |||
| 10a60300ae | |||
| 3acc1dbbe6 | |||
| 84b36267bf | |||
| 53e8b592f0 | |||
| 1752e68d55 | |||
| 19a5559899 | |||
| 878c0fbf49 | |||
| 57f6eb6684 | |||
| d5f194e7b1 | |||
| a85758566a | |||
| f693c4c5b4 | |||
| 3e031a1c80 | |||
| 90be0d6316 | |||
| 976511df44 | |||
| 3db3d91a40 | |||
| e5ff5ec84f | |||
| 6234f4277a | |||
| 559134100d | |||
| 1b75b27188 | |||
| 7366c18692 | |||
| 8c5858b350 | |||
| 592b3fcf4e | |||
| 1e926fcb19 | |||
| 8e1cf9b4e1 | |||
| 88fe56163d | |||
| 0c6ebb2eaa | |||
| 9241eac4e1 | |||
|
|
65f0a6bb41 | ||
| 7e1105f574 | |||
| f4fd0f60c9 | |||
| 37465701af | |||
| c3be57ea1f | |||
| 897e37f14e | |||
| 7c6238508b | |||
| 989cc25465 | |||
| c1afb58bcd | |||
| b2bd7b1b31 | |||
| e5b907dc38 | |||
| d10fe981f0 | |||
| b705f35c2d | |||
| 4cd4a50869 | |||
| 01c31e3e5d | |||
| e22622d36d | |||
| 186ae546f2 | |||
| eb1ecf0021 | |||
| 4b85c52cfe | |||
| 4223004c24 | |||
| bd13641f5e | |||
| 5cfa124d38 | |||
| 64259a85b5 | |||
| 70068ff3d7 |
36
CLAUDE.md
36
CLAUDE.md
@@ -18,10 +18,14 @@
|
||||
| `/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` | 4탭: 프로필 / 내 의뢰(타임라인) / 내 제품(다운로드) / 주문 내역 |
|
||||
| `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
|
||||
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||
|
||||
## 숨김 서비스 (admin_token 세션 전용)
|
||||
@@ -30,10 +34,7 @@ admin/services 패널에서 ON/OFF 전환 가능.
|
||||
|
||||
| 경로 | 서비스 |
|
||||
|------|--------|
|
||||
| `/work/saju*` | 사주 분석 |
|
||||
| `/music/*` | 음악 팩 (단, `/music/packs`는 `/products`로 308 리다이렉트) |
|
||||
| `/gyeol` | CONTOUR PMF 설문 |
|
||||
| `/packages` | 레거시 패키지 |
|
||||
|
||||
## 기술 스택
|
||||
- **Framework**: Next.js 16 (App Router, TypeScript)
|
||||
@@ -81,16 +82,23 @@ app/
|
||||
login/page.tsx — 로그인 (?next= 지원)
|
||||
mypage/page.tsx — 마이페이지 4탭
|
||||
legal/ — privacy / terms / refund
|
||||
admin/ — 관리자 전용 (dashboard·members·services·orders·products·contacts·quotes·packs·...)
|
||||
showcase/page.tsx — 제작 사례 허브 (웹 데모 8종 + 실서비스 운영 사례)
|
||||
admin/ — 관리자 전용 (dashboard·members·services·orders·products·contacts·quotes·marketing(광고 관리: 채널 CRUD + 에셋)·...)
|
||||
api/
|
||||
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)
|
||||
payment/ — PortOne 연동 (보존 전용, 미활성)
|
||||
work/saju/ — 숨김: 사주 서비스
|
||||
music/ — 숨김: 음악 팩 (packs는 /products로 308)
|
||||
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/
|
||||
@@ -105,6 +113,14 @@ lib/
|
||||
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 파싱·검증)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -149,7 +165,6 @@ lib/
|
||||
→ POST /api/packs/sign-link → DSM 서명 링크 (4시간 TTL)
|
||||
```
|
||||
|
||||
- PG(PortOne) 코드는 `products.pay_method` 플래그 기반으로 보존만, 현재 미활성
|
||||
- `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환
|
||||
|
||||
---
|
||||
@@ -214,7 +229,8 @@ lib/
|
||||
|
||||
## 사주 시스템 (`/app/work/saju`, `/lib/saju-*.ts`)
|
||||
|
||||
> **서비스는 현재 숨김 — `/admin/services` 토글로 복귀 가능**
|
||||
> **공개 서비스 — 로그인 시 AI 해석 무료(1회/일)**
|
||||
> 전 화면(랜딩·입력·결과) `--jsm` 라이트 재스킨 완료(2026-07-03) — 디자인 가드레일 준수
|
||||
|
||||
### AI 연동 (`app/api/saju/analyze/route.ts`)
|
||||
- **AI**: Google Gemini (`@google/generative-ai`)
|
||||
|
||||
@@ -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
@@ -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 > Parts & 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>
|
||||
© 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>
|
||||
@@ -76,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}
|
||||
|
||||
@@ -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}건`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
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>
|
||||
<p className="text-amber-400/90 text-xs mt-2">
|
||||
음악 팩 레거시 관리 화면입니다. 신규 제품 파일은{' '}
|
||||
<Link href="/admin/products" className="underline hover:text-amber-300">제품 관리</Link>에서 배정하세요.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }[];
|
||||
@@ -19,8 +19,11 @@ 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' },
|
||||
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']) {
|
||||
|
||||
53
app/api/admin/ad-channels/[id]/route.ts
Normal file
53
app/api/admin/ad-channels/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
60
app/api/admin/ad-channels/route.ts
Normal file
60
app/api/admin/ad-channels/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -51,9 +51,6 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
|
||||
const DEFAULT_SERVICES = [
|
||||
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 (레거시)', is_active: false, order_index: 101 },
|
||||
{ id: 'music', name: 'AI 음악 팩', description: '음악 가이드 패키지·샘플·스튜디오', is_active: false, order_index: 102 },
|
||||
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
|
||||
{ id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 },
|
||||
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
9
app/api/studio/callback/route.ts
Normal file
9
app/api/studio/callback/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
115
app/api/studio/story/route.ts
Normal file
115
app/api/studio/story/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
49
app/api/studio/tracks/route.ts
Normal file
49
app/api/studio/tracks/route.ts
Normal 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 ?? [] });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 ?? [] });
|
||||
}
|
||||
133
app/api/tarot/interpret/route.ts
Normal file
133
app/api/tarot/interpret/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
50
app/api/tarot/readings/route.ts
Normal file
50
app/api/tarot/readings/route.ts
Normal 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 ?? [] });
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 비회원 의뢰 추적 API — 향후 클라이언트 측 폴링/갱신용.
|
||||
// PII(이메일·전화·메시지 본문)는 select에서 제외한다.
|
||||
// DB 예외(마이그레이션 미적용 42703 포함)는 모두 404로 폴백한다.
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
if (!token || token.length > 64) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||
|
||||
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 NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||
|
||||
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 NextResponse.json({ request, quote: quote ?? null });
|
||||
}
|
||||
@@ -1,184 +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 }));
|
||||
};
|
||||
|
||||
const fieldClass =
|
||||
'w-full px-3.5 py-2.5 text-sm border rounded-xl outline-none bg-white disabled:bg-slate-50 transition-colors focus:ring-2 focus:ring-[var(--jsm-accent)] focus:border-[var(--jsm-accent)]';
|
||||
|
||||
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 mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
이름 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
placeholder="홍길동"
|
||||
className={fieldClass}
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>연락처</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'loading'}
|
||||
placeholder="010-0000-0000"
|
||||
className={fieldClass}
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
이메일 <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={fieldClass}
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>문의 서비스</label>
|
||||
<select
|
||||
name="service"
|
||||
value={formData.service}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'loading'}
|
||||
className={fieldClass}
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<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 mb-1.5" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
문의 내용 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={5}
|
||||
disabled={status === 'loading'}
|
||||
placeholder="문의하실 내용을 자유롭게 작성해주세요. 프로젝트 목적, 원하시는 기능, 예산 등을 적어주시면 더 정확한 답변이 가능합니다."
|
||||
className={`${fieldClass} resize-none`}
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm px-4 py-3 rounded-xl">
|
||||
문의가 전송되었습니다. 영업일 2일 내에 회신드리겠습니다.
|
||||
</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 text-white py-3 rounded-xl text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{status === 'loading' ? '전송 중...' : '문의 보내기'}
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-center" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
영업일 2일 내 회신 · 무료 상담 가능
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-slate-400 text-sm">로딩 중...</div>}>
|
||||
<ContactFormInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@ const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const INPUT_STYLE = {
|
||||
background: 'var(--jsm-surface)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
} as const;
|
||||
@@ -218,7 +218,7 @@ export default function OutsourcingRequestForm() {
|
||||
</Link>
|
||||
<p
|
||||
className="mt-3 text-xs leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
추적 링크를 이메일로도 보내드렸습니다.
|
||||
</p>
|
||||
@@ -244,7 +244,7 @@ export default function OutsourcingRequestForm() {
|
||||
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-faint)' }
|
||||
? { 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}
|
||||
@@ -255,7 +255,7 @@ export default function OutsourcingRequestForm() {
|
||||
className="text-xs font-semibold truncate hidden sm:inline"
|
||||
style={{
|
||||
color:
|
||||
state === 'upcoming' ? 'var(--jsm-ink-faint)' : 'var(--jsm-ink)',
|
||||
state === 'upcoming' ? 'var(--jsm-ink-soft)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -307,7 +307,7 @@ export default function OutsourcingRequestForm() {
|
||||
: '1px solid var(--jsm-line)',
|
||||
background: selected
|
||||
? 'var(--jsm-accent-soft)'
|
||||
: 'var(--jsm-surface)',
|
||||
: 'var(--jsm-surface-alt)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
@@ -413,7 +413,7 @@ export default function OutsourcingRequestForm() {
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{trimmedMessage.length}/10자 이상
|
||||
</p>
|
||||
@@ -548,7 +548,7 @@ export default function OutsourcingRequestForm() {
|
||||
className="flex-1 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
|
||||
style={{
|
||||
background: !canAdvance || submitting
|
||||
? 'var(--jsm-ink-faint)'
|
||||
? 'var(--jsm-line)'
|
||||
: 'var(--jsm-accent)',
|
||||
cursor: !canAdvance || submitting ? 'not-allowed' : 'pointer',
|
||||
...KOR_BODY,
|
||||
@@ -563,7 +563,7 @@ export default function OutsourcingRequestForm() {
|
||||
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-ink-faint)',
|
||||
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||
cursor: canAdvance ? 'pointer' : 'not-allowed',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
@@ -596,7 +596,7 @@ function Chip({
|
||||
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)',
|
||||
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import type { User } from '@supabase/supabase-js';
|
||||
const LINKS = [
|
||||
{ href: '/outsourcing', label: '외주 개발' },
|
||||
{ href: '/products', label: '소프트웨어' },
|
||||
{ href: '/showcase', label: '제작 사례' },
|
||||
{ href: '/work/saju', label: '사주' },
|
||||
{ href: '/tarot', label: '타로' },
|
||||
{ href: '/music', label: '음악' },
|
||||
];
|
||||
|
||||
export default function TopNav() {
|
||||
@@ -65,6 +69,14 @@ export default function TopNav() {
|
||||
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 + '/');
|
||||
@@ -76,7 +88,9 @@ export default function TopNav() {
|
||||
className="fixed top-0 left-0 right-0 z-50 w-full transition-all duration-300"
|
||||
style={{
|
||||
background: scrolled ? 'var(--jsm-surface)' : 'transparent',
|
||||
borderBottom: scrolled ? '1px solid var(--jsm-line)' : '1px solid transparent',
|
||||
borderBottom: scrolled
|
||||
? `1px solid ${line}`
|
||||
: '1px solid transparent',
|
||||
boxShadow: scrolled ? '0 1px 8px rgba(15,23,42,0.06)' : 'none',
|
||||
}}
|
||||
>
|
||||
@@ -89,13 +103,13 @@ export default function TopNav() {
|
||||
>
|
||||
<span
|
||||
className="text-xl font-black tracking-tight"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
|
||||
style={{ color: ink, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</span>
|
||||
<span
|
||||
className="hidden sm:inline text-sm font-medium"
|
||||
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
쟁승메이드
|
||||
</span>
|
||||
@@ -109,8 +123,8 @@ export default function TopNav() {
|
||||
href={l.href}
|
||||
className="text-sm font-medium px-4 py-2 rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink-soft)',
|
||||
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
|
||||
color: isActive(l.href) ? accent : inkSoft,
|
||||
background: isActive(l.href) ? accentBg : 'transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
@@ -127,14 +141,14 @@ export default function TopNav() {
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
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: 'var(--jsm-ink-soft)', background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
@@ -143,7 +157,7 @@ export default function TopNav() {
|
||||
<Link
|
||||
href="/login"
|
||||
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
@@ -167,7 +181,7 @@ export default function TopNav() {
|
||||
aria-label="메뉴 열기"
|
||||
aria-expanded={open}
|
||||
className="md:hidden p-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink)' }}
|
||||
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" />
|
||||
@@ -186,7 +200,7 @@ export default function TopNav() {
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-72 flex flex-col shadow-xl"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
style={{ background: surface }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -195,18 +209,18 @@ export default function TopNav() {
|
||||
{/* 드로어 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 h-16 border-b"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
style={{ borderColor: line }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
|
||||
style={{ color: ink, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
style={{ color: inkSoft }}
|
||||
>
|
||||
쟁승메이드
|
||||
</span>
|
||||
@@ -215,7 +229,7 @@ export default function TopNav() {
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="메뉴 닫기"
|
||||
className="p-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
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" />
|
||||
@@ -231,8 +245,8 @@ export default function TopNav() {
|
||||
href={l.href}
|
||||
className="text-base font-semibold px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{
|
||||
color: isActive(l.href) ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
background: isActive(l.href) ? 'var(--jsm-accent-soft)' : 'transparent',
|
||||
color: isActive(l.href) ? accent : ink,
|
||||
background: isActive(l.href) ? accentBg : 'transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
@@ -243,7 +257,7 @@ export default function TopNav() {
|
||||
|
||||
<div
|
||||
className="my-4 border-t"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
style={{ borderColor: line }}
|
||||
/>
|
||||
|
||||
{user ? (
|
||||
@@ -251,14 +265,14 @@ export default function TopNav() {
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="text-left text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)', background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
@@ -267,7 +281,7 @@ export default function TopNav() {
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)', textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
|
||||
76
app/components/deepfield/CountUp.tsx
Normal file
76
app/components/deepfield/CountUp.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
app/components/deepfield/ScrollReveal.tsx
Normal file
70
app/components/deepfield/ScrollReveal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
app/components/deepfield/ShowcaseCard.tsx
Normal file
88
app/components/deepfield/ShowcaseCard.tsx
Normal 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;
|
||||
}
|
||||
61
app/components/deepfield/ShowcaseGrid.tsx
Normal file
61
app/components/deepfield/ShowcaseGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/components/mock/MockWindow.tsx
Normal file
51
app/components/mock/MockWindow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
app/components/mock/keys.ts
Normal file
17
app/components/mock/keys.ts
Normal 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',
|
||||
];
|
||||
24
app/components/mock/registry.ts
Normal file
24
app/components/mock/registry.ts
Normal 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,
|
||||
};
|
||||
250
app/components/mock/screens.tsx
Normal file
250
app/components/mock/screens.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@
|
||||
--jsm-accent-hover: #1e40af; /* blue-800 */
|
||||
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
|
||||
|
||||
/* 기존 kx 변수 재매핑 (잔여 참조 호환용) */
|
||||
/* 기존 kx 변수 재매핑 (레거시·숨김 라우트 /packages·/work·/music 호환용) */
|
||||
--kx-surface: var(--jsm-bg);
|
||||
--kx-surface-low: var(--jsm-surface-alt);
|
||||
--kx-surface-mid: var(--jsm-surface);
|
||||
|
||||
@@ -10,7 +10,7 @@ export const metadata: Metadata = {
|
||||
template: "%s | 쟁승메이드",
|
||||
},
|
||||
description:
|
||||
"7년차 대기업 백엔드 개발자가 직접 설계하고 만듭니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
keywords: [
|
||||
"외주 개발",
|
||||
"소프트웨어 개발",
|
||||
@@ -28,7 +28,7 @@ export const metadata: Metadata = {
|
||||
siteName: "쟁승메이드",
|
||||
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
description:
|
||||
"7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공하는 쟁승메이드.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
images: [
|
||||
{
|
||||
url: "https://jaengseung-made.com/og-image.png",
|
||||
@@ -42,7 +42,7 @@ export const metadata: Metadata = {
|
||||
card: "summary_large_image",
|
||||
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
description:
|
||||
"7년차 대기업 백엔드 개발자가 직접 만듭니다. 맞춤 외주 개발과 검증된 완성 소프트웨어를 제공합니다.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -59,19 +59,18 @@ const jsonLd = {
|
||||
'@id': 'https://jaengseung-made.com/#person',
|
||||
name: '박재오',
|
||||
url: 'https://jaengseung-made.com',
|
||||
jobTitle: '백엔드 개발자 · 외주 개발 전문가',
|
||||
worksFor: { '@type': 'Organization', name: '대기업 재직 중' },
|
||||
jobTitle: '소프트웨어 엔지니어',
|
||||
email: 'bgg8988@gmail.com',
|
||||
telephone: '010-3907-1392',
|
||||
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', '외주 개발', '웹사이트 제작', '업무 자동화', 'API 설계'],
|
||||
description: '7년차 대기업 백엔드 개발자. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 직접 설계·개발·운영합니다.',
|
||||
description: '24시간 돌아가는 실서비스를 직접 설계·운영합니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공합니다.',
|
||||
},
|
||||
{
|
||||
'@type': 'LocalBusiness',
|
||||
'@id': 'https://jaengseung-made.com/#business',
|
||||
name: '쟁승메이드',
|
||||
url: 'https://jaengseung-made.com',
|
||||
description: '7년차 대기업 백엔드 개발자가 직접 설계·개발·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
|
||||
description: '24시간 돌아가는 실서비스를 직접 설계·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
|
||||
email: 'bgg8988@gmail.com',
|
||||
telephone: '010-3907-1392',
|
||||
priceRange: '₩',
|
||||
@@ -88,7 +87,7 @@ const jsonLd = {
|
||||
'@type': 'Service',
|
||||
name: '외주 개발',
|
||||
url: 'https://jaengseung-made.com/outsourcing',
|
||||
description: '7년차 백엔드 개발자의 1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
|
||||
description: '1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
|
||||
serviceType: 'Custom Software Development',
|
||||
provider: { '@id': 'https://jaengseung-made.com/#business' },
|
||||
areaServed: '대한민국',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { isServiceVisible } from '@/lib/service-visibility';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 음악 제품',
|
||||
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.',
|
||||
title: '나의 이야기를 음악으로',
|
||||
description: '당신의 이야기를 AI가 가사와 음악으로. 스토리를 들려주면 나만의 노래가 완성됩니다. 로그인 무료.',
|
||||
};
|
||||
|
||||
export default async function MusicLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!(await isServiceVisible('music'))) notFound();
|
||||
export default function MusicLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,59 +298,227 @@ 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"
|
||||
<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={t}
|
||||
onClick={() => setFlowTab(t)}
|
||||
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
|
||||
style={
|
||||
flowTab === t
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{t === 'story' ? '스토리로 만들기' : '직접 입력'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{flowTab === 'story' ? (
|
||||
<div className="space-y-5">
|
||||
{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
|
||||
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: 'rgba(12,22,45,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||
+ {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: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
}}
|
||||
>
|
||||
{storyLoading ? '다시 만드는 중…' : '가사 다시 만들기'}
|
||||
</button>
|
||||
<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="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.5 text-sm font-semibold rounded-full transition-all"
|
||||
className="flex-1 py-2 text-xs 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)' }
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||
@@ -229,8 +534,7 @@ export default function StudioPage() {
|
||||
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)' }}
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
@@ -241,8 +545,7 @@ export default function StudioPage() {
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
|
||||
@@ -251,8 +554,7 @@ export default function StudioPage() {
|
||||
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)' }}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
@@ -260,8 +562,7 @@ export default function StudioPage() {
|
||||
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)' }}
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
@@ -270,9 +571,9 @@ export default function StudioPage() {
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
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-soft)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
@@ -288,76 +589,53 @@ export default function StudioPage() {
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id} style={{ background: '#0b1428' }}>
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</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>
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
onClick={onManualSubmit}
|
||||
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',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '▶ Generate Track'}
|
||||
{submitting ? '생성 요청 중…' : '트랙 생성하기'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-3 text-xs px-3 py-2 rounded-lg" style={{ background: 'rgba(215,51,87,0.12)', color: '#ff8ba7' }}>
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
</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>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
||||
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
||||
import { findCard } from '@/lib/tarot/cards';
|
||||
import {
|
||||
REQUEST_STATUS,
|
||||
TIMELINE_STEPS,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
type Tab = 'profile' | 'requests' | 'products' | 'orders';
|
||||
type Tab = 'profile' | 'requests' | 'products' | 'orders' | 'ai';
|
||||
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
||||
|
||||
// 구 탭 키 → 새 탭 키 매핑. 사주/구독/프로젝트 등 폐지 탭은 프로필로 폴백.
|
||||
@@ -36,6 +37,8 @@ function resolveTab(raw: string | null): Tab {
|
||||
case 'orders':
|
||||
case 'payments':
|
||||
return 'orders';
|
||||
case 'ai':
|
||||
return 'ai';
|
||||
case 'profile':
|
||||
case 'saju':
|
||||
case 'subscription':
|
||||
@@ -46,6 +49,40 @@ function resolveTab(raw: string | null): Tab {
|
||||
}
|
||||
}
|
||||
|
||||
// AI 기록 탭 — 타로 리딩 (app/api/tarot/readings 응답)
|
||||
type TarotReadingRow = {
|
||||
id: string;
|
||||
category: string | null;
|
||||
question: string | null;
|
||||
cards: { position: string; card_id?: string; reversed?: boolean }[];
|
||||
interpretation: { summary?: string; advice?: string; warning?: string | null };
|
||||
summary: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// AI 기록 탭 — 사주 기록 (saju_records 테이블, 본인 조회)
|
||||
type SajuRecordRow = {
|
||||
id: string;
|
||||
saju_data: Record<string, unknown>;
|
||||
created_at: string;
|
||||
is_paid: boolean;
|
||||
};
|
||||
|
||||
// AI 기록 탭 — 음악 트랙 (app/api/studio/tracks 응답)
|
||||
type MusicTrackRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
story: string | null;
|
||||
audio_url: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// AI 기록 탭 — 사주·타로·음악 병합 렌더용 판별 유니언
|
||||
type AiRecordItem =
|
||||
| { kind: 'saju'; data: SajuRecordRow }
|
||||
| { kind: 'tarot'; data: TarotReadingRow }
|
||||
| { kind: 'music'; data: MusicTrackRow };
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
created_at: string;
|
||||
@@ -87,6 +124,15 @@ interface ProductOrder {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환, /api/projects)
|
||||
type ProjectMilestone = { quote_id: string; step_number: number; title: string; status: 'pending' | 'in_progress' | 'completed' };
|
||||
type Project = { id: string; title: string; status: string; total: number; created_at: string; milestones: ProjectMilestone[] };
|
||||
|
||||
const QUOTE_STATUS_LABELS: Record<string, string> = {
|
||||
sent: '견적 발송', accepted: '발주 확정', in_progress: '진행중', completed: '완료', delivered: '납품 완료',
|
||||
};
|
||||
const PROJECT_ORDERED_STATUSES = ['accepted', 'in_progress', 'completed', 'delivered'];
|
||||
|
||||
function MyPageContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -102,6 +148,13 @@ function MyPageContent() {
|
||||
// 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘)
|
||||
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
|
||||
|
||||
// 발주·진행 (quotes 기반)
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [linkCode, setLinkCode] = useState('');
|
||||
const [linkMsg, setLinkMsg] = useState<string | null>(null);
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [showLinkForm, setShowLinkForm] = useState(false);
|
||||
|
||||
// 텔레그램 연동 상태
|
||||
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
||||
const [telegramLinkState, setTelegramLinkState] = useState<TelegramLinkState>('idle');
|
||||
@@ -109,6 +162,46 @@ function MyPageContent() {
|
||||
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
||||
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
|
||||
|
||||
// AI 기록 탭 — 타로 리딩 / 사주 기록 / 음악 트랙
|
||||
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
|
||||
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
||||
const [musicTracks, setMusicTracks] = useState<MusicTrackRow[]>([]);
|
||||
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
setProjects(d.projects ?? []);
|
||||
} catch { /* 미로그인/네트워크 — 무시 */ }
|
||||
}, []);
|
||||
|
||||
// 사주·타로·음악 결과 통합 로드 — 셋 다 실패해도 서로 영향 없이 무시(best-effort)
|
||||
const loadAiRecords = useCallback(async () => {
|
||||
try {
|
||||
const tr = await fetch('/api/tarot/readings');
|
||||
if (tr.ok) setTarotReadings((await tr.json()).readings ?? []);
|
||||
} catch { /* 무시 */ }
|
||||
try {
|
||||
// 사주: 세션 클라이언트로 본인 saju_records 조회 (result 페이지와 동일 패턴)
|
||||
const { data: { user: authUser } } = await supabase.auth.getUser();
|
||||
if (authUser) {
|
||||
const { data } = await supabase
|
||||
.from('saju_records')
|
||||
.select('id, saju_data, created_at, is_paid')
|
||||
.eq('user_id', authUser.id)
|
||||
.order('created_at', { ascending: false });
|
||||
setSajuRecords(data ?? []);
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
try {
|
||||
const mt = await fetch('/api/studio/tracks');
|
||||
if (mt.ok) setMusicTracks((await mt.json()).tracks ?? []);
|
||||
} catch { /* 무시 */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
@@ -160,10 +253,16 @@ function MyPageContent() {
|
||||
.limit(50);
|
||||
setProductOrders(prodOrders || []);
|
||||
|
||||
// 발주·진행 (quotes 기반) 조회
|
||||
await loadProjects();
|
||||
|
||||
// AI 기록(사주·타로) 조회
|
||||
await loadAiRecords();
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
init();
|
||||
}, []);
|
||||
}, [loadProjects, loadAiRecords]);
|
||||
|
||||
// ── 텔레그램 연결 ──
|
||||
const handleTelegramConnect = async () => {
|
||||
@@ -219,6 +318,33 @@ function MyPageContent() {
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAiCard(id: string) {
|
||||
setExpandedAiCards((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// 견적서 코드 연결 (공개 견적 페이지에서 발급된 public_token)
|
||||
const handleLink = async () => {
|
||||
if (!linkCode.trim() || linking) return;
|
||||
setLinking(true); setLinkMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/projects/link', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: linkCode.trim() }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) { setLinkMsg(d.error ?? '연결에 실패했습니다.'); return; }
|
||||
setLinkMsg(d.alreadyLinked ? '이미 연결된 견적서입니다.' : '견적서가 연결되었습니다.');
|
||||
setLinkCode('');
|
||||
await loadProjects();
|
||||
} catch { setLinkMsg('연결에 실패했습니다. 다시 시도해주세요.'); }
|
||||
finally { setLinking(false); }
|
||||
};
|
||||
|
||||
async function handleDownload(fileId: string) {
|
||||
setDownloading(fileId);
|
||||
try {
|
||||
@@ -258,11 +384,19 @@ function MyPageContent() {
|
||||
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
|
||||
const pendingOrders = productOrders.filter((o) => o.status === 'pending');
|
||||
|
||||
// AI 기록 탭 — 사주·타로·음악 결과를 created_at 기준 내림차순 병합
|
||||
const aiRecords: AiRecordItem[] = [
|
||||
...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })),
|
||||
...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })),
|
||||
...musicTracks.map((r): AiRecordItem => ({ kind: 'music', data: r })),
|
||||
].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime());
|
||||
|
||||
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||
{ key: 'profile', label: '프로필' },
|
||||
{ key: 'requests', label: '내 의뢰', count: orders.length || undefined },
|
||||
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
|
||||
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
|
||||
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
|
||||
{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length + musicTracks.length) || undefined },
|
||||
];
|
||||
|
||||
function selectTab(key: Tab) {
|
||||
@@ -514,9 +648,71 @@ function MyPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 내 의뢰 ===== */}
|
||||
{/* ===== 발주·진행 + 내 의뢰 ===== */}
|
||||
{tab === 'requests' && (
|
||||
<div>
|
||||
{/* 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환) */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
발주·진행
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowLinkForm((v) => !v)}
|
||||
className="text-xs font-semibold transition-colors hover:underline"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
{showLinkForm ? '닫기' : '견적서 코드 연결'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showLinkForm && (
|
||||
<div
|
||||
className="mb-4 rounded-lg border p-4"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={linkCode}
|
||||
onChange={(e) => setLinkCode(e.target.value)}
|
||||
placeholder="견적서 코드를 입력하세요"
|
||||
className="flex-1 min-w-0 px-3 py-2 rounded-lg border text-sm"
|
||||
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink)' }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLink}
|
||||
disabled={linking}
|
||||
className="px-4 py-2 rounded-lg text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)] disabled:opacity-50 flex-shrink-0"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{linking ? '연결중...' : '연결'}
|
||||
</button>
|
||||
</div>
|
||||
{linkMsg && (
|
||||
<p className="text-xs mt-2 break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{linkMsg}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
진행 중인 발주가 없습니다. 견적서 코드를 입력해 연결하거나 새로 의뢰해 보세요.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projects.map((p) => (
|
||||
<ProjectCard key={p.id} project={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 기존 의뢰 카드 리스트 (contact_requests 기반) */}
|
||||
{orders.length === 0 ? (
|
||||
<EmptyState
|
||||
title="의뢰 내역이 없습니다"
|
||||
@@ -717,6 +913,65 @@ function MyPageContent() {
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== AI 기록 (사주·타로 결과 통합) ===== */}
|
||||
{tab === 'ai' && (
|
||||
<div>
|
||||
{aiRecords.length === 0 ? (
|
||||
<div
|
||||
className="text-center px-6 py-16 rounded-2xl border"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="font-bold text-lg mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
AI 기록이 없습니다
|
||||
</div>
|
||||
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
사주 분석·타로 리딩·음악 생성 결과가 여기에 모아서 표시됩니다.
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/work/saju"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
>
|
||||
사주 분석 하기 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/tarot"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
|
||||
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
|
||||
>
|
||||
타로 리딩 하기 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/music"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
|
||||
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
|
||||
>
|
||||
음악 만들기 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{aiRecords.map((item) =>
|
||||
item.kind === 'saju' ? (
|
||||
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
|
||||
) : item.kind === 'tarot' ? (
|
||||
<TarotAiCard
|
||||
key={`tarot-${item.data.id}`}
|
||||
reading={item.data}
|
||||
expanded={expandedAiCards.has(item.data.id)}
|
||||
onToggle={() => toggleAiCard(item.data.id)}
|
||||
/>
|
||||
) : (
|
||||
<MusicAiCard key={`music-${item.data.id}`} track={item.data} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1060,6 +1315,82 @@ function RequestCard({
|
||||
);
|
||||
}
|
||||
|
||||
// 발주서 뱃지 — accepted 이후 상태(발주 확정~납품 완료)에는 "발주서" 뱃지를 병기한다.
|
||||
function isProjectOrder(status: string): boolean {
|
||||
return PROJECT_ORDERED_STATUSES.includes(status);
|
||||
}
|
||||
|
||||
// 발주·진행 카드 — quotes 기반. 총액 + 마일스톤 타임라인(스텝 순서대로 진행 상태 표시).
|
||||
function ProjectCard({ project }: { project: Project }) {
|
||||
return (
|
||||
<Card compact>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{project.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{isProjectOrder(project.status) && (
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
발주서
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{QUOTE_STATUS_LABELS[project.status] ?? project.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-semibold mb-4" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
{project.total.toLocaleString('ko-KR')}원
|
||||
</div>
|
||||
|
||||
{project.milestones.length > 0 && (
|
||||
<ol className="space-y-2">
|
||||
{project.milestones
|
||||
.slice()
|
||||
.sort((a, b) => a.step_number - b.step_number)
|
||||
.map((m) => {
|
||||
const done = m.status === 'completed';
|
||||
const active = m.status === 'in_progress';
|
||||
return (
|
||||
<li key={m.step_number} className="flex items-center gap-3">
|
||||
<span
|
||||
className="flex items-center justify-center rounded-full text-xs font-semibold flex-shrink-0"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
background: done ? 'var(--jsm-accent)' : 'var(--jsm-surface)',
|
||||
border: done || active ? '2px solid var(--jsm-accent)' : '2px solid var(--jsm-line)',
|
||||
color: done ? '#ffffff' : active ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
|
||||
}}
|
||||
>
|
||||
{m.step_number}
|
||||
</span>
|
||||
<span
|
||||
className="text-sm break-keep"
|
||||
style={{
|
||||
color: done || active ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)',
|
||||
fontWeight: active ? 700 : 500,
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{m.title}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({
|
||||
title,
|
||||
desc,
|
||||
@@ -1093,6 +1424,218 @@ function EmptyState({
|
||||
);
|
||||
}
|
||||
|
||||
// saju_data(jsonb)에서 생년월일 요약 문자열 구성 — birth_year/month/day 없으면 안내 문구로 폴백
|
||||
function formatSajuBirth(data: Record<string, unknown>): string {
|
||||
const year = data.birth_year;
|
||||
const month = data.birth_month;
|
||||
const day = data.birth_day;
|
||||
const hour = data.birth_hour;
|
||||
const gender = data.gender;
|
||||
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') {
|
||||
return '생년월일 정보 없음';
|
||||
}
|
||||
const parts = [`${year}.${month}.${day}`];
|
||||
if (typeof hour === 'number') parts.push(`${hour}시`);
|
||||
if (gender === 'female') parts.push('여성');
|
||||
else if (gender === 'male') parts.push('남성');
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
// saju_data → /work/saju/result 쿼리 재구성. calendarType은 saju_data에 없으면 solar 기본값
|
||||
// (result 페이지는 saju_data 저장 시 이미 양력으로 변환된 birth_year/month/day를 사용하기 때문).
|
||||
function sajuResultHref(data: Record<string, unknown>): string {
|
||||
const year = data.birth_year;
|
||||
const month = data.birth_month;
|
||||
const day = data.birth_day;
|
||||
const hour = data.birth_hour;
|
||||
const gender = data.gender;
|
||||
if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') return '/work/saju';
|
||||
const calendarType =
|
||||
(typeof data.calendarType === 'string' && data.calendarType) ||
|
||||
(typeof data.calendar === 'string' && data.calendar) ||
|
||||
'solar';
|
||||
const params = new URLSearchParams({
|
||||
year: String(year),
|
||||
month: String(month),
|
||||
day: String(day),
|
||||
gender: typeof gender === 'string' ? gender : 'male',
|
||||
calendarType,
|
||||
});
|
||||
if (typeof hour === 'number') params.set('hour', String(hour));
|
||||
return `/work/saju/result?${params.toString()}`;
|
||||
}
|
||||
|
||||
// AI 기록 — 사주 카드. 날짜 + 생년월일 요약 + 결과 다시 보기 링크.
|
||||
function SajuAiCard({ record }: { record: SajuRecordRow }) {
|
||||
return (
|
||||
<Card compact>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
사주
|
||||
</span>
|
||||
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{new Date(record.created_at).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-semibold mb-3 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{formatSajuBirth(record.saju_data)}
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={sajuResultHref(record.saju_data)}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors hover:underline"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
결과 다시 보기
|
||||
<span aria-hidden>→</span>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 기록 — 타로 카드. 날짜·카테고리·질문·3장 카드명·요약 + 조언/주의 접이식.
|
||||
function TarotAiCard({
|
||||
reading,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: {
|
||||
reading: TarotReadingRow;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const hasDetail = Boolean(reading.interpretation?.advice || reading.interpretation?.warning);
|
||||
|
||||
return (
|
||||
<Card compact>
|
||||
<div className="flex items-start justify-between gap-3 mb-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
타로
|
||||
</span>
|
||||
{reading.category && (
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{reading.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{new Date(reading.created_at).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{reading.question && (
|
||||
<p className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{reading.question}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
{reading.cards.map((c, i) => {
|
||||
const card = c.card_id ? findCard(c.card_id) : undefined;
|
||||
return (
|
||||
<span
|
||||
key={`${c.position}-${i}`}
|
||||
className="text-xs px-2 py-0.5 rounded-full break-keep"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
{c.position}: {card?.name ?? '알 수 없음'}
|
||||
{c.reversed ? ' (역방향)' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{reading.summary && (
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{reading.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hasDetail && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold transition-colors hover:underline"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
{expanded ? '조언·주의 접기' : '조언·주의 더보기'}
|
||||
<Chevron open={expanded} />
|
||||
</button>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-3 pt-3 border-t space-y-2" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
{reading.interpretation?.advice && (
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
{reading.interpretation.advice}
|
||||
</p>
|
||||
)}
|
||||
{reading.interpretation?.warning && (
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: '#b45309', ...KOR_BODY }}>
|
||||
주의 — {reading.interpretation.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 기록 — 음악 카드. 제목·날짜 + 스토리 요약 + 오디오 플레이어(생성 완료 시).
|
||||
function MusicAiCard({ track }: { track: MusicTrackRow }) {
|
||||
return (
|
||||
<Card compact>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span
|
||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
음악
|
||||
</span>
|
||||
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{new Date(track.created_at).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{track.title || '제목 없는 트랙'}
|
||||
</div>
|
||||
|
||||
{track.story && (
|
||||
<p
|
||||
className="text-sm line-clamp-2 break-keep mb-3"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{track.story}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{track.audio_url ? (
|
||||
<audio controls className="w-full" style={{ height: 36 }}>
|
||||
<source src={track.audio_url} />
|
||||
</audio>
|
||||
) : (
|
||||
<p className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
생성 준비 중입니다. 잠시 후 다시 확인해주세요.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||
return (
|
||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
|
||||
@@ -2,44 +2,32 @@ import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
|
||||
|
||||
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
|
||||
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
|
||||
// 메인(/)의 토큰·타이포 패턴(KOR_TIGHT/KOR_BODY)·섹션 리듬과 일관되게 구성한다.
|
||||
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:
|
||||
'7년차 대기업 백엔드 개발자가 직접 진행하는 맞춤 소프트웨어 외주 개발. 웹 서비스, 업무 자동화, API·백엔드, 봇, AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
|
||||
'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를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.',
|
||||
},
|
||||
{ t: '웹 서비스 개발', d: '회원·결제·관리자까지, 실제로 굴러가는 서비스를 기획부터 배포까지 만들어 드립니다.' },
|
||||
{ t: '웹사이트 제작', d: '기업 소개·포트폴리오·랜딩 페이지를 반응형·SEO까지 갖춰 제작합니다.' },
|
||||
{ t: '업무 자동화', d: 'RPA·엑셀 집계·웹 크롤링으로 반복 업무를 사람 손에서 떼어냅니다.' },
|
||||
{ t: 'API·백엔드', d: '데이터 모델 설계부터 인증·외부 연동까지 안정적인 서버를 구축합니다.' },
|
||||
{ t: '텔레그램·디스코드 봇', d: '알림·명령·자동 응답 봇으로 운영과 커뮤니티 관리를 자동화합니다.' },
|
||||
{ t: 'AI 연동 개발', d: 'LLM·생성형 AI를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.' },
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
@@ -51,485 +39,274 @@ const PROCESS = [
|
||||
{ n: '06', t: '무상 하자보수 30일', d: '납품 후 30일간 결함·수정을 무상으로 대응해 안정화까지 책임집니다.' },
|
||||
];
|
||||
|
||||
// 기존 work/freelance(lib/freelance-portfolio) 실사례를 새 토큰 기준으로 재구성.
|
||||
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'],
|
||||
},
|
||||
];
|
||||
|
||||
// /work/website/samples/* 중 대표 샘플 — 이 라우트는 숨김이 아니라 포트폴리오용으로 잔존.
|
||||
const SAMPLES = [
|
||||
{ slug: 'corporate', t: '기업 홈페이지', sub: '테크솔루션㈜', tag: 'B2B · 신뢰' },
|
||||
{ slug: 'shopping', t: '개인 쇼핑몰', sub: 'MELLOW STUDIO', tag: '쇼핑몰 · 브랜드' },
|
||||
{ slug: 'dashboard', t: '관리자 대시보드', sub: 'DataFlow SaaS', tag: 'SaaS · 자동화' },
|
||||
{ slug: 'portfolio', t: '개인 포트폴리오', sub: 'Kim Jisu', tag: '크리에이터 · 수주' },
|
||||
{ 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일간 결함·오류를 무상으로 하자보수합니다. 이후 기능 추가나 지속 운영이 필요하면 월 단위 유지보수 계약으로 이어갈 수 있습니다.',
|
||||
},
|
||||
{ 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
|
||||
>
|
||||
<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 className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
|
||||
<div className="max-w-3xl">
|
||||
<span
|
||||
className="inline-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)', ...KOR_BODY }}
|
||||
>
|
||||
외주 개발
|
||||
{/* ─────────────────── 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="text-4xl sm:text-5xl lg:text-[3.5rem] font-bold leading-[1.2] break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
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>
|
||||
맞춤 소프트웨어
|
||||
<br />
|
||||
외주 개발
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
|
||||
</h1>
|
||||
<p
|
||||
className="mt-7 text-lg lg:text-xl leading-relaxed break-keep max-w-2xl"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
기획 정리가 안 됐어도 괜찮습니다. 상담에서 함께 정리합니다. 7년차 대기업 백엔드
|
||||
개발자가 기획부터 배포·하자보수까지 단독으로 책임집니다.
|
||||
<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-10 flex flex-col sm:flex-row gap-3">
|
||||
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="#contact"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold text-white transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
|
||||
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="#portfolio"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
|
||||
style={{
|
||||
color: 'var(--jsm-ink)',
|
||||
borderColor: 'var(--jsm-line)',
|
||||
background: 'var(--jsm-surface)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
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. 제공 분야 ─── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="max-w-2xl">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Scope
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
이런 것들을 만들어 드립니다
|
||||
{/* ─────────────────── 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>
|
||||
</div>
|
||||
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{FIELDS.map((f) => (
|
||||
<div
|
||||
key={f.t}
|
||||
className="rounded-2xl p-7 border"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{f.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{f.d}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</ScrollReveal>
|
||||
<div className="mt-12">
|
||||
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 3. 진행 프로세스 ─── */}
|
||||
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="max-w-2xl">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Process
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
상담부터 하자보수까지, 흐름이 분명합니다
|
||||
</h2>
|
||||
</div>
|
||||
<div
|
||||
className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-px rounded-2xl overflow-hidden border"
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
|
||||
>
|
||||
{PROCESS.map((s) => (
|
||||
<div key={s.n} className="p-7 lg:p-8" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<h3
|
||||
className="mt-4 text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{s.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 4. 포트폴리오 ─── */}
|
||||
<section id="portfolio" className="scroll-mt-20" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="max-w-2xl">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Portfolio
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{/* ─────────────────── 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 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<p className="mt-4 max-w-xl break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
운영 중인 서비스와 납품 완료 프로젝트입니다. 의뢰하신 프로젝트도 같은 깊이로 만듭니다.
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
|
||||
{/* 실사례 카드 */}
|
||||
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{CASES.map((c) => (
|
||||
<div
|
||||
key={c.t}
|
||||
className="flex flex-col rounded-2xl p-7 border"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<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="self-start inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full mb-5"
|
||||
style={
|
||||
c.live
|
||||
? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }
|
||||
: { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }
|
||||
}
|
||||
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="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
/>
|
||||
)}
|
||||
{c.live && <span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />}
|
||||
{c.cat}
|
||||
</span>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{c.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<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="text-xs px-2.5 py-1 rounded"
|
||||
style={{
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
<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 className="mt-14">
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
웹사이트 제작 샘플
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
직접 둘러볼 수 있는 데모 사이트입니다. 카드를 눌러 화면을 확인하세요.
|
||||
</p>
|
||||
<div className="mt-6 grid sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{SAMPLES.map((s) => (
|
||||
<Link
|
||||
key={s.slug}
|
||||
href={`/work/website/samples/${s.slug}`}
|
||||
className="group flex flex-col rounded-2xl p-6 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="text-[11px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{s.tag}
|
||||
</span>
|
||||
<h4
|
||||
className="mt-3 text-base font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{s.t}
|
||||
</h4>
|
||||
<p
|
||||
className="mt-1 text-sm break-keep"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
>
|
||||
{s.sub}
|
||||
</p>
|
||||
<span
|
||||
className="mt-5 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>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 5. FAQ ─── */}
|
||||
<section style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="mb-12">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
FAQ
|
||||
{/* ─────────────────── 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>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
</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>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{FAQ.map((item) => (
|
||||
<details
|
||||
key={item.q}
|
||||
className="group rounded-2xl border overflow-hidden"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<summary
|
||||
className="flex items-center justify-between gap-4 cursor-pointer list-none px-6 py-5 font-semibold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
</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-faint)' }}
|
||||
>
|
||||
<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="px-6 pb-5 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<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-navy)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="grid lg:grid-cols-5 gap-10 lg:gap-12">
|
||||
{/* ─────────────────── 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">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: '#7aa7ff' }}
|
||||
>
|
||||
Contact
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
|
||||
style={KOR_TIGHT}
|
||||
>
|
||||
<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 text-lg leading-relaxed text-white/70 break-keep"
|
||||
style={KOR_BODY}
|
||||
>
|
||||
영업일 2일 내에 회신드립니다. 아이디어 단계여도 괜찮습니다 — 상담에서 방향을
|
||||
함께 잡아드립니다.
|
||||
<p className="mt-5 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
영업일 2일 내에 회신드립니다. 아이디어 단계여도 괜찮습니다 — 상담에서 방향을 함께 잡아드립니다.
|
||||
</p>
|
||||
<div
|
||||
className="mt-8 pt-8 border-t space-y-3"
|
||||
style={{ borderColor: 'rgba(255,255,255,0.12)' }}
|
||||
>
|
||||
<a
|
||||
href="mailto:bgg8988@gmail.com"
|
||||
className="flex items-center gap-3 text-sm text-white/80 hover:text-white transition-colors"
|
||||
style={KOR_BODY}
|
||||
>
|
||||
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Mail</span>
|
||||
<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 text-white/80 hover:text-white transition-colors"
|
||||
style={KOR_BODY}
|
||||
>
|
||||
<span className="text-white/40 text-xs uppercase tracking-wider w-12">Tel</span>
|
||||
<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">
|
||||
<div
|
||||
className="rounded-2xl p-6 lg:p-8"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { isServiceVisible } from '@/lib/service-visibility';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'SaaS 제품 · 월 구독 패키지',
|
||||
description:
|
||||
'현직 엔지니어가 실제 운영하며 검증한 자동화를 월 구독 SaaS 제품으로 제공합니다. 첫 제품 준비 중 — 출시 알림을 신청하세요.',
|
||||
keywords: ['SaaS', '자동화 구독', '월 구독 자동화', 'AI 자동화 제품', '쟁승메이드'],
|
||||
openGraph: {
|
||||
title: 'SaaS 제품 · 월 구독 패키지 | 쟁승메이드',
|
||||
description:
|
||||
'검증된 자동화를 SaaS로. 현직 엔지니어가 직접 운영·검증한 자동화 제품 카탈로그.',
|
||||
url: 'https://jaengseung-made.com/packages',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function PackagesLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!(await isServiceVisible('packages'))) notFound();
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
619
app/page.tsx
619
app/page.tsx
@@ -2,15 +2,28 @@ import Link from 'next/link';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
|
||||
|
||||
// 쟁승메이드 메인 — 외주 개발 + 완성 소프트웨어 2축 랜딩 (서버 컴포넌트)
|
||||
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
|
||||
import ShowcaseGrid from './components/deepfield/ShowcaseGrid';
|
||||
import ScrollReveal from './components/deepfield/ScrollReveal';
|
||||
import CountUp from './components/deepfield/CountUp';
|
||||
import MockWindow from './components/mock/MockWindow';
|
||||
import { DashboardMock } from './components/mock/screens';
|
||||
import { SHOWCASE_SLOTS } from '@/lib/showcase';
|
||||
|
||||
// 쟁승메이드 메인 — 라이트 고craft (서버 컴포넌트).
|
||||
// PublicShell이 단일 라이트 TopNav(h-16)·navy 푸터·main(라이트 --jsm-bg, pt-16)을 제공한다.
|
||||
// 섹션은 surface(#fff) ↔ surface-alt(#f1f5f9) 교차로 구분하고, 히어로의 제품 목업이 유일한 강조면.
|
||||
|
||||
// 소프트웨어 진열 섹션이 DB 조회를 포함하므로 항상 최신 목록을 보여준다.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const TRUST = [
|
||||
{ v: '15+', t: '직접 운영 중인 실서비스' },
|
||||
{ v: '24/7', t: '무중단 운영' },
|
||||
{ v: '원스톱', t: '기획 → 배포 단독 진행' },
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
{ n: '01', t: '무료 상담', d: '요구사항을 함께 정리하고 실현 가능성을 점검합니다.' },
|
||||
{ n: '02', t: '견적·범위 확정', d: '영업일 2일 내 범위와 견적을 정리해 회신드립니다.' },
|
||||
@@ -18,15 +31,7 @@ const PROCESS = [
|
||||
{ n: '04', t: '납품·배포 지원', d: '검수 후 30일 무상 하자보수로 안정화까지 책임집니다.' },
|
||||
];
|
||||
|
||||
const STATS = [
|
||||
{ v: '7년차', l: '대기업 백엔드 개발 경력' },
|
||||
{ v: '15+', l: '직접 운영 중인 서비스' },
|
||||
{ v: '기획→배포', l: '원스톱 단독 진행' },
|
||||
];
|
||||
|
||||
const STACK = ['Python', 'Java', 'Spring', 'Next.js', 'AI 연동'];
|
||||
|
||||
const PORTFOLIO = [
|
||||
const PROOF = [
|
||||
{
|
||||
t: '주식 자동매매 시스템',
|
||||
d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.',
|
||||
@@ -63,6 +68,17 @@ function ArrowRight() {
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadFeaturedProducts(): Promise<ProductRow[]> {
|
||||
try {
|
||||
const all = await getListedProducts(createAdminClient());
|
||||
@@ -79,51 +95,57 @@ export default async function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── 1. Hero ─── */}
|
||||
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-32">
|
||||
<div className="max-w-3xl">
|
||||
{/* ─────────────────── 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-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
|
||||
style={{
|
||||
color: 'var(--jsm-accent)',
|
||||
background: 'var(--jsm-accent-soft)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
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 · software
|
||||
</span>
|
||||
<h1
|
||||
className="text-4xl sm:text-5xl lg:text-[3.5rem] font-bold leading-[1.2] break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
className="mt-6 font-extrabold break-keep"
|
||||
style={{
|
||||
color: 'var(--jsm-ink)',
|
||||
fontSize: 'clamp(2.4rem, 7vw, 4rem)',
|
||||
lineHeight: 1.08,
|
||||
letterSpacing: '-0.035em',
|
||||
}}
|
||||
>
|
||||
필요한 소프트웨어,
|
||||
<br className="hidden sm:block" /> 만들어 드리거나{' '}
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>이미 만들어 두었습니다.</span>
|
||||
생각을
|
||||
<br />
|
||||
동작하는 소프트웨어로
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
|
||||
</h1>
|
||||
<p
|
||||
className="mt-7 text-lg lg:text-xl leading-relaxed break-keep max-w-2xl"
|
||||
className="mt-7 max-w-xl break-keep text-lg leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
7년차 대기업 백엔드 개발자가 직접 설계·개발·운영합니다. 맞춤 외주 개발과
|
||||
검증된 완성 소프트웨어 중 필요한 쪽을 선택하세요.
|
||||
24시간 돌아가는 실서비스를 직접 설계하고 운영합니다. 외주 개발도, 완성
|
||||
소프트웨어도 — 같은 손으로.
|
||||
</p>
|
||||
<div className="mt-10 flex flex-col sm:flex-row gap-3">
|
||||
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="/outsourcing#contact"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold text-white transition-colors duration-150"
|
||||
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="/products"
|
||||
className="inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
|
||||
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)',
|
||||
background: 'var(--jsm-surface)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
@@ -131,215 +153,183 @@ export default async function Home() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 2. 2축 서비스 ─── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 외주 개발 */}
|
||||
<Link
|
||||
href="/outsourcing"
|
||||
className="group block rounded-2xl p-9 lg:p-11 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Custom
|
||||
</span>
|
||||
<h2
|
||||
className="mt-3 text-2xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
외주 개발
|
||||
</h2>
|
||||
<p
|
||||
className="mt-3 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
기획부터 배포·운영까지 한 사람이 책임집니다. 웹 서비스, API, 업무 자동화,
|
||||
봇 개발까지 필요한 형태로 만들어 드립니다.
|
||||
</p>
|
||||
<span
|
||||
className="mt-6 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>
|
||||
</Link>
|
||||
{/* 우 — 제품 목업 (유일한 강조면) */}
|
||||
<div className="lg:pl-4">
|
||||
<MockWindow title="stock-report.app">
|
||||
<DashboardMock />
|
||||
</MockWindow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 완성 소프트웨어 */}
|
||||
<Link
|
||||
href="/products"
|
||||
className="group block rounded-2xl p-9 lg:p-11 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
{/* 신뢰 스트립 */}
|
||||
<div className="mx-auto max-w-6xl px-6 pb-16 lg:px-8 lg:pb-20">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border sm:grid-cols-3"
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
|
||||
>
|
||||
{TRUST.map((s) => (
|
||||
<div
|
||||
key={s.v}
|
||||
className="flex items-baseline gap-3 px-6 py-5"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
<span
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Ready-made
|
||||
</span>
|
||||
<h2
|
||||
className="mt-3 text-2xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
완성 소프트웨어
|
||||
</h2>
|
||||
<p
|
||||
className="mt-3 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
입금 확인 후 바로 다운로드해 사용합니다. 제가 직접 운영하며 검증한 도구만
|
||||
정리해 제공합니다.
|
||||
</p>
|
||||
<span
|
||||
className="mt-6 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>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 3. 개발 프로세스 ─── */}
|
||||
<section id="process" style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="max-w-2xl">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Process
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
상담부터 납품까지, 흐름이 분명합니다
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-12 grid sm:grid-cols-2 lg:grid-cols-4 gap-px rounded-2xl overflow-hidden border" style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}>
|
||||
{PROCESS.map((s) => (
|
||||
<div key={s.n} className="p-7 lg:p-8" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<span
|
||||
className="text-sm font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', fontFamily: 'monospace' }}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<h3
|
||||
className="mt-4 text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{s.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 4. 신뢰 요소 ─── */}
|
||||
<section style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-24">
|
||||
<div className="grid sm:grid-cols-3 gap-10 sm:gap-8">
|
||||
{STATS.map((s) => (
|
||||
<div key={s.l}>
|
||||
<p
|
||||
className="text-3xl lg:text-4xl font-bold text-white"
|
||||
style={KOR_TIGHT}
|
||||
className="text-2xl font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', letterSpacing: '-0.03em' }}
|
||||
>
|
||||
{s.v}
|
||||
</p>
|
||||
<p
|
||||
className="mt-2 text-sm leading-relaxed break-keep text-white/60"
|
||||
style={KOR_BODY}
|
||||
>
|
||||
{s.l}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="mt-12 pt-8 border-t flex flex-wrap items-center gap-x-3 gap-y-2"
|
||||
style={{ borderColor: 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<span className="text-xs uppercase tracking-wider text-white/40 mr-1">Stack</span>
|
||||
{STACK.map((s) => (
|
||||
<span
|
||||
key={s}
|
||||
className="text-sm text-white/80 px-3 py-1 rounded-full"
|
||||
style={{ background: 'rgba(255,255,255,0.06)', ...KOR_BODY }}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
<span className="break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{s.t}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 5. 포트폴리오 하이라이트 ─── */}
|
||||
<section id="portfolio" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<div className="max-w-2xl">
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Portfolio
|
||||
</p>
|
||||
{/* ─────────────────── 2. 2축 소개 ─────────────────── */}
|
||||
<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>what we do</Eyebrow>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
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 leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
데모가 아니라 매일 돌아가는 서비스입니다. 같은 깊이로 의뢰하신 프로젝트를 만듭니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-12 grid md:grid-cols-3 gap-6">
|
||||
{PORTFOLIO.map((p) => (
|
||||
<div
|
||||
key={p.t}
|
||||
className="flex flex-col rounded-2xl p-7 border"
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
n: '01',
|
||||
k: 'outsourcing',
|
||||
t: '맞춤 외주 개발',
|
||||
d: '웹 서비스·업무 자동화·API·봇·AI 연동까지. 기획부터 납품과 30일 하자보수까지 단독으로 책임집니다.',
|
||||
href: '/outsourcing',
|
||||
cta: '의뢰 시작',
|
||||
},
|
||||
{
|
||||
n: '02',
|
||||
k: 'software',
|
||||
t: '완성 소프트웨어 구매',
|
||||
d: '직접 운영하며 검증한 도구를 계좌이체로 가져가세요. 입금 확인 즉시 마이페이지에서 다운로드합니다.',
|
||||
href: '/products',
|
||||
cta: '제품 보기',
|
||||
},
|
||||
].map((a, i) => (
|
||||
<ScrollReveal key={a.k} delay={i * 100}>
|
||||
<Link
|
||||
href={a.href}
|
||||
className="group flex h-full flex-col rounded-2xl border p-8 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-10"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="self-start inline-flex items-center gap-1.5 text-[11px] font-semibold px-2.5 py-1 rounded-full mb-5"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }}
|
||||
className="font-mono text-[11px] uppercase tracking-[0.18em]"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
{a.n} · {a.k}
|
||||
</span>
|
||||
<h3
|
||||
className="mt-4 break-keep text-xl font-bold lg:text-2xl"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{a.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-3 flex-1 break-keep leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{a.d}
|
||||
</p>
|
||||
<span
|
||||
className="mt-6 inline-flex items-center gap-1.5 font-semibold transition-transform duration-300 group-hover:translate-x-1"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
{a.cta}
|
||||
<ArrowRight />
|
||||
</span>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 3. SHOWCASE ─────────────────── */}
|
||||
<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>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="home" />
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex justify-end">
|
||||
<Link
|
||||
href="/outsourcing#showcase"
|
||||
className="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>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 4. 운영 실증 ─────────────────── */}
|
||||
<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>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 md:grid-cols-3">
|
||||
{PROOF.map((p, i) => (
|
||||
<ScrollReveal key={p.t} delay={i * 100}>
|
||||
<div
|
||||
className="flex h-full flex-col rounded-2xl border p-7"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
/>
|
||||
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }}
|
||||
>
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
|
||||
직접 개발·운영 중
|
||||
</span>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{p.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.d}
|
||||
@@ -348,87 +338,161 @@ export default async function Home() {
|
||||
{p.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-xs px-2.5 py-1 rounded"
|
||||
style={{
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
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 className="mt-10">
|
||||
<Link
|
||||
href="/outsourcing#portfolio"
|
||||
className="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 }}
|
||||
|
||||
{/* 스탯 3종 — 카운트업 */}
|
||||
<ScrollReveal className="mt-12">
|
||||
<div
|
||||
className="grid grid-cols-1 gap-px overflow-hidden rounded-2xl border sm:grid-cols-3"
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-line)' }}
|
||||
>
|
||||
포트폴리오 자세히 보기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
<div className="px-8 py-10" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<p className="text-4xl font-bold lg:text-5xl" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
<CountUp to={15} suffix="+" />
|
||||
</p>
|
||||
<p className="mt-2 break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
직접 운영 중인 실서비스
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-10" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<p className="text-4xl font-bold lg:text-5xl" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
24/7
|
||||
</p>
|
||||
<p className="mt-2 break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
무중단 운영
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-8 py-10" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<p className="text-4xl font-bold lg:text-5xl" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
원스톱
|
||||
</p>
|
||||
<p className="mt-2 break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
기획 → 배포 단독 진행
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 5. PROCESS ─────────────────── */}
|
||||
<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>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="relative mt-12">
|
||||
{/* 단계 연결선 (데스크톱) */}
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[12%] right-[12%] top-7 hidden h-px lg:block"
|
||||
style={{ background: 'var(--jsm-line)' }}
|
||||
/>
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{PROCESS.map((s, i) => (
|
||||
<ScrollReveal key={s.n} delay={i * 100}>
|
||||
<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-14 w-14 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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 6. 소프트웨어 진열 ─── */}
|
||||
{/* Phase 2: products 테이블 기반 동적 진열. 0개이면 출시 준비 중 폴백. */}
|
||||
<section style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
{/* ─────────────────── 6. 완성 SW + CTA ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
{hasProducts ? (
|
||||
<>
|
||||
<div className="flex items-end justify-between mb-10">
|
||||
<ScrollReveal>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Software
|
||||
</p>
|
||||
<Eyebrow>software</Eyebrow>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
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>
|
||||
<Link
|
||||
href="/products"
|
||||
className="hidden sm:inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)] shrink-0"
|
||||
className="hidden shrink-0 items-center gap-1.5 text-sm font-semibold transition-colors duration-150 hover:text-[var(--jsm-accent-hover)] sm:inline-flex"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
전체 보기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
{featuredProducts.map((p) => (
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
{featuredProducts.map((p, i) => (
|
||||
<ScrollReveal key={p.id} delay={i * 100}>
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/products/${p.id}`}
|
||||
className="group flex flex-col rounded-2xl p-7 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||
className="group flex h-full 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)]"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{p.name}
|
||||
</h3>
|
||||
{p.description && (
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="mt-6 pt-5 flex items-center justify-between border-t"
|
||||
className="mt-6 flex items-center justify-between border-t pt-5"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
@@ -446,6 +510,7 @@ export default async function Home() {
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 sm:hidden">
|
||||
@@ -460,72 +525,68 @@ export default async function Home() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<ScrollReveal>
|
||||
<div
|
||||
className="rounded-2xl border px-8 py-14 lg:px-14 lg:py-16 text-center"
|
||||
className="rounded-2xl border px-8 py-14 text-center lg:px-14 lg:py-16"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
Coming soon
|
||||
</p>
|
||||
<Eyebrow>coming soon</Eyebrow>
|
||||
<h2
|
||||
className="text-2xl lg:text-3xl font-bold break-keep"
|
||||
className="break-keep text-2xl font-bold lg:text-3xl"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
검증된 완성 소프트웨어를 준비하고 있습니다
|
||||
</h2>
|
||||
<p
|
||||
className="mt-4 max-w-xl mx-auto leading-relaxed break-keep"
|
||||
className="mx-auto mt-4 max-w-xl break-keep leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
직접 운영하며 다듬은 도구를 하나씩 다운로드 상품으로 공개할 예정입니다.
|
||||
출시 소식을 가장 먼저 받아보세요.
|
||||
직접 운영하며 다듬은 도구를 하나씩 다운로드 상품으로 공개할 예정입니다. 출시
|
||||
소식을 가장 먼저 받아보세요.
|
||||
</p>
|
||||
<Link
|
||||
href="/outsourcing#contact"
|
||||
className="mt-8 inline-flex items-center justify-center gap-2 px-6 py-3.5 rounded-lg font-semibold border transition-colors duration-150 hover:bg-[var(--jsm-surface-alt)]"
|
||||
style={{
|
||||
color: 'var(--jsm-ink)',
|
||||
borderColor: 'var(--jsm-line)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
className="mt-8 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 }}
|
||||
>
|
||||
출시 소식 받기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 7. 최종 CTA ─── */}
|
||||
<section style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="max-w-6xl mx-auto px-6 lg:px-8 py-24 lg:py-28">
|
||||
{/* 최종 CTA 밴드 — 평면 navy (사이트 유일 다크면) */}
|
||||
<ScrollReveal className="mt-20 lg:mt-28">
|
||||
<div
|
||||
className="rounded-3xl px-8 py-16 lg:px-16 lg:py-20"
|
||||
style={{ background: 'var(--jsm-navy)' }}
|
||||
>
|
||||
<div className="max-w-3xl">
|
||||
<h2
|
||||
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
|
||||
className="break-keep text-3xl font-bold leading-tight text-white lg:text-[2.5rem]"
|
||||
style={KOR_TIGHT}
|
||||
>
|
||||
프로젝트, 이야기부터 시작하세요
|
||||
</h2>
|
||||
<p
|
||||
className="mt-5 text-lg leading-relaxed text-white/70 break-keep max-w-2xl"
|
||||
className="mt-5 max-w-2xl break-keep text-lg leading-relaxed text-white/70"
|
||||
style={KOR_BODY}
|
||||
>
|
||||
아이디어 단계여도 괜찮습니다. 무료 상담에서 방향을 함께 잡아드립니다.
|
||||
</p>
|
||||
<Link
|
||||
href="/outsourcing#contact"
|
||||
className="mt-9 inline-flex items-center justify-center gap-2 px-7 py-4 rounded-lg font-semibold text-white transition-colors duration-150"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
className="mt-9 inline-flex items-center justify-center gap-2 rounded-lg bg-white px-7 py-4 font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
|
||||
style={{ color: 'var(--jsm-navy)', ...KOR_BODY }}
|
||||
>
|
||||
무료 상담 신청
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 mb-2" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{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="px-6 py-4 border-b" style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-white font-bold text-xs" style={{ background: 'var(--jsm-accent)' }}>
|
||||
쟁
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 mb-2" style={{ color: 'var(--jsm-ink)' }}>결제가 완료되었습니다!</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="px-6 py-4 border-b" style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg flex items-center justify-center text-white font-bold text-xs" style={{ background: 'var(--jsm-accent)' }}>
|
||||
쟁
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ 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 = {
|
||||
@@ -12,7 +12,6 @@ export const metadata: Metadata = {
|
||||
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
|
||||
};
|
||||
|
||||
// 카탈로그는 항상 최신 상품을 보여주도록 동적 렌더링.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
@@ -26,17 +25,7 @@ const HOW = [
|
||||
|
||||
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
|
||||
>
|
||||
<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>
|
||||
@@ -45,18 +34,7 @@ function ArrowRight() {
|
||||
|
||||
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
|
||||
>
|
||||
<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>
|
||||
);
|
||||
@@ -66,7 +44,6 @@ async function loadProducts(): Promise<ProductRow[]> {
|
||||
try {
|
||||
return await getListedProducts(createAdminClient());
|
||||
} catch (err) {
|
||||
// DB 장애·컬럼 미존재(마이그레이션 미적용) 등 — 페이지는 준비 중 폴백으로 생존
|
||||
console.error('[Products] getListedProducts failed, falling back to empty:', err);
|
||||
return [];
|
||||
}
|
||||
@@ -79,31 +56,23 @@ export default async function ProductsPage() {
|
||||
return (
|
||||
<>
|
||||
{/* ─── Hero ─── */}
|
||||
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-20 lg:py-28">
|
||||
<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-block text-xs font-semibold mb-6 px-2.5 py-1 rounded"
|
||||
style={{
|
||||
color: 'var(--jsm-accent)',
|
||||
background: 'var(--jsm-accent-soft)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
완성 소프트웨어
|
||||
<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="text-3xl sm:text-4xl lg:text-5xl font-bold leading-[1.2] break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
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="text-base sm:text-lg leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
입금 확인 후 마이페이지에서 바로 다운로드할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,30 +80,24 @@ export default async function ProductsPage() {
|
||||
</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 ? (
|
||||
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<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 p-7 lg:p-8 border transition-colors duration-200 hover:border-[var(--jsm-accent)]"
|
||||
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="text-xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
<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 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -142,11 +105,7 @@ export default async function ProductsPage() {
|
||||
{features.length > 0 && (
|
||||
<ul className="mt-5 space-y-2">
|
||||
{features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2 text-sm break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
<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>
|
||||
@@ -156,17 +115,11 @@ export default async function ProductsPage() {
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-6 pt-5 flex items-center justify-between border-t" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
₩{p.price.toLocaleString('ko-KR')}
|
||||
<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 }}>
|
||||
₩{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 }}
|
||||
>
|
||||
<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>
|
||||
@@ -175,75 +128,46 @@ export default async function ProductsPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className="border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||
<div
|
||||
className="rounded-lg border p-8 text-center"
|
||||
style={{
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
borderColor: 'var(--jsm-line)',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-sm font-semibold mb-3"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
출시 준비 중
|
||||
<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>
|
||||
<p
|
||||
className="text-xl sm:text-2xl font-bold mb-4 break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
현재 상품을 정비하고 있습니다.
|
||||
</p>
|
||||
<p
|
||||
className="text-sm sm:text-base leading-relaxed break-keep max-w-md mx-auto"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
로또 분석 도구, 주식 자동매매 유틸리티 등 실제로 운영 중인 도구들을
|
||||
구매 가능한 형태로 순차 공개할 예정입니다.
|
||||
출시 소식을 먼저 받고 싶다면 아래 링크로 문의해 주세요.
|
||||
<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-alt)' }}>
|
||||
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||
<h2
|
||||
className="text-xl sm:text-2xl font-bold mb-10 break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
<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="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
{HOW.map((step) => (
|
||||
<div
|
||||
key={step.n}
|
||||
className="rounded-lg border p-6"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div key={step.n} className="rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="text-xs font-semibold mb-3 block"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
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="font-bold mb-2 break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
<p className="mt-5 break-keep font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{step.t}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{step.d}
|
||||
</p>
|
||||
</div>
|
||||
@@ -253,29 +177,21 @@ export default async function ProductsPage() {
|
||||
</section>
|
||||
|
||||
{/* ─── CTA ─── */}
|
||||
<section className="border-t" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div className="max-w-5xl mx-auto px-6 lg:px-8 py-16 lg:py-20">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<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 px-6 py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||
style={{
|
||||
background: 'var(--jsm-accent)',
|
||||
color: '#ffffff',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
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 px-6 py-3 rounded-lg border text-sm font-semibold transition-colors"
|
||||
style={{
|
||||
borderColor: 'var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
background: 'var(--jsm-surface)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
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>
|
||||
|
||||
@@ -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
166
app/showcase/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
719
app/tarot/TarotReadingClient.tsx
Normal file
719
app/tarot/TarotReadingClient.tsx
Normal 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
14
app/tarot/layout.tsx
Normal 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
37
app/tarot/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' }}>
|
||||
“{t.content}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -66,14 +66,14 @@ export default function SajuForm() {
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* 생년월일 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
생년월일
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="년 (예: 1990)"
|
||||
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
min="1900"
|
||||
max="2100"
|
||||
value={year}
|
||||
@@ -83,7 +83,7 @@ export default function SajuForm() {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="월 (1-12)"
|
||||
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
min="1"
|
||||
max="12"
|
||||
value={month}
|
||||
@@ -93,7 +93,7 @@ export default function SajuForm() {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="일 (1-31)"
|
||||
className="px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
min="1"
|
||||
max="31"
|
||||
value={day}
|
||||
@@ -105,11 +105,11 @@ export default function SajuForm() {
|
||||
|
||||
{/* 태어난 시간 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
태어난 시간 (선택)
|
||||
</label>
|
||||
<select
|
||||
className="w-full px-4 py-3 border-2 border-[#dbe8ff] rounded-xl focus:border-[#1a56db] focus:outline-none transition bg-white text-[#04102b]"
|
||||
className="w-full px-4 py-3 border-2 border-[var(--jsm-line)] rounded-xl focus:border-[var(--jsm-accent)] focus:outline-none transition bg-white text-[var(--jsm-ink)]"
|
||||
value={hour}
|
||||
onChange={(e) => setHour(e.target.value)}
|
||||
>
|
||||
@@ -131,7 +131,7 @@ export default function SajuForm() {
|
||||
|
||||
{/* 양력/음력 선택 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
생일 구분
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -140,8 +140,8 @@ export default function SajuForm() {
|
||||
onClick={() => setCalendarType('solar')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
calendarType === 'solar'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
양력
|
||||
@@ -151,8 +151,8 @@ export default function SajuForm() {
|
||||
onClick={() => setCalendarType('lunar')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
calendarType === 'lunar'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
음력
|
||||
@@ -165,7 +165,7 @@ export default function SajuForm() {
|
||||
type="checkbox"
|
||||
checked={isLeapMonth}
|
||||
onChange={(e) => setIsLeapMonth(e.target.checked)}
|
||||
className="w-4 h-4 text-[#1a56db] border-gray-300 rounded focus:ring-[#1a56db]"
|
||||
className="w-4 h-4 text-[var(--jsm-accent)] border-gray-300 rounded focus:ring-[var(--jsm-accent)]"
|
||||
/>
|
||||
<span>윤달</span>
|
||||
</label>
|
||||
@@ -175,7 +175,7 @@ export default function SajuForm() {
|
||||
|
||||
{/* 성별 선택 */}
|
||||
<div>
|
||||
<label className="block text-left text-sm font-bold text-[#04102b] mb-3">
|
||||
<label className="block text-left text-sm font-bold text-[var(--jsm-ink)] mb-3">
|
||||
성별
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
@@ -184,8 +184,8 @@ export default function SajuForm() {
|
||||
onClick={() => setGender('male')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
gender === 'male'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
남성
|
||||
@@ -195,8 +195,8 @@ export default function SajuForm() {
|
||||
onClick={() => setGender('female')}
|
||||
className={`px-6 py-3 rounded-xl font-bold transition ${
|
||||
gender === 'female'
|
||||
? 'bg-[#1a56db] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[#dbe8ff] text-[#04102b] hover:border-[#1a56db]'
|
||||
? 'bg-[var(--jsm-accent)] text-white shadow-lg'
|
||||
: 'bg-white border-2 border-[var(--jsm-line)] text-[var(--jsm-ink)] hover:border-[var(--jsm-accent)]'
|
||||
}`}
|
||||
>
|
||||
여성
|
||||
@@ -207,7 +207,7 @@ export default function SajuForm() {
|
||||
{/* 제출 버튼 */}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white py-4 rounded-xl text-lg font-bold transition shadow-lg hover:shadow-xl hover:scale-[1.02]"
|
||||
className="w-full bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)] text-white py-4 rounded-xl text-lg font-bold transition shadow-lg hover:shadow-xl hover:scale-[1.02]"
|
||||
>
|
||||
내 사주 보기 →
|
||||
</button>
|
||||
|
||||
@@ -2,13 +2,12 @@ import SajuForm from '@/app/work/saju/components/SajuForm';
|
||||
|
||||
export default function SajuInputPage() {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
<div className="min-h-full bg-[var(--jsm-bg)]">
|
||||
{/* Hero */}
|
||||
<div className="relative overflow-hidden px-6 py-12"
|
||||
style={{ background: '#04102b', 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="relative overflow-hidden px-6 py-12 bg-[var(--jsm-navy)]">
|
||||
|
||||
<div className="relative max-w-xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4 tracking-wide">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 border border-white/20 text-[var(--jsm-accent-soft)] text-xs font-semibold px-4 py-1.5 rounded-full mb-4 tracking-wide">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
AI 사주 분석 · 생년월일 입력
|
||||
</div>
|
||||
@@ -24,10 +23,10 @@ export default function SajuInputPage() {
|
||||
|
||||
{/* Form 영역 */}
|
||||
<div className="px-6 py-10 max-w-2xl mx-auto">
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 shadow-lg">
|
||||
<div className="bg-white rounded-2xl border border-[var(--jsm-line)] p-8 shadow-lg">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<div className="w-1 h-5 bg-gradient-to-b from-[#1a56db] to-[#7c3aed] rounded-full" />
|
||||
<h2 className="font-bold text-[#04102b] text-base">기본 정보 입력</h2>
|
||||
<div className="w-1 h-5 bg-[var(--jsm-accent)] rounded-full" />
|
||||
<h2 className="font-bold text-[var(--jsm-ink)] text-base">기본 정보 입력</h2>
|
||||
</div>
|
||||
<SajuForm />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { isServiceVisible } from '@/lib/service-visibility';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 사주 분석',
|
||||
@@ -24,7 +22,6 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default async function SajuLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!(await isServiceVisible('saju'))) notFound();
|
||||
export default function SajuLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PaymentButton from '@/app/components/PaymentButton';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
const faqItems = [
|
||||
@@ -75,14 +74,14 @@ export default function SajuPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
<div className="min-h-full bg-[var(--jsm-bg)]">
|
||||
{/* ─── 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.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 40px)' }}>
|
||||
<div className="relative overflow-hidden bg-[var(--jsm-navy)] px-6 py-14 lg:px-12">
|
||||
|
||||
<div className="relative max-w-3xl mx-auto">
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||||
<span className="text-violet-300/70 text-xs font-mono tracking-widest uppercase">전통 명리학 × AI 해석 · 무료</span>
|
||||
<span className="text-[var(--jsm-accent-soft)] text-xs font-mono tracking-widest uppercase">전통 명리학 × AI 해석 · 무료</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold text-white leading-tight mb-5 tracking-tight">
|
||||
AI가 분석하는<br />
|
||||
@@ -98,7 +97,7 @@ export default function SajuPage() {
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all"
|
||||
className="inline-flex items-center gap-2 bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)] text-white px-7 py-3.5 rounded-xl font-semibold text-base transition-all"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
@@ -118,7 +117,7 @@ export default function SajuPage() {
|
||||
) : (
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="inline-flex items-center gap-2 bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:from-[#1e4fc2] hover:to-[#6d28d9] text-white px-8 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg shadow-violet-900/40"
|
||||
className="inline-flex items-center gap-2 bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)] text-white px-8 py-3.5 rounded-xl font-semibold text-base transition-all shadow-lg"
|
||||
>
|
||||
<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" />
|
||||
@@ -136,19 +135,19 @@ export default function SajuPage() {
|
||||
{hasPaid && paidRecords.length > 0 && (
|
||||
<div id="past-records">
|
||||
<div className="text-center mb-6">
|
||||
<p className="text-violet-600 text-xs font-bold uppercase tracking-widest mb-2">MY RECORDS</p>
|
||||
<h2 className="text-2xl font-extrabold text-[#04102b]">이전 AI 사주 기록</h2>
|
||||
<p className="text-[var(--jsm-accent)] text-xs font-bold uppercase tracking-widest mb-2">MY RECORDS</p>
|
||||
<h2 className="text-2xl font-extrabold text-[var(--jsm-ink)]">이전 AI 사주 기록</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">결제한 사주 기록을 다시 확인하세요</p>
|
||||
</div>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{paidRecords.map((rec) => (
|
||||
<div key={rec.id} className="bg-white rounded-2xl border border-[#dbe8ff] p-5 hover:border-violet-300 transition-colors">
|
||||
<div key={rec.id} className="bg-white rounded-2xl border border-[var(--jsm-line)] p-5 hover:border-[var(--jsm-accent-soft)] transition-colors">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="text-xs text-slate-400 mb-1">
|
||||
{new Date(rec.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
|
||||
</div>
|
||||
<div className="font-bold text-[#04102b] text-base">
|
||||
<div className="font-bold text-[var(--jsm-ink)] text-base">
|
||||
{rec.saju_data.birth_year ?? '?'}년{' '}
|
||||
{rec.saju_data.birth_month ?? '?'}월{' '}
|
||||
{rec.saju_data.birth_day ?? '?'}일생
|
||||
@@ -169,7 +168,7 @@ export default function SajuPage() {
|
||||
)}
|
||||
<Link
|
||||
href={buildResultUrl(rec)}
|
||||
className="block w-full text-center py-2 rounded-xl text-sm font-bold bg-[#04102b] hover:bg-[#0a1f5c] text-white border border-[#1a3a7a] transition"
|
||||
className="block w-full text-center py-2 rounded-xl text-sm font-bold bg-[var(--jsm-navy)] hover:opacity-90 text-white transition"
|
||||
>
|
||||
다시 보기 →
|
||||
</Link>
|
||||
@@ -180,18 +179,12 @@ export default function SajuPage() {
|
||||
)}
|
||||
|
||||
{/* ─── 바로 시작하기 CTA ─── */}
|
||||
<div
|
||||
className="rounded-2xl border border-[#1a3a7a] p-8 text-center"
|
||||
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="rounded-2xl p-8 text-center bg-[var(--jsm-navy)]">
|
||||
<h3 className="text-2xl font-extrabold text-white mb-2">지금 무료로 시작하세요</h3>
|
||||
<p className="text-blue-200/60 text-sm mb-6">회원가입 없이, 생년월일만 입력하면 바로 확인 가능합니다</p>
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#04102b] px-8 py-3.5 rounded-xl font-bold text-base transition-all"
|
||||
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[var(--jsm-ink)] px-8 py-3.5 rounded-xl font-bold text-base transition-all"
|
||||
>
|
||||
사주 입력하러 가기 →
|
||||
</Link>
|
||||
@@ -200,23 +193,23 @@ export default function SajuPage() {
|
||||
{/* ─── 무료 vs 유료 비교표 ─── */}
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">PRICING</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[#04102b] tracking-tight">무엇을 분석해드리나요</h2>
|
||||
<p className="text-[var(--jsm-accent)] text-xs font-bold uppercase tracking-widest mb-2">PRICING</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-[var(--jsm-ink)] tracking-tight">무엇을 분석해드리나요</h2>
|
||||
<p className="text-slate-500 text-sm mt-2">기본 원국은 무료, AI 상세 해석은 1,000원</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
{/* 무료 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6 shadow-sm">
|
||||
<div className="bg-white rounded-2xl border border-[var(--jsm-line)] p-6 shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-[#1a56db]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--jsm-surface-alt)] border border-[var(--jsm-line)] flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-[var(--jsm-accent)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-slate-500 uppercase tracking-wide">FREE</div>
|
||||
<div className="text-lg font-extrabold text-[#04102b]">무료 기본 분석</div>
|
||||
<div className="text-lg font-extrabold text-[var(--jsm-ink)]">무료 기본 분석</div>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="space-y-3">
|
||||
@@ -230,18 +223,18 @@ export default function SajuPage() {
|
||||
].map((item) => (
|
||||
<li key={item} className="flex items-center gap-2.5 text-sm text-slate-700">
|
||||
<div className="w-4 h-4 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center flex-shrink-0">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[#1a56db]" />
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-[var(--jsm-accent)]" />
|
||||
</div>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-6 pt-5 border-t border-slate-100">
|
||||
<div className="text-2xl font-extrabold text-[#04102b]">무료</div>
|
||||
<div className="text-2xl font-extrabold text-[var(--jsm-ink)]">무료</div>
|
||||
<div className="text-xs text-slate-500 mt-1">회원가입 불필요</div>
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="mt-4 block w-full text-center py-2.5 rounded-xl text-sm font-bold bg-[#f0f5ff] border border-[#dbe8ff] text-[#1a56db] hover:bg-blue-50 transition"
|
||||
className="mt-4 block w-full text-center py-2.5 rounded-xl text-sm font-bold bg-[var(--jsm-surface-alt)] border border-[var(--jsm-line)] text-[var(--jsm-accent)] hover:bg-blue-50 transition"
|
||||
>
|
||||
무료로 시작하기
|
||||
</Link>
|
||||
@@ -249,24 +242,18 @@ export default function SajuPage() {
|
||||
</div>
|
||||
|
||||
{/* AI 해석 (현재 무료) */}
|
||||
<div
|
||||
className="rounded-2xl border border-[#1a3a7a] p-6 shadow-lg relative overflow-hidden"
|
||||
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="absolute top-4 right-4 bg-amber-400 text-[#04102b] text-xs font-bold px-2 py-0.5 rounded-lg">
|
||||
<div className="rounded-2xl p-6 shadow-lg relative overflow-hidden bg-[var(--jsm-navy)]">
|
||||
<div className="absolute top-4 right-4 bg-amber-400 text-[var(--jsm-ink)] text-xs font-bold px-2 py-0.5 rounded-lg">
|
||||
1,000원
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-5 relative">
|
||||
<div className="w-10 h-10 rounded-xl bg-violet-500/20 border border-violet-400/30 flex items-center justify-center">
|
||||
<div className="w-10 h-10 rounded-xl bg-white/10 border border-white/20 flex items-center justify-center">
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-violet-300 uppercase tracking-wide">AI PREMIUM</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-accent-soft)] uppercase tracking-wide">AI PREMIUM</div>
|
||||
<div className="text-lg font-extrabold text-white">AI 상세 해석</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -295,7 +282,7 @@ export default function SajuPage() {
|
||||
<div className="text-xs text-blue-300/70 mt-1 mb-4">로그인 후 결제 · 12가지 항목 AI 해석</div>
|
||||
<Link
|
||||
href="/work/saju/input"
|
||||
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[#04102b] hover:bg-amber-300"
|
||||
className="block w-full text-center py-3 rounded-xl text-sm font-bold transition bg-amber-400 text-[var(--jsm-ink)] hover:bg-amber-300"
|
||||
>
|
||||
사주 분석 시작하기 →
|
||||
</Link>
|
||||
@@ -307,18 +294,18 @@ export default function SajuPage() {
|
||||
{/* ─── FAQ ─── */}
|
||||
<div>
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-[#1a56db] text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
|
||||
<h2 className="text-2xl font-extrabold text-[#04102b]">자주 묻는 질문</h2>
|
||||
<p className="text-[var(--jsm-accent)] text-xs font-bold uppercase tracking-widest mb-2">FAQ</p>
|
||||
<h2 className="text-2xl font-extrabold text-[var(--jsm-ink)]">자주 묻는 질문</h2>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{faqItems.map((item, i) => (
|
||||
<div key={i} className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<div key={i} className="bg-white rounded-2xl border border-[var(--jsm-line)] p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-6 h-6 rounded-full bg-[#f0f5ff] border border-[#dbe8ff] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-[#1a56db] text-xs font-bold">Q</span>
|
||||
<div className="w-6 h-6 rounded-full bg-[var(--jsm-surface-alt)] border border-[var(--jsm-line)] flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<span className="text-[var(--jsm-accent)] text-xs font-bold">Q</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-[#04102b] text-sm mb-2">{item.q}</p>
|
||||
<p className="font-bold text-[var(--jsm-ink)] text-sm mb-2">{item.q}</p>
|
||||
<p className="text-slate-600 text-sm leading-relaxed">{item.a}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import PaymentButton from '@/app/components/PaymentButton';
|
||||
import { SajuIcon, SECTION_ICON_ORDER } from './SajuIcons';
|
||||
|
||||
interface BirthKey {
|
||||
birth_year: number;
|
||||
@@ -30,26 +31,20 @@ interface SajuAISectionProps {
|
||||
};
|
||||
}
|
||||
|
||||
// ── 섹션별 메타 (아이콘·색상) ──────────────────────────────────────────
|
||||
const SECTION_META: {
|
||||
icon: string;
|
||||
gradient: string;
|
||||
border: string;
|
||||
badge: string;
|
||||
badgeText: string;
|
||||
}[] = [
|
||||
{ icon: '🌟', gradient: 'from-violet-500 to-purple-600', border: 'border-violet-100', badge: 'bg-violet-50 border-violet-200 text-violet-700', badgeText: '기질' },
|
||||
{ icon: '⚖️', gradient: 'from-emerald-500 to-teal-600', border: 'border-emerald-100', badge: 'bg-emerald-50 border-emerald-200 text-emerald-700', badgeText: '오행' },
|
||||
{ icon: '🔗', gradient: 'from-blue-500 to-indigo-600', border: 'border-blue-100', badge: 'bg-blue-50 border-blue-200 text-blue-700', badgeText: '지지' },
|
||||
{ icon: '✨', gradient: 'from-amber-500 to-orange-500', border: 'border-amber-100', badge: 'bg-amber-50 border-amber-200 text-amber-700', badgeText: '신살' },
|
||||
{ icon: '💰', gradient: 'from-yellow-500 to-amber-600', border: 'border-yellow-100', badge: 'bg-yellow-50 border-yellow-200 text-yellow-700', badgeText: '재물' },
|
||||
{ icon: '🎯', gradient: 'from-rose-500 to-pink-600', border: 'border-rose-100', badge: 'bg-rose-50 border-rose-200 text-rose-700', badgeText: '직업' },
|
||||
{ icon: '💕', gradient: 'from-pink-500 to-rose-500', border: 'border-pink-100', badge: 'bg-pink-50 border-pink-200 text-pink-700', badgeText: '애정' },
|
||||
{ icon: '🌿', gradient: 'from-green-500 to-emerald-600', border: 'border-green-100', badge: 'bg-green-50 border-green-200 text-green-700', badgeText: '건강' },
|
||||
{ icon: '🗺️', gradient: 'from-cyan-500 to-blue-600', border: 'border-cyan-100', badge: 'bg-cyan-50 border-cyan-200 text-cyan-700', badgeText: '대운' },
|
||||
{ icon: '📅', gradient: 'from-indigo-500 to-violet-600', border: 'border-indigo-100', badge: 'bg-indigo-50 border-indigo-200 text-indigo-700', badgeText: '세운' },
|
||||
{ icon: '🏆', gradient: 'from-amber-400 to-yellow-500', border: 'border-amber-100', badge: 'bg-amber-50 border-amber-200 text-amber-700', badgeText: '황금기' },
|
||||
{ icon: '💌', gradient: 'from-slate-600 to-slate-800', border: 'border-slate-100', badge: 'bg-slate-50 border-slate-200 text-slate-700', badgeText: '종합' },
|
||||
// ── 섹션별 메타 (뱃지 라벨) — 아이콘은 SECTION_ICON_ORDER에서 동일 인덱스로 조회 ──
|
||||
const SECTION_META: { badgeText: string }[] = [
|
||||
{ badgeText: '기질' },
|
||||
{ badgeText: '오행' },
|
||||
{ badgeText: '지지' },
|
||||
{ badgeText: '신살' },
|
||||
{ badgeText: '재물' },
|
||||
{ badgeText: '직업' },
|
||||
{ badgeText: '애정' },
|
||||
{ badgeText: '건강' },
|
||||
{ badgeText: '대운' },
|
||||
{ badgeText: '세운' },
|
||||
{ badgeText: '황금기' },
|
||||
{ badgeText: '종합' },
|
||||
];
|
||||
|
||||
// ── 마크다운 → 섹션 파싱 ──────────────────────────────────────────────
|
||||
@@ -86,30 +81,31 @@ function parseInterpretation(text: string): ParsedSection[] {
|
||||
}
|
||||
|
||||
// ── 섹션 카드 컴포넌트 ────────────────────────────────────────────────
|
||||
function SectionCard({ section, meta, isOpen, onToggle }: {
|
||||
function SectionCard({ section, meta, iconName, isOpen, onToggle }: {
|
||||
section: ParsedSection;
|
||||
meta: typeof SECTION_META[0];
|
||||
iconName: (typeof SECTION_ICON_ORDER)[number];
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className={`rounded-2xl border-2 ${meta.border} bg-white overflow-hidden shadow-sm transition-all`}>
|
||||
<div className="rounded-2xl border-2 border-[var(--jsm-line)] bg-[var(--jsm-surface)] overflow-hidden shadow-sm transition-all">
|
||||
{/* 헤더 */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-3 p-4 text-left hover:bg-slate-50 transition-colors"
|
||||
className="w-full flex items-center gap-3 p-4 text-left hover:bg-[var(--jsm-surface-alt)] transition-colors"
|
||||
>
|
||||
{/* 번호 아이콘 */}
|
||||
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${meta.gradient} flex items-center justify-center text-white font-extrabold text-sm flex-shrink-0 shadow-sm`}>
|
||||
{section.number > 0 ? section.number : meta.icon}
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--jsm-accent)] flex items-center justify-center text-white font-extrabold text-sm flex-shrink-0 shadow-sm">
|
||||
{section.number > 0 ? section.number : <SajuIcon name={iconName} className="w-5 h-5" />}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-full border ${meta.badge}`}>
|
||||
<span className="text-[11px] font-bold px-2 py-0.5 rounded-full border border-[var(--jsm-line)] bg-[var(--jsm-accent-soft)] text-[var(--jsm-accent)]">
|
||||
{meta.badgeText}
|
||||
</span>
|
||||
<h3 className="font-extrabold text-[#04102b] text-sm leading-snug">
|
||||
<h3 className="font-extrabold text-[var(--jsm-ink)] text-sm leading-snug">
|
||||
{section.title}
|
||||
</h3>
|
||||
</div>
|
||||
@@ -117,7 +113,7 @@ function SectionCard({ section, meta, isOpen, onToggle }: {
|
||||
|
||||
{/* 토글 화살표 */}
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`w-4 h-4 text-[var(--jsm-ink-faint)] flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
@@ -126,31 +122,31 @@ function SectionCard({ section, meta, isOpen, onToggle }: {
|
||||
|
||||
{/* 내용 (아코디언) */}
|
||||
{isOpen && (
|
||||
<div className="px-5 pb-5 pt-1 border-t border-slate-100">
|
||||
<div className={`text-[11px] font-semibold mb-3 flex items-center gap-1.5 ${meta.badge.includes('violet') ? 'text-violet-400' : 'text-slate-400'}`}>
|
||||
<span className="text-base">{meta.icon}</span>
|
||||
<div className="px-5 pb-5 pt-1 border-t border-[var(--jsm-line)]">
|
||||
<div className="text-[11px] font-semibold mb-3 flex items-center gap-1.5 text-[var(--jsm-ink-faint)]">
|
||||
<SajuIcon name={iconName} className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none text-slate-700 leading-relaxed">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className="text-base font-extrabold text-[#04102b] mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-extrabold text-[#04102b] mt-3 mb-1.5">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-bold text-[#04102b] mt-2 mb-1">{children}</h3>,
|
||||
h1: ({ children }) => <h1 className="text-base font-extrabold text-[var(--jsm-ink)] mt-4 mb-2">{children}</h1>,
|
||||
h2: ({ children }) => <h2 className="text-sm font-extrabold text-[var(--jsm-ink)] mt-3 mb-1.5">{children}</h2>,
|
||||
h3: ({ children }) => <h3 className="text-sm font-bold text-[var(--jsm-ink)] mt-2 mb-1">{children}</h3>,
|
||||
p: ({ children }) => <p className="mb-3 text-sm leading-relaxed text-slate-700">{children}</p>,
|
||||
strong: ({ children }) => <strong className="font-bold text-[#04102b]">{children}</strong>,
|
||||
strong: ({ children }) => <strong className="font-bold text-[var(--jsm-ink)]">{children}</strong>,
|
||||
em: ({ children }) => <em className="italic text-slate-600">{children}</em>,
|
||||
ul: ({ children }) => <ul className="list-disc list-inside space-y-1.5 mb-3 text-sm text-slate-700 pl-1">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="list-decimal list-inside space-y-1.5 mb-3 text-sm text-slate-700 pl-1">{children}</ol>,
|
||||
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-violet-300 pl-4 py-1 my-3 text-slate-600 bg-violet-50 rounded-r-lg text-sm italic">
|
||||
<blockquote className="border-l-4 border-[var(--jsm-accent)] pl-4 py-1 my-3 text-slate-600 bg-[var(--jsm-accent-soft)] rounded-r-lg text-sm italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className="border-slate-200 my-4" />,
|
||||
code: ({ children }) => (
|
||||
<code className="bg-slate-100 text-violet-700 px-1.5 py-0.5 rounded text-xs font-mono">
|
||||
<code className="bg-slate-100 text-[var(--jsm-accent)] px-1.5 py-0.5 rounded text-xs font-mono">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
@@ -188,6 +184,11 @@ export default function SajuAISection({
|
||||
currentUrl,
|
||||
engineData,
|
||||
}: SajuAISectionProps) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const search = searchParams.toString();
|
||||
const loginHref = `/login?next=${encodeURIComponent(`${pathname}${search ? `?${search}` : ''}`)}`;
|
||||
|
||||
// 저장된 해석이 mock 데이터면 재생성 필요
|
||||
const isMock = isMockInterpretation(savedInterpretation);
|
||||
const validSaved = savedInterpretation && !isMock ? savedInterpretation : null;
|
||||
@@ -197,6 +198,7 @@ export default function SajuAISection({
|
||||
);
|
||||
const [interpretation, setInterpretation] = useState(validSaved ?? '');
|
||||
const [openSections, setOpenSections] = useState<Set<number>>(new Set([0]));
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const called = useRef(false);
|
||||
|
||||
const sections = parseInterpretation(interpretation);
|
||||
@@ -222,13 +224,22 @@ export default function SajuAISection({
|
||||
setTimeout(() => {
|
||||
called.current = false;
|
||||
setStatus('loading');
|
||||
setErrorMessage(null);
|
||||
fetch('/api/saju/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(async r => {
|
||||
if (r.status === 429) return { __rateLimited: true };
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.__rateLimited) {
|
||||
setErrorMessage('오늘 무료 횟수를 모두 사용했습니다. 내일 다시 시도해주세요.');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
if (data.interpretation && !isMockInterpretation(data.interpretation)) {
|
||||
setInterpretation(data.interpretation);
|
||||
setStatus('done');
|
||||
@@ -254,14 +265,23 @@ export default function SajuAISection({
|
||||
if (!hasPaid || validSaved || called.current) return;
|
||||
called.current = true;
|
||||
setStatus('loading');
|
||||
setErrorMessage(null);
|
||||
|
||||
fetch('/api/saju/analyze', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(async r => {
|
||||
if (r.status === 429) return { __rateLimited: true };
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.__rateLimited) {
|
||||
setErrorMessage('오늘 무료 횟수를 모두 사용했습니다. 내일 다시 시도해주세요.');
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
if (data.interpretation) {
|
||||
setInterpretation(data.interpretation);
|
||||
setStatus('done');
|
||||
@@ -287,18 +307,15 @@ export default function SajuAISection({
|
||||
.catch(() => setStatus('error'));
|
||||
}, [hasPaid]);
|
||||
|
||||
// ── 미결제 ──────────────────────────────────────────────────────────
|
||||
// ── 미로그인 ────────────────────────────────────────────────────────
|
||||
if (!hasPaid) {
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] rounded-2xl border border-[#1a3a7a] p-7 text-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-[0.05]"
|
||||
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '22px 22px' }} />
|
||||
<div className="relative">
|
||||
<div className="inline-flex items-center gap-2 bg-amber-400/10 border border-amber-400/25 text-amber-300 text-xs font-semibold px-3 py-1 rounded-full mb-3">
|
||||
<div className="bg-[var(--jsm-navy)] rounded-2xl p-7 text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-[var(--jsm-accent)] text-white text-xs font-semibold px-3 py-1 rounded-full mb-3">
|
||||
AI PREMIUM
|
||||
</div>
|
||||
<h3 className="text-xl font-extrabold text-white mb-2">AI 상세 해석 (12개 항목)</h3>
|
||||
<p className="text-blue-200/60 text-sm mb-6">
|
||||
<p className="text-white/70 text-sm mb-6">
|
||||
성격, 재물운, 직업 적성, 애정운, 건강운, 대운 분석 등<br />
|
||||
Gemini 2.5 Pro가 생성하는 맞춤형 사주 해석을 받아보세요.
|
||||
</p>
|
||||
@@ -306,21 +323,20 @@ export default function SajuAISection({
|
||||
{/* 미리보기 섹션 목록 */}
|
||||
<div className="grid grid-cols-3 gap-2 mb-6 text-left">
|
||||
{SECTION_META.map((meta, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2 py-1.5">
|
||||
<span className="text-sm">{meta.icon}</span>
|
||||
<span className="text-xs text-blue-200/70 font-medium">{meta.badgeText}</span>
|
||||
<div key={i} className="flex items-center gap-1.5 bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<SajuIcon name={SECTION_ICON_ORDER[i]} className="w-4 h-4 text-white/80" />
|
||||
<span className="text-xs text-white/70 font-medium">{meta.badgeText}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PaymentButton
|
||||
productId="saju_detail"
|
||||
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#04102b] font-bold px-7 py-3 rounded-xl transition-all"
|
||||
<a
|
||||
href={loginHref}
|
||||
className="inline-flex items-center gap-2 bg-white hover:bg-white/90 text-[var(--jsm-navy)] font-semibold px-7 py-3 rounded-xl transition-colors"
|
||||
>
|
||||
AI 상세 해석 받기 — 1,000원
|
||||
</PaymentButton>
|
||||
<p className="text-blue-200/40 text-xs mt-3">결제 후 즉시 AI 분석 시작 · 로그인 필요</p>
|
||||
</div>
|
||||
로그인하고 AI 상세 해석 무료로 받기
|
||||
</a>
|
||||
<p className="text-white/50 text-xs mt-3">로그인 회원은 하루 1회 무료 · 저장된 해석은 언제든 다시 보기</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -328,14 +344,14 @@ export default function SajuAISection({
|
||||
// ── 로딩 ──────────────────────────────────────────────────────────
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 text-center">
|
||||
<div className="w-10 h-10 border-2 border-violet-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] p-8 text-center">
|
||||
<div className="w-10 h-10 border-2 border-[var(--jsm-accent)] border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-slate-500 text-sm font-medium">AI가 사주를 분석하는 중입니다...</p>
|
||||
<p className="text-slate-400 text-xs mt-1">약 20~30초 소요될 수 있습니다</p>
|
||||
<div className="mt-5 flex flex-wrap justify-center gap-2">
|
||||
{SECTION_META.map((meta, i) => (
|
||||
<span key={i} className="flex items-center gap-1 text-xs text-slate-400 animate-pulse">
|
||||
<span>{meta.icon}</span>{meta.badgeText}
|
||||
<SajuIcon name={SECTION_ICON_ORDER[i]} className="w-3.5 h-3.5" />{meta.badgeText}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -347,36 +363,40 @@ export default function SajuAISection({
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-red-200 p-6 text-center">
|
||||
<p className="text-red-500 text-sm font-medium mb-3">AI 해석 생성에 실패했습니다.</p>
|
||||
<p className="text-red-500 text-sm font-medium mb-3">
|
||||
{errorMessage ?? 'AI 해석 생성에 실패했습니다.'}
|
||||
</p>
|
||||
{!errorMessage && (
|
||||
<button
|
||||
onClick={() => { called.current = false; setStatus('idle'); }}
|
||||
className="text-xs text-blue-600 underline"
|
||||
>
|
||||
다시 시도하기
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 해석 완료 ─────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
|
||||
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-[#04102b] to-[#0a1f5c]">
|
||||
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-amber-400 flex items-center justify-center flex-shrink-0">
|
||||
<div className="flex items-center gap-2 px-6 py-4 bg-[var(--jsm-navy)]">
|
||||
<div className="w-7 h-7 rounded-lg bg-[var(--jsm-accent)] flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-sm font-extrabold text-white">AI 상세 해석</h2>
|
||||
<p className="text-blue-300/60 text-[11px]">12개 항목 · 클릭해서 펼쳐보세요</p>
|
||||
<p className="text-white/60 text-[11px]">12개 항목 · 클릭해서 펼쳐보세요</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleRegenerate}
|
||||
title="AI 해석 재생성"
|
||||
className="text-[11px] text-blue-300/60 hover:text-blue-200 px-2 py-1 rounded-lg hover:bg-white/10 transition-all flex items-center gap-1"
|
||||
className="text-[11px] text-white/60 hover:text-white px-2 py-1 rounded-lg hover:bg-white/10 transition-all flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
@@ -384,7 +404,7 @@ export default function SajuAISection({
|
||||
재생성
|
||||
</button>
|
||||
<span className="text-xs bg-emerald-400/20 border border-emerald-400/30 text-emerald-300 font-bold px-2.5 py-1 rounded-full">
|
||||
결제 완료
|
||||
AI 해석 완료
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -400,7 +420,7 @@ export default function SajuAISection({
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="text-xs text-violet-600 hover:text-violet-800 font-semibold px-3 py-1 rounded-lg border border-violet-200 hover:bg-violet-50 transition-colors"
|
||||
className="text-xs text-[var(--jsm-accent)] hover:text-[var(--jsm-accent-hover)] font-semibold px-3 py-1 rounded-lg border border-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-soft)] transition-colors"
|
||||
>
|
||||
전체 펼치기
|
||||
</button>
|
||||
@@ -424,6 +444,7 @@ export default function SajuAISection({
|
||||
key={idx}
|
||||
section={section}
|
||||
meta={meta}
|
||||
iconName={SECTION_ICON_ORDER[metaIdx]}
|
||||
isOpen={openSections.has(idx)}
|
||||
onToggle={() => toggleSection(idx)}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { LottoIcon, SajuIcon } from './SajuIcons';
|
||||
import type { SajuIconName } from './SajuIcons';
|
||||
|
||||
// ── 천간 / 지지 ───────────────────────────────────────────────────────
|
||||
const STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸'];
|
||||
@@ -73,8 +75,47 @@ function seededRand(seed: number) {
|
||||
return () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0xffffffff; };
|
||||
}
|
||||
|
||||
// ── 로컬 라인 아이콘 (이모지 대체, 파일 전용) ──────────────────────────
|
||||
// SajuIcons.tsx와 도형이 겹치는 5종(great/money/love/career/health)은 SajuIcon을
|
||||
// 재사용하고, 로컬 Glyph는 SajuIcons.tsx에 없는 고유 도형만 보유한다(중복 진실원천 제거).
|
||||
type GlyphName = 'sun' | 'good' | 'neutral' | 'caution' | 'social';
|
||||
type ReusedIconName = 'great' | 'money' | 'love' | 'career' | 'health';
|
||||
type IconName = GlyphName | ReusedIconName;
|
||||
|
||||
const GLYPH_PATHS: Record<GlyphName, string> = {
|
||||
sun: 'M12 8a4 4 0 100 8 4 4 0 000-8zM12 2v2M12 20v2M4 12H2M22 12h-2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41',
|
||||
good: 'M4 12l5 5L20 6',
|
||||
neutral: 'M4 13c2-2 4-2 6 0s4 2 6 0 4-2 6 0',
|
||||
caution: 'M12 3l9 16H3zM12 10v4M12 16.5h.01',
|
||||
social: 'M9 11a3 3 0 100-6 3 3 0 000 6zM3 20c0-3 3-5 6-5s6 2 6 5M17 11a3 3 0 100-6M15 20c0-2.5 1.5-4.5 4-5',
|
||||
};
|
||||
|
||||
// SajuIcons.tsx의 기존 도형과 겹치는 항목 → SajuIcon name 매핑(재사용)
|
||||
const REUSED_ICON: Record<ReusedIconName, SajuIconName> = {
|
||||
great: 'trait', money: 'wealth', love: 'love', career: 'career', health: 'health',
|
||||
};
|
||||
|
||||
function isReusedIcon(name: IconName): name is ReusedIconName {
|
||||
return name in REUSED_ICON;
|
||||
}
|
||||
|
||||
function Glyph({ name, className }: { name: GlyphName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}
|
||||
strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden>
|
||||
<path d={GLYPH_PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// 로컬 Glyph / SajuIcons 재사용 아이콘을 함께 렌더링하는 디스패처(시각 결과 동일)
|
||||
function Icon({ name, className }: { name: IconName; className?: string }) {
|
||||
if (isReusedIcon(name)) return <SajuIcon name={REUSED_ICON[name]} className={className} />;
|
||||
return <Glyph name={name} className={className} />;
|
||||
}
|
||||
|
||||
// ── 운세 항목 빌드 ────────────────────────────────────────────────────
|
||||
type Area = { icon: string; label: string; score: number; desc: string };
|
||||
type Area = { icon: IconName; label: string; score: number; desc: string };
|
||||
|
||||
const DESCS: Record<string, Record<Level, string>> = {
|
||||
money: {
|
||||
@@ -119,7 +160,7 @@ function buildAreas(
|
||||
const rand = seededRand(seed);
|
||||
const roll = () => Math.round(Math.max(15, Math.min(98, rand() * 40 + overall - 20)));
|
||||
const keys = ['money','love','career','health','social'] as const;
|
||||
const icons = ['💰','💕','🎯','🌿','🤝'];
|
||||
const icons: IconName[] = ['money','love','career','health','social'];
|
||||
const labels = ['재물운','애정운','직업운','건강운','사회운'];
|
||||
return keys.map((k, i) => {
|
||||
const s = roll();
|
||||
@@ -128,11 +169,11 @@ function buildAreas(
|
||||
}
|
||||
|
||||
// ── 레벨별 색상/라벨 ─────────────────────────────────────────────────
|
||||
const LEVEL_META: Record<Level, { emoji: string; label: string; bar: string; bg: string; border: string; text: string; badge: string }> = {
|
||||
great: { emoji:'🌟', label:'아주 좋은 날', bar:'#f59e0b', bg:'bg-amber-50', border:'border-amber-300', text:'text-amber-800', badge:'bg-amber-100 text-amber-700 border-amber-300' },
|
||||
good: { emoji:'✨', label:'좋은 날', bar:'#22c55e', bg:'bg-emerald-50',border:'border-emerald-300',text:'text-emerald-800',badge:'bg-emerald-100 text-emerald-700 border-emerald-300' },
|
||||
neutral: { emoji:'🌤️', label:'평온한 날', bar:'#64748b', bg:'bg-slate-50', border:'border-slate-200', text:'text-slate-700', badge:'bg-slate-100 text-slate-600 border-slate-200' },
|
||||
caution: { emoji:'⚠️', label:'조심하는 날', bar:'#f97316', bg:'bg-orange-50', border:'border-orange-300',text:'text-orange-800', badge:'bg-orange-100 text-orange-700 border-orange-300' },
|
||||
const LEVEL_META: Record<Level, { icon: IconName; label: string; bar: string; bg: string; border: string; text: string; badge: string }> = {
|
||||
great: { icon:'great', label:'아주 좋은 날', bar:'#f59e0b', bg:'bg-amber-50', border:'border-amber-300', text:'text-amber-800', badge:'bg-amber-100 text-amber-700 border-amber-300' },
|
||||
good: { icon:'good', label:'좋은 날', bar:'#22c55e', bg:'bg-emerald-50',border:'border-emerald-300',text:'text-emerald-800',badge:'bg-emerald-100 text-emerald-700 border-emerald-300' },
|
||||
neutral: { icon:'neutral', label:'평온한 날', bar:'#64748b', bg:'bg-slate-50', border:'border-slate-200', text:'text-slate-700', badge:'bg-slate-100 text-slate-600 border-slate-200' },
|
||||
caution: { icon:'caution', label:'조심하는 날', bar:'#f97316', bg:'bg-orange-50', border:'border-orange-300',text:'text-orange-800', badge:'bg-orange-100 text-orange-700 border-orange-300' },
|
||||
};
|
||||
|
||||
const REL_DESC: (yongShin: string, yongShinKr: string) => Record<Rel, string> = (y, yk) => ({
|
||||
@@ -148,7 +189,7 @@ const REL_DESC: (yongShin: string, yongShinKr: string) => Record<Rel, string> =
|
||||
function ScoreBar({ score, color }: { score: number; color: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="flex-1 h-1.5 bg-[var(--jsm-line)] rounded-full overflow-hidden">
|
||||
<div style={{ width: `${score}%`, background: color, transition: 'width 0.8s ease' }} className="h-full rounded-full" />
|
||||
</div>
|
||||
<span className="text-[10px] font-bold w-6 text-right" style={{ color }}>{score}</span>
|
||||
@@ -185,31 +226,31 @@ export default function SajuFortuneSection({
|
||||
<>
|
||||
{/* ── 상단 연결 화살표 ── */}
|
||||
<div className="flex flex-col items-center gap-0 py-1">
|
||||
<div className="w-px h-5 bg-gradient-to-b from-blue-200 to-amber-300" />
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-amber-50 border border-amber-200 rounded-full text-[11px] font-bold text-amber-700">
|
||||
<span>✨</span> 사주 분석에서 이어지는 오늘의 운세
|
||||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--jsm-accent-soft)] border border-[var(--jsm-line)] rounded-full text-[11px] font-bold text-[var(--jsm-accent)]">
|
||||
사주 분석에서 이어지는 오늘의 운세
|
||||
</div>
|
||||
<div className="w-px h-5 bg-gradient-to-b from-amber-300 to-amber-100" />
|
||||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||||
</div>
|
||||
|
||||
{/* ── 본문 카드 ── */}
|
||||
<div id="today-fortune" className="bg-white rounded-2xl border border-amber-200 overflow-hidden shadow-sm">
|
||||
<div id="today-fortune" className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] overflow-hidden shadow-sm">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gradient-to-r from-[#1a0a00] via-[#3d1a00] to-[#1a0a00] px-6 py-5">
|
||||
<div className="bg-[var(--jsm-navy)] px-6 py-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-md text-xl">
|
||||
☀️
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--jsm-accent)] flex items-center justify-center flex-shrink-0">
|
||||
<Glyph name="sun" className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-extrabold text-white">오늘의 운세</h2>
|
||||
<p className="text-amber-300/70 text-[11px] mt-0.5">
|
||||
<p className="text-white/60 text-[11px] mt-0.5">
|
||||
{today.year}년 {today.month}월 {today.date}일 · 일진 {today.stem}{today.branch} ({today.stemKr}{today.branchKr})
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`text-[11px] font-extrabold px-3 py-1.5 rounded-full border ${meta.badge} flex-shrink-0`}>
|
||||
{meta.emoji} {meta.label}
|
||||
<span className={`text-[11px] font-extrabold px-3 py-1.5 rounded-full border ${meta.badge} flex-shrink-0 flex items-center gap-1`}>
|
||||
<Icon name={meta.icon} className="w-3 h-3" /> {meta.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +278,7 @@ export default function SajuFortuneSection({
|
||||
<span className="text-[11px] font-bold text-slate-500">오늘 종합 운세</span>
|
||||
<div className="flex-1 h-2.5 bg-white/70 rounded-full overflow-hidden border border-white/50">
|
||||
<div
|
||||
style={{ width: `${overall}%`, background: `linear-gradient(90deg, ${meta.bar}cc, ${meta.bar})` }}
|
||||
style={{ width: `${overall}%`, background: meta.bar }}
|
||||
className="h-full rounded-full"
|
||||
/>
|
||||
</div>
|
||||
@@ -247,20 +288,22 @@ export default function SajuFortuneSection({
|
||||
|
||||
{/* 5대 운세 그리드 */}
|
||||
<div>
|
||||
<h3 className="text-xs font-extrabold text-[#04102b] mb-3">오늘의 분야별 운세</h3>
|
||||
<h3 className="text-xs font-extrabold text-[var(--jsm-ink)] mb-3">오늘의 분야별 운세</h3>
|
||||
<div className="space-y-3">
|
||||
{areas.map((area) => {
|
||||
const aLevel = toLevel(area.score);
|
||||
const aMeta = LEVEL_META[aLevel];
|
||||
return (
|
||||
<div key={area.label} className="flex gap-3 items-start">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 text-sm ${aMeta.bg} border ${aMeta.border}`}>
|
||||
{area.icon}
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${aMeta.bg} border ${aMeta.border} ${aMeta.text}`}>
|
||||
<Icon name={area.icon} className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="text-xs font-bold text-[#04102b]">{area.label}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${aMeta.badge}`}>{aMeta.emoji}</span>
|
||||
<span className="text-xs font-bold text-[var(--jsm-ink)]">{area.label}</span>
|
||||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${aMeta.badge} flex items-center gap-1`}>
|
||||
<Icon name={aMeta.icon} className="w-2.5 h-2.5" />
|
||||
</span>
|
||||
</div>
|
||||
<ScoreBar score={area.score} color={aMeta.bar} />
|
||||
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed">{area.desc}</p>
|
||||
@@ -278,18 +321,15 @@ export default function SajuFortuneSection({
|
||||
</p>
|
||||
|
||||
{/* 로또 CTA */}
|
||||
<div className="rounded-2xl bg-gradient-to-br from-[#04102b] via-[#0d1f5c] to-[#04102b] border border-[#1a3a7a] p-5 relative overflow-hidden">
|
||||
<div className="absolute inset-0 opacity-[0.04]"
|
||||
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '20px 20px' }} />
|
||||
<div className="relative">
|
||||
<div className="rounded-2xl bg-[var(--jsm-navy)] p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-base">🎱</span>
|
||||
<span className="text-xs font-extrabold text-amber-300">
|
||||
<LottoIcon className="w-4 h-4 text-[var(--jsm-accent)]" />
|
||||
<span className="text-xs font-extrabold text-white">
|
||||
{level === 'great' ? '오늘 운이 아주 좋습니다! 로또도 한 번 도전해보세요.' : '사주 기반 행운 번호도 확인해보세요.'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-200/70 leading-relaxed mb-4">
|
||||
용신 <strong className="text-amber-300">{yongShin}({yongShinKr})</strong> 오행이 담긴
|
||||
<p className="text-xs text-white/70 leading-relaxed mb-4">
|
||||
용신 <strong className="text-[var(--jsm-accent-soft)]">{yongShin}({yongShinKr})</strong> 오행이 담긴
|
||||
사주 기반 로또 번호가 아래에 준비되어 있습니다.
|
||||
{hasLottoSubscription
|
||||
? ' 구독 중이신 로또 서비스의 매주 최신 추천 번호도 함께 확인하세요.'
|
||||
@@ -301,22 +341,21 @@ export default function SajuFortuneSection({
|
||||
e.preventDefault();
|
||||
document.getElementById('saju-lotto-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
className="block w-full text-center bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 text-[#04102b] text-sm font-extrabold px-4 py-2.5 rounded-xl transition-all shadow-lg cursor-pointer"
|
||||
className="block w-full text-center bg-white hover:bg-white/90 text-[var(--jsm-navy)] text-sm font-extrabold px-4 py-2.5 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
오늘의 로또 번호 추천 보기 ↓
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 연결 */}
|
||||
<div className="flex flex-col items-center gap-0 py-1">
|
||||
<div className="w-px h-5 bg-gradient-to-b from-amber-200 to-blue-300" />
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-blue-50 border border-blue-200 rounded-full text-[11px] font-bold text-blue-700">
|
||||
<span>🎱</span> 오늘의 운세에서 이어지는 사주 로또 추천
|
||||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||||
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--jsm-accent-soft)] border border-[var(--jsm-line)] rounded-full text-[11px] font-bold text-[var(--jsm-accent)]">
|
||||
<LottoIcon className="w-4 h-4 text-[var(--jsm-accent)]" /> 오늘의 운세에서 이어지는 사주 로또 추천
|
||||
</div>
|
||||
<div className="w-px h-5 bg-gradient-to-b from-blue-200 to-transparent" />
|
||||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
45
app/work/saju/result/SajuIcons.tsx
Normal file
45
app/work/saju/result/SajuIcons.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { SVGProps } from 'react';
|
||||
|
||||
export type SajuIconName =
|
||||
| 'trait' | 'element' | 'branch' | 'sinsal' | 'wealth' | 'career'
|
||||
| 'love' | 'health' | 'daeun' | 'seun' | 'golden' | 'summary';
|
||||
|
||||
// SECTION_META 순서(기질·오행·지지·신살·재물·직업·애정·건강·대운·세운·황금기·종합)와 1:1
|
||||
export const SECTION_ICON_ORDER: SajuIconName[] = [
|
||||
'trait', 'element', 'branch', 'sinsal', 'wealth', 'career',
|
||||
'love', 'health', 'daeun', 'seun', 'golden', 'summary',
|
||||
];
|
||||
|
||||
const PATHS: Record<SajuIconName, string> = {
|
||||
trait: 'M12 3l2.2 5.6L20 9l-4.5 3.9L17 19l-5-3-5 3 1.5-6.1L4 9l5.8-.4z', // 별/기질
|
||||
element: 'M12 3v18M3 12h18', // 균형/오행
|
||||
branch: 'M7 7h10v10M7 7v10h10', // 연결/지지
|
||||
sinsal: 'M12 2l2 7h7l-5.5 4 2 7L12 17l-5.5 3 2-7L3 9h7z', // 신살(sparkle)
|
||||
wealth: 'M12 4a8 4 0 1 0 0 8a8 4 0 1 0 0-8M4 8v8a8 4 0 0 0 16 0V8', // 재물(coin stack)
|
||||
career: 'M4 8h16v11H4zM9 8V5h6v3', // 직업(가방)
|
||||
love: 'M12 20s-7-4.4-7-9a4 4 0 0 1 7-2.6A4 4 0 0 1 19 11c0 4.6-7 9-7 9z', // 애정(하트)
|
||||
health: 'M3 12h4l2-5 3 10 2-6 2 1h5', // 건강(맥박)
|
||||
daeun: 'M4 20L20 4M14 4h6v6', // 대운(길/화살)
|
||||
seun: 'M4 7h16v13H4zM4 11h16M8 3v4M16 3v4', // 세운(달력)
|
||||
golden: 'M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0M9 15l-1 6 4-2 4 2-1-6', // 황금기(메달)
|
||||
summary: 'M6 3h9l3 3v15H6zM9 9h6M9 13h6M9 17h4', // 종합(문서)
|
||||
};
|
||||
|
||||
export function SajuIcon({ name, className }: { name: SajuIconName; className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}
|
||||
strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden>
|
||||
<path d={PATHS[name]} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LottoIcon(props: SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}
|
||||
strokeLinecap="round" strokeLinejoin="round" aria-hidden {...props}>
|
||||
<circle cx="12" cy="12" r="8" />
|
||||
<path d="M12 8v8M8 12h8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
|
||||
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center">
|
||||
<div className="min-h-full bg-[var(--jsm-bg)] flex items-center justify-center">
|
||||
<div className="text-center py-20">
|
||||
<p className="text-slate-500 text-sm mb-4">잘못된 접근입니다. 생년월일을 다시 입력해주세요.</p>
|
||||
<a href="/work/saju/input" className="text-blue-600 underline text-sm">사주 입력하기</a>
|
||||
@@ -74,7 +74,8 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum);
|
||||
const solarTermName = getSolarTermName(solarTermIndex);
|
||||
|
||||
// ── 결제 여부 + 저장된 AI 해석 + 로또 구독 확인 ─────────────────────
|
||||
// ── 로그인 여부 + 저장된 AI 해석 + 로또 구독 확인 ─────────────────────
|
||||
// Phase 2: AI 상세 해석은 결제 게이트 → 로그인 게이트로 전환(무료화, 일일 제한은 API에서 강제)
|
||||
let hasPaid = false;
|
||||
let savedInterpretation: string | null = null;
|
||||
let hasLottoSubscription = false;
|
||||
@@ -82,12 +83,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
// 사주 결제 확인 (anon client — 본인 orders는 RLS 허용 가정)
|
||||
const { data: order } = await supabase
|
||||
.from('orders').select('id')
|
||||
.eq('user_id', user.id).eq('product_id', 'saju_detail').eq('status', 'paid')
|
||||
.maybeSingle();
|
||||
hasPaid = !!order;
|
||||
hasPaid = true;
|
||||
|
||||
if (hasPaid) {
|
||||
// 1차: birth_hour 포함 정확한 키로 조회
|
||||
@@ -110,19 +106,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과)
|
||||
const { data: lottoSub } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'active')
|
||||
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
|
||||
.maybeSingle();
|
||||
hasLottoSubscription = !!lottoSub;
|
||||
|
||||
// subscriptions에서 못 찾으면 orders 테이블로 폴백 (구독 마이그레이션 전 데이터)
|
||||
if (!hasLottoSubscription) {
|
||||
const now = new Date().toISOString();
|
||||
// 로또 이용권 확인 — orders 테이블 (최근 31일 paid 주문)
|
||||
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const { data: lottoOrder } = await supabase
|
||||
.from('orders')
|
||||
@@ -134,7 +118,6 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
.maybeSingle();
|
||||
hasLottoSubscription = !!lottoOrder;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 미로그인 시 무시
|
||||
}
|
||||
@@ -158,16 +141,16 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
const engineBadge = <span className="text-[10px] bg-blue-50 border border-blue-200 text-blue-600 px-2 py-0.5 rounded-full font-semibold">TS 계산</span>;
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff]">
|
||||
<div className="min-h-full bg-[var(--jsm-bg)]">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
|
||||
<div className="bg-[var(--jsm-navy)] px-6 py-10">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 border border-white/20 text-white text-xs font-semibold px-4 py-1.5 rounded-full mb-4">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--jsm-accent)]" />
|
||||
사주팔자 감정서
|
||||
</div>
|
||||
<h1 className="text-3xl font-extrabold text-white mb-2">사주팔자 분석 결과</h1>
|
||||
<p className="text-blue-200/60 text-sm">전통 명리학과 AI 기술의 만남</p>
|
||||
<p className="text-white/60 text-sm">전통 명리학과 AI 기술의 만남</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -176,16 +159,16 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
|
||||
{/* 사이드바 */}
|
||||
<aside className="lg:sticky lg:top-6 h-fit">
|
||||
<div className="bg-[#04102b] rounded-2xl p-6 text-white">
|
||||
<div className="bg-[var(--jsm-navy)] rounded-2xl p-6 text-white">
|
||||
<h2 className="text-base font-bold mb-5 text-center pb-4 border-b border-white/10">기본 정보</h2>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="text-blue-300/60 mb-1">생년월일</div>
|
||||
<div className="text-white/50 mb-1">생년월일</div>
|
||||
<div className="font-bold">
|
||||
{isLunar ? (
|
||||
<div>
|
||||
<div>음력 {inputYear}.{inputMonth}.{inputDay}{isLeap ? ' (윤달)' : ''}</div>
|
||||
<div className="text-xs text-blue-300/50 mt-0.5">양력 {yearNum}.{monthNum}.{dayNum}</div>
|
||||
<div className="text-xs text-white/40 mt-0.5">양력 {yearNum}.{monthNum}.{dayNum}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>{yearNum}.{monthNum}.{dayNum}</div>
|
||||
@@ -194,33 +177,33 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
{hourNum !== null && (
|
||||
<div>
|
||||
<div className="text-blue-300/60 mb-1">태어난 시간</div>
|
||||
<div className="text-white/50 mb-1">태어난 시간</div>
|
||||
<div className="font-bold">{hourNum}시</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="text-blue-300/60 mb-1">성별</div>
|
||||
<div className="text-white/50 mb-1">성별</div>
|
||||
<div className="font-bold">{gender === 'male' ? '남성' : '여성'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-blue-300/60 mb-1">띠</div>
|
||||
<div className="text-white/50 mb-1">띠</div>
|
||||
<div className="font-bold">{zodiacAnimal}띠</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-blue-300/60 mb-1">태어난 절기</div>
|
||||
<div className="font-bold text-amber-300">{solarTermName} 이후</div>
|
||||
<div className="text-white/50 mb-1">태어난 절기</div>
|
||||
<div className="font-bold text-[var(--jsm-accent-soft)]">{solarTermName} 이후</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-blue-300/60 mb-1">일간</div>
|
||||
<div className="font-bold text-2xl text-amber-400">
|
||||
<div className="text-white/50 mb-1">일간</div>
|
||||
<div className="font-bold text-2xl text-[var(--jsm-accent-soft)]">
|
||||
{sajuData.day.stem} ({sajuData.day.stemKr})
|
||||
</div>
|
||||
<div className="text-xs text-blue-300/60 mt-1">
|
||||
<div className="text-xs text-white/50 mt-1">
|
||||
{FIVE_ELEMENTS_KR[sajuData.day.element as keyof typeof FIVE_ELEMENTS_KR]}({sajuData.day.element})
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-blue-300/60 text-xs">계산 엔진</div>
|
||||
<div className="text-white/50 text-xs">계산 엔진</div>
|
||||
{engineBadge}
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +214,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
다시 입력하기
|
||||
</Link>
|
||||
<Link href="/work/saju"
|
||||
className="block w-full text-center bg-violet-500/20 hover:bg-violet-500/30 text-violet-300 px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
className="block w-full text-center bg-white/10 hover:bg-white/20 text-[var(--jsm-accent-soft)] px-4 py-2 rounded-lg transition text-sm font-medium">
|
||||
서비스 소개
|
||||
</Link>
|
||||
</div>
|
||||
@@ -242,13 +225,13 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
<main className="space-y-6">
|
||||
|
||||
{/* 사주팔자 표 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h2 className="text-xl font-extrabold text-[#04102b] mb-5 text-center">사주팔자 (四柱八字)</h2>
|
||||
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] p-6">
|
||||
<h2 className="text-xl font-extrabold text-[var(--jsm-ink)] mb-5 text-center">사주팔자 (四柱八字)</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#04102b] text-white">
|
||||
<tr className="bg-[var(--jsm-navy)] text-white">
|
||||
<th className="py-2.5 px-3 text-center font-bold text-xs">구분</th>
|
||||
{sajuData.hour && <th className="py-2.5 px-3 text-center font-bold text-xs">시주</th>}
|
||||
<th className="py-2.5 px-3 text-center font-bold text-xs">일주</th>
|
||||
@@ -259,54 +242,54 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
<tbody>
|
||||
{/* 천간 */}
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">천간</td>
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[var(--jsm-ink)] bg-[var(--jsm-surface-alt)] text-xs">천간</td>
|
||||
{sajuData.hour && (
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.stem}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.hour.stem}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.stemKr}</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2.5 px-3 text-center bg-amber-50">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.stem}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.day.stem}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.stemKr}</div>
|
||||
<div className="text-xs text-amber-600 font-bold mt-0.5">일간</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.stem}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.month.stem}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.stemKr}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.stem}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.year.stem}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.stemKr}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 지지 */}
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">지지</td>
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[var(--jsm-ink)] bg-[var(--jsm-surface-alt)] text-xs">지지</td>
|
||||
{sajuData.hour && (
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.branch}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.hour.branch}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.branchKr}</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2.5 px-3 text-center bg-amber-50">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.branch}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.day.branch}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.branchKr}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.branch}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.month.branch}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.branchKr}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.branch}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)]">{sajuData.year.branch}</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.branchKr}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 지장간 */}
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[var(--jsm-ink)] bg-[var(--jsm-surface-alt)] text-xs">
|
||||
<div>지장간</div>
|
||||
<div className="text-[10px] text-slate-400 font-normal">숨은 천간</div>
|
||||
</td>
|
||||
@@ -337,39 +320,39 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
|
||||
{/* 십성 */}
|
||||
<tr className="border-b border-slate-100">
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">십성</td>
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[var(--jsm-ink)] bg-[var(--jsm-surface-alt)] text-xs">십성</td>
|
||||
{sajuData.hour && (
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.tenGod}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.hour.tenGod}</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2.5 px-3 text-center bg-amber-50">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.tenGod}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.day.tenGod}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.tenGod}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.month.tenGod}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.tenGod}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.year.tenGod}</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 십이운성 */}
|
||||
<tr>
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">십이운성</td>
|
||||
<td className="py-2.5 px-3 text-center font-semibold text-[var(--jsm-ink)] bg-[var(--jsm-surface-alt)] text-xs">십이운성</td>
|
||||
{sajuData.hour && (
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.fortune}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.hour.fortune}</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2.5 px-3 text-center bg-amber-50">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.fortune}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.day.fortune}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.fortune}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.month.fortune}</div>
|
||||
</td>
|
||||
<td className="py-2.5 px-3 text-center">
|
||||
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.fortune}</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{sajuData.year.fortune}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -379,7 +362,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
{/* 지지 상호작용 */}
|
||||
{branchInteractions.length > 0 && (
|
||||
<div className="mt-5 pt-5 border-t border-slate-100">
|
||||
<h3 className="text-sm font-bold text-[#04102b] mb-3 text-center">지지 상호작용</h3>
|
||||
<h3 className="text-sm font-bold text-[var(--jsm-ink)] mb-3 text-center">지지 상호작용</h3>
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{branchInteractions.map((inter: any, idx: number) => {
|
||||
const isPositive = inter.type.includes('합');
|
||||
@@ -403,7 +386,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
|
||||
{/* 오행 균형 */}
|
||||
<div className="mt-5 pt-5 border-t border-slate-100">
|
||||
<h3 className="text-sm font-bold text-[#04102b] mb-4 text-center">오행 균형</h3>
|
||||
<h3 className="text-sm font-bold text-[var(--jsm-ink)] mb-4 text-center">오행 균형</h3>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{Object.entries(elementScores).map(([element, score]) => (
|
||||
<div key={element} className="text-center">
|
||||
@@ -414,13 +397,13 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
<div className="w-full bg-slate-200 rounded-full h-1.5 mb-1">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all ${element === sajuData.day.element
|
||||
? 'bg-gradient-to-r from-[#1a56db] to-[#7c3aed]'
|
||||
? 'bg-[var(--jsm-accent)]'
|
||||
: 'bg-slate-400'
|
||||
}`}
|
||||
style={{ width: `${Math.max(score, 5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-bold text-[#04102b]">{score}%</div>
|
||||
<div className="text-xs font-bold text-[var(--jsm-ink)]">{score}%</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -431,8 +414,8 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
|
||||
{/* 신강/신약 + 용신 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h3 className="text-base font-extrabold text-[#04102b] mb-4">일간 세력 분석</h3>
|
||||
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] p-6">
|
||||
<h3 className="text-base font-extrabold text-[var(--jsm-ink)] mb-4">일간 세력 분석</h3>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className={`inline-block px-4 py-1.5 rounded-xl text-sm font-bold ${
|
||||
analysis.dayMasterStrength.result === '신강'
|
||||
@@ -455,7 +438,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
</ul>
|
||||
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<h4 className="font-bold text-[#04102b] mb-2.5 text-sm">용신 / 희신 / 기신</h4>
|
||||
<h4 className="font-bold text-[var(--jsm-ink)] mb-2.5 text-sm">용신 / 희신 / 기신</h4>
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.yongShin] || 'bg-gray-100'}`}>
|
||||
용신: {analysis.yongShin.yongShinKr}
|
||||
@@ -472,17 +455,17 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
</div>
|
||||
|
||||
{/* 신살 + 공망 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h3 className="text-base font-extrabold text-[#04102b] mb-4">신살 (神煞)</h3>
|
||||
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] p-6">
|
||||
<h3 className="text-base font-extrabold text-[var(--jsm-ink)] mb-4">신살 (神煞)</h3>
|
||||
{shinsal.length > 0 ? (
|
||||
<div className="space-y-2 mb-5">
|
||||
{shinsal.map((s: any, i: number) => (
|
||||
<div key={i} className="flex items-start gap-2 p-3 rounded-xl bg-[#f0f5ff]">
|
||||
<span className="inline-block px-2 py-0.5 bg-[#04102b] text-white rounded-lg text-xs font-bold whitespace-nowrap">
|
||||
<div key={i} className="flex items-start gap-2 p-3 rounded-xl bg-[var(--jsm-surface-alt)]">
|
||||
<span className="inline-block px-2 py-0.5 bg-[var(--jsm-navy)] text-white rounded-lg text-xs font-bold whitespace-nowrap">
|
||||
{s.name}
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-[#04102b]">
|
||||
<div className="text-xs font-semibold text-[var(--jsm-ink)]">
|
||||
{s.pillar} {s.branchKr}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-0.5">{s.description}</div>
|
||||
@@ -495,10 +478,10 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-100 pt-4">
|
||||
<h4 className="font-bold text-[#04102b] mb-2 text-sm">공망 (空亡)</h4>
|
||||
<h4 className="font-bold text-[var(--jsm-ink)] mb-2 text-sm">공망 (空亡)</h4>
|
||||
<div className="flex gap-2 mb-2">
|
||||
{gongmang.branchesKr.map((bk: string, i: number) => (
|
||||
<span key={i} className="px-2.5 py-1 bg-[#04102b] text-white rounded-lg text-xs font-bold">
|
||||
<span key={i} className="px-2.5 py-1 bg-[var(--jsm-navy)] text-white rounded-lg text-xs font-bold">
|
||||
{bk}
|
||||
</span>
|
||||
))}
|
||||
@@ -508,7 +491,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
|
||||
{/* 세운 정보 */}
|
||||
<div className="border-t border-slate-100 pt-4 mt-4">
|
||||
<h4 className="font-bold text-[#04102b] mb-2 text-sm">
|
||||
<h4 className="font-bold text-[var(--jsm-ink)] mb-2 text-sm">
|
||||
{analysis.seun.year}년 세운
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -554,7 +537,7 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 오늘의 운세 (사주 결제 시 표시) */}
|
||||
{/* 오늘의 운세 (로그인 시 표시) */}
|
||||
{hasPaid && (
|
||||
<SajuFortuneSection
|
||||
yongShin={analysis.yongShin.yongShin}
|
||||
@@ -569,26 +552,26 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
)}
|
||||
|
||||
{/* 대운 */}
|
||||
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
|
||||
<h2 className="text-lg font-extrabold text-[#04102b] mb-5 text-center">
|
||||
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] p-6">
|
||||
<h2 className="text-lg font-extrabold text-[var(--jsm-ink)] mb-5 text-center">
|
||||
대운 (大運) — 10년 주기 운세
|
||||
</h2>
|
||||
|
||||
{currentDaeun && (
|
||||
<div className="bg-gradient-to-r from-[#04102b] to-[#0a2060] rounded-2xl p-5 mb-5 text-white">
|
||||
<h3 className="text-sm font-bold mb-3 text-center text-blue-300">현재 대운</h3>
|
||||
<div className="bg-[var(--jsm-navy)] rounded-2xl p-5 mb-5 text-white">
|
||||
<h3 className="text-sm font-bold mb-3 text-center text-white/70">현재 대운</h3>
|
||||
<div className="text-center mb-3">
|
||||
<div className="text-3xl font-bold mb-1">
|
||||
{currentDaeun.stem}{currentDaeun.branch}
|
||||
</div>
|
||||
<div className="text-base text-blue-200">
|
||||
<div className="text-base text-white/80">
|
||||
{currentDaeun.stemKr}{currentDaeun.branchKr}
|
||||
</div>
|
||||
<div className="text-xs text-blue-300/70 mt-1">
|
||||
<div className="text-xs text-white/50 mt-1">
|
||||
{currentDaeun.age}세 ~ {currentDaeun.age + 9}세 ({currentDaeun.startYear} ~ {currentDaeun.endYear}년)
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center leading-relaxed text-xs text-blue-200/80">
|
||||
<p className="text-center leading-relaxed text-xs text-white/80">
|
||||
{getDaeunDescription(currentDaeun, sajuData.day.stem)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -601,15 +584,15 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
daeun.endYear === currentDaeun.endYear;
|
||||
return (
|
||||
<div key={index}
|
||||
className={`rounded-xl p-3 border-2 transition ${isCurrent ? 'bg-amber-50 border-amber-400' : 'bg-white border-[#dbe8ff]'}`}>
|
||||
className={`rounded-xl p-3 border-2 transition ${isCurrent ? 'bg-amber-50 border-amber-400' : 'bg-[var(--jsm-surface)] border-[var(--jsm-line)]'}`}>
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold text-[#04102b] mb-0.5">{daeun.stem}{daeun.branch}</div>
|
||||
<div className="text-xl font-bold text-[var(--jsm-ink)] mb-0.5">{daeun.stem}{daeun.branch}</div>
|
||||
<div className="text-xs text-slate-500 mb-1.5">{daeun.stemKr}{daeun.branchKr}</div>
|
||||
<div className="text-xs text-slate-400">{daeun.age}세 ~ {daeun.age + 9}세</div>
|
||||
<div className="text-xs text-slate-400">{daeun.startYear} ~ {daeun.endYear}</div>
|
||||
{isCurrent && (
|
||||
<div className="mt-1.5">
|
||||
<span className="inline-block bg-[#04102b] text-white text-xs px-2.5 py-0.5 rounded-full font-semibold">현재</span>
|
||||
<span className="inline-block bg-[var(--jsm-navy)] text-white text-xs px-2.5 py-0.5 rounded-full font-semibold">현재</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,962 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import ContactModal from '@/app/components/ContactModal';
|
||||
import { trackCTAClick } from '@/lib/gtag';
|
||||
|
||||
const samples = [
|
||||
{
|
||||
type: 'corporate',
|
||||
title: '기업 홈페이지',
|
||||
subtitle: '테크솔루션㈜',
|
||||
desc: '"홈페이지가 없어서 B2B 영업 때마다 명함만 건넸다"는 고민을 해결한 기업 브랜드 사이트',
|
||||
gradient: 'linear-gradient(135deg, #0a192f 0%, #112240 50%, #1a3a6c 100%)',
|
||||
accent: '#4fc3f7',
|
||||
tags: ['기업', 'B2B', '신뢰'],
|
||||
icon: '🏢',
|
||||
},
|
||||
{
|
||||
type: 'bakery',
|
||||
title: '베이커리 홈페이지',
|
||||
subtitle: '르 쁘띠 포르',
|
||||
desc: '"인스타 팔로워는 많은데 실제 방문 예약이 없다"는 F&B 매장의 전환율 문제를 해결한 사이트',
|
||||
gradient: 'linear-gradient(135deg, #78350f 0%, #92400e 50%, #d97706 100%)',
|
||||
accent: '#fbbf24',
|
||||
tags: ['F&B', '로컬', '예약'],
|
||||
icon: '🥐',
|
||||
},
|
||||
{
|
||||
type: 'portfolio',
|
||||
title: '개인 포트폴리오',
|
||||
subtitle: 'Kim Jisu',
|
||||
desc: '"링크드인에 PDF 올리면 아무도 안 보더라"는 문제를 해결한 채용·수주 전환형 포트폴리오',
|
||||
gradient: 'linear-gradient(135deg, #000000 0%, #0d0d0d 50%, #001a00 100%)',
|
||||
accent: '#00ff88',
|
||||
tags: ['크리에이터', '디자이너', '수주'],
|
||||
icon: '✦',
|
||||
},
|
||||
{
|
||||
type: 'dashboard',
|
||||
title: '관리자 대시보드',
|
||||
subtitle: 'DataFlow SaaS',
|
||||
desc: '"엑셀로 수기 집계하다가 오류가 나서 야근"을 없애는 실시간 데이터 대시보드',
|
||||
gradient: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f2a3a 100%)',
|
||||
accent: '#38bdf8',
|
||||
tags: ['SaaS', '자동화', '관리'],
|
||||
icon: '📊',
|
||||
},
|
||||
{
|
||||
type: 'game',
|
||||
title: '게임 매칭 시스템',
|
||||
subtitle: 'NEXUS ARENA',
|
||||
desc: '"디스코드에서 수동으로 팀 짜다가 싸움 났다"는 커뮤니티의 매칭·랭킹 자동화 플랫폼',
|
||||
gradient: 'linear-gradient(135deg, #000000 0%, #0a0a1a 50%, #0d0029 100%)',
|
||||
accent: '#a855f7',
|
||||
tags: ['게임', '커뮤니티', '자동화'],
|
||||
icon: '⚡',
|
||||
},
|
||||
{
|
||||
type: 'interior',
|
||||
title: '인테리어 업체 소개',
|
||||
subtitle: 'AURUM Interior',
|
||||
desc: '"포트폴리오 사진을 카톡으로만 보내다가 고급 고객을 놓쳤다"는 문제를 해결한 브랜드 사이트',
|
||||
gradient: 'linear-gradient(135deg, #2C1810 0%, #4A3728 50%, #6B4E37 100%)',
|
||||
accent: '#D4A853',
|
||||
tags: ['인테리어', '포트폴리오', '고급'],
|
||||
icon: '◈',
|
||||
},
|
||||
{
|
||||
type: 'reading',
|
||||
title: '독서 기록 노트',
|
||||
subtitle: '나의 독서 기록',
|
||||
desc: '읽은 책과 감상을 아름답게 기록하는 나만의 독서 저널 — 이런 것도 만들 수 있습니다',
|
||||
gradient: 'linear-gradient(135deg, #0C0B09 0%, #1A1710 50%, #2A2218 100%)',
|
||||
accent: '#D4A853',
|
||||
tags: ['라이프', '독서', '기록'],
|
||||
icon: '◻',
|
||||
},
|
||||
{
|
||||
type: 'shopping',
|
||||
title: '개인 쇼핑몰',
|
||||
subtitle: 'MELLOW STUDIO',
|
||||
desc: '"카페24 기본 테마가 너무 흔해서 브랜드가 안 살아난다"는 고민을 해결한 커스텀 쇼핑몰',
|
||||
gradient: 'linear-gradient(135deg, #2A2018 0%, #4A3C2C 50%, #7A6A52 100%)',
|
||||
accent: '#C4A882',
|
||||
tags: ['쇼핑몰', '패션', '라이프'],
|
||||
icon: '◇',
|
||||
},
|
||||
];
|
||||
|
||||
const processSteps = [
|
||||
{ step: '01', title: '무료 상담', desc: '요구사항 파악 및 방향성 논의', icon: '💬' },
|
||||
{ step: '02', title: '기획', desc: '사이트맵 & 와이어프레임', icon: '📋' },
|
||||
{ step: '03', title: '디자인', desc: 'UI/UX 시안 제작', icon: '🎨' },
|
||||
{ step: '04', title: '개발', desc: '반응형 퍼블리싱 & 기능 구현', icon: '⚙️' },
|
||||
{ step: '05', title: '납품', desc: '검수 완료 후 도메인 배포', icon: '🚀' },
|
||||
];
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: '스타터',
|
||||
price: '20',
|
||||
unit: '만원~',
|
||||
color: '#38bdf8',
|
||||
features: ['5페이지 이내', '반응형 디자인', '기본 SEO 설정', '1개월 유지보수', '3~5영업일 납품'],
|
||||
note: '개인 블로그, 소규모 소개 사이트',
|
||||
productId: 'website_starter',
|
||||
},
|
||||
{
|
||||
name: '비즈니스',
|
||||
price: '100',
|
||||
unit: '만원~',
|
||||
color: '#818cf8',
|
||||
featured: true,
|
||||
features: ['10페이지 이내', '반응형 디자인', '관리자 페이지', 'SEO 최적화', '3개월 유지보수', '1~2주 납품'],
|
||||
note: '기업 사이트, 브랜드 페이지',
|
||||
productId: 'website_business',
|
||||
},
|
||||
{
|
||||
name: '프리미엄',
|
||||
price: '200',
|
||||
unit: '만원~',
|
||||
color: '#f472b6',
|
||||
features: ['페이지 수 무제한', '맞춤 디자인', '결제/회원 시스템', 'DB 연동', '6개월 유지보수', '일정 협의'],
|
||||
note: '쇼핑몰, SaaS, 복합 시스템',
|
||||
productId: 'website_premium',
|
||||
},
|
||||
];
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
q: '제작 기간은 얼마나 걸리나요?',
|
||||
a: '규모에 따라 다르지만, 스타터는 3~5영업일, 비즈니스는 1~2주, 프리미엄은 협의 후 결정합니다. 빠른 납품이 필요한 경우 별도 상담해 주세요.',
|
||||
},
|
||||
{
|
||||
q: '수정은 몇 번까지 가능한가요?',
|
||||
a: '기획 확정 후 디자인 시안 수정은 2회, 개발 완료 후 기능 수정은 유지보수 기간 내 자유롭게 가능합니다. 추가 기능 개발은 별도 견적으로 진행합니다.',
|
||||
},
|
||||
{
|
||||
q: '도메인과 호스팅도 도와주시나요?',
|
||||
a: '네, 도메인 구매부터 서버 세팅, 배포까지 전 과정을 도와드립니다. Vercel, AWS, 카페24 등 원하시는 플랫폼에 맞춰 배포해 드립니다.',
|
||||
},
|
||||
{
|
||||
q: '앱(모바일 앱)이나 쇼핑몰도 개발 가능한가요?',
|
||||
a: '네. iOS/Android 앱(React Native), 모바일 웹앱, 결제 연동 쇼핑몰, 회원/관리자 시스템 등 모두 개발 가능합니다. 프리미엄 플랜 이상이거나 규모에 따라 별도 견적으로 진행됩니다. 먼저 어떤 기능이 필요한지 상담해 주세요.',
|
||||
},
|
||||
{
|
||||
q: '계약금은 어떻게 되나요? 중간에 취소하면 어떻게 되나요?',
|
||||
a: '계약서 체결 후 착수금 30%를 먼저 입금받고 개발을 시작합니다. 납품 전 취소 시 완성 비율에 따라 정산하며, 착수 전 전액 환불됩니다. 모든 조건은 계약서에 명시됩니다.',
|
||||
},
|
||||
];
|
||||
|
||||
function useReveal() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const scroller = (document.querySelector('.main-content') as HTMLElement | null) ?? document.documentElement;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => { if (entry.isIntersecting) { el.classList.add('ws-visible'); obs.disconnect(); } },
|
||||
{ threshold: 0.1, root: scroller === document.documentElement ? null : scroller }
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, []);
|
||||
return ref;
|
||||
}
|
||||
|
||||
function SampleMiniPreview({ type }: { type: string }) {
|
||||
const W = 700, H = 350;
|
||||
const inner = (content: React.ReactNode, bg: string) => (
|
||||
<div style={{ height: 175, overflow: 'hidden', position: 'relative', background: bg, borderRadius: '20px 20px 0 0' }}>
|
||||
<div style={{ width: W, height: H, transform: 'scale(0.5)', transformOrigin: 'top left', position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
switch (type) {
|
||||
case 'corporate':
|
||||
return inner(
|
||||
<div style={{ background: '#fff', width: '100%', height: '100%', fontFamily: 'system-ui' }}>
|
||||
<div style={{ height: 50, background: '#0a192f', display: 'flex', alignItems: 'center', padding: '0 28px', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 900, color: '#4fc3f7', letterSpacing: '0.1em' }}>TECHSOLUTION</div>
|
||||
<div style={{ display: 'flex', gap: 22 }}>
|
||||
{['서비스','솔루션','고객사','연락처'].map(t => <span key={t} style={{ fontSize: 11, color: '#475569' }}>{t}</span>)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, background: '#1d4ed8', color: '#fff', padding: '7px 18px', borderRadius: 4, fontWeight: 700 }}>상담 신청</div>
|
||||
</div>
|
||||
<div style={{ padding: '36px 32px', backgroundImage: 'linear-gradient(rgba(29,78,216,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(29,78,216,0.04) 1px, transparent 1px)', backgroundSize: '24px 24px' }}>
|
||||
<div style={{ fontSize: 10, color: '#1d4ed8', letterSpacing: '0.18em', marginBottom: 12, fontWeight: 700 }}>ENTERPRISE IT SOLUTIONS</div>
|
||||
<div style={{ fontSize: 36, fontWeight: 900, color: '#0a192f', lineHeight: 1.1, marginBottom: 14 }}>기업 IT 인프라,<br/>처음부터 끝까지</div>
|
||||
<div style={{ fontSize: 12, color: '#64748b', marginBottom: 22, lineHeight: 1.6 }}>15년 경험의 엔터프라이즈 IT 전문 기업.<br/>클라우드·보안·DX 통합 솔루션을 제공합니다.</div>
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 28 }}>
|
||||
<div style={{ background: '#1d4ed8', color: '#fff', fontSize: 12, padding: '10px 22px', borderRadius: 6, fontWeight: 700 }}>무료 상담 신청</div>
|
||||
<div style={{ border: '1px solid #cbd5e1', color: '#475569', fontSize: 12, padding: '10px 22px', borderRadius: 6 }}>서비스 소개서</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 36 }}>
|
||||
{[['15+','년 업력'],['340+','완료 프로젝트'],['180+','기업 고객'],['99.9%','가동률']].map(([n,l]) => (
|
||||
<div key={l}><div style={{ fontSize: 22, fontWeight: 800, color: '#0a192f', letterSpacing: '-0.02em' }}>{n}</div><div style={{ fontSize: 9, color: '#94a3b8', marginTop: 3 }}>{l}</div></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#fff'
|
||||
);
|
||||
|
||||
case 'bakery':
|
||||
return inner(
|
||||
<div style={{ background: '#fffbf5', width: '100%', height: '100%', fontFamily: 'Georgia, serif' }}>
|
||||
<div style={{ height: 52, background: 'rgba(255,251,245,0.96)', borderBottom: '1px solid #fde8c8', display: 'flex', alignItems: 'center', padding: '0 28px', justifyContent: 'space-between' }}>
|
||||
<div><div style={{ fontSize: 18, fontStyle: 'italic', color: '#78350f', fontWeight: 700 }}>Le Petit Fort</div><div style={{ fontSize: 8, color: '#b45309', letterSpacing: '0.2em' }}>ARTISAN BOULANGERIE</div></div>
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
{['메뉴','스토리','예약'].map(t => <span key={t} style={{ fontSize: 11, color: '#92400e', fontFamily: 'system-ui' }}>{t}</span>)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, background: '#b45309', color: '#fff', padding: '7px 18px', borderRadius: 100, fontFamily: 'system-ui', fontWeight: 700 }}>방문 예약</div>
|
||||
</div>
|
||||
<div style={{ padding: '28px 32px 0', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 28, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#b45309', letterSpacing: '0.2em', marginBottom: 12, fontFamily: 'system-ui', fontWeight: 600 }}>Since 2018 · Paris Recipe</div>
|
||||
<div style={{ fontSize: 38, fontWeight: 700, color: '#1c1008', lineHeight: 1.05, marginBottom: 14 }}>매일 아침<br/><em>구워내는</em><br/>정직한 빵</div>
|
||||
<div style={{ fontSize: 11, color: '#92400e', marginBottom: 18, lineHeight: 1.7, fontFamily: 'system-ui' }}>프랑스산 에슈레 버터와 천연 발효종만으로<br/>만드는 정직한 아르티장 베이커리.</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ background: '#b45309', color: '#fff', fontSize: 11, padding: '9px 20px', borderRadius: 100, fontFamily: 'system-ui', fontWeight: 700 }}>오늘의 빵 보기</div>
|
||||
<div style={{ border: '1px solid #d97706', color: '#92400e', fontSize: 11, padding: '9px 20px', borderRadius: 100, fontFamily: 'system-ui' }}>매장 안내</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
{[{n:'버터 크루아상',p:'3,200',c:'#d97706'},{n:'소금빵',p:'2,800',c:'#b45309'},{n:'딸기 케이크',p:'7,500',c:'#be185d'},{n:'캄파뉴',p:'8,900',c:'#065f46'}].map(item => (
|
||||
<div key={item.n} style={{ background: '#fff8f0', borderRadius: 10, padding: 10, border: '1px solid #fde8c8' }}>
|
||||
<div style={{ height: 38, background: 'linear-gradient(135deg, #fde68a, #fbbf24)', borderRadius: 6, marginBottom: 6 }} />
|
||||
<div style={{ fontSize: 9, color: '#1c1008', fontFamily: 'system-ui', fontWeight: 600 }}>{item.n}</div>
|
||||
<div style={{ fontSize: 10, color: item.c, fontFamily: 'system-ui', fontWeight: 700 }}>₩{item.p}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#fffbf5'
|
||||
);
|
||||
|
||||
case 'portfolio':
|
||||
return inner(
|
||||
<div style={{ background: '#000', width: '100%', height: '100%' }}>
|
||||
<div style={{ position: 'absolute', top: -40, left: '25%', width: 320, height: 320, background: 'radial-gradient(circle, rgba(0,255,136,0.06) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
<div style={{ height: 50, background: 'rgba(0,0,0,0.95)', borderBottom: '1px solid rgba(0,255,136,0.1)', display: 'flex', alignItems: 'center', padding: '0 32px', justifyContent: 'space-between', position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 16, fontWeight: 700, color: '#00ff88' }}>KJ<span style={{ color: 'rgba(0,255,136,0.3)' }}>_</span></div>
|
||||
<div style={{ display: 'flex', gap: 24 }}>
|
||||
{['About','Work','Skills','Contact'].map(t => <span key={t} style={{ fontFamily: 'system-ui', fontSize: 10, color: '#374151', letterSpacing: '0.08em', textTransform: 'uppercase' }}>{t}</span>)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#00ff88' }} />
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 9, color: '#00ff88' }}>Available</span>
|
||||
<div style={{ marginLeft: 8, border: '1px solid #00ff88', color: '#00ff88', fontSize: 9, padding: '5px 12px', borderRadius: 3, fontFamily: 'monospace', fontWeight: 700 }}>HIRE ME</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ position: 'absolute', inset: 0, top: 50, backgroundImage: 'linear-gradient(rgba(0,255,136,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(0,255,136,0.04) 1px, transparent 1px)', backgroundSize: '32px 32px', pointerEvents: 'none' }} />
|
||||
<div style={{ padding: '38px 32px', position: 'relative', zIndex: 2, display: 'grid', gridTemplateColumns: '1fr auto', gap: 32, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 10, color: '#00ff88', letterSpacing: '0.15em', marginBottom: 16, border: '1px solid rgba(0,255,136,0.2)', display: 'inline-block', padding: '3px 10px' }}>FULL-STACK DEVELOPER</div>
|
||||
<div style={{ fontSize: 56, fontWeight: 900, color: '#fff', lineHeight: 0.9, letterSpacing: '-0.03em', marginBottom: 18, fontFamily: 'system-ui' }}>Kim<br/><span style={{ color: '#00ff88' }}>Jisu</span></div>
|
||||
<div style={{ fontSize: 11, color: '#4b5563', lineHeight: 1.7, marginBottom: 22, fontFamily: 'system-ui' }}>React · Next.js · TypeScript · Node.js<br/>디자인과 코드의 경계를 탐험합니다.</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ background: '#00ff88', color: '#000', fontSize: 11, padding: '9px 22px', borderRadius: 4, fontWeight: 800, fontFamily: 'monospace' }}>VIEW WORK</div>
|
||||
<div style={{ border: '1px solid rgba(0,255,136,0.3)', color: '#00ff88', fontSize: 11, padding: '9px 22px', borderRadius: 4, fontFamily: 'monospace' }}>CONTACT</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: 130, height: 160, background: 'linear-gradient(135deg, #001a0d, #003322)', border: '1px solid rgba(0,255,136,0.2)', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<div style={{ width: 80, height: 80, borderRadius: '50%', border: '2px solid rgba(0,255,136,0.3)', background: 'rgba(0,255,136,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<span style={{ fontSize: 26, color: '#00ff88', fontFamily: 'monospace', fontWeight: 700 }}>KJ</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#000'
|
||||
);
|
||||
|
||||
case 'dashboard':
|
||||
return inner(
|
||||
<div style={{ background: '#0f172a', width: '100%', height: '100%', display: 'flex', fontFamily: 'system-ui' }}>
|
||||
<div style={{ width: 140, background: '#020617', borderRight: '1px solid rgba(255,255,255,0.05)', padding: '20px 14px' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 800, color: '#38bdf8', marginBottom: 24, letterSpacing: '-0.02em' }}>DataFlow</div>
|
||||
{['대시보드','분석','리포트','사용자','설정'].map((item, i) => (
|
||||
<div key={item} style={{ fontSize: 10, color: i === 0 ? '#38bdf8' : '#475569', padding: '8px 10px', borderRadius: 6, marginBottom: 4, background: i === 0 ? 'rgba(56,189,248,0.1)' : 'transparent' }}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '20px 22px' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: '#e2e8f0' }}>실시간 현황</div>
|
||||
<div style={{ fontSize: 10, color: '#475569', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)', padding: '4px 12px', borderRadius: 6 }}>이번 달</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3,1fr)', gap: 10, marginBottom: 14 }}>
|
||||
{[{l:'총 매출',v:'₩48.2M',c:'#38bdf8',u:true},{l:'신규 사용자',v:'1,247',c:'#34d399',u:true},{l:'전환율',v:'12.4%',c:'#a78bfa',u:false}].map(s => (
|
||||
<div key={s.l} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.06)', borderRadius: 8, padding: '12px 14px' }}>
|
||||
<div style={{ fontSize: 8, color: '#475569', marginBottom: 6 }}>{s.l}</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 800, color: s.c, letterSpacing: '-0.02em' }}>{s.v}</div>
|
||||
<div style={{ fontSize: 8, color: s.u ? '#34d399' : '#f87171', marginTop: 4 }}>{s.u ? '↑ +8.3%' : '↓ -1.2%'}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: 8, padding: 14, height: 110 }}>
|
||||
<div style={{ fontSize: 9, color: '#475569', marginBottom: 10 }}>월간 매출 추이</div>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6, height: 72 }}>
|
||||
{[40,55,35,65,80,60,90,75,85,95,70,100].map((h, i) => (
|
||||
<div key={i} style={{ flex: 1, height: `${h}%`, background: i === 11 ? '#38bdf8' : 'rgba(56,189,248,0.22)', borderRadius: '2px 2px 0 0' }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#0f172a'
|
||||
);
|
||||
|
||||
case 'game':
|
||||
return inner(
|
||||
<div style={{ background: '#000', width: '100%', height: '100%' }}>
|
||||
<div style={{ position: 'absolute', top: -60, left: '30%', width: 340, height: 340, background: 'radial-gradient(circle, rgba(168,85,247,0.14) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: -20, right: '10%', width: 200, height: 200, background: 'radial-gradient(circle, rgba(6,182,212,0.1) 0%, transparent 70%)', pointerEvents: 'none' }} />
|
||||
<div style={{ height: 50, background: 'rgba(0,0,0,0.9)', borderBottom: '1px solid rgba(6,182,212,0.2)', display: 'flex', alignItems: 'center', padding: '0 28px', justifyContent: 'space-between', position: 'relative', zIndex: 2 }}>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 15, fontWeight: 900, color: '#06b6d4', letterSpacing: '0.15em' }}>NEXUS<span style={{ color: '#a855f7' }}>ARENA</span></div>
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
{['랭킹','매칭','챔피언','스토어'].map(t => <span key={t} style={{ fontFamily: 'system-ui', fontSize: 10, color: '#374151', letterSpacing: '0.08em' }}>{t}</span>)}
|
||||
</div>
|
||||
<div style={{ background: 'linear-gradient(90deg, #06b6d4, #a855f7)', color: '#000', fontSize: 10, padding: '7px 18px', borderRadius: 3, fontWeight: 800, fontFamily: 'monospace' }}>PLAY NOW</div>
|
||||
</div>
|
||||
<div style={{ padding: '32px 32px', position: 'relative', zIndex: 2, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 9, color: '#06b6d4', letterSpacing: '0.2em', marginBottom: 14 }}>SEASON 7 · COMPETITIVE</div>
|
||||
<div style={{ fontSize: 50, fontWeight: 900, color: '#fff', lineHeight: 0.88, letterSpacing: '-0.03em', marginBottom: 18, fontFamily: 'system-ui' }}>NEXUS<br/><span style={{ background: 'linear-gradient(90deg, #06b6d4, #a855f7)', WebkitBackgroundClip: 'text', WebkitTextFillColor: 'transparent' }}>ARENA</span></div>
|
||||
<div style={{ fontSize: 10, color: '#4b5563', lineHeight: 1.65, marginBottom: 22, fontFamily: 'system-ui' }}>실시간 매칭 · 랭크 시스템 · 글로벌 토너먼트<br/>지금 바로 전장에 참전하세요.</div>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<div style={{ background: 'linear-gradient(90deg, #06b6d4, #a855f7)', color: '#fff', fontSize: 11, padding: '10px 22px', borderRadius: 4, fontWeight: 800, fontFamily: 'monospace' }}>PLAY NOW</div>
|
||||
<div style={{ border: '1px solid rgba(6,182,212,0.4)', color: '#06b6d4', fontSize: 11, padding: '10px 22px', borderRadius: 4, fontFamily: 'monospace' }}>랭킹 보기</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
{[{name:'VIPER',role:'Assassin',c:'#06b6d4'},{name:'NOVA',role:'Mage',c:'#a855f7'},{name:'IRON',role:'Tank',c:'#94a3b8'},{name:'KIRA',role:'Support',c:'#ec4899'}].map(ch => (
|
||||
<div key={ch.name} style={{ background: 'rgba(255,255,255,0.03)', border: `1px solid ${ch.c}30`, borderRadius: 8, padding: 10 }}>
|
||||
<div style={{ height: 34, background: `linear-gradient(135deg, ${ch.c}20, ${ch.c}05)`, borderRadius: 4, marginBottom: 6, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 24, height: 24, borderRadius: '50%', background: `${ch.c}30`, border: `1px solid ${ch.c}60` }} />
|
||||
</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: 9, color: ch.c, fontWeight: 700 }}>{ch.name}</div>
|
||||
<div style={{ fontSize: 8, color: '#374151', fontFamily: 'system-ui' }}>{ch.role}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#000'
|
||||
);
|
||||
|
||||
case 'interior':
|
||||
return inner(
|
||||
<div style={{ background: '#faf8f4', width: '100%', height: '100%' }}>
|
||||
<div style={{ height: 50, background: '#2C1810', display: 'flex', alignItems: 'center', padding: '0 28px', justifyContent: 'space-between' }}>
|
||||
<div><div style={{ fontFamily: 'Georgia, serif', fontSize: 14, color: '#D4A853', fontWeight: 700, letterSpacing: '0.12em' }}>AURUM</div><div style={{ fontSize: 7, color: '#6B4E37', letterSpacing: '0.25em' }}>INTERIOR DESIGN</div></div>
|
||||
<div style={{ display: 'flex', gap: 18 }}>
|
||||
{['포트폴리오','서비스','견적 문의'].map(t => <span key={t} style={{ fontSize: 9, color: '#9a8070', fontFamily: 'system-ui' }}>{t}</span>)}
|
||||
</div>
|
||||
<div style={{ border: '1px solid #D4A853', color: '#D4A853', fontSize: 9, padding: '6px 14px', fontFamily: 'system-ui', letterSpacing: '0.08em' }}>CONTACT</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: 300 }}>
|
||||
<div style={{ padding: '32px 28px', display: 'flex', flexDirection: 'column', justifyContent: 'center', background: '#2C1810' }}>
|
||||
<div style={{ fontSize: 9, color: '#D4A853', letterSpacing: '0.25em', marginBottom: 14, fontFamily: 'system-ui', textTransform: 'uppercase' }}>Premium Interior Design</div>
|
||||
<div style={{ fontFamily: 'Georgia, serif', fontSize: 34, color: '#faf8f4', lineHeight: 1.1, marginBottom: 18 }}>공간이<br/><em>이야기가</em><br/>되는 순간</div>
|
||||
<div style={{ fontSize: 10, color: '#9a8070', lineHeight: 1.7, fontFamily: 'system-ui', marginBottom: 22 }}>20년 경험의 인테리어 전문가가<br/>당신만의 공간을 완성합니다.</div>
|
||||
<div style={{ display: 'inline-flex' }}><div style={{ background: '#D4A853', color: '#2C1810', fontSize: 10, padding: '10px 22px', fontFamily: 'system-ui', fontWeight: 700 }}>포트폴리오 보기</div></div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr', gap: 3 }}>
|
||||
{['linear-gradient(135deg, #c9b99a, #a8927a)','linear-gradient(135deg, #8B7355, #6B5A47)','linear-gradient(135deg, #D4C5A9, #B8A88A)','linear-gradient(135deg, #7C6555, #5C4A3A)'].map((bg, i) => (
|
||||
<div key={i} style={{ background: bg }}><div style={{ width: '100%', height: '100%', background: 'rgba(44,24,16,0.08)' }} /></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#faf8f4'
|
||||
);
|
||||
|
||||
case 'reading':
|
||||
return inner(
|
||||
<div style={{ background: '#0C0B09', width: '100%', height: '100%' }}>
|
||||
<div style={{ height: 46, background: '#0C0B09', borderBottom: '1px solid rgba(212,168,83,0.1)', display: 'flex', alignItems: 'center', padding: '0 28px', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontFamily: 'Georgia, serif', fontSize: 14, fontStyle: 'italic', color: '#D4A853', fontWeight: 600 }}>나의 독서 기록</div>
|
||||
<div style={{ display: 'flex', gap: 18 }}>
|
||||
{['서재','월별 기록','통계'].map(t => <span key={t} style={{ fontSize: 9, color: '#5c5040', fontFamily: 'system-ui' }}>{t}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '32px 32px', display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 28, alignItems: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 10, color: '#D4A853', letterSpacing: '0.2em', marginBottom: 14, fontFamily: 'system-ui', textTransform: 'uppercase' }}>My Reading Journal</div>
|
||||
<div style={{ fontFamily: 'Georgia, serif', fontSize: 40, color: '#faf8f4', lineHeight: 1.05, marginBottom: 16 }}>읽은 책들이<br/><em style={{ color: '#D4A853' }}>별처럼</em><br/>빛나는 공간</div>
|
||||
<div style={{ fontSize: 10, color: '#5c5040', lineHeight: 1.7, fontFamily: 'system-ui', marginBottom: 22 }}>독서 기록을 아름답게.<br/>감상과 인용구를 나만의 서재에 담아보세요.</div>
|
||||
<div style={{ display: 'inline-flex', background: '#D4A853', color: '#0C0B09', fontSize: 10, padding: '9px 22px', fontFamily: 'system-ui', fontWeight: 700 }}>기록 시작하기</div>
|
||||
<div style={{ display: 'flex', gap: 24, marginTop: 22 }}>
|
||||
{[['47','완독'],['1,240','페이지'],['12','이번 달']].map(([n,l]) => (
|
||||
<div key={l}><div style={{ fontSize: 20, fontWeight: 800, color: '#D4A853', fontFamily: 'Georgia, serif' }}>{n}</div><div style={{ fontSize: 8, color: '#5c5040', fontFamily: 'system-ui', marginTop: 2 }}>{l}</div></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end', justifyContent: 'center' }}>
|
||||
{[{h:130,bg:'linear-gradient(180deg,#1e3a5f,#0a1628)',sp:'#2563eb'},{h:152,bg:'linear-gradient(180deg,#2C1810,#1a0e0a)',sp:'#D4A853'},{h:118,bg:'linear-gradient(180deg,#1a1a1a,#0d0d0d)',sp:'#6b7280'},{h:142,bg:'linear-gradient(180deg,#1e1b4b,#0f0d2e)',sp:'#7c3aed'},{h:120,bg:'linear-gradient(180deg,#064e3b,#022c22)',sp:'#10b981'}].map((b, i) => (
|
||||
<div key={i} style={{ width: 38, height: b.h, background: b.bg, borderRadius: '3px 3px 0 0', borderLeft: `3px solid ${b.sp}40`, boxShadow: '2px 0 8px rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 1, height: '80%', background: `${b.sp}30` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#0C0B09'
|
||||
);
|
||||
|
||||
case 'shopping':
|
||||
return inner(
|
||||
<div style={{ background: '#F4F2EF', width: '100%', height: '100%' }}>
|
||||
<div style={{ height: 52, background: '#F4F2EF', borderBottom: '1px solid #E0DDD8', display: 'flex', alignItems: 'center', padding: '0 28px', justifyContent: 'space-between' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 900, color: '#0C0B09', letterSpacing: '0.2em', textTransform: 'uppercase', fontFamily: 'system-ui' }}>MELLOW<span style={{ fontWeight: 300 }}> STUDIO</span></div>
|
||||
<div style={{ display: 'flex', gap: 20 }}>
|
||||
{['NEW','OUTER','TOP','BOTTOM'].map(t => <span key={t} style={{ fontSize: 9, color: '#7C7870', fontFamily: 'system-ui', letterSpacing: '0.1em' }}>{t}</span>)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14, fontSize: 12, color: '#0C0B09', fontFamily: 'system-ui' }}><span>🔍</span><span>🛍 2</span></div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', height: 298 }}>
|
||||
<div style={{ background: 'linear-gradient(135deg, #2A2018, #4A3C2C)', display: 'flex', alignItems: 'center', justifyContent: 'center', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ width: 120, height: 200, background: 'linear-gradient(180deg, #c8b89a, #9a8a72)', borderRadius: 4, boxShadow: '16px 16px 40px rgba(0,0,0,0.35)' }} />
|
||||
<div style={{ position: 'absolute', bottom: 16, left: 16 }}>
|
||||
<div style={{ fontSize: 9, color: 'rgba(244,242,239,0.5)', letterSpacing: '0.2em', fontFamily: 'system-ui' }}>NEW ARRIVAL</div>
|
||||
<div style={{ fontSize: 17, fontWeight: 900, color: '#F4F2EF', fontFamily: 'system-ui', letterSpacing: '-0.01em' }}>SS 2025</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 9, color: '#7C7870', letterSpacing: '0.2em', marginBottom: 10, fontFamily: 'system-ui' }}>COLLECTION</div>
|
||||
<div style={{ fontSize: 30, fontWeight: 900, color: '#0C0B09', lineHeight: 1.05, fontFamily: 'system-ui', letterSpacing: '-0.02em', marginBottom: 12 }}>Quiet<br/>Luxury</div>
|
||||
<div style={{ fontSize: 10, color: '#7C7870', lineHeight: 1.65, fontFamily: 'system-ui', marginBottom: 18 }}>소음 없이 존재하는 옷.<br/>절제된 아름다움을 입으세요.</div>
|
||||
<div style={{ display: 'inline-flex', background: '#0C0B09', color: '#F4F2EF', fontSize: 9, padding: '9px 20px', letterSpacing: '0.15em', fontFamily: 'system-ui', fontWeight: 700 }}>SHOP NOW</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8 }}>
|
||||
{[['#c8b89a','₩328K'],['#8a7860','₩498K'],['#d4c5a9','₩218K']].map(([bg, p], i) => (
|
||||
<div key={i} style={{ borderRadius: 3, overflow: 'hidden' }}>
|
||||
<div style={{ height: 52, background: `linear-gradient(160deg, ${bg}, rgba(0,0,0,0.08))` }} />
|
||||
<div style={{ fontSize: 8, color: '#7C7870', fontFamily: 'system-ui', paddingTop: 4 }}>{p}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>, '#F4F2EF'
|
||||
);
|
||||
|
||||
default:
|
||||
return <div style={{ height: 175, background: '#0a0f1e', borderRadius: '20px 20px 0 0' }} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default function WebsiteServicePage() {
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(null);
|
||||
const [showTop, setShowTop] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalService, setModalService] = useState('홈페이지 제작');
|
||||
|
||||
const openModal = (service: string) => {
|
||||
trackCTAClick(service, '/work/website');
|
||||
setModalService(service);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = (document.querySelector('.main-content') as HTMLElement | null) ?? document.documentElement;
|
||||
const onScroll = () => setShowTop(scroller.scrollTop > 400);
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => scroller.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const samplesRef = useReveal();
|
||||
const processRef = useReveal();
|
||||
const pricingRef = useReveal();
|
||||
const faqRef = useReveal();
|
||||
const ctaRef = useReveal();
|
||||
|
||||
return (
|
||||
<div style={{ background: '#030712', minHeight: '100vh', color: 'white', fontFamily: "'Pretendard', 'Apple SD Gothic Neo', system-ui, sans-serif" }}>
|
||||
<ContactModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
service={modalService}
|
||||
checklist={[
|
||||
'원하시는 홈페이지 종류 (소개/쇼핑몰/SaaS 등)',
|
||||
'참고하고 싶은 사이트 URL (있으면)',
|
||||
'필요한 주요 페이지 및 기능',
|
||||
'희망 납품 일정 및 예산 범위',
|
||||
'디자인 선호 스타일 (모던/감성/심플 등)',
|
||||
]}
|
||||
accentColor="text-indigo-400"
|
||||
headerFrom="#0a0a1a"
|
||||
headerTo="#1e1b4b"
|
||||
/>
|
||||
<style dangerouslySetInnerHTML={{ __html: `
|
||||
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.min.css');
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
word-break { word-break: keep-all; }
|
||||
|
||||
/* scroll reveal */
|
||||
.ws-reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(32px);
|
||||
filter: blur(3px);
|
||||
transition: opacity 0.7s cubic-bezier(0.16,1,0.3,1),
|
||||
transform 0.7s cubic-bezier(0.16,1,0.3,1),
|
||||
filter 0.7s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
.ws-reveal.ws-visible { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
.ws-reveal > *:nth-child(1) { transition-delay: 0ms; }
|
||||
.ws-reveal > *:nth-child(2) { transition-delay: 80ms; }
|
||||
.ws-reveal > *:nth-child(3) { transition-delay: 160ms; }
|
||||
.ws-reveal > *:nth-child(4) { transition-delay: 240ms; }
|
||||
.ws-reveal > *:nth-child(5) { transition-delay: 320ms; }
|
||||
|
||||
@keyframes ws-fadeUp {
|
||||
from { opacity: 0; transform: translateY(28px); filter: blur(4px); }
|
||||
to { opacity: 1; transform: translateY(0); filter: blur(0); }
|
||||
}
|
||||
@keyframes ws-gridScroll {
|
||||
from { background-position: 0 0; }
|
||||
to { background-position: 48px 48px; }
|
||||
}
|
||||
@keyframes ws-glow {
|
||||
0%,100% { opacity: 0.5; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.ws-sample-card {
|
||||
border-radius: 20px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
background: #0a0f1e; cursor: pointer;
|
||||
transition: transform 0.45s cubic-bezier(0.16,1,0.3,1),
|
||||
box-shadow 0.45s cubic-bezier(0.16,1,0.3,1),
|
||||
border-color 0.3s;
|
||||
}
|
||||
.ws-sample-card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
|
||||
border-color: rgba(255,255,255,0.14);
|
||||
}
|
||||
|
||||
.ws-plan-card {
|
||||
transition: transform 0.4s cubic-bezier(0.16,1,0.3,1), box-shadow 0.4s;
|
||||
}
|
||||
.ws-plan-card:hover { transform: translateY(-4px); }
|
||||
|
||||
.ws-faq-item {
|
||||
border-radius: 14px; overflow: hidden;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
|
||||
.ws-step-card {
|
||||
transition: transform 0.4s cubic-bezier(0.16,1,0.3,1), box-shadow 0.4s;
|
||||
}
|
||||
.ws-step-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 모바일 반응형 */
|
||||
@media (max-width: 640px) {
|
||||
.ws-portfolio-grid { grid-template-columns: 1fr !important; }
|
||||
.ws-process-steps { flex-direction: column !important; align-items: stretch !important; }
|
||||
.ws-process-divider { display: none !important; }
|
||||
.ws-pricing-grid { grid-template-columns: 1fr !important; }
|
||||
.ws-hero-stats { gap: 0 !important; flex-wrap: nowrap !important; }
|
||||
.ws-hero-stats > div { padding: 0 16px !important; }
|
||||
.ws-cta-box { padding: 36px 24px !important; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.ws-hero-buttons { flex-direction: column !important; align-items: stretch !important; }
|
||||
.ws-hero-buttons a, .ws-hero-buttons button { text-align: center !important; justify-content: center !important; }
|
||||
}
|
||||
|
||||
/* scrollbar */
|
||||
::-webkit-scrollbar { width: 4px; }
|
||||
::-webkit-scrollbar-track { background: #030712; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(99,102,241,0.3); border-radius: 2px; }
|
||||
`}} />
|
||||
|
||||
{/* ── Hero ── */}
|
||||
<section style={{ padding: '80px 24px 60px', position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Diagonal pattern */}
|
||||
<div style={{
|
||||
position: 'absolute', inset: 0, pointerEvents: 'none',
|
||||
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 40px)',
|
||||
}} />
|
||||
|
||||
<div style={{ maxWidth: 820, margin: '0 auto', position: 'relative', animation: 'ws-fadeUp 0.9s cubic-bezier(0.16,1,0.3,1) both' }}>
|
||||
<p style={{
|
||||
fontSize: 11, fontWeight: 700, letterSpacing: '0.18em',
|
||||
color: '#6366f1', textTransform: 'uppercase',
|
||||
fontFamily: 'monospace',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
홈페이지 제작 서비스
|
||||
</p>
|
||||
<h1 style={{
|
||||
fontSize: 'clamp(28px, 4.5vw, 54px)', fontWeight: 800,
|
||||
lineHeight: 1.2, marginBottom: 20,
|
||||
letterSpacing: '-0.02em',
|
||||
color: '#ffffff',
|
||||
wordBreak: 'keep-all',
|
||||
}}>
|
||||
홈페이지·웹앱·앱 개발,<br/>연락 끊기는 일 없습니다
|
||||
</h1>
|
||||
<p style={{
|
||||
fontSize: 16, color: '#64748b', lineHeight: 1.85, marginBottom: 36,
|
||||
wordBreak: 'keep-all',
|
||||
}}>
|
||||
소개 사이트부터 SaaS·쇼핑몰·모바일웹까지 — 계약서부터 소스코드 인도까지<br/>
|
||||
단계마다 증거를 남깁니다. 납기 지연 시 하루당 10만원 감면.
|
||||
</p>
|
||||
<div className="ws-hero-buttons" style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<Link href="/work/freelance?service=website" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '14px 28px',
|
||||
background: '#6366f1',
|
||||
borderRadius: 12, color: 'white', fontWeight: 700, fontSize: 15,
|
||||
textDecoration: 'none',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.background = '#4f46e5'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.background = '#6366f1'; }}>
|
||||
무료 상담 신청 →
|
||||
</Link>
|
||||
<a href="#samples" style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 8,
|
||||
padding: '14px 28px',
|
||||
border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12,
|
||||
color: '#94a3b8', fontWeight: 600, fontSize: 15,
|
||||
textDecoration: 'none',
|
||||
transition: 'border-color 0.3s, color 0.3s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.25)'; (e.currentTarget as HTMLElement).style.color = '#e2e8f0'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.borderColor = 'rgba(255,255,255,0.1)'; (e.currentTarget as HTMLElement).style.color = '#94a3b8'; }}>
|
||||
샘플 보기
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="ws-hero-stats" style={{ display: 'flex', gap: 0, justifyContent: 'center', marginTop: 56, flexWrap: 'wrap' }}>
|
||||
{[
|
||||
{ num: '3~5일', label: '최단 납품 (스타터)' },
|
||||
{ num: '20만원~', label: '시작 가격' },
|
||||
{ num: '전액환불', label: '납품 전 환불 보장' },
|
||||
].map((s, i) => (
|
||||
<div key={s.label} style={{
|
||||
textAlign: 'center', padding: '0 40px',
|
||||
borderRight: i < 2 ? '1px solid rgba(255,255,255,0.08)' : 'none',
|
||||
}}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>{s.num}</div>
|
||||
<div style={{ fontSize: 12, color: '#475569', marginTop: 4, letterSpacing: '0.02em' }}>{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Feature tags ── */}
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.05)', borderBottom: '1px solid rgba(255,255,255,0.05)', padding: '14px 24px' }}>
|
||||
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
{['반응형 디자인', 'SEO 최적화', '웹앱·모바일웹', '계약서 작성', '소스코드 제공', '납기 패널티 보장', '도메인 배포'].map((t) => (
|
||||
<span key={t} style={{ padding: '4px 12px', fontSize: '11px', color: '#475569', letterSpacing: '0.06em', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 4, fontFamily: 'monospace' }}>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Trust badges ── */}
|
||||
<section style={{ padding: '48px 24px', maxWidth: 1000, margin: '0 auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 1, border: '1px solid rgba(255,255,255,0.06)', borderRadius: 12, overflow: 'hidden' }}>
|
||||
{[
|
||||
{
|
||||
title: '계약서 필수 작성', desc: '모든 프로젝트 계약서 체결 후 진행',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" /></svg>,
|
||||
},
|
||||
{
|
||||
title: '주간 진행 보고', desc: '매주 작업 현황 공유, 연락 두절 없음',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" /></svg>,
|
||||
},
|
||||
{
|
||||
title: '소스코드 전액 제공', desc: '완성 후 전체 소스코드 인도',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5" /></svg>,
|
||||
},
|
||||
{
|
||||
title: '납기 지연 패널티', desc: '지연 1일당 10만원 자동 감면',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" /></svg>,
|
||||
},
|
||||
{
|
||||
title: '실시간 진행 현황', desc: '마이페이지에서 7단계 진행 상황 직접 확인',
|
||||
icon: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} style={{ width: 20, height: 20 }}><path strokeLinecap="round" strokeLinejoin="round" 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>,
|
||||
},
|
||||
].map((b) => (
|
||||
<div key={b.title} style={{
|
||||
padding: '20px 22px',
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
display: 'flex', gap: 14, alignItems: 'flex-start',
|
||||
}}>
|
||||
<span style={{ color: '#6366f1', flexShrink: 0, marginTop: 2 }}>{b.icon}</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: '#e2e8f0', marginBottom: 4, wordBreak: 'keep-all' }}>{b.title}</div>
|
||||
<div style={{ fontSize: 12, color: '#475569', lineHeight: 1.6, wordBreak: 'keep-all' }}>{b.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Sample Portfolio ── */}
|
||||
<section id="samples" style={{ padding: '56px 24px', maxWidth: 1160, margin: '0 auto' }}>
|
||||
<div ref={samplesRef} className="ws-reveal">
|
||||
<div style={{ textAlign: 'center', marginBottom: 44 }}>
|
||||
<p style={{ fontSize: 11, color: '#6366f1', letterSpacing: '0.2em', textTransform: 'uppercase', marginBottom: 12, fontWeight: 700 }}>Portfolio Samples</p>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, color: 'white', marginBottom: 10, letterSpacing: '-0.02em' }}>
|
||||
포트폴리오 샘플
|
||||
</h2>
|
||||
<p style={{ color: '#475569', fontSize: 14 }}>
|
||||
카드를 클릭하면 실제 완성 화면을 미리 확인할 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="ws-portfolio-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(300px, 1fr))', gap: 18 }}>
|
||||
{samples.map((s) => (
|
||||
<Link key={s.type} href={`/work/website/samples/${s.type}`} style={{ textDecoration: 'none' }}>
|
||||
<div className="ws-sample-card">
|
||||
<div style={{ position: 'relative' }}>
|
||||
<SampleMiniPreview type={s.type} />
|
||||
<div style={{ position: 'absolute', top: 12, left: 12, display: 'flex', gap: 5, zIndex: 10 }}>
|
||||
{s.tags.map((tag) => (
|
||||
<span key={tag} style={{
|
||||
fontSize: 10, fontWeight: 600, color: '#e2e8f0',
|
||||
background: 'rgba(0,0,0,0.52)', backdropFilter: 'blur(8px)',
|
||||
border: '1px solid rgba(255,255,255,0.13)',
|
||||
padding: '2px 8px', borderRadius: 100,
|
||||
}}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 12, right: 12, zIndex: 10,
|
||||
background: 'rgba(0,0,0,0.55)', backdropFilter: 'blur(8px)',
|
||||
border: `1px solid ${s.accent}45`,
|
||||
borderRadius: 8, padding: '5px 12px',
|
||||
fontSize: 11, color: s.accent, fontWeight: 700,
|
||||
}}>
|
||||
미리보기 →
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '18px 22px 22px' }}>
|
||||
<div style={{ fontSize: 11, color: '#334155', marginBottom: 5, letterSpacing: '0.05em' }}>
|
||||
{s.subtitle}
|
||||
</div>
|
||||
<div style={{ fontSize: 16, fontWeight: 700, color: 'white', marginBottom: 8, letterSpacing: '-0.01em' }}>
|
||||
{s.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, color: '#475569', lineHeight: 1.65, wordBreak: 'keep-all' }}>
|
||||
{s.desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Process ── */}
|
||||
<section style={{ padding: '56px 24px', background: 'rgba(255,255,255,0.015)', borderTop: '1px solid rgba(255,255,255,0.04)', borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<div ref={processRef} className="ws-reveal" style={{ maxWidth: 1060, margin: '0 auto' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: 44 }}>
|
||||
<p style={{ fontSize: 11, color: '#6366f1', letterSpacing: '0.2em', textTransform: 'uppercase', marginBottom: 12, fontWeight: 700 }}>Process</p>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, color: 'white', marginBottom: 10, letterSpacing: '-0.02em' }}>
|
||||
제작 프로세스
|
||||
</h2>
|
||||
<p style={{ color: '#475569', fontSize: 14 }}>투명하고 체계적인 5단계로 진행됩니다</p>
|
||||
</div>
|
||||
<div className="ws-process-steps" style={{ display: 'flex', alignItems: 'stretch', flexWrap: 'wrap', justifyContent: 'center', gap: 0 }}>
|
||||
{processSteps.map((p, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div className="ws-step-card" style={{
|
||||
textAlign: 'center', padding: '28px 22px', minWidth: 138,
|
||||
background: '#080d1a', borderRadius: 16,
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: '#6366f1', fontFamily: 'monospace', marginBottom: 12, letterSpacing: '-0.02em' }}>
|
||||
{p.step}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, fontWeight: 700, color: 'white', marginBottom: 6, wordBreak: 'keep-all' }}>
|
||||
{p.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#334155', lineHeight: 1.55, wordBreak: 'keep-all' }}>
|
||||
{p.desc}
|
||||
</div>
|
||||
</div>
|
||||
{i < processSteps.length - 1 && (
|
||||
<div className="ws-process-divider" style={{ color: '#1e293b', fontSize: 20, padding: '0 4px', flexShrink: 0 }}>›</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Pricing ── */}
|
||||
<section style={{ padding: '64px 24px', maxWidth: 1040, margin: '0 auto' }}>
|
||||
<div ref={pricingRef} className="ws-reveal">
|
||||
<div style={{ textAlign: 'center', marginBottom: 44 }}>
|
||||
<p style={{ fontSize: 11, color: '#6366f1', letterSpacing: '0.2em', textTransform: 'uppercase', marginBottom: 12, fontWeight: 700 }}>Pricing</p>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, color: 'white', marginBottom: 10, letterSpacing: '-0.02em' }}>
|
||||
가격 플랜
|
||||
</h2>
|
||||
<p style={{ color: '#475569', fontSize: 14 }}>프로젝트 규모에 맞는 플랜을 선택하세요</p>
|
||||
</div>
|
||||
<div className="ws-pricing-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(270px, 1fr))', gap: 20 }}>
|
||||
{plans.map((plan) => (
|
||||
<div key={plan.name} className="ws-plan-card" style={{
|
||||
padding: 32, borderRadius: 20,
|
||||
background: plan.featured ? '#0d1240' : '#080d1a',
|
||||
border: `1px solid ${plan.featured ? plan.color + '40' : 'rgba(255,255,255,0.05)'}`,
|
||||
position: 'relative', overflow: 'hidden',
|
||||
boxShadow: plan.featured ? `0 24px 64px ${plan.color}12` : 'none',
|
||||
}}>
|
||||
{plan.featured && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 20, right: 20,
|
||||
background: plan.color, color: '#1e1b4b',
|
||||
fontSize: 10, fontWeight: 800, padding: '3px 10px', borderRadius: 100,
|
||||
}}>BEST</div>
|
||||
)}
|
||||
<div style={{ fontSize: 12, color: plan.color, fontWeight: 700, marginBottom: 12, letterSpacing: '0.05em' }}>
|
||||
{plan.name}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 4, marginBottom: 4 }}>
|
||||
<span style={{ fontSize: 40, fontWeight: 800, color: 'white', letterSpacing: '-0.03em' }}>{plan.price}</span>
|
||||
<span style={{ fontSize: 15, color: '#64748b' }}>{plan.unit}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#334155', marginBottom: 24, wordBreak: 'keep-all' }}>{plan.note}</div>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 20, marginBottom: 24 }}>
|
||||
{plan.features.map((f) => (
|
||||
<div key={f} style={{ display: 'flex', alignItems: 'center', gap: 9, marginBottom: 12 }}>
|
||||
<span style={{ color: plan.color, fontSize: 13, flexShrink: 0, fontWeight: 700 }}>✓</span>
|
||||
<span style={{ fontSize: 13, color: '#94a3b8', wordBreak: 'keep-all' }}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => openModal(`홈페이지 제작 - ${plan.name}`)}
|
||||
style={{
|
||||
display: 'block', width: '100%', textAlign: 'center', padding: '13px',
|
||||
background: plan.featured ? plan.color : 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 10, color: plan.featured ? '#1e1b4b' : '#94a3b8',
|
||||
fontWeight: 700, fontSize: 14, border: plan.featured ? 'none' : '1px solid rgba(255,255,255,0.07)',
|
||||
transition: 'opacity 0.2s', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
견적 문의
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── FAQ ── */}
|
||||
<section style={{ padding: '0 24px 64px', maxWidth: 720, margin: '0 auto' }}>
|
||||
<div ref={faqRef} className="ws-reveal">
|
||||
<div style={{ textAlign: 'center', marginBottom: 36 }}>
|
||||
<p style={{ fontSize: 11, color: '#6366f1', letterSpacing: '0.2em', textTransform: 'uppercase', marginBottom: 12, fontWeight: 700 }}>FAQ</p>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>
|
||||
자주 묻는 질문
|
||||
</h2>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{faqs.map((faq, i) => (
|
||||
<div key={i} className="ws-faq-item" style={{
|
||||
background: '#080d1a',
|
||||
border: `1px solid ${openFaq === i ? 'rgba(99,102,241,0.4)' : 'rgba(255,255,255,0.05)'}`,
|
||||
}}>
|
||||
<button onClick={() => setOpenFaq(openFaq === i ? null : i)} style={{
|
||||
width: '100%', textAlign: 'left', padding: '18px 22px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12,
|
||||
}}>
|
||||
<span style={{ fontSize: 14, fontWeight: 600, color: 'white', wordBreak: 'keep-all' }}>
|
||||
{faq.q}
|
||||
</span>
|
||||
<span style={{
|
||||
color: '#6366f1', fontSize: 22, flexShrink: 0,
|
||||
transition: 'transform 0.25s',
|
||||
transform: openFaq === i ? 'rotate(45deg)' : 'none',
|
||||
display: 'inline-block',
|
||||
}}>+</span>
|
||||
</button>
|
||||
{openFaq === i && (
|
||||
<div style={{ padding: '0 22px 18px', fontSize: 14, color: '#475569', lineHeight: 1.8, wordBreak: 'keep-all' }}>
|
||||
{faq.a}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── CTA ── */}
|
||||
<section style={{ padding: '0 24px 80px', textAlign: 'center' }}>
|
||||
<div ref={ctaRef} className="ws-reveal">
|
||||
<div className="ws-cta-box" style={{
|
||||
maxWidth: 640, margin: '0 auto',
|
||||
padding: '56px 44px', borderRadius: 24,
|
||||
background: '#04102b',
|
||||
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)',
|
||||
border: '1px solid rgba(99,102,241,0.3)',
|
||||
boxShadow: '0 24px 80px rgba(0,0,0,0.3)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 28, fontWeight: 800, color: 'white', marginBottom: 14, letterSpacing: '-0.02em', wordBreak: 'keep-all' }}>
|
||||
내일도 고민만 하실 건가요?
|
||||
</h2>
|
||||
<p style={{ color: '#94a3b8', fontSize: 15, lineHeight: 1.75, marginBottom: 32, wordBreak: 'keep-all' }}>
|
||||
상담 신청 후 24시간 이내 답변드립니다.<br/>
|
||||
소개 사이트·웹앱·쇼핑몰·모바일앱, 규모 무관하게 검토해드립니다.
|
||||
</p>
|
||||
<Link href="/work/freelance?service=website" style={{
|
||||
display: 'inline-block', padding: '15px 40px',
|
||||
background: '#6366f1',
|
||||
borderRadius: 12, color: 'white', fontWeight: 700, fontSize: 15,
|
||||
textDecoration: 'none',
|
||||
boxShadow: '0 8px 24px rgba(99,102,241,0.4)',
|
||||
transition: 'transform 0.3s, box-shadow 0.3s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-2px)'; (e.currentTarget as HTMLElement).style.boxShadow = '0 16px 40px rgba(99,102,241,0.5)'; }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.transform = ''; (e.currentTarget as HTMLElement).style.boxShadow = '0 8px 24px rgba(99,102,241,0.4)'; }}>
|
||||
무료 상담 신청하기 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Scroll to Top ── */}
|
||||
<button
|
||||
onClick={() => {
|
||||
const scroller = (document.querySelector('.main-content') as HTMLElement | null) ?? document.documentElement;
|
||||
scroller.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
style={{
|
||||
position: 'fixed', bottom: '5.5rem', right: '2rem', zIndex: 200,
|
||||
width: 48, height: 48, borderRadius: '50%',
|
||||
background: '#6366f1',
|
||||
border: 'none', cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
boxShadow: '0 8px 32px rgba(99,102,241,0.4)',
|
||||
opacity: showTop ? 1 : 0,
|
||||
transform: showTop ? 'translateY(0) scale(1)' : 'translateY(12px) scale(0.9)',
|
||||
transition: 'opacity 0.35s cubic-bezier(0.16,1,0.3,1), transform 0.35s cubic-bezier(0.16,1,0.3,1)',
|
||||
pointerEvents: showTop ? 'auto' : 'none',
|
||||
}}
|
||||
aria-label="맨 위로"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
384
docs/superpowers/plans/2026-06-12-deepfield-landing.md
Normal file
384
docs/superpowers/plans/2026-06-12-deepfield-landing.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Deep Field 랜딩 경험 구현 계획
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
> **비주얼 태스크(4·5·7·8)는 구현 시 `designer` + `soft-skill` 스킬 로드 필수.**
|
||||
|
||||
**Goal:** 메인(/)·/outsourcing을 "Deep Field" 다크 캔버스로 재구성 — WebGL 커서 반응 히어로 + 몰입형 쇼케이스(주인공) + 스크롤 연출, 3단계 성능 폴백 내장.
|
||||
|
||||
**Architecture:** 다크 토큰 6종을 기존 jsm 체계에 추가(라이트 토큰 무수정). WebGL은 `app/components/deepfield/`에 격리된 클라이언트 경계 — 페이지는 서버 컴포넌트 유지, three.js는 dynamic import. 모드 판정(`full|lite|static`)은 순수 함수(`lib/deepfield-mode.ts`)로 TDD. 쇼케이스 데이터는 `lib/showcase.ts` 단일 소스(8슬롯, href 있는 슬롯만 클릭 가능).
|
||||
|
||||
**Tech Stack:** Next.js 16, three.js(코어만, dynamic import), Tailwind v4, vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-12-deep-field-landing-design.md`
|
||||
**Branch:** `feature/deepfield-landing`
|
||||
|
||||
---
|
||||
|
||||
## 카피 절대 규칙 (전 태스크 공통)
|
||||
|
||||
"7년차", "대기업" 등 경력·소속 표현 **금지** — 신규 카피·metadata·jsonLd 전부. 신뢰 축은 "24시간 돌아가는 실서비스 15+를 직접 설계·운영" ([[feedback-copy-no-career-emphasis]]).
|
||||
|
||||
## 무수정 금지선 (전 태스크 공통)
|
||||
|
||||
OutsourcingRequestForm 로직·검증·API / products 동적 연동 로직(`loadFeaturedProducts`) / 라우팅·redirect / 거래·계정·admin 페이지 / TopNav auth 로직.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 기반 — three 설치 + 다크 토큰 + 쇼케이스 데이터
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (`npm install three @types/three`)
|
||||
- Modify: `app/globals.css` (다크 토큰 6종 추가 — 기존 토큰 무수정)
|
||||
- Create: `lib/showcase.ts`
|
||||
|
||||
- [ ] **Step 1:** `npm install three` + `npm install -D @types/three`
|
||||
- [ ] **Step 2:** `app/globals.css`의 `:root`에 추가 (기존 jsm 라이트 토큰 아래):
|
||||
|
||||
```css
|
||||
/* === Deep Field dark tokens (2026-06 랜딩 경험) — 라이트 토큰과 공존 === */
|
||||
--jsm-dark-bg: #070d1a;
|
||||
--jsm-dark-surface: rgba(255, 255, 255, 0.03);
|
||||
--jsm-dark-line: rgba(148, 163, 184, 0.14);
|
||||
--jsm-dark-ink: #f8fafc;
|
||||
--jsm-dark-soft: #94a3b8;
|
||||
--jsm-accent-bright: #60a5fa;
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** `lib/showcase.ts`:
|
||||
|
||||
```typescript
|
||||
/** Deep Field 쇼케이스 8슬롯 — 단일 소스.
|
||||
* href가 있는 슬롯만 클릭 가능 (샘플 리뉴얼 완료 시 href 추가). */
|
||||
export interface ShowcaseSlot {
|
||||
slug: string;
|
||||
label: string; // 모노스페이스 컨셉 라벨 (영문)
|
||||
title: string; // 카드 타이틀 (한글)
|
||||
desc: string; // 한 줄 설명
|
||||
palette: [string, string]; // 카드 고유 그래디언트 월드 [from, to]
|
||||
accent: string; // 카드 포인트 컬러
|
||||
href?: string; // 리뉴얼 완료된 샘플의 데모 링크
|
||||
}
|
||||
|
||||
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
|
||||
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 IR까지', palette: ['#13203a', '#0d2c54'], accent: '#60a5fa' },
|
||||
{ slug: 'shopping', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', palette: ['#1a1430', '#341a4f'], accent: '#c4b5fd' },
|
||||
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', palette: ['#0f2922', '#14503c'], accent: '#6ee7b7' },
|
||||
{ slug: 'bakery', label: 'local shop', title: '로컬 매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', palette: ['#2b1a10', '#4f2d14'], accent: '#fdba74' },
|
||||
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', palette: ['#101418', '#23272d'], accent: '#e2e8f0' },
|
||||
{ slug: 'game', label: 'game', title: '게임 프로모션', desc: '세계관에 빠져들게 하는 런칭 페이지', palette: ['#250f23', '#4a1342'], accent: '#f0abfc' },
|
||||
{ slug: 'interior', label: 'interior', title: '인테리어 스튜디오', desc: '공간의 톤을 그대로 옮긴 쇼룸', palette: ['#1f2218', '#3a4028'], accent: '#d9f99d' },
|
||||
{ slug: 'reading', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', palette: ['#101b2b', '#1f3a5f'], accent: '#93c5fd' },
|
||||
];
|
||||
```
|
||||
|
||||
(컨셉·팔레트는 기존 샘플 8종의 주제를 승계 — 각 샘플 page.tsx를 열어 주제가 맞는지 확인하고 어긋나면 title/desc만 조정)
|
||||
|
||||
- [ ] **Step 4:** `npm test`(10) + `npm run build` 통과
|
||||
- [ ] **Step 5:** Commit — `feat(deepfield): three.js + 다크 토큰 + 쇼케이스 8슬롯 데이터`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 모드 판정 (TDD) + WebGL 지원 훅
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/deepfield-mode.ts`
|
||||
- Test: `lib/__tests__/deepfield-mode.test.ts`
|
||||
- Create: `app/components/deepfield/useFieldMode.ts`
|
||||
|
||||
- [ ] **Step 1: 실패 테스트** — `lib/__tests__/deepfield-mode.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideFieldMode } from '@/lib/deepfield-mode';
|
||||
|
||||
const base = { reducedMotion: false, webglSupported: true, hardwareConcurrency: 8, viewportWidth: 1440 };
|
||||
|
||||
describe('decideFieldMode', () => {
|
||||
it('데스크톱 + WebGL = full', () => {
|
||||
expect(decideFieldMode(base)).toBe('full');
|
||||
});
|
||||
it('reduced-motion이면 무조건 static', () => {
|
||||
expect(decideFieldMode({ ...base, reducedMotion: true })).toBe('static');
|
||||
expect(decideFieldMode({ ...base, reducedMotion: true, viewportWidth: 375 })).toBe('static');
|
||||
});
|
||||
it('WebGL 미지원이면 static', () => {
|
||||
expect(decideFieldMode({ ...base, webglSupported: false })).toBe('static');
|
||||
});
|
||||
it('모바일 뷰포트(<768)는 lite', () => {
|
||||
expect(decideFieldMode({ ...base, viewportWidth: 767 })).toBe('lite');
|
||||
});
|
||||
it('저성능 코어(<4)는 lite', () => {
|
||||
expect(decideFieldMode({ ...base, hardwareConcurrency: 2 })).toBe('lite');
|
||||
});
|
||||
it('hardwareConcurrency 미보고(0/undefined)는 lite로 보수적 판정', () => {
|
||||
expect(decideFieldMode({ ...base, hardwareConcurrency: 0 })).toBe('lite');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `npm test` → FAIL 확인
|
||||
- [ ] **Step 3: 구현** — `lib/deepfield-mode.ts`:
|
||||
|
||||
```typescript
|
||||
export type FieldMode = 'full' | 'lite' | 'static';
|
||||
|
||||
export interface FieldEnv {
|
||||
reducedMotion: boolean;
|
||||
webglSupported: boolean;
|
||||
hardwareConcurrency: number; // 미보고 시 0
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
/** Deep Field 렌더 모드 판정 — 우선순위: 접근성 > 지원 여부 > 성능 */
|
||||
export function decideFieldMode(env: FieldEnv): FieldMode {
|
||||
if (env.reducedMotion) return 'static';
|
||||
if (!env.webglSupported) return 'static';
|
||||
if (env.viewportWidth < 768) return 'lite';
|
||||
if (!env.hardwareConcurrency || env.hardwareConcurrency < 4) return 'lite';
|
||||
return 'full';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4:** `npm test` → 16 passed (기존 10 + 신규 6)
|
||||
- [ ] **Step 5: 훅** — `app/components/deepfield/useFieldMode.ts` ('use client'):
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { decideFieldMode, type FieldMode } from '@/lib/deepfield-mode';
|
||||
|
||||
function detectWebGL(): boolean {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** SSR/첫 페인트는 'static'으로 시작 — 클라이언트에서 승격 (hydration 불일치 방지) */
|
||||
export function useFieldMode(): FieldMode {
|
||||
const [mode, setMode] = useState<FieldMode>('static');
|
||||
useEffect(() => {
|
||||
setMode(
|
||||
decideFieldMode({
|
||||
reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||
webglSupported: detectWebGL(),
|
||||
hardwareConcurrency: navigator.hardwareConcurrency ?? 0,
|
||||
viewportWidth: window.innerWidth,
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
return mode;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6:** `npm run build` 통과 → Commit — `feat(deepfield): 렌더 모드 판정(TDD) + useFieldMode 훅`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `ScrollReveal` 공용 연출 컴포넌트
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/deepfield/ScrollReveal.tsx`
|
||||
|
||||
- [ ] **Step 1:** 'use client' 컴포넌트 — IntersectionObserver 기반:
|
||||
|
||||
```tsx
|
||||
'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);
|
||||
|
||||
useEffect(() => {
|
||||
// reduced-motion: 즉시 표시 (연출 생략)
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
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';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${className ?? ''} transition-all duration-700 ease-out ${shown ? 'opacity-100 translate-y-0 [transform:none]' : hidden}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `npm run build` 통과 → Commit — `feat(deepfield): ScrollReveal 스크롤 연출 컴포넌트`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: `HeroField` — WebGL 커서 반응 파티클 필드
|
||||
|
||||
> **designer + soft-skill 로드 필수.** 가장 중요한 비주얼 태스크.
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/deepfield/HeroField.tsx`
|
||||
|
||||
**요구 동작:**
|
||||
- props: `{ className?: string }` — 히어로 섹션의 절대배치 배경 캔버스
|
||||
- `useFieldMode()`로 모드 결정:
|
||||
- **static**: 캔버스 미기동 — `--jsm-dark-bg` 위 정적 radial 그래디언트(accent 30~40% 불투명 2개 광원) div 렌더. 이것만으로도 완성된 비주얼이어야 함
|
||||
- **lite**: 파티클 수 full의 1/4, 커서 반응 비활성(자동 드리프트만), DPR 1 고정
|
||||
- **full**: 파티클 포인트 필드(2,000~4,000pt) — 커서 위치를 향해 자기장처럼 휘는 변위(셰이더 uniform으로 마우스 전달), 미세 드리프트, 스크롤 진행도(uniform)에 따라 필드가 흩어짐
|
||||
- **three.js는 `await import('three')`로 dynamic import** — 모듈 상단 정적 import 금지
|
||||
- 색: 파티클은 `#60a5fa`~`#1d4ed8` 범위, 배경은 투명(섹션 bg가 비침)
|
||||
- 정리: 언마운트 시 renderer.dispose()+geometry/material dispose, `document.visibilityState` hidden 시 rAF 정지, IntersectionObserver로 화면 밖이면 정지
|
||||
- 마우스 추적은 window 리스너(passive), rAF 내에서 lerp로 부드럽게
|
||||
- 캔버스에 `aria-hidden="true"`, pointer-events 없음
|
||||
|
||||
- [ ] **Step 1:** 컴포넌트 구현 (위 3모드)
|
||||
- [ ] **Step 2:** `npm run build` 통과 + 임시 검증: dev 서버에서 컴포넌트를 임시로 메인에 올리지 말고, Task 6에서 통합 검증 (이 태스크는 build·타입 통과까지)
|
||||
- [ ] **Step 3:** Commit — `feat(deepfield): HeroField WebGL 파티클 필드 (full/lite/static)`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `ShowcaseGrid` + `ShowcaseCard`
|
||||
|
||||
> **designer + soft-skill 로드 필수.**
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/deepfield/ShowcaseCard.tsx`
|
||||
- Create: `app/components/deepfield/ShowcaseGrid.tsx`
|
||||
|
||||
**ShowcaseCard** — props `{ slot: ShowcaseSlot, size?: 'feature' | 'standard', index: number }`:
|
||||
- 카드 비주얼 = 슬롯 palette 그래디언트 월드 + 절제된 제너러티브 패턴(슬롯별로 달라 보이게 — slug를 시드로 한 캔버스 2D 패턴: 격자/등고선/도트 등 2~3종 변형). WebGL 필수 아님 — **카드 타일은 Canvas2D로 충분** (성능·단순성). hover 시:
|
||||
- full 모드: 타일이 미세 굴절(translate+scale 1.03)되고 패턴이 커서 방향으로 시차 이동 (CSS transform + mousemove 기반 — 카드당 WebGL 인스턴스 금지)
|
||||
- lite/static: CSS 전환만 (border accent 점등 + 살짝 lift)
|
||||
- 텍스트: 모노스페이스 label(accent 컬러) + 한글 title(굵게) + desc 1줄
|
||||
- `slot.href` 있으면 `<Link>` 래핑 + "데모 보기 →" 표시 / 없으면 비클릭(커서 default, hover는 동일하게 동작 — "준비 중" 라벨 금지)
|
||||
- `aria-label` = title
|
||||
|
||||
**ShowcaseGrid** — props `{ slots: ShowcaseSlot[], variant: 'home' | 'full' }`:
|
||||
- `home`: 상위 6슬롯, 비대칭 그리드 — 1번 feature(2col), 2·3 standard, 4 feature, 5·6 standard (데스크톱 3col 기준 / 모바일 1col 스택). 각 카드는 `ScrollReveal`로 스태거 등장(delay = index*80)
|
||||
- `full`: 8슬롯 전체, 2col 균등(모바일 1col)
|
||||
- 서버에서 import 가능하도록 그리드 자체는 서버 컴포넌트, 카드만 'use client'
|
||||
|
||||
- [ ] **Step 1:** ShowcaseCard 구현 (Canvas2D 패턴 + hover)
|
||||
- [ ] **Step 2:** ShowcaseGrid 구현
|
||||
- [ ] **Step 3:** `npm run build` 통과 → Commit — `feat(deepfield): 쇼케이스 카드·그리드 (제너러티브 타일 + 호버 시차)`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: TopNav route-aware 다크 모드
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/TopNav.tsx`
|
||||
|
||||
- [ ] **Step 1:** `usePathname()`으로 다크 페이지 판정:
|
||||
|
||||
```typescript
|
||||
const DARK_ROUTES = ['/', '/outsourcing'];
|
||||
const isDark = DARK_ROUTES.includes(pathname) || pathname.startsWith('/outsourcing/');
|
||||
```
|
||||
|
||||
- 다크 페이지: 기본 투명 배경 + `--jsm-dark-ink` 텍스트, 스크롤 시 `rgba(7,13,26,0.85)` 배경 + `--jsm-dark-line` 하단 보더. 로고·링크·CTA 색상도 다크 팔레트(accent-bright 활성)
|
||||
- 라이트 페이지: **기존 동작 그대로** (흰 배경 전환)
|
||||
- 모바일 드로어: 다크 페이지에서는 다크 패널(`--jsm-dark-bg`), 라이트에서는 기존 흰 패널
|
||||
- **auth 로직(getSession/onAuthStateChange/handleLogout)·접근성 속성(aria-expanded/Esc/dialog) 무수정**
|
||||
|
||||
- [ ] **Step 2:** `npm run build` + dev에서 `/products`(라이트)·`/`(다크 예정 — 아직 페이지는 라이트지만 네비만 다크 톤이 되는 과도기 OK, Task 7과 같은 PR이므로 순서상 문제 없음) 컴파일 확인
|
||||
- [ ] **Step 3:** Commit — `feat(nav): 다크 라우트 인지형 네비게이션`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 메인(/) Deep Field 재조립 + 카피·메타 교체
|
||||
|
||||
> **designer + soft-skill 로드 필수.** 스펙 §2의 5섹션 구조.
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/page.tsx` (전면 재구성 — products 동적 로직 `loadFeaturedProducts`는 그대로 이식)
|
||||
- Modify: `app/layout.tsx` (metadata description·jsonLd에서 경력 표현 제거 — 구조 무수정)
|
||||
- Modify: `app/components/PublicShell.tsx` (main 배경이 페이지별로 다크/라이트 — main의 고정 `--jsm-bg` 인라인 배경을 제거하고 페이지가 자기 배경을 그리도록, 또는 route-aware. 푸터·KakaoFloatButton 무수정)
|
||||
|
||||
**섹션 구성 (승인된 목업 기준):**
|
||||
1. **HERO** — min-h-[100svh] 풀스크린. `HeroField` 배경 + 거대 타이포: "생각을\n동작하는 소프트웨어로." (디자인 스킬로 다듬기 허용 — 단 경력 표현 금지). 서브 1줄: "24시간 돌아가는 실서비스를 직접 설계하고 운영합니다. 외주 개발도, 완성 소프트웨어도 — 같은 손으로." CTA 2개([프로젝트 문의 → /outsourcing#contact] accent 솔리드 / [소프트웨어 보기 → /products] 다크 고스트). 하단 스크롤 큐(미세 바운스 화살표)
|
||||
2. **SHOWCASE** — "이런 걸 만들어 드립니다" + `<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="home" />` + [전체 레퍼런스 → /outsourcing#showcase]
|
||||
3. **PROCESS** — 4단계(기존 카피 유지: 상담→견적 2일→주1회 공유→납품+30일 하자보수), ScrollReveal `draw`로 연결선 + 스태거 점등
|
||||
4. **PROOF** — 운영 시스템 3종 카드(주식 자동매매/청약 자동 매칭/AI 콘텐츠 파이프라인 — 기존 카피 재사용 가능) + 스탯: "실서비스 15+" "24/7 무중단 운영" "기획→배포 원스톱" (스크롤 진입 시 카운트업은 ScrollReveal + 간단한 useEffect 카운터, reduced-motion 시 즉시 최종값)
|
||||
5. **SOFTWARE + CTA** — `loadFeaturedProducts` 동적 연동 그대로(라이트 카드가 다크 위에 뜨는 대비), 빈 상태 폴백 유지. 최종 CTA 밴드(accent)
|
||||
|
||||
- metadata: title 유지, description → "24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어." / jsonLd Person·LocalBusiness description에서 "7년차" 제거, jobTitle "소프트웨어 엔지니어"로
|
||||
- 전체 페이지 배경 `--jsm-dark-bg`, 텍스트 다크 토큰. 가드레일: gradient는 **Deep Field 광원 표현에 한해 radial 그래디언트 허용**(다크 캔버스의 일부 — 기존 "그래디언트 금지"의 의도는 generic AI 보라 그라데이션 차단이었음), 보라 금지 유지(쇼케이스 palette의 컨셉 컬러는 예외 — 카드 월드 한정), blur 금지, 이모지 금지
|
||||
|
||||
- [ ] **Step 1:** 페이지 재조립 + 카피 교체
|
||||
- [ ] **Step 2:** `npm run build` + dev: `/` 200, "7년차"·"대기업" grep 0건(app/page.tsx·app/layout.tsx), products 폴백 동작
|
||||
- [ ] **Step 3:** Commit — `feat(home): Deep Field 다크 캔버스 재조립 + 운영 실증 카피`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: /outsourcing Deep Field 재스킨
|
||||
|
||||
> **designer + soft-skill 로드 필수.**
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/outsourcing/page.tsx`
|
||||
- Modify(스타일만): `app/components/OutsourcingRequestForm.tsx`
|
||||
|
||||
- [ ] **Step 1:** 페이지를 다크 토큰으로 재스킨:
|
||||
- Hero 축약(타이포+간단 필드 배경 — HeroField 재사용 가능, 높이 60vh)
|
||||
- `#showcase` 섹션 신설: `<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />` — 기존 #portfolio 위치에 배치하고 `id="showcase"`와 `id="portfolio"` 모두 도달 가능하게(섹션에 showcase, 내부 앵커 div에 portfolio)
|
||||
- 기존 실사례 6건(운영 시스템)은 PROOF 스타일 카드로 유지
|
||||
- 제공 분야·프로세스·FAQ를 다크 카드로 재스킨 (카피 무수정)
|
||||
- `#contact` 의뢰 폼: OutsourcingRequestForm을 다크 스킨으로 — **INPUT_STYLE 상수·카드 배경 등 스타일 값만 변경, 로직·검증·API·단계 구조 무수정** (goNext 스테일 클로저 경고 주석 보존)
|
||||
- [ ] **Step 2:** `npm run build` + dev: `/outsourcing` 200, 앵커 3+1종(process/portfolio/showcase/contact) 존재, 폼 1단계 카드 렌더
|
||||
- [ ] **Step 3:** Commit — `feat(outsourcing): Deep Field 재스킨 + 쇼케이스 풀 그리드`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: E2E + 성능 검증
|
||||
|
||||
- [ ] **Step 1: 자동** — `npm test`(16) + `npm run build` + prod 서버 curl:
|
||||
- `/` 200 + 새 히어로 카피 존재 + "7년차|대기업" 0건 / `/outsourcing` 200 + id="showcase" / 폼 마크업 존재
|
||||
- 회귀: `/products` 200(라이트 유지), `/work/saju` 404, `/music/packs` 308, POST `/api/contact` 빈 body 400, `/api/orders` 401, `/track/x` 404
|
||||
- 번들 확인: `.next` 빌드 출력에서 `/` 페이지 First Load JS — three.js가 별도 청크인지(메인 First Load에 포함 안 됨), 합계가 과도하지 않은지 보고
|
||||
- [ ] **Step 2: 수동 체크리스트 (CEO + 컨트롤러)**
|
||||
- 데스크톱: 히어로 커서 반응·쇼케이스 hover 시차·스크롤 연출·카운터
|
||||
- 모바일 375px: lite 모드(드리프트만), 레이아웃
|
||||
- DevTools에서 prefers-reduced-motion 에뮬레이션 → 정적 폴백이 그 자체로 완성돼 보이는지
|
||||
- 탭 비활성 시 CPU 사용 0 근접 확인
|
||||
- 의뢰 폼 4단계 제출 회귀 1회
|
||||
- [ ] **Step 3:** 최종 보고
|
||||
|
||||
---
|
||||
|
||||
## 후속 (별도 스펙·플랜)
|
||||
|
||||
샘플 8종 Deep Field 컨셉 리뉴얼 — 2개씩 4회차, 완료 슬롯마다 `lib/showcase.ts`에 href 추가로 활성화.
|
||||
235
docs/superpowers/plans/2026-06-30-jsm-light-redesign.md
Normal file
235
docs/superpowers/plans/2026-06-30-jsm-light-redesign.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# 쟁승메이드 라이트 고craft 재설계 — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 홈·외주·제품 3면을 라이트 `--jsm-*` 단일 시스템으로 통일하고, 히어로·쇼케이스를 코드 UI 목업(MockWindow)으로 재구성한다.
|
||||
|
||||
**Architecture:** 파티클(HeroField)·다크 토큰을 폐기하고, 재사용 가능한 라이트 `MockWindow` 목업 시스템을 craft의 핵심 비주얼로 삼는다. 3면이 동일한 컨테이너·타입 스케일·여백 리듬·카드 스펙을 공유한다. TopNav의 다크 라우트 분기를 제거해 전 페이지 단일 라이트 셸로 통일한다.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, 서버 컴포넌트 우선), TypeScript, Tailwind v4, Pretendard, vitest.
|
||||
|
||||
설계 문서: `docs/superpowers/specs/2026-06-30-jsm-light-redesign-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 색: `--jsm-*` 라이트 토큰만. **금지** — `--jsm-dark-*`, `--kx-*`, 보라/violet, gradient, blur, 이모지.
|
||||
- navy(`--jsm-navy`)는 푸터 + 홈 CTA 밴드 **2곳에서만** (평면, radial 없음).
|
||||
- 컨테이너: `max-w-6xl mx-auto px-6 lg:px-8` (3면 동일).
|
||||
- 한글: 헤딩·본문 `break-keep`. `KOR_TIGHT = letterSpacing -0.02em`, `KOR_BODY = -0.01em`.
|
||||
- 타이포: h1 `clamp(2.4rem,7vw,4rem)` w800 -0.03em / h2 `clamp(1.7rem,4vw,2.4rem)` w700 -0.02em / eyebrow 11px UPPER 0.2em accent / 본문 16–18px ink-soft.
|
||||
- 카피: 경력 어필("대기업 7년차" 류) 금지 → 운영 실증 표현 유지.
|
||||
- 모션: `ScrollReveal`·`.reveal` CSS 유지, `prefers-reduced-motion` 가드.
|
||||
- 각 Task 종료 시 `npm run build` 통과 + 커밋. 브랜치 `redesign/jsm-light-craft` (생성됨).
|
||||
- 빌드 명령(Windows): `npm run build`. 테스트: `npm test`.
|
||||
|
||||
> **계획 altitude 주석:** 본 계획은 *재사용 빌딩블록*(MockWindow API·showcase 타입·테스트)은 완전한 코드로, *페이지 재작성*은 섹션 구조 + 정확한 토큰/클래스 규약 + 검증 게이트로 명세한다. 페이지 JSX 전문을 계획에 박지 않는 것은 의도된 결정이다(시각 레이아웃은 토큰·구조 제약으로 충분히 결정되며, 전문 박제는 중복·열화를 유발).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: MockWindow 목업 시스템
|
||||
|
||||
**Files:**
|
||||
- Create: `app/components/mock/MockWindow.tsx`
|
||||
- Create: `app/components/mock/screens.tsx` (6 스크린 목업 한 파일 — 함께 변경되므로 동거)
|
||||
- Create: `app/components/mock/registry.ts` (mock key → 컴포넌트 + 메타)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `MockWindow({ title, accent?, children, className? }): JSX` — 브라우저 크롬 프레임(● ● ● 신호등 + 타이틀바 + 본문 슬롯). 서버 컴포넌트. 라이트(surface) + navy 타이틀바 옵션.
|
||||
- 스크린 컴포넌트(전부 서버, props 없음, 정적 마크업): `DashboardMock`, `FeedMock`, `MatchMock`, `CommerceMock`, `SiteMock`, `BookingMock`.
|
||||
- `MOCK_REGISTRY: Record<MockKey, React.ComponentType>` 및 `type MockKey = 'dashboard'|'feed'|'match'|'commerce'|'site'|'booking'`.
|
||||
|
||||
**MockWindow 규약 (완전 코드):**
|
||||
```tsx
|
||||
// app/components/mock/MockWindow.tsx
|
||||
interface MockWindowProps {
|
||||
title: string; // 타이틀바 텍스트 (예: 'stock-report', 'realestate-match')
|
||||
children: React.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 px-3.5 py-2.5 border-b"
|
||||
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: '#e2e8f0' }} />
|
||||
<span className="h-2.5 w-2.5 rounded-full" style={{ background: '#e2e8f0' }} />
|
||||
<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>
|
||||
</div>
|
||||
{/* 본문 */}
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**스크린 목업 시각 명세** (`screens.tsx` — 각 컴포넌트가 그릴 요소; 전부 `--jsm-*`, SVG/div, 실데이터 0):
|
||||
- `DashboardMock` — 상단 스탯 3칸(라벨+숫자, 1칸 accent 강조) + 막대 차트(div 높이 배열) 1개. "주식 리포트" 톤.
|
||||
- `FeedMock` — 메시지 버블 3~4개(좌측 정렬, 시각·텍스트·체결/알림 배지). "텔레그램 봇" 톤.
|
||||
- `MatchMock` — 리스트 행 3개(항목명 + 매칭률 배지 `92%` accent-soft) + 상단 필터칩. "부동산 청약" 톤.
|
||||
- `CommerceMock` — 상품 카드 그리드 4(썸네일 박스 + 가격) + 장바구니 바.
|
||||
- `SiteMock` — 기업 사이트 와이어(네비 바 + 큰 헤드라인 라인 2 + CTA 버튼 + 카드 3). "corporate/portfolio".
|
||||
- `BookingMock` — 주간 캘린더 헤더(요일 7) + 슬롯 그리드(일부 accent 채움) + 예약 버튼.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1:** `MockWindow.tsx` 작성 (위 완전 코드).
|
||||
- [ ] **Step 2:** `screens.tsx`에 6개 스크린 컴포넌트 작성 (위 시각 명세 따름, 각 `<div className="space-y-3">...` 라이트 마크업).
|
||||
- [ ] **Step 3:** `registry.ts` 작성 — `MockKey` 타입 + `MOCK_REGISTRY` 매핑 export.
|
||||
- [ ] **Step 4:** 빌드 검증. Run: `npm run build` — Expected: 성공(타입 에러 0).
|
||||
- [ ] **Step 5:** 커밋. `git add app/components/mock && git commit -m "feat(redesign): MockWindow 라이트 목업 시스템(프레임+6스크린+레지스트리)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 쇼케이스 라이트 전환
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/showcase.ts` (슬롯 타입을 mock 기반으로 교체)
|
||||
- Modify: `app/components/deepfield/ShowcaseCard.tsx` (그래디언트/캔버스 → MockWindow 라이트 카드 재작성)
|
||||
- Keep: `app/components/deepfield/ShowcaseGrid.tsx` (레이아웃 로직 유지, 카드만 교체)
|
||||
- Test: `lib/__tests__/showcase.test.ts` (신규 — 가드레일 데이터 테스트)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1의 `MockKey`, `MOCK_REGISTRY`.
|
||||
- Produces: `ShowcaseSlot { slug; label; title; desc; mock: MockKey; href? }` (palette/accent 제거). `SHOWCASE_SLOTS: ShowcaseSlot[]` (8슬롯, 보라 0).
|
||||
|
||||
**신규 슬롯 매핑** (보라 제거, mock 배정):
|
||||
```
|
||||
corporate → site | commerce → commerce | dashboard → dashboard | bakery → booking
|
||||
portfolio → site | game → site | interior → site | reading → site
|
||||
```
|
||||
> 메모: site 목업이 다수 → 시각 단조 방지 위해 `SiteMock`에 variant prop(헤드라인 색/레이아웃 미세 차이) 추가 가능(선택). 1차는 단일 SiteMock로 진행, Task 7 검증 시 단조하면 variant 보강.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1 (테스트 먼저):** `lib/__tests__/showcase.test.ts` 작성 — 각 슬롯이 (a) `mock`이 유효한 MockKey, (b) `slug/title/desc` 비어있지 않음, (c) 어떤 필드에도 보라 hex(`#c4b5fd`,`#f0abfc`,`#341a4f`,`#4a1342`) 부재. (palette 필드 자체가 사라지므로 타입+값 검증.)
|
||||
- [ ] **Step 2:** Run `npm test` — Expected: FAIL (showcase 타입에 mock 없음 / palette 잔존).
|
||||
- [ ] **Step 3:** `lib/showcase.ts` 인터페이스·데이터를 mock 기반으로 교체.
|
||||
- [ ] **Step 4:** `ShowcaseCard.tsx` 재작성 — 카드 = `MockWindow`(상단) + 하단 텍스트(eyebrow label·title·desc, href면 "데모 보기"). 캔버스/시드/그래디언트/보라 전량 제거. 라이트 카드. `'use client'` 불필요면 서버 컴포넌트로.
|
||||
- [ ] **Step 5:** Run `npm test` — Expected: PASS. 이어서 `npm run build` — Expected: 성공.
|
||||
- [ ] **Step 6:** 커밋. `git commit -am "feat(redesign): 쇼케이스 그래디언트 타일 → 라이트 MockWindow 카드 + 가드레일 테스트"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: TopNav 라이트 단일화
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/TopNav.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음. Produces: 단일 라이트 네비(전 라우트 동일).
|
||||
|
||||
**변경:**
|
||||
- `DARK_ROUTES`/`isDark` 분기 + 다크 팔레트 헬퍼(`ink/inkSoft/surface/line/accent/accentBg`의 isDark 삼항) 전량 제거 → 라이트 고정값.
|
||||
- 최상단(미스크롤): 배경 transparent 유지(라이트 히어로 위 dark ink 텍스트로 가독) / 스크롤 시: `--jsm-surface` + `--jsm-line` border + 미세 shadow.
|
||||
- 모바일 드로어 `surface` = `--jsm-surface` 고정.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1:** `isDark` 및 다크 분기 제거, 팔레트를 라이트 토큰 고정으로 치환.
|
||||
- [ ] **Step 2:** Run `npm run build` — Expected: 성공.
|
||||
- [ ] **Step 3:** 커밋. `git commit -am "feat(redesign): TopNav 다크 라우트 분기 제거 → 단일 라이트 네비"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 홈 라이트 재구성 (`app/page.tsx`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/page.tsx` (전면 재작성)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 `MockWindow`+스크린, Task 2 `ShowcaseGrid`/`SHOWCASE_SLOTS`, 기존 `getListedProducts`·`CountUp`·`ScrollReveal`.
|
||||
|
||||
**섹션 구조(배경 교차):**
|
||||
1. HERO (surface) — 비대칭 2단: 좌(eyebrow `OUTSOURCING · SOFTWARE` / h1 "생각을 / 동작하는 소프트웨어로." / sub / CTA 2개: filled accent `프로젝트 문의`→`/outsourcing#contact`, ghost `소프트웨어 보기`→`/products`) · 우(`MockWindow title="stock-report"` 안에 `DashboardMock`). `-mt-16`/스크림/HeroField 전량 제거. 하단 신뢰 스트립(15+ 실서비스 · 24/7 · 원스톱) border-y row.
|
||||
2. 2축 소개 (surface-alt) — `01 OUTSOURCING`/`02 SOFTWARE` 2카드(라벨·제목·요약·링크).
|
||||
3. SHOWCASE (surface) — `ShowcaseGrid slots variant="home"` (6).
|
||||
4. 운영 실증 (surface-alt) — PROOF 3카드 + 스탯(CountUp 15+/24·7/원스톱). 라이트 카드.
|
||||
5. PROCESS (surface) — 4단계 + 가로 연결선.
|
||||
6. 완성 SW (surface-alt) — featured 3(DB) / 0개 coming-soon 폴백, 라이트 카드.
|
||||
7. CTA 밴드 (navy 평면) — "프로젝트, 이야기부터 시작하세요" + 흰 버튼.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1:** 다크 래퍼/HeroField/스크림 제거, 위 7섹션을 라이트 토큰으로 재작성. 모든 `--jsm-dark-*`/`accent-bright` → 라이트 대응(`--jsm-ink`/`ink-soft`/`accent`).
|
||||
- [ ] **Step 2:** Run `npm run build` — Expected: 성공. (DB 0개 폴백 경로도 타입 통과 확인.)
|
||||
- [ ] **Step 3:** 커밋. `git commit -am "feat(redesign): 홈 라이트 재구성 + 2축 복원 + 히어로 목업"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 외주 라이트 전환 (`app/outsourcing/page.tsx` + 폼)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/outsourcing/page.tsx`
|
||||
- Modify: `app/components/OutsourcingRequestForm.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1·2 컴포넌트, 기존 `ScrollReveal`.
|
||||
|
||||
**변경:**
|
||||
- 페이지: 다크 래퍼/HeroField/스크림 제거. 섹션 구조 유지(HERO·SHOWCASE 8·운영 실사례 6·제공분야 6·PROCESS 6·FAQ·CONTACT)를 라이트 토큰으로. 앵커(`#showcase`/`#portfolio`/`#process`/`#contact`) 유지. HERO 우측에 소형 `MockWindow`(`FeedMock` 등) 1개 추가(선택, 2단 비대칭).
|
||||
- 폼: `INPUT_STYLE`·각 `--jsm-dark-*`/`accent-bright`/`rgba(96,165,250,..)` → 라이트(`--jsm-surface`/`--jsm-line`/`--jsm-ink`/`--jsm-accent`/`--jsm-accent-soft`). 래퍼 `className="jsm-dark-form"` 제거. 에러 박스(이미 라이트 `#fef2f2`)는 유지.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1:** `OutsourcingRequestForm.tsx`의 다크 토큰 전량 라이트 치환 + `jsm-dark-form` 제거.
|
||||
- [ ] **Step 2:** `outsourcing/page.tsx` 라이트 재작성(구조 유지).
|
||||
- [ ] **Step 3:** Run `npm run build` — Expected: 성공.
|
||||
- [ ] **Step 4:** 커밋. `git commit -am "feat(redesign): 외주 페이지 + 의뢰폼 라이트 전환"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 제품 craft 정렬 (`app/products/page.tsx`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/products/page.tsx`
|
||||
|
||||
**변경:** 이미 라이트 → `max-w-5xl`→`max-w-6xl`, 타입 스케일(h1 clamp·eyebrow·h2)·여백 리듬·카드(rounded-2xl·shadow-sm·hover) 를 홈과 동일 언어로 정렬. 교차 배경(surface↔surface-alt) 적용. 구조·카피 유지.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1:** 컨테이너·타입·카드 스펙을 공통 언어로 정렬.
|
||||
- [ ] **Step 2:** Run `npm run build` — Expected: 성공.
|
||||
- [ ] **Step 3:** 커밋. `git commit -am "feat(redesign): 제품 페이지 craft 정렬(공통 언어)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 죽은 CSS 제거 + 전체 검증 + 문서 정리
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/globals.css`
|
||||
- Modify: `CLAUDE.md` (다크 토큰 언급 정리 — 가드레일 본문 변경 없음)
|
||||
|
||||
**변경 (globals.css 제거 대상):** `--jsm-dark-*` 토큰, `--kx-*` 매핑, `.kx-section/.kx-display/.kx-label/.kx-folder/.kx-glass/.kx-glow/.kx-btn-*/.kx-gradient-text/.kx-orb`, `.gradient-text`(보라), `.jsm-dark-form`, `.df-scroll-dot` + `@keyframes df-scroll-cue`. **유지:** `--jsm-*` 라이트, `@font-face`, `.reveal*`, `.marquee*`(사용처 grep 후 미사용이면 제거), 스크롤바, `.scrollbar-hide`, `.service-card`.
|
||||
|
||||
**Steps:**
|
||||
- [ ] **Step 1:** `HeroField`/`useFieldMode` import 잔존 grep — Run: `grep -rn "HeroField\|useFieldMode\|jsm-dark\|--kx-\|gradient-text" app lib` — Expected: 코드(컴포넌트 파일 제외)에서 0건. 잔존 시 해당 파일 수정.
|
||||
- [ ] **Step 2:** `globals.css`에서 위 제거 대상 삭제.
|
||||
- [ ] **Step 3:** 가드레일 grep — Run: `grep -rn "jsm-dark\|--kx-\|#7c3aed\|#c4b5fd\|#f0abfc\|backdrop-filter\|blur(" app lib` — Expected: 0건(`globals.css` `.kx`/dark 제거 후).
|
||||
- [ ] **Step 4:** Run `npm test` — Expected: PASS. 이어서 `npm run build` — Expected: 성공.
|
||||
- [ ] **Step 5:** `CLAUDE.md` 디자인 시스템 섹션에서 다크 토큰 잔재 언급 정리(있다면).
|
||||
- [ ] **Step 6:** 커밋. `git commit -am "chore(redesign): 죽은 다크/kx/보라 CSS 제거 + 가드레일 검증 통과"`
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §3 시스템 기반 → Global Constraints + 각 Task. ✓
|
||||
- §4 MockWindow → Task 1. ✓
|
||||
- §5.1 홈 → Task 4. ✓ / §5.2 외주 → Task 5. ✓ / §5.3 제품 → Task 6. ✓
|
||||
- §6 셸(TopNav/Footer) → Task 3 (Footer는 이미 navy 유지, 변경 없음 명시). ✓
|
||||
- §7 정리 → Task 7. ✓
|
||||
- §9 검증 기준 → Task 2(테스트)·Task 7(grep/build/test). ✓
|
||||
|
||||
**Placeholder scan:** 페이지 JSX 전문 미기재는 의도(계획 altitude 주석). 스크린 목업은 시각 명세로 구체화. 빌딩블록(MockWindow)·테스트는 완전 코드. TBD 없음.
|
||||
|
||||
**Type consistency:** `MockKey`/`MOCK_REGISTRY`(Task1) → `ShowcaseSlot.mock`(Task2)에서 동일 사용. `ShowcaseGrid`의 `variant`/`size`(home|full / feature|standard) 기존 시그니처 유지. ✓
|
||||
476
docs/superpowers/plans/2026-07-02-phase0-cleanup.md
Normal file
476
docs/superpowers/plans/2026-07-02-phase0-cleanup.md
Normal file
@@ -0,0 +1,476 @@
|
||||
# Phase 0 정리·삭제 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 새 운영 비전(외주 메인 + 사주·타로·음악)에 없는 기능(eBay 세트·packages/subscription·PortOne)과 도달 불가능한 죽은 코드를 제거하고, DB 마이그레이션과 문서를 정합화한다.
|
||||
|
||||
**Architecture:** 삭제 전용 리팩토링. 6개 삭제 그룹을 독립 커밋으로 진행하고, 각 커밋마다 `npm test` + `npm run build`로 회귀를 차단한다. IA(네비·홈)와 next.config.ts 리다이렉트는 건드리지 않는다.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router), TypeScript, Supabase, vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-02-saas-operation-refactor-phase0-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- next.config.ts의 redirects()는 **한 줄도 수정 금지** (외부 URL 호환)
|
||||
- `app/work/website/samples/**` 8종, `app/work/layout.tsx`, `app/work/website/layout.tsx`는 **삭제 금지** (Phase 1 자산 + 경로 세그먼트 유지)
|
||||
- gyeol 세트(`/gyeol`, `/api/survey`, `admin/survey`, `survey_responses` 테이블)는 **삭제 금지** (CEO 의도적 보존)
|
||||
- `app/api/projects/**`, telegram 3종(`webhook`·`connect`·`setup`), `lib/telegram.ts`는 **삭제 금지** (Phase 1~3 재활용)
|
||||
- 기존 마이그레이션 파일은 이력이므로 삭제·수정 금지, 신규 파일만 추가
|
||||
- 각 Task 종료 시 `npm test` 전체 통과 + `npm run build` 성공 후 커밋
|
||||
- 커밋 메시지 끝에 다음 트레일러 포함:
|
||||
`Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: eBay 세트 삭제
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/api/questionnaire/` (submit/route.ts 포함 디렉토리)
|
||||
- Delete: `app/admin/questionnaire/page.tsx` (디렉토리째)
|
||||
- Delete: `app/api/admin/questionnaire/` (route.ts + [id]/route.ts 디렉토리)
|
||||
- Delete: `app/admin/documents/page.tsx` (디렉토리째)
|
||||
- Delete: `app/api/admin/documents/` ([filename]/route.ts 디렉토리)
|
||||
- Delete: `lib/ebay-tools/` (crawler.ts·pricing.ts·ai-analyzer.ts·types.ts)
|
||||
- Delete: `CONTENT/ebay-tool-questionnaire.html`, `CONTENT/ebay-tool-proposal.html`, `CONTENT/ARCHITECTURE_EBAY_PARTS_TOOL.md`
|
||||
- Modify: `app/admin/components/AdminSidebar.tsx` (NAV_ITEMS 2항목 제거)
|
||||
- Modify: `package.json` (`cheerio` 제거 — 유일 소비처가 lib/ebay-tools/crawler.ts:1)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음 (독립 삭제)
|
||||
- Produces: 없음. 이후 Task는 이 파일들이 없다고 가정
|
||||
|
||||
- [ ] **Step 1: 파일 삭제**
|
||||
|
||||
```bash
|
||||
git rm -r app/api/questionnaire app/admin/questionnaire app/api/admin/questionnaire \
|
||||
app/admin/documents app/api/admin/documents lib/ebay-tools \
|
||||
CONTENT/ebay-tool-questionnaire.html CONTENT/ebay-tool-proposal.html CONTENT/ARCHITECTURE_EBAY_PARTS_TOOL.md
|
||||
```
|
||||
|
||||
- [ ] **Step 2: AdminSidebar에서 메뉴 2개 제거**
|
||||
|
||||
`app/admin/components/AdminSidebar.tsx`의 NAV_ITEMS 배열에서 다음 두 객체를 통째로 제거 (href 기준으로 식별, 각 객체는 `{ href, label, icon }` 형태로 svg 포함 약 20줄):
|
||||
- `href: '/admin/documents'` (label '프로젝트 문서', 약 79~100행)
|
||||
- `href: '/admin/questionnaire'` (label '질문지 응답', 약 101~122행)
|
||||
|
||||
- [ ] **Step 3: cheerio 의존성 제거**
|
||||
|
||||
```bash
|
||||
npm uninstall cheerio
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 잔존 참조 확인 후 테스트·빌드**
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.ts' --include='*.tsx' -iE 'ebay|questionnaire' app lib
|
||||
# 기대: 0건
|
||||
npm test # 기대: 전체 PASS
|
||||
npm run build # 기대: 빌드 성공
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(phase0): eBay 세트 제거 — 문진·문서 admin/API/lib/CONTENT + cheerio"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: packages + subscription 삭제
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/packages/` (page.tsx + layout.tsx)
|
||||
- Delete: `lib/saas-catalog.ts`
|
||||
- Delete: `app/api/subscription/` (route.ts + [id]/route.ts)
|
||||
- Delete: `app/api/cron/subscription-expiry/` (route.ts — cron 디렉토리에 다른 항목 없으면 `app/api/cron/`째)
|
||||
- Delete: `vercel.json` (내용이 subscription cron 하나뿐)
|
||||
- Modify: `lib/service-visibility.ts:6` (HideableService 타입에서 `'packages'` 제거)
|
||||
- Modify: `app/api/admin/services/route.ts:57` (DEFAULT_SERVICES에서 packages 행 제거)
|
||||
- Modify: `app/api/admin/stats/route.ts:18-31,52` (subscriptions 집계 제거)
|
||||
- Modify: `app/admin/dashboard/page.tsx:10,160-171` (activeSubscribers 제거)
|
||||
- Modify: `app/api/admin/members/route.ts:30-37` (subsRes/activeSub 제거)
|
||||
- Modify: `app/admin/members/page.tsx:12,15,94-101,127-131,154-158` (activeSub UI 제거)
|
||||
- Modify: `app/work/saju/result/page.tsx:113-136` (subscriptions 쿼리 제거, orders 단일화)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: `/api/admin/stats` 응답에서 `activeSubscribers` 필드 소멸, `/api/admin/members` 응답에서 `activeSub` 필드 소멸. 이 두 API의 프론트 소비처 수정까지 이 Task에 포함
|
||||
|
||||
- [ ] **Step 1: 파일 삭제**
|
||||
|
||||
```bash
|
||||
git rm -r app/packages lib/saas-catalog.ts app/api/subscription app/api/cron vercel.json
|
||||
```
|
||||
(사전 확인: `ls app/api/cron` 결과가 `subscription-expiry`뿐이면 cron째 삭제, 아니면 subscription-expiry만)
|
||||
|
||||
- [ ] **Step 2: HideableService 타입에서 packages 제거**
|
||||
|
||||
`lib/service-visibility.ts`:
|
||||
```typescript
|
||||
// 변경 전
|
||||
export type HideableService = 'saju' | 'music' | 'gyeol' | 'packages' | 'lotto';
|
||||
// 변경 후
|
||||
export type HideableService = 'saju' | 'music' | 'gyeol' | 'lotto';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: DEFAULT_SERVICES에서 packages 행 제거**
|
||||
|
||||
`app/api/admin/services/route.ts`에서 다음 한 줄 삭제:
|
||||
```typescript
|
||||
{ id: 'packages', name: 'SaaS 제품 허브(구)', description: '구 /packages 페이지', is_active: false, order_index: 104 },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: admin/stats에서 구독 집계 제거**
|
||||
|
||||
`app/api/admin/stats/route.ts`:
|
||||
```typescript
|
||||
// 변경 전 (18행, 24행, 31행, 52행)
|
||||
const [profilesRes, ordersRes, paymentsRes, contactsRes, monthlyRes, subsRes] = await Promise.all([
|
||||
...
|
||||
supabase.from('subscriptions').select('id', { count: 'exact', head: true }).eq('status', 'active'),
|
||||
]);
|
||||
const activeSubscribers = subsRes.count ?? 0;
|
||||
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, activeSubscribers, monthlyChart });
|
||||
|
||||
// 변경 후
|
||||
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 }),
|
||||
]);
|
||||
// activeSubscribers 계산 라인 삭제
|
||||
return NextResponse.json({ totalMembers, totalOrders, totalRevenue, pendingContacts, monthlyChart });
|
||||
```
|
||||
|
||||
- [ ] **Step 5: admin/dashboard에서 활성 구독자 카드 제거**
|
||||
|
||||
`app/admin/dashboard/page.tsx`:
|
||||
- 인터페이스에서 `activeSubscribers: number;` 필드 삭제 (10행)
|
||||
- `label="활성 구독자"`인 `<StatCard ... />` 블록(약 160~171행, svg 포함) 통째로 삭제
|
||||
|
||||
- [ ] **Step 6: admin/members API에서 구독 조회 제거**
|
||||
|
||||
`app/api/admin/members/route.ts`:
|
||||
```typescript
|
||||
// 변경 전 (30~37행)
|
||||
const [ordersRes, paymentsRes, subsRes] = 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 };
|
||||
|
||||
// 변경 후
|
||||
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'),
|
||||
]);
|
||||
const totalPaid = (paymentsRes.data ?? []).reduce((s: number, x: { amount: number }) => s + x.amount, 0);
|
||||
return { ...p, orderCount: ordersRes.count ?? 0, totalPaid };
|
||||
```
|
||||
|
||||
- [ ] **Step 7: admin/members 페이지에서 activeSub UI 제거**
|
||||
|
||||
`app/admin/members/page.tsx`에서:
|
||||
- Member 인터페이스의 `activeSub: { product_id: string; status: string; expires_at: string } | null;` 필드 삭제 (12행)
|
||||
- `PLAN_LABELS` 상수 삭제 (15행~)
|
||||
- 테이블의 구독 셀 블록 삭제 (94~101행: `{m.activeSub ? (...) : (<span ...>-</span>)}` — 해당 `<td>`와 대응하는 `<th>` 헤더도 함께)
|
||||
- 모바일 카드의 구독 뱃지 블록 삭제 (127~131행: `{m.activeSub && (<span ...>...)}`)
|
||||
- 구독 만료 문구 블록 삭제 (154~158행: `{m.activeSub && (<p ...>구독 만료: ...</p>)}`)
|
||||
|
||||
- [ ] **Step 8: saju result에서 subscriptions 쿼리 제거 (orders 단일화)**
|
||||
|
||||
`app/work/saju/result/page.tsx`의 113~136행을 다음으로 교체:
|
||||
```typescript
|
||||
// 로또 이용권 확인 — orders 테이블 (최근 31일 paid 주문)
|
||||
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const { data: lottoOrder } = await supabase
|
||||
.from('orders')
|
||||
.select('id, created_at')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'paid')
|
||||
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
|
||||
.gte('created_at', thirtyOneDaysAgo)
|
||||
.maybeSingle();
|
||||
hasLottoSubscription = !!lottoOrder;
|
||||
```
|
||||
(`hasLottoSubscription` 변수명·`SajuFortuneSection` prop은 유지 — 시맨틱은 Phase 2에서 재정의)
|
||||
|
||||
- [ ] **Step 9: 잔존 참조 확인 후 테스트·빌드**
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.ts' --include='*.tsx' -E "saas-catalog|from\('subscriptions'\)|'packages'|activeSubscribers|activeSub\b" app lib
|
||||
# 기대: 0건
|
||||
npm test && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 10: 커밋**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(phase0): packages·subscription 제거 — 페이지/API/cron/vercel.json + 파급(stats·members·saju) 수정"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: PortOne 결제 잔재 삭제
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/components/PaymentButton.tsx`
|
||||
- Delete: `app/payment/` (test·fail·success 3페이지)
|
||||
- Delete: `app/api/payment/` (confirm/route.ts)
|
||||
- Delete: `lib/payment-channels.ts`, `lib/products.ts`
|
||||
- Modify: `app/work/saju/page.tsx:5` (미사용 import 삭제)
|
||||
- Modify: `app/work/saju/result/SajuAISection.tsx:6,316-322` (PaymentButton → 안내 문구)
|
||||
- Modify: `package.json` (`@portone/browser-sdk` 제거)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: `PaymentButton` 컴포넌트 소멸 — 이후 어떤 Task/Phase도 import 불가. 결제는 `BankTransferModal`(계좌이체) 단일 경로
|
||||
|
||||
- [ ] **Step 1: 파일 삭제**
|
||||
|
||||
```bash
|
||||
git rm -r app/components/PaymentButton.tsx app/payment app/api/payment lib/payment-channels.ts lib/products.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: saju 페이지의 미사용 import 삭제**
|
||||
|
||||
`app/work/saju/page.tsx` 5행 삭제 (렌더 사용처 없음 확인됨):
|
||||
```typescript
|
||||
import PaymentButton from '@/app/components/PaymentButton';
|
||||
```
|
||||
|
||||
- [ ] **Step 3: SajuAISection의 결제 버튼을 안내 문구로 교체**
|
||||
|
||||
`app/work/saju/result/SajuAISection.tsx`:
|
||||
- 6행 import 삭제: `import PaymentButton from '@/app/components/PaymentButton';`
|
||||
- 316~322행을 다음으로 교체:
|
||||
```tsx
|
||||
<p className="inline-flex items-center gap-2 bg-white/10 text-blue-100/80 font-semibold px-7 py-3 rounded-xl">
|
||||
AI 상세 해석은 서비스 개편 준비 중입니다
|
||||
</p>
|
||||
<p className="text-blue-200/40 text-xs mt-3">사주 서비스 개편(Phase 2)에서 무료 제공 예정</p>
|
||||
```
|
||||
(`hasPaid` 게이트 로직은 유지 — orders 테이블 기반이라 그대로 컴파일됨. 무료화 UX는 Phase 2)
|
||||
|
||||
- [ ] **Step 4: SDK 의존성 제거**
|
||||
|
||||
```bash
|
||||
npm uninstall @portone/browser-sdk
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 잔존 참조 확인 후 테스트·빌드**
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.ts' --include='*.tsx' -iE 'portone|PaymentButton|payment-channels|@/lib/products' app lib
|
||||
# 기대: 0건
|
||||
npm test && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 커밋**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(phase0): PortOne 잔재 제거 — 계좌이체 단일 소스 확정, saju 결제 CTA 제거"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 죽은 페이지 4종 + 전이 고아 삭제
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/work/page.tsx` (`/work`→`/outsourcing` 리다이렉트에 가려짐. `app/work/layout.tsx`는 유지)
|
||||
- Delete: `app/work/freelance/` (page.tsx + layout.tsx — 디렉토리째)
|
||||
- Delete: `app/work/website/page.tsx` (`app/work/website/layout.tsx`와 `samples/`는 유지)
|
||||
- Delete: `app/music/packs/` (page.tsx + layout.tsx — 디렉토리째)
|
||||
- Delete: `app/components/ContactForm.tsx` (유일 소비처가 죽은 `/work/freelance`)
|
||||
- Delete: `lib/freelance-portfolio.ts` (소비처가 죽은 `/work`·`/work/freelance`뿐)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음. `/work`, `/work/freelance`, `/work/website`, `/music/packs` URL은 리다이렉트가 계속 처리
|
||||
|
||||
- [ ] **Step 1: 파일 삭제**
|
||||
|
||||
```bash
|
||||
git rm app/work/page.tsx app/work/website/page.tsx
|
||||
git rm -r app/work/freelance app/music/packs
|
||||
git rm app/components/ContactForm.tsx lib/freelance-portfolio.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 잔존 참조 확인 후 테스트·빌드**
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.ts' --include='*.tsx' -E "ContactForm|freelance-portfolio" app lib
|
||||
# 기대: 0건
|
||||
npm test && npm run build
|
||||
# 빌드 후 확인: /work/website/samples/* 8종이 라우트 목록에 존재해야 함
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(phase0): redirect에 가린 죽은 페이지 4종 + 전이 고아(ContactForm·freelance-portfolio) 제거"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: deepfield 잔재 + three 의존성 삭제
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/components/deepfield/HeroField.tsx` (import 0회)
|
||||
- Delete: `app/components/deepfield/useFieldMode.ts` (HeroField 전용)
|
||||
- Delete: `lib/deepfield-mode.ts`, `lib/__tests__/deepfield-mode.test.ts`
|
||||
- Modify: `package.json` (`three` 제거 — 유일 소비처가 HeroField.tsx:5,166)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음. `deepfield/{ScrollReveal,ShowcaseGrid,ShowcaseCard,CountUp}.tsx`는 활성이므로 유지
|
||||
|
||||
- [ ] **Step 1: 파일 삭제**
|
||||
|
||||
```bash
|
||||
git rm app/components/deepfield/HeroField.tsx app/components/deepfield/useFieldMode.ts \
|
||||
lib/deepfield-mode.ts lib/__tests__/deepfield-mode.test.ts
|
||||
```
|
||||
|
||||
- [ ] **Step 2: three 의존성 제거**
|
||||
|
||||
```bash
|
||||
npm uninstall three
|
||||
grep -rn "from 'three'" app lib # 기대: 0건
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 테스트·빌드**
|
||||
|
||||
```bash
|
||||
npm test # 기대: deepfield-mode.test 제외된 채 전체 PASS
|
||||
npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 커밋**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(phase0): deepfield 파티클 잔재 3파일 + three 의존성 제거"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 고아 API 삭제
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/api/track/[token]/route.ts` (추적 페이지가 Supabase 직접 조회 — `app/track/[token]/page.tsx:16` 주석 확인됨. **페이지는 유지**)
|
||||
- Delete: `app/api/saju/lotto/route.ts` (프론트 fetch 0회, 외부 saju-engine 전용)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음. `/track/[token]` 페이지 동작 불변
|
||||
|
||||
- [ ] **Step 1: 파일 삭제**
|
||||
|
||||
```bash
|
||||
git rm -r "app/api/track" "app/api/saju/lotto"
|
||||
```
|
||||
(주의: `app/api/saju/analyze`·`app/api/saju/save-interpretation`은 활성 — saju 디렉토리째 삭제 금지)
|
||||
|
||||
- [ ] **Step 2: 잔존 참조 확인 후 테스트·빌드**
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.ts' --include='*.tsx' -E "api/track|api/saju/lotto" app lib
|
||||
# 기대: 0건
|
||||
npm test && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(phase0): 고아 API 제거 — track/[token](페이지 직접조회로 대체됨)·saju/lotto"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: DB 마이그레이션 + CLAUDE.md 정합화 + 최종 스윕
|
||||
|
||||
**Files:**
|
||||
- Create: `supabase/migrations/2026-07-02-phase0-cleanup.sql`
|
||||
- Modify: `CLAUDE.md` (삭제된 기능 서술 제거)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1~6 완료 상태
|
||||
- Produces: DB 스키마와 코드의 정합. 마이그레이션은 클라우드+NAS 양쪽 수동 적용 항목으로 CEO에 안내
|
||||
|
||||
- [ ] **Step 1: 마이그레이션 파일 작성**
|
||||
|
||||
`supabase/migrations/2026-07-02-phase0-cleanup.sql`:
|
||||
```sql
|
||||
-- Phase 0 정리 (2026-07-02): 비전 제외 기능의 테이블·설정 제거
|
||||
-- 적용 대상: 클라우드 Supabase + NAS self-host 양쪽 (운영 규칙)
|
||||
-- survey_responses(gyeol)는 의도적 보존 — 건드리지 않음
|
||||
|
||||
DROP TABLE IF EXISTS questionnaire_responses;
|
||||
DROP TABLE IF EXISTS ebay_search_history;
|
||||
DROP TABLE IF EXISTS subscriptions;
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'packages';
|
||||
```
|
||||
|
||||
- [ ] **Step 2: CLAUDE.md 갱신**
|
||||
|
||||
`CLAUDE.md`에서:
|
||||
- 숨김 서비스 표에서 `/packages` 행 삭제
|
||||
- 파일 구조 트리에서 `payment/` 항목과 "PortOne 연동 (보존 전용, 미활성)" 서술 삭제
|
||||
- 결제 플로우 섹션의 "PG(PortOne) 코드는 `products.pay_method` 플래그 기반으로 보존만, 현재 미활성" 불릿 삭제
|
||||
- 운영 주의사항 등 나머지는 유지
|
||||
- 파일 구조·표 어디에도 questionnaire/documents/packages/subscription 서술이 남지 않도록 검색(`grep -n "PortOne\|packages\|questionnaire\|subscription" CLAUDE.md`) 후 정리
|
||||
|
||||
- [ ] **Step 3: 최종 잔존 참조 스윕**
|
||||
|
||||
```bash
|
||||
grep -rn --include='*.ts' --include='*.tsx' -iE \
|
||||
"portone|PaymentButton|payment-channels|saas-catalog|ebay|questionnaire|from\('subscriptions'\)|freelance-portfolio|HeroField|useFieldMode|deepfield-mode|from 'three'" \
|
||||
app lib scripts
|
||||
# 기대: 0건
|
||||
grep -n "cheerio\|three\|portone" package.json
|
||||
# 기대: 0건
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 전체 테스트·빌드**
|
||||
|
||||
```bash
|
||||
npm test # 기대: 전체 PASS
|
||||
npm run build # 기대: 빌드 성공
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add supabase/migrations/2026-07-02-phase0-cleanup.sql CLAUDE.md
|
||||
git commit -m "chore(phase0): DB 마이그레이션(DROP 3테이블+packages 행) + CLAUDE.md 정합화"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: CEO 안내 사항 정리 (구현 아님, 보고)**
|
||||
|
||||
- 마이그레이션 SQL을 **클라우드 Supabase + NAS self-host 양쪽**에 수동 적용 필요
|
||||
- Vercel 대시보드에서 기존 cron(subscription-expiry) 잔재 확인 (vercel.json 삭제로 다음 배포 시 자동 해제)
|
||||
- 배포는 별도 지시 시 진행
|
||||
|
||||
---
|
||||
|
||||
## 검증 요약 (전 Task 공통)
|
||||
|
||||
| 검증 | 명령 | 기대 |
|
||||
|------|------|------|
|
||||
| 단위 테스트 | `npm test` | product-access·request-status·showcase 등 전체 PASS |
|
||||
| 빌드 | `npm run build` | standalone 빌드 성공 |
|
||||
| 잔존 참조 | Task별 grep | 0건 |
|
||||
| 라우트 보존 | 빌드 출력 | `/work/website/samples/*` 8종, `/gyeol`, saju·music 라우트 존재 |
|
||||
481
docs/superpowers/plans/2026-07-02-phase1-outsourcing-core.md
Normal file
481
docs/superpowers/plans/2026-07-02-phase1-outsourcing-core.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# Phase 1 외주 코어 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 발주서 표면화(mypage·admin) + `/showcase` 제작 사례 허브 + admin 광고 관리 + packs 페이지 정리로 외주 코어를 정비한다.
|
||||
|
||||
**Architecture:** 8개 태스크, 서로 파일 비중첩. 신규 데이터 단일 소스는 `lib/showcase-samples.ts`(데모 메타)와 `ad_channels` 테이블(광고 채널). mypage는 기존 의뢰 카드를 유지한 채 상단에 발주·진행 섹션을 추가(정보 손실 없음).
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4 (`--jsm-*` 토큰), Supabase, vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-02-phase1-outsourcing-core-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 디자인 가드레일: gradient / blur / 보라(violet/purple) / 이모지 금지, `--jsm-*` 토큰만 (신규 공개 페이지)
|
||||
- 카피 가드레일: "대기업 N년차" 류 자격 어필 금지 — "실서비스 직접 운영" 실증 서술만
|
||||
- next.config.ts redirects() 수정 금지
|
||||
- `/api/admin/packs`·`/api/admin/packs/upload-url`은 삭제 금지 (products·mypage가 공유)
|
||||
- `app/work/website/samples/**` 데모 8종 수정 금지 (링크만 연결)
|
||||
- 기존 supabase/migrations/ 파일 삭제·수정 금지, 신규만 추가
|
||||
- 커밋은 스코프 파일만 스테이징 — **`git add -A`·`git commit -a` 금지**
|
||||
- 각 Task 종료 시 `npm test` 전체 통과 + `npm run build` 성공 후 커밋
|
||||
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 확인된 기존 계약 (구현 시 그대로 사용)
|
||||
|
||||
- `GET /api/projects` → `{ projects: [{ id, title, status, total, created_at, milestones: [{ quote_id, step_number, title, status: 'pending'|'in_progress'|'completed', ... }] }] }` — quotes.status ∈ sent/accepted/in_progress/completed/delivered 필터
|
||||
- `POST /api/projects/link` — body `{ token: string }` → 200 `{ success: true, quoteId, alreadyLinked? }` / 4xx·5xx `{ error: string }`
|
||||
- admin API 인증 패턴: `cookies()` → `admin_token` → `verifyAdminTokenNode(token)` 실패 시 401 → `createAdminClient()` (참고: `app/api/admin/services/route.ts`)
|
||||
- mypage requests 탭: `contact_requests` 기반 카드 리스트(변수명 `orders`), 탭 key `requests` — 라벨만 변경, 기존 카드 유지
|
||||
|
||||
---
|
||||
|
||||
### Task 1: showcase 데이터 모듈 (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/showcase-samples.ts`
|
||||
- Test: `lib/__tests__/showcase-samples.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `SHOWCASE_SAMPLES: ShowcaseSample[]`, `type ShowcaseSample = { slug: string; title: string; description: string; tags: string[] }` — Task 2가 import
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`lib/__tests__/showcase-samples.test.ts`:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SHOWCASE_SAMPLES } from '../showcase-samples';
|
||||
|
||||
const EXPECTED_SLUGS = [
|
||||
'bakery', 'corporate', 'dashboard', 'game',
|
||||
'interior', 'portfolio', 'reading', 'shopping',
|
||||
];
|
||||
|
||||
describe('SHOWCASE_SAMPLES', () => {
|
||||
it('데모 8종의 slug가 정확히 존재한다', () => {
|
||||
expect(SHOWCASE_SAMPLES.map((s) => s.slug).sort()).toEqual([...EXPECTED_SLUGS].sort());
|
||||
});
|
||||
|
||||
it('모든 항목에 title/description/tags가 채워져 있다', () => {
|
||||
for (const s of SHOWCASE_SAMPLES) {
|
||||
expect(s.title.length).toBeGreaterThan(0);
|
||||
expect(s.description.length).toBeGreaterThan(0);
|
||||
expect(s.tags.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('demo 경로는 /work/website/samples/[slug] 형식이다', () => {
|
||||
for (const s of SHOWCASE_SAMPLES) {
|
||||
expect(`/work/website/samples/${s.slug}`).toMatch(/^\/work\/website\/samples\/[a-z]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — Run: `npx vitest run lib/__tests__/showcase-samples.test.ts` / Expected: FAIL (모듈 없음)
|
||||
|
||||
- [ ] **Step 3: 구현**
|
||||
|
||||
`lib/showcase-samples.ts`:
|
||||
```typescript
|
||||
/** /showcase 제작 사례 허브의 데모 카드 단일 소스. 데모 실체는 app/work/website/samples/[slug]. */
|
||||
export type ShowcaseSample = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
};
|
||||
|
||||
export const SHOWCASE_SAMPLES: ShowcaseSample[] = [
|
||||
{ slug: 'corporate', title: '기업 홈페이지', description: 'IT 기업 소개·서비스·문의까지 담은 반응형 공식 홈페이지.', tags: ['기업', '반응형', 'SEO'] },
|
||||
{ slug: 'shopping', title: '쇼핑몰', description: '상품 목록·상세·장바구니 흐름을 갖춘 커머스 데모.', tags: ['커머스', '결제 흐름'] },
|
||||
{ slug: 'dashboard', title: 'SaaS 대시보드', description: '지표 카드·차트·테이블로 구성한 관리자 대시보드.', tags: ['대시보드', '차트'] },
|
||||
{ slug: 'bakery', title: '베이커리 브랜드', description: '메뉴·매장·브랜드 스토리를 담은 로컬 비즈니스 사이트.', tags: ['브랜드', '로컬'] },
|
||||
{ slug: 'interior', title: '인테리어 포트폴리오', description: '시공 사례 중심의 갤러리형 인테리어 회사 사이트.', tags: ['갤러리', '포트폴리오'] },
|
||||
{ slug: 'portfolio', title: '디자이너 포트폴리오', description: '작업물·경력·연락처를 담은 개인 포트폴리오.', tags: ['개인', '포트폴리오'] },
|
||||
{ slug: 'game', title: '게임 프로모션', description: '출시 게임을 소개하는 인터랙티브 프로모션 페이지.', tags: ['프로모션', '랜딩'] },
|
||||
{ slug: 'reading', title: '도서 콘텐츠', description: '책 소개·리뷰 중심의 콘텐츠 페이지.', tags: ['콘텐츠', '블로그'] },
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — Run: `npx vitest run lib/__tests__/showcase-samples.test.ts` / Expected: 3 tests PASS
|
||||
- [ ] **Step 5: 커밋** — `git add lib/showcase-samples.ts lib/__tests__/showcase-samples.test.ts && git commit -m "feat(phase1): showcase 데모 메타 단일 소스 + 무결성 테스트"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: /showcase 페이지 + TopNav + robots
|
||||
|
||||
**Files:**
|
||||
- Create: `app/showcase/page.tsx`
|
||||
- Modify: `app/components/TopNav.tsx:9-12` (NAV_LINKS에 항목 추가)
|
||||
- Modify: `app/robots.ts:9` (죽은 경로 3개 제거)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1의 `SHOWCASE_SAMPLES`, `ShowcaseSample`
|
||||
- Produces: 공개 라우트 `/showcase`
|
||||
|
||||
- [ ] **Step 1: /showcase 페이지 구현**
|
||||
|
||||
`app/showcase/page.tsx` — 서버 컴포넌트. 구조(스타일은 기존 `app/products/page.tsx`의 카드·섹션 패턴을 Read 후 동일 관용구로):
|
||||
```tsx
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { SHOWCASE_SAMPLES } from '@/lib/showcase-samples';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '제작 사례 | 쟁승메이드',
|
||||
description: '직접 설계·개발한 웹사이트 데모와 실서비스 운영 사례.',
|
||||
};
|
||||
|
||||
// 실운영 서비스(개인 NAS 실서비스 — 외부 링크 없음, 실증 서술만)
|
||||
const LIVE_SERVICES = [
|
||||
{ title: '로또 분석 랩', desc: '회차 수집·통계 분석·리포트 자동 생성까지 무인 운영' },
|
||||
{ title: '주식 자동매매 대시보드', desc: '시세 수집·스크리너·자동 주문을 하나의 콘솔로 운영' },
|
||||
{ title: 'AI 미디어 파이프라인', desc: '음악·영상·이미지 생성 워커를 큐 기반으로 상시 가동' },
|
||||
{ title: '여행 사진 갤러리', desc: '수천 장 사진의 지역 분류·썸네일·지도 탐색 자동화' },
|
||||
];
|
||||
|
||||
export default function ShowcasePage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Hero: h1 "제작 사례" + 부제 "실서비스를 직접 만들고 운영하며 검증한 방식 그대로 만듭니다." */}
|
||||
{/* 섹션 1: 웹사이트 데모 — SHOWCASE_SAMPLES.map 카드 그리드(md:grid-cols-2 lg:grid-cols-4)
|
||||
카드: title, description, tags(작은 pill), "데모 보기" 링크
|
||||
<Link href={`/work/website/samples/${s.slug}`} target="_blank" rel="noopener noreferrer"> */}
|
||||
{/* 섹션 2: 실서비스 운영 — LIVE_SERVICES 카드(링크 없음) + 하단 캡션
|
||||
"위 서비스들은 개인 인프라에서 상시 운영 중인 실제 서비스입니다." */}
|
||||
{/* CTA: "이런 걸 만들어 드립니다" → /outsourcing#contact 버튼(--jsm-accent) */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
주석 블록은 실제 JSX로 구현한다(플레이스홀더로 남기지 말 것). 색상은 `var(--jsm-*)` 토큰/기존 Tailwind 클래스 관용구만.
|
||||
|
||||
- [ ] **Step 2: TopNav 링크 추가**
|
||||
|
||||
`app/components/TopNav.tsx`의 NAV_LINKS 배열:
|
||||
```typescript
|
||||
const NAV_LINKS = [
|
||||
{ href: '/outsourcing', label: '외주 개발' },
|
||||
{ href: '/products', label: '소프트웨어' },
|
||||
{ href: '/showcase', label: '제작 사례' },
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 3: robots.ts 정리**
|
||||
|
||||
```typescript
|
||||
disallow: ['/admin/', '/api/', '/mypage/', '/portfolio/'],
|
||||
```
|
||||
(죽은 경로 `/payment/`·`/freelance`·`/services/website` 제거. `/showcase`는 allow '/'에 포함되므로 추가 불필요)
|
||||
|
||||
- [ ] **Step 4: 검증** — `npm test && npm run build` / Expected: PASS + 빌드 라우트에 `/showcase` 등장. 가드레일 grep: `grep -nE "gradient|violet|purple|blur" app/showcase/page.tsx` → 0건
|
||||
- [ ] **Step 5: 커밋** — `git add app/showcase/page.tsx app/components/TopNav.tsx app/robots.ts && git commit -m "feat(phase1): /showcase 제작 사례 허브 + TopNav 제작 사례 + robots 죽은 경로 정리"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: mypage 발주·진행 섹션
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/mypage/page.tsx` (탭 라벨 263행, requests 탭 렌더 518행~)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /api/projects`, `POST /api/projects/link` (위 "확인된 기존 계약")
|
||||
- Produces: 없음 (말단 UI)
|
||||
|
||||
- [ ] **Step 1: 상태·타입·페치 추가**
|
||||
|
||||
`app/mypage/page.tsx`에 (기존 state들 옆):
|
||||
```typescript
|
||||
type ProjectMilestone = { quote_id: string; step_number: number; title: string; status: 'pending' | 'in_progress' | 'completed' };
|
||||
type Project = { id: string; title: string; status: string; total: number; created_at: string; milestones: ProjectMilestone[] };
|
||||
|
||||
const QUOTE_STATUS_LABELS: Record<string, string> = {
|
||||
sent: '견적 발송', accepted: '발주 확정', in_progress: '진행중', completed: '완료', delivered: '납품 완료',
|
||||
};
|
||||
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [linkCode, setLinkCode] = useState('');
|
||||
const [linkMsg, setLinkMsg] = useState<string | null>(null);
|
||||
const [linking, setLinking] = useState(false);
|
||||
|
||||
const loadProjects = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (!res.ok) return;
|
||||
const d = await res.json();
|
||||
setProjects(d.projects ?? []);
|
||||
} catch { /* 미로그인/네트워크 — 무시 */ }
|
||||
}, []);
|
||||
// 기존 초기 로드 useEffect에 loadProjects() 추가
|
||||
|
||||
const handleLink = async () => {
|
||||
if (!linkCode.trim() || linking) return;
|
||||
setLinking(true); setLinkMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/projects/link', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: linkCode.trim() }),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (!res.ok) { setLinkMsg(d.error ?? '연결에 실패했습니다.'); return; }
|
||||
setLinkMsg(d.alreadyLinked ? '이미 연결된 견적서입니다.' : '견적서가 연결되었습니다.');
|
||||
setLinkCode('');
|
||||
await loadProjects();
|
||||
} catch { setLinkMsg('연결에 실패했습니다. 다시 시도해주세요.'); }
|
||||
finally { setLinking(false); }
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 탭 라벨 변경** — 263행: `label: '내 의뢰'` → `label: '발주·진행'` (key `requests` 유지)
|
||||
|
||||
- [ ] **Step 3: requests 탭 상단에 발주·진행 섹션 추가**
|
||||
|
||||
`{tab === 'requests' && (` 블록 최상단(기존 의뢰 카드 리스트는 그대로 아래 유지):
|
||||
```tsx
|
||||
{/* 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환) */}
|
||||
<section className="mb-8">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-base font-bold">발주·진행</h2>
|
||||
{/* 견적코드 연결: 접이식 — input + 연결 버튼 + linkMsg 표시 */}
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">진행 중인 발주가 없습니다. 견적서 코드를 입력해 연결하거나 새로 의뢰해 보세요.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{projects.map((p) => (
|
||||
<div key={p.id} className="rounded-lg border border-[var(--jsm-line)] bg-[var(--jsm-surface)] p-4">
|
||||
{/* 헤더: p.title + 상태 뱃지(QUOTE_STATUS_LABELS[p.status] ?? p.status)
|
||||
+ accepted/in_progress/completed/delivered면 "발주서" 뱃지 병기 */}
|
||||
{/* 총액: p.total.toLocaleString('ko-KR') + '원' */}
|
||||
{/* 마일스톤 타임라인: p.milestones step_number 순.
|
||||
completed=accent 채움, in_progress=accent 테두리, pending=회색.
|
||||
각 스텝: 번호 원 + title */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
```
|
||||
주석 블록은 실제 JSX로 구현. 견적코드 폼: `<input value={linkCode} ...>` + `<button onClick={handleLink} disabled={linking}>연결</button>` + `{linkMsg && <p ...>{linkMsg}</p>}`. 스타일은 파일 내 기존 폼·뱃지 관용구를 따른다.
|
||||
|
||||
- [ ] **Step 4: 검증** — `npm test && npm run build` PASS. 수동 확인 항목(보고서에 기재): 로그인 후 /mypage?tab=requests에서 발주 섹션·견적코드 폼 렌더
|
||||
- [ ] **Step 5: 커밋** — `git add app/mypage/page.tsx && git commit -m "feat(phase1): mypage 발주·진행 섹션 — projects API 배선 + 견적코드 연결"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: admin/quotes 발주 뱃지 + 상태 확장
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/admin/quotes/page.tsx:12,19` (status 타입·STATUS 맵)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: quotes.status 값 집합 sent/accepted/in_progress/completed/delivered (+draft/rejected)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 타입·STATUS 맵 확장**
|
||||
|
||||
12행 타입에 `'in_progress' | 'completed' | 'delivered'` 추가. 19행 STATUS 맵(기존 draft/sent/accepted/rejected 스타일 관용구 유지)에:
|
||||
```typescript
|
||||
in_progress: { label: '진행중 · 발주', ... },
|
||||
completed: { label: '완료 · 발주', ... },
|
||||
delivered: { label: '납품 완료 · 발주', ... },
|
||||
```
|
||||
그리고 기존 `accepted` 라벨을 `'수락 · 발주'`로 변경. 색상 값은 파일 내 기존 STATUS 항목의 색 체계에서 선택(초록 계열=진행/완료, 기존 관용구 확인 후).
|
||||
|
||||
- [ ] **Step 2: 검증** — `npm test && npm run build` PASS
|
||||
- [ ] **Step 3: 커밋** — `git add app/admin/quotes/page.tsx && git commit -m "feat(phase1): admin 견적 리스트 발주 뱃지 + 진행 상태 라벨 확장"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: ad_channels 마이그레이션 + CRUD API
|
||||
|
||||
**Files:**
|
||||
- Create: `supabase/migrations/2026-07-02-phase1-ad-channels.sql`
|
||||
- Create: `app/api/admin/ad-channels/route.ts`
|
||||
- Create: `app/api/admin/ad-channels/[id]/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `GET /api/admin/ad-channels` → `{ channels: AdChannel[] }`, `POST` body `{ name, url?, memo? }` → `{ channel }`, `PATCH /api/admin/ad-channels/[id]` body `{ name?, url?, status?, memo? }` → `{ success: true }`, `DELETE` → `{ success: true }`. `AdChannel = { id: string; name: string; url: string | null; status: 'active'|'paused'; memo: string | null; created_at: string; updated_at: string }` — Task 6이 소비
|
||||
|
||||
- [ ] **Step 1: 마이그레이션 파일** — 스펙 §WS3의 SQL 그대로:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ad_channels (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
url text,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused')),
|
||||
memo text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE ad_channels ENABLE ROW LEVEL SECURITY;
|
||||
-- service_role(관리자 API)만 접근 — 별도 policy 없음(기본 거부)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 목록/생성 API**
|
||||
|
||||
`app/api/admin/ad-channels/route.ts` (인증 패턴은 `app/api/admin/services/route.ts`를 Read 후 동일하게):
|
||||
```typescript
|
||||
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 requireAdmin() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
if (!token || !verifyAdminTokenNode(token)) return null;
|
||||
return createAdminClient();
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await requireAdmin();
|
||||
if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
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) {
|
||||
const supabase = await requireAdmin();
|
||||
if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const body = await request.json();
|
||||
const name = (body.name as string | undefined)?.trim();
|
||||
if (!name) return NextResponse.json({ error: '채널명을 입력해주세요.' }, { status: 400 });
|
||||
const { data, error } = await supabase
|
||||
.from('ad_channels')
|
||||
.insert({ name, url: body.url?.trim() || null, memo: body.memo?.trim() || null })
|
||||
.select().single();
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ channel: data });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 수정/삭제 API**
|
||||
|
||||
`app/api/admin/ad-channels/[id]/route.ts`:
|
||||
```typescript
|
||||
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 requireAdmin() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
if (!token || !verifyAdminTokenNode(token)) return null;
|
||||
return createAdminClient();
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const supabase = await requireAdmin();
|
||||
if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
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 = body.url?.trim() || null;
|
||||
if ('memo' in body) patch.memo = body.memo?.trim() || null;
|
||||
if (body.status === 'active' || body.status === 'paused') patch.status = body.status;
|
||||
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 }> }) {
|
||||
const supabase = await requireAdmin();
|
||||
if (!supabase) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
const { id } = await params;
|
||||
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 });
|
||||
}
|
||||
```
|
||||
(주의: Next 16에서 route context의 `params`는 Promise — 기존 `app/api/admin/quotes/[id]/route.ts`의 시그니처를 Read 후 동일 관용구로 맞출 것)
|
||||
|
||||
- [ ] **Step 4: 검증** — `npm test && npm run build` PASS (라우트 2개 빌드 등장)
|
||||
- [ ] **Step 5: 커밋** — `git add supabase/migrations/2026-07-02-phase1-ad-channels.sql app/api/admin/ad-channels && git commit -m "feat(phase1): ad_channels 테이블 + admin CRUD API"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: admin 광고 관리 페이지 재편 + 사이드바
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/admin/marketing/page.tsx` (2탭 재구성 — 기존 에셋 UI는 탭 안으로 이동)
|
||||
- Modify: `app/admin/components/AdminSidebar.tsx:90-91` (`마케팅 에셋` → `광고 관리`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 5의 ad-channels API 계약 (`AdChannel` 타입 포함)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 페이지 2탭 재구성**
|
||||
|
||||
`app/admin/marketing/page.tsx`: 파일 상단에 `type AdminTab = 'channels' | 'assets'` state 추가, 페이지 타이틀 `광고 관리`. 기존 에셋 렌더 전체(통계 카드·그리드·모달)를 `{tab === 'assets' && (...)}`로 감싸고, `channels` 탭(기본값)에 채널 CRUD UI 구현:
|
||||
```tsx
|
||||
// channels 탭 구성:
|
||||
// - 상단: 신규 채널 추가 폼 (name 필수, url, memo) → POST /api/admin/ad-channels
|
||||
// - 테이블: 채널명 | URL(외부 링크, 없으면 '-') | 상태 토글(active↔paused, PATCH) | 메모(인라인 편집 또는 표시) | 등록일 | 삭제 버튼(confirm 후 DELETE)
|
||||
// - fetch는 useEffect 초기 1회 GET + 각 뮤테이션 후 재조회
|
||||
// - 에러는 상단 배너 텍스트로 표시
|
||||
```
|
||||
주석은 실제 구현으로. 스타일은 기존 admin 페이지들(orders/products)의 테이블·버튼 관용구를 Read 후 동일하게.
|
||||
|
||||
- [ ] **Step 2: 사이드바 라벨** — `label: '마케팅 에셋'` → `label: '광고 관리'` (href 유지)
|
||||
- [ ] **Step 3: 검증** — `npm test && npm run build` PASS
|
||||
- [ ] **Step 4: 커밋** — `git add app/admin/marketing/page.tsx app/admin/components/AdminSidebar.tsx && git commit -m "feat(phase1): admin 광고 관리 — 채널·캠페인 CRUD 탭 + 에셋 탭 재편"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: admin/packs 페이지 제거
|
||||
|
||||
**Files:**
|
||||
- Delete: `app/admin/packs/page.tsx` (디렉토리째)
|
||||
- Modify: `app/admin/components/AdminSidebar.tsx:80-81` (`팩 자료` 메뉴 항목 객체 제거)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음. `/api/admin/packs*`는 유지(products·mypage 공유 — Global Constraints)
|
||||
|
||||
- [ ] **Step 1: 삭제** — `git rm -r app/admin/packs` + AdminSidebar에서 href `/admin/packs` 객체(svg 포함) 제거
|
||||
- [ ] **Step 2: 잔존 참조 확인** — `grep -rn "admin/packs" app lib` → 허용되는 매치: `app/admin/products/page.tsx`·`app/mypage`의 `/api/admin/packs`(API 호출)뿐. 페이지 라우트 `/admin/packs` href 참조 0건
|
||||
- [ ] **Step 3: 검증** — `npm test && npm run build` PASS
|
||||
- [ ] **Step 4: 커밋** — `git add -u app/admin && git commit -m "chore(phase1): admin/packs 레거시 페이지 제거 (API는 products·mypage 공유로 유지)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: CLAUDE.md 갱신 + 이메일 경로 점검 + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md` (IA 표·admin 서술)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1~7 완료 상태
|
||||
- Produces: 문서 정합 + 검증 보고
|
||||
|
||||
- [ ] **Step 1: CLAUDE.md 갱신**
|
||||
- 핵심 IA 표에 `| /showcase | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |` 추가
|
||||
- `/mypage` 행: `4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역`으로 갱신
|
||||
- admin 서술: `packs` 제거, `marketing`→`광고 관리(채널 CRUD + 에셋)` 반영
|
||||
- 파일 구조 트리에 `showcase/page.tsx`, `api/admin/ad-channels/` 추가
|
||||
|
||||
- [ ] **Step 2: 이메일 경로 점검 (변경 없음, 검증만)**
|
||||
`lib/request-emails.ts`·`lib/order-emails.ts`의 export 함수들이 각각 `app/api/contact/route.ts`·`app/api/admin/quotes/[id]/send/route.ts`·`app/api/quote/[token]/route.ts`·`app/api/orders/route.ts`·`app/api/admin/orders/route.ts`에서 여전히 import·호출되는지 grep으로 확인하고 결과를 보고서에 기재 (Phase 0 삭제가 메일 경로를 건드리지 않았음을 실증)
|
||||
|
||||
- [ ] **Step 3: 최종 검증**
|
||||
```bash
|
||||
npm test # showcase-samples 테스트 포함 전체 PASS
|
||||
npm run build # /showcase 라우트 존재, /admin/packs 라우트 소멸
|
||||
grep -nE "gradient|violet|purple|blur" app/showcase/page.tsx # 0건
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add CLAUDE.md && git commit -m "docs(phase1): CLAUDE.md — showcase·발주 탭·광고 관리·packs 정리 반영"`
|
||||
|
||||
- [ ] **Step 5: CEO 안내 (보고)**
|
||||
- `2026-07-02-phase1-ad-channels.sql`을 클라우드 Supabase + NAS self-host 양쪽 적용
|
||||
- 수동 확인 2종: /mypage 발주·진행 탭(견적코드 연결), /admin/marketing 채널 CRUD
|
||||
858
docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md
Normal file
858
docs/superpowers/plans/2026-07-02-phase2-saju-tarot.md
Normal file
@@ -0,0 +1,858 @@
|
||||
# Phase 2 사주 재활성 + 타로 신규 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 사주를 공개·무료화(로그인+일일제한)하고, web-ui 타로 구조를 이 repo에 포팅해 회원 결과 저장·마이페이지 재확인까지 붙인다.
|
||||
|
||||
**Architecture:** 타로는 순수 로직(카드·셔플·reference)을 lib/tarot/에 두고 테스트, AI는 Gemini 재사용(strict JSON + reroll), 저장은 user_id+RLS 테이블. 사주는 가드/결제 게이트를 로그인 게이트로 교체하고 서버측 일일 제한을 강제. 마이페이지 5번째 탭이 두 서비스 기록을 통합.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*`), Supabase, @google/generative-ai (기존), vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-02-phase2-saju-tarot-design.md`
|
||||
**포팅 원본:** `C:\Users\jaeoh\Desktop\workspace\web-ui\src\pages\tarot\` (조사 보고 스펙에 포함)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 디자인 가드레일: gradient / blur / 보라(violet/purple) / 이모지 금지, 공개 신규 페이지는 `--jsm-*` 토큰. (카드 PNG 이미지 에셋은 가드레일 대상 아님)
|
||||
- 카피 가드레일: "대기업 N년차" 류 자격 어필 금지
|
||||
- 셔플/역방향은 클라이언트 전용(`'use client'` + effect 초기화) — SSR hydration mismatch 방지
|
||||
- AI 실패한 생성은 일일 카운트에 넣지 않음 — 성공 시에만 recordUsage
|
||||
- 일일 제한 상수: `SAJU_DAILY_LIMIT = 1`, `TAROT_DAILY_LIMIT = 3` (lib/ai-usage.ts)
|
||||
- next.config.ts redirects() 수정 금지 (`/saju→/work/saju` 유지)
|
||||
- 기존 supabase/migrations/ 파일 삭제·수정 금지, 신규 1개만
|
||||
- GEMINI_API_KEY 미설정 시 타로 interpret는 503(예시 해석 미제공)
|
||||
- 커밋은 스코프 파일만 스테이징 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
|
||||
- 각 Task 종료 시 `npm test` 전체 통과 + `npm run build` 성공 후 커밋
|
||||
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 확인된 기존 구조
|
||||
|
||||
- supabase: `createClient()` (세션·RLS, `lib/supabase/server.ts`), `createAdminClient()` (service role, `lib/supabase/admin.ts`)
|
||||
- saju analyze: `@google/generative-ai`, MODELS 폴백 배열(`gemini-2.5-pro`→`2.5-flash`→`2.0-flash`), `GEMINI_API_KEY` 미설정 시 MOCK 반환, `dotenv` .env.local 로드, `maxDuration=60`
|
||||
- saju guard: `app/work/saju/layout.tsx:28` `isServiceVisible('saju')` + `notFound()`
|
||||
- mypage: `type Tab = 'profile'|'requests'|'products'|'orders'` (25행), TABS 배열(308~311행)
|
||||
- TopNav LINKS(9~13행): outsourcing/products/showcase 3개
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 타로 카드 데이터 포팅 (lib/tarot/cards.ts)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/tarot/cards.ts`
|
||||
- Test: `lib/__tests__/tarot-cards.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `type TarotCard`, `TAROT_DECK: TarotCard[]`(78장), `SPREADS`, `CATEGORIES: string[]`, `findCard(slug: string): TarotCard | undefined` — 이후 Task 2·4·6이 소비
|
||||
|
||||
- [ ] **Step 1: 실패 테스트 작성**
|
||||
|
||||
`lib/__tests__/tarot-cards.test.ts`:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TAROT_DECK, findCard, CATEGORIES } from '../tarot/cards';
|
||||
|
||||
describe('TAROT_DECK', () => {
|
||||
it('78장이다', () => { expect(TAROT_DECK).toHaveLength(78); });
|
||||
it('slug가 고유하다', () => {
|
||||
const slugs = TAROT_DECK.map((c) => c.slug);
|
||||
expect(new Set(slugs).size).toBe(78);
|
||||
});
|
||||
it('메이저 22 + 마이너 56', () => {
|
||||
expect(TAROT_DECK.filter((c) => c.arcana === 'major')).toHaveLength(22);
|
||||
expect(TAROT_DECK.filter((c) => c.arcana === 'minor')).toHaveLength(56);
|
||||
});
|
||||
it('모든 카드에 필수 필드가 채워져 있다', () => {
|
||||
for (const c of TAROT_DECK) {
|
||||
expect(c.name.length).toBeGreaterThan(0);
|
||||
expect(c.nameEn.length).toBeGreaterThan(0);
|
||||
expect(c.keywords.length).toBeGreaterThan(0);
|
||||
expect(c.reversedKeywords.length).toBeGreaterThan(0);
|
||||
expect(c.meaningUpright.length).toBeGreaterThan(0);
|
||||
expect(c.meaningReversed.length).toBeGreaterThan(0);
|
||||
expect(c.image).toMatch(/^\/images\/tarot\/cards\/[a-z0-9-]+\.png$/);
|
||||
}
|
||||
});
|
||||
it('findCard가 slug로 카드를 찾는다', () => {
|
||||
expect(findCard('the-fool')?.nameEn).toBe('The Fool');
|
||||
expect(findCard('nonexistent')).toBeUndefined();
|
||||
});
|
||||
it('CATEGORIES는 6개', () => { expect(CATEGORIES).toHaveLength(6); });
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `npx vitest run lib/__tests__/tarot-cards.test.ts` → FAIL(모듈 없음)
|
||||
|
||||
- [ ] **Step 3: 구현 — web-ui cards.js를 TS로 포팅**
|
||||
|
||||
`C:\Users\jaeoh\Desktop\workspace\web-ui\src\pages\tarot\data\cards.js`(672줄)의 `MAJOR_ARCANA`, `MAJOR_DETAILS`, `SUIT_DETAILS`, `RANK_DETAILS`, `CARD_LENSES`, `buildMinor()`/`buildMinorDetails()` 로직, `SPREADS`, `CATEGORIES`, `findCard`를 **데이터·알고리즘 그대로** `lib/tarot/cards.ts`로 옮긴다. 상단에 타입 정의 추가:
|
||||
```typescript
|
||||
export type TarotCard = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
arcana: 'major' | 'minor';
|
||||
element: 'air' | 'water' | 'fire' | 'earth';
|
||||
suit?: 'wands' | 'cups' | 'swords' | 'pentacles';
|
||||
rank?: number;
|
||||
keywords: string[];
|
||||
reversedKeywords: string[];
|
||||
meaningUpright: string;
|
||||
meaningReversed: string;
|
||||
symbols: { label: string; meaning: string }[];
|
||||
image: string;
|
||||
};
|
||||
export type Spread = { id: 'three_card'; name: string; positions: string[] };
|
||||
```
|
||||
- `image` 필드는 `/images/tarot/cards/${slug}.png` 형식 유지(web-ui와 동일 경로)
|
||||
- `SPREADS`는 three_card만 포함(원카드 제외 — 범위 밖): `[{ id:'three_card', name:'3카드(과거·현재·미래)', positions:['과거','현재','미래'] }]`
|
||||
- `CATEGORIES = ['연애','일·커리어','관계','재물','건강','일반']`
|
||||
- JS의 무타입 객체에 위 타입을 부여하되 데이터 값은 변경 금지. lint(`no-explicit-any`) 통과하도록 타입 명시
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — `npx vitest run lib/__tests__/tarot-cards.test.ts` → 6 PASS
|
||||
- [ ] **Step 5: 커밋** — `git add lib/tarot/cards.ts lib/__tests__/tarot-cards.test.ts && git commit -m "feat(phase2): 타로 78장 카드 데이터 TS 포팅 + 무결성 테스트"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 셔플 + reference 유틸 (lib/tarot/)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/tarot/shuffle.ts`, `lib/tarot/reference.ts`
|
||||
- Test: `lib/__tests__/tarot-shuffle.test.ts`, `lib/__tests__/tarot-reference.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TarotCard` (Task 1)
|
||||
- Produces:
|
||||
- `type Pick = { card: TarotCard; position: string; reversed: boolean }`
|
||||
- `fisherYates<T>(input: T[]): T[]`
|
||||
- `buildShuffle(deck: TarotCard[], size: number): (TarotCard & { reversed: boolean })[]`
|
||||
- `buildReferenceBlock(picks: Pick[]): string`
|
||||
- `buildContextMeta(picks: Pick[]): { major_minor_ratio: string; element_distribution: Record<string, number>; orientation_flow: string }`
|
||||
- Task 4(interpret API)·Task 6(UI)이 소비
|
||||
|
||||
- [ ] **Step 1: 셔플 테스트**
|
||||
|
||||
`lib/__tests__/tarot-shuffle.test.ts`:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { fisherYates, buildShuffle } from '../tarot/shuffle';
|
||||
import { TAROT_DECK } from '../tarot/cards';
|
||||
|
||||
describe('fisherYates', () => {
|
||||
it('원본을 변형하지 않고 같은 원소 집합을 반환한다', () => {
|
||||
const input = [1, 2, 3, 4, 5];
|
||||
const out = fisherYates(input);
|
||||
expect(input).toEqual([1, 2, 3, 4, 5]);
|
||||
expect([...out].sort()).toEqual([1, 2, 3, 4, 5]);
|
||||
});
|
||||
});
|
||||
describe('buildShuffle', () => {
|
||||
it('요청한 수만큼, 중복 없이, reversed 필드를 갖고 반환한다', () => {
|
||||
const out = buildShuffle(TAROT_DECK, 20);
|
||||
expect(out).toHaveLength(20);
|
||||
expect(new Set(out.map((c) => c.slug)).size).toBe(20);
|
||||
for (const c of out) expect(typeof c.reversed).toBe('boolean');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 셔플 구현**
|
||||
|
||||
`lib/tarot/shuffle.ts`:
|
||||
```typescript
|
||||
import type { TarotCard } from './cards';
|
||||
|
||||
export type Pick = { card: TarotCard; position: string; reversed: boolean };
|
||||
|
||||
export function fisherYates<T>(input: T[]): T[] {
|
||||
const a = [...input];
|
||||
for (let i = a.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function buildShuffle(deck: TarotCard[], size: number): (TarotCard & { reversed: boolean })[] {
|
||||
return fisherYates(deck)
|
||||
.slice(0, size)
|
||||
.map((c) => ({ ...c, reversed: Math.random() < 0.5 }));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: reference 테스트**
|
||||
|
||||
`lib/__tests__/tarot-reference.test.ts`:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildReferenceBlock, buildContextMeta } from '../tarot/reference';
|
||||
import { findCard } from '../tarot/cards';
|
||||
|
||||
const picks = [
|
||||
{ card: findCard('the-fool')!, position: '과거', reversed: false },
|
||||
{ card: findCard('the-magician')!, position: '현재', reversed: true },
|
||||
{ card: findCard('the-high-priestess')!, position: '미래', reversed: false },
|
||||
];
|
||||
|
||||
describe('buildReferenceBlock', () => {
|
||||
it('각 카드의 위치·정역·키워드·의미를 텍스트 블록으로 만든다', () => {
|
||||
const block = buildReferenceBlock(picks);
|
||||
expect(block).toContain('과거');
|
||||
expect(block).toContain('The Fool');
|
||||
expect(block).toContain('정방향');
|
||||
expect(block).toContain('역방향');
|
||||
expect(block.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
describe('buildContextMeta', () => {
|
||||
it('메이저 비율·원소 분포·정역 흐름을 계산한다', () => {
|
||||
const meta = buildContextMeta(picks);
|
||||
expect(meta.major_minor_ratio).toBe('3:0');
|
||||
expect(meta.orientation_flow).toBe('upright→reversed→upright');
|
||||
expect(typeof meta.element_distribution).toBe('object');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: reference 구현**
|
||||
|
||||
`lib/tarot/reference.ts` — web-ui `useTarotReading.js:6-41`의 `buildReferenceBlock`/`buildContextMeta` 로직 포팅(정역방향에 따라 keywords/reversedKeywords·meaningUpright/meaningReversed 선택):
|
||||
```typescript
|
||||
import type { Pick } from './shuffle';
|
||||
|
||||
export function buildReferenceBlock(picks: Pick[]): string {
|
||||
return picks
|
||||
.map((p, i) => {
|
||||
const c = p.card;
|
||||
const dir = p.reversed ? '역방향' : '정방향';
|
||||
const kws = (p.reversed ? c.reversedKeywords : c.keywords).join(', ');
|
||||
const meaning = p.reversed ? c.meaningReversed : c.meaningUpright;
|
||||
const arcana = c.arcana === 'major' ? `Major (${c.id})` : `Minor (${c.suit})`;
|
||||
return [
|
||||
`## ${i + 1}. 위치: ${p.position} | 카드: ${c.nameEn} (${dir})`,
|
||||
`- 아르카나: ${arcana}`,
|
||||
`- 원소: ${c.element}`,
|
||||
`- ${dir} 키워드: ${kws}`,
|
||||
`- ${dir} 의미: ${meaning}`,
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildContextMeta(picks: Pick[]) {
|
||||
const major = picks.filter((p) => p.card.arcana === 'major').length;
|
||||
const minor = picks.length - major;
|
||||
const element_distribution: Record<string, number> = { air: 0, water: 0, fire: 0, earth: 0 };
|
||||
for (const p of picks) element_distribution[p.card.element] += 1;
|
||||
const orientation_flow = picks.map((p) => (p.reversed ? 'reversed' : 'upright')).join('→');
|
||||
return { major_minor_ratio: `${major}:${minor}`, element_distribution, orientation_flow };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 통과 확인** — `npx vitest run lib/__tests__/tarot-shuffle.test.ts lib/__tests__/tarot-reference.test.ts` → PASS
|
||||
- [ ] **Step 6: 커밋** — `git add lib/tarot/shuffle.ts lib/tarot/reference.ts lib/__tests__/tarot-shuffle.test.ts lib/__tests__/tarot-reference.test.ts && git commit -m "feat(phase2): 타로 셔플·reference 순수 유틸 + 테스트"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 일일 사용량 유틸 + DB 마이그레이션
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/ai-usage.ts`, `lib/__tests__/ai-usage.test.ts`
|
||||
- Create: `supabase/migrations/2026-07-02-phase2-saju-tarot.sql`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `SAJU_DAILY_LIMIT = 1`, `TAROT_DAILY_LIMIT = 3`
|
||||
- `kstDayStartISO(now: Date): string` (KST 자정의 UTC ISO)
|
||||
- `getTodayUsage(admin, userId, service): Promise<number>`
|
||||
- `recordUsage(admin, userId, service): Promise<void>`
|
||||
- Task 4·7이 소비. `admin`은 `createAdminClient()` 반환 타입
|
||||
|
||||
- [ ] **Step 1: kstDayStartISO 테스트**
|
||||
|
||||
`lib/__tests__/ai-usage.test.ts`:
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT } from '../ai-usage';
|
||||
|
||||
describe('kstDayStartISO', () => {
|
||||
it('KST 자정을 UTC로 환산한다 (KST 15:00 UTC = 당일 00:00 KST)', () => {
|
||||
// 2026-07-02T05:00:00Z = 2026-07-02 14:00 KST → 그날 KST 자정 = 2026-07-01T15:00:00Z
|
||||
expect(kstDayStartISO(new Date('2026-07-02T05:00:00Z'))).toBe('2026-07-01T15:00:00.000Z');
|
||||
});
|
||||
it('KST 자정 직후도 같은 날로 계산한다', () => {
|
||||
// 2026-07-01T15:30:00Z = 2026-07-02 00:30 KST → KST 자정 = 2026-07-01T15:00:00Z
|
||||
expect(kstDayStartISO(new Date('2026-07-01T15:30:00Z'))).toBe('2026-07-01T15:00:00.000Z');
|
||||
});
|
||||
it('제한 상수', () => {
|
||||
expect(SAJU_DAILY_LIMIT).toBe(1);
|
||||
expect(TAROT_DAILY_LIMIT).toBe(3);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → FAIL
|
||||
|
||||
- [ ] **Step 3: 구현**
|
||||
|
||||
`lib/ai-usage.ts`:
|
||||
```typescript
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
export const SAJU_DAILY_LIMIT = 1;
|
||||
export const TAROT_DAILY_LIMIT = 3;
|
||||
export type AiService = 'saju' | 'tarot';
|
||||
|
||||
/** KST(UTC+9) 자정을 UTC ISO로. 오늘 사용량 집계 하한. */
|
||||
export function kstDayStartISO(now: Date): string {
|
||||
const kstMs = now.getTime() + 9 * 60 * 60 * 1000;
|
||||
const kst = new Date(kstMs);
|
||||
const kstMidnightUtcMs = Date.UTC(kst.getUTCFullYear(), kst.getUTCMonth(), kst.getUTCDate()) - 9 * 60 * 60 * 1000;
|
||||
return new Date(kstMidnightUtcMs).toISOString();
|
||||
}
|
||||
|
||||
export async function getTodayUsage(admin: SupabaseClient, userId: string, service: AiService): Promise<number> {
|
||||
const since = kstDayStartISO(new Date());
|
||||
const { count } = await admin
|
||||
.from('ai_usage_log')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('user_id', userId)
|
||||
.eq('service', service)
|
||||
.gte('created_at', since);
|
||||
return count ?? 0;
|
||||
}
|
||||
|
||||
export async function recordUsage(admin: SupabaseClient, userId: string, service: AiService): Promise<void> {
|
||||
await admin.from('ai_usage_log').insert({ user_id: userId, service });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → PASS
|
||||
|
||||
- [ ] **Step 5: 마이그레이션 파일** — 스펙 §WS2 DB SQL 그대로
|
||||
|
||||
`supabase/migrations/2026-07-02-phase2-saju-tarot.sql`:
|
||||
```sql
|
||||
-- Phase 2 (2026-07-02): 타로 저장·AI 사용량 로그 + 사주 숨김 해제
|
||||
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
spread_type text NOT NULL DEFAULT 'three_card',
|
||||
category text,
|
||||
question text,
|
||||
cards jsonb NOT NULL,
|
||||
interpretation jsonb NOT NULL,
|
||||
summary text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE tarot_readings ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tarot_select_own ON tarot_readings FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
service text NOT NULL CHECK (service IN ('saju','tarot')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE ai_usage_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_day ON ai_usage_log (user_id, service, created_at);
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'saju';
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 검증·커밋** — `npm test && npm run build` PASS. `git add lib/ai-usage.ts lib/__tests__/ai-usage.test.ts supabase/migrations/2026-07-02-phase2-saju-tarot.sql && git commit -m "feat(phase2): 일일 사용량 유틸(KST) + tarot_readings·ai_usage_log 마이그레이션"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 타로 프롬프트 + interpret API
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/tarot/prompt.ts`, `app/api/tarot/interpret/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TarotCard`/`findCard`(T1), `Pick`(T2), `getTodayUsage`/`recordUsage`/`TAROT_DAILY_LIMIT`(T3)
|
||||
- Produces:
|
||||
- `type TarotInterpretation`(interpretation_json 스키마 타입)
|
||||
- `POST /api/tarot/interpret` → 200 `{ interpretation_json: TarotInterpretation, model: string }` / 401 / 429 `{ error }` / 503 `{ error }`
|
||||
- Task 5·6이 소비
|
||||
|
||||
- [ ] **Step 1: 프롬프트·스키마 모듈**
|
||||
|
||||
`lib/tarot/prompt.ts` — web-ui tarot-lab `prompt.py`/`schema.py` 포팅:
|
||||
```typescript
|
||||
export type TarotInterpretation = {
|
||||
summary: string;
|
||||
cards: {
|
||||
position: string; card: string; reversed: boolean; interpretation: string;
|
||||
evidence: { card_meaning_used: string; position_logic: string; category_lens: string };
|
||||
advice: string;
|
||||
}[];
|
||||
interactions: { type: 'synergy' | 'conflict' | 'transition'; between: string[]; explanation: string }[];
|
||||
advice: string;
|
||||
warning: string | null;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
};
|
||||
|
||||
export const TAROT_SYSTEM_PROMPT = `당신은 라이더-웨이트(RWS) 덱 전통 상징에 기반해 타로를 해석하는 전문가입니다.
|
||||
해석 원칙:
|
||||
1. 제공된 참고 블록의 키워드/의미만 근거로 삼습니다. 외부 해석·임의 상징을 도입하지 않습니다.
|
||||
2. 각 카드의 위치 의미와 카드 의미를 결합하고, evidence에 근거를 남깁니다.
|
||||
3. 3장 스프레드는 카드 간 상호작용(원소·슈트·메이저 비율의 시너지, 슈트 충돌, 정역 전환)을 분석합니다.
|
||||
4. 운명을 단정하지 말고 성찰을 돕는 톤으로 씁니다.
|
||||
5. 카테고리에 따라 강조점을 달리합니다.
|
||||
6. 사용자의 질문을 evidence와 advice에서 인용합니다.
|
||||
반드시 코드블록 없이 순수 JSON만 출력합니다. 아래 스키마를 정확히 따릅니다:
|
||||
{"summary","cards":[{"position","card","reversed","interpretation","evidence":{"card_meaning_used","position_logic","category_lens"},"advice"}],"interactions":[{"type":"synergy|conflict|transition","between":[],"explanation"}],"advice","warning","confidence":"high|medium|low"}
|
||||
confidence: 카드들이 일관된 서사면 high, 충돌이 크면 low.`;
|
||||
|
||||
export function buildTarotUserMessage(input: {
|
||||
spread_type: string; category: string | null; question: string | null;
|
||||
cards_reference: string; context_meta: unknown;
|
||||
}): string {
|
||||
return [
|
||||
input.question ? `질문: ${input.question}` : '질문: (없음)',
|
||||
input.category ? `카테고리: ${input.category}` : '카테고리: 일반',
|
||||
`스프레드: ${input.spread_type} (3장)`,
|
||||
'--- 카드 참고 블록 ---',
|
||||
input.cards_reference,
|
||||
'--- 맥락 메타 ---',
|
||||
JSON.stringify(input.context_meta),
|
||||
'위 근거만으로 스키마에 맞는 JSON을 생성하세요.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** 코드블록 스트립 + {...} 추출 후 파싱. 실패 시 null */
|
||||
export function parseTarotJson(raw: string): TarotInterpretation | null {
|
||||
let text = raw.trim().replace(/^\`\`\`(json)?/i, '').replace(/\`\`\`$/,'').trim();
|
||||
const first = text.indexOf('{'); const last = text.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) text = text.slice(first, last + 1);
|
||||
try { return JSON.parse(text) as TarotInterpretation; } catch { return null; }
|
||||
}
|
||||
|
||||
/** 스키마 검증. 통과 못하면 사유 문자열, 통과면 null */
|
||||
export function validateTarot(obj: unknown, spreadType: string): string | null {
|
||||
if (!obj || typeof obj !== 'object') return 'not an object';
|
||||
const o = obj as Record<string, unknown>;
|
||||
if (typeof o.summary !== 'string' || !o.summary) return 'summary 누락';
|
||||
if (!Array.isArray(o.cards) || o.cards.length === 0) return 'cards 누락';
|
||||
for (const c of o.cards as Record<string, unknown>[]) {
|
||||
if (typeof c.position !== 'string' || typeof c.card !== 'string') return 'card position/card 누락';
|
||||
if (typeof c.interpretation !== 'string' || !c.interpretation) return 'card interpretation 누락';
|
||||
const ev = c.evidence as Record<string, unknown> | undefined;
|
||||
if (!ev || !ev.card_meaning_used || !ev.position_logic || !ev.category_lens) return 'evidence 3필드 필요';
|
||||
if (typeof c.advice !== 'string') return 'card advice 누락';
|
||||
}
|
||||
if (!Array.isArray(o.interactions)) return 'interactions 누락';
|
||||
if (spreadType === 'three_card' && (o.interactions as unknown[]).length < 1) return 'three_card interactions ≥1 필요';
|
||||
if (typeof o.advice !== 'string' || !o.advice) return 'advice 누락';
|
||||
if (!['high', 'medium', 'low'].includes(o.confidence as string)) return 'confidence enum 오류';
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: interpret API**
|
||||
|
||||
`app/api/tarot/interpret/route.ts` — 사주 analyze의 Gemini 폴백 패턴 재사용 + 인증·제한·reroll:
|
||||
```typescript
|
||||
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';
|
||||
export const maxDuration = 60;
|
||||
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
|
||||
const MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'];
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증
|
||||
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 = String(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 });
|
||||
|
||||
// 4) API 키
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
|
||||
const userMsg = buildTarotUserMessage({
|
||||
spread_type,
|
||||
category: (body.category as string) ?? null,
|
||||
question: (body.question as string) ?? null,
|
||||
cards_reference,
|
||||
context_meta: body.context_meta ?? {},
|
||||
});
|
||||
|
||||
// 5) 호출 + 최대 2회(검증 실패 시 사유 주입 reroll 1회)
|
||||
let feedback = '';
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
for (const modelId of MODELS) {
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({ model: modelId, systemInstruction: TAROT_SYSTEM_PROMPT });
|
||||
const prompt = feedback ? `${userMsg}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.` : userMsg;
|
||||
const res = await model.generateContent(prompt);
|
||||
const parsed = parseTarotJson(res.response.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 });
|
||||
}
|
||||
feedback = invalid ?? 'JSON 파싱 실패';
|
||||
} catch (e) {
|
||||
feedback = e instanceof Error ? e.message : 'model error';
|
||||
continue; // 다음 모델
|
||||
}
|
||||
}
|
||||
}
|
||||
return NextResponse.json({ error: '해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 502 });
|
||||
}
|
||||
```
|
||||
(사주 analyze의 실제 MODELS 배열·model 옵션 형태를 Read해서 파라미터명이 다르면 맞출 것 — 특히 `systemInstruction`/`getGenerativeModel` 시그니처)
|
||||
|
||||
- [ ] **Step 3: 검증·커밋** — `npm test && npm run build` PASS(라우트 등장). `git add lib/tarot/prompt.ts app/api/tarot/interpret/route.ts && git commit -m "feat(phase2): 타로 interpret API — Gemini strict JSON + 인증·일일제한·reroll"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 타로 저장·조회 API
|
||||
|
||||
**Files:**
|
||||
- Create: `app/api/tarot/readings/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createClient`, `createAdminClient`, `TarotInterpretation`(T4)
|
||||
- Produces:
|
||||
- `POST /api/tarot/readings` (로그인) body `{ spread_type, category, question, cards, interpretation_json }` → 200 `{ id, created_at }` / 401
|
||||
- `GET /api/tarot/readings` (로그인) → `{ readings: [{ id, spread_type, category, question, cards, interpretation, summary, created_at }] }` / 401
|
||||
- Task 6·9가 소비
|
||||
|
||||
- [ ] **Step 1: 구현**
|
||||
|
||||
`app/api/tarot/readings/route.ts`:
|
||||
```typescript
|
||||
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 ?? [] });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 검증·커밋** — `npm test && npm run build` PASS. `git add app/api/tarot/readings/route.ts && git commit -m "feat(phase2): 타로 저장·조회 API (user_id + RLS 본인 조회)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 카드 이미지 복사 + 타로 UI
|
||||
|
||||
**Files:**
|
||||
- Create: `public/images/tarot/cards/*.png`(78) + `public/images/tarot/card_back.png`
|
||||
- Create: `app/tarot/page.tsx`, `app/tarot/TarotReadingClient.tsx`, `app/tarot/layout.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `TAROT_DECK`/`findCard`/`SPREADS`/`CATEGORIES`(T1), `buildShuffle`/`Pick`/`buildReferenceBlock`/`buildContextMeta`(T2), interpret·readings API(T4·T5)
|
||||
- Produces: 공개 라우트 `/tarot`
|
||||
|
||||
- [ ] **Step 1: 이미지 복사**
|
||||
|
||||
```bash
|
||||
mkdir -p public/images/tarot/cards
|
||||
cp /c/Users/jaeoh/Desktop/workspace/web-ui/public/images/tarot/cards/*.png public/images/tarot/cards/
|
||||
cp /c/Users/jaeoh/Desktop/workspace/web-ui/public/images/tarot/card_back.png public/images/tarot/
|
||||
ls public/images/tarot/cards | wc -l # 기대: 78
|
||||
```
|
||||
|
||||
- [ ] **Step 2: layout(메타데이터)**
|
||||
|
||||
`app/tarot/layout.tsx`:
|
||||
```tsx
|
||||
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}</>; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 페이지 셸 + 클라이언트 컴포넌트**
|
||||
|
||||
`app/tarot/page.tsx`(서버, Hero + 클라이언트 마운트):
|
||||
```tsx
|
||||
import TarotReadingClient from './TarotReadingClient';
|
||||
export default function TarotPage() {
|
||||
return (
|
||||
<div>
|
||||
{/* Hero: h1 "타로 리딩" + 부제 "3장의 카드로 과거·현재·미래의 흐름을 읽습니다." */}
|
||||
<TarotReadingClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
`app/tarot/TarotReadingClient.tsx`(`'use client'`) — web-ui `Reading.jsx` 구조 포팅:
|
||||
- **셔플 초기화**: `const [deck, setDeck] = useState<(TarotCard&{reversed:boolean})[]>([]); useEffect(() => setDeck(buildShuffle(TAROT_DECK, 20)), [])` (hydration mismatch 방지 — 최초 빈 배열 렌더 후 클라에서 셔플)
|
||||
- **3-step 상태머신**: `step: 'setup'|'pick'|'result'`
|
||||
- setup: 질문 textarea(선택) + 카테고리 버튼(CATEGORIES) → "카드 뽑기" → step 'pick'
|
||||
- pick: deck 20장 뒷면(`card_back.png`) 부채꼴, 클릭 시 position 순서(SPREADS[0].positions: 과거/현재/미래)대로 `picks`에 push, 이미 뽑은 slug 제외. 3장 차면 step 'result'
|
||||
- result: 뽑은 3장 앞면(이미지 + `<img onError>` 텍스트 폴백: 카드명/영문명) + **2탭**
|
||||
- "카드 해석"(항상): 각 카드 키워드·의미(정역 반영)·상징
|
||||
- "AI 인사이트": 버튼으로 interpret 호출. 로그인 안 됐으면(401) "로그인하면 AI 해석 무료(일 3회)" + `/login?next=/tarot` 링크. 429면 제한 안내. 성공 시 summary·카드별 해석+evidence·interactions·advice·warning·confidence 뱃지 렌더 + 자동 `POST /readings` 저장 시도(실패해도 해석 유지)
|
||||
- **interpret 호출 payload**: `{ spread_type:'three_card', category, question, cards: picks.map(p=>({position:p.position, card_id:p.card.slug, reversed:p.reversed})), cards_reference: buildReferenceBlock(picks), context_meta: buildContextMeta(picks) }`
|
||||
- 디자인: `--jsm-*` 토큰, 카드 앞/뒷면·역방향 회전(`transform: rotate(180deg)`), gradient/blur/보라/이모지 금지
|
||||
- 주석 블록은 전부 실제 구현. 스타일은 `app/products/page.tsx`·`app/showcase/page.tsx` 라이트 관용구 참고
|
||||
|
||||
- [ ] **Step 4: 검증** — `npm test && npm run build` PASS(라우트 `/tarot` 등장). `grep -nE "gradient|violet|purple|blur" app/tarot/*.tsx` → 0건
|
||||
- [ ] **Step 5: 커밋** — `git add public/images/tarot app/tarot && git commit -m "feat(phase2): 타로 UI(3카드 리딩) + 카드 이미지 78종"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 사주 공개 전환 + 서버측 일일 제한
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/work/saju/layout.tsx` (가드 제거)
|
||||
- Modify: `lib/service-visibility.ts` (HideableService에서 saju 제거)
|
||||
- Modify: `app/api/admin/services/route.ts` (DEFAULT_SERVICES saju 행 제거)
|
||||
- Modify: `app/api/saju/analyze/route.ts` (인증 + 일일 제한)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getTodayUsage`/`recordUsage`/`SAJU_DAILY_LIMIT`(T3)
|
||||
- Produces: `/work/saju` 공개, analyze는 로그인+일 1회
|
||||
|
||||
- [ ] **Step 1: 가드 제거**
|
||||
|
||||
`app/work/saju/layout.tsx`:
|
||||
```tsx
|
||||
import type { Metadata } from 'next';
|
||||
export const metadata: Metadata = { /* 기존 metadata 유지 */ };
|
||||
export default function SajuLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
```
|
||||
(import `notFound`·`isServiceVisible` 제거, metadata 객체는 기존 값 그대로 유지)
|
||||
|
||||
- [ ] **Step 2: HideableService에서 saju 제거**
|
||||
|
||||
`lib/service-visibility.ts`: `export type HideableService = 'music' | 'gyeol' | 'lotto';`
|
||||
|
||||
- [ ] **Step 3: DEFAULT_SERVICES saju 행 제거**
|
||||
|
||||
`app/api/admin/services/route.ts`에서 `{ id: 'saju', ... }` 한 줄 삭제 (music/gyeol/lotto 유지)
|
||||
|
||||
- [ ] **Step 4: analyze에 인증 + 일일 제한 추가**
|
||||
|
||||
`app/api/saju/analyze/route.ts` POST 핸들러 최상단(입력 파싱 전)에:
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getTodayUsage, recordUsage, SAJU_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||
// ...
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
const admin = createAdminClient();
|
||||
if ((await getTodayUsage(admin, user.id, 'saju')) >= SAJU_DAILY_LIMIT) {
|
||||
return NextResponse.json({ error: `오늘 AI 사주 해석을 모두 사용했습니다. (${SAJU_DAILY_LIMIT}회/일)` }, { status: 429 });
|
||||
}
|
||||
```
|
||||
그리고 실제 Gemini 해석이 성공 반환되는 지점 직전에 `await recordUsage(admin, user.id, 'saju');` 추가. (MOCK 폴백 경로에는 recordUsage 넣지 않음 — 실 해석 성공만 카운트. 기존 핸들러 구조를 Read해서 성공 반환 지점 정확히 파악)
|
||||
|
||||
- [ ] **Step 5: 검증·커밋** — `npm test && npm run build` PASS. `git add app/work/saju/layout.tsx lib/service-visibility.ts app/api/admin/services/route.ts app/api/saju/analyze/route.ts && git commit -m "feat(phase2): 사주 공개 전환 + analyze 로그인·일일제한(서버 강제)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: 사주 AI 섹션 무료화(로그인 게이트)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/work/saju/result/SajuAISection.tsx`
|
||||
- Modify: `app/work/saju/result/page.tsx` (hasPaid → 로그인 여부)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: analyze/save API(기존), 401·429 응답(T7)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: page.tsx의 hasPaid를 로그인 여부로**
|
||||
|
||||
`app/work/saju/result/page.tsx`: 기존 `hasPaid`(orders 'saju_detail' 조회)를 제거하고 `const hasPaid = !!user;`(세션 유저 존재)로 대체. 저장된 해석 조회(`savedInterpretation`) 로직은 유지. `hasPaid` prop 이름은 유지(SajuAISection이 소비) — 의미만 "로그인됨"으로
|
||||
|
||||
- [ ] **Step 2: SajuAISection의 미로그인 UI 교체**
|
||||
|
||||
`app/work/saju/result/SajuAISection.tsx`의 `if (!hasPaid)` 블록(Phase 0에서 "개편 준비 중" 문구로 바뀐 부분)을 로그인 유도로 교체:
|
||||
```tsx
|
||||
if (!hasPaid) {
|
||||
return (
|
||||
<div className="...(기존 컨테이너 스타일 유지)">
|
||||
{/* AI PREMIUM 뱃지 + "AI 상세 해석 (12개 항목)" 제목 + 미리보기 SECTION_META 그리드 유지 */}
|
||||
<a href={`/login?next=${encodeURIComponent(pathname + search)}`} className="...(기존 버튼 스타일)">
|
||||
로그인하고 AI 상세 해석 무료로 받기
|
||||
</a>
|
||||
<p className="...">로그인 회원은 하루 1회 무료 · 저장된 해석은 언제든 다시 보기</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
(현재 경로는 `usePathname`/`useSearchParams`로. 컴포넌트가 이미 클라이언트면 그대로, 아니면 next 파라미터는 서버에서 prop으로 전달)
|
||||
- 429 처리: 해석 요청 fetch가 429면 상태 메시지로 "오늘 무료 횟수를 모두 사용했습니다" 표시(기존 error 상태 재사용)
|
||||
|
||||
- [ ] **Step 3: 검증·커밋** — `npm test && npm run build` PASS. 가드레일 grep(변경분). `git add app/work/saju/result/SajuAISection.tsx app/work/saju/result/page.tsx && git commit -m "feat(phase2): 사주 AI 해석 무료화 — 결제 게이트 → 로그인 게이트"`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: 마이페이지 'AI 기록' 탭
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/mypage/page.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `GET /api/tarot/readings`(T5), `saju_records`(세션 조회)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: Tab 타입·TABS·로드**
|
||||
|
||||
`app/mypage/page.tsx`:
|
||||
- `type Tab = 'profile' | 'requests' | 'products' | 'orders' | 'ai';` (25행)
|
||||
- TABS 배열에 `{ key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.length) || undefined }` 추가
|
||||
- state: `const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]); const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);`
|
||||
- 타입:
|
||||
```typescript
|
||||
type TarotReadingRow = { id: string; category: string | null; question: string | null; cards: { position: string; card_id?: string; reversed?: boolean }[]; interpretation: { summary?: string; advice?: string; warning?: string | null }; summary: string | null; created_at: string };
|
||||
type SajuRecordRow = { id: string; saju_data: Record<string, unknown>; created_at: string; is_paid: boolean };
|
||||
```
|
||||
- 로드 함수(초기 useEffect에 배선):
|
||||
```typescript
|
||||
const loadAiRecords = useCallback(async () => {
|
||||
try {
|
||||
const tr = await fetch('/api/tarot/readings');
|
||||
if (tr.ok) setTarotReadings((await tr.json()).readings ?? []);
|
||||
} catch { /* 무시 */ }
|
||||
try {
|
||||
// 사주: 세션 클라이언트로 본인 saju_records (result 페이지와 동일 패턴)
|
||||
const supabase = createClient(); // lib/supabase/client
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
const { data } = await supabase.from('saju_records')
|
||||
.select('id, saju_data, created_at, is_paid')
|
||||
.eq('user_id', user.id).order('created_at', { ascending: false });
|
||||
setSajuRecords(data ?? []);
|
||||
}
|
||||
} catch { /* 무시 */ }
|
||||
}, []);
|
||||
```
|
||||
(saju_records 실제 컬럼은 `app/work/saju/result/page.tsx`의 쿼리를 Read해서 정확히 맞출 것 — `saju_data`/`interpretation`/`is_paid`/`user_id` 존재 확인)
|
||||
|
||||
- [ ] **Step 2: AI 기록 탭 렌더**
|
||||
|
||||
`{tab === 'ai' && (...)}` 블록: 사주·타로 카드를 created_at 병합 내림차순으로 렌더.
|
||||
- 타로 카드: 날짜·카테고리·질문·`cards` 3장 카드명(findCard(card_id)?.name)·`summary` + 접이식(advice/warning)
|
||||
- 사주 카드: 날짜·생년월일 요약(saju_data에서)·"결과 다시 보기" 링크. birth 파라미터로 `/work/saju/result?...` 재구성 (result 페이지가 받는 쿼리 파라미터 형식 확인 후)
|
||||
- 빈 상태: 사주·타로 바로가기 CTA(`/work/saju`, `/tarot`)
|
||||
|
||||
- [ ] **Step 3: 검증·커밋** — `npm test && npm run build` PASS. `git add app/mypage/page.tsx && git commit -m "feat(phase2): 마이페이지 AI 기록 탭 — 사주·타로 결과 통합"`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: TopNav 진입점 + CLAUDE.md + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/TopNav.tsx` (LINKS)
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1~9 완료
|
||||
- Produces: 문서·네비 정합
|
||||
|
||||
- [ ] **Step 1: TopNav 링크**
|
||||
|
||||
`app/components/TopNav.tsx` LINKS 배열에 추가:
|
||||
```typescript
|
||||
{ href: '/work/saju', label: '사주' },
|
||||
{ href: '/tarot', label: '타로' },
|
||||
```
|
||||
(외주 개발/소프트웨어/제작 사례/사주/타로 — 5링크. 모바일 드로어는 같은 배열이라 자동)
|
||||
|
||||
- [ ] **Step 2: CLAUDE.md 갱신**
|
||||
- 핵심 IA 표: `/work/saju`(공개 AI 사주), `/tarot`(3카드 타로) 추가
|
||||
- 숨김 서비스 표에서 `/work/saju*` 행 제거(공개 전환)
|
||||
- 사주 시스템 섹션 상단 "> 서비스는 현재 숨김" 문구 → "공개 서비스(로그인 시 AI 무료 1회/일)"로 갱신
|
||||
- 파일 구조에 `tarot/`, `api/tarot/`, `lib/tarot/`, `lib/ai-usage.ts` 추가
|
||||
|
||||
- [ ] **Step 3: 최종 검증**
|
||||
```bash
|
||||
npm test # tarot-cards/shuffle/reference/ai-usage 포함 전체 PASS
|
||||
npm run build # /tarot, /work/saju(공개), /api/tarot/* 라우트 존재
|
||||
grep -rnE "gradient|violet|purple|blur" app/tarot/ app/work/saju/result/SajuAISection.tsx # 신규/변경분 0건
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add app/components/TopNav.tsx CLAUDE.md && git commit -m "feat(phase2): TopNav 사주·타로 진입점 + CLAUDE.md 정합화"`
|
||||
|
||||
- [ ] **Step 5: CEO 안내(보고)**
|
||||
- `2026-07-02-phase2-saju-tarot.sql`을 클라우드 Supabase + NAS self-host 양쪽 적용(tarot_readings·ai_usage_log 생성, service_settings saju 삭제)
|
||||
- `saju_records` 테이블이 클라우드에 존재하는지 확인(AI 기록 탭 사주 조회 의존)
|
||||
- 수동 E2E: 비로그인 타로 카드 해석 → 로그인 AI 인사이트(일 3회 제한) → 마이페이지 AI 기록 / 사주 무료 해석(일 1회)
|
||||
- GEMINI_API_KEY 운영 환경 설정 확인
|
||||
|
||||
---
|
||||
|
||||
## 검증 요약
|
||||
|
||||
| 검증 | 명령 | 기대 |
|
||||
|------|------|------|
|
||||
| 단위 테스트 | `npm test` | tarot(cards/shuffle/reference)·ai-usage + 기존 전체 PASS |
|
||||
| 빌드 | `npm run build` | /tarot·/work/saju·/api/tarot/* 라우트, 실패 없음 |
|
||||
| 가드레일 | grep(신규 공개 파일) | gradient/violet/purple/blur 0건 |
|
||||
| 이미지 | `ls public/images/tarot/cards \| wc -l` | 78 |
|
||||
143
docs/superpowers/plans/2026-07-03-phase2_6-saju-entry-reskin.md
Normal file
143
docs/superpowers/plans/2026-07-03-phase2_6-saju-entry-reskin.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Phase 2.6 사주 랜딩·입력 라이트 재스킨 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 사주 진입 화면 3파일(랜딩 page.tsx·input/page.tsx·SajuForm.tsx)을 `--jsm` 라이트로 재스킨해 사주 서비스 전 화면 톤을 통일한다.
|
||||
|
||||
**Architecture:** Phase 2.5와 동일한 순수 시각 재스킨 — 로직·데이터·props 무변경, 다크 hex·gradient·보라 className을 `--jsm-*` 토큰으로 치환. 대상 3파일엔 이모지가 없어 아이콘 작업 불필요. 각 파일 done-condition은 금지 패턴 grep 0건 + 빌드 성공.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*` 토큰), vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-03-phase2_6-saju-entry-reskin-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **순수 시각 변경**: 로직·조건분기·fetch·상태·props·데이터·라우팅 라인은 건드리지 않는다(className/style만)
|
||||
- 신규 색 토큰 추가 금지 — 아래 11개 `--jsm-*`만:
|
||||
`--jsm-bg --jsm-surface --jsm-surface-alt --jsm-ink --jsm-ink-soft --jsm-ink-faint --jsm-line --jsm-navy --jsm-accent --jsm-accent-soft --jsm-accent-hover`
|
||||
- 대상 3파일에서 `gradient` / `violet` / `purple` / `blur` / 이모지 **0건**(각 Task 종료 게이트)
|
||||
- navy 밴드 = 무테두리 flat + 흰 CTA(2.5·`app/page.tsx`에서 확립된 사이트 관용구). navy 배경 위 강조 텍스트는 `--jsm-accent-soft`(대비 확보)
|
||||
- 의미색(상태 신호색)은 있으면 2.5 선례대로 보존
|
||||
- next.config.ts·로직 파일 수정 금지
|
||||
- 커밋은 스코프 파일만 스테이징 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
|
||||
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(30) 유지 후 커밋
|
||||
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 색상 매핑 (모든 Task 공통 — 이 표대로 치환)
|
||||
|
||||
| 현재(제거 대상) | 교체(→) |
|
||||
|------|------|
|
||||
| `#04102b` 다크 배경 + `repeating-linear-gradient` 텍스처 | `bg-[var(--jsm-navy)]` 플랫(히어로 밴드) / `bg-[var(--jsm-bg)]`(페이지). 텍스처 style 삭제 |
|
||||
| `bg-gradient-to-r from-[#1a56db] to-[#7c3aed]`(+hover) CTA | `bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)]` 흰 텍스트 |
|
||||
| `violet-300/500/600`·`#7c3aed`·`shadow-violet-*` | `var(--jsm-accent)`; navy 밴드 위 텍스트는 `var(--jsm-accent-soft)` |
|
||||
| amber 강조 | `var(--jsm-accent)` 또는 `var(--jsm-ink-soft)` |
|
||||
| 흰색·`blue-xx` 본문 텍스트 | `text-[var(--jsm-ink)]` / `ink-soft` / `ink-faint` |
|
||||
| 다크 카드/테두리 `#dbe8ff`·`#1a3a7a` 등 | `bg-[var(--jsm-surface)]` + `border-[var(--jsm-line)]` |
|
||||
|
||||
참고 관용구: `app/work/saju/result/page.tsx`(2.5 완료본), `app/showcase/page.tsx`, `app/page.tsx`(navy CTA 밴드).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 랜딩 page.tsx 재스킨
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/work/saju/page.tsx` (333줄, 위반 9건)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음 (기존 --jsm 토큰만)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 전체 Read 후 색상 매핑 치환**
|
||||
|
||||
`app/work/saju/page.tsx`를 먼저 전체 Read. 다음 위반 지점을 색상 매핑 표대로 치환(className/style만):
|
||||
- 히어로(79행): `bg-[#04102b]` + `repeating-linear-gradient` style → `bg-[var(--jsm-navy)]`(밴드) + 텍스처 style 삭제
|
||||
- 부제(84행): `text-violet-300/70` → `text-[var(--jsm-accent-soft)]`(navy 위)
|
||||
- CTA 버튼(120행): `bg-gradient-to-r from-[#1a56db] to-[#7c3aed] hover:… shadow-violet-900/40` → `bg-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-hover)]`(shadow는 기본 유지 또는 제거)
|
||||
- MY RECORDS 라벨(138행): `text-violet-600` → `text-[var(--jsm-accent)]`
|
||||
- 레코드 카드(144행): `border-[#dbe8ff] hover:border-violet-300` → `border-[var(--jsm-line)] hover:border-[var(--jsm-accent-soft)]`
|
||||
- 하단 섹션 텍스처(186·255행): `repeating-linear-gradient` style 삭제, 다크 배경 → navy/surface
|
||||
- AI PREMIUM 카드(262·268행): `bg-violet-500/20 border-violet-400/30`·`text-violet-300` → navy 밴드 위 `bg-white/10 border-white/20`·`text-[var(--jsm-accent-soft)]`
|
||||
- 그 외 `#04102b` 텍스트·다크 카드 → `--jsm-ink`/`surface`/`line`
|
||||
- **서버/클라 로직·데이터 조회(MY RECORDS)·JSX 구조 미변경**
|
||||
|
||||
- [ ] **Step 2: 게이트 검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/work/saju/page.tsx # 기대: 0
|
||||
npm run build && npm test # build 성공 + 30 PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/work/saju/page.tsx && git commit -m "feat(phase2.6): 사주 랜딩 라이트 재스킨 — gradient/보라→--jsm, 텍스처 제거"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: input/page.tsx + SajuForm.tsx 재스킨
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/work/saju/input/page.tsx` (41줄, 위반 3건)
|
||||
- Modify: `app/work/saju/components/SajuForm.tsx` (220줄, 위반 1건)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 두 파일 Read 후 치환**
|
||||
|
||||
각 파일 전체 Read. 다크/gradient/violet/purple 지점을 색상 매핑 표대로 치환:
|
||||
- `input/page.tsx`: 입력 화면 컨테이너·헤더·다크 배경 → `bg-[var(--jsm-bg)]`/navy 밴드, violet → accent, 텍스트 → ink
|
||||
- `SajuForm.tsx`: 폼 필드·버튼·강조 색의 gradient/violet → 플랫 accent, 다크 → surface/line. **`useSajuForm` 등 폼 상태·핸들러·submit 로직 미변경**
|
||||
|
||||
- [ ] **Step 2: 게이트 검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/work/saju/input/page.tsx app/work/saju/components/SajuForm.tsx # 0
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/work/saju/input/page.tsx app/work/saju/components/SajuForm.tsx && git commit -m "feat(phase2.6): 사주 입력 화면·폼 라이트 재스킨"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 사주 전체 게이트 + CLAUDE.md
|
||||
|
||||
**Files:**
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1~2 완료
|
||||
- Produces: 문서 정합
|
||||
|
||||
- [ ] **Step 1: 사주 전체 게이트 검증**
|
||||
|
||||
```bash
|
||||
grep -rnE "gradient|violet|purple|blur" app/work/saju --include="*.tsx" # result 포함 사주 전체 0건
|
||||
```
|
||||
2.5 재스킨된 result 파일(SajuIcons·SajuAISection·SajuFortuneSection·result/page)과 이번 3파일 모두 0건이어야 함. 이모지도 Grep 유니코드로 0건 확인.
|
||||
|
||||
- [ ] **Step 2: CLAUDE.md 갱신**
|
||||
|
||||
`## 사주 시스템` 섹션의 2.5 재스킨 안내 문구("결과 화면 --jsm 라이트 재스킨 완료(2026-07-03)")를 "전 화면(랜딩·입력·결과) --jsm 라이트 재스킨 완료(2026-07-03)"로 갱신. 무관 섹션 미변경.
|
||||
|
||||
- [ ] **Step 3: 최종 빌드·테스트**
|
||||
|
||||
```bash
|
||||
npm run build # 성공, /work/saju·/work/saju/input·/work/saju/result 라우트 존재
|
||||
npm test # 30 PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 커밋** — `git add CLAUDE.md && git commit -m "docs(phase2.6): CLAUDE.md — 사주 전 화면 라이트 재스킨 반영"`
|
||||
|
||||
- [ ] **Step 5: 보고**
|
||||
- 수동 확인: `/work/saju` 랜딩→입력→결과 전 플로우가 라이트로 일관 렌더
|
||||
- 사주 서비스 전 화면 라이트 완결 — 재스킨 후속 없음
|
||||
|
||||
---
|
||||
|
||||
## 검증 요약
|
||||
|
||||
| 검증 | 명령 | 기대 |
|
||||
|------|------|------|
|
||||
| 사주 전체 가드레일 | `grep -rnE "gradient\|violet\|purple\|blur" app/work/saju --include="*.tsx"` | 0건 |
|
||||
| 이모지 | grep 유니코드 범위 | 0건 |
|
||||
| 빌드 | `npm run build` | 성공 |
|
||||
| 테스트 | `npm test` | 30 PASS(무변) |
|
||||
436
docs/superpowers/plans/2026-07-03-phase3a-music-public.md
Normal file
436
docs/superpowers/plans/2026-07-03-phase3a-music-public.md
Normal file
@@ -0,0 +1,436 @@
|
||||
# Phase 3a 음악 서비스 공개화 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 숨김 상태의 Suno 음악 스튜디오를 공개·무료화하고 "스토리→음악"(Gemini) 흐름·회원 저장·라이트 디자인을 붙인다.
|
||||
|
||||
**Architecture:** 사주·타로의 "공개+무료+로그인 저장+일일제한" 패턴을 음악에 적용. Gemini가 스토리→가사/스타일 변환, Suno가 음악 생성(폴링), 완료 시 회원 저장. 음악 페이지는 --jsm 라이트로 재스킨.
|
||||
|
||||
**Tech Stack:** Next.js 16 (App Router, TS), Tailwind v4(`--jsm-*`), Supabase, @google/generative-ai, Suno API, vitest
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-03-phase3a-music-public-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **순수 시각 변경 태스크**에서는 로직 라인 미변경(className/style만); **API 태스크**에서는 인증→제한→호출→(성공)recordUsage 순서 준수
|
||||
- 신규 색 토큰 금지 — 11개 `--jsm-*`만. 음악 신규/재스킨 파일에서 `gradient`/`violet`/`purple`/`blur`/이모지 **0건**
|
||||
- 일일 제한: `MUSIC_DAILY_LIMIT = 1`. 생성(Suno) 성공 시에만 `recordUsage('music')`. story(Gemini) 단계는 인증만, 미집계
|
||||
- GEMINI_API_KEY/SUNO_API_KEY 미설정 시 각각 503(예시 폴백 금지)
|
||||
- `ai_usage_log` CHECK ALTER는 **phase2-saju-tarot 마이그 DB 적용 후** 실행 전제(플랜/CEO 안내에 명시)
|
||||
- next.config.ts 수정 금지, 기존 supabase/migrations/ 파일 수정 금지(신규만)
|
||||
- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인
|
||||
- 각 Task 종료 시 `npm run build` 성공 + `npm test`(30→) 유지 후 커밋
|
||||
- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`
|
||||
|
||||
## 확인된 기존 계약
|
||||
|
||||
- `POST /api/studio/generate`(무인증): body `{mode:'simple'|'custom', prompt?, title?, lyrics?, tags?, make_instrumental?, model?}` → Suno `/api/v1/generate` → `{ ok, data }`. `callBackUrl=${origin}/api/studio/callback`(부재)
|
||||
- `GET /api/studio/status?taskId=`(무인증) → Suno record-info `{ ok, data }`
|
||||
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `kstDayStartISO`, `getTodayUsage(admin,userId,service)`, `recordUsage(admin,userId,service)`
|
||||
- supabase 헬퍼: `createClient()`(세션·RLS), `createAdminClient()`(service role). 타로 prompt 방어 패턴: `app/api/tarot/interpret/route.ts`, `lib/tarot/prompt.ts`
|
||||
|
||||
---
|
||||
|
||||
### Task 1: ai-usage 확장 + DB 마이그레이션
|
||||
|
||||
**Files:**
|
||||
- Modify: `lib/ai-usage.ts`
|
||||
- Create: `supabase/migrations/2026-07-03-phase3a-music.sql`
|
||||
- Modify: `lib/__tests__/ai-usage.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `AiService = 'saju'|'tarot'|'music'`, `MUSIC_DAILY_LIMIT = 1`, `music_tracks` 테이블. Task 3·4·6이 소비
|
||||
|
||||
- [ ] **Step 1: 테스트에 music 상수 추가**
|
||||
|
||||
`lib/__tests__/ai-usage.test.ts`의 상수 검증 `it`에 추가(기존 테스트 유지):
|
||||
```typescript
|
||||
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
|
||||
// ... 기존 KST 테스트 유지 ...
|
||||
it('음악 일일 제한 상수', () => {
|
||||
expect(MUSIC_DAILY_LIMIT).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 실패 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → FAIL(MUSIC_DAILY_LIMIT 없음)
|
||||
|
||||
- [ ] **Step 3: ai-usage.ts 확장**
|
||||
|
||||
`lib/ai-usage.ts`:
|
||||
```typescript
|
||||
export const SAJU_DAILY_LIMIT = 1;
|
||||
export const TAROT_DAILY_LIMIT = 3;
|
||||
export const MUSIC_DAILY_LIMIT = 1;
|
||||
export type AiService = 'saju' | 'tarot' | 'music';
|
||||
```
|
||||
(getTodayUsage/recordUsage/kstDayStartISO 본문 무변경 — AiService 타입만 확장되어 'music' 허용)
|
||||
|
||||
- [ ] **Step 4: 통과 확인** — `npx vitest run lib/__tests__/ai-usage.test.ts` → PASS
|
||||
|
||||
- [ ] **Step 5: 마이그레이션 파일**
|
||||
|
||||
`supabase/migrations/2026-07-03-phase3a-music.sql`:
|
||||
```sql
|
||||
-- Phase 3a (2026-07-03): 음악 회원 저장 + 사용량 로그 확장 + 음악 숨김 해제
|
||||
-- 의존성: 2026-07-02-phase2-saju-tarot.sql(ai_usage_log 생성) 적용 후 실행
|
||||
-- 적용: 클라우드 Supabase + NAS self-host 양쪽
|
||||
|
||||
CREATE TABLE IF NOT EXISTS music_tracks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
story text,
|
||||
lyrics text,
|
||||
style text,
|
||||
audio_url text,
|
||||
task_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- ai_usage_log CHECK에 'music' 추가 (phase2의 인라인 CHECK auto-name 제거 후 재정의)
|
||||
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
|
||||
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'music';
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 검증·커밋** — `npm test && npm run build` PASS. `git add lib/ai-usage.ts lib/__tests__/ai-usage.test.ts supabase/migrations/2026-07-03-phase3a-music.sql && git commit -m "feat(phase3a): ai-usage에 music 추가 + music_tracks·CHECK 마이그레이션"`
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 음악 공개화 (가드 제거)
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/music/layout.tsx`
|
||||
- Modify: `lib/service-visibility.ts`
|
||||
- Modify: `app/api/admin/services/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: `/music*` 공개
|
||||
|
||||
- [ ] **Step 1: layout 가드 제거**
|
||||
|
||||
`app/music/layout.tsx`: `isServiceVisible`/`notFound` import·호출 제거, metadata 있으면 유지, 단순 `return <>{children}</>`. (파일 먼저 Read — metadata 유무 확인)
|
||||
|
||||
- [ ] **Step 2: HideableService에서 music 제거**
|
||||
|
||||
`lib/service-visibility.ts`: `export type HideableService = 'gyeol' | 'lotto';`
|
||||
|
||||
- [ ] **Step 3: DEFAULT_SERVICES music 행 제거**
|
||||
|
||||
`app/api/admin/services/route.ts` DEFAULT_SERVICES에서 `{ id: 'music', ... }` 한 줄 삭제(gyeol/lotto 유지). (service_settings music DELETE는 Task 1 마이그레이션이 담당)
|
||||
|
||||
- [ ] **Step 4: 검증·커밋** — `npm test && npm run build`(빌드 라우트에 /music이 static/공개로 등장). `git add app/music/layout.tsx lib/service-visibility.ts app/api/admin/services/route.ts && git commit -m "feat(phase3a): 음악 서비스 공개화 — 가드·HideableService·DEFAULT_SERVICES 정리"`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 스토리→음악 (story-prompt + story API + generate 인증/제한 + callback)
|
||||
|
||||
**Files:**
|
||||
- Create: `lib/music/story-prompt.ts`
|
||||
- Create: `app/api/studio/story/route.ts`
|
||||
- Create: `app/api/studio/callback/route.ts`
|
||||
- Modify: `app/api/studio/generate/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `getTodayUsage`/`recordUsage`/`MUSIC_DAILY_LIMIT`(T1), `createClient`/`createAdminClient`
|
||||
- Produces:
|
||||
- `type MusicStory = { title: string; lyrics: string; style: string; mood: string }`
|
||||
- `POST /api/studio/story` (로그인) → 200 `{ story: MusicStory }` / 401 / 503
|
||||
- `POST /api/studio/generate` (로그인+제한) → 기존 `{ ok, data }` / 401 / 429 / 503
|
||||
- `POST /api/studio/callback` → `{ ok: true }`
|
||||
- Task 6이 소비
|
||||
|
||||
- [ ] **Step 1: story-prompt 모듈** (타로 prompt.ts 방어 패턴 포팅)
|
||||
|
||||
`lib/music/story-prompt.ts`:
|
||||
```typescript
|
||||
export type MusicStory = { title: string; lyrics: string; style: string; mood: string };
|
||||
|
||||
export const STORY_SYSTEM_PROMPT = `당신은 사용자의 개인적 이야기를 노래로 바꾸는 작사가 겸 음악 프로듀서입니다.
|
||||
사용자가 들려준 이야기를 바탕으로:
|
||||
1. title: 노래 제목(짧고 인상적으로)
|
||||
2. lyrics: 이야기의 감정과 장면을 담은 한국어 가사(절/후렴 구조, 6~16줄)
|
||||
3. style: 어울리는 음악 장르·악기·템포를 영어 키워드로(Suno style, 예 "acoustic ballad, warm piano, mid tempo")
|
||||
4. mood: 전체 정서를 한 단어로(예 "그리움", "희망")
|
||||
반드시 코드블록 없이 순수 JSON만 출력합니다: {"title","lyrics","style","mood"}
|
||||
사용자 이야기에 없는 사실을 지어내지 말고, 감정에 충실하게 각색합니다.`;
|
||||
|
||||
export function buildStoryUserMessage(story: string): string {
|
||||
return `사용자의 이야기:\n${story}\n\n위 이야기를 노래로 만들기 위한 JSON을 생성하세요.`;
|
||||
}
|
||||
|
||||
export function parseStoryJson(raw: string): MusicStory | null {
|
||||
let text = raw.trim().replace(/^\`\`\`(json)?/i, '').replace(/\`\`\`$/,'').trim();
|
||||
const first = text.indexOf('{'); const last = text.lastIndexOf('}');
|
||||
if (first >= 0 && last > first) text = text.slice(first, last + 1);
|
||||
try { return JSON.parse(text) as MusicStory; } catch { return null; }
|
||||
}
|
||||
|
||||
export function validateStory(obj: unknown): string | null {
|
||||
if (!obj || typeof obj !== 'object') return 'not an object';
|
||||
const o = obj as Record<string, unknown>;
|
||||
for (const k of ['title', 'lyrics', 'style', 'mood']) {
|
||||
if (typeof o[k] !== 'string' || !(o[k] as string).trim()) return `${k} 누락`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: story API** (타로 interpret의 Gemini 폴백·45s 가드·reroll 패턴)
|
||||
|
||||
`app/api/studio/story/route.ts` — `app/api/tarot/interpret/route.ts`를 참고해 동일 SDK 사용법으로:
|
||||
```typescript
|
||||
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';
|
||||
export const maxDuration = 60;
|
||||
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
|
||||
const MODELS = [{ id: 'gemini-2.5-pro', maxTokens: 8192 }, { id: 'gemini-2.5-flash', maxTokens: 8192 }, { id: 'gemini-2.0-flash', maxTokens: 8192 }];
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const TIME_BUDGET_MS = 45_000;
|
||||
|
||||
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 story = typeof body.story === 'string' ? body.story.trim() : '';
|
||||
if (!story) return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
const userMsg = buildStoryUserMessage(story);
|
||||
|
||||
const startedAt = Date.now();
|
||||
let attempts = 0; let feedback = '';
|
||||
for (const m of MODELS) {
|
||||
for (let retry = 0; retry < 2; retry += 1) {
|
||||
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) break;
|
||||
attempts += 1;
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({ model: m.id, systemInstruction: STORY_SYSTEM_PROMPT, generationConfig: { temperature: 0.9, topP: 0.95, maxOutputTokens: m.maxTokens } });
|
||||
const prompt = feedback ? `${userMsg}\n\n[이전 오류: ${feedback}] 스키마를 지켜 다시 출력하세요.` : userMsg;
|
||||
const res = await model.generateContent(prompt);
|
||||
const parsed = parseStoryJson(res.response.text());
|
||||
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
|
||||
if (parsed && !invalid) return NextResponse.json({ story: parsed });
|
||||
feedback = invalid ?? 'JSON 파싱 실패';
|
||||
} catch (e) { feedback = e instanceof Error ? e.message : 'model error'; break; }
|
||||
}
|
||||
if (attempts >= MAX_ATTEMPTS) break;
|
||||
}
|
||||
return NextResponse.json({ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' }, { status: 502 });
|
||||
}
|
||||
```
|
||||
(작성 전 `app/api/tarot/interpret/route.ts`를 Read해 실제 SDK 시그니처와 일치시킬 것)
|
||||
|
||||
- [ ] **Step 3: callback 최소 라우트**
|
||||
|
||||
`app/api/studio/callback/route.ts`:
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
export const runtime = 'nodejs';
|
||||
// Suno webhook 수신용 최소 엔드포인트. 회원 저장은 폴링+클라 트리거(/api/studio/tracks)가 담당하므로 여기선 200만.
|
||||
export async function POST() { return NextResponse.json({ ok: true }); }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: generate에 인증 + 일일제한**
|
||||
|
||||
`app/api/studio/generate/route.ts` POST 최상단(Suno 키 체크 전 또는 직후)에 인증·제한 추가:
|
||||
```typescript
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||
// POST 시작부:
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
const admin = createAdminClient();
|
||||
if ((await getTodayUsage(admin, user.id, 'music')) >= MUSIC_DAILY_LIMIT) {
|
||||
return NextResponse.json({ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` }, { status: 429 });
|
||||
}
|
||||
```
|
||||
그리고 Suno task 생성이 성공 반환(`return NextResponse.json({ ok: true, data })`)되기 **직전**에 `await recordUsage(admin, user.id, 'music');` 추가. (503/502/400 실패 경로엔 넣지 않음)
|
||||
|
||||
- [ ] **Step 5: 검증·커밋** — `npm test && npm run build`(라우트 /api/studio/story·/callback 등장). `git add lib/music/story-prompt.ts app/api/studio/story/route.ts app/api/studio/callback/route.ts app/api/studio/generate/route.ts && git commit -m "feat(phase3a): 스토리→가사(Gemini) + generate 인증·일일제한 + callback 정리"`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 음악 저장·조회 API
|
||||
|
||||
**Files:**
|
||||
- Create: `app/api/studio/tracks/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createClient`/`createAdminClient`, `music_tracks`(T1)
|
||||
- Produces:
|
||||
- `POST /api/studio/tracks` (로그인) body `{ title?, story?, lyrics?, style?, audio_url?, task_id? }` → `{ id, created_at }` / 401
|
||||
- `GET /api/studio/tracks` (로그인) → `{ tracks: [{ id, title, story, lyrics, style, audio_url, task_id, created_at }] }` / 401
|
||||
- Task 6이 소비
|
||||
|
||||
- [ ] **Step 1: 구현** (타로 readings 패턴)
|
||||
|
||||
`app/api/studio/tracks/route.ts`:
|
||||
```typescript
|
||||
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 });
|
||||
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 ?? [] });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 검증·커밋** — `npm test && npm run build`. `git add app/api/studio/tracks/route.ts && git commit -m "feat(phase3a): 음악 트랙 저장·조회 API (user_id + RLS)"`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: music/page·samples 라이트 재스킨
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/music/page.tsx` (72줄)
|
||||
- Modify: `app/music/samples/page.tsx` (102줄)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: 없음
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: 두 파일 Read 후 --jsm 치환**
|
||||
|
||||
색상 매핑(사주 재스킨과 동일): 다크 hex/`gradient`/`violet`/`purple`/`blur`/amber → `--jsm-navy`(밴드 플랫)/`accent`/`accent-soft`/`surface`/`line`/`ink`. 이모지 있으면 제거 또는 인라인 SVG. **로직·데이터 조회·JSX 구조 미변경.** navy 밴드=무테두리 flat + 흰 CTA 관용구.
|
||||
|
||||
- [ ] **Step 2: 게이트·검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/music/page.tsx app/music/samples/page.tsx # 0
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/music/page.tsx app/music/samples/page.tsx && git commit -m "feat(phase3a): 음악 랜딩·샘플 라이트 재스킨"`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: studio 라이트 재스킨 + 스토리 UI 흐름
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/music/studio/page.tsx` (543줄)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/studio/story`(T3), `/api/studio/generate`(T3), `/api/studio/status`(기존), `/api/studio/tracks`(T4)
|
||||
- Produces: 없음
|
||||
|
||||
- [ ] **Step 1: Read 후 라이트 재스킨 + 스토리 흐름 재구성**
|
||||
|
||||
`app/music/studio/page.tsx` 전체 Read. 두 가지 동시:
|
||||
1. **라이트 재스킨**: 다크/gradient/violet/purple/amber/이모지 → --jsm 토큰(사주 스튜디오 아님 — 신규 라이트). 폼 필드는 라이트 관용구(`bg-white`+`border-[var(--jsm-line)]`+`focus:border-[var(--jsm-accent)]`)
|
||||
2. **스토리 UI 흐름**(기존 prompt/lyrics 직접입력 → 스토리 우선 흐름으로 확장, 기존 custom/simple 모드는 "직접 입력" 탭으로 보존 가능):
|
||||
- ①스토리 textarea + "가사 만들기" → `POST /api/studio/story` → 401이면 로그인 CTA(`/login?next=/music/studio`), 503/502면 안내
|
||||
- ②반환된 `{title, lyrics, style, mood}` 미리보기(편집 가능한 필드)
|
||||
- ③"음악 만들기" → `POST /api/studio/generate`(custom 모드, title/lyrics/tags=style) → 429면 제한 안내
|
||||
- ④기존 `status` 폴링 로직 유지 → 완료 시 오디오 URL 표시(플레이어)
|
||||
- ⑤완료+로그인 시 `POST /api/studio/tracks`로 자동 저장(best-effort, 실패해도 재생 유지)
|
||||
- 디자인 가드레일: gradient/blur/보라/이모지 0건
|
||||
|
||||
- [ ] **Step 2: 게이트·검증**
|
||||
|
||||
```bash
|
||||
grep -nE "gradient|violet|purple|blur" app/music/studio/page.tsx # 0
|
||||
npm run build && npm test
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 커밋** — `git add app/music/studio/page.tsx && git commit -m "feat(phase3a): 음악 스튜디오 라이트 재스킨 + 스토리→음악 흐름"`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: TopNav + 마이페이지 AI기록 음악 + CLAUDE.md + 최종 검증
|
||||
|
||||
**Files:**
|
||||
- Modify: `app/components/TopNav.tsx`
|
||||
- Modify: `app/mypage/page.tsx`
|
||||
- Modify: `CLAUDE.md`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `/api/studio/tracks`(T4)
|
||||
- Produces: 문서·네비 정합
|
||||
|
||||
- [ ] **Step 1: TopNav 링크**
|
||||
|
||||
`app/components/TopNav.tsx` LINKS에 `{ href: '/music', label: '음악' }` 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크). 다른 항목 무변.
|
||||
|
||||
- [ ] **Step 2: 마이페이지 AI 기록 탭에 음악 통합**
|
||||
|
||||
`app/mypage/page.tsx`의 'AI 기록' 탭(사주·타로 병합 리스트)에 음악 트랙 추가:
|
||||
- 타입 `type MusicTrackRow = { id: string; title: string | null; story: string | null; audio_url: string | null; created_at: string }` 추가
|
||||
- state·로드: `loadAiRecords`에 `fetch('/api/studio/tracks')` 추가(try/catch, `{ tracks }`)
|
||||
- 렌더: 병합 내림차순에 음악 카드 추가(제목·스토리 요약·오디오 링크/`<audio>`), 빈 상태 CTA에 `/music` 추가
|
||||
- **기존 사주·타로 렌더·로직 미변경, 음악만 추가**
|
||||
|
||||
- [ ] **Step 3: CLAUDE.md 갱신**
|
||||
- 핵심 IA 표에 `/music`(공개 음악 — 스토리→음악) 추가
|
||||
- 숨김 서비스 표에서 `/music/*` 행 제거
|
||||
- 파일 구조에 `lib/music/`, `api/studio/{story,tracks,callback}` 반영
|
||||
- `/mypage` 탭 서술에 음악 포함(AI 기록: 사주·타로·음악)
|
||||
|
||||
- [ ] **Step 4: 최종 검증**
|
||||
```bash
|
||||
grep -rnE "gradient|violet|purple|blur" app/music --include="*.tsx" # 0
|
||||
npm run build # /music·/music/studio·/music/samples·/api/studio/{story,tracks,callback} 라우트 존재
|
||||
npm test # 30+ PASS
|
||||
```
|
||||
|
||||
- [ ] **Step 5: 커밋** — `git add app/components/TopNav.tsx app/mypage/page.tsx CLAUDE.md && git commit -m "feat(phase3a): TopNav 음악 + 마이페이지 AI기록 음악 통합 + CLAUDE.md"`
|
||||
|
||||
- [ ] **Step 6: CEO 안내(보고)**
|
||||
- 마이그레이션 `2026-07-03-phase3a-music.sql`을 클라우드+NAS 양쪽 적용(**phase2 마이그 적용 후**)
|
||||
- `SUNO_API_KEY`·`GEMINI_API_KEY` 운영 설정 확인(미설정 시 각 503)
|
||||
- 수동 E2E: 비로그인 /music/studio → 스토리 입력→가사→로그인 CTA→로그인 후 생성(일1회)→저장→마이페이지 AI기록 음악
|
||||
|
||||
---
|
||||
|
||||
## 검증 요약
|
||||
|
||||
| 검증 | 명령 | 기대 |
|
||||
|------|------|------|
|
||||
| 음악 가드레일 | `grep -rnE "gradient\|violet\|purple\|blur" app/music --include="*.tsx"` | 0건 |
|
||||
| 단위 테스트 | `npm test` | ai-usage music 포함 전체 PASS |
|
||||
| 빌드 | `npm run build` | /music·studio·samples·api/studio/{story,tracks,callback} |
|
||||
@@ -0,0 +1,85 @@
|
||||
# "Deep Field" 랜딩 경험 — 메인·외주 다크 캔버스 + WebGL 쇼케이스
|
||||
|
||||
- **작성일**: 2026-06-12
|
||||
- **상태**: CEO 승인 완료 (Visual Companion 세션으로 방향·구조 확정)
|
||||
- **목표**: 고객이 "AI가 만든 디자인"으로 느끼지 않는 새로운 경험의 랜딩. phantom.land 류의 커서 반응형 WebGL·몰입형 쇼케이스·볼드 타이포·스크롤 연출을 메인(/)과 /outsourcing에 적용하고, 외주 레퍼런스 쇼케이스를 페이지의 주인공으로.
|
||||
|
||||
---
|
||||
|
||||
## 0. 확정된 결정 (CEO)
|
||||
|
||||
| 항목 | 결정 |
|
||||
|------|------|
|
||||
| 레퍼런스 포인트 | phantom.land의 4요소 전부: 커서 반응 비주얼 + 몰입형 쇼케이스 + 볼드 타이포 + 스크롤 연출 |
|
||||
| 기술 수준 | **three.js WebGL 풀장착** (성능·폴백 조건은 §6) |
|
||||
| 범위 | **메인(/) + /outsourcing** 다크 캔버스 통일. 거래 페이지는 라이트 유지 + 브릿지 |
|
||||
| 쇼케이스 콘텐츠 | 기존 샘플 8종을 각 컨셉에 맞게 리뉴얼해 연결 — **샘플 리뉴얼은 별도 후속 스펙**, 이번엔 쇼케이스 시스템 + 아트 타일 |
|
||||
| 톤 | 히어로·본문·쇼케이스 동일 톤 (다크 하이브리드 아님 — 페이지 전체 통일) |
|
||||
| **카피** | **"대기업 7년차 개발자" 류 경력 강조 금지.** 신뢰는 운영 실증으로: "24시간 돌아가는 실서비스를 직접 설계·운영" 축 ([[feedback-copy-no-career-emphasis]]) |
|
||||
|
||||
## 1. 컨셉 — "Deep Field"
|
||||
|
||||
깊은 네이비 우주(필드) 위에 작업물이 떠오른다. 다크 베이스는 순수 검정이 아닌 **브랜드 네이비 혈통**(`#070d1a` 계열)으로, 기존 `--jsm-navy` 푸터와 한 핏줄. 포인트는 기존 `--jsm-accent`(#1d4ed8)에 다크 위 가독용 밝은 변형(#60a5fa)을 추가.
|
||||
|
||||
### 다크 토큰 확장 (`globals.css`)
|
||||
```
|
||||
--jsm-dark-bg: #070d1a /* 페이지 베이스 */
|
||||
--jsm-dark-surface: rgba(255,255,255,.03) /* 카드 */
|
||||
--jsm-dark-line: rgba(148,163,184,.14) /* 보더 */
|
||||
--jsm-dark-ink: #f8fafc /* 헤드라인 */
|
||||
--jsm-dark-soft: #94a3b8 /* 보조 텍스트 */
|
||||
--jsm-accent-bright:#60a5fa /* 다크 위 포인트 */
|
||||
```
|
||||
기존 라이트 토큰은 무수정 (거래 페이지가 사용).
|
||||
|
||||
## 2. 메인(/) 스크롤 구조 (승인된 목업 기준)
|
||||
|
||||
1. **HERO** — WebGL 파티클/포인트 필드가 커서를 자기장처럼 따라 굴절. 거대 타이포(2줄, letter-spacing 타이트): 카피는 "생각을 동작하는 소프트웨어로." 방향 (구현 시 디자인 스킬로 다듬되 경력 표현 금지). 서브: 운영 실증 한 줄. CTA 2개(프로젝트 문의 솔리드 / 소프트웨어 보기 고스트). 스크롤 시작 시 필드가 흩어지며 다음 섹션으로.
|
||||
2. **SHOWCASE (주인공)** — "이런 걸 만들어 드립니다". 비대칭 그리드(대형 1 + 보조 2 페턴, 데스크톱) / 세로 스택(모바일). 각 카드는 컨셉별 고유 컬러 월드를 가진 WebGL 평면 — hover 시 굴절·미세 확대, 클릭 시 풀스크린 몰입 전환 후 데모로 이동. 8슬롯 체계: 리뉴얼 완료된 샘플부터 클릭 활성, 미완료 슬롯은 아트 타일(비활성, "coming" 라벨 없이 자연스럽게 비클릭). [전체 보기 → /outsourcing#showcase]
|
||||
3. **PROCESS** — 4단계. 스크롤 진입 시 연결선이 그려지며 단계가 순차 점등.
|
||||
4. **PROOF** — 운영 중 시스템 3종(주식 자동매매/청약 매칭/AI 파이프라인) + 카운터 스탯("운영 중인 실서비스가 곧 포트폴리오"). 숫자는 스크롤 진입 시 카운트업.
|
||||
5. **SOFTWARE + CTA** — 제품 카드(라이트 카드가 다크 위에 떠 있는 대비), 기존 동적 products 연동 유지. 최종 CTA 밴드.
|
||||
|
||||
## 3. /outsourcing 구조
|
||||
|
||||
동일 다크 톤. Hero(축약) → **#showcase 풀 그리드(8슬롯 전체)** → 제공 분야 → 프로세스(상세 6단계) → FAQ → **의뢰 폼**(4단계 폼 — 다크 스타일로 재스킨, **로직·검증·API 무수정**). 기존 앵커(#process/#portfolio/#contact) 유지 — #portfolio는 #showcase로 통합하되 구 앵커도 동작(중복 id 불가하니 #portfolio 위치에 showcase 배치).
|
||||
|
||||
## 4. 쇼케이스 카드 시스템 (8슬롯)
|
||||
|
||||
기존 샘플 8종(`/work/website/samples/*`)의 컨셉을 슬롯으로 승계: corporate / commerce(shopping) / dashboard / bakery / portfolio / 기타 3종(구현 시 실제 샘플 목록 확인). 각 슬롯: 컨셉명(모노스페이스 라벨) + 고유 컬러 그래디언트 월드 + 한 줄 설명. 카드 비주얼은 **이번 스펙에서 신규 제작하는 아트 타일**(WebGL 텍스처/캔버스), 스크린샷 의존 없음 → 샘플 리뉴얼 전에도 완성도 유지. 샘플이 리뉴얼되면 해당 슬롯에 라이브 링크 활성화(데이터는 `lib/showcase.ts` 단일 배열로 관리 — `{ slug, label, title, desc, palette, href?: string }`, href 있으면 클릭 가능).
|
||||
|
||||
## 5. 네비·브릿지 전략
|
||||
|
||||
- **TopNav route-aware**: 다크 페이지(`/`, `/outsourcing`)에서는 투명→스크롤 시 다크 배경, 라이트 페이지에서는 기존 흰색 동작 유지. `usePathname` 기반 분기 (auth 로직 무수정).
|
||||
- 푸터는 전 페이지 동일 네이비(기존) — 자연 브릿지.
|
||||
- 거래·계정 페이지(/products, /mypage, /track, /quote, /login, /legal)는 **라이트 유지, 무수정** (이번 스펙 범위 밖).
|
||||
|
||||
## 6. 기술·성능·접근성 (WebGL 풀장착의 조건)
|
||||
|
||||
- **three.js는 dynamic import** — 클라이언트 전용 청크, 첫 페인트는 서버 렌더 정적 콘텐츠(텍스트·레이아웃)가 담당. SEO 텍스트는 전부 SSR 유지.
|
||||
- **폴백 3단계**: ① `prefers-reduced-motion` → WebGL 미기동, 정적 그래디언트 ② 모바일/저성능(`navigator.hardwareConcurrency<4` 또는 뷰포트<768) → 경량 모드(파티클 수 1/4, 쇼케이스 굴절 비활성·CSS 전환) ③ WebGL 컨텍스트 실패 → 정적 폴백. 폴백 상태에서도 페이지는 완전한 경험이어야 함 (그래디언트·타이포·CSS 모션).
|
||||
- 단일 `<canvas>` 재사용(히어로) + 쇼케이스는 카드별 경량 셰이더 또는 IntersectionObserver로 화면 내만 렌더. `requestAnimationFrame`은 탭 비활성 시 정지.
|
||||
- 번들: three.js 코어만(없는 기능 import 금지), 목표 추가 JS ≤ 200KB gzip.
|
||||
- 컴포넌트 구조: `app/components/deepfield/` — `HeroField.tsx`(WebGL 히어로) / `ShowcaseGrid.tsx` + `ShowcaseCard.tsx` / `ScrollReveal.tsx`(공용 스크롤 연출) / `useWebGLSupport.ts`(폴백 판정 훅). 페이지는 서버 컴포넌트 유지, WebGL 부분만 클라이언트 경계.
|
||||
|
||||
## 7. 카피 원칙
|
||||
|
||||
- 경력·소속 자격("7년차", "대기업") 표현 전면 제거 — **metadata description·jsonLd 포함**.
|
||||
- 신뢰 축: "24시간 돌아가는 실서비스 15+를 직접 설계·운영합니다", "납품으로 끝나지 않습니다 — 직접 쓰는 사람이 만듭니다" 류.
|
||||
- 한글 헤드라인 우선, 영문은 라벨·모노스페이스 디테일에만.
|
||||
|
||||
## 8. 무수정 보존 (회귀 금지선)
|
||||
|
||||
- 의뢰 폼(OutsourcingRequestForm) 로직·검증·API 호출 / products 동적 연동 로직 / 모든 라우팅·redirect / GA·jsonLd 구조(내용 카피만 갱신) / 거래·계정·admin 페이지 전부.
|
||||
|
||||
## 9. 검증
|
||||
|
||||
- `npm test` + `npm run build` + Phase 1~3 E2E 매트릭스 회귀 (숨김 404·redirect·API 401)
|
||||
- 수동: 데스크톱(커서 반응·쇼케이스 hover·스크롤 연출), 모바일 375px(경량 모드), `prefers-reduced-motion` 에뮬레이션(정적 폴백), 탭 전환 CPU, Lighthouse 성능 확인(LCP가 WebGL에 막히지 않는지)
|
||||
- 의뢰 폼 4단계 제출 회귀 1회
|
||||
|
||||
## 10. 의도적 제외 (후속 스펙)
|
||||
|
||||
- **샘플 8종 리뉴얼** (별도 스펙 — 2개씩 점진, 완료 시 쇼케이스 슬롯 활성화)
|
||||
- 거래·계정 페이지 다크 전환
|
||||
- 쇼케이스 클릭 몰입 전환의 풀스크린 WebGL 트랜지션 고도화 (1차는 절제된 전환으로 시작)
|
||||
158
docs/superpowers/specs/2026-06-30-jsm-light-redesign-design.md
Normal file
158
docs/superpowers/specs/2026-06-30-jsm-light-redesign-design.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 쟁승메이드 라이트 고craft 재설계 — 설계 문서
|
||||
|
||||
> 작성 2026-06-30 · brainstorming 산출물 (승인 완료)
|
||||
> 대상: `app/page.tsx`(홈) · `app/outsourcing/page.tsx` · `app/products/page.tsx` + 공통 시스템
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 / 문제 정의
|
||||
|
||||
최근 "Deep Field" 다크 캔버스 재스킨이 검증 없이 얹히면서 다음 문제가 발생했다.
|
||||
|
||||
1. **문서 ↔ 코드 충돌** — `CLAUDE.md` 가드레일(라이트·gradient/blur/보라 금지·`--jsm-*`)을 실제 메인/외주 코드가 정면으로 위반(다크 배경 + WebGL 파티클 + radial gradient + 보라 팔레트).
|
||||
2. **반복된 사후 패치** — 최근 커밋 2개가 전부 "히어로 텍스트 대비 복구" 류 → 다크 파티클 히어로가 픽셀 단위 튜닝에 실패.
|
||||
3. **톤 단절** — 홈·외주는 다크, `/products`는 라이트. 첫 클릭에서 톤이 깨진다.
|
||||
4. **가짜 포트폴리오** — 쇼케이스 8슬롯이 실작업 이미지가 아닌 그래디언트 타일(보라 포함). "AI가 뽑은 가짜" 인상.
|
||||
5. **사이트 정체성 누락** — CLAUDE.md가 규정한 "외주+완성SW 2축" 소개가 홈에 없고 바로 쇼케이스로 점프.
|
||||
6. **죽은 CSS** — `kx-*`(blur), `gradient-text`(보라), `kx-orb/glow`, `--jsm-dark-*`, `--kx-*` 잔존.
|
||||
|
||||
### 타깃·포지셔닝 (의사결정 근거)
|
||||
- 고객: 크몽·숨고·위시캣 트래픽 = 다수가 비개발자 소상공인/실무자.
|
||||
- 무기: "실서비스 15+ 직접 운영"이라는 **운영 실증** (경력 어필 금지 — `feedback_copy_no_career`).
|
||||
- 결론: 다크 스펙터클이 아니라 **라이트·명료 + 진짜 목업**이 신뢰·전환에 유리.
|
||||
|
||||
---
|
||||
|
||||
## 2. 확정된 방향 (승인됨)
|
||||
|
||||
| 결정 | 값 |
|
||||
|------|-----|
|
||||
| 비주얼 방향 | 라이트 기반 고(高)craft + 강조면 1곳 |
|
||||
| 강조면 위치 | **히어로의 코드 제품 목업** (운영 실증을 이미지로) |
|
||||
| 소재 확보 | **코드로 디자인한 UI 목업** (실데이터 0, `--jsm-*` 라이트/navy) |
|
||||
| 범위 | 홈 + 외주 + 제품 3면 통일 + 공통 시스템 정리 |
|
||||
| 가드레일 | 라이트 복귀 = **CLAUDE.md 컴플라이언스 회복** (개정 불필요, 다크 토큰 언급만 정리) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 디자인 시스템 기반 (3면 공통)
|
||||
|
||||
### 색 (─ `--jsm-*` 만)
|
||||
```
|
||||
bg #f8fafc · surface #fff · surface-alt #f1f5f9
|
||||
ink #0f172a · ink-soft #475569 · ink-faint #94a3b8 · line #e2e8f0
|
||||
navy #0b1f3a (푸터 + CTA 1곳, 사이트 유일 다크면)
|
||||
accent #1d4ed8 (유일 포인트) · accent-hover #1e40af · accent-soft #dbeafe
|
||||
금지: 보라/violet · gradient · blur (navy CTA 밴드도 평면 navy로 — radial 광원 제거)
|
||||
```
|
||||
|
||||
### 타이포 (Pretendard)
|
||||
| 역할 | 스펙 |
|
||||
|------|------|
|
||||
| 디스플레이 h1 | `clamp(2.4rem, 7vw, 4rem)` · w800 · `-0.03em` · `break-keep` · lh 1.08 |
|
||||
| 섹션 h2 | `clamp(1.7rem, 4vw, 2.4rem)` · w700 · `-0.02em` |
|
||||
| 모노 라벨(eyebrow) | 11px · UPPER · `0.2em` · accent — 편집 디자인 시그니처 |
|
||||
| 본문 | 16–18px · ink-soft · `-0.01em` · leading-relaxed |
|
||||
|
||||
### 레이아웃·여백·리듬
|
||||
- 컨테이너 `max-w-6xl`(1152) · 패딩 `px-6 lg:px-8`. **3면 동일** (현재 제품은 max-w-5xl로 어긋남 → 통일).
|
||||
- **여백 변주**: 현재 전부 `py-24/32` 단조 → 히어로 큰 호흡, 이후 섹션 `py-20 / py-24 / py-28`로 리듬.
|
||||
- **교차 배경**: `surface`(#fff) ↔ `surface-alt`(#f1f5f9) 교차로 섹션 구분. `border-t` 단독 의존 탈피.
|
||||
- 카드: `rounded-2xl` · `border line` · `shadow-sm` · hover `translateY(-2px)` + border accent.
|
||||
|
||||
### 모션
|
||||
- `ScrollReveal`(fade+rise) 유지. `prefers-reduced-motion` 가드(기존 `.reveal` CSS 활용). 절제.
|
||||
- `CountUp` 유지 (운영 실증 스탯).
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 신규 컴포넌트 — `MockWindow` 목업 시스템
|
||||
|
||||
파티클(HeroField)을 대체하는 craft의 핵심. **재사용 가능한 라이트 UI 목업.**
|
||||
|
||||
```
|
||||
app/components/mock/
|
||||
MockWindow.tsx 브라우저/앱 크롬 프레임 (● ● ● 신호등 + 타이틀바 + 본문 슬롯)
|
||||
screens/
|
||||
DashboardMock 스탯 카드 3 + 막대/라인 차트 (주식 리포트 톤)
|
||||
FeedMock 텔레그램풍 메시지 피드 (봇 알림)
|
||||
MatchMock 매물/항목 카드 + 매칭률 배지 (부동산 청약)
|
||||
CommerceMock 상품 그리드 + 장바구니/가격
|
||||
SiteMock 기업 사이트 히어로 와이어 (corporate/portfolio/editorial)
|
||||
BookingMock 예약 캘린더/슬롯 (로컬 매장)
|
||||
```
|
||||
- 전부 SVG/CSS, `--jsm-*` 라이트 + navy 헤더, accent 포인트. **실데이터 없음.**
|
||||
- 결정적 렌더(난수 시드 불필요 — 정적 마크업). SSR-safe(클라이언트 캔버스 의존 제거 → 서버 컴포넌트로 렌더 가능).
|
||||
- 용도: **히어로 1개**(대표 = DashboardMock/FeedMock) + **쇼케이스 N개**.
|
||||
|
||||
---
|
||||
|
||||
## 5. 페이지별 설계
|
||||
|
||||
### 5.1 홈 `/`
|
||||
섹션 순서 (배경 교차 표기):
|
||||
1. **HERO** (surface) — 비대칭 2단: 좌 텍스트(eyebrow·h1·sub·CTA 2) / 우 `MockWindow`(대표 목업). 하단 **신뢰 스트립**(15+ 실서비스 · 24/7 · 원스톱).
|
||||
2. **2축 소개** (surface-alt) — 신규. `01 OUTSOURCING` / `02 SOFTWARE` 2카드. 사이트 정체성 복원.
|
||||
3. **SHOWCASE** (surface) — `ShowcaseGrid` 재작성: 그래디언트 타일 → `MockWindow` 그리드. 홈 6장.
|
||||
4. **운영 실증** (surface-alt) — 3종 카드 + 스탯(CountUp 15+/24·7/원스톱). 라이트 카드 통일.
|
||||
5. **PROCESS** (surface) — 4단계 + 가로 연결선.
|
||||
6. **완성 SW** (surface-alt) — featured 3종(DB, `getListedProducts`). 0개면 coming-soon 폴백(라이트).
|
||||
7. **CTA 밴드** (navy) — 사이트 유일 다크면. 평면 navy(radial gradient 제거). "프로젝트, 이야기부터".
|
||||
|
||||
삭제: `HeroField` 사용, 좌측 스크림/비네트, `-mt-16` 다크 풀블리드 트릭(라이트라 불필요).
|
||||
|
||||
### 5.2 외주 `/outsourcing` — 다크→라이트 전환 (구조 유지)
|
||||
```
|
||||
HERO(라이트, 소형 MockWindow 1개) → 제공 분야 6 → 운영 실사례 6(라이트 카드)
|
||||
→ SHOWCASE 풀그리드 8(MockWindow) → PROCESS 6단계 → FAQ(아코디언) → 의뢰 폼
|
||||
```
|
||||
- 의뢰 폼: 라이트 스킨. `.jsm-dark-form` placeholder 규칙 제거/라이트화. `OutsourcingRequestForm` 입력 가독성 복구.
|
||||
- 앵커(`#showcase`/`#portfolio`/`#process`/`#contact`) 유지.
|
||||
|
||||
### 5.3 제품 `/products` — 이미 라이트, craft 격상
|
||||
```
|
||||
HERO → 카탈로그(2열 카드) → 구매방식 3단계 → CTA
|
||||
```
|
||||
- `max-w-5xl` → `max-w-6xl`, 타입 스케일·여백을 홈과 동일 언어로 정렬.
|
||||
- 카드 hover·라운드·그림자를 공통 카드 스펙에 맞춤.
|
||||
|
||||
---
|
||||
|
||||
## 6. 공통 셸
|
||||
|
||||
- **TopNav** — "다크 인지형" 라우트 분기 제거 → 단일 라이트 네비(흰 배경 + 하단 line, 스크롤 시 미세 shadow) + 우측 `프로젝트 문의` CTA. (구현 시 현 코드 확인 후 최소 수정.)
|
||||
- **Footer** — navy 유지. 사이트 유일 다크면(CTA 밴드와 함께).
|
||||
|
||||
---
|
||||
|
||||
## 7. 정리·마이그레이션
|
||||
|
||||
- `app/globals.css`:
|
||||
- 제거: `--jsm-dark-*` 토큰, `--kx-*` 매핑, `.kx-*`(glass/orb/glow/folder/...), `.gradient-text`(보라), `.kx-gradient-text`, `.jsm-dark-form`, `.df-scroll-dot`(파티클 전용).
|
||||
- 유지: `--jsm-*` 라이트, `.reveal*`, `.marquee*`(사용처 확인 후), 스크롤바, `.scrollbar-hide`.
|
||||
- `lib/showcase.ts`: `palette/accent` 그래디언트 스펙 → **목업 타입 스펙**(`mock: 'dashboard'|'commerce'|...`)으로 교체. 보라 4슬롯 제거/치환.
|
||||
- `app/components/deepfield/`:
|
||||
- `ShowcaseCard.tsx` → `MockWindow` 기반으로 재작성(또는 `app/components/mock/`로 이전).
|
||||
- `ShowcaseGrid.tsx` 유지(레이아웃 로직) — 카드만 교체.
|
||||
- `HeroField.tsx`·`useFieldMode.ts` — 홈/외주에서 import 제거. 파일은 보존만(미사용). three 의존 트리셰이킹 확인.
|
||||
- `CLAUDE.md`: 디자인 시스템 섹션에서 다크 토큰 언급 정리(가드레일 본문은 이미 라이트 → 변경 불필요).
|
||||
|
||||
---
|
||||
|
||||
## 8. 비목표 (YAGNI)
|
||||
- 다크 모드 토글/테마 시스템 (불필요).
|
||||
- 실제 스크린샷 수집·마스킹 파이프라인 (코드 목업으로 대체).
|
||||
- admin/mypage/legal 등 비공개·내부 페이지 재설계 (이번 범위 밖 — 이미 라이트).
|
||||
- 카피 전면 재작성 (기존 카피 유지, 구조·톤만 변경. 단 경력 어필 카피는 금지 유지).
|
||||
|
||||
---
|
||||
|
||||
## 9. 검증 기준
|
||||
- [ ] 3면 모두 라이트 `--jsm-*`만 사용, 다크 토큰/보라/blur/임의 색 0건 (grep).
|
||||
- [ ] 홈→외주→제품 클릭 시 톤 단절 없음.
|
||||
- [ ] 쇼케이스가 코드 목업(실화면 느낌)으로 렌더, 그래디언트 타일 0건.
|
||||
- [ ] 홈에 "2축 소개" 섹션 존재.
|
||||
- [ ] 의뢰 폼 입력 텍스트·placeholder 가독성 정상(라이트).
|
||||
- [ ] `npm run build` 성공 + `npm test`(lib 단위) 통과.
|
||||
- [ ] 죽은 CSS(`kx-*`/`gradient-text` 등) 제거 확인.
|
||||
- [ ] `prefers-reduced-motion` 시 모션 정지.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Phase 1 외주 코어 — 발주서 통합 · 제작 사례 허브 · 광고 관리 설계
|
||||
|
||||
- 날짜: 2026-07-02
|
||||
- 선행: Phase 0 정리·삭제 (main 머지 완료, `2026-07-02-saas-operation-refactor-phase0-design.md`)
|
||||
- 배경: 새 운영 비전 1축(SaaS 외주 메인)의 코어 정비. 로드맵은 Phase 0 스펙 §2 참조.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-02)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 발주서 표면화 | mypage `내 의뢰` 탭을 **발주·진행 중심으로 개편** (신규 탭 아님, 4탭 유지) |
|
||||
| 예시 허브 | **`/showcase` 신규 허브 + TopNav `제작 사례`**. samples 8종 경로는 유지하고 링크만 연결 |
|
||||
| 광고 관리 | admin/marketing을 **채널·캠페인 관리로 확장** (기존 에셋 기능 유지 + 채널 CRUD 탭) |
|
||||
| admin/packs | **페이지 제거, API 유지** (products·mypage가 API 공유) |
|
||||
|
||||
## 워크스트림 5개 (서로 파일 비중첩, 순차 실행)
|
||||
|
||||
### WS1. 발주서 — mypage 개편 + admin 뱃지
|
||||
|
||||
**mypage (`app/mypage/page.tsx`)**
|
||||
- 탭 라벨: `내 의뢰` → `발주·진행` (탭 key `requests` 유지 — URL 호환)
|
||||
- 탭 콘텐츠를 projects API 배선으로 교체:
|
||||
- `GET /api/projects` (기존 선구현) → `{ projects: [{ id, title, status, total, created_at, milestones[] }] }`
|
||||
- 발주서 카드: 제목 · 상태 뱃지(`lib/request-status.ts` 라벨 재사용) · 총액(₩ 포맷) · 마일스톤 타임라인(step_number 순, 완료/진행/대기 시각화)
|
||||
- `accepted` 이후 상태 카드에 "발주서 N호" 표기(quotes.id 기반)
|
||||
- **견적코드 연결 UI**: 탭 상단 접이식 폼 → `POST /api/projects/link` (body: `{ code }` — 기존 API 계약 그대로) → 성공 시 목록 갱신, 실패 시 에러 메시지
|
||||
- 빈 상태: "진행 중인 발주가 없습니다" + 견적코드 입력 + `/outsourcing` CTA
|
||||
- 기존 탭이 렌더하던 콘텐츠는 구현 시 확인 후 대체(주문 정보는 `주문 내역` 탭이 이미 담당)
|
||||
|
||||
**admin (`app/admin/quotes/page.tsx`)**
|
||||
- 리스트 행에서 `accepted`/`in_progress`/`completed` 상태에 "발주" 뱃지 표면화(라벨 병기). 사이드바 메뉴명·라우트는 변경하지 않음(최소 변경)
|
||||
|
||||
### WS2. `/showcase` — 제작 사례 허브
|
||||
|
||||
- 신규 `app/showcase/page.tsx` (서버 컴포넌트, PublicShell 레이아웃 자동 적용)
|
||||
- Hero: "제작 사례" 타이틀 + 운영 실증 카피 한 줄
|
||||
- **데모 카드 그리드(8종)**: bakery·corporate·dashboard·game·interior·portfolio·reading·shopping — 제목·한줄 설명·태그·"데모 보기" 버튼(`/work/website/samples/[slug]`, 새 탭)
|
||||
- **실운영 서비스 섹션**: NAS 실서비스(로또 랩·주식 대시보드·여행 갤러리 등) 소개 카드 — 링크 없는 텍스트 카드(개인 서비스라 외부 링크 없음)
|
||||
- 카피 가드레일: "대기업 N년차" 류 자격 어필 금지, "실서비스 직접 운영" 실증 서술 사용
|
||||
- 데이터: 신규 `lib/showcase-samples.ts` — 8종 메타(slug·title·description·tags) 단일 소스. 단위 테스트 1개(8종 slug 무결성)
|
||||
- **TopNav** (`app/components/TopNav.tsx:9-12`): `{ href: '/showcase', label: '제작 사례' }` 추가 (외주 개발 / 소프트웨어 / 제작 사례 순)
|
||||
- **robots.ts** (`app/robots.ts`): disallow에서 죽은 경로 3개(`/payment/`·`/freelance`·`/services/website`) 제거. `/showcase`는 색인 허용, `/work/website/samples/*`의 기존 noindex(layout robots)는 유지 — 데모 자체는 검색 노출 안 함
|
||||
- 디자인: `--jsm-*` 토큰만, gradient/blur/보라/이모지 금지
|
||||
|
||||
### WS3. admin 광고 관리 (marketing 재편)
|
||||
|
||||
- **사이드바** (`AdminSidebar.tsx`): 메뉴명 `마케팅` → `광고 관리` (href `/admin/marketing` 유지)
|
||||
- **페이지** (`app/admin/marketing/page.tsx`): 상단 탭 2개로 재구성
|
||||
- `[채널·캠페인]` (신규 기본 탭): ad_channels CRUD 테이블 — 채널명·URL(외부 링크)·상태(active/paused 토글)·메모·등록일. 행 추가/수정/삭제
|
||||
- `[에셋]`: 기존 썸네일·배너·체크리스트 기능 그대로 이동(코드 보존)
|
||||
- **API**: 신규 `app/api/admin/ad-channels/route.ts` (GET 목록/POST 생성) + `app/api/admin/ad-channels/[id]/route.ts` (PATCH 수정/DELETE 삭제). admin_token 검증은 기존 admin API 패턴(`verifyAdminTokenNode`) 동일 적용
|
||||
- **DB**: 신규 마이그레이션 `supabase/migrations/2026-07-02-phase1-ad-channels.sql`
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS ad_channels (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
url text,
|
||||
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','paused')),
|
||||
memo text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE ad_channels ENABLE ROW LEVEL SECURITY;
|
||||
-- service_role만 접근(관리자 API 전용) — 별도 policy 없음(기본 거부)
|
||||
```
|
||||
클라우드+NAS 양쪽 적용 (운영 규칙)
|
||||
|
||||
### WS4. admin/packs 페이지 제거
|
||||
|
||||
- 삭제: `app/admin/packs/page.tsx` (+디렉토리), AdminSidebar의 `/admin/packs` 메뉴 항목
|
||||
- 유지: `/api/admin/packs`, `/api/admin/packs/upload-url` (products 페이지·mypage 다운로드가 소비)
|
||||
- products 페이지가 packs API로 파일 배정을 이미 지원함을 확인(감사 완료) — 기능 공백 없음
|
||||
|
||||
### WS5. 문서·검증
|
||||
|
||||
- CLAUDE.md: 핵심 IA에 `/showcase` 추가, mypage 탭 서술 갱신, admin 서술(광고 관리·packs 제거) 갱신
|
||||
- 이메일 플로우 점검(변경 없음, 검증만): contact 접수 메일 → quote 발송 메일 → 수락 알림 메일 경로가 Phase 0 이후에도 온전한지 코드 경로 확인
|
||||
- 검증: `npm test`(신규 showcase 테스트 포함) · `npm run build` · 가드레일 grep(`gradient|purple|violet|blur` 신규 파일) · 수동 확인 안내(마이페이지 발주 탭, /showcase, admin 광고 관리)
|
||||
|
||||
## 범위 밖 (다음 Phase)
|
||||
|
||||
- 사주·타로·음악(Phase 2·3)
|
||||
- TopNav의 별도 서비스 진입점(Phase 2에서 서비스와 함께)
|
||||
- 사이트 내 프로모션 배너 노출 관리(광고 관리 확장분 — 추후)
|
||||
- quotes 테이블 스키마 변경(발주서는 뷰 차원 표면화만)
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- projects API는 `quotes.user_id` 기반 — 비회원 의뢰는 견적코드 연결 전까지 마이페이지에 안 보임(의도된 동작, track/[token]이 커버)
|
||||
- `projects/link`의 코드 형식(public_token)은 기존 구현 계약을 따름 — 구현 시 실제 필드명 확인
|
||||
- admin/marketing 재편 시 기존 에셋 데이터(정적 ASSETS 배열)는 코드 이동만, 손실 없음
|
||||
128
docs/superpowers/specs/2026-07-02-phase2-saju-tarot-design.md
Normal file
128
docs/superpowers/specs/2026-07-02-phase2-saju-tarot-design.md
Normal file
@@ -0,0 +1,128 @@
|
||||
# Phase 2 별도 서비스 — 사주 재활성 + 타로 신규 설계
|
||||
|
||||
- 날짜: 2026-07-02
|
||||
- 선행: Phase 0(정리)·Phase 1(외주 코어) main 머지 완료
|
||||
- 배경: 운영 비전 2축(별도 서비스)의 첫 구현. 사주는 숨김 해제·무료화, 타로는 web-ui(개인 사이트) 구현 구조를 이 repo에 포팅.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-02)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 노출 정책 | **공개** — 누구나 체험, **결과 저장·재확인만 로그인** |
|
||||
| 사주 AI 무료화 | 회원 무료 + **일일 횟수 제한**(서버측 강제). 비회원은 계산 기반 기본 해석까지 |
|
||||
| 타로 스펙 | **3카드 스프레드, web-ui 타로 구조 그대로 포팅**(카드 데이터·셔플·선택 UX·해석 스키마) |
|
||||
| 마이페이지 | **5번째 탭 'AI 기록'**. 사주 경로 `/work/saju` 유지, 타로는 `/tarot` 신규 |
|
||||
| 일일 제한 기본값 | 사주 AI 해석 **1회/일**, 타로 AI 인사이트 **3회/일** (상수 분리, 조정 가능) |
|
||||
| 타로 AI | **Gemini** (기존 GEMINI_API_KEY 재사용, 사주와 동일 폴백 체인 패턴). tarot-lab(Claude)의 프롬프트·스키마를 포팅 |
|
||||
|
||||
## 포팅 원본 (web-ui 조사 결과 — 2026-07-02 감사)
|
||||
|
||||
- 카드: `web-ui/src/pages/tarot/data/cards.js` — 78장(메이저 22 하드코딩 + 마이너 56 프로그램 생성), 필드 `{id, slug, name, nameEn, arcana, element, suit?, rank?, keywords[], reversedKeywords[], meaningUpright, meaningReversed, symbols[], image}`
|
||||
- 이미지: `web-ui/public/images/tarot/cards/*.png` 78장 + `card_back.png` 실재 — 복사해 사용. `<img onError>` CSS 텍스트 폴백 패턴 유지
|
||||
- 셔플: Fisher-Yates + 카드별 독립 50% 역방향. 리딩 시 덱에서 20장만 부채꼴로 펼쳐 사용자가 3장 직접 선택(과거/현재/미래 position 순)
|
||||
- AI 계약: `cards_reference`(카드 의미 텍스트 블록)와 `context_meta`(메이저 비율·원소 분포·정역 흐름)를 **프론트가 로컬 카드 데이터로 조립**해 전송 — 서버는 카드 DB 없음
|
||||
- interpretation_json 스키마: `{summary, cards[{position, card, reversed, interpretation, evidence{card_meaning_used, position_logic, category_lens}, advice}], interactions[{type: synergy|conflict|transition, between[], explanation}], advice, warning|null, confidence: high|medium|low}` — three_card는 interactions ≥1 필수
|
||||
- 파이프라인 견고성: strict JSON 프롬프트 + 파싱 폴백(코드블록 스트립) + 검증 실패 시 사유 주입 reroll 1회
|
||||
- 카테고리: `연애 / 일·커리어 / 관계 / 재물 / 건강 / 일반`
|
||||
- interpret ↔ save 분리: 저장 실패해도 해석은 유지(`save_failed` 상태)
|
||||
- 고아 컴포넌트(CardGrid, SpreadSlots)와 원카드·히스토리 페이지는 포팅 제외(YAGNI — 히스토리는 마이페이지 AI 기록 탭이 대체)
|
||||
|
||||
## 워크스트림 4개
|
||||
|
||||
### WS1. 사주 재활성 (공개 + 무료화)
|
||||
|
||||
- **가드 제거**: `app/work/saju/layout.tsx`의 `isServiceVisible('saju')` + `notFound()` 제거. `lib/service-visibility.ts`의 `HideableService`에서 `'saju'` 제거. `app/api/admin/services/route.ts` DEFAULT_SERVICES에서 saju 행 제거. `service_settings`에서 saju 행 DELETE(마이그레이션)
|
||||
- **무료화(SajuAISection)**: Phase 0에서 넣은 "개편 준비 중" 안내와 `hasPaid` 게이트를 **로그인 게이트**로 교체:
|
||||
- 비로그인: 기본 해석(사주팔자·오행·대운 등 계산 기반)은 그대로 표시 + "AI 상세 해석(12항목)은 로그인하면 무료" CTA(`/login?next=` 현재 경로)
|
||||
- 로그인: AI 상세 해석 무료 생성. 이미 저장된 해석 있으면 즉시 표시(무제한)
|
||||
- `hasPaid` 데이터 소스(orders 'saju_detail')는 제거하고 `user` 세션 유무로 대체. `saju_records` 저장 시 `is_paid: true` 유지(필드 의미: "AI 해석 보유" — 하위 호환)
|
||||
- **일일 제한(서버측 강제)**: `app/api/saju/analyze/route.ts`에 ① 세션 인증 확인(401) ② `ai_usage_log`에서 user_id+service='saju'+오늘(KST) 카운트 ≥ `SAJU_DAILY_LIMIT`(=1)이면 429 `{ error: '오늘 사용량을 모두 썼습니다. 내일 다시 시도해주세요.' }` ③ 성공 시 usage 기록. 제한 상수는 `lib/ai-usage.ts`에 정의
|
||||
- 로또 번호 섹션(SajuFortuneSection)·`hasLottoSubscription`은 현행 유지(추후 별도 정리)
|
||||
|
||||
### WS2. 타로 신규 (`/tarot`) — web-ui 구조 포팅
|
||||
|
||||
**데이터·유틸 (lib/tarot/)**
|
||||
- `lib/tarot/cards.ts`: 78장 데이터 TS 포팅(타입 `TarotCard` 정의, 메이저 하드코딩+마이너 생성 로직 그대로). `SPREADS`(three_card만), `CATEGORIES`, `findCard(slug)`
|
||||
- `lib/tarot/shuffle.ts`: `fisherYates`, `buildShuffle(deck, 20)` — 순수 함수(테스트 가능)
|
||||
- `lib/tarot/reference.ts`: `buildReferenceBlock(picks)`, `buildContextMeta(picks)` — 순수 함수(테스트 가능)
|
||||
- 단위 테스트: 78장 무결성(slug 중복 없음·필드 채움), 셔플(길이·중복 없음·원본 불변), reference 블록 형식
|
||||
|
||||
**에셋**
|
||||
- `web-ui/public/images/tarot/cards/*.png` 78장 + `card_back.png` → `public/images/tarot/` 복사. `<img onError>` 텍스트 폴백 구현(카드명+영문명)
|
||||
|
||||
**UI (`app/tarot/page.tsx` + 컴포넌트)**
|
||||
- 3-step 클라이언트 플로우(web-ui Reading 구조): ①질문(선택)·카테고리 선택 ②20장 부채꼴 뒷면에서 3장 클릭 선택(과거/현재/미래) ③결과
|
||||
- 결과 2탭: **카드 해석**(로컬 데이터 — 키워드·의미·상징, 비회원 포함 모두) / **AI 인사이트**(로그인+일일 제한 — summary·카드별 해석+evidence·interactions·advice·warning·confidence)
|
||||
- 비로그인이 AI 탭 클릭 시: "로그인하면 AI 해석 무료(일 3회)" CTA
|
||||
- 셔플은 `'use client'` + `useEffect` 초기화(hydration mismatch 방지)
|
||||
- 저장: AI 해석 성공 시 자동 저장 시도(로그인 상태). 저장 실패해도 해석 표시 유지
|
||||
- 디자인: `--jsm-*` 토큰 라이트 재작성. gradient/blur/보라/이모지 금지. 카드 이미지는 그대로(에셋은 가드레일 대상 아님)
|
||||
|
||||
**API**
|
||||
- `POST /api/tarot/interpret`: body는 web-ui 계약 그대로(`{spread_type:'three_card', category, question, cards[{position,card_id,reversed}], cards_reference, context_meta}`). 서버: ①세션 인증(401) ②일일 제한 `TAROT_DAILY_LIMIT`(=3) 체크(429) ③Gemini 호출(사주와 동일 모델 폴백 체인) — tarot-lab SYSTEM_PROMPT 포팅(RWS 전통·데이터 우선·strict JSON·confidence 기준) ④JSON 파싱 폴백+스키마 검증(위 interpretation_json 스키마, three_card interactions ≥1) ⑤실패 시 사유 주입 reroll 1회 ⑥성공 시 usage 기록. 응답 `{ interpretation_json, model }`
|
||||
- `POST /api/tarot/readings`: 로그인 필수. `{spread_type, category, question, cards, interpretation_json}` → `tarot_readings` insert(user_id, summary는 interpretation_json.summary 추출). 응답 `{ id, created_at }`
|
||||
- `GET /api/tarot/readings`: 로그인 필수, 본인 것만 최신순(마이페이지 소비)
|
||||
|
||||
**DB (마이그레이션 1개, 클라우드+NAS 양쪽)**
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS tarot_readings (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
spread_type text NOT NULL DEFAULT 'three_card',
|
||||
category text,
|
||||
question text,
|
||||
cards jsonb NOT NULL,
|
||||
interpretation jsonb NOT NULL,
|
||||
summary text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE tarot_readings ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY tarot_select_own ON tarot_readings FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_usage_log (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL,
|
||||
service text NOT NULL CHECK (service IN ('saju','tarot')),
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE ai_usage_log ENABLE ROW LEVEL SECURITY;
|
||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_day ON ai_usage_log (user_id, service, created_at);
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'saju';
|
||||
```
|
||||
(insert는 service role로 수행 — policy 불요. tarot_readings insert도 서버 admin client 경유)
|
||||
|
||||
**사용량 유틸 (`lib/ai-usage.ts`)**
|
||||
- `SAJU_DAILY_LIMIT = 1`, `TAROT_DAILY_LIMIT = 3`
|
||||
- `kstDayStart(now?)`: KST 자정 기준 오늘 시작 시각(UTC ISO) 계산 — 순수 함수, 단위 테스트(KST 경계)
|
||||
- `getTodayUsage(admin, userId, service)`: `ai_usage_log`에서 오늘 카운트 조회
|
||||
- `recordUsage(admin, userId, service)`: 사용 1건 기록
|
||||
- 호출 순서(각 API): 인증 → `getTodayUsage ≥ limit`이면 429 → AI 호출 → **성공 시에만** `recordUsage` (실패한 생성은 카운트하지 않음 — 동시 요청 레이스는 개인 서비스 규모에서 허용)
|
||||
|
||||
### WS3. 마이페이지 'AI 기록' 탭
|
||||
|
||||
- `app/mypage/page.tsx`: Tab 타입에 `'ai'` 추가, 라벨 `AI 기록` (5탭)
|
||||
- 콘텐츠: 사주 기록(`saju_records` 본인 — 세션 클라이언트 RLS 조회)과 타로 기록(`GET /api/tarot/readings`) 통합 최신순 리스트
|
||||
- 사주 카드: 생년월일시·성별 요약 + 해석 보유 뱃지 + "결과 다시 보기" 링크(`/work/saju/result?` 저장된 birth 파라미터로 재구성)
|
||||
- 타로 카드: 날짜·카테고리·질문·뽑은 카드 3장 이름 + summary + 접이식 상세(advice·warning)
|
||||
- 빈 상태: 사주/타로 바로가기 CTA
|
||||
|
||||
### WS4. 진입점 + 문서
|
||||
|
||||
- TopNav LINKS: `{ href: '/work/saju', label: '사주' }`, `{ href: '/tarot', label: '타로' }` 추가 (5링크, 모바일 드로어 자동)
|
||||
- CLAUDE.md: 핵심 IA에 사주(공개 전환)·`/tarot` 반영, 숨김 서비스 표에서 saju 제거, 사주 시스템 섹션의 "현재 숨김" 문구 갱신, 파일 구조에 tarot 추가
|
||||
- 검증: `npm test`(신규 tarot·ai-usage 테스트 포함) + `npm run build` + 가드레일 grep(신규 공개 파일) + 수동 확인 안내(사주 무료 플로우·타로 3카드 플로우·AI 기록 탭)
|
||||
|
||||
## 범위 밖
|
||||
|
||||
- 타로 원카드("오늘의 카드")·전용 히스토리 페이지 — AI 기록 탭이 대체, 필요 시 추후
|
||||
- 사주 로또 섹션 정리, music 고도화(Phase 3)
|
||||
- 사주 결과 재확인용 딥링크 고도화(birth 파라미터 재구성으로 충분)
|
||||
- 카드 이미지 최적화(next/image 전환) — `<img>` + 폴백으로 시작
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- `saju_records` 테이블은 이 repo 마이그레이션에 없음(클라우드 직접 생성) — AI 기록 탭의 사주 조회는 기존 result 페이지와 동일한 세션 클라이언트 쿼리 패턴 재사용. 스키마 확인은 구현 시 result/SajuAISection 코드 기준
|
||||
- Gemini strict JSON: 사주 analyze의 기존 파싱 패턴 + tarot-lab의 폴백·reroll 포팅으로 이중 방어. 검증 실패 2회 시 사용자에게 재시도 안내(usage 카운트는 성공 시에만)
|
||||
- 카드 이미지 78장(수 MB)이 repo에 들어감 — Vercel/NAS 정적 서빙 문제없음, git 용량만 인지
|
||||
- GEMINI_API_KEY 미설정 환경(로컬)에서는 사주와 동일하게 예시 폴백 또는 503 — 타로는 503 + 안내 문구(예시 해석 미제공, 데이터 오염 방지)
|
||||
@@ -0,0 +1,179 @@
|
||||
# SaaS 운영 리팩토링 — 로드맵 + Phase 0(정리·삭제) 설계
|
||||
|
||||
- 날짜: 2026-07-02
|
||||
- 배경: 4개 병렬 감사(공개 라우트/API/보존 서브시스템/admin)로 전 기능의 생사를 판정한 뒤, CEO가 운영 비전을 재정의함. 이 문서는 전체 로드맵과 Phase 0 상세 설계를 담는다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 운영 비전 (CEO 확정)
|
||||
|
||||
### 1축 — SaaS 외주 개발 (메인)
|
||||
- 모든 의뢰는 **발주서**로 관리
|
||||
- 외주 프로그램 소개 페이지 (제품 소개 + 제품/의뢰 금액)
|
||||
- 의뢰 폼 ↔ 이메일 연동
|
||||
- 회원 관리 + 회원 페이지 (회원 정보, 발주서 확인)
|
||||
- 관리자: 발주 관리 · 회원 관리 · 수익 관리 · 노출 서비스 관리 · 제품 관리 · 광고 관리
|
||||
- 완성 제품(웹사이트·프로그램) 예시 페이지
|
||||
|
||||
### 2축 — 별도 서비스
|
||||
- **사주**: 회원별로 보고, 결과를 마이페이지에서 재확인
|
||||
- **타로**: 동일 패턴 (이 repo에 자체 구현)
|
||||
- **음악**: "나의 이야기를 음악으로" — 개별 음악 제작 + 영상화까지, 고도화 필요
|
||||
|
||||
### 핵심 결정 (2026-07-02 Q&A)
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 발주서 정의 | **견적 수락 시 그 견적이 발주서로 전환**. 기존 contact→quote→order 상태머신 유지, 문서 개념만 통합. 마이페이지·admin 모두 발주서 중심 리네이밍 |
|
||||
| 타로 구축 | 이 repo 자체 구현 (Next.js API에서 AI 직접 호출 + Supabase 회원별 저장). NAS tarot-lab 연동 아님 |
|
||||
| 과금 | 별도 서비스(사주·타로·음악 체험) **무료**, **음악 영상화만 유료(계좌이체)**. 외주는 기존 발주서·계좌이체 |
|
||||
| 삭제 확정 | eBay 세트 + packages/subscription + PortOne 잔재 |
|
||||
| gyeol/CONTOUR | **의도적 숨김 보존** (삭제 안 함) |
|
||||
| IA 재편 시점 | Phase 0은 삭제만. TopNav·홈 개편은 각 Phase에서 해당 기능과 함께 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 로드맵 (4 Phase 순차, 각자 독립 스펙→플랜→구현)
|
||||
|
||||
| Phase | 내용 | 스펙 |
|
||||
|-------|------|------|
|
||||
| **0** | 정리·삭제 (이 문서) | 이 문서 §3 |
|
||||
| **1** | 외주 코어 — 발주서 개념 통합(마이페이지·admin 리네이밍+뷰), `/work/website/samples` 8종을 "완성 제품 예시"로 정식 재노출, projects API 마이페이지 배선, admin 광고 관리 신설(admin/marketing 재편) | 추후 |
|
||||
| **2** | 사주 재활성(무료화) + 타로 신규 구현, 회원별 결과 저장·마이페이지 재확인 탭 | 추후 |
|
||||
| **3** | 음악 고도화 — 스토리 입력→음악 생성→영상화(유료·계좌이체 발주), studio 콜백 결함 해소 | 추후 |
|
||||
|
||||
**순차 이유**: 삭제부터 하면 이후 작업의 탐색 노이즈가 사라짐. mypage와 service_settings를 여러 Phase가 만지므로 병렬 진행 시 충돌. 매 Phase가 배포 가능 상태로 종료.
|
||||
|
||||
---
|
||||
|
||||
## 3. Phase 0 상세 — 정리·삭제
|
||||
|
||||
원칙: **비전에 없는 기능 + 도달 불가능한 죽은 코드 제거. IA(네비·홈)는 건드리지 않음.** 외부 URL 호환을 위해 next.config.ts 리다이렉트는 전부 유지.
|
||||
|
||||
### 3-1. 삭제 목록
|
||||
|
||||
#### A. eBay 세트 (특정 클라이언트 제안용 리드 도구 — 용도 종료)
|
||||
| 대상 | 경로 |
|
||||
|------|------|
|
||||
| 문진 제출 API | `app/api/questionnaire/submit/route.ts` |
|
||||
| admin 문진 페이지 | `app/admin/questionnaire/page.tsx` |
|
||||
| admin 문진 API | `app/api/admin/questionnaire/route.ts`, `app/api/admin/questionnaire/[id]/route.ts` |
|
||||
| admin 문서 페이지 | `app/admin/documents/page.tsx` |
|
||||
| admin 문서 API | `app/api/admin/documents/[filename]/route.ts` |
|
||||
| eBay 라이브러리 | `lib/ebay-tools/` (crawler·pricing·ai-analyzer·types, import 0회 확인) |
|
||||
| 콘텐츠 | `CONTENT/ebay-tool-questionnaire.html`, `CONTENT/ebay-tool-proposal.html`, `CONTENT/ARCHITECTURE_EBAY_PARTS_TOOL.md` |
|
||||
| 사이드바 | AdminSidebar에서 문진·문서 메뉴 항목 제거 |
|
||||
| DB | `questionnaire_responses`, `ebay_search_history` DROP |
|
||||
|
||||
#### B. packages + subscription (SaaS 피벗 잔재 — 재개 시 재설계)
|
||||
| 대상 | 경로 |
|
||||
|------|------|
|
||||
| 페이지 | `app/packages/` (page + layout) |
|
||||
| 카탈로그 | `lib/saas-catalog.ts` (빈 배열) |
|
||||
| 구독 API | `app/api/subscription/route.ts`, `app/api/subscription/[id]/route.ts` (프론트 호출 0회) |
|
||||
| 구독 cron | `app/api/cron/subscription-expiry/route.ts` + `vercel.json` crons 항목(유일 항목이므로 crons 비움) |
|
||||
| 서비스 토글 | `service_settings`에서 `packages` 행 제거, admin/services 목록·`HideableService` 타입에서 `'packages'` 제거 |
|
||||
| DB | `subscriptions` DROP |
|
||||
|
||||
**파급 수정 (subscriptions 참조 3곳)**:
|
||||
- `app/api/admin/stats/route.ts` — 구독 집계 제거
|
||||
- `app/api/admin/members/route.ts` — 구독 카운트 제거
|
||||
- `app/work/saju/result/page.tsx` — 구독 확인 로직 제거 (사주 무료화 방향과 일치)
|
||||
|
||||
#### C. PortOne 결제 잔재 (계좌이체 단일 소스 확정)
|
||||
| 대상 | 경로 |
|
||||
|------|------|
|
||||
| 컴포넌트 | `app/components/PaymentButton.tsx` |
|
||||
| 페이지 | `app/payment/` 전체 (test — 코드 스스로 "배포 전 삭제" 명시 · fail · success) |
|
||||
| API | `app/api/payment/confirm/route.ts` |
|
||||
| 라이브러리 | `lib/payment-channels.ts`, `lib/products.ts` (소비처가 전부 삭제 대상임을 확인) |
|
||||
| 의존성 | `@portone/browser-sdk` (package.json) |
|
||||
|
||||
**파급 수정**: `app/work/saju/page.tsx`, `app/work/saju/result/SajuAISection.tsx`의 PaymentButton import·사용 제거 (무료 전환). `/payment/success`로 push하는 코드는 PaymentButton뿐이므로 함께 소멸.
|
||||
|
||||
#### D. 리다이렉트에 가려 렌더 불가능한 페이지 (redirect는 유지)
|
||||
- `app/work/page.tsx` (`/work`→`/outsourcing`)
|
||||
- `app/work/freelance/page.tsx` (`/work/freelance`→`/outsourcing`)
|
||||
- `app/work/website/page.tsx` (`/work/website`→`/outsourcing`)
|
||||
- `app/music/packs/page.tsx` (`/music/packs`→`/products`)
|
||||
- 전이 고아: `app/components/ContactForm.tsx` (유일 소비처가 죽은 `/work/freelance`), `lib/freelance-portfolio.ts` (소비처가 죽은 `/work`·`/work/freelance`뿐)
|
||||
|
||||
주의: `app/work/website/samples/*` 8종은 **삭제 금지** — `/services/website/samples/:slug` 리다이렉트 목적지로 도달 가능하며 Phase 1 재활용 자산.
|
||||
|
||||
#### E. 2026-06 라이트 재설계 잔재
|
||||
- `app/components/deepfield/HeroField.tsx` (import 0회, 스펙상 "보존만"이었으나 새 비전으로 폐기 확정)
|
||||
- `app/components/deepfield/useFieldMode.ts` (HeroField 전용)
|
||||
- `lib/deepfield-mode.ts` + `lib/__tests__/deepfield-mode.test.ts` (체인 고아)
|
||||
- 의존성 `three` 제거 (HeroField가 유일 사용처임을 확인)
|
||||
- 잔존: `deepfield/{ScrollReveal,ShowcaseGrid,ShowcaseCard,CountUp}.tsx`는 활성 — 유지
|
||||
|
||||
#### F. 고아 API
|
||||
- `app/api/track/[token]/route.ts` — 추적 페이지가 Supabase 직접 조회, 라우트 호출 0회
|
||||
- `app/api/saju/lotto/route.ts` — 프론트 fetch 0회, 외부 엔진용. 비전 무관
|
||||
|
||||
### 3-2. 유지 목록 (비전에 엮이거나 의도적 보존)
|
||||
|
||||
| 자산 | 이유 | 활용 Phase |
|
||||
|------|------|-----------|
|
||||
| `app/api/projects/`, `app/api/projects/link/` | "회원 페이지에서 발주서 확인" 용도의 선구현(quotes+milestones 집계) | Phase 1 배선 |
|
||||
| `/work/website/samples/*` 8종 | 완성 제품 예시 페이지 자산 | Phase 1 재노출 |
|
||||
| telegram 3종 (webhook·connect·setup) + `lib/telegram.ts` | mypage 연결 UI 활성, 알림 채널 재활용 여지. 단 subscription-expiry 소비처 제거로 `sendMessage` 소비처가 줄어드는 것은 무방 | Phase 2~3 |
|
||||
| gyeol 세트 (`/gyeol`, `/api/survey`, admin/survey, `survey_responses`) | CEO 의도적 숨김 보존 | — |
|
||||
| `admin/marketing` | 광고 관리로 재편 예정 | Phase 1 |
|
||||
| `admin/packs` 페이지 + `/api/admin/packs*` | 페이지는 자칭 레거시(products가 대체)나 API는 products 페이지·mypage 다운로드가 공유 — Phase 0에서는 건드리지 않고 Phase 1에서 products로 완전 통합 후 페이지 제거 검토 | Phase 1 |
|
||||
| `portfolio/[token]` + `admin/portfolio-token` | 위시캣 등 지원용 토큰 공유 도구 | — |
|
||||
| saju 페이지·API (analyze, save-interpretation) | 별도 서비스 2축, 무료화 후 재활성 | Phase 2 |
|
||||
| music 페이지·studio API | 음악 고도화 기반 (콜백 결함은 Phase 3에서 해소) | Phase 3 |
|
||||
| next.config.ts 리다이렉트 전체 | 외부 유입 URL 호환 | — |
|
||||
|
||||
### 3-3. DB 마이그레이션
|
||||
|
||||
신규 파일 `supabase/migrations/2026-07-02-phase0-cleanup.sql` 1개:
|
||||
```sql
|
||||
DROP TABLE IF EXISTS questionnaire_responses;
|
||||
DROP TABLE IF EXISTS ebay_search_history;
|
||||
DROP TABLE IF EXISTS subscriptions;
|
||||
DELETE FROM service_settings WHERE id = 'packages';
|
||||
```
|
||||
- **클라우드 Supabase + NAS self-host 양쪽 적용** (운영 규칙)
|
||||
- `survey_responses`는 건드리지 않음 (gyeol 보존)
|
||||
- 기존 마이그레이션 파일(004_subscriptions.sql 등)은 이력이므로 삭제하지 않음
|
||||
|
||||
### 3-4. 검증
|
||||
|
||||
1. `npm test` (vitest) 통과 — deepfield-mode.test.ts 삭제 반영
|
||||
2. `npm run build` 성공 (standalone)
|
||||
3. 잔존 참조 grep 스윕 0건: `PaymentButton|payment-channels|lib/products|saas-catalog|ebay|questionnaire|subscription|freelance-portfolio|ContactForm|HeroField|useFieldMode|deepfield-mode|portone|from 'three'`
|
||||
4. admin 사이드바에서 제거된 메뉴(문진·문서) 링크 없음 확인
|
||||
5. 수동: `/admin/services`에 packages 미표시, `/admin/dashboard`·`/admin/members` 정상 렌더(구독 집계 제거 후)
|
||||
|
||||
### 3-5. 산출물
|
||||
- 삭제 커밋(들) + 마이그레이션 파일 + CLAUDE.md 갱신(삭제된 기능 서술 제거: PortOne 보존 서술, 숨김 서비스 표에서 packages 등)
|
||||
|
||||
---
|
||||
|
||||
## 4. Phase 1~3 개요 (각자 별도 스펙에서 상세화)
|
||||
|
||||
### Phase 1 — 외주 코어 (발주서 통합)
|
||||
- quotes의 `accepted` 이후 상태를 "발주서"로 표면화: 마이페이지 "내 발주서" 탭(projects API 배선), admin 발주 관리 뷰 리네이밍
|
||||
- `/work/website/samples/*` → 완성 제품 예시 허브로 재노출(TopNav 반영)
|
||||
- admin "광고 관리" 신설 (admin/marketing 재편)
|
||||
- admin/packs 페이지의 products 통합 마무리 후 페이지 제거 검토
|
||||
- 이메일 연동 점검 (contact·quote·order Resend 플로우)
|
||||
|
||||
### Phase 2 — 사주 + 타로 (회원 연동)
|
||||
- 사주: service_settings 토글 ON + 무료화 + 결과 저장을 회원 계정에 연결, 마이페이지 재확인 탭
|
||||
- 타로: 자체 구현 (AI 호출 API + 카드 UI + Supabase 저장 + 마이페이지)
|
||||
- TopNav에 별도 서비스 진입점
|
||||
|
||||
### Phase 3 — 음악 고도화
|
||||
- "나의 이야기를 음악으로": 스토리 입력 → 음악 생성(Suno, 폴링) → 영상화 의뢰(유료·계좌이체 발주서)
|
||||
- `/api/studio/callback` 댕글링 해소(폴링 전용 확정 또는 콜백 구현)
|
||||
|
||||
---
|
||||
|
||||
## 5. 감사 근거 (2026-07-02 병렬 감사 요약)
|
||||
|
||||
- 공개 라우트: LIVE 11 / HIDDEN 8 / DEAD-shadowed 4 / ORPHAN 11
|
||||
- API: admin 21종 전부 ACTIVE, 고아 5~6종(track·saju/lotto·subscription×2·projects×2), 외부 트리거 3종 정상
|
||||
- admin: 고아 없음, packs만 명시적 레거시(products가 대체)
|
||||
- 고아 파일: ebay-tools 4, deepfield 잔재 3, ContactForm, freelance-portfolio
|
||||
@@ -0,0 +1,69 @@
|
||||
# Phase 2.5 사주 결과 화면 라이트 재스킨 설계
|
||||
|
||||
- 날짜: 2026-07-03
|
||||
- 선행: Phase 2(사주 공개 전환) main 머지 완료
|
||||
- 배경: Phase 2에서 `/work/saju`가 공개 전환됐으나 결과 화면이 다크 우주테마 + gradient + 보라(violet/purple) + 이모지로 jaengseung-made 가드레일을 위반. 최종 리뷰가 후속 재스킨을 권고.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-03)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 미감 방향 | **타로·사이트와 동일한 `--jsm` 라이트**. navy 밴드로 명리 무게감만 유지(gradient 없이 플랫) |
|
||||
| 범위 | **결과 화면 중심** — `result/SajuAISection.tsx`(위반 23) · `result/SajuFortuneSection.tsx`(14) · `result/page.tsx`(5). 랜딩·입력은 범위 밖(후속) |
|
||||
| 이모지 | **인라인 stroke SVG 아이콘으로 교체**(currentColor) |
|
||||
|
||||
## 원칙
|
||||
|
||||
- **순수 시각 재스킨**: 로직·데이터·props·AI 프롬프트·계산 무변경. JSX 구조는 유지하되 className/style만 교체
|
||||
- 신규 색 토큰 추가 금지 — 기존 `--jsm-*`(bg/surface/surface-alt/ink/ink-soft/ink-faint/line/navy/accent/accent-soft/accent-hover)만
|
||||
- gradient / blur / 보라(violet/purple) / 이모지 **0건**(대상 3파일)
|
||||
|
||||
## 색상 매핑 (현재 → --jsm)
|
||||
|
||||
| 현재 | 교체 |
|
||||
|------|------|
|
||||
| `#04102b`·`#0a1f5c`·`#0a2a44` 다크 배경 | `--jsm-bg` / `--jsm-surface` / `--jsm-surface-alt` |
|
||||
| 히어로 다크 밴드·그라디언트 | `--jsm-navy` 플랫 배경 + `--jsm-surface` 본문 |
|
||||
| `from-violet-500 to-purple-600`, `#a78bfa` 라디얼 | `--jsm-accent` 단색 (blur 오버레이 제거) |
|
||||
| amber-400 (프리미엄·행운 강조) | `--jsm-accent` (강조) 또는 `--jsm-ink-soft` (보조) |
|
||||
| 흰색/`blue-200/xx` 텍스트 | `--jsm-ink` / `--jsm-ink-soft` / `--jsm-ink-faint` |
|
||||
| gradient 버튼·뱃지 | 플랫 `accent` bg + 흰 텍스트 / `accent-soft` bg + `accent` 텍스트 |
|
||||
| SECTION_META 항목별 gradient/badge 팔레트(violet·rose·pink·amber…) | 전 항목 통일 `accent`/`accent-soft` (또는 중립 line/surface-alt). 항목 구분은 아이콘+라벨로 |
|
||||
|
||||
## 워크스트림 4개 (순차)
|
||||
|
||||
### WS1. 아이콘 세트 (`SajuIcons.tsx`)
|
||||
- 신규 `app/work/saju/result/SajuIcons.tsx`: SECTION_META 12항목 대응 stroke SVG(currentColor, `w-*` prop) + 로또 아이콘
|
||||
- 12 라벨: 기질·오행·지지·신살·재물·직업·애정·건강·대운·세운·황금기·종합
|
||||
- 간결한 라인 아이콘. 의미 매핑(예: 재물=코인, 애정=하트-라인, 건강=하트비트, 대운=길/화살, 종합=문서). 필요 시 유사 항목 재사용
|
||||
- `export const SAJU_ICONS: Record<string, (props)=>JSX.Element>` 또는 인덱스 배열 — SECTION_META 순서와 1:1
|
||||
- 단위 테스트 불요(시각). 렌더 스모크는 build로 대체
|
||||
|
||||
### WS2. SajuAISection 재스킨 (위반 23·최다)
|
||||
- `SECTION_META` 재정의: 이모지 → 아이콘 참조로, 항목별 gradient/badge 팔레트 → 통일 `accent`/중립 토큰. `icon: string`(이모지) 필드 제거하고 아이콘 컴포넌트/인덱스로 대체
|
||||
- AI PREMIUM 뱃지·**로그인 CTA(Phase 2 추가분)**·로딩 스피너·완료 헤더의 gradient/보라/amber → `--jsm` 토큰
|
||||
- 미리보기 그리드(SECTION_META.map)·해석 카드(section별 meta)의 다크/그라디언트 → 라이트 카드(`surface`+`line`)
|
||||
- 로직(`hasPaid` 게이트, 재생성, fetch, 429 처리) 무변경
|
||||
|
||||
### WS3. SajuFortuneSection 재스킨 (위반 14)
|
||||
- 오늘의 운세 카드·점수 링(SVG stroke 색)·용신 표시·로또 섹션(🎱→SajuIcons 로또 아이콘) 라이트 전환
|
||||
- 점수/오행 색: 기존 다크 대비색 → `--jsm` 토큰 유지 대비. `hasLottoSubscription` 분기 문구·로직 무변경
|
||||
|
||||
### WS4. result/page.tsx 재스킨 + 검증·문서 (위반 5)
|
||||
- 히어로 다크 밴드 → `--jsm-navy` 플랫 배경(gradient 제거), 컨테이너 배경 → `--jsm-bg`/`surface`
|
||||
- 사주팔자 표(사주 4기둥) 등 잔여 다크 카드 라이트 전환
|
||||
- CLAUDE.md: 사주 시스템 섹션에 "결과 화면 --jsm 라이트 재스킨 완료(2026-07-03)" 반영, 디자인 가드레일 준수 명시
|
||||
- 최종 검증: `grep -nE "gradient|violet|purple|blur" app/work/saju/result/*.tsx` 0건, 이모지 0건, `npm run build` 성공, `npm test` 30 유지
|
||||
|
||||
## 범위 밖 (후속 Phase 2.6 권고)
|
||||
|
||||
- `/work/saju/page.tsx`(랜딩, 위반 9)·`input/page.tsx`·`components/SajuForm.tsx` — 공개 진입점이라 결과와 톤 불일치가 남음. 별도 후속으로 라이트 전환 권고(스펙에 명시)
|
||||
- 로직·계산·프롬프트·데이터 무변경(순수 시각)
|
||||
- 호령 마스코트/전통 일러스트 신규 제작(있다면) — 이번 범위 아님
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- 대상 3파일이 크다(합 ~1400줄). 항목별 className 치환이 많으므로 **JSX 구조·조건분기 보존**이 핵심 — 로직 라인은 건드리지 않음
|
||||
- 점수 링 등 SVG stroke는 인라인 색을 쓰므로 `--jsm` 토큰 CSS 변수를 `stroke`/`fill`에 적용(`var(--jsm-accent)`)
|
||||
- SECTION_META의 `number` 기반 meta 매핑(`section.number - 1`) 로직은 유지 — 아이콘 배열도 동일 인덱스 순서 보장
|
||||
- 시각 회귀는 자동 테스트 불가 → 빌드+가드레일 grep + 수동 확인으로 검증. 수동 확인은 CEO E2E 항목에 추가
|
||||
@@ -0,0 +1,68 @@
|
||||
# Phase 2.6 사주 랜딩·입력 라이트 재스킨 설계
|
||||
|
||||
- 날짜: 2026-07-03
|
||||
- 선행: Phase 2.5(사주 결과 화면 라이트 재스킨) main 머지 완료(5ace251)
|
||||
- 배경: 2.5가 결과 화면만 라이트로 전환 → 공개 진입점(`/work/saju` 랜딩·입력)이 아직 다크로 남아 톤 불일치. 남은 3파일을 동일 방식으로 전환해 사주 서비스 전 화면 라이트 완결.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-03)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 방식 | **Phase 2.5와 완전 동일** — --jsm 라이트 순수 시각 재스킨, 색상 매핑 표 재사용 |
|
||||
| 범위 | 랜딩 `page.tsx` + 입력 `input/page.tsx` + `components/SajuForm.tsx` (3파일) |
|
||||
| 이모지 | **대상 3파일에 이모지 없음(grep 0)** — 아이콘 교체 불필요, 순수 색 재스킨 |
|
||||
|
||||
## 대상 (위반 분포, gradient/violet/purple/blur)
|
||||
|
||||
- `app/work/saju/page.tsx` — 랜딩(333줄, **9건**): 히어로 `bg-[#04102b]`+`repeating-linear-gradient` 텍스처, `bg-gradient-to-r from-[#1a56db] to-[#7c3aed]` CTA 버튼, `violet-300/600/500` 텍스트·뱃지·테두리, AI PREMIUM 카드, MY RECORDS 섹션
|
||||
- `app/work/saju/input/page.tsx` — 입력(41줄, **3건**)
|
||||
- `app/work/saju/components/SajuForm.tsx` — 폼(220줄, **1건**)
|
||||
|
||||
## 원칙 (2.5 스펙 그대로)
|
||||
|
||||
- **순수 시각 변경**: 로직·데이터·props·라우팅 무변경. className/style만
|
||||
- 신규 색 토큰 금지 — 기존 11개 `--jsm-*`(bg/surface/surface-alt/ink/ink-soft/ink-faint/line/navy/accent/accent-soft/accent-hover)만
|
||||
- gradient / blur / 보라(violet/purple) / 이모지 **0건**(대상 3파일)
|
||||
- navy 밴드 = 무테두리 flat + 흰 CTA(2.5·`app/page.tsx`에서 확립된 사이트 관용구)
|
||||
- 의미색(있다면 상태 신호색)은 2.5 선례대로 보존
|
||||
|
||||
## 색상 매핑 (2.5와 동일 — 이 표대로 치환)
|
||||
|
||||
| 현재(제거) | 교체(→) |
|
||||
|------|------|
|
||||
| `#04102b` 다크 배경 + `repeating-linear-gradient` 텍스처 | `bg-[var(--jsm-navy)]` 플랫(히어로 밴드) / `bg-[var(--jsm-bg)]`(페이지) — 텍스처 제거 |
|
||||
| `bg-gradient-to-r from-[#1a56db] to-[#7c3aed]` CTA | `bg-[var(--jsm-accent)]` 단색 + `hover:bg-[var(--jsm-accent-hover)]`, 흰 텍스트 |
|
||||
| `violet-300/500/600`·`#7c3aed`·`shadow-violet-*` | `var(--jsm-accent)` / navy 밴드 위 텍스트는 `var(--jsm-accent-soft)`(대비) |
|
||||
| amber 강조 | `var(--jsm-accent)` 또는 `var(--jsm-ink-soft)` |
|
||||
| 흰색/`blue-xx` 본문 텍스트 | `text-[var(--jsm-ink)]` / `ink-soft` / `ink-faint` |
|
||||
| 다크 카드/테두리 `#dbe8ff` 등 | `bg-[var(--jsm-surface)]` + `border-[var(--jsm-line)]` |
|
||||
|
||||
참고 관용구: `app/work/saju/result/page.tsx`(2.5 재스킨 완료본), `app/showcase/page.tsx`, `app/page.tsx`(navy CTA 밴드).
|
||||
|
||||
## 워크스트림 3개 (순차)
|
||||
|
||||
### WS1. 랜딩 page.tsx 재스킨 (9건)
|
||||
- 히어로 다크 밴드+repeating-linear-gradient 텍스처 → `bg-[var(--jsm-navy)]` 플랫, 페이지 배경 라이트
|
||||
- CTA 버튼 gradient → 플랫 accent
|
||||
- `violet-*` 텍스트/뱃지/테두리(부제·MY RECORDS 라벨·레코드 카드 hover·AI PREMIUM 카드) → --jsm 토큰
|
||||
- 서버/클라 로직·데이터 조회·JSX 구조 미변경
|
||||
|
||||
### WS2. input/page.tsx + SajuForm.tsx 재스킨 (4건)
|
||||
- 입력 화면·폼의 다크/gradient/violet → --jsm 토큰. 폼 필드·버튼 스타일 라이트
|
||||
- `useSajuForm` 등 로직·상태·핸들러 미변경
|
||||
|
||||
### WS3. 최종 검증 + CLAUDE.md
|
||||
- **사주 전체 게이트**: `grep -rnE "gradient|violet|purple|blur" app/work/saju/**/*.tsx`(result 포함) → 0건, 이모지 0건
|
||||
- `npm run build` 성공, `npm test`(30) 유지
|
||||
- CLAUDE.md 사주 시스템 섹션: "결과 화면"→"전 화면(랜딩·입력·결과) --jsm 라이트 재스킨 완료(2026-07-03)"로 갱신
|
||||
|
||||
## 범위 밖
|
||||
|
||||
- 로직·계산·프롬프트·데이터 무변경(순수 시각)
|
||||
- 이로써 사주 서비스 전 화면 라이트 완결 — 추가 재스킨 후속 없음
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- 랜딩 `page.tsx`가 클라이언트에서 saju 기록(MY RECORDS)을 조회하는 로직이 있으면 그 fetch/상태는 미변경 — className만
|
||||
- navy 밴드 위 accent 텍스트는 대비상 `accent-soft` 사용(2.5 선례)
|
||||
- 시각 회귀 자동 테스트 불가 → 빌드+게이트 grep + 수동 확인
|
||||
@@ -0,0 +1,90 @@
|
||||
# Phase 3a 음악 서비스 공개화 — "나의 이야기를 음악으로" 설계
|
||||
|
||||
- 날짜: 2026-07-03
|
||||
- 선행: Phase 2(사주/타로)·2.5/2.6(사주 재스킨) main 머지 완료
|
||||
- 배경: 운영 비전 2축 3번째 서비스(음악). 숨김 상태의 Suno 스튜디오를 공개·무료화하고, "스토리→음악" 흐름과 회원 저장을 추가. 영상화 유료는 Phase 3b로 분리.
|
||||
|
||||
## 결정 사항 (CEO 확정, 2026-07-03)
|
||||
|
||||
| 결정 | 내용 |
|
||||
|------|------|
|
||||
| 영상화 | **Phase 3b로 분리** — 계좌이체 발주(관리자 수동 제작·납품). 이번 3a 범위 밖 |
|
||||
| 음악 노출·과금 | **공개+무료** — 페이지 공개(숨김 해제), 생성은 로그인+일일제한(무료). 사주·타로 패턴 |
|
||||
| 스토리→음악 | **Gemini가 스토리→{가사·스타일·무드} 변환** 후 Suno 생성 |
|
||||
| 일일 제한 | 음악 생성 **1회/일**(Suno 비용) |
|
||||
| callback | `/api/studio/callback` **폴링 전용 확정** — 최소 200 응답 route로 댕글링 해소, 회원 저장은 폴링 완료 후 클라 트리거 |
|
||||
|
||||
## 확인된 기존 구조
|
||||
|
||||
- `POST /api/studio/generate`: **무인증**, `SUNO_API_KEY` 미설정 시 503, custom/simple 모드, `callBackUrl=${origin}/api/studio/callback`(부재), Suno `/api/v1/generate` 호출 → task 반환
|
||||
- `GET /api/studio/status?taskId=`: **무인증**, Suno `record-info` 폴링
|
||||
- `lib/ai-usage.ts`: `AiService='saju'|'tarot'`, `getTodayUsage`/`recordUsage`, KST 일일 집계. `ai_usage_log` CHECK는 `('saju','tarot')`(phase2 마이그, auto-name `ai_usage_log_service_check`)
|
||||
- `app/music/`: layout(가드 `isServiceVisible('music')`), page(72), samples(102), studio(543, 다크 테마 Suno UI)
|
||||
- 저장·마이페이지 통합 패턴: 타로 `interpret→readings save`, 마이페이지 'AI 기록' 탭(사주·타로)
|
||||
|
||||
## 워크스트림 5개
|
||||
|
||||
### WS1. 음악 공개화 + 무료화
|
||||
- `app/music/layout.tsx`의 `isServiceVisible('music')`+`notFound()`+import 제거 → 공개
|
||||
- `lib/service-visibility.ts` `HideableService`에서 `'music'` 제거 → `'gyeol'|'lotto'`
|
||||
- `app/api/admin/services/route.ts` DEFAULT_SERVICES music 행 제거
|
||||
- 마이그레이션에서 `service_settings` music 행 DELETE
|
||||
- Suno 키 미설정 시 503 유지(예시 폴백 없음)
|
||||
|
||||
### WS2. 스토리 → 음악 (Gemini + Suno)
|
||||
- 신규 `lib/music/story-prompt.ts`: `STORY_SYSTEM_PROMPT` + `buildStoryUserMessage(story)` + `parseStoryJson`/`validateStory`. Gemini가 스토리 텍스트 → `{ title, lyrics, style, mood }` strict JSON. 타로 prompt.ts 방어(코드블록 스트립·검증·reroll 1회, 45s 가드) 패턴 재사용
|
||||
- 신규 `POST /api/studio/story`: 인증(401) → Gemini 변환(GEMINI_API_KEY, 미설정 503) → `{ title, lyrics, style, mood }` 반환(사용자 편집 가능). **story 단계는 일일제한 미집계**(값싼 단계)
|
||||
- 기존 `POST /api/studio/generate`(Suno) 수정: 인증(401) 추가 + **일일제한(429, `MUSIC_DAILY_LIMIT`)** + Suno task 생성 성공 시에만 `recordUsage('music')`. body에 `title/lyrics/tags(style)`를 story 결과에서 채워 custom 모드로 호출
|
||||
- `GET /api/studio/status` 유지(무인증 폴링 가능 — taskId만 필요, 민감정보 없음)
|
||||
- **callback 정리**: 신규 `POST /api/studio/callback`이 최소 `{ ok: true }` 200 반환(Suno webhook 404 방지). 회원 저장은 콜백이 아니라 폴링 완료 후 클라가 `POST /api/studio/tracks` 트리거
|
||||
|
||||
### WS3. 회원 저장 + 일일제한
|
||||
- 마이그레이션 `supabase/migrations/2026-07-03-phase3a-music.sql`:
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS music_tracks (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
title text,
|
||||
story text,
|
||||
lyrics text,
|
||||
style text,
|
||||
audio_url text,
|
||||
task_id text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE music_tracks ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY music_select_own ON music_tracks FOR SELECT USING (auth.uid() = user_id);
|
||||
|
||||
-- ai_usage_log CHECK에 'music' 추가 (phase2 마이그 적용 후 실행 전제)
|
||||
ALTER TABLE ai_usage_log DROP CONSTRAINT IF EXISTS ai_usage_log_service_check;
|
||||
ALTER TABLE ai_usage_log ADD CONSTRAINT ai_usage_log_service_check CHECK (service IN ('saju','tarot','music'));
|
||||
|
||||
DELETE FROM service_settings WHERE id = 'music';
|
||||
```
|
||||
**의존성**: phase2-saju-tarot 마이그(ai_usage_log 생성)가 먼저 적용돼야 함 — 스펙·플랜·CEO 안내에 명시
|
||||
- `lib/ai-usage.ts`: `AiService`에 `'music'` 추가, `export const MUSIC_DAILY_LIMIT = 1;`
|
||||
- 신규 `POST/GET /api/studio/tracks`: 저장(admin insert, user_id 세션)·본인 조회(세션 client RLS). 타로 readings 패턴
|
||||
|
||||
### WS4. 라이트 재스킨 + 스토리 UI
|
||||
- `app/music/{page,samples,studio}.tsx` 다크 → `--jsm` 라이트 재스킨(사주 2.5/2.6 패턴, gradient/blur/보라/이모지 0건). navy 밴드 무테두리 flat 관용구
|
||||
- **스토리 UI 흐름**(studio 재구성): ①스토리 textarea → `POST /studio/story` → ②가사·스타일 미리보기(편집 가능) → ③"음악 만들기" → `POST /studio/generate` → ④`status` 폴링 → ⑤플레이어 + (로그인 시) 자동 저장(`POST /studio/tracks`). 비로그인 생성 시도 → 로그인 CTA(`/login?next=/music/studio`)
|
||||
- 셔플류 클라 이슈 없음(폼 기반)
|
||||
|
||||
### WS5. 진입점 + AI기록 통합 + 문서
|
||||
- TopNav `음악` 링크 추가(외주/소프트웨어/제작사례/사주/타로/음악 = 6링크)
|
||||
- 마이페이지 'AI 기록' 탭에 음악 트랙 통합(`GET /api/studio/tracks`) — 사주·타로·음악 3종 병합 리스트, 음악은 제목·스토리 요약·오디오 링크
|
||||
- CLAUDE.md: 음악 공개 서비스·스토리→음악·music_tracks·studio API 반영, 숨김 서비스 표에서 music 제거
|
||||
- 최종 검증: `grep -rnE "gradient|violet|purple|blur" app/music/**/*.tsx` 0건, build+test 30, sajumusic 라우트 존재
|
||||
|
||||
## 범위 밖 (Phase 3b)
|
||||
|
||||
- 영상화 유료 발주(계좌이체 order로 video 신청·관리자 제작·납품·다운로드)
|
||||
- 음악 자동 영상 생성 API 연동
|
||||
|
||||
## 리스크·주의
|
||||
|
||||
- **Suno 실동작**은 `SUNO_API_KEY` 운영 설정 의존 — 로컬 미설정 시 503(사주 Gemini와 동일 정책). 수동 E2E는 운영 키 있는 환경에서
|
||||
- `ai_usage_log` CHECK ALTER는 phase2 마이그 DB 적용 후 실행돼야 함(미적용 시 ALTER 실패) — CEO 안내
|
||||
- studio 페이지가 큼(543줄) — 재스킨 + 스토리 UI 재구성은 태스크 분할(라이트 재스킨과 스토리 흐름 분리 가능)
|
||||
- Suno 응답 스키마(task/record-info)는 기존 status route가 이미 다룸 — 저장 시 audio_url 추출 지점은 구현 시 record-info 응답 구조 확인
|
||||
- 생성은 비동기(task) — recordUsage는 task 생성 성공(Suno 202/200) 시점 집계(완료 아님). 완료 실패해도 1회 소진되나 개인 서비스 규모에서 허용(사주·타로와 동일 기조)
|
||||
20
lib/__tests__/ai-usage.test.ts
Normal file
20
lib/__tests__/ai-usage.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { kstDayStartISO, SAJU_DAILY_LIMIT, TAROT_DAILY_LIMIT, MUSIC_DAILY_LIMIT } from '../ai-usage';
|
||||
|
||||
describe('kstDayStartISO', () => {
|
||||
it('KST 자정을 UTC로 환산한다 (KST 15:00 UTC = 당일 00:00 KST)', () => {
|
||||
// 2026-07-02T05:00:00Z = 2026-07-02 14:00 KST → 그날 KST 자정 = 2026-07-01T15:00:00Z
|
||||
expect(kstDayStartISO(new Date('2026-07-02T05:00:00Z'))).toBe('2026-07-01T15:00:00.000Z');
|
||||
});
|
||||
it('KST 자정 직후도 같은 날로 계산한다', () => {
|
||||
// 2026-07-01T15:30:00Z = 2026-07-02 00:30 KST → KST 자정 = 2026-07-01T15:00:00Z
|
||||
expect(kstDayStartISO(new Date('2026-07-01T15:30:00Z'))).toBe('2026-07-01T15:00:00.000Z');
|
||||
});
|
||||
it('제한 상수', () => {
|
||||
expect(SAJU_DAILY_LIMIT).toBe(1);
|
||||
expect(TAROT_DAILY_LIMIT).toBe(3);
|
||||
});
|
||||
it('음악 일일 제한 상수', () => {
|
||||
expect(MUSIC_DAILY_LIMIT).toBe(1);
|
||||
});
|
||||
});
|
||||
27
lib/__tests__/showcase-samples.test.ts
Normal file
27
lib/__tests__/showcase-samples.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SHOWCASE_SAMPLES } from '../showcase-samples';
|
||||
|
||||
const EXPECTED_SLUGS = [
|
||||
'bakery', 'corporate', 'dashboard', 'game',
|
||||
'interior', 'portfolio', 'reading', 'shopping',
|
||||
];
|
||||
|
||||
describe('SHOWCASE_SAMPLES', () => {
|
||||
it('데모 8종의 slug가 정확히 존재한다', () => {
|
||||
expect(SHOWCASE_SAMPLES.map((s) => s.slug).sort()).toEqual([...EXPECTED_SLUGS].sort());
|
||||
});
|
||||
|
||||
it('모든 항목에 title/description/tags가 채워져 있다', () => {
|
||||
for (const s of SHOWCASE_SAMPLES) {
|
||||
expect(s.title.length).toBeGreaterThan(0);
|
||||
expect(s.description.length).toBeGreaterThan(0);
|
||||
expect(s.tags.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('demo 경로는 /work/website/samples/[slug] 형식이다', () => {
|
||||
for (const s of SHOWCASE_SAMPLES) {
|
||||
expect(`/work/website/samples/${s.slug}`).toMatch(/^\/work\/website\/samples\/[a-z]+$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
40
lib/__tests__/showcase.test.ts
Normal file
40
lib/__tests__/showcase.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SHOWCASE_SLOTS } from '@/lib/showcase';
|
||||
import { MOCK_KEYS } from '@/app/components/mock/keys';
|
||||
|
||||
// 가드레일: 쇼케이스 슬롯이 라이트 목업 기반이고 보라/그래디언트 잔재가 없어야 한다.
|
||||
const VIOLET_HEXES = ['#c4b5fd', '#f0abfc', '#341a4f', '#4a1342', '#7c3aed', '#9c48ea'];
|
||||
|
||||
describe('SHOWCASE_SLOTS 가드레일', () => {
|
||||
it('8슬롯이고 slug가 고유하다', () => {
|
||||
expect(SHOWCASE_SLOTS.length).toBe(8);
|
||||
const slugs = SHOWCASE_SLOTS.map((s) => s.slug);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
});
|
||||
|
||||
it('각 슬롯의 mock이 유효한 MockKey이고 핵심 필드가 비어있지 않다', () => {
|
||||
for (const s of SHOWCASE_SLOTS) {
|
||||
expect(MOCK_KEYS).toContain(s.mock);
|
||||
expect(s.slug.length).toBeGreaterThan(0);
|
||||
expect(s.label.length).toBeGreaterThan(0);
|
||||
expect(s.title.length).toBeGreaterThan(0);
|
||||
expect(s.desc.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('어떤 슬롯에도 보라/그래디언트 hex가 남아있지 않다', () => {
|
||||
const serialized = JSON.stringify(SHOWCASE_SLOTS).toLowerCase();
|
||||
for (const hex of VIOLET_HEXES) {
|
||||
expect(serialized).not.toContain(hex.toLowerCase());
|
||||
}
|
||||
// 더 이상 palette 필드를 갖지 않는다 (라이트 목업 전환).
|
||||
for (const s of SHOWCASE_SLOTS) {
|
||||
expect('palette' in s).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('목업 종류가 최소 4가지 이상으로 다양하다 (단조 방지)', () => {
|
||||
const uniqueMocks = new Set(SHOWCASE_SLOTS.map((s) => s.mock));
|
||||
expect(uniqueMocks.size).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
});
|
||||
30
lib/__tests__/tarot-cards.test.ts
Normal file
30
lib/__tests__/tarot-cards.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { TAROT_DECK, findCard, CATEGORIES } from '../tarot/cards';
|
||||
|
||||
describe('TAROT_DECK', () => {
|
||||
it('78장이다', () => { expect(TAROT_DECK).toHaveLength(78); });
|
||||
it('slug가 고유하다', () => {
|
||||
const slugs = TAROT_DECK.map((c) => c.slug);
|
||||
expect(new Set(slugs).size).toBe(78);
|
||||
});
|
||||
it('메이저 22 + 마이너 56', () => {
|
||||
expect(TAROT_DECK.filter((c) => c.arcana === 'major')).toHaveLength(22);
|
||||
expect(TAROT_DECK.filter((c) => c.arcana === 'minor')).toHaveLength(56);
|
||||
});
|
||||
it('모든 카드에 필수 필드가 채워져 있다', () => {
|
||||
for (const c of TAROT_DECK) {
|
||||
expect(c.name.length).toBeGreaterThan(0);
|
||||
expect(c.nameEn.length).toBeGreaterThan(0);
|
||||
expect(c.keywords.length).toBeGreaterThan(0);
|
||||
expect(c.reversedKeywords.length).toBeGreaterThan(0);
|
||||
expect(c.meaningUpright.length).toBeGreaterThan(0);
|
||||
expect(c.meaningReversed.length).toBeGreaterThan(0);
|
||||
expect(c.image).toMatch(/^\/images\/tarot\/cards\/[a-z0-9-]+\.png$/);
|
||||
}
|
||||
});
|
||||
it('findCard가 slug로 카드를 찾는다', () => {
|
||||
expect(findCard('the-fool')?.nameEn).toBe('The Fool');
|
||||
expect(findCard('nonexistent')).toBeUndefined();
|
||||
});
|
||||
it('CATEGORIES는 6개', () => { expect(CATEGORIES).toHaveLength(6); });
|
||||
});
|
||||
28
lib/__tests__/tarot-reference.test.ts
Normal file
28
lib/__tests__/tarot-reference.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildReferenceBlock, buildContextMeta } from '../tarot/reference';
|
||||
import { findCard } from '../tarot/cards';
|
||||
|
||||
const picks = [
|
||||
{ card: findCard('the-fool')!, position: '과거', reversed: false },
|
||||
{ card: findCard('the-magician')!, position: '현재', reversed: true },
|
||||
{ card: findCard('the-high-priestess')!, position: '미래', reversed: false },
|
||||
];
|
||||
|
||||
describe('buildReferenceBlock', () => {
|
||||
it('각 카드의 위치·정역·키워드·의미를 텍스트 블록으로 만든다', () => {
|
||||
const block = buildReferenceBlock(picks);
|
||||
expect(block).toContain('과거');
|
||||
expect(block).toContain('The Fool');
|
||||
expect(block).toContain('정방향');
|
||||
expect(block).toContain('역방향');
|
||||
expect(block.length).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
describe('buildContextMeta', () => {
|
||||
it('메이저 비율·원소 분포·정역 흐름을 계산한다', () => {
|
||||
const meta = buildContextMeta(picks);
|
||||
expect(meta.major_minor_ratio).toBe('3:0');
|
||||
expect(meta.orientation_flow).toBe('upright→reversed→upright');
|
||||
expect(typeof meta.element_distribution).toBe('object');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user