Compare commits
64 Commits
055469a2d5
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
27
CLAUDE.md
27
CLAUDE.md
@@ -18,10 +18,13 @@
|
||||
| `/outsourcing` | 외주 개발 — 4단계 의뢰 폼 · 프로세스 · 포트폴리오 · FAQ |
|
||||
| `/products` | 완성 소프트웨어 목록 — 계좌이체 구매 |
|
||||
| `/products/[id]` | 제품 상세 — 구매 신청·결제 안내 |
|
||||
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
|
||||
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
|
||||
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
|
||||
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
||||
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
||||
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
||||
| `/mypage` | 4탭: 프로필 / 내 의뢰(타임라인) / 내 제품(다운로드) / 주문 내역 |
|
||||
| `/mypage` | 4탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 |
|
||||
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||
|
||||
## 숨김 서비스 (admin_token 세션 전용)
|
||||
@@ -30,10 +33,8 @@ admin/services 패널에서 ON/OFF 전환 가능.
|
||||
|
||||
| 경로 | 서비스 |
|
||||
|------|--------|
|
||||
| `/work/saju*` | 사주 분석 |
|
||||
| `/music/*` | 음악 팩 (단, `/music/packs`는 `/products`로 308 리다이렉트) |
|
||||
| `/gyeol` | CONTOUR PMF 설문 |
|
||||
| `/packages` | 레거시 패키지 |
|
||||
|
||||
## 기술 스택
|
||||
- **Framework**: Next.js 16 (App Router, TypeScript)
|
||||
@@ -81,15 +82,19 @@ 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/ — 숨김: 사주 서비스
|
||||
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
|
||||
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
|
||||
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
|
||||
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
|
||||
music/ — 숨김: 음악 팩 (packs는 /products로 308)
|
||||
gyeol/ — 숨김: CONTOUR PMF 설문
|
||||
|
||||
@@ -105,6 +110,12 @@ 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 해석 프롬프트
|
||||
```
|
||||
|
||||
---
|
||||
@@ -149,7 +160,6 @@ lib/
|
||||
→ POST /api/packs/sign-link → DSM 서명 링크 (4시간 TTL)
|
||||
```
|
||||
|
||||
- PG(PortOne) 코드는 `products.pay_method` 플래그 기반으로 보존만, 현재 미활성
|
||||
- `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환
|
||||
|
||||
---
|
||||
@@ -214,7 +224,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 }[];
|
||||
@@ -17,10 +17,13 @@ interface Quote {
|
||||
}
|
||||
|
||||
const STATUS = {
|
||||
draft: { label: '초안', color: 'bg-slate-700 text-slate-300' },
|
||||
sent: { label: '발송됨', color: 'bg-blue-900/50 text-blue-400' },
|
||||
accepted: { label: '수락됨', color: 'bg-green-900/50 text-green-400' },
|
||||
rejected: { label: '거절됨', color: 'bg-red-900/50 text-red-400' },
|
||||
draft: { label: '초안', color: 'bg-slate-700 text-slate-300' },
|
||||
sent: { label: '발송됨', color: 'bg-blue-900/50 text-blue-400' },
|
||||
accepted: { label: '수락 · 발주', color: 'bg-green-900/50 text-green-400' },
|
||||
rejected: { label: '거절됨', color: 'bg-red-900/50 text-red-400' },
|
||||
in_progress: { label: '진행중 · 발주', color: 'bg-blue-900/50 text-blue-400' },
|
||||
completed: { label: '완료 · 발주', color: 'bg-emerald-900/50 text-emerald-400' },
|
||||
delivered: { label: '납품 완료 · 발주', color: 'bg-teal-900/50 text-teal-400' },
|
||||
};
|
||||
|
||||
function calcTotal(items: Quote['items']) {
|
||||
|
||||
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,7 @@ 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 });
|
||||
}
|
||||
}
|
||||
@@ -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,9 @@ 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: '타로' },
|
||||
];
|
||||
|
||||
export default function TopNav() {
|
||||
@@ -65,6 +68,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 +87,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 +102,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 +122,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 +140,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 +156,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 +180,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 +199,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 +208,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 +228,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 +244,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 +256,7 @@ export default function TopNav() {
|
||||
|
||||
<div
|
||||
className="my-4 border-t"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
style={{ borderColor: line }}
|
||||
/>
|
||||
|
||||
{user ? (
|
||||
@@ -251,14 +264,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 +280,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,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>
|
||||
);
|
||||
}
|
||||
@@ -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,30 @@ 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 기록 탭 — 사주·타로 병합 렌더용 판별 유니언
|
||||
type AiRecordItem =
|
||||
| { kind: 'saju'; data: SajuRecordRow }
|
||||
| { kind: 'tarot'; data: TarotReadingRow };
|
||||
|
||||
interface Payment {
|
||||
id: string;
|
||||
created_at: string;
|
||||
@@ -87,6 +114,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 +138,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 +152,41 @@ function MyPageContent() {
|
||||
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
||||
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
|
||||
|
||||
// AI 기록 탭 — 타로 리딩 / 사주 기록
|
||||
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
|
||||
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
|
||||
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 { /* 무시 */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
@@ -160,10 +238,16 @@ function MyPageContent() {
|
||||
.limit(50);
|
||||
setProductOrders(prodOrders || []);
|
||||
|
||||
// 발주·진행 (quotes 기반) 조회
|
||||
await loadProjects();
|
||||
|
||||
// AI 기록(사주·타로) 조회
|
||||
await loadAiRecords();
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
init();
|
||||
}, []);
|
||||
}, [loadProjects, loadAiRecords]);
|
||||
|
||||
// ── 텔레그램 연결 ──
|
||||
const handleTelegramConnect = async () => {
|
||||
@@ -219,6 +303,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 +369,18 @@ 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 })),
|
||||
].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) || undefined },
|
||||
];
|
||||
|
||||
function selectTab(key: Tab) {
|
||||
@@ -514,9 +632,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 +897,56 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{aiRecords.map((item) =>
|
||||
item.kind === 'saju' ? (
|
||||
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
|
||||
) : (
|
||||
<TarotAiCard
|
||||
key={`tarot-${item.data.id}`}
|
||||
reading={item.data}
|
||||
expanded={expandedAiCards.has(item.data.id)}
|
||||
onToggle={() => toggleAiCard(item.data.id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -1060,6 +1290,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 +1399,176 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
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)' }}
|
||||
>
|
||||
<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)' }
|
||||
}
|
||||
>
|
||||
{c.live && (
|
||||
<span
|
||||
className="w-1.5 h-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 }}
|
||||
>
|
||||
{c.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
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,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</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)' }}
|
||||
>
|
||||
<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="text-[11px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: '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={c.live ? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' } : { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }}
|
||||
>
|
||||
{s.tag}
|
||||
{c.live && <span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />}
|
||||
{c.cat}
|
||||
</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}
|
||||
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{c.t}
|
||||
</h3>
|
||||
<p className="mt-2.5 flex-1 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{c.d}
|
||||
</p>
|
||||
<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 className="mt-5 flex flex-wrap gap-1.5">
|
||||
{c.tags.map((tag) => (
|
||||
<span key={tag} className="rounded px-2.5 py-1 text-xs" style={{ color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)', ...KOR_BODY }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 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
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{/* ─────────────────── 4a. 제공 분야 ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>scope</Eyebrow>
|
||||
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
이런 것들을 만들어 드립니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FIELDS.map((f, i) => (
|
||||
<ScrollReveal key={f.t} delay={i * 80}>
|
||||
<div className="h-full rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{f.t}
|
||||
</h3>
|
||||
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{f.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 4b. 진행 프로세스 ─────────────────── */}
|
||||
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>process</Eyebrow>
|
||||
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
상담부터 하자보수까지, 흐름이 분명합니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PROCESS.map((s, i) => (
|
||||
<ScrollReveal key={s.n} delay={i * 80}>
|
||||
<div className="relative h-full rounded-2xl border p-7 lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="relative z-10 inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<h3 className="mt-5 break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{s.t}
|
||||
</h3>
|
||||
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 5. FAQ ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-3xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>faq</Eyebrow>
|
||||
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
자주 묻는 질문
|
||||
</h2>
|
||||
</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 }}
|
||||
>
|
||||
{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)' }}
|
||||
>
|
||||
<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 }}
|
||||
>
|
||||
{item.a}
|
||||
</p>
|
||||
</details>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 space-y-3">
|
||||
{FAQ.map((item, i) => (
|
||||
<ScrollReveal key={item.q} delay={i * 80}>
|
||||
<details className="group overflow-hidden rounded-2xl border" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 break-keep px-6 py-5 font-semibold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{item.q}
|
||||
<svg className="shrink-0 transition-transform duration-200 group-open:rotate-45" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</summary>
|
||||
<p className="break-keep px-6 pb-5 text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{item.a}
|
||||
</p>
|
||||
</details>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 6. 의뢰 폼 ─── */}
|
||||
<section id="contact" className="scroll-mt-20" style={{ background: 'var(--jsm-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}
|
||||
>
|
||||
프로젝트 문의
|
||||
</h2>
|
||||
<p
|
||||
className="mt-5 text-lg leading-relaxed text-white/70 break-keep"
|
||||
style={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>
|
||||
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>
|
||||
010-3907-1392
|
||||
</a>
|
||||
</div>
|
||||
<ScrollReveal>
|
||||
<Eyebrow>contact</Eyebrow>
|
||||
<h2 className="break-keep text-3xl font-bold leading-tight lg:text-[2.4rem]" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
프로젝트 문의
|
||||
</h2>
|
||||
<p className="mt-5 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
영업일 2일 내에 회신드립니다. 아이디어 단계여도 괜찮습니다 — 상담에서 방향을 함께 잡아드립니다.
|
||||
</p>
|
||||
<div className="mt-8 space-y-3 border-t pt-8" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<a href="mailto:bgg8988@gmail.com" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Mail</span>
|
||||
bgg8988@gmail.com
|
||||
</a>
|
||||
<a href="tel:010-3907-1392" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Tel</span>
|
||||
010-3907-1392
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<div className="lg:col-span-3">
|
||||
<div
|
||||
className="rounded-2xl p-6 lg:p-8"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
<OutsourcingRequestForm />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
795
app/page.tsx
795
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,321 +153,364 @@ 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>
|
||||
|
||||
{/* 완성 소프트웨어 */}
|
||||
<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)' }}
|
||||
>
|
||||
<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 className="lg:pl-4">
|
||||
<MockWindow title="stock-report.app">
|
||||
<DashboardMock />
|
||||
</MockWindow>
|
||||
</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)' }}>
|
||||
{/* 신뢰 스트립 */}
|
||||
<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-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>
|
||||
</span>
|
||||
<span className="break-keep text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{s.t}
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
</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"
|
||||
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)' }}
|
||||
</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="w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: 'var(--jsm-accent)' }}
|
||||
/>
|
||||
직접 개발·운영 중
|
||||
</span>
|
||||
<h3
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{p.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.d}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-1.5">
|
||||
{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,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
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 className="mt-10">
|
||||
</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#portfolio"
|
||||
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>
|
||||
|
||||
{/* ─── 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">
|
||||
{hasProducts ? (
|
||||
<>
|
||||
<div className="flex items-end justify-between mb-10">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-3"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
{/* ─────────────────── 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="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)' }}
|
||||
>
|
||||
Software
|
||||
</p>
|
||||
<h2
|
||||
className="text-3xl lg:text-4xl font-bold break-keep"
|
||||
<span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
|
||||
직접 개발·운영 중
|
||||
</span>
|
||||
<h3
|
||||
className="break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
완성 소프트웨어
|
||||
</h2>
|
||||
{p.t}
|
||||
</h3>
|
||||
<p
|
||||
className="mt-2.5 flex-1 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.d}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-1.5">
|
||||
{p.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded px-2.5 py-1 text-xs"
|
||||
style={{ color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)', ...KOR_BODY }}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<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"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
전체 보기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 스탯 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)' }}
|
||||
>
|
||||
<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="grid md:grid-cols-3 gap-6">
|
||||
{featuredProducts.map((p) => (
|
||||
<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)]"
|
||||
<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="text-lg font-bold break-keep"
|
||||
className="mt-5 break-keep text-lg font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{p.name}
|
||||
{s.t}
|
||||
</h3>
|
||||
{p.description && (
|
||||
<p
|
||||
className="mt-2.5 text-sm leading-relaxed break-keep flex-1"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
<div
|
||||
className="mt-6 pt-5 flex items-center justify-between border-t"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
<p
|
||||
className="mt-2 break-keep text-sm leading-relaxed"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<span
|
||||
className="text-lg font-bold"
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 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 ? (
|
||||
<>
|
||||
<ScrollReveal>
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<Eyebrow>software</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>
|
||||
<Link
|
||||
href="/products"
|
||||
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>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 md:grid-cols-3">
|
||||
{featuredProducts.map((p, i) => (
|
||||
<ScrollReveal key={p.id} delay={i * 100}>
|
||||
<Link
|
||||
href={`/products/${p.id}`}
|
||||
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="break-keep 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 }}
|
||||
{p.name}
|
||||
</h3>
|
||||
{p.description && (
|
||||
<p
|
||||
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 flex items-center justify-between border-t pt-5"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
자세히
|
||||
<ArrowRight />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
<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 }}
|
||||
>
|
||||
자세히
|
||||
<ArrowRight />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 sm:hidden">
|
||||
@@ -460,71 +525,67 @@ export default async function Home() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="rounded-2xl border px-8 py-14 lg:px-14 lg:py-16 text-center"
|
||||
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)' }}
|
||||
<ScrollReveal>
|
||||
<div
|
||||
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)' }}
|
||||
>
|
||||
Coming soon
|
||||
</p>
|
||||
<h2
|
||||
className="text-2xl lg:text-3xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
검증된 완성 소프트웨어를 준비하고 있습니다
|
||||
</h2>
|
||||
<p
|
||||
className="mt-4 max-w-xl mx-auto leading-relaxed break-keep"
|
||||
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,
|
||||
}}
|
||||
>
|
||||
출시 소식 받기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
<Eyebrow>coming soon</Eyebrow>
|
||||
<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-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 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">
|
||||
<div className="max-w-3xl">
|
||||
<h2
|
||||
className="text-3xl lg:text-[2.5rem] font-bold leading-tight text-white break-keep"
|
||||
style={KOR_TIGHT}
|
||||
{/* 최종 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)' }}
|
||||
>
|
||||
프로젝트, 이야기부터 시작하세요
|
||||
</h2>
|
||||
<p
|
||||
className="mt-5 text-lg leading-relaxed text-white/70 break-keep max-w-2xl"
|
||||
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 }}
|
||||
>
|
||||
무료 상담 신청
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="max-w-3xl">
|
||||
<h2
|
||||
className="break-keep text-3xl font-bold leading-tight text-white lg:text-[2.5rem]"
|
||||
style={KOR_TIGHT}
|
||||
>
|
||||
프로젝트, 이야기부터 시작하세요
|
||||
</h2>
|
||||
<p
|
||||
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 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>
|
||||
|
||||
{/* ─── 카탈로그 / 준비 중 ─── */}
|
||||
{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">
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
{hasProducts ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{products.map((p) => {
|
||||
const features = (p.features ?? []).slice(0, 3);
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/products/${p.id}`}
|
||||
className="group flex flex-col rounded-2xl 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>
|
||||
)}
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,40 +307,36 @@ 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">
|
||||
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">
|
||||
성격, 재물운, 직업 적성, 애정운, 건강운, 대운 분석 등<br />
|
||||
Gemini 2.5 Pro가 생성하는 맞춤형 사주 해석을 받아보세요.
|
||||
</p>
|
||||
|
||||
{/* 미리보기 섹션 목록 */}
|
||||
<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>
|
||||
))}
|
||||
</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"
|
||||
>
|
||||
AI 상세 해석 받기 — 1,000원
|
||||
</PaymentButton>
|
||||
<p className="text-blue-200/40 text-xs mt-3">결제 후 즉시 AI 분석 시작 · 로그인 필요</p>
|
||||
<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-white/70 text-sm mb-6">
|
||||
성격, 재물운, 직업 적성, 애정운, 건강운, 대운 분석 등<br />
|
||||
Gemini 2.5 Pro가 생성하는 맞춤형 사주 해석을 받아보세요.
|
||||
</p>
|
||||
|
||||
{/* 미리보기 섹션 목록 */}
|
||||
<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/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>
|
||||
|
||||
<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 상세 해석 무료로 받기
|
||||
</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>
|
||||
<button
|
||||
onClick={() => { called.current = false; setStatus('idle'); }}
|
||||
className="text-xs text-blue-600 underline"
|
||||
>
|
||||
다시 시도하기
|
||||
</button>
|
||||
<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,45 +321,41 @@ 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="flex items-center gap-2 mb-2">
|
||||
<span className="text-base">🎱</span>
|
||||
<span className="text-xs font-extrabold text-amber-300">
|
||||
{level === 'great' ? '오늘 운이 아주 좋습니다! 로또도 한 번 도전해보세요.' : '사주 기반 행운 번호도 확인해보세요.'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-blue-200/70 leading-relaxed mb-4">
|
||||
용신 <strong className="text-amber-300">{yongShin}({yongShinKr})</strong> 오행이 담긴
|
||||
사주 기반 로또 번호가 아래에 준비되어 있습니다.
|
||||
{hasLottoSubscription
|
||||
? ' 구독 중이신 로또 서비스의 매주 최신 추천 번호도 함께 확인하세요.'
|
||||
: ' 로또 구독 시 대운 교차 분석으로 더 정밀한 번호를 매주 받을 수 있어요.'}
|
||||
</p>
|
||||
<a
|
||||
href="#saju-lotto-section"
|
||||
onClick={e => {
|
||||
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"
|
||||
>
|
||||
오늘의 로또 번호 추천 보기 ↓
|
||||
</a>
|
||||
<div className="rounded-2xl bg-[var(--jsm-navy)] p-5">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<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-white/70 leading-relaxed mb-4">
|
||||
용신 <strong className="text-[var(--jsm-accent-soft)]">{yongShin}({yongShinKr})</strong> 오행이 담긴
|
||||
사주 기반 로또 번호가 아래에 준비되어 있습니다.
|
||||
{hasLottoSubscription
|
||||
? ' 구독 중이신 로또 서비스의 매주 최신 추천 번호도 함께 확인하세요.'
|
||||
: ' 로또 구독 시 대운 교차 분석으로 더 정밀한 번호를 매주 받을 수 있어요.'}
|
||||
</p>
|
||||
<a
|
||||
href="#saju-lotto-section"
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
document.getElementById('saju-lotto-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
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 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,30 +106,17 @@ export default async function SajuResultPage({ searchParams }: PageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과)
|
||||
const { data: lottoSub } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id')
|
||||
// 로또 이용권 확인 — 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', 'active')
|
||||
.eq('status', 'paid')
|
||||
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
|
||||
.gte('created_at', thirtyOneDaysAgo)
|
||||
.maybeSingle();
|
||||
hasLottoSubscription = !!lottoSub;
|
||||
|
||||
// subscriptions에서 못 찾으면 orders 테이블로 폴백 (구독 마이그레이션 전 데이터)
|
||||
if (!hasLottoSubscription) {
|
||||
const now = new Date().toISOString();
|
||||
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 = !!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 |
|
||||
@@ -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 항목에 추가
|
||||
17
lib/__tests__/ai-usage.test.ts
Normal file
17
lib/__tests__/ai-usage.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
20
lib/__tests__/tarot-shuffle.test.ts
Normal file
20
lib/__tests__/tarot-shuffle.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
28
lib/ai-usage.ts
Normal file
28
lib/ai-usage.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import type { CrawlResult, BasicInfo, ListingInfo, FitmentEntry } from './types';
|
||||
|
||||
function getClient(): Anthropic {
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY;
|
||||
if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set');
|
||||
return new Anthropic({ apiKey });
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 자동차 부품 전문가이자 이베이 리스팅 최적화 전문가입니다.
|
||||
주어진 크롤링 데이터에서 정확한 부품 정보를 추출하고, eBay 리스팅에 최적화된 형태로 정리합니다.
|
||||
|
||||
중요 원칙:
|
||||
- 확인된 정보만 포함합니다. 추측하지 마세요.
|
||||
- Fitment(호환 차종)은 크롤링 데이터에서 확인된 것만 포함합니다.
|
||||
- 데이터에 없는 정보는 빈 문자열이나 빈 배열로 남깁니다.
|
||||
- 이베이 제목은 80자 이내, 핵심 키워드 포함 (브랜드 + 부품명 + 적용차종 + OEM번호)`;
|
||||
|
||||
interface AIAnalysisResult {
|
||||
basicInfo: BasicInfo;
|
||||
listing: ListingInfo;
|
||||
fitment: FitmentEntry[];
|
||||
}
|
||||
|
||||
export async function analyzeWithAI(
|
||||
partNumber: string,
|
||||
partName: string | undefined,
|
||||
crawlResults: CrawlResult[]
|
||||
): Promise<AIAnalysisResult> {
|
||||
// 크롤링 결과를 텍스트로 정리
|
||||
const crawlSummary = crawlResults
|
||||
.map(r => {
|
||||
if (!r.success) return `[${r.source}] 크롤링 실패: ${r.error}`;
|
||||
return `[${r.source}] 수집 데이터:\n${JSON.stringify(r.data, null, 2).slice(0, 3000)}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
|
||||
const userMessage = `품번: ${partNumber}
|
||||
${partName ? `품명: ${partName}` : ''}
|
||||
|
||||
아래는 여러 자동차 부품 사이트에서 크롤링한 데이터입니다. 이 데이터를 분석해 이베이 리스팅에 필요한 정보를 정리해주세요.
|
||||
|
||||
${crawlSummary}`;
|
||||
|
||||
const response = await getClient().messages.create({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
max_tokens: 4096,
|
||||
system: SYSTEM_PROMPT,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
tools: [{
|
||||
name: 'format_listing_data',
|
||||
description: '이베이 리스팅용 데이터를 구조화된 형태로 반환합니다',
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
basicInfo: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
partNumber: { type: 'string' as const },
|
||||
partName: { type: 'string' as const },
|
||||
brand: { type: 'string' as const },
|
||||
oemNumbers: { type: 'array' as const, items: { type: 'string' as const } },
|
||||
category: { type: 'string' as const },
|
||||
imageUrl: { type: 'string' as const },
|
||||
},
|
||||
required: ['partNumber', 'partName', 'brand', 'oemNumbers', 'category'] as const,
|
||||
},
|
||||
listing: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
title: { type: 'string' as const, description: '이베이 리스팅 제목 (80자 이내)' },
|
||||
category: { type: 'string' as const, description: '이베이 카테고리 ID' },
|
||||
itemSpecifics: {
|
||||
type: 'object' as const,
|
||||
additionalProperties: { type: 'string' as const },
|
||||
description: '이베이 Item Specifics (Brand, MPN, Type 등)',
|
||||
},
|
||||
},
|
||||
required: ['title', 'category', 'itemSpecifics'] as const,
|
||||
},
|
||||
fitment: {
|
||||
type: 'array' as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
year: { type: 'string' as const },
|
||||
make: { type: 'string' as const },
|
||||
model: { type: 'string' as const },
|
||||
engine: { type: 'string' as const },
|
||||
confidence: { type: 'string' as const, enum: ['high', 'medium', 'low'] },
|
||||
source: { type: 'string' as const },
|
||||
},
|
||||
required: ['year', 'make', 'model', 'engine', 'confidence', 'source'] as const,
|
||||
},
|
||||
description: '호환 차종 목록 (크롤링 데이터에서 확인된 것만)',
|
||||
},
|
||||
},
|
||||
required: ['basicInfo', 'listing', 'fitment'] as const,
|
||||
},
|
||||
}],
|
||||
tool_choice: { type: 'tool', name: 'format_listing_data' },
|
||||
});
|
||||
|
||||
// Tool Use 응답에서 결과 추출
|
||||
const toolUse = response.content.find(block => block.type === 'tool_use');
|
||||
if (!toolUse || toolUse.type !== 'tool_use') {
|
||||
throw new Error('AI 분석 결과를 파싱할 수 없습니다');
|
||||
}
|
||||
|
||||
const input = toolUse.input as Record<string, unknown>;
|
||||
if (!input.basicInfo || !input.listing || !Array.isArray(input.fitment)) {
|
||||
throw new Error('AI 응답에 필수 필드가 누락되었습니다');
|
||||
}
|
||||
const result = input as unknown as AIAnalysisResult;
|
||||
return result;
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
import * as cheerio from 'cheerio';
|
||||
import type { CrawlResult } from './types';
|
||||
|
||||
// 크롤러 설정
|
||||
const CRAWL_CONFIG = {
|
||||
rockAuto: {
|
||||
baseUrl: 'https://www.rockauto.com',
|
||||
searchUrl: 'https://www.rockauto.com/en/partsearch/',
|
||||
type: 'http' as const,
|
||||
rateLimit: 3000, // ms between requests
|
||||
},
|
||||
// 향후 사이트 추가
|
||||
};
|
||||
|
||||
// User-Agent 로테이션
|
||||
const USER_AGENTS = [
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0',
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Safari/605.1.15',
|
||||
];
|
||||
|
||||
function getRandomUA() {
|
||||
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
||||
}
|
||||
|
||||
// 딜레이 함수
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// HTTP 기반 크롤러
|
||||
async function fetchPage(url: string): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': getRandomUA(),
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.text();
|
||||
}
|
||||
|
||||
// RockAuto 검색
|
||||
export async function crawlRockAuto(partNumber: string): Promise<CrawlResult> {
|
||||
try {
|
||||
const searchUrl = `${CRAWL_CONFIG.rockAuto.searchUrl}?partnum=${encodeURIComponent(partNumber)}`;
|
||||
const html = await fetchPage(searchUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
// RockAuto 검색 결과 파싱
|
||||
// MVP: 기본 구조만 추출 (실제 셀렉터는 사이트 구조에 따라 조정 필요)
|
||||
const results: Record<string, unknown> = {
|
||||
searchUrl,
|
||||
title: $('title').text().trim(),
|
||||
// 부품 정보 추출 시도
|
||||
parts: [] as Array<Record<string, string>>,
|
||||
};
|
||||
|
||||
// 검색 결과에서 부품 정보 추출 시도
|
||||
// RockAuto의 실제 DOM 구조에 맞게 셀렉터 조정 필요
|
||||
const partsList: Array<Record<string, string>> = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
$('[id^="vPRD"]').each((_: number, el: any) => {
|
||||
const $el = $(el);
|
||||
partsList.push({
|
||||
name: $el.find('.listing-text-row-moreinfo-truck').text().trim() || $el.text().trim().slice(0, 100),
|
||||
price: $el.find('.listing-final-price').text().trim(),
|
||||
brand: $el.find('.listing-text-row-moreinfo-pair .listing-text-row-moreinfo-value').first().text().trim(),
|
||||
});
|
||||
});
|
||||
results.parts = partsList;
|
||||
|
||||
// 페이지 전체 텍스트도 보관 (AI 분석용)
|
||||
results.pageText = $('body').text().replace(/\s+/g, ' ').trim().slice(0, 5000);
|
||||
|
||||
return { source: 'RockAuto', success: true, data: results };
|
||||
} catch (error) {
|
||||
return {
|
||||
source: 'RockAuto',
|
||||
success: false,
|
||||
data: {},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eBay 검색 (공식 API — MVP에서는 간소화된 Browse API 호출)
|
||||
export async function searchEbay(partNumber: string): Promise<CrawlResult> {
|
||||
try {
|
||||
// eBay Browse API (인증 필요 — MVP에서는 비인증 검색)
|
||||
// 실제 구현 시 OAuth 토큰 필요
|
||||
const searchUrl = `https://www.ebay.com/sch/i.html?_nkw=${encodeURIComponent(partNumber)}&_sacat=6028`;
|
||||
const html = await fetchPage(searchUrl);
|
||||
const $ = cheerio.load(html);
|
||||
|
||||
const listings: Array<Record<string, string>> = [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
$('.s-item').slice(0, 5).each((_: number, el: any) => {
|
||||
const $el = $(el);
|
||||
listings.push({
|
||||
title: $el.find('.s-item__title').text().trim(),
|
||||
price: $el.find('.s-item__price').text().trim(),
|
||||
url: $el.find('.s-item__link').attr('href') || '',
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
source: 'eBay',
|
||||
success: true,
|
||||
data: { listings, searchUrl, pageText: $('body').text().replace(/\s+/g, ' ').trim().slice(0, 3000) },
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
source: 'eBay',
|
||||
success: false,
|
||||
data: {},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 메인 크롤링 오케스트레이터
|
||||
export async function crawlAll(partNumber: string): Promise<CrawlResult[]> {
|
||||
const results: CrawlResult[] = [];
|
||||
|
||||
// 순차 실행 (rate limiting 준수)
|
||||
results.push(await crawlRockAuto(partNumber));
|
||||
await delay(CRAWL_CONFIG.rockAuto.rateLimit);
|
||||
results.push(await searchEbay(partNumber));
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { PricingInfo, PriceSource } from './types';
|
||||
|
||||
// 환율 조회 (한국은행 공개 API 또는 fallback)
|
||||
export async function getExchangeRate(): Promise<{ rate: number; source: string; date: string }> {
|
||||
try {
|
||||
// ExchangeRate-API (무료 티어)
|
||||
const res = await fetch('https://open.er-api.com/v6/latest/USD');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
return {
|
||||
rate: data.rates?.KRW || 1380,
|
||||
source: 'ExchangeRate-API',
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return { rate: 1380, source: 'fallback', date: new Date().toISOString().slice(0, 10) };
|
||||
}
|
||||
|
||||
// HS Code 기반 관세율 (자동차 부품 기본)
|
||||
const CUSTOMS_RATES: Record<string, { hsCode: string; rate: number }> = {
|
||||
'fuel_pump': { hsCode: '8413.30', rate: 8 },
|
||||
'brake_pad': { hsCode: '6813.81', rate: 8 },
|
||||
'filter': { hsCode: '8421.23', rate: 8 },
|
||||
'sensor': { hsCode: '9032.89', rate: 0 },
|
||||
'bearing': { hsCode: '8482.10', rate: 8 },
|
||||
'default': { hsCode: '8708.99', rate: 8 }, // 기타 자동차 부품
|
||||
};
|
||||
|
||||
function estimateCustomsCategory(partName: string): { hsCode: string; rate: number } {
|
||||
const lower = partName.toLowerCase();
|
||||
if (lower.includes('pump')) return CUSTOMS_RATES.fuel_pump;
|
||||
if (lower.includes('brake') || lower.includes('pad')) return CUSTOMS_RATES.brake_pad;
|
||||
if (lower.includes('filter')) return CUSTOMS_RATES.filter;
|
||||
if (lower.includes('sensor')) return CUSTOMS_RATES.sensor;
|
||||
if (lower.includes('bearing')) return CUSTOMS_RATES.bearing;
|
||||
return CUSTOMS_RATES.default;
|
||||
}
|
||||
|
||||
// 가격 정보 종합
|
||||
export async function calculatePricing(
|
||||
sources: PriceSource[],
|
||||
partName: string
|
||||
): Promise<PricingInfo> {
|
||||
const exchangeRate = await getExchangeRate();
|
||||
const customs = estimateCustomsCategory(partName);
|
||||
|
||||
// 최저가 기준 관세 계산
|
||||
const usdPrices = sources.filter(s => s.currency === 'USD').map(s => s.price);
|
||||
const lowestUSD = usdPrices.length > 0 ? Math.min(...usdPrices) : 0;
|
||||
const krwValue = Math.round(lowestUSD * exchangeRate.rate);
|
||||
|
||||
// $150 이하 소액면세 (목록통관 기준)
|
||||
const isExempt = lowestUSD <= 150;
|
||||
const estimatedDuty = isExempt ? 0 : Math.round(krwValue * (customs.rate / 100));
|
||||
// VAT = (물품가 + 관세) * 10%
|
||||
const vat = isExempt ? 0 : Math.round((krwValue + estimatedDuty) * 0.1);
|
||||
const totalImportCost = krwValue + estimatedDuty + vat;
|
||||
|
||||
return {
|
||||
sources,
|
||||
exchangeRate,
|
||||
customs: {
|
||||
hsCode: customs.hsCode,
|
||||
dutyRate: `${customs.rate}%`,
|
||||
estimatedDuty,
|
||||
vat,
|
||||
totalImportCost,
|
||||
isExempt,
|
||||
disclaimer: '본 관세/부가세 계산은 참고용 추정치이며, 실제 통관 시 세관 심사에 따라 달라질 수 있습니다. 정확한 세액은 관세사에게 문의하세요.',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
export interface SearchRequest {
|
||||
partNumber: string;
|
||||
partName?: string;
|
||||
}
|
||||
|
||||
export interface BasicInfo {
|
||||
partNumber: string;
|
||||
partName: string;
|
||||
brand: string;
|
||||
oemNumbers: string[];
|
||||
category: string;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface ListingInfo {
|
||||
title: string;
|
||||
category: string;
|
||||
itemSpecifics: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FitmentEntry {
|
||||
year: string;
|
||||
make: string;
|
||||
model: string;
|
||||
engine: string;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface PriceSource {
|
||||
site: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PricingInfo {
|
||||
sources: PriceSource[];
|
||||
exchangeRate: { rate: number; source: string; date: string };
|
||||
customs: {
|
||||
hsCode: string;
|
||||
dutyRate: string;
|
||||
estimatedDuty: number;
|
||||
vat: number;
|
||||
totalImportCost: number;
|
||||
isExempt: boolean;
|
||||
disclaimer: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
success: boolean;
|
||||
data: {
|
||||
basicInfo: BasicInfo;
|
||||
listing: ListingInfo;
|
||||
fitment: FitmentEntry[];
|
||||
pricing: PricingInfo;
|
||||
rawData: Record<string, unknown>;
|
||||
meta: {
|
||||
searchedAt: string;
|
||||
sourcesChecked: string[];
|
||||
processingTime: string;
|
||||
aiModel: string;
|
||||
};
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CrawlResult {
|
||||
source: string;
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
error?: string;
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
export interface PortfolioItem {
|
||||
title: string;
|
||||
category: string;
|
||||
desc: string;
|
||||
result: string;
|
||||
tags: string[];
|
||||
status: string;
|
||||
statusType: string;
|
||||
priceRange: string;
|
||||
accentColor: string;
|
||||
accentBg: string;
|
||||
borderAccent: string;
|
||||
}
|
||||
|
||||
export const PORTFOLIO: PortfolioItem[] = [
|
||||
{
|
||||
title: '기업 브랜드 홈페이지',
|
||||
category: '웹사이트 제작 · Next.js',
|
||||
desc: '제조업체의 영업용 기업 소개 사이트. 서비스·연혁·팀 소개·문의 폼 포함. 모바일 반응형 및 SEO 최적화까지 포함하여 납품.',
|
||||
result: '납품 후 B2B 영업 미팅 시 "홈페이지 보고 연락했다" 비율 증가',
|
||||
tags: ['Next.js', 'Tailwind CSS', 'Vercel', 'SEO'],
|
||||
status: '납품 완료',
|
||||
statusType: 'done',
|
||||
priceRange: '50~200만원',
|
||||
accentColor: 'text-indigo-400',
|
||||
accentBg: 'bg-[#0d0a2e]',
|
||||
borderAccent: 'border-indigo-400/20',
|
||||
},
|
||||
{
|
||||
title: 'Gmail 자동화 RPA',
|
||||
category: 'RPA · 업무 자동화',
|
||||
desc: '거래처 이메일 수신 시 자동 분류, 답장 초안 작성, 담당자 알림 전송하는 Gmail 자동화 시스템.',
|
||||
result: '이메일 처리 시간 일 2시간 → 10분 (의뢰인 직접 확인)',
|
||||
tags: ['Python', 'Gmail API', 'Google Apps Script'],
|
||||
status: '납품 완료',
|
||||
statusType: 'done',
|
||||
priceRange: '30~150만원',
|
||||
accentColor: 'text-red-400',
|
||||
accentBg: 'bg-[#200a0a]',
|
||||
borderAccent: 'border-red-400/20',
|
||||
},
|
||||
{
|
||||
title: '쇼핑몰 가격 모니터링 봇',
|
||||
category: '웹 스크래핑 · 알림 자동화',
|
||||
desc: '경쟁사 쇼핑몰의 특정 상품 가격을 매일 모니터링하여 변동 시 텔레그램으로 즉시 알림.',
|
||||
result: '경쟁사 10곳 · 상품 50개 매일 자동 추적, 수동 확인 0분',
|
||||
tags: ['Python', 'Selenium', 'Telegram Bot'],
|
||||
status: '납품 완료',
|
||||
statusType: 'done',
|
||||
priceRange: '30~150만원',
|
||||
accentColor: 'text-violet-400',
|
||||
accentBg: 'bg-[#0d0a2e]',
|
||||
borderAccent: 'border-violet-400/20',
|
||||
},
|
||||
{
|
||||
title: '영업 일보 자동화 시스템',
|
||||
category: '엑셀 자동화 · 보고서 생성',
|
||||
desc: '영업 데이터 엑셀 파일을 자동으로 집계하여 일별/주별/월별 영업 일보 PDF를 생성하고 이메일 발송.',
|
||||
result: '보고서 작성 3시간 → 5분, 매일 09:00 자동 발송',
|
||||
tags: ['Python', 'OpenPyXL', 'ReportLab'],
|
||||
status: '납품 완료',
|
||||
statusType: 'done',
|
||||
priceRange: '30~150만원',
|
||||
accentColor: 'text-cyan-400',
|
||||
accentBg: 'bg-[#012030]',
|
||||
borderAccent: 'border-cyan-400/20',
|
||||
},
|
||||
{
|
||||
title: '부동산 공시지가 수집 시스템',
|
||||
category: '공공 데이터 · API 연동',
|
||||
desc: '국토교통부 공공 API를 통해 특정 지역 공시지가를 주기적으로 수집·저장하고 변동 알림 제공.',
|
||||
result: '전국 3개 지역 공시지가 주 1회 자동 수집·변동 알림',
|
||||
tags: ['Python', '공공데이터 API', 'PostgreSQL', 'Telegram'],
|
||||
status: '납품 완료',
|
||||
statusType: 'done',
|
||||
priceRange: '30~150만원',
|
||||
accentColor: 'text-blue-400',
|
||||
accentBg: 'bg-[#04102b]',
|
||||
borderAccent: 'border-blue-400/20',
|
||||
},
|
||||
];
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* 포트원 V2 결제 채널 설정
|
||||
* 포트원 admin에서 채널 추가 후 각 Channel Key를 .env.local에 설정
|
||||
*/
|
||||
|
||||
export type PayMethod = 'CARD' | 'EASY_PAY';
|
||||
|
||||
export interface PaymentChannel {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string; // SVG or emoji placeholder
|
||||
channelKey: string;
|
||||
payMethod: PayMethod;
|
||||
}
|
||||
|
||||
export const PAYMENT_CHANNELS: PaymentChannel[] = [
|
||||
{
|
||||
id: 'card',
|
||||
label: '신용/체크카드',
|
||||
icon: '💳',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KPN ?? '',
|
||||
payMethod: 'CARD',
|
||||
},
|
||||
{
|
||||
id: 'kakaopay',
|
||||
label: '카카오페이',
|
||||
icon: '🟡',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KAKAOPAY ?? '',
|
||||
payMethod: 'EASY_PAY',
|
||||
},
|
||||
{
|
||||
id: 'naverpay',
|
||||
label: '네이버페이',
|
||||
icon: '🟢',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_NAVERPAY ?? '',
|
||||
payMethod: 'EASY_PAY',
|
||||
},
|
||||
{
|
||||
id: 'tosspay',
|
||||
label: '토스페이',
|
||||
icon: '🔵',
|
||||
channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_TOSSPAY ?? '',
|
||||
payMethod: 'EASY_PAY',
|
||||
},
|
||||
];
|
||||
|
||||
/** channelKey가 설정된 채널만 반환 */
|
||||
export function getActiveChannels(): PaymentChannel[] {
|
||||
return PAYMENT_CHANNELS.filter((ch) => ch.channelKey.length > 0);
|
||||
}
|
||||
143
lib/products.ts
143
lib/products.ts
@@ -1,143 +0,0 @@
|
||||
export interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
type: 'one_time' | 'monthly' | 'annual';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const PRODUCTS: Record<string, Product> = {
|
||||
stock_starter_install: {
|
||||
id: 'stock_starter_install',
|
||||
name: '주식 스타터 설치',
|
||||
price: 99000,
|
||||
type: 'one_time',
|
||||
description: '1개 종목 자동 매매 설치',
|
||||
},
|
||||
stock_pro_install: {
|
||||
id: 'stock_pro_install',
|
||||
name: '주식 프로 설치',
|
||||
price: 199000,
|
||||
type: 'one_time',
|
||||
description: '5개 종목 + 전략 커스터마이징 설치',
|
||||
},
|
||||
stock_starter_monthly: {
|
||||
id: 'stock_starter_monthly',
|
||||
name: '주식 스타터 월 유지비',
|
||||
price: 29000,
|
||||
type: 'monthly',
|
||||
description: '스타터 월 유지보수 비용',
|
||||
},
|
||||
stock_pro_monthly: {
|
||||
id: 'stock_pro_monthly',
|
||||
name: '주식 프로 월 유지비',
|
||||
price: 49000,
|
||||
type: 'monthly',
|
||||
description: '프로 월 유지보수 비용',
|
||||
},
|
||||
saju_detail: {
|
||||
id: 'saju_detail',
|
||||
name: 'AI 사주 상세 리포트',
|
||||
price: 1000,
|
||||
type: 'one_time',
|
||||
description: 'AI 12가지 항목 상세 해석',
|
||||
},
|
||||
prompt_single: {
|
||||
id: 'prompt_single',
|
||||
name: '프롬프트 단건 설계',
|
||||
price: 30000,
|
||||
type: 'one_time',
|
||||
description: '업무 특화 프롬프트 1개 맞춤 설계 · 수정 1회 포함',
|
||||
},
|
||||
prompt_business: {
|
||||
id: 'prompt_business',
|
||||
name: '프롬프트 비즈니스 패키지',
|
||||
price: 99000,
|
||||
type: 'one_time',
|
||||
description: '업무 유형별 프롬프트 5개 설계 · 수정 3회 · 1:1 교육 포함',
|
||||
},
|
||||
prompt_team: {
|
||||
id: 'prompt_team',
|
||||
name: '프롬프트 팀/기업 패키지',
|
||||
price: 249000,
|
||||
type: 'one_time',
|
||||
description: '팀 전체 프롬프트 시스템 구축 · 10개 이상 설계 · 교육 자료 포함',
|
||||
},
|
||||
automation_basic: {
|
||||
id: 'automation_basic',
|
||||
name: '단순 업무 자동화',
|
||||
price: 50000,
|
||||
type: 'one_time',
|
||||
description: '단일 반복 업무 자동화 1건 개발 · 1~3일 납품',
|
||||
},
|
||||
automation_advanced: {
|
||||
id: 'automation_advanced',
|
||||
name: '업무 자동화 심화',
|
||||
price: 150000,
|
||||
type: 'one_time',
|
||||
description: '복합 업무 자동화 개발 · RPA·API 연동 · 1~2주 납품',
|
||||
},
|
||||
website_starter: {
|
||||
id: 'website_starter',
|
||||
name: '홈페이지 스타터 패키지',
|
||||
price: 200000,
|
||||
type: 'one_time',
|
||||
description: '5페이지 이내 반응형 홈페이지 · 기본 SEO · 3~5영업일 납품',
|
||||
},
|
||||
website_business: {
|
||||
id: 'website_business',
|
||||
name: '홈페이지 비즈니스 패키지',
|
||||
price: 1000000,
|
||||
type: 'one_time',
|
||||
description: '10페이지 이내 · 관리자 페이지 · SEO 최적화 · 1~2주 납품',
|
||||
},
|
||||
website_premium: {
|
||||
id: 'website_premium',
|
||||
name: '홈페이지 프리미엄 패키지',
|
||||
price: 2000000,
|
||||
type: 'one_time',
|
||||
description: '페이지 수 무제한 · 결제/회원 시스템 · DB 연동 · 일정 협의',
|
||||
},
|
||||
prompt_image_gen: {
|
||||
id: 'prompt_image_gen',
|
||||
name: 'AI 이미지 생성 마스터 프롬프트 패키지',
|
||||
price: 12900,
|
||||
type: 'one_time',
|
||||
description: '50종 이미지 생성 프롬프트 · 구도/조명/후처리 공식 포함 · 즉시 다운로드',
|
||||
},
|
||||
prompt_resume: {
|
||||
id: 'prompt_resume',
|
||||
name: 'AI 자소서·이력서 첨삭 마스터 프롬프트',
|
||||
price: 9900,
|
||||
type: 'one_time',
|
||||
description: '7가지 유형별 자소서 프롬프트 · STAR 기법 · ATS 최적화 · 즉시 다운로드',
|
||||
},
|
||||
prompt_email: {
|
||||
id: 'prompt_email',
|
||||
name: '비즈니스 이메일 마스터 프롬프트 패키지',
|
||||
price: 10900,
|
||||
type: 'one_time',
|
||||
description: '20종 비즈니스 이메일 프롬프트 · 상황별 템플릿 · 즉시 다운로드',
|
||||
},
|
||||
prompt_marketing: {
|
||||
id: 'prompt_marketing',
|
||||
name: '마케팅 카피·SNS 콘텐츠 프롬프트 패키지',
|
||||
price: 12900,
|
||||
type: 'one_time',
|
||||
description: '플랫폼별 카피 프롬프트 30종 · 광고 문구 · SNS 캡션 · 즉시 다운로드',
|
||||
},
|
||||
prompt_report: {
|
||||
id: 'prompt_report',
|
||||
name: '업무 보고서·기획서 작성 프롬프트 패키지',
|
||||
price: 10900,
|
||||
type: 'one_time',
|
||||
description: '보고서/기획서/회의록 유형별 프롬프트 25종 · 즉시 다운로드',
|
||||
},
|
||||
ai_kit_monthly: {
|
||||
id: 'ai_kit_monthly',
|
||||
name: 'AI 자동화 월 구독 키트',
|
||||
price: 19900,
|
||||
type: 'monthly',
|
||||
description: '소상공인·직장인을 위한 AI 자동화 도구 월 구독 · 매월 업데이트',
|
||||
},
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
// SaaS 제품 카탈로그 (/packages)
|
||||
//
|
||||
// 확장 규칙: 새 SaaS 제품을 출시하면 SAAS_CATALOG 배열에 객체 하나만 추가하면
|
||||
// /packages 페이지에 카드가 자동으로 노출된다. 결제는 productId로 lib/products.ts의
|
||||
// PRODUCTS 정의와 subscriptions 인프라에 연결한다.
|
||||
//
|
||||
// 음악(AI 음악 생성 개발 가이드 패키지)은 단품 라인이므로 여기에 넣지 않는다(/music 유지).
|
||||
|
||||
export type SaasStatus = 'available' | 'coming_soon';
|
||||
|
||||
export interface SaasCatalogItem {
|
||||
/** /packages 내 식별자 (향후 /packages/[slug] 상세에 사용) */
|
||||
slug: string;
|
||||
/** 카드 제목 */
|
||||
name: string;
|
||||
/** 한 줄 요약 (카드 상단) */
|
||||
tagline: string;
|
||||
/** 카드 본문 설명 */
|
||||
description: string;
|
||||
/** 가격 표시용 라벨 (예: "월 ₩29,000"). 미정이면 생략 */
|
||||
priceLabel?: string;
|
||||
/** 과금 형태 */
|
||||
billing: 'monthly' | 'one_time';
|
||||
/** 노출 상태 — available: 구매 가능 / coming_soon: 예고 */
|
||||
status: SaasStatus;
|
||||
/** 핵심 기능 목록 */
|
||||
features: string[];
|
||||
/** 분류 라벨 (예: '자동화') */
|
||||
category: string;
|
||||
/** lib/products.ts PRODUCTS 키 참조 (결제 연결, available일 때) */
|
||||
productId?: string;
|
||||
/** available일 때 상세/결제 경로 */
|
||||
href?: string;
|
||||
/** 카드 강조 뱃지 (예: 'NEW') */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 등록된 SaaS 제품 목록.
|
||||
*
|
||||
* 2026-05-31 현재 비어 있다. 메이킹 스페이스에서 검증된 자동화가 1개 확정되면
|
||||
* 아래 형태로 항목을 추가한다:
|
||||
*
|
||||
* {
|
||||
* slug: 'making-verify',
|
||||
* name: '메이킹 검증 자동화',
|
||||
* tagline: '...',
|
||||
* description: '...',
|
||||
* priceLabel: '월 ₩29,000',
|
||||
* billing: 'monthly',
|
||||
* status: 'available',
|
||||
* features: ['...'],
|
||||
* category: '자동화',
|
||||
* productId: 'making_verify_monthly', // lib/products.ts에 함께 추가
|
||||
* href: '/packages/making-verify',
|
||||
* }
|
||||
*/
|
||||
export const SAAS_CATALOG: SaasCatalogItem[] = [];
|
||||
|
||||
export const getAvailablePackages = () =>
|
||||
SAAS_CATALOG.filter((p) => p.status === 'available');
|
||||
|
||||
export const getComingSoonPackages = () =>
|
||||
SAAS_CATALOG.filter((p) => p.status === 'coming_soon');
|
||||
@@ -3,7 +3,7 @@ import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
|
||||
/** 숨김 가능 서비스 id (service_settings.id와 일치) */
|
||||
export type HideableService = 'saju' | 'music' | 'gyeol' | 'packages' | 'lotto';
|
||||
export type HideableService = 'music' | 'gyeol' | 'lotto';
|
||||
|
||||
/**
|
||||
* 서비스 노출 여부. admin_token 세션이면 항상 true.
|
||||
|
||||
18
lib/showcase-samples.ts
Normal file
18
lib/showcase-samples.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** /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: ['콘텐츠', '블로그'] },
|
||||
];
|
||||
23
lib/showcase.ts
Normal file
23
lib/showcase.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Deep Field 쇼케이스 8슬롯 — 단일 소스 (라이트 MockWindow 목업 기반).
|
||||
* href가 있는 슬롯만 클릭 가능 (샘플 데모 완료 시 href 추가). */
|
||||
import type { MockKey } from '@/app/components/mock/keys';
|
||||
|
||||
export interface ShowcaseSlot {
|
||||
slug: string;
|
||||
label: string; // 모노스페이스 컨셉 라벨 (영문)
|
||||
title: string; // 카드 타이틀 (한글)
|
||||
desc: string; // 한 줄 설명
|
||||
mock: MockKey; // 카드에 렌더할 라이트 목업 화면
|
||||
href?: string; // 데모 링크 (있으면 클릭 가능)
|
||||
}
|
||||
|
||||
export const SHOWCASE_SLOTS: ShowcaseSlot[] = [
|
||||
{ slug: 'corporate', label: 'corporate', title: '기업 브랜드 사이트', desc: '신뢰를 첫인상으로 — 브랜드 스토리와 회사 소개', mock: 'site' },
|
||||
{ slug: 'commerce', label: 'commerce', title: '커머스 스토어', desc: '탐색부터 결제까지 끊김 없는 구매 동선', mock: 'commerce' },
|
||||
{ slug: 'dashboard', label: 'dashboard', title: '데이터 대시보드', desc: '실시간 지표를 한눈에 — 의사결정용 화면', mock: 'dashboard' },
|
||||
{ slug: 'automation', label: 'automation', title: '봇·자동화 알림', desc: '체결·알림·리포트를 사람 손 없이 자동 전송', mock: 'feed' },
|
||||
{ slug: 'matching', label: 'matching', title: '조건 매칭 시스템', desc: '수집·필터·매칭으로 원하는 것만 골라내는 화면', mock: 'match' },
|
||||
{ slug: 'booking', label: 'local shop', title: '예약·매장 사이트', desc: '예약·주문이 자연스러운 동네 가게의 얼굴', mock: 'booking' },
|
||||
{ slug: 'portfolio', label: 'portfolio', title: '포트폴리오', desc: '작업물이 주인공이 되는 미니멀 갤러리', mock: 'site' },
|
||||
{ slug: 'editorial', label: 'editorial', title: '에디토리얼·매거진', desc: '읽는 경험을 설계한 콘텐츠 사이트', mock: 'site' },
|
||||
];
|
||||
723
lib/tarot/cards.ts
Normal file
723
lib/tarot/cards.ts
Normal file
@@ -0,0 +1,723 @@
|
||||
// 타로 78장 카드 데이터
|
||||
// web-ui(src/pages/tarot/data/cards.js)에서 데이터·알고리즘 값 변경 없이 TS로 포팅.
|
||||
|
||||
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[] };
|
||||
|
||||
type Element = TarotCard['element'];
|
||||
type Suit = NonNullable<TarotCard['suit']>;
|
||||
type TarotSymbol = { label: string; meaning: string };
|
||||
|
||||
type MajorArcanaEntry = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
element: Element;
|
||||
keywords: string[];
|
||||
reversedKeywords: string[];
|
||||
meaningUpright: string;
|
||||
meaningReversed: string;
|
||||
};
|
||||
|
||||
type MajorDetail = {
|
||||
keywords: string[];
|
||||
reversedKeywords: string[];
|
||||
meaningUpright: string;
|
||||
meaningReversed: string;
|
||||
symbols: TarotSymbol[];
|
||||
};
|
||||
|
||||
type SuitDetail = {
|
||||
domain: string;
|
||||
elementMeaning: string;
|
||||
symbolLabels: string[];
|
||||
symbolMeanings: string[];
|
||||
};
|
||||
|
||||
type RankDetail = {
|
||||
keywords: string[];
|
||||
reversedKeywords: string[];
|
||||
upright: string;
|
||||
reversed: string;
|
||||
symbols: TarotSymbol[];
|
||||
};
|
||||
|
||||
type SuitInfo = { suit: Suit; element: Element; kr: string };
|
||||
|
||||
type MinorDetails = {
|
||||
keywords: string[];
|
||||
reversedKeywords: string[];
|
||||
meaningUpright: string;
|
||||
meaningReversed: string;
|
||||
symbols: TarotSymbol[];
|
||||
};
|
||||
|
||||
const cardImage = (slug: string): string => `/images/tarot/cards/${slug}.png`;
|
||||
|
||||
const MAJOR_ARCANA: MajorArcanaEntry[] = [
|
||||
{ id: 0, slug: 'the-fool', name: '바보', nameEn: 'The Fool', element: 'air',
|
||||
keywords: ['새로운 시작','도약','순수','자유','무한한 가능성'],
|
||||
reversedKeywords: ['무모함','경솔함','위험','방향 상실','준비 부족'],
|
||||
meaningUpright: '미지의 세계로 내딛는 첫걸음. 계산보다 직관과 신뢰로 시작하는 시기. 위험은 있으나 그 자체가 성장의 통로.',
|
||||
meaningReversed: '준비 없이 뛰어들어 위험을 자초하거나, 두려움으로 첫걸음을 미루는 상태.' },
|
||||
{ id: 1, slug: 'the-magician', name: '마법사', nameEn: 'The Magician', element: 'air',
|
||||
keywords: ['의지','창조','집중','실행력','자기 효능감'],
|
||||
reversedKeywords: ['조작','자기 기만','산만함','잠재력 미발현'],
|
||||
meaningUpright: '내가 가진 자원과 의지를 명확히 모아 현실로 옮길 수 있는 시기. 시작과 추진력이 일치한다.',
|
||||
meaningReversed: '의도가 흐려지거나 능력을 잘못 사용해 자기 기만에 빠질 위험.' },
|
||||
{ id: 2, slug: 'the-high-priestess', name: '여사제', nameEn: 'The High Priestess', element: 'water',
|
||||
keywords: ['직관','내면의 지혜','비밀','잠재의식','신비'],
|
||||
reversedKeywords: ['직관 무시','정보 단절','억압','표면적 판단'],
|
||||
meaningUpright: '드러나지 않은 진실을 들여다볼 시기. 외부 답이 아닌 내면의 신호에 귀 기울일 때 길이 보인다.',
|
||||
meaningReversed: '직관을 무시하거나 비밀이 노출되어 균형이 깨지는 상태.' },
|
||||
{ id: 3, slug: 'the-empress', name: '여황제', nameEn: 'The Empress', element: 'earth',
|
||||
keywords: ['풍요','창조성','어머니','자연','감각적 충만'],
|
||||
reversedKeywords: ['창조 정체','과보호','의존','정서적 소진'],
|
||||
meaningUpright: '풍요와 창조가 무르익는 시기. 보살핌·예술·자연과의 연결에서 에너지가 자라남.',
|
||||
meaningReversed: '돌봄이 과해 자신을 잃거나, 창조 흐름이 정체된 상태.' },
|
||||
{ id: 4, slug: 'the-emperor', name: '황제', nameEn: 'The Emperor', element: 'fire',
|
||||
keywords: ['권위','구조','책임','통제','아버지'],
|
||||
reversedKeywords: ['독선','경직','통제 욕구','권위 남용'],
|
||||
meaningUpright: '질서와 책임을 세워 안정을 만드는 시기. 명확한 경계와 원칙이 힘이 된다.',
|
||||
meaningReversed: '경직된 통제가 관계를 막거나, 권위 남용으로 신뢰가 깨질 위험.' },
|
||||
{ id: 5, slug: 'the-hierophant', name: '교황', nameEn: 'The Hierophant', element: 'earth',
|
||||
keywords: ['전통','가르침','믿음','제도','조언자'],
|
||||
reversedKeywords: ['관습 거부','독학','권위 도전','형식주의'],
|
||||
meaningUpright: '전통과 멘토의 지혜를 빌릴 때. 검증된 길과 가르침이 도움을 준다.',
|
||||
meaningReversed: '관습이 답이 되지 않거나, 자기만의 길을 새로 찾고 싶은 시기.' },
|
||||
{ id: 6, slug: 'the-lovers', name: '연인', nameEn: 'The Lovers', element: 'air',
|
||||
keywords: ['사랑','선택','조화','가치관 일치','결합'],
|
||||
reversedKeywords: ['관계 갈등','선택의 어려움','가치관 충돌','미성숙한 결정'],
|
||||
meaningUpright: '깊은 결합과 가치관의 일치. 중요한 선택을 마음으로부터 내릴 때.',
|
||||
meaningReversed: '두 길 사이에서 머뭇거리거나, 이미 내린 선택의 의구심이 커지는 시기.' },
|
||||
{ id: 7, slug: 'the-chariot', name: '전차', nameEn: 'The Chariot', element: 'water',
|
||||
keywords: ['의지','전진','승리','자기 통제','목표 추진'],
|
||||
reversedKeywords: ['방향 상실','자기 통제 부족','과욕','지연'],
|
||||
meaningUpright: '명확한 목표와 강한 의지로 전진하는 시기. 상반된 힘들을 조율해 추진력으로 바꾼다.',
|
||||
meaningReversed: '방향이 흔들리거나 통제력을 잃어 진전이 멈춘 상태.' },
|
||||
{ id: 8, slug: 'strength', name: '힘', nameEn: 'Strength', element: 'fire',
|
||||
keywords: ['내면의 힘','용기','부드러운 통제','인내','자제'],
|
||||
reversedKeywords: ['자신감 부족','감정 과잉','자제력 상실'],
|
||||
meaningUpright: '강제가 아닌 부드러움으로 어려움을 다루는 시기. 진짜 힘은 자기 통제와 인내에서 나온다.',
|
||||
meaningReversed: '감정에 휘말려 자제력을 잃거나, 자신감이 흔들리는 상태.' },
|
||||
{ id: 9, slug: 'the-hermit', name: '은둔자', nameEn: 'The Hermit', element: 'earth',
|
||||
keywords: ['성찰','고독','내면의 빛','지혜 추구','은둔'],
|
||||
reversedKeywords: ['고립','회피','외로움','자기 폐쇄'],
|
||||
meaningUpright: '바깥 소음에서 물러나 자기 안의 빛으로 길을 찾는 시기.',
|
||||
meaningReversed: '회피·고립이 길어져 균형이 깨진 상태.' },
|
||||
{ id: 10, slug: 'wheel-of-fortune', name: '운명의 수레바퀴', nameEn: 'Wheel of Fortune', element: 'fire',
|
||||
keywords: ['전환점','순환','운명','기회','변화'],
|
||||
reversedKeywords: ['악순환','정체','불운','통제력 상실'],
|
||||
meaningUpright: '큰 흐름이 바뀌는 전환점. 받아들이고 흐름에 올라타면 새로운 국면이 열린다.',
|
||||
meaningReversed: '순환의 하강기. 흐름을 거스르기보다 자세를 낮추고 견뎌야 할 시기.' },
|
||||
{ id: 11, slug: 'justice', name: '정의', nameEn: 'Justice', element: 'air',
|
||||
keywords: ['정의','균형','진실','책임','명료성'],
|
||||
reversedKeywords: ['불공정','책임 회피','판단 왜곡'],
|
||||
meaningUpright: '원인과 결과가 명확히 드러나는 시기. 진실에 기초한 결정이 길을 연다.',
|
||||
meaningReversed: '책임을 외면하거나 한쪽 시각에 치우쳐 균형이 깨진 상태.' },
|
||||
{ id: 12, slug: 'the-hanged-man', name: '매달린 사람', nameEn: 'The Hanged Man', element: 'water',
|
||||
keywords: ['시야 전환','내려놓음','희생','수용','새로운 관점'],
|
||||
reversedKeywords: ['고집','정체','희생 거부','시야의 닫힘'],
|
||||
meaningUpright: '잠시 멈춰 시야를 뒤집어 보는 시기. 강제로 풀려 하지 말고 다른 각도를 받아들이자.',
|
||||
meaningReversed: '내려놓아야 할 것을 붙들고 있어 정체가 길어지는 상태.' },
|
||||
{ id: 13, slug: 'death', name: '죽음', nameEn: 'Death', element: 'water',
|
||||
keywords: ['종결','변형','놓아주기','재탄생','전환'],
|
||||
reversedKeywords: ['변화 저항','놓지 못함','정체','두려움'],
|
||||
meaningUpright: '한 챕터가 닫히고 새로운 챕터가 열리는 결정적 전환. 끝맺음이 새 시작의 조건이다.',
|
||||
meaningReversed: '끝나야 할 것을 붙들어 변화가 늦어지는 상태.' },
|
||||
{ id: 14, slug: 'temperance', name: '절제', nameEn: 'Temperance', element: 'fire',
|
||||
keywords: ['조화','중용','연금술적 결합','인내','치유'],
|
||||
reversedKeywords: ['불균형','과잉','조급함','조화 상실'],
|
||||
meaningUpright: '서로 다른 것들을 천천히 섞어 균형 잡힌 상태로 만드는 시기. 조급함보다 끈기.',
|
||||
meaningReversed: '극단으로 치우치거나 조급함이 흐름을 깨는 상태.' },
|
||||
{ id: 15, slug: 'the-devil', name: '악마', nameEn: 'The Devil', element: 'earth',
|
||||
keywords: ['속박','집착','중독','물질주의','그림자'],
|
||||
reversedKeywords: ['해방','구속에서 벗어남','자각','단절'],
|
||||
meaningUpright: '스스로 묶어둔 사슬을 직시할 시기. 욕망·중독·집착이 시야를 가린다.',
|
||||
meaningReversed: '구속이 풀리며 의식적인 해방이 가능한 상태.' },
|
||||
{ id: 16, slug: 'the-tower', name: '탑', nameEn: 'The Tower', element: 'fire',
|
||||
keywords: ['붕괴','갑작스러운 변화','각성','진실 노출'],
|
||||
reversedKeywords: ['붕괴 회피','두려움','지연된 충격'],
|
||||
meaningUpright: '거짓 기반 위의 구조가 갑자기 무너지는 시기. 충격은 크나 진실의 자리를 만든다.',
|
||||
meaningReversed: '붕괴를 미루거나 외면해 더 큰 충격을 키울 수 있는 상태.' },
|
||||
{ id: 17, slug: 'the-star', name: '별', nameEn: 'The Star', element: 'air',
|
||||
keywords: ['희망','영감','치유','평온','신뢰'],
|
||||
reversedKeywords: ['희망 상실','자기 의심','단절감'],
|
||||
meaningUpright: '폭풍 뒤의 평온. 영감이 회복되고 길게 볼 힘이 돌아오는 시기.',
|
||||
meaningReversed: '의심과 무력감으로 빛이 잘 보이지 않는 상태.' },
|
||||
{ id: 18, slug: 'the-moon', name: '달', nameEn: 'The Moon', element: 'water',
|
||||
keywords: ['직관','무의식','환영','불안','꿈'],
|
||||
reversedKeywords: ['혼란 해소','진실 드러남','직관 회복'],
|
||||
meaningUpright: '명확하지 않은 신호와 감정의 파도. 직관을 따르되 환상은 분별해야 하는 시기.',
|
||||
meaningReversed: '안개가 걷히며 가려졌던 진실이 드러나는 상태.' },
|
||||
{ id: 19, slug: 'the-sun', name: '태양', nameEn: 'The Sun', element: 'fire',
|
||||
keywords: ['기쁨','성공','명료성','활력','진실'],
|
||||
reversedKeywords: ['과신','단편적 기쁨','피상적 성공'],
|
||||
meaningUpright: '명료하고 따뜻한 시기. 노력의 결실이 분명히 드러난다.',
|
||||
meaningReversed: '겉만 환한 기쁨이거나, 자만으로 본질을 놓칠 위험.' },
|
||||
{ id: 20, slug: 'judgement', name: '심판', nameEn: 'Judgement', element: 'fire',
|
||||
keywords: ['각성','부름','재평가','부활','결단'],
|
||||
reversedKeywords: ['자기 비판','부름 무시','과거에 묶임'],
|
||||
meaningUpright: '오랜 흐름을 정산하고 새 부름에 응답하는 시기. 결단의 순간.',
|
||||
meaningReversed: '과거의 비판이나 미련에 묶여 새 길로 나서지 못하는 상태.' },
|
||||
{ id: 21, slug: 'the-world', name: '세계', nameEn: 'The World', element: 'earth',
|
||||
keywords: ['완성','통합','성취','순환의 닫힘','전체성'],
|
||||
reversedKeywords: ['미완성','마무리 지연','반복'],
|
||||
meaningUpright: '한 사이클의 완성과 통합. 다음 시작을 위한 단단한 기반이 마련된다.',
|
||||
meaningReversed: '마무리가 늦어지거나 반복으로 인해 다음 단계로 나아가지 못하는 상태.' },
|
||||
];
|
||||
|
||||
const SUITS: SuitInfo[] = [
|
||||
{ suit: 'wands', element: 'fire', kr: '완드' },
|
||||
{ suit: 'cups', element: 'water', kr: '컵' },
|
||||
{ suit: 'swords', element: 'air', kr: '소드' },
|
||||
{ suit: 'pentacles', element: 'earth', kr: '펜타클' },
|
||||
];
|
||||
|
||||
const RANK_NAMES: string[] = ['에이스', '2', '3', '4', '5', '6', '7', '8', '9', '10', '시종', '기사', '여왕', '왕'];
|
||||
const RANK_EN: string[] = ['Ace', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seven', 'Eight', 'Nine', 'Ten', 'Page', 'Knight', 'Queen', 'King'];
|
||||
const SUIT_NAMES_EN: Record<Suit, string> = { wands: 'Wands', cups: 'Cups', swords: 'Swords', pentacles: 'Pentacles' };
|
||||
|
||||
const MAJOR_DETAILS: Record<string, MajorDetail> = {
|
||||
'the-fool': {
|
||||
keywords: ['새로운 시작', '자유', '도약', '순수한 신뢰', '미지의 여정'],
|
||||
reversedKeywords: ['경솔함', '무모함', '준비 부족', '현실감 결여', '위험 신호 무시'],
|
||||
meaningUpright: '바보는 아직 증명되지 않은 길 앞에서 마음을 여는 카드입니다. 완벽한 계획보다 첫걸음, 계산보다 경험, 안전한 반복보다 성장 가능성이 중요합니다. 질문의 핵심에는 새 출발, 관점 전환, 예전 방식에서 벗어나려는 욕구가 있으며, 지금은 모든 답을 알고 움직이기보다 길 위에서 배우는 태도가 필요합니다.',
|
||||
meaningReversed: '역방향의 바보는 자유가 충동으로 흐르거나, 준비 없는 낙관이 위험을 키우는 상태를 말합니다. 반대로 실패가 두려워 출발을 계속 미루는 모습일 수도 있습니다. 지금 필요한 것은 모험 자체를 포기하는 것이 아니라 최소한의 기준, 정보, 책임을 갖춘 뒤 움직이는 것입니다.',
|
||||
symbols: [
|
||||
{ label: '절벽', meaning: '익숙한 세계와 미지의 세계 사이의 경계. 도약은 가능성이지만 동시에 주의가 필요합니다.' },
|
||||
{ label: '작은 짐', meaning: '과거의 짐이 가볍다는 뜻입니다. 아직 고정된 역할이나 부담이 적어 새로운 선택이 열려 있습니다.' },
|
||||
{ label: '흰 개', meaning: '본능, 동반자, 경고의 상징입니다. 순수한 충동을 따르되 위험 신호도 들어야 합니다.' },
|
||||
{ label: '태양과 산', meaning: '높은 이상과 장기 여정. 지금의 시작은 당장 작아 보여도 큰 성장으로 이어질 수 있습니다.' },
|
||||
],
|
||||
},
|
||||
'the-magician': {
|
||||
keywords: ['의지', '실행력', '자원 활용', '집중', '현실화'],
|
||||
reversedKeywords: ['조작', '산만함', '잠재력 낭비', '말뿐인 계획', '자기기만'],
|
||||
meaningUpright: '마법사는 생각을 현실로 끌어내리는 능력의 카드입니다. 이미 필요한 도구와 재료는 상당 부분 갖춰져 있으며, 관건은 흩어진 에너지를 한 방향으로 모으는 것입니다. 질문이 일, 관계, 창작 어느 영역이든 지금은 기다림보다 명확한 의도, 설득, 기술, 실행 계획이 결과를 만듭니다.',
|
||||
meaningReversed: '역방향의 마법사는 능력은 있으나 초점이 흐려져 결과가 나오지 않거나, 말과 이미지로 상황을 과장하는 모습을 경고합니다. 누군가의 능숙한 표현에 휘둘리고 있거나, 스스로도 준비보다 포장에 의존하고 있을 수 있습니다. 의도와 수단이 윤리적으로 일치하는지 확인해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '네 가지 도구', meaning: '완드, 컵, 검, 펜타클은 의지·감정·사고·물질 자원을 뜻합니다. 필요한 요소가 이미 테이블 위에 있습니다.' },
|
||||
{ label: '위와 아래를 잇는 손', meaning: '영감과 현실을 연결하는 행위. 생각을 구체적 행동으로 번역해야 함을 보여줍니다.' },
|
||||
{ label: '무한대 표식', meaning: '지속 가능한 잠재력과 순환하는 에너지. 재능은 반복 사용될 때 힘이 됩니다.' },
|
||||
{ label: '붉은 장미와 흰 백합', meaning: '욕망과 순수한 의도의 조화. 열망이 분명할수록 방향도 선명해집니다.' },
|
||||
],
|
||||
},
|
||||
'the-high-priestess': {
|
||||
keywords: ['직관', '침묵', '잠재의식', '비밀', '내면의 지혜'],
|
||||
reversedKeywords: ['직관 무시', '정보 은폐', '불안한 추측', '내면 단절', '표면적 판단'],
|
||||
meaningUpright: '여사제는 겉으로 드러난 사실보다 아직 말해지지 않은 진실에 주목하라는 카드입니다. 상황을 억지로 밀어붙이기보다 관찰, 침묵, 꿈, 반복되는 감각을 읽어야 합니다. 질문의 답은 외부의 큰 소리보다 내면의 조용한 확신, 숨겨진 정보, 아직 공개되지 않은 맥락 속에 있습니다.',
|
||||
meaningReversed: '역방향의 여사제는 직관과 불안을 혼동하거나, 중요한 정보가 닫혀 있어 판단이 왜곡되는 상태입니다. 침묵이 지혜가 아니라 회피가 되었을 가능성도 있습니다. 감정적 추측을 사실처럼 단정하지 말고, 필요한 질문을 정확히 던져 확인하는 과정이 필요합니다.',
|
||||
symbols: [
|
||||
{ label: 'B와 J 기둥', meaning: '대립하는 힘 사이의 문턱. 흑백 판단을 넘어 중간의 숨은 질서를 보라는 뜻입니다.' },
|
||||
{ label: '장막', meaning: '아직 드러나지 않은 영역. 모든 정보를 당장 볼 수 없으므로 성급한 결론을 피해야 합니다.' },
|
||||
{ label: '두루마리', meaning: '비밀 지식과 기록. 이미 알고 있지만 의식적으로 읽지 않은 진실을 상징합니다.' },
|
||||
{ label: '달', meaning: '무의식과 주기. 감정의 흐름과 타이밍이 중요한 단서가 됩니다.' },
|
||||
],
|
||||
},
|
||||
'the-empress': {
|
||||
keywords: ['풍요', '돌봄', '창조성', '감각', '성장'],
|
||||
reversedKeywords: ['과보호', '의존', '창조 정체', '소진', '자기 돌봄 부족'],
|
||||
meaningUpright: '여황제는 무언가를 억지로 밀어붙이기보다 잘 자랄 환경을 만드는 카드입니다. 관계에서는 따뜻한 수용과 애정, 일에서는 창작과 생산성, 재물에서는 자원이 서서히 불어나는 흐름을 뜻합니다. 몸의 감각, 안정된 생활 리듬, 자연스러운 즐거움이 문제 해결의 기반이 됩니다.',
|
||||
meaningReversed: '역방향의 여황제는 돌봄이 지나쳐 의존을 만들거나, 타인을 챙기느라 자신의 에너지가 고갈된 상태입니다. 창조적 결과를 원하지만 몸과 마음의 토양이 말라 있을 수 있습니다. 주는 것과 받는 것의 균형, 휴식, 현실적인 자원 관리가 먼저입니다.',
|
||||
symbols: [
|
||||
{ label: '밀밭', meaning: '결실과 물질적 풍요. 지금의 노력은 가시적인 수확으로 이어질 가능성이 있습니다.' },
|
||||
{ label: '석류 문양', meaning: '생명력, 다산, 창조의 씨앗. 아이디어나 관계가 무르익는 단계입니다.' },
|
||||
{ label: '숲과 강', meaning: '감정과 자연 리듬. 성장은 통제보다 흐름을 탈 때 빨라집니다.' },
|
||||
{ label: '비너스 표식', meaning: '사랑, 아름다움, 매력. 가치 있는 것을 부드럽게 끌어당기는 힘입니다.' },
|
||||
],
|
||||
},
|
||||
'the-emperor': {
|
||||
keywords: ['구조', '권위', '책임', '통제', '안정'],
|
||||
reversedKeywords: ['강압', '경직', '권위 남용', '책임 회피', '통제 욕구'],
|
||||
meaningUpright: '황제는 감정의 파도보다 구조와 원칙이 필요한 시점을 말합니다. 기준을 세우고, 역할을 분명히 하며, 장기적으로 유지될 수 있는 질서를 만들어야 합니다. 질문의 핵심이 관계라면 경계와 책임, 일이라면 리더십과 운영 능력, 재물이라면 안정적인 관리 체계를 뜻합니다.',
|
||||
meaningReversed: '역방향의 황제는 질서가 지나쳐 압박이 되거나, 반대로 책임자가 부재해 기준이 무너진 상태를 가리킵니다. 누군가가 권위를 방패처럼 쓰고 있거나, 결정해야 할 사람이 결정을 피하고 있을 수 있습니다. 유연성을 잃지 않는 원칙이 필요합니다.',
|
||||
symbols: [
|
||||
{ label: '돌 왕좌', meaning: '견고한 기반과 현실적 권력. 감정보다 지속 가능한 구조가 중요합니다.' },
|
||||
{ label: '양 머리 장식', meaning: '개척, 추진력, 지배적 의지. 빠른 결단과 리더십을 요구합니다.' },
|
||||
{ label: '갑옷', meaning: '보호와 방어. 부드러운 대화보다 원칙을 지켜야 할 때가 있습니다.' },
|
||||
{ label: '메마른 산', meaning: '쉽지 않은 환경에서도 버티는 힘. 편안함보다 책임이 우선될 수 있습니다.' },
|
||||
],
|
||||
},
|
||||
'the-hierophant': {
|
||||
keywords: ['전통', '가르침', '제도', '멘토', '공식화'],
|
||||
reversedKeywords: ['관습 거부', '형식주의', '권위 의심', '독자 노선', '낡은 규칙'],
|
||||
meaningUpright: '교황은 검증된 지식, 제도, 공동체의 규칙 안에서 길을 찾는 카드입니다. 혼자 추측하기보다 경험자에게 배우고, 절차를 따르고, 관계나 일을 공식화하는 것이 도움이 됩니다. 질문 속 상황은 개인적 감정만으로 해결되기보다 사회적 기준, 윤리, 약속, 신뢰의 틀 안에서 다뤄져야 합니다.',
|
||||
meaningReversed: '역방향의 교황은 전통이 더 이상 답이 아니거나, 겉으로만 옳은 형식이 본질을 가리는 상태입니다. 권위자의 말이 실제 상황과 맞는지 점검해야 합니다. 규칙을 깨야 할 수도 있지만, 단순한 반항이 아니라 나만의 원칙을 세우는 것이 중요합니다.',
|
||||
symbols: [
|
||||
{ label: '두 제자', meaning: '배움과 전수. 혼자 해결하기보다 조언과 검증을 받아야 합니다.' },
|
||||
{ label: '삼중관', meaning: '정신·도덕·사회적 권위. 개인 문제도 더 큰 기준과 연결되어 있습니다.' },
|
||||
{ label: '열쇠', meaning: '숨은 문을 여는 지식. 올바른 절차나 핵심 정보를 찾는 것이 관건입니다.' },
|
||||
{ label: '축복의 손', meaning: '승인과 의식. 관계나 계획을 공식화하는 흐름을 뜻합니다.' },
|
||||
],
|
||||
},
|
||||
'the-lovers': {
|
||||
keywords: ['사랑', '선택', '가치관', '결합', '정직한 끌림'],
|
||||
reversedKeywords: ['불일치', '관계 갈등', '선택 회피', '가치 충돌', '유혹'],
|
||||
meaningUpright: '연인은 단순한 로맨스보다 마음과 가치가 만나는 선택을 뜻합니다. 누군가와 연결되거나 어떤 길을 택할 때, 외부 조건보다 내가 진심으로 동의하는 가치가 기준이 되어야 합니다. 관계에서는 신뢰와 투명성, 일에서는 자신의 신념과 맞는 선택, 삶에서는 두 방향 중 하나를 의식적으로 고르는 순간을 나타냅니다.',
|
||||
meaningReversed: '역방향의 연인은 마음과 행동이 어긋난 상태입니다. 끌림은 있지만 신뢰가 부족하거나, 선택을 미뤄 더 큰 혼란을 만들 수 있습니다. 관계의 문제라면 솔직한 대화가 필요하고, 선택의 문제라면 남이 기대하는 답과 내가 감당할 답을 분리해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '두 인물', meaning: '서로 다른 자아나 두 선택지. 진정한 결합은 숨김없는 상태에서 가능합니다.' },
|
||||
{ label: '천사', meaning: '높은 가치와 보호. 단순 욕망보다 더 큰 윤리와 의미가 개입합니다.' },
|
||||
{ label: '나무와 뱀', meaning: '유혹, 지식, 선택의 책임. 알고도 선택하는 성숙함이 필요합니다.' },
|
||||
{ label: '산', meaning: '관계와 선택이 넘어야 할 과제. 사랑만으로는 부족하고 의지가 필요합니다.' },
|
||||
],
|
||||
},
|
||||
'the-chariot': {
|
||||
keywords: ['전진', '승리', '의지', '자기 통제', '목표 집중'],
|
||||
reversedKeywords: ['방향 상실', '과속', '통제 실패', '분산', '억지 추진'],
|
||||
meaningUpright: '전차는 상반된 힘을 하나의 목표로 묶어 앞으로 나아가는 카드입니다. 갈등이 사라져서 움직이는 것이 아니라, 갈등을 통제하면서 전진합니다. 지금은 감정과 환경에 끌려가기보다 목적지를 분명히 하고, 규율과 결단으로 흐름을 잡아야 합니다.',
|
||||
meaningReversed: '역방향의 전차는 속도는 있지만 방향이 없거나, 통제하려 할수록 상황이 더 흔들리는 상태입니다. 의지만으로 밀어붙이면 주변과 충돌할 수 있습니다. 목표, 수단, 감정의 균형을 다시 맞추고 무엇을 이기려 하는지보다 어디로 가려는지 확인해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '두 스핑크스', meaning: '상반된 욕구와 방향. 둘 중 하나를 없애기보다 함께 끌고 가야 합니다.' },
|
||||
{ label: '갑옷과 별 장식', meaning: '보호된 의지와 높은 목표. 사적인 감정보다 사명이 앞섭니다.' },
|
||||
{ label: '성 밖의 길', meaning: '안전지대에서 벗어난 도전. 익숙한 환경을 떠나야 전진이 시작됩니다.' },
|
||||
{ label: '고삐 없는 전차', meaning: '물리적 통제보다 정신적 집중이 핵심입니다.' },
|
||||
],
|
||||
},
|
||||
strength: {
|
||||
keywords: ['용기', '인내', '자기 조절', '부드러운 힘', '신뢰'],
|
||||
reversedKeywords: ['자신감 저하', '감정 폭발', '힘의 남용', '두려움', '자제력 부족'],
|
||||
meaningUpright: '힘 카드는 강압이 아니라 부드러운 통제와 내면의 용기를 말합니다. 문제를 제압하려 하기보다 신뢰와 인내로 다루면 더 큰 힘이 나옵니다. 관계에서는 상대를 밀어붙이지 않는 영향력, 일에서는 꾸준함, 자기 문제에서는 충동을 다스리는 성숙함을 뜻합니다.',
|
||||
meaningReversed: '역방향의 힘은 감정을 억누르다 폭발하거나, 자신감 부족 때문에 필요 이상으로 방어적인 상태를 가리킵니다. 외부의 적보다 내 안의 두려움, 분노, 열등감이 핵심일 수 있습니다. 문제를 이기기 전에 자신을 진정시키는 과정이 먼저입니다.',
|
||||
symbols: [
|
||||
{ label: '사자', meaning: '본능, 욕망, 두려움. 없애야 할 것이 아니라 길들여야 할 에너지입니다.' },
|
||||
{ label: '부드러운 손길', meaning: '강제보다 신뢰가 효과적임을 보여줍니다.' },
|
||||
{ label: '무한대 표식', meaning: '지속적인 내면의 힘. 단발성 승부보다 꾸준한 자기 조절이 중요합니다.' },
|
||||
{ label: '흰 옷', meaning: '순수한 의도. 힘은 목적이 맑을 때 가장 안정적으로 작동합니다.' },
|
||||
],
|
||||
},
|
||||
'the-hermit': {
|
||||
keywords: ['성찰', '고독', '탐구', '내면의 길', '지혜'],
|
||||
reversedKeywords: ['고립', '회피', '외로움', '닫힌 마음', '조언 거부'],
|
||||
meaningUpright: '은둔자는 외부의 소음에서 물러나 자신의 빛으로 길을 확인하는 카드입니다. 지금은 빠른 반응보다 깊은 숙고, 정보 수집, 혼자만의 정리가 필요합니다. 관계에서도 즉각적 답을 강요하기보다 거리를 두고 본질을 보아야 하고, 일에서는 전문가적 집중과 장기적 관점이 중요합니다.',
|
||||
meaningReversed: '역방향의 은둔자는 필요한 고독이 지나쳐 단절이 되거나, 혼자 생각하는 시간이 회피로 변한 상태입니다. 누구의 말도 듣지 않으려 하거나, 도움을 요청해야 할 때 스스로를 가두고 있을 수 있습니다. 내면의 답과 외부 피드백의 균형이 필요합니다.',
|
||||
symbols: [
|
||||
{ label: '등불', meaning: '내면의 진실과 작은 단서. 길 전체가 아니라 다음 한 걸음을 비춥니다.' },
|
||||
{ label: '지팡이', meaning: '경험으로 얻은 지지대. 오래 쌓은 지혜가 현재를 버티게 합니다.' },
|
||||
{ label: '산 정상', meaning: '높은 관점과 고독. 멀리 보기 위해 잠시 떨어져 있어야 합니다.' },
|
||||
{ label: '회색 망토', meaning: '외부 자극을 줄이고 본질을 가리는 색을 걷어내는 태도입니다.' },
|
||||
],
|
||||
},
|
||||
'wheel-of-fortune': {
|
||||
keywords: ['전환점', '운명', '순환', '기회', '흐름 변화'],
|
||||
reversedKeywords: ['정체', '악순환', '타이밍 불일치', '통제 집착', '예상 밖 변수'],
|
||||
meaningUpright: '운명의 수레바퀴는 상황의 축이 바뀌는 시점을 알립니다. 개인의 노력만으로 모든 것을 통제할 수는 없지만, 흐름을 읽고 타이밍에 맞춰 움직이면 큰 기회가 열립니다. 반복되던 패턴이 전환되며, 우연처럼 보이는 사건이 새로운 방향을 만들 수 있습니다.',
|
||||
meaningReversed: '역방향의 수레바퀴는 변화가 지연되거나 같은 패턴을 반복하는 상태입니다. 흐름을 억지로 붙잡을수록 피로가 커질 수 있습니다. 지금은 불운을 탓하기보다 반복되는 선택 구조를 확인하고, 바꿀 수 있는 작은 행동부터 조정해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '바퀴', meaning: '상승과 하강의 순환. 현재 상태는 고정된 운명이 아니라 움직이는 흐름입니다.' },
|
||||
{ label: '네 생물', meaning: '안정된 원리와 네 방향. 변화 속에서도 기준이 필요합니다.' },
|
||||
{ label: '스핑크스', meaning: '수수께끼와 균형. 모든 답을 모를 때도 중심을 잡아야 합니다.' },
|
||||
{ label: '책과 문자', meaning: '배움과 기록. 반복되는 사건에서 패턴을 읽으라는 신호입니다.' },
|
||||
],
|
||||
},
|
||||
justice: {
|
||||
keywords: ['균형', '진실', '책임', '공정함', '원인과 결과'],
|
||||
reversedKeywords: ['불공정', '책임 회피', '편향', '판단 오류', '불균형'],
|
||||
meaningUpright: '정의는 감정적 호불호보다 사실, 균형, 책임을 요구합니다. 지금의 결과는 이전 선택의 누적과 연결되어 있으며, 앞으로의 결정도 명확한 기준을 가져야 합니다. 계약, 법적 문제, 관계의 공정성, 일의 평가에서 객관적 자료와 정직한 태도가 핵심입니다.',
|
||||
meaningReversed: '역방향의 정의는 사실을 외면하거나 한쪽 입장만으로 판단하는 위험을 경고합니다. 누군가 책임을 회피하거나 불공정한 구조가 작동할 수 있습니다. 감정적 보복이나 자기합리화를 멈추고, 증거와 기준을 다시 확인해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '저울', meaning: '균형과 비교. 선택의 양쪽 결과를 공정하게 재야 합니다.' },
|
||||
{ label: '검', meaning: '명료한 판단과 결단. 애매함을 자르는 순간이 필요합니다.' },
|
||||
{ label: '붉은 커튼', meaning: '숨은 동기와 결과의 무게. 표면 아래의 책임을 봐야 합니다.' },
|
||||
{ label: '왕관', meaning: '원칙의 권위. 개인 감정보다 상위 기준이 작동합니다.' },
|
||||
],
|
||||
},
|
||||
'the-hanged-man': {
|
||||
keywords: ['멈춤', '관점 전환', '내려놓음', '수용', '희생의 의미'],
|
||||
reversedKeywords: ['정체', '고집', '무의미한 희생', '회피', '관점 폐쇄'],
|
||||
meaningUpright: '매달린 사람은 억지로 움직일수록 더 묶이는 상황에서 관점을 바꾸라고 말합니다. 지금의 지연은 실패가 아니라 다른 시각을 얻는 시간일 수 있습니다. 무언가를 내려놓거나 잠시 멈추면, 이전에는 보이지 않던 해법과 더 큰 의미가 드러납니다.',
|
||||
meaningReversed: '역방향은 멈춤이 통찰로 이어지지 않고 고집이나 회피가 된 상태입니다. 희생하고 있다고 느끼지만 실제로는 변화해야 할 관점을 붙들고 있을 수 있습니다. 기다림을 계속할지, 매듭을 풀 행동을 할지 분명히 정해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '거꾸로 매달린 자세', meaning: '세상을 반대로 보는 훈련. 기존 판단을 뒤집어야 답이 보입니다.' },
|
||||
{ label: '평온한 얼굴', meaning: '수동적 패배가 아니라 의식적 수용을 뜻합니다.' },
|
||||
{ label: '빛나는 머리', meaning: '멈춤 속에서 생기는 깨달음. 행동보다 통찰이 먼저입니다.' },
|
||||
{ label: '한쪽 다리의 십자 형태', meaning: '제한 속에서도 내적 균형을 세우는 상징입니다.' },
|
||||
],
|
||||
},
|
||||
death: {
|
||||
keywords: ['종결', '전환', '놓아주기', '변형', '재생'],
|
||||
reversedKeywords: ['변화 저항', '집착', '지연', '두려움', '미완의 끝맺음'],
|
||||
meaningUpright: '죽음은 문자 그대로의 끝이 아니라 한 단계가 완전히 닫히고 다음 형태로 변하는 과정입니다. 더 이상 살릴 수 없는 방식, 관계, 역할, 기대를 놓아야 새 흐름이 들어옵니다. 질문의 핵심에는 손실보다 재편성이 있으며, 진짜 변화는 표면 수정이 아니라 구조의 전환입니다.',
|
||||
meaningReversed: '역방향의 죽음은 끝난 것을 인정하지 못해 에너지가 묶인 상태입니다. 변화가 두려워 낡은 구조를 붙잡으면 자연스러운 재생도 지연됩니다. 무엇을 잃는지보다 무엇이 이미 생명력을 다했는지 정확히 봐야 합니다.',
|
||||
symbols: [
|
||||
{ label: '흰 말', meaning: '정화된 변화의 힘. 끝남은 파괴만이 아니라 새로운 질서의 도착입니다.' },
|
||||
{ label: '검은 갑옷', meaning: '피할 수 없는 전환. 감정과 상관없이 진행되는 변화가 있습니다.' },
|
||||
{ label: '떠오르는 태양', meaning: '끝 뒤의 시작. 상실 이후에도 다음 국면은 열립니다.' },
|
||||
{ label: '쓰러진 왕과 기도하는 인물', meaning: '지위와 태도에 상관없이 모두 변화 앞에 서게 됨을 보여줍니다.' },
|
||||
],
|
||||
},
|
||||
temperance: {
|
||||
keywords: ['절제', '조화', '치유', '중용', '통합'],
|
||||
reversedKeywords: ['불균형', '과잉', '조급함', '극단', '조율 실패'],
|
||||
meaningUpright: '절제는 서로 다른 요소를 천천히 섞어 새로운 균형을 만드는 카드입니다. 지금은 극단적 선택보다 조율, 회복, 적당한 속도가 중요합니다. 관계에서는 차이를 부드럽게 맞추고, 일에서는 자원과 시간을 균형 있게 배분하며, 마음에서는 치유가 진행됩니다.',
|
||||
meaningReversed: '역방향의 절제는 한쪽으로 치우친 상태입니다. 너무 빨리 결론을 내리거나, 감정·일·소비·관계에서 과잉이 생겼을 수 있습니다. 해결은 더 강한 자극이 아니라 리듬을 되찾고 섞이지 않는 요소를 다시 분리해 조정하는 것입니다.',
|
||||
symbols: [
|
||||
{ label: '두 컵 사이의 물', meaning: '감정과 에너지의 교환. 서로 다른 것을 천천히 조화시키는 과정입니다.' },
|
||||
{ label: '한 발은 물, 한 발은 땅', meaning: '감정과 현실의 균형. 직관과 실용을 함께 써야 합니다.' },
|
||||
{ label: '천사', meaning: '치유와 보호. 조급하지 않은 회복의 흐름이 있습니다.' },
|
||||
{ label: '길과 산', meaning: '장기적 목표로 이어지는 중간 과정. 지금의 조율은 다음 단계의 준비입니다.' },
|
||||
],
|
||||
},
|
||||
'the-devil': {
|
||||
keywords: ['집착', '속박', '욕망', '그림자', '중독적 패턴'],
|
||||
reversedKeywords: ['해방', '자각', '단절', '회복', '속박 풀림'],
|
||||
meaningUpright: '악마는 외부의 운명보다 스스로 반복하는 집착과 의존을 직시하게 합니다. 관계에서는 통제와 욕망, 일에서는 물질적 유혹이나 과도한 야망, 심리적으로는 부정하고 싶은 그림자가 드러납니다. 이 카드는 죄책감보다 자각을 요구하며, 묶인 사슬이 실제보다 느슨할 수 있음을 보여줍니다.',
|
||||
meaningReversed: '역방향의 악마는 속박을 알아차리고 풀어내는 단계입니다. 중독적 관계, 나쁜 습관, 물질적 집착에서 벗어날 기회가 있습니다. 다만 해방은 선언만으로 오지 않으므로 유혹을 반복시키는 구조를 실제로 바꿔야 합니다.',
|
||||
symbols: [
|
||||
{ label: '느슨한 사슬', meaning: '벗어날 수 있지만 익숙해서 머무는 패턴. 선택의 책임을 묻습니다.' },
|
||||
{ label: '염소 형상', meaning: '본능, 욕망, 금기. 억압된 욕구가 왜곡되어 나타날 수 있습니다.' },
|
||||
{ label: '횃불', meaning: '아래로 향한 에너지. 창조적 힘이 파괴적 습관으로 흐르는 상태입니다.' },
|
||||
{ label: '나체의 두 인물', meaning: '취약성과 의존. 관계나 욕망 앞에서 경계가 약해질 수 있습니다.' },
|
||||
],
|
||||
},
|
||||
'the-tower': {
|
||||
keywords: ['붕괴', '각성', '급변', '진실 노출', '해방적 충격'],
|
||||
reversedKeywords: ['붕괴 회피', '지연된 변화', '내부 균열', '두려움', '경고 무시'],
|
||||
meaningUpright: '탑은 더 이상 유지될 수 없는 구조가 갑자기 무너지는 카드입니다. 충격은 크지만, 거짓 기반이나 억눌린 진실을 드러내어 새로운 현실을 만들 공간을 냅니다. 관계, 일, 믿음 체계 어디에서든 지금 필요한 것은 무너짐을 막는 척이 아니라 실제 균열을 인정하는 것입니다.',
|
||||
meaningReversed: '역방향의 탑은 변화의 조짐을 이미 알면서도 외면하는 상태입니다. 겉으로는 큰 사고가 없지만 내부 압력은 쌓이고 있을 수 있습니다. 작은 수정으로 끝낼 수 있을 때 구조를 점검해야 하며, 진실을 미룰수록 충격은 커집니다.',
|
||||
symbols: [
|
||||
{ label: '번개', meaning: '갑작스러운 깨달음과 외부 충격. 숨겨진 사실이 순식간에 드러납니다.' },
|
||||
{ label: '무너지는 왕관', meaning: '잘못된 자부심이나 권위의 붕괴. 통제 환상이 깨집니다.' },
|
||||
{ label: '떨어지는 인물', meaning: '안전하다고 믿던 위치에서 내려오는 경험. 현실을 다시 배워야 합니다.' },
|
||||
{ label: '불꽃', meaning: '파괴와 정화. 불편한 진실이 낡은 구조를 태웁니다.' },
|
||||
],
|
||||
},
|
||||
'the-star': {
|
||||
keywords: ['희망', '치유', '영감', '신뢰', '장기 비전'],
|
||||
reversedKeywords: ['낙담', '자기 의심', '단절감', '희망 상실', '회복 지연'],
|
||||
meaningUpright: '별은 큰 혼란이 지나간 뒤 회복되는 빛입니다. 당장 결과가 완성되지 않아도 방향은 밝아지고, 마음은 다시 신뢰를 배웁니다. 치유, 영감, 창작, 장기 목표에 유리하며, 자신을 있는 그대로 드러낼수록 필요한 도움과 흐름이 찾아옵니다.',
|
||||
meaningReversed: '역방향의 별은 희망이 약해지고 자신을 믿기 어려운 상태입니다. 주변의 좋은 신호를 보지 못하거나, 회복이 늦다고 느낄 수 있습니다. 큰 확신을 억지로 만들기보다 작은 회복의 증거를 기록하고 몸과 마음을 다시 연결해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '큰 별과 작은 별들', meaning: '중심 비전과 여러 가능성. 한 가지 희망이 여러 길을 비춥니다.' },
|
||||
{ label: '물 붓기', meaning: '감정의 치유와 현실의 돌봄. 내면과 외부 세계를 함께 회복해야 합니다.' },
|
||||
{ label: '나체의 인물', meaning: '진정성, 취약함, 꾸밈없는 자기. 숨김이 줄수록 치유가 빠릅니다.' },
|
||||
{ label: '새', meaning: '영감과 소식. 높은 관점에서 새로운 메시지가 도착합니다.' },
|
||||
],
|
||||
},
|
||||
'the-moon': {
|
||||
keywords: ['불확실성', '무의식', '환상', '직관', '감정의 안개'],
|
||||
reversedKeywords: ['혼란 해소', '진실 드러남', '불안 완화', '자기기만 자각', '직관 회복'],
|
||||
meaningUpright: '달은 사실과 상상이 섞여 시야가 흐린 상태를 말합니다. 지금 보이는 것만으로 단정하기 어렵고, 꿈·불안·직관·반복되는 감정이 중요한 단서가 됩니다. 숨겨진 동기나 오해가 있을 수 있으므로 결론을 서두르지 말고, 감정의 안개가 걷힐 시간을 두어야 합니다.',
|
||||
meaningReversed: '역방향의 달은 혼란이 서서히 풀리거나, 자신을 속이던 패턴을 알아차리는 단계입니다. 불안은 여전히 남아 있어도 진실에 가까워지고 있습니다. 다만 새로 드러난 단서를 왜곡하지 않도록 차분한 확인이 필요합니다.',
|
||||
symbols: [
|
||||
{ label: '달빛', meaning: '부분적으로만 보이는 진실. 밝지만 태양처럼 명확하지는 않습니다.' },
|
||||
{ label: '개와 늑대', meaning: '길들여진 자아와 야생의 본능. 이성보다 본능이 크게 반응할 수 있습니다.' },
|
||||
{ label: '가재', meaning: '무의식에서 올라오는 감정. 오래 묻어둔 불안이나 욕구가 표면화됩니다.' },
|
||||
{ label: '두 탑 사이의 길', meaning: '불확실한 통과 의례. 두려움을 지나야 다음 세계로 갑니다.' },
|
||||
],
|
||||
},
|
||||
'the-sun': {
|
||||
keywords: ['명료성', '성공', '기쁨', '활력', '공개'],
|
||||
reversedKeywords: ['과신', '일시적 지연', '피상적 낙관', '기쁨 차단', '자만'],
|
||||
meaningUpright: '태양은 숨겨진 것이 밝게 드러나고 생명력이 회복되는 카드입니다. 관계에서는 솔직함과 따뜻함, 일에서는 성과와 인정, 개인적으로는 자신감과 건강한 자기표현을 뜻합니다. 복잡했던 문제가 단순해지고, 긍정적 결과를 공개적으로 누릴 수 있습니다.',
|
||||
meaningReversed: '역방향의 태양은 좋은 흐름이 있지만 충분히 받아들이지 못하거나, 지나친 자신감으로 세부를 놓치는 상태입니다. 성공이 늦어질 수는 있으나 완전히 사라진 것은 아닙니다. 기쁨을 현실적인 계획과 연결하면 흐름을 회복할 수 있습니다.',
|
||||
symbols: [
|
||||
{ label: '태양', meaning: '명료한 의식과 생명력. 숨겨진 것이 드러나고 에너지가 회복됩니다.' },
|
||||
{ label: '아이', meaning: '순수한 기쁨과 자유로운 표현. 복잡함보다 단순함이 힘입니다.' },
|
||||
{ label: '흰 말', meaning: '순수한 추진력. 억지 통제가 아니라 자연스러운 전진입니다.' },
|
||||
{ label: '해바라기', meaning: '성장과 성과. 빛을 받은 노력이 결실로 나타납니다.' },
|
||||
],
|
||||
},
|
||||
judgement: {
|
||||
keywords: ['각성', '부름', '재평가', '결단', '새 단계'],
|
||||
reversedKeywords: ['부름 회피', '자기비판', '과거 집착', '결단 지연', '변화 두려움'],
|
||||
meaningUpright: '심판은 과거를 정산하고 더 큰 부름에 응답하는 카드입니다. 이전 선택의 의미가 분명해지고, 이제는 같은 패턴을 반복할지 새 단계로 올라설지 결정해야 합니다. 용서, 재평가, 합격·판정, 중요한 전환점과 연결되며 자기 삶을 더 넓은 관점에서 바라보게 합니다.',
|
||||
meaningReversed: '역방향의 심판은 이미 들은 내면의 부름을 무시하거나, 과거의 후회 때문에 앞으로 나아가지 못하는 상태입니다. 자신을 지나치게 심판하면 변화의 문도 닫힙니다. 실수의 의미를 배움으로 전환하고 결정을 미루는 이유를 직면해야 합니다.',
|
||||
symbols: [
|
||||
{ label: '나팔', meaning: '깨어나라는 신호. 더 이상 예전 방식으로 잠들어 있을 수 없습니다.' },
|
||||
{ label: '무덤에서 일어나는 인물', meaning: '재생과 부활. 끝났다고 여긴 가능성이 다시 살아납니다.' },
|
||||
{ label: '천사', meaning: '상위의 부름과 평가. 개인적 판단을 넘어 삶의 방향성이 개입합니다.' },
|
||||
{ label: '벌거벗은 몸', meaning: '숨김없는 자기 평가. 진실 앞에서 변명보다 수용이 필요합니다.' },
|
||||
],
|
||||
},
|
||||
'the-world': {
|
||||
keywords: ['완성', '통합', '성취', '순환의 마무리', '전체성'],
|
||||
reversedKeywords: ['미완성', '마무리 지연', '닫히지 않은 과제', '성취감 부족', '반복'],
|
||||
meaningUpright: '세계는 한 주기의 완성과 통합을 나타냅니다. 흩어져 있던 경험이 하나의 의미로 묶이고, 노력의 결과를 인정받거나 다음 단계로 넘어갈 준비가 됩니다. 관계에서는 성숙한 연결, 일에서는 프로젝트 완수, 삶에서는 자신이 지나온 길을 온전하게 받아들이는 시점입니다.',
|
||||
meaningReversed: '역방향의 세계는 거의 끝났지만 마지막 매듭이 남아 있거나, 성취를 했음에도 스스로 인정하지 못하는 상태입니다. 반복되는 과제를 마무리하지 않으면 다음 장으로 넘어가기 어렵습니다. 완벽함보다 완료를 선택해야 할 수 있습니다.',
|
||||
symbols: [
|
||||
{ label: '월계관', meaning: '완성과 승리. 하나의 순환이 닫히고 새로운 순환의 문이 열립니다.' },
|
||||
{ label: '춤추는 인물', meaning: '자유로운 통합. 몸과 마음, 경험과 의미가 조화를 이룹니다.' },
|
||||
{ label: '네 생물', meaning: '네 원소와 네 방향의 균형. 삶의 여러 영역이 하나로 정리됩니다.' },
|
||||
{ label: '두 지팡이', meaning: '균형 잡힌 창조력. 이전 단계에서 얻은 힘을 다음 세계로 가져갑니다.' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const SUIT_DETAILS: Record<Suit, SuitDetail> = {
|
||||
wands: {
|
||||
domain: '열정, 창조성, 일의 추진력, 생명력, 목표를 향한 행동',
|
||||
elementMeaning: '불 원소의 확장성. 빠르게 타오르는 의욕과 경쟁심, 영감이 행동으로 옮겨지는 방식을 보여줍니다.',
|
||||
symbolLabels: ['싹 난 지팡이', '사막과 언덕', '불 원소'],
|
||||
symbolMeanings: ['살아 있는 의지와 성장 가능성', '도전적 환경에서도 길을 여는 추진력', '영감, 욕망, 빠른 전개'],
|
||||
},
|
||||
cups: {
|
||||
domain: '감정, 관계, 사랑, 직관, 기억과 치유',
|
||||
elementMeaning: '물 원소의 수용성. 관계의 흐름, 감정의 깊이, 꿈과 상상, 마음의 회복을 읽습니다.',
|
||||
symbolLabels: ['성배', '물', '물고기와 구름'],
|
||||
symbolMeanings: ['감정을 담는 그릇과 마음의 교류', '무의식과 정서의 흐름', '직관적 메시지와 상상력'],
|
||||
},
|
||||
swords: {
|
||||
domain: '생각, 말, 판단, 갈등, 진실을 가르는 힘',
|
||||
elementMeaning: '공기 원소의 명료성. 분석, 의사소통, 결정, 불안과 갈등이 어떻게 작동하는지 보여줍니다.',
|
||||
symbolLabels: ['검', '바람과 구름', '새벽의 빛'],
|
||||
symbolMeanings: ['진실을 자르는 판단력과 말의 힘', '생각의 변화와 불안정한 정신 흐름', '고통 뒤에 오는 명료성'],
|
||||
},
|
||||
pentacles: {
|
||||
domain: '돈, 일, 몸, 건강, 생활 기반, 현실적 성취',
|
||||
elementMeaning: '땅 원소의 구체성. 자원, 노동, 습관, 물질적 안정과 장기적 결과를 다룹니다.',
|
||||
symbolLabels: ['펜타클', '정원과 도시', '손과 도구'],
|
||||
symbolMeanings: ['현실 세계의 가치와 자원', '시간을 들여 가꾸는 기반', '기술, 노동, 반복의 힘'],
|
||||
},
|
||||
};
|
||||
|
||||
const RANK_DETAILS: Record<number, RankDetail> = {
|
||||
1: {
|
||||
keywords: ['새 씨앗', '기회', '순수한 가능성', '시작의 힘', '선물'],
|
||||
reversedKeywords: ['기회 지연', '잠재력 낭비', '시작 불안', '준비 부족'],
|
||||
upright: '새로운 가능성이 처음 모습을 드러내는 카드입니다. 아직 결과는 작지만, 이 씨앗은 제대로 다루면 큰 성장으로 이어질 수 있습니다.',
|
||||
reversed: '기회는 있지만 아직 형태가 불안정하거나, 시작할 준비가 부족한 상태입니다. 가능성을 현실로 만들 구체적 첫 행동이 필요합니다.',
|
||||
symbols: [{ label: '구름 속의 손', meaning: '외부에서 들어오는 기회와 아직 인간의 손에 완전히 들어오지 않은 가능성입니다.' }],
|
||||
},
|
||||
2: {
|
||||
keywords: ['선택', '균형', '초기 조율', '두 방향', '관계 설정'],
|
||||
reversedKeywords: ['우유부단', '불균형', '관계 긴장', '선택 회피'],
|
||||
upright: '두 흐름 사이에서 균형을 잡고 방향을 정하는 단계입니다. 아직 작지만 중요한 결정이 이후 전체 흐름을 바꿉니다.',
|
||||
reversed: '선택을 미루거나 양쪽을 모두 잡으려다 균형이 무너질 수 있습니다. 핵심 기준을 세우는 것이 먼저입니다.',
|
||||
symbols: [{ label: '둘의 구도', meaning: '대립이 아니라 조율의 가능성입니다. 두 요소가 어떤 방식으로 만나는지가 중요합니다.' }],
|
||||
},
|
||||
3: {
|
||||
keywords: ['확장', '협력', '초기 성과', '표현', '성장'],
|
||||
reversedKeywords: ['협업 불일치', '성장 지연', '분산', '기대와 현실 차이'],
|
||||
upright: '처음의 선택이 외부 세계로 확장되는 단계입니다. 협력, 표현, 초기 결과가 나타나며 다음 가능성을 확인합니다.',
|
||||
reversed: '확장하려는 힘은 있지만 호흡이 맞지 않거나 목표가 분산되어 있습니다. 역할과 기대치를 다시 맞춰야 합니다.',
|
||||
symbols: [{ label: '세 요소의 삼각형', meaning: '관계와 구조가 안정되기 시작하는 첫 형태입니다. 혼자보다 함께 만들 때 힘이 커집니다.' }],
|
||||
},
|
||||
4: {
|
||||
keywords: ['안정', '기반', '휴식', '구조화', '경계'],
|
||||
reversedKeywords: ['정체', '불안정', '닫힘', '안주', '휴식 부족'],
|
||||
upright: '기반을 세우고 에너지를 안정시키는 카드입니다. 급히 확장하기보다 지금까지 만든 구조를 다지는 데 의미가 있습니다.',
|
||||
reversed: '안정이 정체가 되거나, 충분히 쉬지 못해 기반이 흔들릴 수 있습니다. 닫힌 구조를 조금 열어야 합니다.',
|
||||
symbols: [{ label: '네 모서리', meaning: '집, 몸, 계획의 기본 구조. 움직임보다 안정성이 우선되는 시기입니다.' }],
|
||||
},
|
||||
5: {
|
||||
keywords: ['갈등', '손실', '변화 압력', '불편한 성장', '시험'],
|
||||
reversedKeywords: ['갈등 회피', '회복 시작', '분쟁 완화', '내부 소진'],
|
||||
upright: '안정이 깨지고 문제의식이 드러나는 단계입니다. 불편하지만 이 충돌은 무엇을 고쳐야 하는지 보여줍니다.',
|
||||
reversed: '갈등이 줄어들거나 회복이 시작되지만, 아직 상처나 피로가 남아 있습니다. 문제를 덮기보다 정리해야 합니다.',
|
||||
symbols: [{ label: '흐트러진 중심', meaning: '예전 질서가 더는 충분하지 않다는 신호입니다. 손실 속에서도 배울 지점이 있습니다.' }],
|
||||
},
|
||||
6: {
|
||||
keywords: ['회복', '조화', '교환', '인정', '이동'],
|
||||
reversedKeywords: ['불공정한 교환', '과거 집착', '인정 결핍', '회복 지연'],
|
||||
upright: '갈등 뒤 균형이 회복되고 도움, 인정, 이동이 생기는 단계입니다. 관계와 상황이 조금 더 부드러워집니다.',
|
||||
reversed: '겉보기에는 회복처럼 보여도 교환이 불균형하거나 과거에 묶여 있을 수 있습니다. 진짜 균형인지 확인해야 합니다.',
|
||||
symbols: [{ label: '주고받는 흐름', meaning: '도움, 인정, 기억, 이동처럼 에너지가 한쪽에서 다른 쪽으로 흐릅니다.' }],
|
||||
},
|
||||
7: {
|
||||
keywords: ['평가', '선택지', '방어', '전략', '내면 시험'],
|
||||
reversedKeywords: ['혼란', '포기 충동', '과잉 방어', '현실 회피'],
|
||||
upright: '여러 가능성 앞에서 기준을 세우고 자신이 지킬 것을 선택하는 단계입니다. 전략과 분별력이 필요합니다.',
|
||||
reversed: '가능성이 너무 많아 흐려지거나, 방어가 지나쳐 기회를 막을 수 있습니다. 환상과 현실을 분리해야 합니다.',
|
||||
symbols: [{ label: '높은 지점과 여러 대상', meaning: '상황 전체를 보려는 노력. 무엇을 선택하고 무엇을 지킬지 시험받습니다.' }],
|
||||
},
|
||||
8: {
|
||||
keywords: ['움직임', '전념', '반복', '진행', '탈피'],
|
||||
reversedKeywords: ['지연', '강박', '속도 문제', '방향성 부족'],
|
||||
upright: '에너지가 실제 움직임으로 전환되는 단계입니다. 반복, 기술, 빠른 진행 또는 더 깊은 의미를 향한 이동을 뜻합니다.',
|
||||
reversed: '움직이고 있지만 효율이 낮거나 방향이 맞지 않을 수 있습니다. 속도를 조절하고 반복의 목적을 점검해야 합니다.',
|
||||
symbols: [{ label: '반복되는 여덟 요소', meaning: '같은 행동이 누적되어 실력, 변화, 이동을 만듭니다.' }],
|
||||
},
|
||||
9: {
|
||||
keywords: ['완성 직전', '내적 성숙', '만족', '인내', '회복력'],
|
||||
reversedKeywords: ['불만족', '소진', '방어 과잉', '고립된 성취'],
|
||||
upright: '긴 과정을 지나 거의 완성에 가까워진 상태입니다. 경험에서 나온 성숙함, 만족, 버티는 힘이 핵심입니다.',
|
||||
reversed: '성과가 있어도 만족하지 못하거나, 끝까지 버티느라 소진되었을 수 있습니다. 자신을 돌보며 마무리해야 합니다.',
|
||||
symbols: [{ label: '홀로 선 인물', meaning: '스스로 쌓은 경험과 독립성. 성취와 고립이 함께 나타날 수 있습니다.' }],
|
||||
},
|
||||
10: {
|
||||
keywords: ['완성', '결과', '부담', '가족과 공동체', '다음 주기'],
|
||||
reversedKeywords: ['과부하', '무너지는 구조', '불완전한 마무리', '책임 재분배'],
|
||||
upright: '하나의 주기가 완성되어 결과가 현실화되는 단계입니다. 성취와 부담이 함께 오며, 다음 순환을 준비해야 합니다.',
|
||||
reversed: '완성 직전의 부담이 지나치거나, 결과를 유지하기 어려운 구조일 수 있습니다. 책임을 나누고 마무리 방식을 조정해야 합니다.',
|
||||
symbols: [{ label: '가득 찬 열 요소', meaning: '충만함과 포화 상태. 얻은 것이 많을수록 관리해야 할 것도 많습니다.' }],
|
||||
},
|
||||
11: {
|
||||
keywords: ['메신저', '호기심', '초보자의 배움', '가능성 탐색', '새 소식'],
|
||||
reversedKeywords: ['미숙함', '산만함', '소식 지연', '현실감 부족'],
|
||||
upright: '시종은 해당 슈트의 에너지를 처음 배우는 사람입니다. 메시지, 새로운 관심, 실험, 호기심이 상황을 엽니다.',
|
||||
reversed: '배우려는 마음은 있지만 미숙하거나 쉽게 산만해질 수 있습니다. 작은 책임부터 정확히 수행해야 합니다.',
|
||||
symbols: [{ label: '젊은 인물', meaning: '새로운 태도와 배움의 시작. 아직 능숙하지 않지만 가능성이 큽니다.' }],
|
||||
},
|
||||
12: {
|
||||
keywords: ['행동가', '추진', '탐색', '변화', '목표 추격'],
|
||||
reversedKeywords: ['충동', '불안정', '과속', '방향 전환', '성급함'],
|
||||
upright: '기사는 해당 슈트의 에너지를 적극적으로 움직입니다. 제안, 이동, 추격, 빠른 변화가 나타납니다.',
|
||||
reversed: '추진력이 지나쳐 불안정하거나, 마음이 자주 바뀔 수 있습니다. 속도보다 방향과 책임이 중요합니다.',
|
||||
symbols: [{ label: '말', meaning: '움직임과 추진력. 에너지가 머무르지 않고 외부로 나아갑니다.' }],
|
||||
},
|
||||
13: {
|
||||
keywords: ['성숙한 수용', '돌봄', '직관적 운영', '내적 권위', '안정된 영향력'],
|
||||
reversedKeywords: ['과잉 돌봄', '감정적 흔들림', '의존', '자기 돌봄 부족'],
|
||||
upright: '여왕은 해당 슈트의 에너지를 내면화하고 돌봄과 감각으로 운영합니다. 부드럽지만 강한 영향력을 뜻합니다.',
|
||||
reversed: '타인을 챙기느라 자신을 잃거나, 감정과 욕구가 과잉될 수 있습니다. 내면의 균형을 먼저 회복해야 합니다.',
|
||||
symbols: [{ label: '왕좌의 여왕', meaning: '받아들이고 가꾸는 힘. 외적 지배보다 내적 안정과 영향력이 큽니다.' }],
|
||||
},
|
||||
14: {
|
||||
keywords: ['숙련된 통제', '리더십', '책임', '현실화', '전략적 운영'],
|
||||
reversedKeywords: ['권위 남용', '경직', '통제 과잉', '책임 회피'],
|
||||
upright: '왕은 해당 슈트의 에너지를 사회적 책임과 성과로 구현합니다. 리더십, 판단, 장기 운영 능력이 핵심입니다.',
|
||||
reversed: '능력은 있지만 경직되거나 통제적일 수 있습니다. 책임 있는 리더십인지, 단순한 지배 욕구인지 점검해야 합니다.',
|
||||
symbols: [{ label: '왕좌의 왕', meaning: '성숙한 지배와 책임. 에너지를 외부 세계에서 결과로 만드는 힘입니다.' }],
|
||||
},
|
||||
};
|
||||
|
||||
const CARD_LENSES: Record<Suit, Record<number, string[]>> = {
|
||||
wands: {
|
||||
1: ['영감의 불씨', '창업/프로젝트 시작', '강한 의욕'],
|
||||
2: ['세계관 확장', '계획 수립', '기다리는 선택'],
|
||||
3: ['사업 확장', '협력의 성과', '먼 곳의 가능성'],
|
||||
4: ['축하', '안정된 기반', '공동체의 기쁨'],
|
||||
5: ['경쟁', '의견 충돌', '훈련을 통한 성장'],
|
||||
6: ['승리', '인정', '리더로 보이는 순간'],
|
||||
7: ['방어', '입장 고수', '불리한 상황에서의 용기'],
|
||||
8: ['빠른 전개', '연락', '지체 없는 이동'],
|
||||
9: ['버티는 힘', '경계심', '마지막 시험'],
|
||||
10: ['과부하', '책임의 무게', '혼자 짊어진 일'],
|
||||
11: ['새 아이디어', '모험심', '창조적 소식'],
|
||||
12: ['돌진', '열정적 이동', '성급한 추진'],
|
||||
13: ['카리스마', '자기표현', '따뜻한 리더십'],
|
||||
14: ['비전 리더', '사업 감각', '확신 있는 추진'],
|
||||
},
|
||||
cups: {
|
||||
1: ['감정의 시작', '사랑의 문', '치유의 물'],
|
||||
2: ['상호 호감', '파트너십', '마음의 교환'],
|
||||
3: ['우정', '축하', '창조적 협업'],
|
||||
4: ['권태', '재평가', '내면으로 물러남'],
|
||||
5: ['상실감', '후회', '남은 가능성'],
|
||||
6: ['추억', '순수함', '과거와의 화해'],
|
||||
7: ['환상', '선택지', '욕망의 그림자'],
|
||||
8: ['떠남', '의미 탐색', '정서적 탈피'],
|
||||
9: ['만족', '소원 성취', '감정적 풍요'],
|
||||
10: ['가족적 행복', '정서적 완성', '관계의 조화'],
|
||||
11: ['감성적 메시지', '상상력', '부드러운 제안'],
|
||||
12: ['로맨스', '이상주의', '마음을 따르는 이동'],
|
||||
13: ['깊은 공감', '직관적 돌봄', '정서적 안정'],
|
||||
14: ['감정의 통제', '성숙한 사랑', '침착한 조언'],
|
||||
},
|
||||
swords: {
|
||||
1: ['명료한 판단', '진실의 시작', '결정적 아이디어'],
|
||||
2: ['교착', '판단 보류', '감정 차단'],
|
||||
3: ['상처', '슬픔', '진실이 찌르는 순간'],
|
||||
4: ['휴식', '회복', '생각의 정리'],
|
||||
5: ['불편한 승리', '말의 상처', '갈등의 후유증'],
|
||||
6: ['이동', '회복의 여정', '더 나은 곳으로 건너감'],
|
||||
7: ['전략', '회피', '혼자 처리하려는 태도'],
|
||||
8: ['제한감', '두려움', '스스로 만든 감옥'],
|
||||
9: ['불안', '악몽', '죄책감'],
|
||||
10: ['끝난 싸움', '완전한 종료', '바닥을 친 뒤의 새벽'],
|
||||
11: ['정보 수집', '예리한 호기심', '경계하는 말'],
|
||||
12: ['돌진하는 논리', '급한 결정', '논쟁'],
|
||||
13: ['분별력', '독립적 판단', '솔직한 경계'],
|
||||
14: ['전략적 사고', '냉철한 결정', '지적 권위'],
|
||||
},
|
||||
pentacles: {
|
||||
1: ['현실적 기회', '돈의 씨앗', '건강한 기반'],
|
||||
2: ['자원 균형', '멀티태스킹', '변동 관리'],
|
||||
3: ['기술 협업', '인정받는 실력', '전문성'],
|
||||
4: ['소유와 경계', '안정 집착', '재정 방어'],
|
||||
5: ['결핍감', '소외', '도움 요청'],
|
||||
6: ['나눔', '공정한 교환', '지원과 보상'],
|
||||
7: ['기다림', '투자 평가', '장기 성과'],
|
||||
8: ['훈련', '장인정신', '반복 노동'],
|
||||
9: ['자립', '품격', '물질적 여유'],
|
||||
10: ['가문/조직', '상속과 장기 안정', '완성된 기반'],
|
||||
11: ['공부', '현실적 제안', '기술 습득'],
|
||||
12: ['꾸준함', '책임감', '느리지만 확실한 전진'],
|
||||
13: ['생활 감각', '돌봄과 풍요', '실용적 지혜'],
|
||||
14: ['성공한 운영자', '재정 리더십', '안정된 성취'],
|
||||
},
|
||||
};
|
||||
|
||||
function buildMinorDetails(suit: Suit, rank: number, krName: string): MinorDetails {
|
||||
const suitDetail = SUIT_DETAILS[suit];
|
||||
const rankDetail = RANK_DETAILS[rank];
|
||||
const lens = CARD_LENSES[suit][rank];
|
||||
const keywords = [...lens, ...rankDetail.keywords.slice(0, 3)];
|
||||
const reversedKeywords = [...rankDetail.reversedKeywords, `${SUIT_NAMES_EN[suit]} 에너지 불균형`];
|
||||
|
||||
return {
|
||||
keywords,
|
||||
reversedKeywords,
|
||||
meaningUpright: `${krName}은 ${suitDetail.domain}의 영역에서 ${lens.join(', ')}을(를) 보여줍니다. ${rankDetail.upright} 이 카드는 ${suitDetail.elementMeaning} 질문의 맥락에서는 지금 무엇이 자라고, 어디에 에너지를 쏟아야 하며, 어떤 현실적 행동이 다음 변화를 만드는지 읽게 합니다.`,
|
||||
meaningReversed: `${krName} 역방향은 ${lens[0]}의 흐름이 막히거나 과장된 상태입니다. ${rankDetail.reversed} ${suitDetail.domain} 안에서 반복되는 불균형을 점검하고, 감정적 반응보다 실제로 조정 가능한 선택을 찾아야 합니다.`,
|
||||
symbols: [
|
||||
{ label: suitDetail.symbolLabels[0], meaning: suitDetail.symbolMeanings[0] },
|
||||
{ label: suitDetail.symbolLabels[1], meaning: suitDetail.symbolMeanings[1] },
|
||||
{ label: rankDetail.symbols[0].label, meaning: rankDetail.symbols[0].meaning },
|
||||
{ label: lens[0], meaning: `${krName}의 핵심 장면입니다. 이 상징은 현재 질문에서 ${lens.join(', ')}이 어떻게 드러나는지 살피게 합니다.` },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function buildMinor(): Omit<TarotCard, 'image'>[] {
|
||||
const out: Omit<TarotCard, 'image'>[] = [];
|
||||
let id = 22;
|
||||
for (const { suit, element, kr } of SUITS) {
|
||||
for (let rank = 1; rank <= 14; rank++) {
|
||||
const krName = `${kr} ${RANK_NAMES[rank - 1]}`;
|
||||
const enName = `${RANK_EN[rank - 1]} of ${SUIT_NAMES_EN[suit]}`;
|
||||
const details = buildMinorDetails(suit, rank, krName);
|
||||
out.push({
|
||||
id: id++,
|
||||
slug: `${RANK_EN[rank - 1].toLowerCase()}-of-${suit}`,
|
||||
name: krName,
|
||||
nameEn: enName,
|
||||
arcana: 'minor',
|
||||
suit,
|
||||
rank,
|
||||
element,
|
||||
...details,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const TAROT_DECK: TarotCard[] = [
|
||||
...MAJOR_ARCANA.map((c) => ({
|
||||
...c,
|
||||
...MAJOR_DETAILS[c.slug],
|
||||
arcana: 'major' as const,
|
||||
image: cardImage(c.slug),
|
||||
})),
|
||||
...buildMinor().map((c) => ({ ...c, image: cardImage(c.slug) })),
|
||||
];
|
||||
|
||||
export const SPREADS: Spread[] = [
|
||||
{ id: 'three_card', name: '3카드(과거·현재·미래)', positions: ['과거', '현재', '미래'] },
|
||||
];
|
||||
|
||||
export const CATEGORIES: string[] = ['연애', '일·커리어', '관계', '재물', '건강', '일반'];
|
||||
|
||||
export function findCard(slug: string): TarotCard | undefined {
|
||||
return TAROT_DECK.find((c) => c.slug === slug);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user