Compare commits
135 Commits
e56a2af9e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a67a9d812 | |||
| 677012a9c8 | |||
| 468ee84687 | |||
| 39025fc57b | |||
| 7cd63a3868 | |||
| 895b33d83d | |||
| 2aa424f3ce | |||
| 0742059db2 | |||
| 7100842179 | |||
| da33254076 | |||
| a1a281d059 | |||
| a5b47a0278 | |||
| d5be617eb2 | |||
| abec100a73 | |||
| fc55e6a928 | |||
| 4f41f09a8c | |||
| 5ace251b58 | |||
| 15825616a3 | |||
| fa9cda4f50 | |||
| 5e79ea9233 | |||
| 57a95dee16 | |||
| e50b5a6dc9 | |||
| 65ff294e89 | |||
| 124478e3d6 | |||
| 96a0b06706 | |||
| 26fef53174 | |||
| 5fd7ab8872 | |||
| a9f5d8cee6 | |||
| b3d845a532 | |||
| 10a60300ae | |||
| 3acc1dbbe6 | |||
| 84b36267bf | |||
| 53e8b592f0 | |||
| 1752e68d55 | |||
| 19a5559899 | |||
| 878c0fbf49 | |||
| 57f6eb6684 | |||
| d5f194e7b1 | |||
| a85758566a | |||
| f693c4c5b4 | |||
| 3e031a1c80 | |||
| 90be0d6316 | |||
| 976511df44 | |||
| 3db3d91a40 | |||
| e5ff5ec84f | |||
| 6234f4277a | |||
| 559134100d | |||
| 1b75b27188 | |||
| 7366c18692 | |||
| 8c5858b350 | |||
| 592b3fcf4e | |||
| 1e926fcb19 | |||
| 8e1cf9b4e1 | |||
| 88fe56163d | |||
| 0c6ebb2eaa | |||
| 9241eac4e1 | |||
|
|
65f0a6bb41 | ||
| 7e1105f574 | |||
| f4fd0f60c9 | |||
| 37465701af | |||
| c3be57ea1f | |||
| 897e37f14e | |||
| 7c6238508b | |||
| 989cc25465 | |||
| c1afb58bcd | |||
| b2bd7b1b31 | |||
| e5b907dc38 | |||
| d10fe981f0 | |||
| b705f35c2d | |||
| 4cd4a50869 | |||
| 01c31e3e5d | |||
| e22622d36d | |||
| 186ae546f2 | |||
| eb1ecf0021 | |||
| 4b85c52cfe | |||
| 4223004c24 | |||
| bd13641f5e | |||
| 5cfa124d38 | |||
| 64259a85b5 | |||
| 70068ff3d7 | |||
| 055469a2d5 | |||
| 76fb722a27 | |||
| 7f5c7fcb20 | |||
| dbd4bbf21b | |||
| 5e90295d26 | |||
| 32b07e31fa | |||
| d62653e834 | |||
| 5ceae7e90b | |||
| 70abad31b7 | |||
| f5cfb8bd6f | |||
| b4f57c85ec | |||
| 429780d65d | |||
| 8e820760e2 | |||
| 146836f56b | |||
| f7d26c4c3f | |||
| 5077f6ad17 | |||
| 5751cddcea | |||
| a3933c1081 | |||
| d2a20c5cb7 | |||
| e713ebceee | |||
| dc5e9d431c | |||
| 7b02e28f6c | |||
| 8dafb98f47 | |||
| 199dae0ee5 | |||
| f151af89f3 | |||
| 3fa865a6e7 | |||
| 1d5e7254ed | |||
| 692fb504d9 | |||
| e86ca27831 | |||
| 5d90ac310e | |||
| cf89e8cbdb | |||
| fe055fd0d0 | |||
|
|
0580fe8f5a | ||
| a25b645933 | |||
| c447294c84 | |||
| c2d7455f65 | |||
| 4bd5400406 | |||
| 76994c28f1 | |||
| cd1f67d076 | |||
| e0b6120bb6 | |||
| a11006fab5 | |||
| b846a713c1 | |||
| be3cc3752e | |||
| 89dc5364d1 | |||
| 6d6d6f353a | |||
| b13ddd3841 | |||
| 281edd9a52 | |||
| f6df890297 | |||
| 776985eca8 | |||
| e14e527e28 | |||
| a496c2244b | |||
| d46acc43e3 | |||
| 3e0d8bcf88 | |||
| 0aa4da7143 | |||
| 38fe9dec3f |
212
CLAUDE.md
212
CLAUDE.md
@@ -2,7 +2,7 @@
|
||||
|
||||
## 프로젝트 개요
|
||||
7년차 대기업 백엔드 개발자 **박재오**가 운영하는 개발 부업 사이트.
|
||||
고객 맞춤형 서비스를 개발·판매하거나, 이미 완성된 솔루션을 구독 형태로 제공한다.
|
||||
고객 맞춤형 서비스를 외주 개발하거나, 이미 완성된 솔루션을 계좌이체 구매 형태로 제공한다.
|
||||
|
||||
## 운영자 정보
|
||||
- 이름: 박재오
|
||||
@@ -11,51 +11,174 @@
|
||||
- 연락처: 010-3907-1392
|
||||
- NAS 개인 서버: 로또 랩, 주식 자동매매 프로그램 등 실제 서비스 운영 중
|
||||
|
||||
## 핵심 서비스
|
||||
| 서비스 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| 로또 번호 추천 | `/services/lotto` | 빅데이터/통계 기반 로또 번호 분석 제공 |
|
||||
| 주식 자동 매매 | `/services/stock` | 텔레그램 연동 주식 자동 매매 프로그램 |
|
||||
| 프롬프트 엔지니어링 | `/services/prompt` | 업무 특화 AI 프롬프트 설계 서비스 |
|
||||
| 업무 자동화 | `/services/automation` | RPA·엑셀·이메일 등 일상 업무 자동화 개발 |
|
||||
| 외주 개발 | `/freelance` | 맞춤형 소프트웨어 외주 (포트폴리오 + 문의) |
|
||||
## 핵심 IA (공개 라우트)
|
||||
| 경로 | 설명 |
|
||||
|------|------|
|
||||
| `/` | 메인 — 외주 개발 + 완성 소프트웨어 2축 소개 |
|
||||
| `/outsourcing` | 외주 개발 — 4단계 의뢰 폼 · 프로세스 · 포트폴리오 · FAQ |
|
||||
| `/products` | 완성 소프트웨어 목록 — 계좌이체 구매 |
|
||||
| `/products/[id]` | 제품 상세 — 구매 신청·결제 안내 |
|
||||
| `/showcase` | 제작 사례 — 웹 데모 8종 + 실서비스 운영 사례 |
|
||||
| `/work/saju` | 사주 분석 — 공개 AI 사주 (로그인 시 무료 해석 1회/일) |
|
||||
| `/tarot` | 타로 — 3카드 셔플·해석 (비로그인 카드 리딩, 로그인 AI 인사이트) |
|
||||
| `/music` | 공개 음악 — 스토리→음악 AI 스튜디오 (studio·samples, 로그인 시 생성·저장) |
|
||||
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
||||
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
||||
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
||||
| `/mypage` | 5탭: 프로필 / 발주·진행(발주서·마일스톤·견적코드 연결) / 내 제품(다운로드) / 주문 내역 / AI 기록(사주·타로·음악 병합) |
|
||||
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||
|
||||
## 숨김 서비스 (admin_token 세션 전용)
|
||||
`service_settings` 테이블 토글 + `lib/service-visibility.ts` 가드로 접근 제한.
|
||||
admin/services 패널에서 ON/OFF 전환 가능.
|
||||
|
||||
| 경로 | 서비스 |
|
||||
|------|--------|
|
||||
| `/gyeol` | CONTOUR PMF 설문 |
|
||||
|
||||
## 기술 스택
|
||||
- **Framework**: Next.js 16 (App Router, TypeScript)
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **Email**: Resend (API key: 환경변수 `RESEND_API_KEY`)
|
||||
- **DB**: Supabase (클라우드 + NAS self-host 이중 운영)
|
||||
- **Email**: Resend (`RESEND_API_KEY`) — 문의 접수·주문 확인·견적 발송 메일
|
||||
- **Analytics**: Google Analytics G-WG77RNHXRK
|
||||
- **Deployment**: Vercel
|
||||
- **Test**: vitest (`npm test`) — lib 단위 테스트
|
||||
- **Deployment**: Vercel (NAS self-host 전환 진행 중, 컷오버 전 Vercel 운영)
|
||||
|
||||
## 디자인 시스템
|
||||
- **Primary**: Blue (`#1d4ed8` blue-700, `#2563eb` blue-600)
|
||||
- **Secondary**: Violet/Purple (`#7c3aed` violet-600, `#8b5cf6` violet-500)
|
||||
- **Layout**: 대시보드형 — 왼쪽 고정 사이드바 + 오른쪽 스크롤 콘텐츠
|
||||
- **Sidebar bg**: `#0f172a` (slate-900)
|
||||
- **Main bg**: `#f1f5f9` (slate-100)
|
||||
- **Cards**: white + 그림자
|
||||
## 디자인 시스템 (`--jsm-*` 토큰)
|
||||
|
||||
### CSS 변수
|
||||
| 토큰 | 값 | 역할 |
|
||||
|------|----|------|
|
||||
| `--jsm-bg` | `#f8fafc` | 페이지 배경 |
|
||||
| `--jsm-surface` | `#ffffff` | 카드·패널 배경 |
|
||||
| `--jsm-ink` | `#0f172a` | 본문 텍스트 |
|
||||
| `--jsm-line` | `#e2e8f0` | 구분선·테두리 |
|
||||
| `--jsm-navy` | `#0b1f3a` | 헤더·강조 배경 |
|
||||
| `--jsm-accent` | `#1d4ed8` | 단일 포인트 컬러 (버튼·링크) |
|
||||
|
||||
### 레이아웃
|
||||
- 상단 네비(`TopNav`) + 푸터 포함 `PublicShell` 기업형 레이아웃
|
||||
- Pretendard 폰트
|
||||
|
||||
### 금지 가이드레일
|
||||
- gradient / blur / 보라(violet/purple) 계열 색상 사용 금지
|
||||
- 이모지 사용 금지 (UI 내)
|
||||
- `--jsm-*` 토큰 외 임의 색상 변수 추가 금지
|
||||
|
||||
## 파일 구조
|
||||
```
|
||||
app/
|
||||
layout.tsx — 루트 레이아웃 (메타데이터, 폰트, GA, DashboardShell 래핑)
|
||||
page.tsx — 홈 대시보드 (서비스 카드 그리드)
|
||||
globals.css — 전역 스타일 + CSS 변수
|
||||
components/
|
||||
DashboardShell.tsx — 클라이언트: 사이드바 + 메인 영역 레이아웃 래퍼
|
||||
Sidebar.tsx — 클라이언트: 왼쪽 사이드바 내비게이션
|
||||
ContactForm.tsx — 클라이언트: 문의 폼 (Resend 연동)
|
||||
services/
|
||||
lotto/page.tsx — 로또 번호 추천 서비스 상세
|
||||
stock/page.tsx — 주식 자동 매매 서비스 상세
|
||||
prompt/page.tsx — 프롬프트 엔지니어링 서비스 상세
|
||||
automation/page.tsx — 업무 자동화 서비스 상세
|
||||
freelance/
|
||||
page.tsx — 외주 개발 포트폴리오 + 문의 폼
|
||||
layout.tsx — 루트 레이아웃 (메타데이터·폰트·GA·PublicShell)
|
||||
page.tsx — 메인 (2축 랜딩)
|
||||
globals.css — 전역 스타일 + --jsm-* CSS 변수
|
||||
components/ — 공용 UI (TopNav, PublicShell, ContactForm 등)
|
||||
outsourcing/page.tsx — 외주 의뢰 페이지
|
||||
products/
|
||||
page.tsx — 완성 소프트웨어 목록
|
||||
[id]/page.tsx — 제품 상세 + 구매 신청
|
||||
track/[token]/page.tsx — 비회원 의뢰 추적
|
||||
quote/[token]/page.tsx — 공개 견적 수락/거절
|
||||
login/page.tsx — 로그인 (?next= 지원)
|
||||
mypage/page.tsx — 마이페이지 4탭
|
||||
legal/ — privacy / terms / refund
|
||||
showcase/page.tsx — 제작 사례 허브 (웹 데모 8종 + 실서비스 운영 사례)
|
||||
admin/ — 관리자 전용 (dashboard·members·services·orders·products·contacts·quotes·marketing(광고 관리: 채널 CRUD + 에셋)·...)
|
||||
api/
|
||||
contact/route.ts — POST: 문의 이메일 발송 (Resend)
|
||||
contact/route.ts — POST: 의뢰 접수 (public_token 발급 + 고객 메일)
|
||||
orders/route.ts — POST: 주문 생성(pending)
|
||||
quote/[token]/route.ts — GET/POST: 견적 조회·수락/거절
|
||||
admin/quotes/[id]/send/route.ts — 견적 발송 (메일 + 'quoted' 상태 동기화)
|
||||
admin/ad-channels/ — 광고 채널 CRUD (ad_channels 테이블)
|
||||
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
|
||||
tarot/interpret/route.ts — 타로 AI 인사이트 (로그인·일 3회 제한)
|
||||
tarot/readings/route.ts — 타로 리딩 저장·조회 (tarot_readings)
|
||||
studio/story/route.ts — POST: 스토리→가사 생성 (Gemini, 로그인 필요)
|
||||
studio/tracks/route.ts — GET/POST: 음악 트랙 저장·조회 (music_tracks, 본인 것만)
|
||||
studio/callback/route.ts — POST: Suno webhook 수신용 최소 엔드포인트
|
||||
work/saju/ — 공개: 사주 서비스 (로그인 시 AI 해석 무료 1회/일)
|
||||
tarot/ — 공개: 타로 3카드 (셔플·reference·AI 해석)
|
||||
music/ — 공개: 스토리→음악 AI 스튜디오 (studio·samples, packs는 /products로 308)
|
||||
gyeol/ — 숨김: CONTOUR PMF 설문
|
||||
|
||||
lib/
|
||||
service-visibility.ts — 숨김 서비스 접근 가드
|
||||
product-access.ts — orders→제품 접근 확장 (music tier 하위 호환)
|
||||
request-status.ts — 의뢰 상태 머신 단일 소스
|
||||
order-emails.ts — 주문 관련 Resend 메일
|
||||
request-emails.ts — 의뢰 관련 Resend 메일
|
||||
supabase/
|
||||
product-files.ts — 제품·파일 조회
|
||||
pack-files.ts — 레거시 팩 파일
|
||||
saju-calculator.ts — 사주팔자 계산 (검증 완료)
|
||||
solar-terms.ts — 절기 계산
|
||||
ai-interpretation.ts — 사주 AI 해석·용신 추정
|
||||
ai-usage.ts — AI 기능 일일 사용량 제한 (ai_usage_log 테이블)
|
||||
tarot/
|
||||
cards.ts — 타로 78장 카드 데이터
|
||||
shuffle.ts — 셔플·3카드 드로우 로직
|
||||
reference.ts — 카드 의미 레퍼런스
|
||||
prompt.ts — AI 해석 프롬프트
|
||||
music/
|
||||
story-prompt.ts — 스토리→가사 AI 프롬프트 (시스템 프롬프트·JSON 파싱·검증)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 외주 플로우 (의뢰 상태 머신)
|
||||
|
||||
```
|
||||
고객 의뢰 (/api/contact)
|
||||
→ public_token 발급 + 고객 접수 메일
|
||||
→ admin/contacts 수신
|
||||
↓
|
||||
pending → reviewing → quoted ──→ accepted ──→ in_progress → completed
|
||||
↓ ↓
|
||||
on_hold on_hold
|
||||
↓
|
||||
cancelled (어느 단계에서도 가능)
|
||||
```
|
||||
|
||||
| 전환 | 트리거 |
|
||||
|------|--------|
|
||||
| `pending → reviewing` | 관리자 확인 |
|
||||
| `reviewing → quoted` | 관리자 견적 작성 + `/api/admin/quotes/[id]/send` 발송 (메일 + 상태 동기화) |
|
||||
| `quoted → accepted` | 고객 `/quote/[token]` 수락 (관리자 메일 알림) |
|
||||
| `quoted → on_hold` | 고객 `/quote/[token]` 거절 |
|
||||
| `accepted → in_progress` | 관리자 착수 처리 |
|
||||
| `in_progress → completed` | 관리자 완료 처리 |
|
||||
|
||||
---
|
||||
|
||||
## 결제 플로우 (계좌이체 단일 소스)
|
||||
|
||||
```
|
||||
고객 구매 신청 (/products/[id])
|
||||
→ POST /api/orders → orders 레코드 생성 (status: pending)
|
||||
→ 입금 안내 메일 발송 (케이뱅크 100-116-337157 박재오)
|
||||
|
||||
관리자 입금 확인 (/admin/orders)
|
||||
→ orders.status: pending → paid
|
||||
→ 다운로드 링크 메일 발송
|
||||
|
||||
고객 다운로드 (/mypage → 내 제품 탭)
|
||||
→ POST /api/packs/sign-link → DSM 서명 링크 (4시간 TTL)
|
||||
```
|
||||
|
||||
- `lib/product-access.ts`: orders 기반 접근 + music tier 하위 호환
|
||||
|
||||
---
|
||||
|
||||
## 개발 규칙
|
||||
- 서비스 페이지 공통 구조: Hero → Features → Pricing → FAQ → CTA
|
||||
- 구매/신청 CTA는 `/outsourcing#contact` 또는 `/products/[id]` 구매 버튼으로 연결
|
||||
- 가드레일 준수: gradient·blur·보라·이모지 금지, `--jsm-*` 토큰만 사용
|
||||
- 숨김 서비스 접근: `lib/service-visibility.ts` 가드 → admin_token 세션 없으면 404 반환
|
||||
- 새 라우트 추가 시 공개/숨김 여부를 `service_settings`에 명시
|
||||
- DB 마이그레이션은 클라우드 Supabase + NAS self-host **양쪽** 적용 필수
|
||||
|
||||
---
|
||||
|
||||
## 쟁승메이드 Co. — AI 에이전트 팀 (`.claude/commands/`)
|
||||
|
||||
쟁승메이드는 **회사 단위 AI 팀**으로 운영됩니다.
|
||||
@@ -104,16 +227,10 @@ app/
|
||||
|
||||
---
|
||||
|
||||
## 개발 규칙
|
||||
- 서비스 페이지 공통 구조: Hero → Features → Pricing → FAQ → CTA
|
||||
- 구매/신청 CTA는 `/freelance` 페이지 ContactForm으로 연결 (service 파라미터로 pre-fill)
|
||||
- 사이드바는 `usePathname`으로 활성 경로 감지
|
||||
- 모바일: 햄버거 메뉴로 사이드바 토글 (overlay 포함)
|
||||
- 이미지 없이 아이콘·그래디언트·SVG로 시각적 완성도 유지
|
||||
## 사주 시스템 (`/app/work/saju`, `/lib/saju-*.ts`)
|
||||
|
||||
---
|
||||
|
||||
## 사주 시스템 (`/app/saju`, `/lib/saju-*.ts`)
|
||||
> **공개 서비스 — 로그인 시 AI 해석 무료(1회/일)**
|
||||
> 전 화면(랜딩·입력·결과) `--jsm` 라이트 재스킨 완료(2026-07-03) — 디자인 가드레일 준수
|
||||
|
||||
### AI 연동 (`app/api/saju/analyze/route.ts`)
|
||||
- **AI**: Google Gemini (`@google/generative-ai`)
|
||||
@@ -124,7 +241,7 @@ app/
|
||||
- **Vercel 타임아웃**: `export const maxDuration = 60` (Pro 플랜 기준)
|
||||
- **Mock 감지**: `isMockInterpretation()` 함수로 DB에 캐시된 예시 데이터 판별
|
||||
- `SajuAISection.tsx`에서 mock이면 `validSaved = null`로 처리 → API 재호출
|
||||
- 재생성 버튼(🔄)으로 수동 재생성 가능
|
||||
- 재생성 버튼으로 수동 재생성 가능
|
||||
|
||||
### 사주팔자 계산 원칙 (검증 완료)
|
||||
|
||||
@@ -159,3 +276,12 @@ const stemIndex = (startStem + (branchIndex - 2 + 12) % 12) % 10;
|
||||
년주: 壬申 월주: 壬子 일주: 癸酉 시주: 庚申
|
||||
```
|
||||
이 결과가 나오면 계산 로직 정상. 다른 값이면 위 원칙 재확인.
|
||||
|
||||
---
|
||||
|
||||
## 운영 주의사항
|
||||
- **`.env` 파일 절대 커밋 금지**
|
||||
- **DB 마이그레이션**: 클라우드 Supabase + NAS self-host **양쪽** 적용 필수
|
||||
- **`2026-06-12-products-extend.sql`의 pack_files 백필 UPDATE는 재실행 금지** (중복 데이터 발생)
|
||||
- **NAS self-host 전환 진행 중**: 컷오버 전까지 Vercel 운영 유지
|
||||
- **music/packs 고아 경로**: `/music/packs` → `/products` 308 리다이렉트 (next.config.ts 처리)
|
||||
|
||||
@@ -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>
|
||||
@@ -14,6 +14,9 @@ ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# 빌드타임에만 필요한 더미(일부 route가 모듈 로드 시 env로 SDK 초기화 — 예: new Resend()).
|
||||
# 런타임에는 env_file의 실제값이 사용되므로 무해.
|
||||
ENV RESEND_API_KEY=re_build_dummy
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
@@ -36,6 +36,26 @@ const NAV_ITEMS = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/orders',
|
||||
label: '주문 관리',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/products',
|
||||
label: '제품 관리',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/contacts',
|
||||
label: '문의 내역',
|
||||
@@ -56,41 +76,9 @@ const NAV_ITEMS = [
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/documents',
|
||||
label: '프로젝트 문서',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M13 3v5a2 2 0 002 2h4M9 13h6M9 17h4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/packs',
|
||||
label: '팩 자료',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/questionnaire',
|
||||
label: '질문지 응답',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: '/admin/marketing',
|
||||
label: '마케팅 에셋',
|
||||
label: '광고 관리',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { REQUEST_STATUS, RequestStatus } from '@/lib/request-status';
|
||||
|
||||
interface QuoteSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
id: string;
|
||||
@@ -8,16 +16,35 @@ interface Contact {
|
||||
name: string | null;
|
||||
service: string;
|
||||
message: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
status: string;
|
||||
created_at: string;
|
||||
public_token?: string;
|
||||
project_type?: string;
|
||||
budget?: string;
|
||||
timeline?: string;
|
||||
quotes?: QuoteSummary[];
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '미처리', color: 'bg-yellow-900/40 text-yellow-400' },
|
||||
in_progress: { label: '처리중', color: 'bg-blue-900/40 text-blue-400' },
|
||||
completed: { label: '완료', color: 'bg-green-900/40 text-green-400' },
|
||||
/** 상태별 색상 매핑 — admin 다크 톤 bg-*-900/40 text-*-400 */
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: 'bg-yellow-900/40 text-yellow-400',
|
||||
reviewing: 'bg-sky-900/40 text-sky-400',
|
||||
quoted: 'bg-blue-900/40 text-blue-400',
|
||||
accepted: 'bg-green-900/40 text-green-400',
|
||||
in_progress: 'bg-blue-900/40 text-blue-400',
|
||||
completed: 'bg-green-900/40 text-green-400',
|
||||
on_hold: 'bg-slate-700/60 text-slate-400',
|
||||
cancelled: 'bg-red-900/40 text-red-400',
|
||||
};
|
||||
|
||||
function getStatusColor(status: string): string {
|
||||
return STATUS_COLORS[status] ?? 'bg-slate-700/60 text-slate-400';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
return (REQUEST_STATUS as Record<string, { label: string }>)[status]?.label ?? status;
|
||||
}
|
||||
|
||||
const SERVICE_LABELS: Record<string, string> = {
|
||||
lotto: '로또 추천',
|
||||
stock: '주식 자동매매',
|
||||
@@ -28,12 +55,68 @@ const SERVICE_LABELS: Record<string, string> = {
|
||||
general: '일반 문의',
|
||||
};
|
||||
|
||||
/** 필터 탭 정의 */
|
||||
const FILTER_TABS: { val: string; label: string }[] = [
|
||||
{ val: 'all', label: '전체' },
|
||||
{ val: 'pending', label: '접수' },
|
||||
{ val: 'reviewing', label: '검토중' },
|
||||
{ val: 'quoted', label: '견적 발송' },
|
||||
{ val: 'accepted', label: '수주 확정' },
|
||||
{ val: 'in_progress', label: '진행중' },
|
||||
{ val: 'completed', label: '완료' },
|
||||
{ val: '__other', label: '기타' },
|
||||
];
|
||||
|
||||
const OTHER_STATUSES = new Set(['on_hold', 'cancelled']);
|
||||
|
||||
function matchFilter(status: string, filterVal: string): boolean {
|
||||
if (filterVal === 'all') return true;
|
||||
if (filterVal === '__other') return OTHER_STATUSES.has(status);
|
||||
return status === filterVal;
|
||||
}
|
||||
|
||||
function filterCount(contacts: Contact[], filterVal: string): number {
|
||||
if (filterVal === 'all') return contacts.length;
|
||||
return contacts.filter((c) => matchFilter(c.status, filterVal)).length;
|
||||
}
|
||||
|
||||
export default function AdminContactsPage() {
|
||||
const router = useRouter();
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Contact | null>(null);
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [creatingQuote, setCreatingQuote] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function createQuote(contact: Contact) {
|
||||
setCreatingQuote(true);
|
||||
try {
|
||||
const title = `${SERVICE_LABELS[contact.service] ?? contact.service ?? '외주 문의'} — ${contact.name ?? ''}`.trim();
|
||||
const res = await fetch('/api/admin/quotes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
contact_request_id: contact.id,
|
||||
client_name: contact.name ?? '',
|
||||
client_email: contact.email,
|
||||
}),
|
||||
});
|
||||
const d = await res.json();
|
||||
if (res.ok && d.quote?.id) {
|
||||
router.push('/admin/quotes/' + d.quote.id);
|
||||
} else {
|
||||
alert(d.error || '견적서 생성에 실패했습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('견적서 생성 중 오류가 발생했습니다');
|
||||
} finally {
|
||||
setCreatingQuote(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin/contacts')
|
||||
@@ -53,10 +136,10 @@ export default function AdminContactsPage() {
|
||||
});
|
||||
if (res.ok) {
|
||||
setContacts((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, status: status as Contact['status'] } : c))
|
||||
prev.map((c) => (c.id === id ? { ...c, status } : c))
|
||||
);
|
||||
if (selected?.id === id) {
|
||||
setSelected((prev) => prev ? { ...prev, status: status as Contact['status'] } : null);
|
||||
setSelected((prev) => prev ? { ...prev, status } : null);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -66,7 +149,14 @@ export default function AdminContactsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = contacts.filter((c) => filterStatus === 'all' || c.status === filterStatus);
|
||||
function copyTrackingLink(token: string) {
|
||||
navigator.clipboard.writeText(location.origin + '/track/' + token).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
}
|
||||
|
||||
const filtered = contacts.filter((c) => matchFilter(c.status, filterStatus));
|
||||
const pendingCount = contacts.filter((c) => c.status === 'pending').length;
|
||||
|
||||
return (
|
||||
@@ -84,8 +174,8 @@ export default function AdminContactsPage() {
|
||||
</div>
|
||||
|
||||
{/* 필터 탭 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{[['all', '전체'], ['pending', '미처리'], ['in_progress', '처리중'], ['completed', '완료']].map(([val, label]) => (
|
||||
<div className="flex gap-2 mb-4 flex-wrap">
|
||||
{FILTER_TABS.map(({ val, label }) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setFilterStatus(val)}
|
||||
@@ -98,7 +188,7 @@ export default function AdminContactsPage() {
|
||||
{label}
|
||||
{val !== 'all' && (
|
||||
<span className="ml-1.5 text-xs opacity-70">
|
||||
{contacts.filter((c) => c.status === val).length}
|
||||
{filterCount(contacts, val)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -121,7 +211,10 @@ export default function AdminContactsPage() {
|
||||
filtered.map((contact) => (
|
||||
<button
|
||||
key={contact.id}
|
||||
onClick={() => setSelected(contact)}
|
||||
onClick={() => {
|
||||
setSelected(contact);
|
||||
setCopied(false);
|
||||
}}
|
||||
className={`w-full text-left bg-slate-900 rounded-xl p-4 border transition-all hover:border-slate-600 ${
|
||||
selected?.id === contact.id ? 'border-red-500/50' : 'border-slate-700/50'
|
||||
}`}
|
||||
@@ -139,8 +232,8 @@ export default function AdminContactsPage() {
|
||||
<p className="text-slate-400 text-xs truncate">{contact.message}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_LABELS[contact.status]?.color}`}>
|
||||
{STATUS_LABELS[contact.status]?.label}
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(contact.status)}`}>
|
||||
{getStatusLabel(contact.status)}
|
||||
</span>
|
||||
<span className="text-slate-600 text-xs">
|
||||
{new Date(contact.created_at).toLocaleDateString('ko-KR')}
|
||||
@@ -189,27 +282,85 @@ export default function AdminContactsPage() {
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{/* 상태 변경 */}
|
||||
<div>
|
||||
<p className="text-slate-500 text-xs mb-2">상태 변경</p>
|
||||
<div className="flex gap-2">
|
||||
{(['pending', 'in_progress', 'completed'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => updateStatus(selected.id, s)}
|
||||
disabled={selected.status === s || updating === selected.id}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
|
||||
selected.status === s
|
||||
? STATUS_LABELS[s].color + ' opacity-100'
|
||||
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{STATUS_LABELS[s].label}
|
||||
</button>
|
||||
))}
|
||||
{/* 프로젝트 정보 */}
|
||||
{(selected.project_type || selected.budget || selected.timeline) && (
|
||||
<div className="mb-4 p-3 bg-slate-800 rounded-lg text-sm space-y-1.5">
|
||||
<p className="text-slate-400 font-medium mb-2">프로젝트 정보</p>
|
||||
{selected.project_type && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-500 w-16 flex-shrink-0">유형</span>
|
||||
<span className="text-slate-200">{selected.project_type}</span>
|
||||
</div>
|
||||
)}
|
||||
{selected.budget && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-500 w-16 flex-shrink-0">예산</span>
|
||||
<span className="text-slate-200">{selected.budget}</span>
|
||||
</div>
|
||||
)}
|
||||
{selected.timeline && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-slate-500 w-16 flex-shrink-0">일정</span>
|
||||
<span className="text-slate-200">{selected.timeline}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상태 변경 — 8종 select */}
|
||||
<div className="mb-3">
|
||||
<p className="text-slate-500 text-xs mb-2">상태 변경</p>
|
||||
<select
|
||||
value={selected.status}
|
||||
onChange={(e) => updateStatus(selected.id, e.target.value)}
|
||||
disabled={updating === selected.id}
|
||||
className="w-full bg-slate-800 text-white text-sm rounded-lg px-3 py-2 border border-slate-700 focus:outline-none focus:border-slate-500 disabled:opacity-50"
|
||||
>
|
||||
{(Object.entries(REQUEST_STATUS) as [RequestStatus, { label: string }][]).map(([key, { label }]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
))}
|
||||
{/* 레거시 값 폴백 — REQUEST_STATUS에 없는 경우 표시 */}
|
||||
{!(selected.status in REQUEST_STATUS) && (
|
||||
<option value={selected.status}>{selected.status}</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 추적 링크 복사 */}
|
||||
{selected.public_token && (
|
||||
<button
|
||||
onClick={() => copyTrackingLink(selected.public_token!)}
|
||||
className="mb-2 w-full flex items-center justify-center gap-2 py-2 bg-slate-700/60 text-slate-300 rounded-lg text-xs hover:bg-slate-700 transition"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{copied ? '복사됨!' : '추적 링크 복사'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 연결된 견적 */}
|
||||
{selected.quotes && selected.quotes.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-slate-500 text-xs mb-2">연결된 견적</p>
|
||||
<div className="space-y-1">
|
||||
{selected.quotes.map((q) => (
|
||||
<a
|
||||
key={q.id}
|
||||
href={`/admin/quotes/${q.id}`}
|
||||
className="flex items-center justify-between bg-slate-800 rounded-lg px-3 py-2 text-xs hover:bg-slate-700 transition"
|
||||
>
|
||||
<span className="text-slate-200 truncate flex-1 mr-2">{q.title}</span>
|
||||
<span className="flex-shrink-0 px-2 py-0.5 rounded-full bg-blue-900/40 text-blue-400">
|
||||
{q.status}
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이메일 바로 보내기 링크 */}
|
||||
<a
|
||||
href={`mailto:${selected.email}?subject=[쟁승메이드] 문의 답변&body=안녕하세요, 쟁승메이드입니다.%0A%0A`}
|
||||
@@ -221,6 +372,23 @@ export default function AdminContactsPage() {
|
||||
</svg>
|
||||
이메일 답장하기
|
||||
</a>
|
||||
|
||||
{/* 견적서 작성 (연결 견적이 있으면 라벨 변경) */}
|
||||
<button
|
||||
onClick={() => createQuote(selected)}
|
||||
disabled={creatingQuote}
|
||||
className="mt-2 w-full flex items-center justify-center gap-2 py-2 bg-violet-600/20 text-violet-300 rounded-lg text-xs hover:bg-violet-600/30 transition disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
{creatingQuote
|
||||
? '생성 중...'
|
||||
: selected.quotes && selected.quotes.length > 0
|
||||
? '추가 견적서 작성'
|
||||
: '견적서 작성'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
222
app/admin/orders/page.tsx
Normal file
222
app/admin/orders/page.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
product_id: string | null;
|
||||
amount: number;
|
||||
status: 'pending' | 'paid' | 'cancelled';
|
||||
metadata: Record<string, unknown> | null;
|
||||
created_at: string;
|
||||
product_name: string | null;
|
||||
customer_email: string | null;
|
||||
}
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
pending: { label: '입금 대기', color: 'bg-yellow-900/40 text-yellow-400' },
|
||||
paid: { label: '완료', color: 'bg-green-900/40 text-green-400' },
|
||||
cancelled: { label: '취소', color: 'bg-slate-700/60 text-slate-500' },
|
||||
};
|
||||
|
||||
const FILTER_TABS = [
|
||||
{ val: 'all', label: '전체' },
|
||||
{ val: 'pending', label: '입금 대기' },
|
||||
{ val: 'paid', label: '완료' },
|
||||
{ val: 'cancelled', label: '취소' },
|
||||
] as const;
|
||||
|
||||
export default function AdminOrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
const [updating, setUpdating] = useState<string | null>(null);
|
||||
|
||||
async function loadOrders() {
|
||||
try {
|
||||
const res = await fetch('/api/admin/orders');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const d = await res.json();
|
||||
setOrders(d.orders ?? []);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '불러오기 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadOrders();
|
||||
}, []);
|
||||
|
||||
async function updateStatus(id: string, status: 'paid' | 'cancelled' | 'pending') {
|
||||
if (status === 'paid') {
|
||||
const ok = confirm('입금을 확인하셨습니까? 고객에게 다운로드 활성화 메일이 발송됩니다.');
|
||||
if (!ok) return;
|
||||
}
|
||||
if (status === 'cancelled') {
|
||||
const ok = confirm('이 주문을 취소하시겠습니까?');
|
||||
if (!ok) return;
|
||||
}
|
||||
setUpdating(id);
|
||||
try {
|
||||
const res = await fetch('/api/admin/orders', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, status }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setOrders((prev) =>
|
||||
prev.map((o) => (o.id === id ? { ...o, status } : o))
|
||||
);
|
||||
} else {
|
||||
alert('상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('네트워크 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setUpdating(null);
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = orders.filter((o) => filterStatus === 'all' || o.status === filterStatus);
|
||||
const pendingCount = orders.filter((o) => o.status === 'pending').length;
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-white text-2xl font-bold">주문 관리</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">계좌이체 입금 확인 및 다운로드 활성화</p>
|
||||
</div>
|
||||
{pendingCount > 0 && (
|
||||
<span className="bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 px-3 py-1 rounded-full text-sm font-medium">
|
||||
입금 대기 {pendingCount}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필터 탭 */}
|
||||
<div className="flex gap-2 mb-4">
|
||||
{FILTER_TABS.map(({ val, label }) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setFilterStatus(val)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition ${
|
||||
filterStatus === val
|
||||
? 'bg-red-600/30 text-red-300 border border-red-500/30'
|
||||
: 'bg-slate-800 text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
{val !== 'all' && (
|
||||
<span className="ml-1.5 text-xs opacity-70">
|
||||
{orders.filter((o) => o.status === val).length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-48">
|
||||
<div className="animate-spin w-8 h-8 border-2 border-red-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-2xl p-10 text-center">
|
||||
<p className="text-red-400 font-medium">{error}</p>
|
||||
<button
|
||||
onClick={() => { setLoading(true); setError(null); loadOrders(); }}
|
||||
className="mt-3 text-sm text-slate-400 hover:text-white transition"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="bg-slate-900 rounded-2xl p-10 text-center text-slate-500 border border-slate-700/50">
|
||||
주문 내역이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((order) => {
|
||||
const depositorName =
|
||||
typeof order.metadata?.depositor_name === 'string'
|
||||
? order.metadata.depositor_name
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={order.id}
|
||||
className={`bg-slate-900 rounded-xl p-4 border transition-all ${
|
||||
order.status === 'cancelled'
|
||||
? 'border-slate-800/50 opacity-50'
|
||||
: 'border-slate-700/50 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* 상품명 + 이메일 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-white font-medium text-sm truncate">
|
||||
{order.product_name ?? '(상품 없음)'}
|
||||
</span>
|
||||
<span className="text-blue-400 font-semibold text-sm flex-shrink-0">
|
||||
₩{order.amount.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span className="truncate">
|
||||
{order.customer_email ?? order.user_id ?? '이메일 없음'}
|
||||
</span>
|
||||
{depositorName && (
|
||||
<span className="flex-shrink-0 bg-slate-700 text-slate-300 px-2 py-0.5 rounded-full">
|
||||
입금자: {depositorName}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex-shrink-0">
|
||||
{new Date(order.created_at).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상태 뱃지 + 액션 버튼 */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{order.status === 'paid' ? (
|
||||
<span className="text-green-400 text-xs font-medium">다운로드 활성</span>
|
||||
) : null}
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-medium ${STATUS_LABELS[order.status]?.color}`}
|
||||
>
|
||||
{STATUS_LABELS[order.status]?.label}
|
||||
</span>
|
||||
{order.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'paid')}
|
||||
disabled={updating === order.id}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-green-600/20 text-green-400 border border-green-500/30 hover:bg-green-600/30 transition disabled:opacity-50"
|
||||
>
|
||||
{updating === order.id ? '처리중...' : '입금 확인'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateStatus(order.id, 'cancelled')}
|
||||
disabled={updating === order.id}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium bg-slate-700 text-slate-400 hover:bg-slate-600 hover:text-white transition disabled:opacity-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,252 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type PackTier = 'starter' | 'pro' | 'master';
|
||||
|
||||
interface PackFile {
|
||||
id: string;
|
||||
min_tier: PackTier;
|
||||
label: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
sort_order: number;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
const TIER_LABEL: Record<PackTier, string> = {
|
||||
starter: '입문',
|
||||
pro: '프로',
|
||||
master: '마스터',
|
||||
};
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export default function AdminPacksPage() {
|
||||
const [files, setFiles] = useState<PackFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 업로드 form state
|
||||
const [tier, setTier] = useState<PackTier>('starter');
|
||||
const [label, setLabel] = useState('');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function loadFiles() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/admin/packs');
|
||||
const data = await res.json();
|
||||
setFiles(data.files ?? []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadFiles(); }, []);
|
||||
|
||||
async function handleUpload(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!file || !label) return;
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
// 1) Vercel API에서 일회성 토큰 발급
|
||||
const tokenRes = await fetch('/api/admin/packs/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
label,
|
||||
filename: file.name,
|
||||
sizeBytes: file.size,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.json();
|
||||
throw new Error(err.error ?? '토큰 발급 실패');
|
||||
}
|
||||
const { token, uploadUrl } = await tokenRes.json();
|
||||
|
||||
// 2) 브라우저가 web-backend에 직접 multipart POST (XHR로 진행률 추적)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', uploadUrl);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
setProgress(Math.round((ev.loaded / ev.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else {
|
||||
try {
|
||||
const { detail } = JSON.parse(xhr.responseText);
|
||||
reject(new Error(detail ?? `HTTP ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
xhr.send(fd);
|
||||
});
|
||||
|
||||
// 3) 리스트 갱신
|
||||
setFile(null);
|
||||
setLabel('');
|
||||
setProgress(0);
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '업로드 실패');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, label: string) {
|
||||
if (!confirm(`"${label}" 자료를 삭제하시겠습니까?`)) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/packs?id=${id}`, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error('삭제 실패');
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : '삭제 실패');
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePatch(id: string, updates: Partial<PackFile>) {
|
||||
try {
|
||||
await fetch('/api/admin/packs', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, ...updates }),
|
||||
});
|
||||
await loadFiles();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
const grouped: Record<PackTier, PackFile[]> = {
|
||||
starter: files.filter((f) => f.min_tier === 'starter'),
|
||||
pro: files.filter((f) => f.min_tier === 'pro'),
|
||||
master: files.filter((f) => f.min_tier === 'master'),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-white text-2xl font-bold">팩 자료 관리</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
NAS 자료 업로드 + 다운로드 활성화. 최대 5GB / 4시간 만료 공유 링크.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 업로드 폼 */}
|
||||
<form onSubmit={handleUpload} className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-8">
|
||||
<h2 className="text-white font-bold mb-4">새 자료 업로드</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||
<select
|
||||
value={tier}
|
||||
onChange={(e) => setTier(e.target.value as PackTier)}
|
||||
disabled={uploading}
|
||||
className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
>
|
||||
<option value="starter">{TIER_LABEL.starter}</option>
|
||||
<option value="pro">{TIER_LABEL.pro}</option>
|
||||
<option value="master">{TIER_LABEL.master}</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
disabled={uploading}
|
||||
placeholder="자료 라벨 (예: Suno 프롬프트 북 PDF)"
|
||||
className="bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 md:col-span-2"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
disabled={uploading}
|
||||
className="text-slate-300 mb-3 block"
|
||||
/>
|
||||
{file && (
|
||||
<p className="text-slate-400 text-xs mb-3">
|
||||
선택됨: {file.name} ({formatSize(file.size)})
|
||||
</p>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="mb-3">
|
||||
<div className="bg-slate-800 rounded h-2 overflow-hidden">
|
||||
<div className="bg-violet-500 h-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-slate-400 text-xs mt-1">{progress}% 업로드 중... 페이지를 닫지 마세요</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-red-400 text-sm mb-3">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !file || !label}
|
||||
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white font-bold px-5 py-2 rounded"
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 자료 리스트 */}
|
||||
{loading ? (
|
||||
<p className="text-slate-400">불러오는 중...</p>
|
||||
) : (
|
||||
(['starter', 'pro', 'master'] as PackTier[]).map((t) => (
|
||||
<div key={t} className="mb-6">
|
||||
<h3 className="text-white font-bold mb-2">
|
||||
{TIER_LABEL[t]} ({grouped[t].length})
|
||||
</h3>
|
||||
{grouped[t].length === 0 ? (
|
||||
<p className="text-slate-500 text-sm pl-2">자료 없음</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{grouped[t].map((f) => (
|
||||
<div key={f.id} className="bg-slate-900 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={f.label}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value !== f.label) handlePatch(f.id, { label: e.target.value });
|
||||
}}
|
||||
className="flex-1 bg-transparent text-white border-b border-transparent focus:border-slate-500 px-1 py-1"
|
||||
/>
|
||||
<span className="text-slate-400 text-xs">{f.filename}</span>
|
||||
<span className="text-slate-500 text-xs">{formatSize(f.size_bytes)}</span>
|
||||
<button
|
||||
onClick={() => handleDelete(f.id, f.label)}
|
||||
className="text-red-400 hover:text-red-300 text-sm px-2"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
560
app/admin/products/page.tsx
Normal file
560
app/admin/products/page.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
description_long: string | null;
|
||||
price: number;
|
||||
features: string[] | null;
|
||||
is_listed: boolean;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface PackFile {
|
||||
id: string;
|
||||
product_id: string | null;
|
||||
label: string;
|
||||
filename: string;
|
||||
size_bytes: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id: '',
|
||||
name: '',
|
||||
price: 0,
|
||||
description: '',
|
||||
description_long: '',
|
||||
featuresText: '',
|
||||
is_listed: false,
|
||||
sort_order: 0,
|
||||
};
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [files, setFiles] = useState<PackFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// 폼 상태
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null); // null = 신규
|
||||
const [form, setForm] = useState({ ...EMPTY_FORM });
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 파일 관리 선택 제품
|
||||
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
|
||||
|
||||
// 업로드 상태
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploadLabel, setUploadLabel] = useState('');
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [uploadMsg, setUploadMsg] = useState<string | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
async function loadAll() {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const [pRes, fRes] = await Promise.all([
|
||||
fetch('/api/admin/products'),
|
||||
fetch('/api/admin/packs'),
|
||||
]);
|
||||
const pData = await pRes.json();
|
||||
const fData = await fRes.json();
|
||||
if (!pRes.ok) throw new Error(pData.error ?? '제품 로드 실패');
|
||||
setProducts(pData.products ?? []);
|
||||
setFiles(fData.files ?? []);
|
||||
} catch (e) {
|
||||
setLoadError(e instanceof Error ? e.message : '로드 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 목록만 재조회 후 반환 (자동 배정 매칭용)
|
||||
async function reloadFiles(): Promise<PackFile[]> {
|
||||
const res = await fetch('/api/admin/packs');
|
||||
const data = await res.json();
|
||||
const list: PackFile[] = data.files ?? [];
|
||||
setFiles(list);
|
||||
return list;
|
||||
}
|
||||
|
||||
useEffect(() => { loadAll(); }, []);
|
||||
|
||||
function openNew() {
|
||||
setEditingId(null);
|
||||
setForm({ ...EMPTY_FORM });
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
function openEdit(p: Product) {
|
||||
setEditingId(p.id);
|
||||
setForm({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
price: p.price,
|
||||
description: p.description ?? '',
|
||||
description_long: p.description_long ?? '',
|
||||
featuresText: (p.features ?? []).join('\n'),
|
||||
is_listed: p.is_listed,
|
||||
sort_order: p.sort_order,
|
||||
});
|
||||
setFormError(null);
|
||||
setShowForm(true);
|
||||
}
|
||||
|
||||
async function submitForm(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const features = form.featuresText
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
const payload = {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
price: Number(form.price),
|
||||
description: form.description,
|
||||
description_long: form.description_long,
|
||||
features,
|
||||
is_listed: form.is_listed,
|
||||
sort_order: Number(form.sort_order),
|
||||
};
|
||||
const method = editingId ? 'PATCH' : 'POST';
|
||||
const res = await fetch('/api/admin/products', {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? '저장 실패');
|
||||
setShowForm(false);
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : '저장 실패');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleListed(p: Product) {
|
||||
try {
|
||||
await fetch('/api/admin/products', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: p.id, is_listed: !p.is_listed }),
|
||||
});
|
||||
await loadAll();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function patchFileProduct(fileId: string, productId: string | null) {
|
||||
await fetch('/api/admin/packs', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: fileId, product_id: productId }),
|
||||
});
|
||||
await reloadFiles();
|
||||
}
|
||||
|
||||
async function handleUpload(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setUploadError(null);
|
||||
setUploadMsg(null);
|
||||
if (!uploadFile || !uploadLabel || !selectedProductId) return;
|
||||
setUploading(true);
|
||||
setProgress(0);
|
||||
const targetName = uploadFile.name;
|
||||
const targetSize = uploadFile.size;
|
||||
|
||||
try {
|
||||
// 1) 토큰 발급 (tier는 starter 고정)
|
||||
const tokenRes = await fetch('/api/admin/packs/upload-url', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tier: 'starter',
|
||||
label: uploadLabel,
|
||||
filename: uploadFile.name,
|
||||
sizeBytes: uploadFile.size,
|
||||
}),
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
const err = await tokenRes.json();
|
||||
throw new Error(err.error ?? '토큰 발급 실패');
|
||||
}
|
||||
const { token, uploadUrl } = await tokenRes.json();
|
||||
|
||||
// 2) 브라우저가 web-backend에 직접 multipart POST (XHR 진행률)
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', uploadUrl);
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.upload.onprogress = (ev) => {
|
||||
if (ev.lengthComputable) {
|
||||
setProgress(Math.round((ev.loaded / ev.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else {
|
||||
try {
|
||||
const { detail } = JSON.parse(xhr.responseText);
|
||||
reject(new Error(detail ?? `HTTP ${xhr.status}`));
|
||||
} catch {
|
||||
reject(new Error(`HTTP ${xhr.status}`));
|
||||
}
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('네트워크 오류'));
|
||||
const fd = new FormData();
|
||||
fd.append('file', uploadFile);
|
||||
xhr.send(fd);
|
||||
});
|
||||
|
||||
// 3) 방금 생성된 행을 filename+size로 찾아 자동 배정
|
||||
const fresh = await reloadFiles();
|
||||
const candidates = fresh.filter(
|
||||
(f) => f.filename === targetName && f.size_bytes === targetSize && f.product_id === null,
|
||||
);
|
||||
if (candidates.length === 1) {
|
||||
await patchFileProduct(candidates[0].id, selectedProductId);
|
||||
setUploadMsg('업로드 + 제품 배정 완료');
|
||||
} else {
|
||||
setUploadMsg(
|
||||
'업로드 완료. 자동 배정에 실패했습니다(동명 파일 등). 아래 미배정 목록에서 수동으로 배정하세요.',
|
||||
);
|
||||
}
|
||||
|
||||
setUploadFile(null);
|
||||
setUploadLabel('');
|
||||
setProgress(0);
|
||||
} catch (e) {
|
||||
setUploadError(e instanceof Error ? e.message : '업로드 실패');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedProduct = products.find((p) => p.id === selectedProductId) ?? null;
|
||||
const productFiles = selectedProductId
|
||||
? files.filter((f) => f.product_id === selectedProductId)
|
||||
: [];
|
||||
const otherFiles = selectedProductId
|
||||
? files.filter((f) => f.product_id !== selectedProductId)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-white text-2xl font-bold">제품 관리</h1>
|
||||
<p className="text-slate-400 text-sm mt-0.5">
|
||||
완성 소프트웨어 제품 등록·카탈로그 노출·다운로드 파일 배정.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={openNew}
|
||||
className="bg-violet-600 hover:bg-violet-500 text-white font-bold px-4 py-2 rounded"
|
||||
>
|
||||
+ 새 제품
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
{showForm && (
|
||||
<form onSubmit={submitForm} className="bg-slate-900 rounded-xl border border-slate-700 p-5 mb-8">
|
||||
<h2 className="text-white font-bold mb-4">{editingId ? `제품 편집: ${editingId}` : '새 제품 등록'}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs block mb-1">제품 id (영소문자/숫자/_)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.id}
|
||||
onChange={(e) => setForm({ ...form, id: e.target.value })}
|
||||
disabled={!!editingId || saving}
|
||||
placeholder="예: lotto_pro"
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs block mb-1">제품명</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
disabled={saving}
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs block mb-1">가격 (원, 정수)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
value={form.price}
|
||||
onChange={(e) => setForm({ ...form, price: Number(e.target.value) })}
|
||||
disabled={saving}
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-slate-400 text-xs block mb-1">정렬 순서</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.sort_order}
|
||||
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
|
||||
disabled={saving}
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-slate-400 text-xs block mb-1">짧은 설명 (1줄)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
disabled={saving}
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-slate-400 text-xs block mb-1">상세 설명</label>
|
||||
<textarea
|
||||
value={form.description_long}
|
||||
onChange={(e) => setForm({ ...form, description_long: e.target.value })}
|
||||
disabled={saving}
|
||||
rows={3}
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label className="text-slate-400 text-xs block mb-1">특징 (줄바꿈으로 구분)</label>
|
||||
<textarea
|
||||
value={form.featuresText}
|
||||
onChange={(e) => setForm({ ...form, featuresText: e.target.value })}
|
||||
disabled={saving}
|
||||
rows={3}
|
||||
placeholder={'텔레그램 연동\n실시간 알림\n백테스트'}
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 mb-4 text-slate-300 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.is_listed}
|
||||
onChange={(e) => setForm({ ...form, is_listed: e.target.checked })}
|
||||
disabled={saving}
|
||||
/>
|
||||
카탈로그에 노출 (is_listed)
|
||||
</label>
|
||||
{formError && <p className="text-red-400 text-sm mb-3">{formError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 text-white font-bold px-5 py-2 rounded"
|
||||
>
|
||||
{saving ? '저장 중...' : editingId ? '수정 저장' : '제품 생성'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
disabled={saving}
|
||||
className="bg-slate-700 hover:bg-slate-600 text-white px-5 py-2 rounded"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 제품 목록 */}
|
||||
{loading ? (
|
||||
<p className="text-slate-400">불러오는 중...</p>
|
||||
) : loadError ? (
|
||||
<p className="text-red-400">{loadError}</p>
|
||||
) : products.length === 0 ? (
|
||||
<p className="text-slate-500">등록된 제품이 없습니다. [+ 새 제품]으로 등록하세요.</p>
|
||||
) : (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl overflow-hidden mb-8">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800 text-slate-400">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3">제품명</th>
|
||||
<th className="text-right px-4 py-3">가격</th>
|
||||
<th className="text-center px-4 py-3">노출</th>
|
||||
<th className="text-center px-4 py-3">순서</th>
|
||||
<th className="text-right px-4 py-3">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={p.id} className="border-t border-slate-800">
|
||||
<td className="px-4 py-3 text-white">
|
||||
{p.name}
|
||||
<span className="text-slate-500 text-xs ml-2">{p.id}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-slate-300">₩{p.price.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<button
|
||||
onClick={() => toggleListed(p)}
|
||||
className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
p.is_listed
|
||||
? 'bg-emerald-600/30 text-emerald-300 border border-emerald-500/40'
|
||||
: 'bg-slate-700 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{p.is_listed ? '노출' : '숨김'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-slate-400">{p.sort_order}</td>
|
||||
<td className="px-4 py-3 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => openEdit(p)}
|
||||
className="text-violet-400 hover:text-violet-300 px-2"
|
||||
>
|
||||
편집
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedProductId(p.id); setUploadMsg(null); setUploadError(null); }}
|
||||
className="text-blue-400 hover:text-blue-300 px-2"
|
||||
>
|
||||
파일 관리
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 관리 섹션 */}
|
||||
{selectedProduct && (
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-white font-bold">
|
||||
파일 관리 — {selectedProduct.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedProductId(null)}
|
||||
className="text-slate-400 hover:text-white text-sm"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 현재 제품 파일 */}
|
||||
<h3 className="text-slate-300 font-semibold text-sm mb-2">배정된 파일 ({productFiles.length})</h3>
|
||||
{productFiles.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm mb-4">배정된 파일이 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-2 mb-4">
|
||||
{productFiles.map((f) => (
|
||||
<div key={f.id} className="bg-slate-800 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
|
||||
<span className="flex-1 text-white">{f.label}</span>
|
||||
<span className="text-slate-400 text-xs">{f.filename}</span>
|
||||
<span className="text-slate-500 text-xs">{formatSize(f.size_bytes)}</span>
|
||||
<button
|
||||
onClick={() => patchFileProduct(f.id, null)}
|
||||
className="text-red-400 hover:text-red-300 text-sm px-2"
|
||||
>
|
||||
배정 해제
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 업로드 */}
|
||||
<form onSubmit={handleUpload} className="border-t border-slate-800 pt-4 mb-4">
|
||||
<h3 className="text-slate-300 font-semibold text-sm mb-2">파일 업로드</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={uploadLabel}
|
||||
onChange={(e) => setUploadLabel(e.target.value)}
|
||||
disabled={uploading}
|
||||
placeholder="파일 라벨 (예: 설치 가이드 PDF)"
|
||||
className="w-full bg-slate-800 text-white border border-slate-700 rounded px-3 py-2 mb-3"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
onChange={(e) => setUploadFile(e.target.files?.[0] ?? null)}
|
||||
disabled={uploading}
|
||||
className="text-slate-300 mb-3 block"
|
||||
/>
|
||||
{uploadFile && (
|
||||
<p className="text-slate-400 text-xs mb-3">
|
||||
선택됨: {uploadFile.name} ({formatSize(uploadFile.size)})
|
||||
</p>
|
||||
)}
|
||||
{uploading && (
|
||||
<div className="mb-3">
|
||||
<div className="bg-slate-800 rounded h-2 overflow-hidden">
|
||||
<div className="bg-violet-500 h-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-slate-400 text-xs mt-1">{progress}% 업로드 중... 페이지를 닫지 마세요</p>
|
||||
</div>
|
||||
)}
|
||||
{uploadMsg && <p className="text-emerald-400 text-sm mb-3">{uploadMsg}</p>}
|
||||
{uploadError && <p className="text-red-400 text-sm mb-3">{uploadError}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={uploading || !uploadFile || !uploadLabel}
|
||||
className="bg-violet-600 hover:bg-violet-500 disabled:bg-slate-700 disabled:cursor-not-allowed text-white font-bold px-5 py-2 rounded"
|
||||
>
|
||||
{uploading ? '업로드 중...' : '업로드 + 자동 배정'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 미배정/타제품 파일 배정 */}
|
||||
<div className="border-t border-slate-800 pt-4">
|
||||
<h3 className="text-slate-300 font-semibold text-sm mb-2">다른 파일 배정 ({otherFiles.length})</h3>
|
||||
{otherFiles.length === 0 ? (
|
||||
<p className="text-slate-500 text-sm">배정 가능한 다른 파일이 없습니다.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{otherFiles.map((f) => (
|
||||
<div key={f.id} className="bg-slate-800 border border-slate-700 rounded-lg p-3 flex items-center gap-3">
|
||||
<span className="flex-1 text-white">{f.label}</span>
|
||||
<span className="text-slate-400 text-xs">{f.filename}</span>
|
||||
<span className="text-slate-500 text-xs">
|
||||
{f.product_id ? `현재: ${f.product_id}` : '미배정'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => patchFileProduct(f.id, selectedProduct.id)}
|
||||
className="text-blue-400 hover:text-blue-300 text-sm px-2"
|
||||
>
|
||||
이 제품에 배정
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ export default function QuoteEditorPage() {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||
const [mileSaving, setMileSaving] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/admin/quotes/${id}`)
|
||||
@@ -125,6 +126,39 @@ export default function QuoteEditorPage() {
|
||||
setMileSaving(null);
|
||||
}
|
||||
|
||||
// ── 고객에게 발송 ───────────────────────
|
||||
const SENT_STATUSES = ['sent', 'accepted', 'rejected'];
|
||||
const isSentStatus = SENT_STATUSES.includes(form.status);
|
||||
|
||||
async function sendToClient() {
|
||||
if (!form.client_email || isSentStatus) return;
|
||||
if (!confirm("고객에게 견적 메일을 발송하고 상태를 '발송됨'으로 변경합니다.")) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/quotes/${id}/send`, { method: 'POST' });
|
||||
const d = await res.json();
|
||||
if (res.ok && d.success) {
|
||||
if (d.alreadySent) {
|
||||
alert('이미 발송된 견적입니다');
|
||||
return;
|
||||
}
|
||||
setField('status', 'sent');
|
||||
if (d.emailSent === false) {
|
||||
alert('상태는 변경됐으나 메일 발송에 실패했습니다 — 수동 발송이 필요합니다');
|
||||
} else {
|
||||
alert('발송 완료');
|
||||
}
|
||||
} else {
|
||||
alert(d.error || '발송에 실패했습니다');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('발송 중 오류가 발생했습니다');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ────────────────────────────
|
||||
const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
||||
|
||||
@@ -255,6 +289,27 @@ export default function QuoteEditorPage() {
|
||||
PDF 저장
|
||||
</a>
|
||||
)}
|
||||
{/* 고객에게 발송 */}
|
||||
<button
|
||||
onClick={sendToClient}
|
||||
disabled={sending || !form.client_email || isSentStatus}
|
||||
title={
|
||||
isSentStatus ? '이미 발송된 견적입니다' :
|
||||
!form.client_email ? '고객 이메일을 먼저 입력하세요' :
|
||||
'고객에게 견적 메일 발송'
|
||||
}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{sending ? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
{isSentStatus ? '발송됨' : '고객에게 발송'}
|
||||
</button>
|
||||
{!form.client_email && !isSentStatus && (
|
||||
<span className="text-xs text-amber-400/80">이메일 입력 필요</span>
|
||||
)}
|
||||
|
||||
{/* 저장 */}
|
||||
<button onClick={() => save()} disabled={saving}
|
||||
className={`flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold transition-all ${saved ? 'bg-green-600 text-white' : 'bg-blue-600 hover:bg-blue-500 text-white'} disabled:opacity-60`}>
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
import { cookies } from 'next/headers';
|
||||
import { isRequestStatus } from '@/lib/request-status';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -18,7 +19,7 @@ export async function GET() {
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data: contacts, error } = await supabase
|
||||
.from('contact_requests')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
@@ -28,7 +29,35 @@ export async function GET() {
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ contacts: data ?? [] });
|
||||
if (!contacts || contacts.length === 0) {
|
||||
return NextResponse.json({ contacts: [] });
|
||||
}
|
||||
|
||||
// 2-쿼리 머지: 연결 견적 부착 (컬럼 부재 등 오류는 빈 배열 폴백)
|
||||
const ids = contacts.map((c) => c.id).filter(Boolean) as string[];
|
||||
let quotesMap: Record<string, { id: string; title: string; status: string }[]> = {};
|
||||
try {
|
||||
const { data: quotesData } = await supabase
|
||||
.from('quotes')
|
||||
.select('id, title, status, contact_request_id')
|
||||
.in('contact_request_id', ids);
|
||||
if (quotesData) {
|
||||
for (const q of quotesData) {
|
||||
if (!q.contact_request_id) continue;
|
||||
if (!quotesMap[q.contact_request_id]) quotesMap[q.contact_request_id] = [];
|
||||
quotesMap[q.contact_request_id].push({ id: q.id, title: q.title, status: q.status });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 컬럼 부재 등 — 빈 배열 폴백
|
||||
}
|
||||
|
||||
const enriched = contacts.map((c) => ({
|
||||
...c,
|
||||
quotes: quotesMap[c.id] ?? [],
|
||||
}));
|
||||
|
||||
return NextResponse.json({ contacts: enriched });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
@@ -37,11 +66,16 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
|
||||
const { id, status } = await request.json();
|
||||
|
||||
if (typeof id !== 'string' || !isRequestStatus(status)) {
|
||||
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contact_requests')
|
||||
.update({ status })
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -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 };
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
97
app/api/admin/orders/route.ts
Normal file
97
app/api/admin/orders/route.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
import { getProductById } from '@/lib/supabase/product-files';
|
||||
import { sendOrderPaidEmail } from '@/lib/order-emails';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function checkAuth() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
return token && verifyAdminTokenNode(token);
|
||||
}
|
||||
|
||||
// GET: 주문 목록 (최근 200건) — 상품명 + 주문자 이메일 포함
|
||||
export async function GET() {
|
||||
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// 2-쿼리 방식: FK 관계 중첩 select 대신 명시적 조인으로 안전하게
|
||||
const { data: orders, error } = await supabase
|
||||
.from('orders')
|
||||
.select('id, user_id, product_id, amount, status, metadata, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(200);
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
|
||||
if (!orders || orders.length === 0) {
|
||||
return NextResponse.json({ orders: [] });
|
||||
}
|
||||
|
||||
// 상품명 조회
|
||||
const productIds = [...new Set(orders.map((o) => o.product_id).filter(Boolean))] as string[];
|
||||
const userIds = [...new Set(orders.map((o) => o.user_id).filter(Boolean))] as string[];
|
||||
|
||||
const [productsRes, profilesRes] = await Promise.all([
|
||||
productIds.length > 0
|
||||
? supabase.from('products').select('id, name').in('id', productIds)
|
||||
: Promise.resolve({ data: [] as { id: string; name: string }[] | null, error: null }),
|
||||
userIds.length > 0
|
||||
? supabase.from('profiles').select('id, email').in('id', userIds)
|
||||
: Promise.resolve({ data: [] as { id: string; email: string }[] | null, error: null }),
|
||||
]);
|
||||
|
||||
const productMap = Object.fromEntries((productsRes.data ?? []).map((p) => [p.id, p.name]));
|
||||
const profileMap = Object.fromEntries((profilesRes.data ?? []).map((p) => [p.id, p.email]));
|
||||
|
||||
const enriched = orders.map((o) => ({
|
||||
...o,
|
||||
product_name: o.product_id ? (productMap[o.product_id] ?? null) : null,
|
||||
customer_email: o.user_id ? (profileMap[o.user_id] ?? null) : null,
|
||||
}));
|
||||
|
||||
return NextResponse.json({ orders: enriched });
|
||||
}
|
||||
|
||||
// PATCH: 상태 변경 ('paid' 전환 시 고객에게 다운로드 활성화 메일)
|
||||
export async function PATCH(request: Request) {
|
||||
if (!(await checkAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
const { id, status } = await request.json();
|
||||
if (typeof id !== 'string' || !['pending', 'paid', 'cancelled'].includes(status)) {
|
||||
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data: order, error } = await supabase
|
||||
.from('orders')
|
||||
.update({ status, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select('id, product_id, user_id')
|
||||
.single();
|
||||
|
||||
if (error || !order) return NextResponse.json({ error: error?.message ?? 'not found' }, { status: 500 });
|
||||
|
||||
// paid 전환 시에만 메일 발송 — 실패해도 상태 변경은 이미 완료
|
||||
if (status === 'paid' && order.product_id && order.user_id) {
|
||||
try {
|
||||
const product = await getProductById(supabase, order.product_id);
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.eq('id', order.user_id)
|
||||
.maybeSingle();
|
||||
if (product && profile?.email) {
|
||||
await sendOrderPaidEmail({ product, customerEmail: profile.email });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('paid email failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export async function PATCH(request: Request) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const { id, label, sort_order, min_tier } = await request.json();
|
||||
const { id, label, sort_order, min_tier, product_id } = await request.json();
|
||||
if (!id) return NextResponse.json({ error: 'id 필요' }, { status: 400 });
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
@@ -43,6 +43,7 @@ export async function PATCH(request: Request) {
|
||||
if (typeof min_tier === 'string' && VALID_TIERS.has(min_tier as PackTier)) {
|
||||
updates.min_tier = min_tier;
|
||||
}
|
||||
if (typeof product_id === 'string' || product_id === null) updates.product_id = product_id;
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
|
||||
}
|
||||
|
||||
111
app/api/admin/products/route.ts
Normal file
111
app/api/admin/products/route.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function checkAuth() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
return token && verifyAdminTokenNode(token);
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9_]{2,40}$/;
|
||||
|
||||
function sanitizeFeatures(input: unknown): string[] | undefined {
|
||||
if (!Array.isArray(input)) return undefined;
|
||||
return input.filter((v): v is string => typeof v === 'string');
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.order('sort_order')
|
||||
.order('id');
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ products: data ?? [] });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id, name, description, description_long, price, features, is_listed, sort_order } = body;
|
||||
|
||||
if (typeof id !== 'string' || !ID_RE.test(id)) {
|
||||
return NextResponse.json({ error: 'id는 영소문자/숫자/언더스코어 2-40자' }, { status: 400 });
|
||||
}
|
||||
if (typeof name !== 'string' || name.trim().length === 0) {
|
||||
return NextResponse.json({ error: 'name 필요' }, { status: 400 });
|
||||
}
|
||||
if (typeof price !== 'number' || !Number.isInteger(price) || price < 0) {
|
||||
return NextResponse.json({ error: 'price는 0 이상의 정수' }, { status: 400 });
|
||||
}
|
||||
|
||||
const insert: Record<string, unknown> = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
price,
|
||||
category: 'software',
|
||||
pay_method: 'bank_transfer',
|
||||
is_active: true,
|
||||
};
|
||||
if (typeof description === 'string') insert.description = description;
|
||||
if (typeof description_long === 'string') insert.description_long = description_long;
|
||||
const feats = sanitizeFeatures(features);
|
||||
if (feats !== undefined) insert.features = feats;
|
||||
if (typeof is_listed === 'boolean') insert.is_listed = is_listed;
|
||||
if (typeof sort_order === 'number') insert.sort_order = sort_order;
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { data, error } = await supabase.from('products').insert(insert).select().single();
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
return NextResponse.json({ error: '이미 존재하는 제품 id' }, { status: 409 });
|
||||
}
|
||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
}
|
||||
return NextResponse.json({ product: data });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { id } = body;
|
||||
if (typeof id !== 'string' || !id) {
|
||||
return NextResponse.json({ error: 'id 필요' }, { status: 400 });
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (typeof body.name === 'string') updates.name = body.name.trim();
|
||||
if (typeof body.description === 'string') updates.description = body.description;
|
||||
if (typeof body.description_long === 'string') updates.description_long = body.description_long;
|
||||
if (typeof body.price === 'number' && Number.isInteger(body.price) && body.price >= 0) {
|
||||
updates.price = body.price;
|
||||
}
|
||||
const feats = sanitizeFeatures(body.features);
|
||||
if (feats !== undefined) updates.features = feats;
|
||||
if (typeof body.is_listed === 'boolean') updates.is_listed = body.is_listed;
|
||||
if (typeof body.is_active === 'boolean') updates.is_active = body.is_active;
|
||||
if (typeof body.sort_order === 'number') updates.sort_order = body.sort_order;
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return NextResponse.json({ error: '변경할 필드 없음' }, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const { error } = await supabase.from('products').update(updates).eq('id', id);
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
89
app/api/admin/quotes/[id]/send/route.ts
Normal file
89
app/api/admin/quotes/[id]/send/route.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||
import { sendQuoteSentEmail } from '@/lib/request-emails';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
async function checkAuth() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('admin_token')?.value;
|
||||
return token && verifyAdminTokenNode(token);
|
||||
}
|
||||
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!(await checkAuth())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// 1. 견적서 조회
|
||||
const { data: quote, error: fetchError } = await supabase
|
||||
.from('quotes')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single();
|
||||
|
||||
if (fetchError || !quote) {
|
||||
return NextResponse.json({ error: '견적서를 찾을 수 없습니다' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 2. 이미 발송/수락/거절된 견적은 재발송 차단
|
||||
if (['sent', 'accepted', 'rejected'].includes(quote.status)) {
|
||||
return NextResponse.json({ success: true, emailSent: false, alreadySent: true });
|
||||
}
|
||||
|
||||
// 3. 고객 이메일 필수
|
||||
if (!quote.client_email) {
|
||||
return NextResponse.json({ error: '고객 이메일을 먼저 입력하세요' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 4. public_token 보장
|
||||
const quoteToken: string = quote.public_token || crypto.randomUUID();
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
// 5. 견적 상태 업데이트
|
||||
const updatePayload: Record<string, unknown> = { status: 'sent', updated_at: nowIso };
|
||||
if (!quote.public_token) updatePayload.public_token = quoteToken;
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('quotes')
|
||||
.update(updatePayload)
|
||||
.eq('id', id);
|
||||
|
||||
if (updateError) {
|
||||
console.error('[Quote Send] update error:', updateError.message);
|
||||
return NextResponse.json({ error: '견적 상태 업데이트 실패' }, { status: 500 });
|
||||
}
|
||||
|
||||
// 6. 연결된 의뢰 상태 동기화 (실패해도 진행)
|
||||
if (quote.contact_request_id) {
|
||||
const { error: syncError } = await supabase
|
||||
.from('contact_requests')
|
||||
.update({ status: 'quoted', updated_at: nowIso })
|
||||
.eq('id', quote.contact_request_id);
|
||||
if (syncError) {
|
||||
console.error('[Quote Send] contact sync error:', syncError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 견적 메일 발송 (실패해도 상태 변경은 유지)
|
||||
let emailSent = true;
|
||||
try {
|
||||
await sendQuoteSentEmail({
|
||||
clientName: quote.client_name || '고객',
|
||||
clientEmail: quote.client_email,
|
||||
quoteTitle: quote.title,
|
||||
quoteToken,
|
||||
validUntil: quote.valid_until ?? null,
|
||||
});
|
||||
} catch (e) {
|
||||
emailSent = false;
|
||||
console.error('[Quote Send] email error:', e);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, emailSent });
|
||||
}
|
||||
@@ -34,19 +34,25 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const supabase = createAdminClient();
|
||||
|
||||
// 의뢰(contact_requests) 연결용 필드 — string만 허용
|
||||
const insertData: Record<string, unknown> = {
|
||||
title: typeof body.title === 'string' && body.title.trim() ? body.title : '새 견적서',
|
||||
client_name: typeof body.client_name === 'string' ? body.client_name : '',
|
||||
client_email: typeof body.client_email === 'string' ? body.client_email : '',
|
||||
valid_until: body.valid_until || null,
|
||||
wbs: body.wbs || [],
|
||||
items: body.items || [],
|
||||
maintenance: body.maintenance || [],
|
||||
notes: body.notes || '',
|
||||
status: 'draft',
|
||||
};
|
||||
if (typeof body.contact_request_id === 'string' && body.contact_request_id) {
|
||||
insertData.contact_request_id = body.contact_request_id;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('quotes')
|
||||
.insert({
|
||||
title: body.title || '새 견적서',
|
||||
client_name: body.client_name || '',
|
||||
client_email: body.client_email || '',
|
||||
valid_until: body.valid_until || null,
|
||||
wbs: body.wbs || [],
|
||||
items: body.items || [],
|
||||
maintenance: body.maintenance || [],
|
||||
notes: body.notes || '',
|
||||
status: 'draft',
|
||||
})
|
||||
.insert(insertData)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
|
||||
@@ -51,10 +51,6 @@ export async function PATCH(request: Request) {
|
||||
}
|
||||
|
||||
const DEFAULT_SERVICES = [
|
||||
{ id: 'saju', name: 'AI 사주 분석', description: '사주 입력 및 AI 해석 서비스', is_active: true, order_index: 1 },
|
||||
{ id: 'lotto', name: '로또 번호 추천', description: '빅데이터 기반 로또 번호 분석', is_active: true, order_index: 2 },
|
||||
{ id: 'stock', name: '주식 자동매매', description: '텔레그램 연동 자동매매 프로그램', is_active: true, order_index: 3 },
|
||||
{ id: 'automation', name: '업무 자동화 RPA', description: '반복 업무 자동화 개발', is_active: true, order_index: 4 },
|
||||
{ id: 'prompt', name: '프롬프트 엔지니어링', description: 'AI 프롬프트 설계 서비스', is_active: true, order_index: 5 },
|
||||
{ id: 'freelance', name: '외주 개발', description: '맞춤형 소프트웨어 개발', is_active: true, order_index: 6 },
|
||||
{ id: 'gyeol', name: 'CONTOUR 설문', description: '/gyeol PMF 설문', is_active: false, order_index: 103 },
|
||||
{ id: 'lotto', name: '로또 추천', description: '로또 번호 추천 노출', is_active: false, order_index: 105 },
|
||||
];
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/lib/security';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { sendRequestReceivedEmail } from '@/lib/request-emails';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
@@ -31,11 +32,15 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
|
||||
// ── 입력 정제 + 길이 제한 ─────────────────────────────────
|
||||
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
|
||||
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
|
||||
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
|
||||
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
|
||||
const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE);
|
||||
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
|
||||
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
|
||||
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
|
||||
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
|
||||
const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE);
|
||||
// 구조화 필드 (선택값 — 미전송 시 빈 문자열)
|
||||
const projectType = sanitizeStr(body.projectType, 100);
|
||||
const budget = sanitizeStr(body.budget, 100);
|
||||
const timeline = sanitizeStr(body.timeline, 100);
|
||||
|
||||
// ── 필수값 검증 ───────────────────────────────────────────
|
||||
if (!name || !email || !message) {
|
||||
@@ -99,21 +104,74 @@ export async function POST(request: Request) {
|
||||
emailSent = false;
|
||||
}
|
||||
|
||||
// ── 추적 토큰 생성 ────────────────────────────────────────
|
||||
let publicToken: string;
|
||||
try {
|
||||
publicToken = globalThis.crypto.randomUUID();
|
||||
} catch {
|
||||
const { randomUUID } = await import('crypto');
|
||||
publicToken = randomUUID();
|
||||
}
|
||||
|
||||
// ── DB 저장 (이메일 성공/실패 무관) ──────────────────────
|
||||
// 신규 컬럼 포함 insert 시도 → 컬럼 부재(42703) 시 기존 필드만으로 재시도
|
||||
let tokenStored = false;
|
||||
try {
|
||||
const admin = createAdminClient();
|
||||
await admin.from('contact_requests').insert({
|
||||
const { error: insertError } = await admin.from('contact_requests').insert({
|
||||
name,
|
||||
email,
|
||||
phone: phone || null,
|
||||
service: service || null,
|
||||
message,
|
||||
user_id: userId,
|
||||
public_token: publicToken,
|
||||
project_type: projectType || null,
|
||||
budget: budget || null,
|
||||
timeline: timeline || null,
|
||||
});
|
||||
|
||||
if (insertError) {
|
||||
// PostgreSQL undefined_column (42703) — 마이그레이션 미적용 환경 폴백
|
||||
const pgCode = (insertError as { code?: string }).code;
|
||||
if (pgCode === '42703') {
|
||||
console.warn('[Contact] 신규 컬럼 없음(42703) — 기존 필드만으로 재시도');
|
||||
const { error: fallbackError } = await admin.from('contact_requests').insert({
|
||||
name,
|
||||
email,
|
||||
phone: phone || null,
|
||||
service: service || null,
|
||||
message,
|
||||
user_id: userId,
|
||||
});
|
||||
if (fallbackError) {
|
||||
console.error('[Contact] DB fallback insert error:', fallbackError);
|
||||
}
|
||||
// tokenStored는 false 유지 (공개 토큰이 DB에 없음)
|
||||
} else {
|
||||
console.error('[Contact] DB insert error:', insertError);
|
||||
}
|
||||
} else {
|
||||
tokenStored = true;
|
||||
}
|
||||
} catch (dbError) {
|
||||
console.error('[Contact] DB insert error:', dbError);
|
||||
}
|
||||
|
||||
// ── 고객 접수 확인 메일 (신규 컬럼 insert 성공 시에만) ──
|
||||
if (tokenStored) {
|
||||
try {
|
||||
await sendRequestReceivedEmail({
|
||||
name,
|
||||
email,
|
||||
service: service || '외주 문의',
|
||||
publicToken,
|
||||
});
|
||||
} catch (confirmEmailError) {
|
||||
console.error('[Contact] 고객 확인 메일 발송 오류:', confirmEmailError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!emailSent) {
|
||||
return NextResponse.json(
|
||||
{ error: '메일 전송에 실패했습니다. 다시 시도해주세요.' },
|
||||
@@ -122,7 +180,11 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, message: '문의가 성공적으로 전송되었습니다!' },
|
||||
{
|
||||
success: true,
|
||||
message: '문의가 성공적으로 전송되었습니다!',
|
||||
trackUrl: tokenStored ? `/track/${publicToken}` : null,
|
||||
},
|
||||
{ status: 200 }
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { sendMessage } from '@/lib/telegram';
|
||||
|
||||
/**
|
||||
* GET /api/cron/subscription-expiry
|
||||
* Vercel Cron: 매일 01:00 KST (UTC 16:00) 실행
|
||||
* - 만료된 구독 → status='expired'
|
||||
* - 3일 후 만료 예정 구독 → 텔레그램 알림 발송
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
// Vercel Cron 인증
|
||||
const authHeader = req.headers.get('authorization');
|
||||
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const supabase = createAdminClient();
|
||||
const now = new Date().toISOString();
|
||||
const in3days = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
// 1. 만료된 구독 처리
|
||||
const { data: expired, error: expireError } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({ status: 'expired' })
|
||||
.eq('status', 'active')
|
||||
.lt('expires_at', now)
|
||||
.select('id, user_id, product_id');
|
||||
|
||||
if (expireError) {
|
||||
console.error('subscription expiry error:', expireError);
|
||||
}
|
||||
|
||||
// 2. 3일 후 만료 예정 → 텔레그램 알림
|
||||
const { data: expiringSoon } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, user_id, product_id, expires_at, profiles!inner(telegram_chat_id)')
|
||||
.eq('status', 'active')
|
||||
.eq('auto_renew', false)
|
||||
.lt('expires_at', in3days)
|
||||
.gt('expires_at', now);
|
||||
|
||||
const PLAN_NAMES: Record<string, string> = {
|
||||
lotto_gold: '🥇 골드',
|
||||
lotto_platinum: '💎 플래티넘',
|
||||
lotto_diamond: '👑 다이아',
|
||||
};
|
||||
|
||||
let notified = 0;
|
||||
if (expiringSoon) {
|
||||
for (const sub of expiringSoon) {
|
||||
const profile = sub.profiles as unknown as { telegram_chat_id: string | null };
|
||||
const chatId = profile?.telegram_chat_id;
|
||||
if (!chatId) continue;
|
||||
|
||||
const expiresAt = new Date(sub.expires_at).toLocaleDateString('ko-KR');
|
||||
const planName = PLAN_NAMES[sub.product_id] ?? sub.product_id;
|
||||
|
||||
await sendMessage(
|
||||
chatId,
|
||||
`⏰ *구독 만료 안내*\n\n` +
|
||||
`로또 번호 추천 *${planName}* 플랜이\n` +
|
||||
`*${expiresAt}*에 만료됩니다.\n\n` +
|
||||
`지속적인 번호 추천을 받으시려면\n` +
|
||||
`마이페이지에서 구독을 갱신해 주세요.\n\n` +
|
||||
`👉 https://jaengseung-made.com/mypage`
|
||||
);
|
||||
notified++;
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
expired_count: expired?.length ?? 0,
|
||||
notified_count: notified,
|
||||
processed_at: now,
|
||||
});
|
||||
}
|
||||
123
app/api/orders/route.ts
Normal file
123
app/api/orders/route.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createServerClient as createSSRClient } from '@supabase/ssr';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getProductById } from '@/lib/supabase/product-files';
|
||||
import { sanitizeStr, checkRateLimit } from '@/lib/security';
|
||||
import { sendOrderReceivedEmails } from '@/lib/order-emails';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증 확인 (SSR 쿠키 클라이언트)
|
||||
const cookieStore = await cookies();
|
||||
const supabase = createSSRClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll: () => cookieStore.getAll(),
|
||||
setAll: () => {},
|
||||
},
|
||||
},
|
||||
);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 1-b) Rate Limit: user 기준 분당 5회
|
||||
const rl = checkRateLimit(`orders:${user.id}`, 60_000, 5);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: '요청이 너무 잦습니다. 잠시 후 다시 시도해주세요' },
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Retry-After': String(Math.ceil(rl.retryAfterMs / 1000)) },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// 2) body 검증
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청입니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawProductId = (body as Record<string, unknown>).productId;
|
||||
const rawDepositorName = (body as Record<string, unknown>).depositorName;
|
||||
|
||||
const productId = sanitizeStr(rawProductId, 64);
|
||||
const depositorName = sanitizeStr(rawDepositorName, 40);
|
||||
|
||||
if (!productId || !depositorName) {
|
||||
return NextResponse.json({ error: 'productId와 depositorName이 필요합니다' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3) 상품 조회 및 활성 상태 확인
|
||||
const admin = createAdminClient();
|
||||
let product;
|
||||
try {
|
||||
product = await getProductById(admin, productId);
|
||||
} catch (dbErr) {
|
||||
console.error('[Orders] product lookup error:', dbErr);
|
||||
return NextResponse.json({ error: '상품 조회에 실패했습니다' }, { status: 500 });
|
||||
}
|
||||
|
||||
if (!product || !product.is_active) {
|
||||
return NextResponse.json({ error: '판매 중인 상품이 아닙니다' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 4) 중복 pending 방지
|
||||
const { data: existing } = await admin
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('product_id', productId)
|
||||
.eq('status', 'pending')
|
||||
.maybeSingle();
|
||||
|
||||
if (existing) {
|
||||
return NextResponse.json({ orderId: existing.id, reused: true });
|
||||
}
|
||||
|
||||
// 5) 주문 생성 (가격은 DB 소스)
|
||||
const { data: order, error: insertError } = await admin
|
||||
.from('orders')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
product_id: productId,
|
||||
amount: product.price,
|
||||
status: 'pending',
|
||||
metadata: {
|
||||
method: 'bank_transfer',
|
||||
depositor_name: depositorName,
|
||||
},
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (insertError || !order) {
|
||||
console.error('[Orders] insert error:', insertError);
|
||||
return NextResponse.json({ error: '주문 생성에 실패했습니다' }, { status: 500 });
|
||||
}
|
||||
|
||||
const orderId = order.id as string;
|
||||
|
||||
// 6) 메일 발송 (실패해도 주문 유효)
|
||||
try {
|
||||
await sendOrderReceivedEmails({
|
||||
orderId,
|
||||
product,
|
||||
customerEmail: user.email ?? '',
|
||||
depositorName,
|
||||
});
|
||||
} catch (mailError) {
|
||||
console.error('[Orders] email send error:', mailError);
|
||||
}
|
||||
|
||||
// 7) 응답
|
||||
return NextResponse.json({ orderId });
|
||||
}
|
||||
@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createServerClient as createSSRClient } from '@supabase/ssr';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
|
||||
import { tierIncludes, getPackFilesForTiers } from '@/lib/supabase/pack-files';
|
||||
import { getUserAccessibleProductIds, getFilesByProductIds } from '@/lib/supabase/product-files';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -20,21 +19,25 @@ export async function GET() {
|
||||
},
|
||||
);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ files: [] });
|
||||
if (!user) return NextResponse.json({ products: [] });
|
||||
|
||||
const admin = createAdminClient();
|
||||
const { data: orders } = await admin
|
||||
.from('contact_requests')
|
||||
.select('service, status')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'completed');
|
||||
const productIds = await getUserAccessibleProductIds(admin, user.id);
|
||||
if (productIds.length === 0) return NextResponse.json({ products: [] });
|
||||
|
||||
const tiers = new Set<PackTier>();
|
||||
for (const o of (orders ?? [])) {
|
||||
const t = extractPackTier(o.service);
|
||||
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
|
||||
const [files, { data: products }] = await Promise.all([
|
||||
getFilesByProductIds(admin, productIds),
|
||||
admin.from('products').select('id, name').in('id', productIds),
|
||||
]);
|
||||
|
||||
const nameMap = new Map((products ?? []).map((p) => [p.id, p.name as string]));
|
||||
const grouped = new Map<string, { id: string; name: string; files: typeof files }>();
|
||||
for (const f of files) {
|
||||
if (!f.product_id) continue;
|
||||
if (!grouped.has(f.product_id)) {
|
||||
grouped.set(f.product_id, { id: f.product_id, name: nameMap.get(f.product_id) ?? f.product_id, files: [] });
|
||||
}
|
||||
grouped.get(f.product_id)!.files.push(f);
|
||||
}
|
||||
|
||||
const files = await getPackFilesForTiers(admin, Array.from(tiers));
|
||||
return NextResponse.json({ files });
|
||||
return NextResponse.json({ products: Array.from(grouped.values()) });
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { createServerClient as createSSRClient } from '@supabase/ssr';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { extractPackTier, type PackTier } from '@/lib/pack-assets';
|
||||
import { tierIncludes, getPackFileById } from '@/lib/supabase/pack-files';
|
||||
import { getUserAccessibleProductIds, getFileById } from '@/lib/supabase/product-files';
|
||||
import { signLink } from '@/lib/web-backend';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -33,33 +32,18 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2) orders 조회 — completed Music 팩 구매 확인
|
||||
// 2) orders(paid) 단일 소스로 접근 가능한 product_id 확인
|
||||
const admin = createAdminClient();
|
||||
const { data: orders } = await admin
|
||||
.from('contact_requests')
|
||||
.select('service, status')
|
||||
.eq('user_id', user.id)
|
||||
.eq('status', 'completed');
|
||||
|
||||
const tiers = new Set<PackTier>();
|
||||
for (const o of (orders ?? [])) {
|
||||
const t = extractPackTier(o.service);
|
||||
if (t) tierIncludes(t).forEach((x) => tiers.add(x));
|
||||
const accessible = await getUserAccessibleProductIds(admin, user.id);
|
||||
if (accessible.length === 0) {
|
||||
return NextResponse.json({ error: '구매 내역이 없거나 입금 확인 전입니다' }, { status: 403 });
|
||||
}
|
||||
if (tiers.size === 0) {
|
||||
return NextResponse.json({ error: '구매 내역이 없거나 결제 미완료입니다' }, { status: 403 });
|
||||
const file = await getFileById(admin, fileId);
|
||||
if (!file || file.deleted_at || !file.product_id || !accessible.includes(file.product_id)) {
|
||||
return NextResponse.json({ error: '구매한 제품의 파일이 아닙니다' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 3) 파일 조회 + tier 매칭
|
||||
const file = await getPackFileById(admin, fileId);
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: '파일을 찾을 수 없습니다' }, { status: 404 });
|
||||
}
|
||||
if (!tiers.has(file.min_tier)) {
|
||||
return NextResponse.json({ error: '구매 등급에서 접근할 수 없는 파일입니다' }, { status: 403 });
|
||||
}
|
||||
|
||||
// 4) web-backend 호출 → DSM 공유 링크
|
||||
// 3) web-backend 호출 → DSM 공유 링크
|
||||
try {
|
||||
const { url, expires_at } = await signLink({
|
||||
file_path: file.file_path,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { sendQuoteDecisionEmail } from '@/lib/request-emails';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -24,31 +25,79 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
|
||||
return NextResponse.json({ quote: data, expired });
|
||||
}
|
||||
|
||||
// 고객이 견적 수락
|
||||
// 고객이 견적 수락/거절
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const body = await request.json(); // { selectedItems, selectedMaintenance }
|
||||
const body = await request.json(); // { action?, selectedItems, selectedMaintenance, total }
|
||||
const action: 'accept' | 'reject' = body.action === 'reject' ? 'reject' : 'accept';
|
||||
const supabase = createAdminClient();
|
||||
|
||||
const { data: quote, error: findErr } = await supabase
|
||||
.from('quotes')
|
||||
.select('id, title, client_name, client_email')
|
||||
.select('id, title, client_name, client_email, status, contact_request_id')
|
||||
.eq('public_token', token)
|
||||
.single();
|
||||
|
||||
if (findErr || !quote) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||
|
||||
// 상태를 accepted로 변경
|
||||
await supabase
|
||||
.from('quotes')
|
||||
.update({
|
||||
status: 'accepted',
|
||||
accepted_items: body.selectedItems,
|
||||
accepted_maintenance: body.selectedMaintenance,
|
||||
accepted_total: body.total,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', quote.id);
|
||||
// 이미 처리된 견적 중복 처리 방지
|
||||
if (quote.status === 'accepted' || quote.status === 'rejected') {
|
||||
return NextResponse.json({ error: '이미 처리된 견적입니다' }, { status: 409 });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (action === 'accept') {
|
||||
// 상태를 accepted로 변경 (기존 로직 유지)
|
||||
await supabase
|
||||
.from('quotes')
|
||||
.update({
|
||||
status: 'accepted',
|
||||
accepted_items: body.selectedItems,
|
||||
accepted_maintenance: body.selectedMaintenance,
|
||||
accepted_total: body.total,
|
||||
updated_at: now,
|
||||
})
|
||||
.eq('id', quote.id);
|
||||
} else {
|
||||
// 상태를 rejected로 변경 (accepted_* 미기록)
|
||||
await supabase
|
||||
.from('quotes')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
updated_at: now,
|
||||
})
|
||||
.eq('id', quote.id);
|
||||
}
|
||||
|
||||
// 연결된 의뢰 상태 동기화 (실패 시 무시)
|
||||
if (quote.contact_request_id) {
|
||||
try {
|
||||
const crStatus = action === 'accept' ? 'accepted' : 'on_hold';
|
||||
await supabase
|
||||
.from('contact_requests')
|
||||
.update({ status: crStatus, updated_at: now })
|
||||
.eq('id', quote.contact_request_id);
|
||||
} catch (e) {
|
||||
console.error('[quote POST] contact_request sync failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 관리자 알림 메일 (실패 시 무시)
|
||||
try {
|
||||
const decision = action === 'accept' ? 'accepted' : 'rejected';
|
||||
const totalValue = action === 'accept' && typeof body.total === 'number' && Number.isFinite(body.total)
|
||||
? body.total
|
||||
: undefined;
|
||||
await sendQuoteDecisionEmail({
|
||||
decision,
|
||||
quoteTitle: quote.title,
|
||||
clientName: quote.client_name || '고객',
|
||||
total: totalValue,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[quote POST] sendQuoteDecisionEmail failed:', e);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
||||
@@ -64,29 +64,23 @@ const MODELS = [
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
// ── 결제 사용자 인증 (Gemini API 무단 호출 방지) ──────────
|
||||
// ── 로그인 인증 + 서버측 일일 사용량 제한 (Gemini API 무단 호출 방지) ──────────
|
||||
const { createClient } = await import('@/lib/supabase/server');
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
|
||||
if (user) {
|
||||
// 로그인된 경우: saju_detail 결제 여부 확인
|
||||
const { data: paidOrder } = await supabase
|
||||
.from('orders')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.eq('product_id', 'saju_detail')
|
||||
.eq('status', 'paid')
|
||||
.maybeSingle();
|
||||
|
||||
if (!paidOrder) {
|
||||
return NextResponse.json({ error: '사주 리포트를 구매한 사용자만 이용할 수 있습니다' }, { status: 403 });
|
||||
}
|
||||
} else {
|
||||
if (!user) {
|
||||
// 비로그인 사용자는 AI 호출 불가
|
||||
return NextResponse.json({ error: '로그인이 필요합니다' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { createAdminClient } = await import('@/lib/supabase/admin');
|
||||
const { getTodayUsage, recordUsage, SAJU_DAILY_LIMIT } = await import('@/lib/ai-usage');
|
||||
const admin = createAdminClient();
|
||||
if ((await getTodayUsage(admin, user.id, 'saju')) >= SAJU_DAILY_LIMIT) {
|
||||
return NextResponse.json({ error: `오늘 AI 사주 해석을 모두 사용했습니다. (${SAJU_DAILY_LIMIT}회/일)` }, { status: 429 });
|
||||
}
|
||||
|
||||
// ── 입력 길이 검증 (DoS / 프롬프트 인젝션 기초 방어) ──────
|
||||
const raw = await request.json();
|
||||
if (JSON.stringify(raw).length > 50_000) {
|
||||
@@ -182,6 +176,9 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ interpretation: MOCK_INTERPRETATION, analysis });
|
||||
}
|
||||
|
||||
// 실제 Gemini 해석 성공 시에만 일일 사용량 카운트 (MOCK 폴백 경로는 카운트하지 않음)
|
||||
await recordUsage(admin, user.id, 'saju');
|
||||
|
||||
return NextResponse.json({ interpretation, analysis });
|
||||
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const SAJU_ENGINE_URL = process.env.SAJU_ENGINE_URL;
|
||||
const SAJU_ENGINE_SECRET = process.env.SAJU_ENGINE_SECRET;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!SAJU_ENGINE_URL) {
|
||||
return NextResponse.json({ error: '사주 엔진 URL이 설정되지 않았습니다' }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const response = await fetch(`${SAJU_ENGINE_URL}/saju/lotto`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(SAJU_ENGINE_SECRET ? { 'X-API-Secret': SAJU_ENGINE_SECRET } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: data.detail || '로또 번호 생성 실패' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return NextResponse.json({ error: '사주 엔진 응답 시간 초과' }, { status: 504 });
|
||||
}
|
||||
console.error('로또 번호 생성 프록시 오류:', error);
|
||||
return NextResponse.json({ error: '서버 오류' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
9
app/api/studio/callback/route.ts
Normal file
9
app/api/studio/callback/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// Suno webhook 수신용 최소 엔드포인트.
|
||||
// 트랙 저장은 폴링 + 클라이언트 트리거(/api/studio/tracks)가 담당하므로 여기서는 200만 반환한다.
|
||||
export async function POST() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getTodayUsage, recordUsage, MUSIC_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
@@ -13,6 +16,23 @@ type GenerateBody = {
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증 — 로그인 사용자만 (Suno API 무단 호출 방지)
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2) 일일 제한
|
||||
const admin = createAdminClient();
|
||||
const used = await getTodayUsage(admin, user.id, 'music');
|
||||
if (used >= MUSIC_DAILY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: `오늘 음악 생성을 모두 사용했습니다. (${MUSIC_DAILY_LIMIT}회/일)` },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
const apiUrl = process.env.SUNO_API_URL ?? 'https://api.sunoapi.org';
|
||||
const apiKey = process.env.SUNO_API_KEY;
|
||||
|
||||
@@ -69,6 +89,11 @@ export async function POST(request: Request) {
|
||||
{ status: res.ok ? 502 : res.status },
|
||||
);
|
||||
}
|
||||
try {
|
||||
await recordUsage(admin, user.id, 'music');
|
||||
} catch {
|
||||
/* 집계 실패는 무시 — 생성은 이미 성공 */
|
||||
}
|
||||
return NextResponse.json({ ok: true, data });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
|
||||
115
app/api/studio/story/route.ts
Normal file
115
app/api/studio/story/route.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import {
|
||||
STORY_SYSTEM_PROMPT,
|
||||
buildStoryUserMessage,
|
||||
parseStoryJson,
|
||||
validateStory,
|
||||
} from '@/lib/music/story-prompt';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
|
||||
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
|
||||
// 모델 우선순위 — 사주 analyze·타로 interpret와 동일 폴백 목록(이 API 키로 접근 가능한 모델만)
|
||||
const MODELS = [
|
||||
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
|
||||
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
|
||||
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
|
||||
] as const;
|
||||
|
||||
// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환
|
||||
const TIME_BUDGET_MS = 45_000;
|
||||
// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지)
|
||||
// 일일 사용량 집계·제한은 generate 단계에서만 수행 — story는 가사 초안 생성일 뿐이라 미집계.
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2) 입력 검증
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 });
|
||||
}
|
||||
const story = typeof body.story === 'string' ? body.story.trim() : '';
|
||||
if (!story) {
|
||||
return NextResponse.json({ error: '이야기를 입력해주세요.' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 3) API 키
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.warn('[스튜디오] GEMINI_API_KEY 미설정 — 503 반환 (예시 가사 반환 금지, 데이터 오염 방지)');
|
||||
return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||
}
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
const userMessage = buildStoryUserMessage(story);
|
||||
|
||||
// 4) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll
|
||||
// wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502)
|
||||
const startedAt = Date.now();
|
||||
let feedback = '';
|
||||
let attempts = 0;
|
||||
|
||||
modelLoop:
|
||||
for (const { id: modelId, maxTokens } of MODELS) {
|
||||
// retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll
|
||||
for (let retry = 0; retry < 2; retry += 1) {
|
||||
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) {
|
||||
break modelLoop;
|
||||
}
|
||||
attempts += 1;
|
||||
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelId,
|
||||
systemInstruction: STORY_SYSTEM_PROMPT,
|
||||
generationConfig: {
|
||||
temperature: 0.9,
|
||||
topP: 0.95,
|
||||
maxOutputTokens: maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = feedback
|
||||
? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.`
|
||||
: userMessage;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const text = result.response.text();
|
||||
const parsed = parseStoryJson(text);
|
||||
const invalid = parsed ? validateStory(parsed) : 'JSON 파싱 실패';
|
||||
|
||||
if (parsed && !invalid) {
|
||||
return NextResponse.json({ story: parsed });
|
||||
}
|
||||
|
||||
// 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속)
|
||||
feedback = invalid ?? 'JSON 파싱 실패';
|
||||
} catch (modelError) {
|
||||
// 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백
|
||||
feedback = modelError instanceof Error ? modelError.message : 'model error';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: '가사 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
49
app/api/studio/tracks/route.ts
Normal file
49
app/api/studio/tracks/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
|
||||
}
|
||||
|
||||
const str = (k: string) => (typeof body[k] === 'string' ? (body[k] as string) : null);
|
||||
|
||||
const admin = createAdminClient();
|
||||
const { data, error } = await admin.from('music_tracks').insert({
|
||||
user_id: user.id,
|
||||
title: str('title'),
|
||||
story: str('story'),
|
||||
lyrics: str('lyrics'),
|
||||
style: str('style'),
|
||||
audio_url: str('audio_url'),
|
||||
task_id: str('task_id'),
|
||||
}).select('id, created_at').single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
// 세션 클라이언트로 본인 것만(RLS music_select_own)
|
||||
const { data, error } = await supabase
|
||||
.from('music_tracks')
|
||||
.select('id, title, story, lyrics, style, audio_url, task_id, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ tracks: data ?? [] });
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
|
||||
/**
|
||||
* PATCH /api/subscription/[id]
|
||||
* action: 'cancel' | 'toggle_autorenew'
|
||||
*
|
||||
* cancel — 구독 즉시 해지 (status='cancelled', auto_renew=false)
|
||||
* toggle_autorenew — 자동갱신 on/off 전환
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
let body: { action?: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'INVALID_JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { action } = body;
|
||||
|
||||
// 본인 구독인지 확인
|
||||
const { data: sub, error: fetchError } = await supabase
|
||||
.from('subscriptions')
|
||||
.select('id, status, auto_renew, expires_at')
|
||||
.eq('id', id)
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (fetchError || !sub) {
|
||||
return NextResponse.json({ error: 'NOT_FOUND' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (action === 'cancel') {
|
||||
if (sub.status === 'cancelled') {
|
||||
return NextResponse.json({ error: 'ALREADY_CANCELLED' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({
|
||||
status: 'cancelled',
|
||||
auto_renew: false,
|
||||
cancelled_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: '구독이 해지되었습니다. 만료일까지는 서비스를 계속 이용할 수 있습니다.',
|
||||
expires_at: sub.expires_at,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'toggle_autorenew') {
|
||||
if (sub.status === 'cancelled' || sub.status === 'expired') {
|
||||
return NextResponse.json({ error: 'SUBSCRIPTION_NOT_ACTIVE' }, { status: 400 });
|
||||
}
|
||||
|
||||
const newValue = !sub.auto_renew;
|
||||
const { error } = await supabase
|
||||
.from('subscriptions')
|
||||
.update({ auto_renew: newValue })
|
||||
.eq('id', id);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, auto_renew: newValue });
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'INVALID_ACTION' }, { status: 400 });
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
/**
|
||||
* GET /api/subscription
|
||||
* 내 활성/만료 구독 목록 조회
|
||||
* - auth 검증은 anon client, DB 조회는 admin client (RLS 우회)
|
||||
*/
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user }, error: authError } = await supabase.auth.getUser();
|
||||
if (authError || !user) {
|
||||
return NextResponse.json({ error: 'UNAUTHORIZED' }, { status: 401 });
|
||||
}
|
||||
|
||||
// admin client로 RLS 우회 (subscriptions 테이블 SELECT 정책 없을 때도 동작)
|
||||
const admin = createAdminClient();
|
||||
const { data, error } = await admin
|
||||
.from('subscriptions')
|
||||
.select('id, product_id, status, auto_renew, started_at, expires_at, cancelled_at')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(20);
|
||||
|
||||
if (error) {
|
||||
return NextResponse.json({ error: 'DB_ERROR', detail: error.message }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, subscriptions: data ?? [] });
|
||||
}
|
||||
133
app/api/tarot/interpret/route.ts
Normal file
133
app/api/tarot/interpret/route.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getTodayUsage, recordUsage, TAROT_DAILY_LIMIT } from '@/lib/ai-usage';
|
||||
import { TAROT_SYSTEM_PROMPT, buildTarotUserMessage, parseTarotJson, validateTarot } from '@/lib/tarot/prompt';
|
||||
import { config as loadDotenv } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
// Vercel 최대 타임아웃 (Pro plan 300s, Hobby 60s)
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Next.js가 env 로드를 놓치는 경우 대비해 직접 로드 (Windows 환경 대응)
|
||||
loadDotenv({ path: resolve(process.cwd(), '.env.local'), override: true });
|
||||
|
||||
// 모델 우선순위 — 강력한 순서 (이 API 키로 접근 가능한 모델만) — 사주 analyze와 동일 폴백 목록
|
||||
const MODELS = [
|
||||
{ id: 'gemini-2.5-pro', maxTokens: 8192 },
|
||||
{ id: 'gemini-2.5-flash', maxTokens: 8192 },
|
||||
{ id: 'gemini-2.0-flash', maxTokens: 8192 },
|
||||
] as const;
|
||||
|
||||
// wall-clock 예산 — maxDuration(60s)보다 여유 있게 끊어 graceful 502를 반환
|
||||
const TIME_BUDGET_MS = 45_000;
|
||||
// 최악 호출 수 상한 — 모델 폴백 × 검증 실패 reroll을 합쳐도 이 값을 넘지 않음
|
||||
const MAX_ATTEMPTS = 3;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// 1) 인증 — 로그인 사용자만 (Gemini API 무단 호출 방지)
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 2) 일일 제한
|
||||
const admin = createAdminClient();
|
||||
const used = await getTodayUsage(admin, user.id, 'tarot');
|
||||
if (used >= TAROT_DAILY_LIMIT) {
|
||||
return NextResponse.json(
|
||||
{ error: `오늘 타로 AI 해석을 모두 사용했습니다. (${TAROT_DAILY_LIMIT}회/일)` },
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// 3) 입력 검증
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식입니다.' }, { status: 400 });
|
||||
}
|
||||
const spread_type = typeof body.spread_type === 'string' && body.spread_type ? body.spread_type : 'three_card';
|
||||
const cards_reference = typeof body.cards_reference === 'string' ? body.cards_reference : '';
|
||||
if (!cards_reference) {
|
||||
return NextResponse.json({ error: 'cards_reference가 필요합니다.' }, { status: 400 });
|
||||
}
|
||||
const category = typeof body.category === 'string' ? body.category : null;
|
||||
const question = typeof body.question === 'string' ? body.question : null;
|
||||
const context_meta = body.context_meta ?? {};
|
||||
|
||||
// 4) API 키
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
console.warn('[타로] GEMINI_API_KEY 미설정 — 503 반환 (예시 해석 반환 금지, 데이터 오염 방지)');
|
||||
return NextResponse.json({ error: 'AI 서비스가 준비 중입니다.' }, { status: 503 });
|
||||
}
|
||||
const genAI = new GoogleGenerativeAI(apiKey);
|
||||
|
||||
const userMessage = buildTarotUserMessage({
|
||||
spread_type,
|
||||
category,
|
||||
question,
|
||||
cards_reference,
|
||||
context_meta,
|
||||
});
|
||||
|
||||
// 5) 호출 — 모델 폴백 + 검증 실패 시 같은 모델로 1회 reroll
|
||||
// wall-clock 45s 예산과 총 호출 3회 상한으로 최악 케이스를 조기 종료(→ 502)
|
||||
const startedAt = Date.now();
|
||||
let feedback = '';
|
||||
let attempts = 0;
|
||||
|
||||
modelLoop:
|
||||
for (const { id: modelId, maxTokens } of MODELS) {
|
||||
// retry 0: 최초 시도, retry 1: 검증 실패 시에만 같은 모델로 1회 reroll
|
||||
for (let retry = 0; retry < 2; retry += 1) {
|
||||
if (attempts >= MAX_ATTEMPTS || Date.now() - startedAt > TIME_BUDGET_MS) {
|
||||
break modelLoop;
|
||||
}
|
||||
attempts += 1;
|
||||
|
||||
try {
|
||||
const model = genAI.getGenerativeModel({
|
||||
model: modelId,
|
||||
systemInstruction: TAROT_SYSTEM_PROMPT,
|
||||
generationConfig: {
|
||||
temperature: 0.8,
|
||||
topP: 0.95,
|
||||
maxOutputTokens: maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
const prompt = feedback
|
||||
? `${userMessage}\n\n[이전 시도 오류: ${feedback}] 스키마를 정확히 지켜 다시 출력하세요.`
|
||||
: userMessage;
|
||||
|
||||
const result = await model.generateContent(prompt);
|
||||
const text = result.response.text();
|
||||
const parsed = parseTarotJson(text);
|
||||
const invalid = parsed ? validateTarot(parsed, spread_type) : 'JSON 파싱 실패';
|
||||
|
||||
if (parsed && !invalid) {
|
||||
await recordUsage(admin, user.id, 'tarot');
|
||||
return NextResponse.json({ interpretation_json: parsed, model: modelId });
|
||||
}
|
||||
|
||||
// 검증 실패 — 사유를 피드백으로 주입해 같은 모델로 1회 reroll(retry 루프 계속)
|
||||
feedback = invalid ?? 'JSON 파싱 실패';
|
||||
} catch (modelError) {
|
||||
// 호출 자체의 예외(레이트리밋 등)는 reroll하지 않고 바로 다음 모델로 폴백
|
||||
feedback = modelError instanceof Error ? modelError.message : 'model error';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: '해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
50
app/api/tarot/readings/route.ts
Normal file
50
app/api/tarot/readings/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { createClient } from '@/lib/supabase/server';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: '잘못된 요청 형식' }, { status: 400 });
|
||||
}
|
||||
|
||||
const interp = body.interpretation_json as { summary?: string } | undefined;
|
||||
if (!interp) return NextResponse.json({ error: 'interpretation_json 필요' }, { status: 400 });
|
||||
|
||||
const admin = createAdminClient();
|
||||
const { data, error } = await admin.from('tarot_readings').insert({
|
||||
user_id: user.id,
|
||||
spread_type: (body.spread_type as string) ?? 'three_card',
|
||||
category: (body.category as string) ?? null,
|
||||
question: (body.question as string) ?? null,
|
||||
cards: body.cards ?? [],
|
||||
interpretation: interp,
|
||||
summary: interp.summary ?? null,
|
||||
}).select('id, created_at').single();
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 });
|
||||
|
||||
// 세션 클라이언트로 본인 것만(RLS tarot_select_own)
|
||||
const { data, error } = await supabase
|
||||
.from('tarot_readings')
|
||||
.select('id, spread_type, category, question, cards, interpretation, summary, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
|
||||
return NextResponse.json({ readings: data ?? [] });
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import { createClient } from '@/lib/supabase/server';
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams, origin } = new URL(request.url);
|
||||
const code = searchParams.get('code');
|
||||
const next = searchParams.get('next') ?? '/mypage';
|
||||
const rawNext = searchParams.get('next') ?? '/mypage';
|
||||
const next =
|
||||
rawNext.startsWith('/') && !rawNext.startsWith('//') && !rawNext.startsWith('/\\')
|
||||
? rawNext
|
||||
: '/mypage';
|
||||
|
||||
// 리다이렉트 기준 URL 결정
|
||||
// - dev: 항상 현재 request의 origin (localhost) → NEXT_PUBLIC_SITE_URL 무시
|
||||
|
||||
374
app/components/BankTransferModal.tsx
Normal file
374
app/components/BankTransferModal.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
// 계좌이체 구매 모달.
|
||||
// - 열릴 때 세션 확인 → 미로그인이면 로그인 유도(구매 폼 미노출)
|
||||
// - 로그인 상태: 입금자명 + 약관 동의 → POST /api/orders
|
||||
// - 주문 금액은 서버가 DB price로 확정한다. 아래 표시 금액은 안내용일 뿐이다.
|
||||
// 접근성: role="dialog" aria-modal, Esc/backdrop 닫기, TopNav 드로어 패턴 차용.
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const BANK = { name: '케이뱅크', account: '100-116-337157', holder: '박재오' };
|
||||
|
||||
interface Props {
|
||||
product: { id: string; name: string; price: number };
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type AuthState = 'checking' | 'guest' | 'user';
|
||||
|
||||
interface SuccessInfo {
|
||||
orderId: string;
|
||||
depositorName: string;
|
||||
reused: boolean;
|
||||
}
|
||||
|
||||
export default function BankTransferModal({ product, isOpen, onClose }: Props) {
|
||||
const [authState, setAuthState] = useState<AuthState>('checking');
|
||||
const [depositorName, setDepositorName] = useState('');
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState<SuccessInfo | null>(null);
|
||||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const priceLabel = `₩${product.price.toLocaleString('ko-KR')}`;
|
||||
const loginHref = `/login?next=${encodeURIComponent(`/products/${product.id}`)}`;
|
||||
|
||||
// 열릴 때마다 상태 초기화 + 세션 확인
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let mounted = true;
|
||||
setAuthState('checking');
|
||||
setDepositorName('');
|
||||
setAgreed(false);
|
||||
setSubmitting(false);
|
||||
setError('');
|
||||
setSuccess(null);
|
||||
|
||||
const supabase = createClient();
|
||||
supabase.auth
|
||||
.getSession()
|
||||
.then(({ data }) => {
|
||||
if (mounted) setAuthState(data.session?.user ? 'user' : 'guest');
|
||||
})
|
||||
.catch(() => {
|
||||
if (mounted) setAuthState('guest');
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Esc 닫기 + body 스크롤 잠금
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
const prevOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prevOverflow;
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// 초기 포커스: 모달 열릴 때 닫기 버튼으로 포커스 이동
|
||||
useEffect(() => {
|
||||
if (isOpen) closeBtnRef.current?.focus();
|
||||
}, [isOpen]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const name = depositorName.trim();
|
||||
if (!name || !agreed || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/orders', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ productId: product.id, depositorName: name }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setSubmitting(false);
|
||||
setAuthState('guest');
|
||||
return;
|
||||
}
|
||||
setError(data?.error || '주문 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
setSuccess({
|
||||
orderId: data.orderId as string,
|
||||
depositorName: name,
|
||||
reused: Boolean(data.reused),
|
||||
});
|
||||
} catch {
|
||||
setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[depositorName, agreed, submitting, product.id],
|
||||
);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const canSubmit = depositorName.trim().length > 0 && agreed && !submitting;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[70] flex items-end sm:items-center justify-center p-0 sm:p-4"
|
||||
style={{ background: 'rgba(15,23,42,0.45)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={success ? '주문 접수 완료' : `${product.name} 구매`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full sm:max-w-md max-h-[92vh] overflow-y-auto rounded-t-2xl sm:rounded-2xl shadow-xl"
|
||||
style={{ background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="sticky top-0 flex items-center justify-between px-6 h-16 border-b"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<h2
|
||||
className="text-base font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{success ? '주문 접수 완료' : '계좌이체 구매'}
|
||||
</h2>
|
||||
<button
|
||||
ref={closeBtnRef}
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
className="p-2 -mr-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6">
|
||||
{/* 상품 요약 */}
|
||||
{!success && (
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3.5 mb-6 flex items-center justify-between gap-3"
|
||||
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{product.name}
|
||||
</span>
|
||||
<span
|
||||
className="text-base font-bold shrink-0"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{priceLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 세션 확인 중 ── */}
|
||||
{authState === 'checking' && !success && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
|
||||
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 미로그인 ── */}
|
||||
{authState === 'guest' && !success && (
|
||||
<div className="text-center py-2">
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
로그인 후 구매할 수 있습니다.
|
||||
</p>
|
||||
<Link
|
||||
href={loginHref}
|
||||
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
|
||||
>
|
||||
로그인하기
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 로그인 상태: 구매 폼 ── */}
|
||||
{authState === 'user' && !success && (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="depositor-name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
입금자명 <span style={{ color: 'var(--jsm-accent)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="depositor-name"
|
||||
type="text"
|
||||
value={depositorName}
|
||||
onChange={(e) => setDepositorName(e.target.value)}
|
||||
placeholder="입금하실 분의 성함"
|
||||
required
|
||||
maxLength={40}
|
||||
disabled={submitting}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
background: 'var(--jsm-surface)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1.5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||
입금자명이 다르면 확인이 늦어질 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2.5 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={agreed}
|
||||
onChange={(e) => setAgreed(e.target.checked)}
|
||||
disabled={submitting}
|
||||
className="mt-0.5 w-4 h-4 shrink-0 accent-[var(--jsm-accent)]"
|
||||
/>
|
||||
<span className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
<Link
|
||||
href="/legal/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
이용약관
|
||||
</Link>
|
||||
과{' '}
|
||||
<Link
|
||||
href="/legal/refund"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
환불정책
|
||||
</Link>
|
||||
에 동의합니다.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className="px-3.5 py-3 rounded-lg text-sm break-keep"
|
||||
style={{ background: '#fef2f2', border: '1px solid #fecaca', color: '#dc2626', ...KOR_BODY }}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="w-full py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||
style={{
|
||||
background: canSubmit ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)',
|
||||
color: '#ffffff',
|
||||
cursor: canSubmit ? 'pointer' : 'not-allowed',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{submitting ? '처리 중...' : '주문하기'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* ── 성공 화면 ── */}
|
||||
{success && (
|
||||
<div>
|
||||
<p
|
||||
className="text-lg font-bold mb-2 break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{success.reused ? '이미 접수된 주문이 있습니다' : '주문이 접수되었습니다'}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
아래 계좌로 입금해 주세요. 입금이 확인되면 마이페이지에서 다운로드할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<dl
|
||||
className="rounded-lg border divide-y mb-5"
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}
|
||||
>
|
||||
{[
|
||||
{ k: '입금 계좌', v: `${BANK.name} ${BANK.account}` },
|
||||
{ k: '예금주', v: BANK.holder },
|
||||
{ k: '입금 금액', v: priceLabel },
|
||||
{ k: '입금자명', v: success.depositorName },
|
||||
].map((row) => (
|
||||
<div
|
||||
key={row.k}
|
||||
className="flex items-center justify-between gap-3 px-4 py-3"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<dt className="text-xs shrink-0" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||
{row.k}
|
||||
</dt>
|
||||
<dd
|
||||
className="text-sm font-semibold text-right break-all"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
{row.v}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<p
|
||||
className="text-xs leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
>
|
||||
입금 확인 후 마이페이지 → 내 제품에서 다운로드할 수 있습니다. 최대 24시간 내 처리됩니다.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/mypage?tab=products"
|
||||
className="inline-flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
|
||||
>
|
||||
마이페이지로
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { trackEvent } from '../../lib/gtag';
|
||||
|
||||
function ContactFormInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
service: '외주 개발 문의',
|
||||
message: '',
|
||||
});
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const serviceParam = searchParams.get('service');
|
||||
if (serviceParam) {
|
||||
setFormData((prev) => ({ ...prev, service: serviceParam }));
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setStatus('loading');
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error || '문의 전송에 실패했습니다.');
|
||||
setStatus('success');
|
||||
trackEvent('generate_lead', {
|
||||
event_category: 'contact',
|
||||
event_label: formData.service,
|
||||
});
|
||||
setFormData({ name: '', phone: '', email: '', service: '외주 개발 문의', message: '' });
|
||||
setTimeout(() => setStatus('idle'), 5000);
|
||||
} catch (error) {
|
||||
setStatus('error');
|
||||
setErrorMessage(error instanceof Error ? error.message : '문의 전송에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [e.target.name]: e.target.value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
|
||||
이름 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
placeholder="홍길동"
|
||||
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">연락처</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'loading'}
|
||||
placeholder="010-0000-0000"
|
||||
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
|
||||
이메일 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={status === 'loading'}
|
||||
placeholder="example@email.com"
|
||||
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">문의 서비스</label>
|
||||
<select
|
||||
name="service"
|
||||
value={formData.service}
|
||||
onChange={handleChange}
|
||||
disabled={status === 'loading'}
|
||||
className="w-full px-3.5 py-2.5 text-sm border border-[#dbe8ff] rounded-xl focus:ring-2 focus:ring-[#1a56db] focus:border-[#1a56db] outline-none bg-white disabled:bg-slate-50"
|
||||
>
|
||||
<option>외주 개발 문의</option>
|
||||
<option>AI 자동화 키트 - 월 구독</option>
|
||||
<option>프롬프트 엔지니어링 - 단건 설계</option>
|
||||
<option>프롬프트 엔지니어링 - 비즈니스 패키지</option>
|
||||
<option>프롬프트 엔지니어링 - 팀/기업 패키지</option>
|
||||
<option>업무 자동화 - 단순 자동화</option>
|
||||
<option>업무 자동화 - 중간 자동화</option>
|
||||
<option>업무 자동화 - 대형 자동화</option>
|
||||
<option>기타 문의</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5">
|
||||
문의 내용 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
required
|
||||
rows={5}
|
||||
disabled={status === 'loading'}
|
||||
placeholder="문의하실 내용을 자유롭게 작성해주세요. 프로젝트 목적, 원하시는 기능, 예산 등을 적어주시면 더 정확한 답변이 가능합니다."
|
||||
className="w-full px-3.5 py-2.5 text-sm border border-slate-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none resize-none bg-white disabled:bg-slate-50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 text-emerald-800 text-sm px-4 py-3 rounded-xl">
|
||||
✅ 문의가 전송되었습니다! 24시간 이내 답변드리겠습니다.
|
||||
</div>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-800 text-sm px-4 py-3 rounded-xl">
|
||||
❌ {errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'loading'}
|
||||
className="w-full bg-[#1a56db] hover:bg-[#1e4fc2] text-white py-3 rounded-xl text-sm font-bold transition shadow-lg shadow-blue-900/20 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{status === 'loading' ? '전송 중...' : '문의 보내기'}
|
||||
</button>
|
||||
|
||||
<p className="text-slate-400 text-xs text-center">
|
||||
문의 후 24시간 이내 답변 보장 · 무료 상담 가능
|
||||
</p>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ContactForm() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-slate-400 text-sm">로딩 중...</div>}>
|
||||
<ContactFormInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
626
app/components/OutsourcingRequestForm.tsx
Normal file
626
app/components/OutsourcingRequestForm.tsx
Normal file
@@ -0,0 +1,626 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { trackEvent } from '@/lib/gtag';
|
||||
|
||||
// 외주 의뢰용 4단계 폼.
|
||||
// ① 프로젝트 유형 → ② 예산·일정 → ③ 상세 내용 → ④ 연락처
|
||||
// 각 단계 검증을 통과해야 다음으로 진행한다. 마지막에 POST /api/contact.
|
||||
// 마운트 시 로그인 사용자면 이메일을 자동 채운다(수정 가능).
|
||||
// 기존 ContactForm.tsx는 보존하고, 이 폼이 /outsourcing #contact에서 대체한다.
|
||||
// 디자인: --jsm-* 토큰만 사용. gradient/blur/보라/이모지 금지.
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const INPUT_STYLE = {
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
} as const;
|
||||
|
||||
const PROJECT_TYPES = [
|
||||
'웹 서비스',
|
||||
'웹사이트',
|
||||
'업무 자동화',
|
||||
'API·백엔드',
|
||||
'봇 개발',
|
||||
'AI 연동',
|
||||
'기타',
|
||||
] as const;
|
||||
|
||||
const BUDGETS = [
|
||||
'100만원 미만',
|
||||
'100~300만원',
|
||||
'300~1,000만원',
|
||||
'1,000만원 이상',
|
||||
'미정',
|
||||
] as const;
|
||||
|
||||
const TIMELINES = ['1개월 내', '1~3개월', '3개월 이상', '미정'] as const;
|
||||
|
||||
const STEPS = [
|
||||
{ n: 1, label: '프로젝트 유형' },
|
||||
{ n: 2, label: '예산·일정' },
|
||||
{ n: 3, label: '상세 내용' },
|
||||
{ n: 4, label: '연락처' },
|
||||
] as const;
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
interface SuccessInfo {
|
||||
trackUrl: string | null;
|
||||
}
|
||||
|
||||
export default function OutsourcingRequestForm() {
|
||||
const [step, setStep] = useState(1);
|
||||
const [projectType, setProjectType] = useState('');
|
||||
const [budget, setBudget] = useState('');
|
||||
const [timeline, setTimeline] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState<SuccessInfo | null>(null);
|
||||
|
||||
const headingRef = useRef<HTMLElement | null>(null);
|
||||
const setHeadingRef = useCallback((el: HTMLElement | null) => {
|
||||
headingRef.current = el;
|
||||
}, []);
|
||||
const firstRender = useRef(true);
|
||||
|
||||
// 로그인 사용자 이메일 자동 채움 (BankTransferModal 세션 확인 패턴)
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const supabase = createClient();
|
||||
supabase.auth
|
||||
.getUser()
|
||||
.then(({ data }) => {
|
||||
const userEmail = data?.user?.email;
|
||||
if (mounted && userEmail) {
|
||||
setEmail((prev) => (prev ? prev : userEmail));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
/* 비로그인 — 무시 */
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 단계 전환 시 헤딩으로 포커스 이동 (초기 마운트는 제외)
|
||||
useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false;
|
||||
return;
|
||||
}
|
||||
headingRef.current?.focus();
|
||||
}, [step, success]);
|
||||
|
||||
const trimmedMessage = message.trim();
|
||||
const trimmedName = name.trim();
|
||||
const trimmedEmail = email.trim();
|
||||
|
||||
const stepValid = (s: number): boolean => {
|
||||
switch (s) {
|
||||
case 1:
|
||||
return projectType !== '';
|
||||
case 2:
|
||||
return budget !== '' && timeline !== '';
|
||||
case 3:
|
||||
return trimmedMessage.length >= 10;
|
||||
case 4:
|
||||
return trimmedName !== '' && EMAIL_RE.test(trimmedEmail);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 주의: useCallback 금지 — stepValid가 매 렌더 갱신되는 state를 캡처하므로
|
||||
// 메모이즈하면 스테일 클로저로 항상 초기(빈) 상태 기준 검증이 됨 (실제 운영 버그였음)
|
||||
const goNext = () => {
|
||||
if (!stepValid(step)) return;
|
||||
setError('');
|
||||
setStep((s) => Math.min(s + 1, STEPS.length));
|
||||
};
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setError('');
|
||||
setStep((s) => Math.max(s - 1, 1));
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!stepValid(4) || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
phone: phone.trim(),
|
||||
email: trimmedEmail,
|
||||
service: `외주 개발 문의 — ${projectType}`,
|
||||
message: trimmedMessage,
|
||||
projectType,
|
||||
budget,
|
||||
timeline,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(
|
||||
data?.error || '의뢰 전송 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'
|
||||
);
|
||||
setSubmitting(false);
|
||||
return;
|
||||
}
|
||||
trackEvent('generate_lead', {
|
||||
event_category: 'contact',
|
||||
event_label: `외주 개발 문의 — ${projectType}`,
|
||||
});
|
||||
setSuccess({ trackUrl: typeof data?.trackUrl === 'string' ? data.trackUrl : null });
|
||||
} catch {
|
||||
setError('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
submitting,
|
||||
trimmedName,
|
||||
trimmedEmail,
|
||||
trimmedMessage,
|
||||
phone,
|
||||
projectType,
|
||||
budget,
|
||||
timeline,
|
||||
]
|
||||
);
|
||||
|
||||
// ── 완료 화면 ──────────────────────────────────────────────
|
||||
if (success) {
|
||||
return (
|
||||
<div>
|
||||
<h3
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-xl font-bold break-keep outline-none"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
의뢰가 접수되었습니다
|
||||
</h3>
|
||||
<p
|
||||
className="mt-3 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
영업일 2일 내 회신드립니다.
|
||||
</p>
|
||||
|
||||
{success.trackUrl ? (
|
||||
<div className="mt-7">
|
||||
<Link
|
||||
href={success.trackUrl}
|
||||
className="inline-flex items-center justify-center gap-2 w-full py-3 rounded-lg text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
진행 상태 확인하기
|
||||
<Arrow />
|
||||
</Link>
|
||||
<p
|
||||
className="mt-3 text-xs leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
추적 링크를 이메일로도 보내드렸습니다.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isLast = step === STEPS.length;
|
||||
const canAdvance = stepValid(step);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 진행 표시기 */}
|
||||
<ol className="flex items-center gap-2 mb-7" aria-label="진행 단계">
|
||||
{STEPS.map((s, i) => {
|
||||
const state =
|
||||
s.n < step ? 'done' : s.n === step ? 'current' : 'upcoming';
|
||||
return (
|
||||
<li key={s.n} className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className="flex items-center justify-center w-6 h-6 rounded-full text-xs font-bold shrink-0 transition-colors"
|
||||
style={
|
||||
state === 'upcoming'
|
||||
? { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }
|
||||
: { background: 'var(--jsm-accent)', color: '#ffffff' }
|
||||
}
|
||||
aria-current={state === 'current' ? 'step' : undefined}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-semibold truncate hidden sm:inline"
|
||||
style={{
|
||||
color:
|
||||
state === 'upcoming' ? 'var(--jsm-ink-soft)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
{i < STEPS.length - 1 && (
|
||||
<span
|
||||
className="w-4 sm:w-6 h-px shrink-0"
|
||||
style={{ background: 'var(--jsm-line)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* ── 단계 ① 프로젝트 유형 ── */}
|
||||
{step === 1 && (
|
||||
<fieldset>
|
||||
<legend
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
어떤 프로젝트인가요?
|
||||
</legend>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
가장 가까운 유형을 하나 선택해주세요.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{PROJECT_TYPES.map((t) => {
|
||||
const selected = projectType === t;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={t}
|
||||
onClick={() => setProjectType(t)}
|
||||
aria-pressed={selected}
|
||||
className="px-4 py-3.5 rounded-lg text-sm font-semibold text-center break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
border: selected
|
||||
? '1px solid var(--jsm-accent)'
|
||||
: '1px solid var(--jsm-line)',
|
||||
background: selected
|
||||
? 'var(--jsm-accent-soft)'
|
||||
: 'var(--jsm-surface-alt)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{/* ── 단계 ② 예산·일정 ── */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h3
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
예산과 일정을 알려주세요
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
대략적인 범위면 충분합니다. 정해지지 않았다면 미정을 선택하세요.
|
||||
</p>
|
||||
|
||||
<fieldset className="mb-6">
|
||||
<legend
|
||||
className="text-sm font-semibold mb-2.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
예산
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{BUDGETS.map((b) => (
|
||||
<Chip
|
||||
key={b}
|
||||
label={b}
|
||||
selected={budget === b}
|
||||
onClick={() => setBudget(b)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend
|
||||
className="text-sm font-semibold mb-2.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
희망 일정
|
||||
</legend>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{TIMELINES.map((t) => (
|
||||
<Chip
|
||||
key={t}
|
||||
label={t}
|
||||
selected={timeline === t}
|
||||
onClick={() => setTimeline(t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 단계 ③ 상세 내용 ── */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h3
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
자세히 들려주세요
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
구체적일수록 정확한 견적이 가능합니다. 최소 10자 이상 작성해주세요.
|
||||
</p>
|
||||
<label htmlFor="req-message" className="sr-only">
|
||||
상세 내용
|
||||
</label>
|
||||
<textarea
|
||||
id="req-message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
rows={7}
|
||||
maxLength={5000}
|
||||
placeholder="만들고 싶은 것, 참고 서비스, 현재 상황을 자유롭게 적어주세요. 기획이 정리되지 않았어도 괜찮습니다."
|
||||
className="w-full px-3.5 py-3 rounded-lg text-sm leading-relaxed resize-none outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
...INPUT_STYLE,
|
||||
...KOR_BODY,
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="mt-1.5 text-xs"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{trimmedMessage.length}/10자 이상
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 단계 ④ 연락처 ── */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
<h3
|
||||
ref={setHeadingRef}
|
||||
tabIndex={-1}
|
||||
className="text-lg font-bold break-keep outline-none mb-1"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
어디로 회신드릴까요?
|
||||
</h3>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep mb-5"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
영업일 2일 내에 회신드립니다.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="req-name"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
이름 <span style={{ color: 'var(--jsm-accent)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="req-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
maxLength={40}
|
||||
disabled={submitting}
|
||||
placeholder="홍길동"
|
||||
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="req-email"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
이메일 <span style={{ color: 'var(--jsm-accent)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="req-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
maxLength={120}
|
||||
disabled={submitting}
|
||||
placeholder="example@email.com"
|
||||
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="req-phone"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
연락처
|
||||
</label>
|
||||
<input
|
||||
id="req-phone"
|
||||
type="tel"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
maxLength={40}
|
||||
disabled={submitting}
|
||||
placeholder="010-0000-0000 (선택)"
|
||||
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={INPUT_STYLE}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 에러 */}
|
||||
{error && (
|
||||
<div
|
||||
className="mt-5 px-3.5 py-3 rounded-lg text-sm break-keep"
|
||||
style={{
|
||||
background: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#dc2626',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 내비게이션 */}
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{step > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goPrev}
|
||||
disabled={submitting}
|
||||
className="px-5 py-3 rounded-lg text-sm font-semibold border transition-colors hover:bg-[var(--jsm-surface-alt)] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{
|
||||
...INPUT_STYLE,
|
||||
borderColor: 'var(--jsm-line)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLast ? (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canAdvance || submitting}
|
||||
className="flex-1 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
|
||||
style={{
|
||||
background: !canAdvance || submitting
|
||||
? 'var(--jsm-line)'
|
||||
: 'var(--jsm-accent)',
|
||||
cursor: !canAdvance || submitting ? 'not-allowed' : 'pointer',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{submitting ? '보내는 중...' : '의뢰 보내기'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={goNext}
|
||||
disabled={!canAdvance}
|
||||
className="flex-1 inline-flex items-center justify-center gap-2 py-3 rounded-lg text-sm font-semibold text-white transition-colors"
|
||||
style={{
|
||||
background: canAdvance ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||
cursor: canAdvance ? 'pointer' : 'not-allowed',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
다음
|
||||
<Arrow />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 칩 버튼 (예산·일정 단일 선택) ──────────────────────────────
|
||||
function Chip({
|
||||
label,
|
||||
selected,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className="px-4 py-2.5 rounded-lg text-sm font-semibold break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
border: selected ? '1px solid var(--jsm-accent)' : '1px solid var(--jsm-line)',
|
||||
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 5 7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -7,111 +7,147 @@ export default function PublicShell({ children }: { children: React.ReactNode })
|
||||
<>
|
||||
<TopNav />
|
||||
<main
|
||||
className="min-h-screen pt-20"
|
||||
className="min-h-screen pt-16"
|
||||
style={{
|
||||
background: 'var(--kx-surface)',
|
||||
color: 'var(--kx-on-surface)',
|
||||
background: 'var(--jsm-bg)',
|
||||
color: 'var(--jsm-ink)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<footer className="bg-black text-white/70 px-6 lg:px-12 py-14 text-sm border-t border-white/10">
|
||||
<footer
|
||||
className="text-white/70 px-6 lg:px-12 py-14 text-sm"
|
||||
style={{ background: 'var(--jsm-navy)' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-12 md:gap-8">
|
||||
{/* 좌 — JSM + social */}
|
||||
{/* 좌 — JSM + 연락처 */}
|
||||
<div>
|
||||
<p
|
||||
className="kx-display font-bold text-2xl mb-5 text-white tracking-tight"
|
||||
style={{ letterSpacing: '0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="https://www.youtube.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="YouTube"
|
||||
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
|
||||
<div className="flex items-baseline gap-2 mb-4">
|
||||
<span
|
||||
className="font-black text-2xl text-white"
|
||||
style={{ letterSpacing: '-0.02em' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1c.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.6 15.6V8.4l6.2 3.6-6.2 3.6z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="X (Twitter)"
|
||||
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Instagram"
|
||||
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="3" width="18" height="18" rx="5" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:bgg8988@gmail.com"
|
||||
aria-label="Email"
|
||||
className="w-9 h-9 rounded-full border border-white/20 hover:border-white hover:bg-white hover:text-black text-white flex items-center justify-center transition"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
</a>
|
||||
JSM
|
||||
</span>
|
||||
<span className="text-sm text-white/50" style={{ letterSpacing: '-0.01em' }}>
|
||||
쟁승메이드
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href="mailto:bgg8988@gmail.com"
|
||||
className="flex items-center gap-2 text-white/50 hover:text-white transition-colors duration-150 text-sm"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden>
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m3 7 9 6 9-6" />
|
||||
</svg>
|
||||
bgg8988@gmail.com
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 우 — Link groups */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-10">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-10">
|
||||
<div>
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">SaaS 제품</p>
|
||||
<p
|
||||
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
서비스
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
<li><Link href="/packages" className="hover:text-white transition">제품 카탈로그</Link></li>
|
||||
<li><Link href="/packages" className="hover:text-white transition">출시 알림 신청</Link></li>
|
||||
<li>
|
||||
<Link
|
||||
href="/outsourcing"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
외주 개발
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/products"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
소프트웨어
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">AI 음악</p>
|
||||
<p
|
||||
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
회사
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
<li><Link href="/music/packs" className="hover:text-white transition">음악 가이드 패키지</Link></li>
|
||||
<li><Link href="/music/samples" className="hover:text-white transition">샘플 갤러리</Link></li>
|
||||
<li><Link href="/music/packs#pricing" className="hover:text-white transition">가격</Link></li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:bgg8988@gmail.com"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
문의하기
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/outsourcing#process"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
진행 프로세스
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">커스텀 외주</p>
|
||||
<p
|
||||
className="text-[11px] tracking-widest uppercase text-white/40 mb-4 font-medium"
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
>
|
||||
Legal
|
||||
</p>
|
||||
<ul className="space-y-2.5">
|
||||
<li><Link href="/work/freelance" className="hover:text-white transition">외주 개발</Link></li>
|
||||
<li><Link href="/work/website" className="hover:text-white transition">웹사이트 제작</Link></li>
|
||||
<li><Link href="/work/saju" className="hover:text-white transition">AI 사주</Link></li>
|
||||
<li><a href="mailto:bgg8988@gmail.com" className="hover:text-white transition">문의하기</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-white/40 mb-4">Legal</p>
|
||||
<ul className="space-y-2.5">
|
||||
<li><Link href="/legal/terms" className="hover:text-white transition">이용약관</Link></li>
|
||||
<li><Link href="/legal/privacy" className="hover:text-white transition">개인정보처리방침</Link></li>
|
||||
<li><Link href="/legal/refund" className="hover:text-white transition">환불 정책</Link></li>
|
||||
<li>
|
||||
<Link
|
||||
href="/legal/terms"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
이용약관
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/legal/privacy"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
개인정보처리방침
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/legal/refund"
|
||||
className="hover:text-white transition-colors duration-150"
|
||||
style={{ letterSpacing: '-0.01em' }}
|
||||
>
|
||||
환불 정책
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 pt-6 border-t border-white/10 flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40 leading-relaxed">
|
||||
<div
|
||||
className="mt-12 pt-6 border-t flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/40 leading-relaxed"
|
||||
style={{ borderColor: 'rgba(255,255,255,0.08)' }}
|
||||
>
|
||||
<span>대표자: 박재오</span>
|
||||
<span>사업자등록번호: 267-53-00822</span>
|
||||
<span>서울시 동작구 여의대방로22아길 22, 1동 109호</span>
|
||||
|
||||
@@ -7,9 +7,12 @@ import { createClient } from '@/lib/supabase/client';
|
||||
import type { User } from '@supabase/supabase-js';
|
||||
|
||||
const LINKS = [
|
||||
{ href: '/packages', label: 'SaaS 제품' },
|
||||
{ href: '/music', label: 'AI 음악' },
|
||||
{ href: '/work', label: '커스텀 외주' },
|
||||
{ href: '/outsourcing', label: '외주 개발' },
|
||||
{ href: '/products', label: '소프트웨어' },
|
||||
{ href: '/showcase', label: '제작 사례' },
|
||||
{ href: '/work/saju', label: '사주' },
|
||||
{ href: '/tarot', label: '타로' },
|
||||
{ href: '/music', label: '음악' },
|
||||
];
|
||||
|
||||
export default function TopNav() {
|
||||
@@ -59,6 +62,21 @@ export default function TopNav() {
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open]);
|
||||
|
||||
// 단일 라이트 팔레트 (전 라우트 동일 — 라우트 분기 제거)
|
||||
const ink = 'var(--jsm-ink)';
|
||||
const inkSoft = 'var(--jsm-ink-soft)';
|
||||
const surface = 'var(--jsm-surface)';
|
||||
const line = 'var(--jsm-line)';
|
||||
const accent = 'var(--jsm-accent)';
|
||||
const accentBg = 'var(--jsm-accent-soft)';
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/';
|
||||
return pathname === href || pathname.startsWith(href + '/');
|
||||
@@ -67,187 +85,224 @@ export default function TopNav() {
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className={[
|
||||
'fixed left-1/2 -translate-x-1/2 z-50 w-full border-b border-transparent',
|
||||
'md:rounded-full md:border transition-all duration-300 ease-out',
|
||||
scrolled
|
||||
? 'top-4 max-w-3xl md:shadow-[0_10px_40px_rgba(0,0,0,0.35)] md:border-white/10'
|
||||
: 'top-0 max-w-none',
|
||||
].join(' ')}
|
||||
className="fixed top-0 left-0 right-0 z-50 w-full transition-all duration-300"
|
||||
style={{
|
||||
background: scrolled ? 'rgba(10,10,12,0.6)' : 'transparent',
|
||||
backdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
|
||||
WebkitBackdropFilter: scrolled ? 'blur(18px) saturate(160%)' : 'none',
|
||||
background: scrolled ? 'var(--jsm-surface)' : 'transparent',
|
||||
borderBottom: scrolled
|
||||
? `1px solid ${line}`
|
||||
: '1px solid transparent',
|
||||
boxShadow: scrolled ? '0 1px 8px rgba(15,23,42,0.06)' : 'none',
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
className={[
|
||||
'flex w-full items-center justify-between transition-all duration-300 ease-out',
|
||||
scrolled ? 'h-14 px-4 md:px-3' : 'h-20 px-6 lg:px-12',
|
||||
].join(' ')}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="kx-display text-2xl font-black tracking-tight kx-gradient-text"
|
||||
style={{ textDecoration: 'none', letterSpacing: '0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="text-sm font-medium transition-colors"
|
||||
style={{
|
||||
color: isActive(l.href) ? '#fff' : 'var(--kx-on-variant)',
|
||||
borderBottom: isActive(l.href) ? '2px solid var(--kx-primary)' : '2px solid transparent',
|
||||
paddingBottom: 4,
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<>
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="hidden sm:inline-block text-sm font-medium px-4 py-2 transition-colors"
|
||||
style={{ color: 'var(--kx-on-variant)', textDecoration: 'none' }}
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<Link
|
||||
href="/music"
|
||||
className="kx-btn-primary hidden sm:inline-flex items-center px-5 py-2 rounded-full text-sm"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Try now
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium transition-colors"
|
||||
style={{ color: 'var(--kx-on-variant)', background: 'transparent' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
href="/login"
|
||||
className="hidden sm:inline-block text-sm font-medium px-4 py-2 transition-colors"
|
||||
style={{ color: 'var(--kx-on-variant)', textDecoration: 'none' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
<Link
|
||||
href="/music"
|
||||
className="kx-btn-primary hidden sm:inline-flex items-center px-5 py-2 rounded-full text-sm"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Try now
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="메뉴 열기"
|
||||
className="md:hidden p-2 rounded-lg"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
<nav className="max-w-7xl mx-auto flex w-full items-center justify-between h-16 px-6 lg:px-8">
|
||||
{/* 로고 */}
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-baseline gap-2"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* 모바일 오버레이 */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] md:hidden flex flex-col"
|
||||
style={{ background: 'rgba(6,14,32,0.98)', backdropFilter: 'blur(16px)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 h-20">
|
||||
<span className="kx-display text-2xl font-black kx-gradient-text" style={{ letterSpacing: '0.02em' }}>JSM</span>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="메뉴 닫기"
|
||||
className="p-2"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
<span
|
||||
className="text-xl font-black tracking-tight"
|
||||
style={{ color: ink, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-2 px-6 pt-6">
|
||||
JSM
|
||||
</span>
|
||||
<span
|
||||
className="hidden sm:inline text-sm font-medium"
|
||||
style={{ color: inkSoft, letterSpacing: '-0.01em' }}
|
||||
>
|
||||
쟁승메이드
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* 데스크탑 링크 */}
|
||||
<div className="hidden md:flex items-center gap-1">
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="kx-display text-2xl font-bold py-3"
|
||||
className="text-sm font-medium px-4 py-2 rounded-md transition-colors duration-150"
|
||||
style={{
|
||||
color: isActive(l.href) ? 'var(--kx-primary)' : 'var(--kx-on-surface)',
|
||||
color: isActive(l.href) ? accent : inkSoft,
|
||||
background: isActive(l.href) ? accentBg : 'transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-6 flex flex-col gap-2">
|
||||
</div>
|
||||
|
||||
{/* 데스크탑 CTA + auth */}
|
||||
<div className="flex items-center gap-2">
|
||||
{user ? (
|
||||
<>
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="hidden sm:inline-flex items-center px-3 py-2 text-sm font-medium rounded-md transition-colors duration-150"
|
||||
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href="/login"
|
||||
className="hidden sm:inline-block text-sm font-medium px-3 py-2 rounded-md transition-colors duration-150"
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
href="/outsourcing#contact"
|
||||
className="hidden sm:inline-flex items-center px-4 py-2 rounded-lg text-sm font-semibold transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{
|
||||
background: 'var(--jsm-accent)',
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
프로젝트 문의
|
||||
</Link>
|
||||
|
||||
{/* 모바일 햄버거 */}
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
aria-label="메뉴 열기"
|
||||
aria-expanded={open}
|
||||
className="md:hidden p-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: ink }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{/* 모바일 드로어 */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] md:hidden"
|
||||
style={{ background: 'rgba(15,23,42,0.4)' }}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 right-0 h-full w-72 flex flex-col shadow-xl"
|
||||
style={{ background: surface }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="메뉴"
|
||||
>
|
||||
{/* 드로어 헤더 */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 h-16 border-b"
|
||||
style={{ borderColor: line }}
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span
|
||||
className="text-lg font-black tracking-tight"
|
||||
style={{ color: ink, letterSpacing: '-0.02em' }}
|
||||
>
|
||||
JSM
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-medium"
|
||||
style={{ color: inkSoft }}
|
||||
>
|
||||
쟁승메이드
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="메뉴 닫기"
|
||||
className="p-2 rounded-lg transition-colors duration-150"
|
||||
style={{ color: inkSoft }}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 드로어 링크 */}
|
||||
<div className="flex-1 flex flex-col px-4 pt-4 gap-1">
|
||||
{LINKS.map((l) => (
|
||||
<Link
|
||||
key={l.href}
|
||||
href={l.href}
|
||||
className="text-base font-semibold px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{
|
||||
color: isActive(l.href) ? accent : ink,
|
||||
background: isActive(l.href) ? accentBg : 'transparent',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="my-4 border-t"
|
||||
style={{ borderColor: line }}
|
||||
/>
|
||||
|
||||
{user ? (
|
||||
<>
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="flex-1 py-3 text-center rounded-full text-sm font-bold"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.15)', color: 'var(--kx-on-surface)', textDecoration: 'none' }}
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<Link
|
||||
href="/music"
|
||||
className="kx-btn-primary flex-1 py-3 text-center rounded-full text-sm"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Try now
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/mypage"
|
||||
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
마이페이지
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full py-3 text-center text-sm font-medium transition-colors"
|
||||
style={{ color: 'var(--kx-on-variant)', background: 'transparent' }}
|
||||
className="text-left text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{ color: inkSoft, background: 'transparent', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-3">
|
||||
<Link
|
||||
href="/login"
|
||||
className="flex-1 py-3 text-center rounded-full text-sm font-bold"
|
||||
style={{ border: '1px solid rgba(255,255,255,0.15)', color: 'var(--kx-on-surface)', textDecoration: 'none' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
<Link
|
||||
href="/music"
|
||||
className="kx-btn-primary flex-1 py-3 text-center rounded-full text-sm"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
Try now
|
||||
</Link>
|
||||
</div>
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-sm font-medium px-3 py-3 rounded-lg transition-colors duration-150"
|
||||
style={{ color: inkSoft, textDecoration: 'none', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
로그인
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 드로어 하단 CTA */}
|
||||
<div className="px-4 pb-6">
|
||||
<Link
|
||||
href="/outsourcing#contact"
|
||||
className="flex items-center justify-center w-full py-3 rounded-lg text-sm font-semibold transition-colors duration-150"
|
||||
style={{
|
||||
background: 'var(--jsm-accent)',
|
||||
color: '#ffffff',
|
||||
textDecoration: 'none',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
프로젝트 문의
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -36,19 +36,32 @@
|
||||
--card-bg: #ffffff;
|
||||
--border: #dbe8ff;
|
||||
|
||||
/* ─── Kinetic Ether Tokens (다크 테마 섹션 전용) ─── */
|
||||
--kx-surface: #060e20;
|
||||
--kx-surface-low: #091328;
|
||||
--kx-surface-mid: #0f1930;
|
||||
--kx-surface-high: #141f38;
|
||||
--kx-surface-bright: #1f2b49;
|
||||
--kx-on-surface: #dee5ff;
|
||||
--kx-on-variant: #a3aac4;
|
||||
--kx-primary: #cc97ff;
|
||||
--kx-primary-dim: #9c48ea;
|
||||
--kx-secondary: #53ddfc;
|
||||
--kx-secondary-dim: #40ceed;
|
||||
--kx-outline: rgba(64, 72, 93, 0.15);
|
||||
/* === JSM Professional tokens (2026-06 renewal) === */
|
||||
--jsm-bg: #f8fafc; /* slate-50 본문 배경 */
|
||||
--jsm-surface: #ffffff; /* 카드 */
|
||||
--jsm-surface-alt: #f1f5f9; /* slate-100 섹션 교차 배경 */
|
||||
--jsm-ink: #0f172a; /* slate-900 본문 텍스트 */
|
||||
--jsm-ink-soft: #475569; /* slate-600 보조 텍스트 */
|
||||
--jsm-ink-faint: #94a3b8; /* slate-400 캡션 */
|
||||
--jsm-line: #e2e8f0; /* slate-200 보더 */
|
||||
--jsm-navy: #0b1f3a; /* 딥네이비 — 푸터/다크 섹션 */
|
||||
--jsm-accent: #1d4ed8; /* blue-700 포인트 (단일 포인트 컬러) */
|
||||
--jsm-accent-hover: #1e40af; /* blue-800 */
|
||||
--jsm-accent-soft: #dbeafe; /* blue-100 뱃지 배경 */
|
||||
|
||||
/* 기존 kx 변수 재매핑 (레거시·숨김 라우트 /packages·/work·/music 호환용) */
|
||||
--kx-surface: var(--jsm-bg);
|
||||
--kx-surface-low: var(--jsm-surface-alt);
|
||||
--kx-surface-mid: var(--jsm-surface);
|
||||
--kx-surface-high: var(--jsm-surface);
|
||||
--kx-surface-bright: var(--jsm-surface-alt);
|
||||
--kx-on-surface: var(--jsm-ink);
|
||||
--kx-on-variant: var(--jsm-ink-soft);
|
||||
--kx-primary: var(--jsm-accent);
|
||||
--kx-primary-dim: var(--jsm-accent-hover);
|
||||
--kx-secondary: var(--jsm-accent);
|
||||
--kx-secondary-dim: var(--jsm-accent-hover);
|
||||
--kx-outline: var(--jsm-line);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -56,8 +69,8 @@
|
||||
--color-foreground: var(--foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-secondary: var(--secondary);
|
||||
--font-sans: var(--font-jua), 'Jua', -apple-system, system-ui, sans-serif;
|
||||
--font-mono: var(--font-jua), 'Jua', ui-monospace, monospace;
|
||||
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--font-mono: 'Pretendard Variable', Pretendard, ui-monospace, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
@@ -69,16 +82,16 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-jua), 'Jua', -apple-system, system-ui, sans-serif;
|
||||
background: var(--jsm-bg);
|
||||
color: var(--jsm-ink);
|
||||
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* font-mono utility → Jua 통일 */
|
||||
/* font-mono utility → Pretendard 통일 */
|
||||
.font-mono, code, pre, kbd, samp {
|
||||
font-family: var(--font-jua), 'Jua', ui-monospace, monospace;
|
||||
font-family: 'Pretendard Variable', Pretendard, ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* Dashboard layout */
|
||||
@@ -86,7 +99,7 @@ body {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
background: var(--background);
|
||||
background: var(--jsm-bg);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
@@ -107,18 +120,19 @@ body {
|
||||
.kx-section {
|
||||
background: var(--kx-surface);
|
||||
color: var(--kx-on-surface);
|
||||
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
|
||||
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
|
||||
}
|
||||
.kx-section p, .kx-section li, .kx-section span:not(.kx-label) {
|
||||
color: var(--kx-on-variant);
|
||||
}
|
||||
.kx-display {
|
||||
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
|
||||
letter-spacing: -0.01em;
|
||||
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: inherit;
|
||||
}
|
||||
.kx-label {
|
||||
font-family: var(--font-jua), 'Jua', system-ui, sans-serif;
|
||||
font-family: 'Pretendard Variable', Pretendard, system-ui, sans-serif;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
@@ -147,15 +161,14 @@ body {
|
||||
0 0 80px 0 rgba(83, 221, 252, 0.08);
|
||||
}
|
||||
.kx-btn-primary {
|
||||
background: linear-gradient(135deg, #cc97ff 0%, #c284ff 100%);
|
||||
color: #0b0113;
|
||||
background: var(--jsm-accent);
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 20px 0 rgba(168, 85, 247, 0.4);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.kx-btn-primary:hover {
|
||||
background: var(--jsm-accent-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 0 28px 0 rgba(168, 85, 247, 0.55);
|
||||
}
|
||||
.kx-btn-ghost {
|
||||
color: var(--kx-secondary);
|
||||
@@ -166,10 +179,8 @@ body {
|
||||
background: var(--kx-surface-bright);
|
||||
}
|
||||
.kx-gradient-text {
|
||||
background: linear-gradient(135deg, #cc97ff 0%, #53ddfc 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
color: var(--jsm-ink);
|
||||
-webkit-text-fill-color: var(--jsm-ink);
|
||||
}
|
||||
.kx-orb {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import type { Metadata } from 'next';
|
||||
import { isServiceVisible } from '@/lib/service-visibility';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'CONTOUR — 나를 더 선명하게 이해하는 3분',
|
||||
@@ -22,7 +24,8 @@ export const metadata: Metadata = {
|
||||
},
|
||||
};
|
||||
|
||||
export default function GyeolLayout({ children }: { children: React.ReactNode }) {
|
||||
export default async function GyeolLayout({ children }: { children: React.ReactNode }) {
|
||||
if (!(await isServiceVisible('gyeol'))) notFound();
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen"
|
||||
|
||||
@@ -1,33 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import Script from "next/script";
|
||||
import { Jua } from "next/font/google";
|
||||
import "pretendard/dist/web/variable/pretendardvariable-dynamic-subset.css";
|
||||
import "./globals.css";
|
||||
import DashboardShell from "./components/DashboardShell";
|
||||
import { GlassFilter } from "./components/LiquidGlass";
|
||||
|
||||
const jua = Jua({
|
||||
weight: "400",
|
||||
subsets: ["latin"],
|
||||
variable: "--font-jua",
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
|
||||
default: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
template: "%s | 쟁승메이드",
|
||||
},
|
||||
description:
|
||||
"Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 팩. AI로 음악과 뮤비를 1시간 만에 완성하는 4단계 크리에이터 툴킷. ₩39,000부터.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
keywords: [
|
||||
"AI 음악",
|
||||
"AI 작곡",
|
||||
"Suno 프롬프트",
|
||||
"AI 뮤직비디오",
|
||||
"유튜브 쇼츠 음악",
|
||||
"AI 뮤비",
|
||||
"음악 프롬프트",
|
||||
"AI 사주",
|
||||
"외주 개발",
|
||||
"소프트웨어 개발",
|
||||
"웹사이트 제작",
|
||||
"업무 자동화",
|
||||
"백엔드 개발자",
|
||||
"프리랜서 개발자",
|
||||
],
|
||||
authors: [{ name: "박재오", url: "https://jaengseung-made.com" }],
|
||||
creator: "박재오",
|
||||
@@ -36,22 +26,23 @@ export const metadata: Metadata = {
|
||||
locale: "ko_KR",
|
||||
url: "https://jaengseung-made.com",
|
||||
siteName: "쟁승메이드",
|
||||
title: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
|
||||
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
description:
|
||||
"Suno 프롬프트 + 뮤비 워크플로우 + 유튜브 SEO 템플릿 팩. AI로 음악·뮤비를 1시간에 완성하는 4단계 크리에이터 툴킷.",
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
images: [
|
||||
{
|
||||
url: "https://jaengseung-made.com/og-image.png",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "쟁승메이드 — AI 프롬프트 · 자동화 스토어",
|
||||
alt: "쟁승메이드 — 외주 개발 · 완성 소프트웨어",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "AI 음악·뮤비 팩 ₩39,000~ | 쟁승메이드",
|
||||
description: "AI로 음악과 뮤비를 1시간 만에. Suno 프롬프트 + 뮤비 워크플로우 + 유튜브 SEO 템플릿.",
|
||||
title: "외주 개발 · 완성 소프트웨어 | 쟁승메이드",
|
||||
description:
|
||||
"24시간 돌아가는 실서비스를 직접 설계·운영하는 개발 스튜디오. 맞춤 외주 개발과 검증된 완성 소프트웨어.",
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -68,40 +59,35 @@ const jsonLd = {
|
||||
'@id': 'https://jaengseung-made.com/#person',
|
||||
name: '박재오',
|
||||
url: 'https://jaengseung-made.com',
|
||||
jobTitle: '백엔드 개발자 · AI 자동화 전문가',
|
||||
worksFor: { '@type': 'Organization', name: '대기업 재직 중' },
|
||||
jobTitle: '소프트웨어 엔지니어',
|
||||
email: 'bgg8988@gmail.com',
|
||||
telephone: '010-3907-1392',
|
||||
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', 'AI 프롬프트', 'AI 자동화', '업무 자동화', 'ChatGPT', 'Claude'],
|
||||
description: '현직 엔지니어. AI 음악 생성 개발 가이드 패키지, AI 사주 분석 등 AI 크리에이티브 도구를 직접 개발·운영합니다.',
|
||||
knowsAbout: ['Python', 'Java', 'Spring Boot', 'Next.js', '외주 개발', '웹사이트 제작', '업무 자동화', 'API 설계'],
|
||||
description: '24시간 돌아가는 실서비스를 직접 설계·운영합니다. 맞춤 소프트웨어 외주 개발과 검증된 완성 소프트웨어를 제공합니다.',
|
||||
},
|
||||
{
|
||||
'@type': 'LocalBusiness',
|
||||
'@id': 'https://jaengseung-made.com/#business',
|
||||
name: '쟁승메이드',
|
||||
url: 'https://jaengseung-made.com',
|
||||
description: 'AI 음악 생성 개발 가이드 패키지, AI 사주 분석. 현직 엔지니어가 직접 설계·운영하는 AI 크리에이티브 스토어.',
|
||||
description: '24시간 돌아가는 실서비스를 직접 설계·운영하는 외주 개발 · 완성 소프트웨어 스토어.',
|
||||
email: 'bgg8988@gmail.com',
|
||||
telephone: '010-3907-1392',
|
||||
priceRange: '₩',
|
||||
areaServed: '대한민국',
|
||||
hasOfferCatalog: {
|
||||
'@type': 'OfferCatalog',
|
||||
name: '쟁승메이드 AI 도구 · 서비스',
|
||||
name: '쟁승메이드 개발 서비스',
|
||||
itemListElement: [
|
||||
{ '@type': 'Offer', price: '39000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (입문)', url: 'https://jaengseung-made.com/music/packs', description: 'Suno 프롬프트 조합법 + MV 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트. AI 음악 생성 개발 가이드 (1회 결제).' } },
|
||||
{ '@type': 'Offer', price: '99000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (프로)', url: 'https://jaengseung-made.com/music/packs', description: '입문 전체 + 샘플 프로젝트 1개(.prj · 영상 포함). 1회 결제.' } },
|
||||
{ '@type': 'Offer', price: '149000', priceCurrency: 'KRW', availability: 'https://schema.org/InStock', url: 'https://jaengseung-made.com/music/packs', itemOffered: { '@type': 'Product', name: 'AI 음악 생성 개발 가이드 패키지 (마스터)', url: 'https://jaengseung-made.com/music/packs', description: '프로 전체 + 샘플 다수 + 우선 업데이트·베타 선공개. 1회 결제.' } },
|
||||
{ '@type': 'Offer', price: '0', priceCurrency: 'KRW', url: 'https://jaengseung-made.com/work/saju', itemOffered: { '@type': 'Service', name: 'AI 사주 분석', url: 'https://jaengseung-made.com/work/saju', description: '생년월일 기반 AI 사주팔자 분석. 무료 체험 가능.' } },
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: 'https://jaengseung-made.com/work/freelance',
|
||||
url: 'https://jaengseung-made.com/outsourcing',
|
||||
availability: 'https://schema.org/InStock',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: '외주 개발',
|
||||
url: 'https://jaengseung-made.com/work/freelance',
|
||||
description: '7년차 백엔드 개발자의 1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
|
||||
url: 'https://jaengseung-made.com/outsourcing',
|
||||
description: '1:1 맞춤 소프트웨어 개발 외주. 자동화·API·웹/모바일 등 사이트 한정가로 제공.',
|
||||
serviceType: 'Custom Software Development',
|
||||
provider: { '@id': 'https://jaengseung-made.com/#business' },
|
||||
areaServed: '대한민국',
|
||||
@@ -109,12 +95,12 @@ const jsonLd = {
|
||||
},
|
||||
{
|
||||
'@type': 'Offer',
|
||||
url: 'https://jaengseung-made.com/work/website',
|
||||
url: 'https://jaengseung-made.com/outsourcing',
|
||||
availability: 'https://schema.org/InStock',
|
||||
itemOffered: {
|
||||
'@type': 'Service',
|
||||
name: '웹사이트 제작',
|
||||
url: 'https://jaengseung-made.com/work/website',
|
||||
url: 'https://jaengseung-made.com/outsourcing',
|
||||
description: 'Next.js 기반 기업·브랜드 웹사이트 맞춤 제작. 반응형 + SEO + 배포 포함.',
|
||||
serviceType: 'Web Development',
|
||||
provider: { '@id': 'https://jaengseung-made.com/#business' },
|
||||
@@ -133,7 +119,7 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="ko" data-scroll-behavior="smooth" className={jua.variable}>
|
||||
<html lang="ko" data-scroll-behavior="smooth">
|
||||
<head>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
@@ -156,7 +142,6 @@ export default function RootLayout({
|
||||
</Script>
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<GlassFilter />
|
||||
<DashboardShell>{children}</DashboardShell>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-12">
|
||||
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8">개인정보처리방침</h1>
|
||||
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}>개인정보처리방침</h1>
|
||||
|
||||
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
|
||||
<p>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
|
||||
export default function RefundPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-12">
|
||||
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8">환불 정책</h1>
|
||||
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}>환불 정책</h1>
|
||||
|
||||
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
|
||||
<p>
|
||||
|
||||
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
|
||||
export default function TermsPage() {
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto px-6 py-12">
|
||||
<h1 className="text-2xl font-extrabold text-[#04102b] mb-8">이용약관</h1>
|
||||
<h1 className="text-2xl font-extrabold mb-8" style={{ color: 'var(--jsm-ink)' }}>이용약관</h1>
|
||||
|
||||
<div className="prose prose-sm prose-slate max-w-none space-y-6 text-slate-600 leading-relaxed">
|
||||
<section>
|
||||
|
||||
@@ -6,6 +6,13 @@ import Link from 'next/link';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
// next 파라미터가 안전한 내부 경로(`/`로 시작, `//`·`/\` 프로토콜-상대 아님)일 때만 허용.
|
||||
function safeNext(raw: string | null): string {
|
||||
if (!raw) return '/mypage';
|
||||
if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return '/mypage';
|
||||
return raw;
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -15,6 +22,7 @@ function LoginForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const supabase = createClient();
|
||||
const next = safeNext(searchParams.get('next'));
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get('error')) {
|
||||
@@ -22,7 +30,7 @@ function LoginForm() {
|
||||
}
|
||||
// 이미 로그인된 경우 리다이렉트
|
||||
supabase.auth.getUser().then(({ data }) => {
|
||||
if (data.user) router.push('/mypage');
|
||||
if (data.user) router.push(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -52,7 +60,7 @@ function LoginForm() {
|
||||
if (error) {
|
||||
setMessage('로그인 실패: 이메일 또는 비밀번호를 확인해주세요.');
|
||||
} else {
|
||||
router.push('/mypage');
|
||||
router.push(next);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
@@ -66,124 +74,86 @@ function LoginForm() {
|
||||
process.env.NODE_ENV === 'development'
|
||||
? window.location.origin
|
||||
: (process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin);
|
||||
// next는 /auth/callback에서 read해 로그인 후 목적지로 리다이렉트 (기본 /mypage)
|
||||
const callbackUrl = `${base}/auth/callback?next=${encodeURIComponent(next)}`;
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: `${base}/auth/callback` },
|
||||
options: { redirectTo: callbackUrl },
|
||||
});
|
||||
if (error) setMessage('Google 로그인 오류: ' + error.message);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[#04102b] flex items-center justify-center p-4">
|
||||
{/* 배경 장식 */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}
|
||||
/>
|
||||
const isSuccess =
|
||||
message.includes('완료') || message.includes('확인해주세요');
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
{/* 로고 */}
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center px-4 py-12"
|
||||
style={{ background: 'var(--jsm-bg)' }}
|
||||
>
|
||||
<div className="w-full max-w-sm">
|
||||
{/* 워드마크 */}
|
||||
<div className="text-center mb-8">
|
||||
<Link href="/" className="inline-flex items-center gap-3 group">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#1a56db] flex items-center justify-center text-white font-bold text-xl">
|
||||
쟁
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-white font-bold text-xl leading-tight">쟁승메이드</div>
|
||||
<div className="text-blue-400 text-xs font-medium">박재오의 개발 공방</div>
|
||||
</div>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-block"
|
||||
style={{
|
||||
fontWeight: 800,
|
||||
fontSize: '1.375rem',
|
||||
letterSpacing: '-0.03em',
|
||||
color: 'var(--jsm-ink)',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
>
|
||||
쟁승메이드
|
||||
</Link>
|
||||
<p
|
||||
className="mt-2 text-sm break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
{isSignUp
|
||||
? '가입 후 의뢰 현황과 구매 내역을 관리하세요'
|
||||
: '로그인하고 의뢰 현황과 구매 내역을 확인하세요'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 카드 */}
|
||||
<div className="bg-white/5 border border-white/10 backdrop-blur rounded-2xl p-8 shadow-2xl">
|
||||
<div className="text-center mb-7">
|
||||
<h1 className="text-2xl font-extrabold text-white mb-1">
|
||||
{isSignUp ? '회원가입' : '로그인'}
|
||||
</h1>
|
||||
<p className="text-blue-300/60 text-sm">
|
||||
{isSignUp
|
||||
? '가입 후 사주 기록, 결제 내역을 관리하세요'
|
||||
: '사주 기록·결제·의뢰 내역을 확인하세요'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 오류/성공 메시지 */}
|
||||
{message && (
|
||||
<div className={`mb-4 px-4 py-3 rounded-xl text-sm font-medium ${
|
||||
message.includes('완료') || message.includes('확인해주세요')
|
||||
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-300'
|
||||
: 'bg-red-500/10 border border-red-500/30 text-red-300'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이메일/비밀번호 폼 */}
|
||||
<form onSubmit={handleAuth} className="space-y-4 mb-5">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-slate-300 mb-1.5">
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="6자 이상"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:bg-white/8 transition text-sm"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#1a56db] hover:bg-[#1e4fc2] text-white font-bold py-3 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? '처리 중...' : (isSignUp ? '회원가입' : '로그인')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 전환 링크 */}
|
||||
<div className="text-center mb-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
|
||||
className="text-sm text-blue-400 hover:text-blue-300 transition"
|
||||
>
|
||||
{isSignUp ? '이미 계정이 있으신가요? 로그인 →' : '아직 계정이 없으신가요? 회원가입 →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="relative mb-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-white/10" />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span className="px-3 bg-transparent text-slate-500 text-xs">또는 소셜 로그인</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구글 로그인 */}
|
||||
<button
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full flex items-center justify-center gap-3 px-4 py-3 bg-white/5 border border-white/10 rounded-xl hover:bg-white/10 transition text-white font-medium text-sm"
|
||||
<div
|
||||
className="rounded-xl p-8"
|
||||
style={{
|
||||
background: 'var(--jsm-surface)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
boxShadow: '0 1px 4px 0 rgba(15,23,42,0.06), 0 4px 16px 0 rgba(15,23,42,0.04)',
|
||||
}}
|
||||
>
|
||||
{/* 카드 헤더 */}
|
||||
<h1
|
||||
className="text-xl font-bold mb-6 text-center"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.02em' }}
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
{isSignUp ? '회원가입' : '로그인'}
|
||||
</h1>
|
||||
|
||||
{/* Google 로그인 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-4 py-2.5 rounded-lg text-sm font-medium mb-5"
|
||||
style={{
|
||||
background: 'var(--jsm-surface)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-surface-alt)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-surface)';
|
||||
}}
|
||||
>
|
||||
{/* Google G 로고 */}
|
||||
<svg className="w-4 h-4 shrink-0" viewBox="0 0 24 24" aria-hidden>
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
@@ -191,12 +161,169 @@ function LoginForm() {
|
||||
</svg>
|
||||
Google로 계속하기
|
||||
</button>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="relative mb-5">
|
||||
<div
|
||||
className="absolute inset-0 flex items-center"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="w-full" style={{ borderTop: '1px solid var(--jsm-line)' }} />
|
||||
</div>
|
||||
<div className="relative flex justify-center">
|
||||
<span
|
||||
className="px-3 text-xs"
|
||||
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-faint)' }}
|
||||
>
|
||||
또는
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오류/성공 메시지 */}
|
||||
{message && (
|
||||
<div
|
||||
className="mb-4 px-3.5 py-3 rounded-lg text-sm"
|
||||
style={{
|
||||
background: isSuccess ? '#f0fdf4' : '#fef2f2',
|
||||
border: `1px solid ${isSuccess ? '#bbf7d0' : '#fecaca'}`,
|
||||
color: isSuccess ? '#15803d' : '#dc2626',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이메일/비밀번호 폼 */}
|
||||
<form onSubmit={handleAuth} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-email"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
이메일
|
||||
</label>
|
||||
<input
|
||||
id="login-email"
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
background: 'var(--jsm-surface)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--jsm-accent)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--jsm-line)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="login-password"
|
||||
className="block text-sm font-medium mb-1.5"
|
||||
style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.01em' }}
|
||||
>
|
||||
비밀번호
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="6자 이상 입력해주세요"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
minLength={6}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
background: 'var(--jsm-surface)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--jsm-accent)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--jsm-line)';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2.5 rounded-lg text-sm font-semibold mt-1"
|
||||
style={{
|
||||
background: loading ? 'var(--jsm-ink-faint)' : 'var(--jsm-accent)',
|
||||
color: loading ? '#ffffff' : '#ffffff',
|
||||
border: 'none',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'background 0.15s, transform 0.15s',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-accent-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!loading) (e.currentTarget as HTMLButtonElement).style.background = 'var(--jsm-accent)';
|
||||
}}
|
||||
>
|
||||
{loading ? '처리 중...' : isSignUp ? '가입하기' : '로그인'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* 가입/로그인 전환 */}
|
||||
<div className="mt-5 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setIsSignUp(!isSignUp); setMessage(''); }}
|
||||
className="text-sm"
|
||||
style={{
|
||||
color: 'var(--jsm-accent)',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '-0.01em',
|
||||
transition: 'color 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = 'var(--jsm-accent-hover)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLButtonElement).style.color = 'var(--jsm-accent)';
|
||||
}}
|
||||
>
|
||||
{isSignUp
|
||||
? '이미 계정이 있으신가요? 로그인'
|
||||
: '계정이 없으신가요? 회원가입'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 홈으로 */}
|
||||
<div className="text-center mt-6">
|
||||
<Link href="/" className="text-slate-500 hover:text-slate-300 text-sm transition">
|
||||
← 홈으로 돌아가기
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm"
|
||||
style={{ color: 'var(--jsm-ink-faint)', transition: 'color 0.15s', letterSpacing: '-0.01em' }}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--jsm-ink-soft)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLAnchorElement).style.color = 'var(--jsm-ink-faint)';
|
||||
}}
|
||||
>
|
||||
홈으로 돌아가기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,8 +334,14 @@ function LoginForm() {
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="min-h-screen bg-[#04102b] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div
|
||||
className="min-h-screen flex items-center justify-center"
|
||||
style={{ background: 'var(--jsm-bg)' }}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full border-2 border-t-transparent animate-spin"
|
||||
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
|
||||
/>
|
||||
</div>
|
||||
}>
|
||||
<LoginForm />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 음악 제품',
|
||||
description: 'Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿 한 팩에. 1시간 만에 음악·뮤비 완성.',
|
||||
title: '나의 이야기를 음악으로',
|
||||
description: '당신의 이야기를 AI가 가사와 음악으로. 스토리를 들려주면 나만의 노래가 완성됩니다. 로그인 무료.',
|
||||
};
|
||||
|
||||
export default function MusicLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'AI 음악 생성 개발 가이드 패키지 | Suno · MV · 유튜브 쇼츠',
|
||||
description:
|
||||
'엔지니어가 설계한 AI 음악 생성 개발 가이드. Suno 프롬프트 조합법 + MV 비디오 생성 워크플로우 + 저작권 가이드 + 템플릿 PDF + 샘플 프로젝트. 1회 결제 · 입문 ₩39k / 프로 ₩99k / 마스터 ₩149k.',
|
||||
keywords: [
|
||||
'AI 음악 만들기',
|
||||
'Suno 프롬프트',
|
||||
'AI 뮤직비디오',
|
||||
'AI 커버곡',
|
||||
'유튜브 쇼츠 음악',
|
||||
'AI 작곡',
|
||||
'크리에이터 이코노미',
|
||||
'Lyria 프롬프트',
|
||||
'Runway AI 비디오',
|
||||
],
|
||||
openGraph: {
|
||||
title: 'AI 음악 생성 개발 가이드 패키지 | 쟁승메이드',
|
||||
description:
|
||||
'네 사연을 노래로. 쇼츠까지 한 번에. AI 음악 생성 개발 가이드 · Suno Pro 검증 · 평생 업데이트.',
|
||||
url: 'https://jaengseung-made.com/music/packs',
|
||||
},
|
||||
};
|
||||
|
||||
export default function MusicPacksLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import PurchaseAgreementModal from '@/app/components/PurchaseAgreementModal';
|
||||
import { SparklesOverlay } from '@/components/ui/sparkles-text';
|
||||
import { CardBody, CardContainer, CardItem } from '@/components/ui/3d-card-effect';
|
||||
|
||||
type Tier = 'starter' | 'pro' | 'master';
|
||||
|
||||
const TIERS: Record<Tier, { name: string; price: string; priceNum: string; desc: string; features: string[]; highlight?: boolean }> = {
|
||||
starter: {
|
||||
name: '입문',
|
||||
price: '₩39,000',
|
||||
priceNum: '39,000',
|
||||
desc: 'AI 음악 생성을 처음 시작하는 개발 가이드',
|
||||
features: [
|
||||
'Suno 프롬프트 조합법 20종',
|
||||
'구조 템플릿 PDF 40p',
|
||||
'저작권 가이드 기본판',
|
||||
'12개월 무료 업데이트',
|
||||
],
|
||||
},
|
||||
pro: {
|
||||
name: '프로',
|
||||
price: '₩99,000',
|
||||
priceNum: '99,000',
|
||||
desc: '쇼츠 업로드까지 반복 가능한 워크플로우 가이드',
|
||||
highlight: true,
|
||||
features: [
|
||||
'입문 전체 포함',
|
||||
'MV 워크플로우 (Runway · Luma · Pika)',
|
||||
'샘플 프로젝트 1개 (.prj · 영상)',
|
||||
'1:1 Q&A 1회 + 유튜브 SEO 템플릿',
|
||||
],
|
||||
},
|
||||
master: {
|
||||
name: '마스터',
|
||||
price: '₩149,000',
|
||||
priceNum: '149,000',
|
||||
desc: '여러 장르·포맷을 커버하는 마스터 가이드',
|
||||
features: [
|
||||
'프로 전체 포함',
|
||||
'샘플 프로젝트 장르별 3종',
|
||||
'저작권 심화판 + 상업 이용 체크리스트',
|
||||
'우선 업데이트 · 제작 레시피 영상',
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const PROCESS = [
|
||||
{ num: '01', subtitle: 'Concept & Lyrics', title: '크리에이티브 디렉팅', result: 'AI 최적화 가사 · 메타데이터 시트' },
|
||||
{ num: '02', subtitle: 'Music Generation', title: '오디오 엔지니어링', result: '고품질 완곡 (Full Track, 스템 분리본)' },
|
||||
{ num: '03', subtitle: 'AI MV Generation', title: '비주얼 마스터링', result: '쇼츠(9:16) 또는 유튜브(16:9) 고화질 영상' },
|
||||
{ num: '04', subtitle: 'Viral Optimization', title: '퍼블리싱 가이드', result: '즉시 업로드 가능한 유튜브 배포 패키지' },
|
||||
];
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: 'Suno 유료 플랜 가입이 꼭 필요한가요?',
|
||||
a: 'Suno 무료 플랜은 상업적 이용이 제한됩니다. 본인 결과물을 유튜브·SNS에 업로드해 수익화하려면 Suno Pro 이상 권장. 팩 구매 후 가입 전 플랜 선택 가이드가 포함됩니다.',
|
||||
},
|
||||
{
|
||||
q: '제가 만든 결과물의 상업 이용·저작권은?',
|
||||
a: '결과물의 상업권은 고객이 가입한 AI 서비스의 이용약관을 따릅니다. 팩에는 Suno·Runway·Luma 각 서비스의 최신 약관 요약과 상업 이용 체크리스트가 포함되어 있습니다. (법률 자문이 아닌 참고용 가이드입니다.)',
|
||||
},
|
||||
{
|
||||
q: '결과물 품질을 보장하나요?',
|
||||
a: 'AI 생성물은 모델 버전·프롬프트 입력에 따라 달라지므로 결과물 자체를 보장하지 않습니다. 다만 팩은 동일 프롬프트로 반복 가능한 고품질 구간을 설계하는 방법을 제공합니다. 샘플 쇼츠·프로젝트로 품질 기대치를 사전 확인하세요.',
|
||||
},
|
||||
{
|
||||
q: '환불이 가능한가요?',
|
||||
a: '전자상거래법 제17조 제2항 제5호에 따라 디지털 콘텐츠는 제공 시작 후 청약철회가 제한됩니다. 무료 샘플로 사전 확인을 제공하므로 충분히 검토 후 구매해주세요. 파일 손상·전달 불량 등 회사 귀책은 즉시 재전달 또는 환불됩니다.',
|
||||
},
|
||||
{
|
||||
q: '업데이트는 어떻게 받나요?',
|
||||
a: '구매자 전용 Notion 페이지에서 변경 이력과 최신 파일을 제공. 12개월간 무료 업데이트가 기본, 마스터는 우선 업데이트·베타 선공개가 포함됩니다.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MusicServicePage() {
|
||||
const [selectedTier, setSelectedTier] = useState<Tier | null>(null);
|
||||
const [openFaq, setOpenFaq] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-black text-white">
|
||||
{/* PRICING */}
|
||||
<section id="pricing" className="px-6 py-14 lg:px-14 bg-black">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="flex items-end justify-between flex-wrap gap-3 mb-8">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-1">Pricing · 1회 결제</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold">3개 티어, 목표에 맞게 선택</h2>
|
||||
</div>
|
||||
<Link href="/music/samples" className="text-sm text-white/80 hover:text-white underline underline-offset-4">
|
||||
샘플 먼저 보기
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-5 items-stretch">
|
||||
{(Object.keys(TIERS) as Tier[]).map((key) => {
|
||||
const t = TIERS[key];
|
||||
return (
|
||||
<CardContainer key={key} containerClassName="w-full py-0" className="w-full h-full">
|
||||
<CardBody
|
||||
className={`relative w-full h-full rounded-2xl p-8 flex flex-col border transition-all ${
|
||||
t.highlight
|
||||
? 'border-white bg-white text-black md:scale-[1.03] md:-translate-y-2'
|
||||
: 'border-white/15 bg-white/[0.02] hover:border-white/40 text-white'
|
||||
}`}
|
||||
>
|
||||
{t.highlight && (
|
||||
<SparklesOverlay
|
||||
sparklesCount={20}
|
||||
colors={{ first: '#9E7AFF', second: '#FE8BBB' }}
|
||||
className="rounded-2xl"
|
||||
/>
|
||||
)}
|
||||
{t.highlight && (
|
||||
<CardItem translateZ={60} className="absolute -top-3 left-1/2 -translate-x-1/2 z-20">
|
||||
<span className="inline-flex items-center bg-black text-white text-[10px] font-extrabold px-3 py-1.5 rounded-full uppercase tracking-wider border border-white">
|
||||
가장 많이 팔림
|
||||
</span>
|
||||
</CardItem>
|
||||
)}
|
||||
<CardItem translateZ={40} as="h3" className="font-extrabold text-2xl mb-1 relative z-10">
|
||||
{t.name}
|
||||
</CardItem>
|
||||
<CardItem translateZ={20} as="p" className={`text-sm mb-6 ${t.highlight ? 'text-black/60' : 'text-white/60'}`}>
|
||||
{t.desc}
|
||||
</CardItem>
|
||||
<CardItem translateZ={50} className="mb-6">
|
||||
<span className="text-4xl font-extrabold font-mono">{t.price}</span>
|
||||
<span className={`text-xs ml-2 ${t.highlight ? 'text-black/50' : 'text-white/50'}`}>1회 결제</span>
|
||||
</CardItem>
|
||||
<CardItem
|
||||
translateZ={20}
|
||||
as="ul"
|
||||
className={`space-y-3 text-sm mb-8 flex-1 ${t.highlight ? 'text-black/80' : 'text-white/80'}`}
|
||||
>
|
||||
{t.features.map((f) => (
|
||||
<li key={f} className="flex gap-2.5">
|
||||
<span className="flex-shrink-0 mt-0.5">·</span>
|
||||
<span className="leading-relaxed">{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</CardItem>
|
||||
<CardItem
|
||||
translateZ={40}
|
||||
as="button"
|
||||
onClick={() => setSelectedTier(key)}
|
||||
className={`w-full py-4 rounded-xl font-extrabold text-sm transition-colors ${
|
||||
t.highlight
|
||||
? 'bg-black hover:bg-black/85 text-white'
|
||||
: 'bg-white/10 hover:bg-white/20 text-white border border-white/20'
|
||||
}`}
|
||||
>
|
||||
{t.name} 구매하기
|
||||
</CardItem>
|
||||
</CardBody>
|
||||
</CardContainer>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-xs text-white/50 text-center mt-8">
|
||||
구매 전 <Link href="/legal/refund" className="underline hover:text-white">환불 정책</Link>을 반드시 확인해주세요.
|
||||
디지털 콘텐츠 특성상 제공 시작 후 청약철회가 제한됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 팩 구성품 */}
|
||||
<section className="px-6 py-16 lg:px-14 bg-black border-t border-white/10">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-2">What's Included</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold mb-8">팩 구성품</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ title: 'Suno 프롬프트 북', desc: '장르·무드·보컬 톤 조합법 20+종. 복붙해서 바로 사용하는 PDF.' },
|
||||
{ title: 'MV 워크플로우', desc: 'Midjourney·Runway·Luma로 비트 싱크 영상 만드는 단계별 가이드.' },
|
||||
{ title: '저작권 & 상업 이용', desc: 'Suno·Runway 약관 요약 + 수익화 전 안전 체크리스트.' },
|
||||
{ title: '샘플 프로젝트 파일', desc: '완성된 가사·프롬프트·영상 세트. 그대로 수정해 재사용 가능.' },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="p-6 rounded-2xl border border-white/15 bg-white/[0.02]"
|
||||
>
|
||||
<h3 className="font-bold text-white mb-1">{item.title}</h3>
|
||||
<p className="text-sm text-white/60 leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* PROCESS */}
|
||||
<section className="px-6 py-16 lg:px-14 bg-black border-t border-white/10">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-2">Process</p>
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold mb-10" style={{ wordBreak: 'keep-all' }}>
|
||||
컨셉 → 음악 → 비주얼 → 퍼블리싱
|
||||
</h2>
|
||||
|
||||
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{PROCESS.map((step) => (
|
||||
<div
|
||||
key={step.num}
|
||||
className="rounded-2xl p-6 border border-white/15 bg-white/[0.02] hover:border-white/40 transition-colors"
|
||||
>
|
||||
<p className="font-mono text-xs text-white/50 mb-3">{step.num}</p>
|
||||
<p className="font-mono text-[10px] text-white/50 uppercase tracking-widest mb-1">
|
||||
{step.subtitle}
|
||||
</p>
|
||||
<h3 className="text-lg font-extrabold text-white mb-2">{step.title}</h3>
|
||||
<p className="text-sm text-white/60 leading-relaxed" style={{ wordBreak: 'keep-all' }}>
|
||||
{step.result}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* SAMPLES */}
|
||||
<section id="samples" className="px-6 py-12 lg:px-14 bg-black border-t border-white/10">
|
||||
<div className="max-w-6xl mx-auto flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="font-mono text-xs text-white/50 tracking-widest uppercase mb-1">Samples</p>
|
||||
<h2 className="text-xl md:text-2xl font-extrabold">이 팩으로 만든 실제 쇼츠들</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/music/samples"
|
||||
className="inline-flex items-center px-6 py-3 rounded-xl border border-white/30 hover:bg-white hover:text-black text-sm font-semibold text-white transition whitespace-nowrap"
|
||||
>
|
||||
전체 샘플 갤러리
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* FAQ */}
|
||||
<section className="px-6 py-20 lg:px-14 bg-black border-t border-white/10">
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<h2 className="text-2xl md:text-3xl font-extrabold text-center mb-10">
|
||||
자주 묻는 질문
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{FAQS.map((f, i) => (
|
||||
<div key={i} className="border border-white/15 rounded-2xl overflow-hidden bg-white/[0.02]">
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === i ? null : i)}
|
||||
className="w-full flex items-center justify-between px-5 py-4 text-left hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span className="font-bold text-white text-sm">{f.q}</span>
|
||||
<span className={`text-white text-xl transition-transform ${openFaq === i ? 'rotate-45' : ''}`}>
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
{openFaq === i && (
|
||||
<div className="px-5 pb-5 text-sm text-white/70 leading-relaxed" style={{ wordBreak: 'keep-all' }}>
|
||||
{f.a}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Sticky CTA */}
|
||||
<div
|
||||
className="fixed bottom-0 inset-x-0 z-40 border-t border-white/15 backdrop-blur-md"
|
||||
style={{ background: 'rgba(0,0,0,0.85)' }}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-5 py-3 flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-mono text-white/50 tracking-widest uppercase">From</p>
|
||||
<p className="text-white font-extrabold text-lg leading-tight">
|
||||
₩39,000 <span className="text-xs text-white/50 font-medium">· 1회 결제</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="#pricing"
|
||||
className="inline-flex items-center bg-white hover:bg-white/90 text-black px-6 py-3 rounded-xl font-extrabold text-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
팩 선택하기
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-20" aria-hidden />
|
||||
|
||||
{selectedTier && (
|
||||
<PurchaseAgreementModal
|
||||
isOpen={!!selectedTier}
|
||||
onClose={() => setSelectedTier(null)}
|
||||
productName={`AI 음악 생성 개발 가이드 · ${TIERS[selectedTier].name}`}
|
||||
price={TIERS[selectedTier].price}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,67 +2,60 @@ import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Music — AI 음악 제품',
|
||||
title: '음악 — 나의 이야기를 음악으로',
|
||||
};
|
||||
|
||||
const CARDS = [
|
||||
{
|
||||
href: '/music/packs',
|
||||
label: '팩 상세',
|
||||
desc: '입문 ₩39,000부터 — Suno 프롬프트북 + 뮤비 워크플로우 + SEO 템플릿',
|
||||
key: 'packs',
|
||||
href: '/music/studio',
|
||||
label: 'AI 스튜디오',
|
||||
desc: '스토리를 입력하면 가사·음악을 자동 생성 — 로그인 무료',
|
||||
key: 'studio',
|
||||
},
|
||||
{
|
||||
href: '/music/samples',
|
||||
label: '샘플 갤러리',
|
||||
desc: '실제 결과물 — 장르별 데모 + 가사 + 영상 미리보기',
|
||||
desc: '실제 결과물 — 장르별 데모와 가사',
|
||||
key: 'samples',
|
||||
},
|
||||
{
|
||||
href: '/music/studio',
|
||||
label: 'AI 스튜디오',
|
||||
desc: 'Suno API 연동 — 직접 트랙 생성 (베타)',
|
||||
key: 'studio',
|
||||
},
|
||||
];
|
||||
|
||||
export default function MusicHub() {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-white">
|
||||
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 border-b border-white/10">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-[#060e20] to-black pointer-events-none" />
|
||||
<div className="min-h-screen bg-[var(--jsm-bg)]">
|
||||
<section className="relative w-full min-h-[60vh] flex items-center justify-center px-6 bg-[var(--jsm-navy)]">
|
||||
<div className="relative z-10 max-w-3xl mx-auto text-center">
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-white/50 mb-4">
|
||||
<p className="font-mono text-[11px] tracking-widest uppercase text-[var(--jsm-accent-soft)] mb-4">
|
||||
Music
|
||||
</p>
|
||||
<h1
|
||||
className="kx-display text-4xl md:text-6xl font-bold mb-5"
|
||||
className="kx-display text-4xl md:text-6xl font-bold mb-5 text-white"
|
||||
style={{ wordBreak: 'keep-all', letterSpacing: '-0.02em' }}
|
||||
>
|
||||
AI 음악 제품
|
||||
나의 이야기를 음악으로
|
||||
</h1>
|
||||
<p className="text-base md:text-lg text-white/70 max-w-2xl mx-auto leading-relaxed">
|
||||
Suno 프롬프트 + 뮤직비디오 워크플로우 + 유튜브 SEO 템플릿. 한 팩에 담긴 4단계 워크플로우로 1시간 안에 결과물 완성.
|
||||
당신의 이야기를 들려주면 AI가 가사와 음악으로 만들어 드립니다. 로그인하면 무료로 만들고 보관하세요.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 px-6">
|
||||
<div className="max-w-6xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{CARDS.map((c) => (
|
||||
<Link
|
||||
key={c.key}
|
||||
href={c.href}
|
||||
className="group rounded-2xl border border-white/15 bg-white/[0.02] p-7 hover:border-white/40 hover:bg-white/[0.05] transition flex flex-col"
|
||||
className="rounded-2xl border border-[var(--jsm-line)] bg-[var(--jsm-surface)] p-7 transition-colors hover:border-[var(--jsm-accent)] hover:bg-[var(--jsm-surface-alt)] flex flex-col"
|
||||
style={{ textDecoration: 'none' }}
|
||||
>
|
||||
<h2 className="kx-display text-xl md:text-2xl font-bold text-white mb-3">
|
||||
<h2 className="kx-display text-xl md:text-2xl font-bold text-[var(--jsm-ink)] mb-3">
|
||||
{c.label}
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-white/60 leading-relaxed flex-1">
|
||||
<p className="text-sm md:text-base text-[var(--jsm-ink-soft)] leading-relaxed flex-1">
|
||||
{c.desc}
|
||||
</p>
|
||||
<span aria-hidden="true" className="mt-4 text-white/40 text-xs">→</span>
|
||||
<span aria-hidden="true" className="mt-4 text-[var(--jsm-ink-faint)] text-xs">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -27,17 +27,17 @@ const SAMPLES: Sample[] = [
|
||||
|
||||
export default function MusicSamplesPage() {
|
||||
return (
|
||||
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--kx-surface)' }}>
|
||||
<div className="px-6 py-20 lg:px-14" style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<span className="kx-label">SAMPLE GALLERY</span>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-bold mt-3 mb-4" style={{ color: 'var(--jsm-ink)' }}>
|
||||
AI 음악·뮤비 샘플 모음
|
||||
</h1>
|
||||
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="max-w-2xl mx-auto text-sm md:text-base" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
팩 워크플로우로 제작된 결과물입니다. 장르별로 다양한 톤을 확인해보세요.
|
||||
<br className="hidden md:block" />
|
||||
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
일부 샘플은 런칭 직후 순차 공개됩니다.
|
||||
</span>
|
||||
</p>
|
||||
@@ -47,30 +47,36 @@ export default function MusicSamplesPage() {
|
||||
{SAMPLES.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={`group relative aspect-[9/16] rounded-2xl overflow-hidden border ${
|
||||
s.featured ? 'border-violet-400/50 shadow-2xl shadow-violet-900/40' : 'border-white/10'
|
||||
className={`relative aspect-[9/16] rounded-2xl overflow-hidden border ${
|
||||
s.featured ? 'border-[var(--jsm-accent)] shadow-lg' : 'border-[var(--jsm-line)]'
|
||||
}`}
|
||||
style={{ background: 'linear-gradient(135deg, #1a0840 0%, #061228 100%)' }}
|
||||
style={{ background: 'var(--jsm-surface-alt)' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-violet-500/15 to-cyan-500/10 group-hover:from-violet-500/25 group-hover:to-cyan-500/20 transition-all" />
|
||||
|
||||
{s.featured && (
|
||||
<span
|
||||
className="absolute top-3 left-3 z-10 text-[10px] px-2 py-1 rounded-full font-semibold tracking-widest"
|
||||
style={{ background: 'rgba(204,151,255,0.2)', color: 'var(--kx-primary)', border: '1px solid rgba(204,151,255,0.5)' }}
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', border: '1px solid var(--jsm-accent)' }}
|
||||
>
|
||||
TOP
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center p-5">
|
||||
<div className="text-5xl mb-3 opacity-80 group-hover:scale-110 transition-transform">🎬</div>
|
||||
<p className="text-[10px] md:text-xs font-mono tracking-widest text-violet-300/80 mb-1">{s.genre.toUpperCase()}</p>
|
||||
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<svg
|
||||
className="w-10 h-10 mb-3 opacity-80"
|
||||
fill="none"
|
||||
stroke="var(--jsm-accent)"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15.75 10.5l4.72-2.72a.75.75 0 011.28.53v9.38a.75.75 0 01-1.28.53l-4.72-2.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
||||
</svg>
|
||||
<p className="text-[10px] md:text-xs font-mono tracking-widest mb-1" style={{ color: 'var(--jsm-accent)' }}>{s.genre.toUpperCase()}</p>
|
||||
<p className="text-sm md:text-base font-semibold" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{s.title}
|
||||
</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--kx-on-variant)' }}>{s.duration}</p>
|
||||
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--jsm-ink-soft)' }}>{s.duration}</p>
|
||||
<p className="text-[10px] mt-3 opacity-60" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{s.embedId ? '영상 재생' : '영상 준비 중'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -79,19 +85,20 @@ export default function MusicSamplesPage() {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mt-16 text-center p-10 kx-glass"
|
||||
style={{ border: '1px solid rgba(204,151,255,0.12)', borderRadius: '0.75rem 0.75rem 0.125rem 0.125rem' }}
|
||||
className="mt-16 text-center px-8 py-16 rounded-3xl"
|
||||
style={{ background: 'var(--jsm-navy)' }}
|
||||
>
|
||||
<span className="kx-label">NEXT</span>
|
||||
<h2 className="kx-display text-2xl md:text-3xl font-bold mt-2 mb-3" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<span className="text-[var(--jsm-accent-soft)] text-xs font-bold uppercase tracking-widest">NEXT</span>
|
||||
<h2 className="text-2xl md:text-3xl font-bold mt-3 mb-3 text-white">
|
||||
내 채널에도 이런 쇼츠 올리고 싶다면
|
||||
</h2>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-sm mb-6 text-white/70">
|
||||
동일 워크플로우 팩 ₩39,000부터.
|
||||
</p>
|
||||
<Link
|
||||
href="/music/packs#pricing"
|
||||
className="kx-btn-primary px-8 py-3.5 rounded-full text-sm inline-flex"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-white px-8 py-3.5 text-sm font-semibold transition-transform duration-200 hover:translate-y-[-1px]"
|
||||
style={{ color: 'var(--jsm-navy)' }}
|
||||
>
|
||||
팩 가격 보기
|
||||
</Link>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type Mode = 'simple' | 'custom';
|
||||
type FlowTab = 'story' | 'manual';
|
||||
type StoryStage = 'input' | 'preview';
|
||||
|
||||
type SunoClip = {
|
||||
id: string;
|
||||
@@ -23,6 +26,20 @@ type TaskState = {
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type TrackMeta = {
|
||||
title?: string;
|
||||
story?: string;
|
||||
lyrics?: string;
|
||||
style?: string;
|
||||
};
|
||||
|
||||
type MusicStory = {
|
||||
title: string;
|
||||
lyrics: string;
|
||||
style: string;
|
||||
mood: string;
|
||||
};
|
||||
|
||||
const MODELS = [
|
||||
{ id: 'V4', label: 'V4 (기본)', desc: '안정적 고품질' },
|
||||
{ id: 'V4_5', label: 'V4.5', desc: '최신 · 풍부한 디테일' },
|
||||
@@ -35,11 +52,16 @@ const TAG_PRESETS = [
|
||||
];
|
||||
|
||||
const LS_KEY = 'jsm_studio_task_ids_v2';
|
||||
const LOGIN_HREF = '/login?next=/music/studio';
|
||||
|
||||
const isDone = (s: string) => s === 'SUCCESS' || s === 'FIRST_SUCCESS';
|
||||
const isFailed = (s: string) => s.includes('FAILED') || s === 'SENSITIVE_WORD_ERROR';
|
||||
|
||||
const FIELD_INPUT =
|
||||
'w-full rounded-xl border border-[var(--jsm-line)] bg-white px-4 py-3 text-base text-[var(--jsm-ink)] outline-none transition focus:border-[var(--jsm-accent)]';
|
||||
|
||||
export default function StudioPage() {
|
||||
const [flowTab, setFlowTab] = useState<FlowTab>('story');
|
||||
const [mode, setMode] = useState<Mode>('simple');
|
||||
const [model, setModel] = useState('V4');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
@@ -48,11 +70,28 @@ export default function StudioPage() {
|
||||
const [tags, setTags] = useState('');
|
||||
const [instrumental, setInstrumental] = useState(false);
|
||||
|
||||
// 스토리 흐름 상태
|
||||
const [storyText, setStoryText] = useState('');
|
||||
const [storyStage, setStoryStage] = useState<StoryStage>('input');
|
||||
const [storyLoading, setStoryLoading] = useState(false);
|
||||
const [storyError, setStoryError] = useState<string | null>(null);
|
||||
const [storyAuthRequired, setStoryAuthRequired] = useState(false);
|
||||
const [mood, setMood] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [authRequired, setAuthRequired] = useState(false);
|
||||
const [tasks, setTasks] = useState<TaskState[]>([]);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
// 생성 요청 시점의 원본(스토리/가사/스타일)을 taskId에 매핑 — 완료 후 자동 저장에 사용
|
||||
const metaRef = useRef<Map<string, TrackMeta>>(new Map());
|
||||
// 자동 저장 완료(또는 시도) 표시 — 중복 저장 방지
|
||||
const savedRef = useRef<Set<string>>(new Set());
|
||||
// 이번 세션에서 새로 생성한 taskId만 자동 저장 대상으로 삼는다
|
||||
// (새로고침 시 localStorage에서 복원된 과거 완료 트랙까지 재저장하는 것 방지)
|
||||
const sessionTaskIdsRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const saveToLS = useCallback((ids: string[]) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try { localStorage.setItem(LS_KEY, JSON.stringify(ids.slice(0, 20))); } catch { /* noop */ }
|
||||
@@ -105,10 +144,38 @@ export default function StudioPage() {
|
||||
return () => { if (pollRef.current) window.clearInterval(pollRef.current); };
|
||||
}, [tasks, refreshAll]);
|
||||
|
||||
const onSubmit = async () => {
|
||||
// 완료된 트랙 자동 저장 (best-effort) — 실패해도 재생에는 영향 없음
|
||||
useEffect(() => {
|
||||
tasks.forEach((task) => {
|
||||
if (!isDone(task.status)) return;
|
||||
if (!sessionTaskIdsRef.current.has(task.taskId)) return;
|
||||
if (savedRef.current.has(task.taskId)) return;
|
||||
const clip = task.clips.find((c) => c.audioUrl || c.streamAudioUrl);
|
||||
if (!clip) return;
|
||||
|
||||
savedRef.current.add(task.taskId);
|
||||
const meta = metaRef.current.get(task.taskId);
|
||||
const audioUrl = clip.audioUrl || clip.streamAudioUrl || '';
|
||||
fetch('/api/studio/tracks', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: meta?.title || clip.title || null,
|
||||
story: meta?.story || null,
|
||||
lyrics: meta?.lyrics || null,
|
||||
style: meta?.style || clip.tags || null,
|
||||
audio_url: audioUrl,
|
||||
task_id: task.taskId,
|
||||
}),
|
||||
}).catch(() => { /* 비로그인·오류 등 — 무시(best-effort) */ });
|
||||
});
|
||||
}, [tasks]);
|
||||
|
||||
const runGenerate = useCallback(async (forcedMode: Mode, meta: TrackMeta) => {
|
||||
setError(null);
|
||||
if (mode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||
if (mode === 'custom') {
|
||||
setAuthRequired(false);
|
||||
if (forcedMode === 'simple' && !prompt.trim()) { setError('프롬프트를 입력해주세요.'); return; }
|
||||
if (forcedMode === 'custom') {
|
||||
if (!title.trim()) { setError('트랙 제목을 입력해주세요.'); return; }
|
||||
if (!tags.trim()) { setError('스타일 태그를 입력해주세요.'); return; }
|
||||
if (!lyrics.trim() && !instrumental) { setError('가사를 입력하거나 Instrumental을 켜주세요.'); return; }
|
||||
@@ -119,7 +186,7 @@ export default function StudioPage() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode, model,
|
||||
mode: forcedMode, model,
|
||||
prompt: prompt.trim(),
|
||||
title: title.trim(),
|
||||
lyrics: lyrics.trim(),
|
||||
@@ -127,7 +194,16 @@ export default function StudioPage() {
|
||||
make_instrumental: instrumental,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
setAuthRequired(true);
|
||||
setError('로그인이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 429) {
|
||||
setError(typeof json.error === 'string' ? json.error : '오늘 생성 가능 횟수를 모두 사용했습니다.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !json.ok) {
|
||||
setError(typeof json.error === 'string' ? json.error : '생성 실패');
|
||||
return;
|
||||
@@ -137,6 +213,8 @@ export default function StudioPage() {
|
||||
setError('응답에서 taskId를 찾지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
metaRef.current.set(taskId, meta);
|
||||
sessionTaskIdsRef.current.add(taskId);
|
||||
setTasks((prev) => {
|
||||
const next: TaskState[] = [
|
||||
{ taskId, status: 'PENDING', clips: [], updatedAt: Date.now() },
|
||||
@@ -150,6 +228,65 @@ export default function StudioPage() {
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [prompt, title, lyrics, tags, instrumental, model, saveToLS]);
|
||||
|
||||
const onManualSubmit = () => {
|
||||
runGenerate(mode, {
|
||||
title: mode === 'custom' ? title.trim() : undefined,
|
||||
lyrics: mode === 'custom' ? lyrics.trim() : undefined,
|
||||
style: mode === 'custom' ? tags.trim() : undefined,
|
||||
story: mode === 'simple' ? prompt.trim() : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const onStoryGenerate = () => {
|
||||
runGenerate('custom', {
|
||||
title: title.trim(),
|
||||
lyrics: lyrics.trim(),
|
||||
style: tags.trim(),
|
||||
story: storyText.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const onMakeLyrics = async () => {
|
||||
setStoryError(null);
|
||||
setStoryAuthRequired(false);
|
||||
if (!storyText.trim()) {
|
||||
setStoryError('이야기를 먼저 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
setStoryLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/studio/story', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ story: storyText.trim() }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (res.status === 401) {
|
||||
setStoryAuthRequired(true);
|
||||
setStoryError('로그인이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (res.status === 503 || res.status === 502) {
|
||||
setStoryError(typeof json.error === 'string' ? json.error : 'AI 서비스가 잠시 준비 중입니다. 잠시 후 다시 시도해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!res.ok || !json.story) {
|
||||
setStoryError(typeof json.error === 'string' ? json.error : '가사 생성에 실패했습니다.');
|
||||
return;
|
||||
}
|
||||
const s = json.story as MusicStory;
|
||||
setTitle(s.title);
|
||||
setLyrics(s.lyrics);
|
||||
setTags(s.style);
|
||||
setMood(s.mood);
|
||||
setStoryStage('preview');
|
||||
} catch (e) {
|
||||
setStoryError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setStoryLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addTag = (t: string) => {
|
||||
@@ -161,203 +298,344 @@ export default function StudioPage() {
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen px-4 md:px-8 lg:px-12 py-10"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(1200px 600px at 20% -10%, rgba(156,72,234,0.18), transparent 60%), radial-gradient(1000px 500px at 110% 10%, rgba(83,221,252,0.12), transparent 55%), var(--kx-surface)',
|
||||
color: 'var(--kx-on-surface)',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-bg)', color: 'var(--jsm-ink)' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex items-end justify-between flex-wrap gap-4 mb-8">
|
||||
<div>
|
||||
<span className="kx-label">JAENGSEUNG STUDIO</span>
|
||||
<h1 className="kx-display text-3xl md:text-5xl font-extrabold mt-2" style={{ letterSpacing: '-0.02em' }}>
|
||||
프롬프트 한 줄로 트랙 만들기
|
||||
나의 이야기를 음악으로
|
||||
</h1>
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
Suno 엔진 기반 · Custom 모드로 가사·태그·보컬까지 세밀 제어
|
||||
<p className="mt-2 text-sm" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
이야기를 들려주면 AI가 가사·스타일을 제안합니다. 직접 입력 모드로 세밀하게 조정할 수도 있어요.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="text-xs px-3 py-1.5 rounded-full border"
|
||||
className="text-xs px-3 py-1.5 rounded-full font-semibold tracking-wide"
|
||||
style={{
|
||||
borderColor: 'rgba(204,151,255,0.35)',
|
||||
background: 'rgba(204,151,255,0.1)',
|
||||
color: 'var(--kx-primary)',
|
||||
border: '1px solid var(--jsm-accent)',
|
||||
background: 'var(--jsm-accent-soft)',
|
||||
color: 'var(--jsm-accent)',
|
||||
}}
|
||||
>
|
||||
⚡ v1 Studio · Live
|
||||
STUDIO · LIVE
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-[minmax(0,7fr)_minmax(0,5fr)] gap-6">
|
||||
{/* 좌측: 제어판 */}
|
||||
<div
|
||||
className="rounded-2xl p-6 md:p-8"
|
||||
style={{
|
||||
background: 'rgba(12,22,45,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'rgba(255,255,255,0.04)' }}>
|
||||
{(['simple', 'custom'] as Mode[]).map((m) => (
|
||||
<div className="rounded-2xl p-6 md:p-8 bg-white border border-[var(--jsm-line)]">
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{(['story', 'manual'] as FlowTab[]).map((t) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
key={t}
|
||||
onClick={() => setFlowTab(t)}
|
||||
className="flex-1 py-2.5 text-sm font-semibold rounded-full transition-all"
|
||||
style={
|
||||
mode === m
|
||||
? {
|
||||
background: 'linear-gradient(135deg, rgba(204,151,255,0.25), rgba(83,221,252,0.15))',
|
||||
color: '#fff',
|
||||
boxShadow: '0 0 24px rgba(204,151,255,0.25) inset',
|
||||
}
|
||||
: { color: 'var(--kx-on-variant)' }
|
||||
flowTab === t
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||
{t === 'story' ? '스토리로 만들기' : '직접 입력'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === 'simple' ? (
|
||||
{flowTab === 'story' ? (
|
||||
<div className="space-y-5">
|
||||
<Field label="프롬프트" hint="무드·장르·가사 아이디어를 한 줄로">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
|
||||
className="w-full bg-transparent outline-none resize-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
|
||||
className="w-full bg-transparent outline-none resize-none font-mono text-sm leading-relaxed"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className="w-full bg-transparent outline-none text-base"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
{storyStage === 'input' ? (
|
||||
<>
|
||||
<Field label="나의 이야기" hint="추억·순간·감정을 편하게 적어주세요">
|
||||
<textarea
|
||||
value={storyText}
|
||||
onChange={(e) => setStoryText(e.target.value)}
|
||||
rows={7}
|
||||
placeholder="예: 대학 시절 자취방에서 혼자 라면을 끓여 먹으며 창밖 비 오는 거리를 보던 밤, 외로웠지만 이상하게 평온했던 기억"
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
onClick={onMakeLyrics}
|
||||
disabled={storyLoading}
|
||||
className="w-full py-4 rounded-xl font-bold text-base transition disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{storyLoading ? '가사 만드는 중…' : '가사 만들기'}
|
||||
</button>
|
||||
|
||||
{storyError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{storyError}
|
||||
{storyAuthRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className="text-xs px-3 py-1 rounded-full font-semibold"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
무드 · {mood || '미정'}
|
||||
</span>
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
onClick={() => setStoryStage('input')}
|
||||
className="text-xs underline underline-offset-4"
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
이야기 다시 쓰기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="AI가 제안한 가사입니다 — 자유롭게 수정 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
style={{
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onMakeLyrics}
|
||||
disabled={storyLoading}
|
||||
className="flex-1 py-3.5 rounded-xl font-semibold text-sm transition disabled:opacity-60"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
color: 'var(--kx-on-variant)',
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
{storyLoading ? '다시 만드는 중…' : '가사 다시 만들기'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
<button
|
||||
onClick={onStoryGenerate}
|
||||
disabled={submitting}
|
||||
className="flex-1 py-3.5 rounded-xl font-bold text-sm transition disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '음악 만들기'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{storyError && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{storyError}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className="w-full bg-transparent outline-none text-sm"
|
||||
style={{ color: 'var(--kx-on-surface)' }}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id} style={{ background: '#0b1428' }}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-1 p-1 rounded-full mb-6" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{(['simple', 'custom'] as Mode[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className="flex-1 py-2 text-xs font-semibold rounded-full transition-all"
|
||||
style={
|
||||
mode === m
|
||||
? { background: 'var(--jsm-accent)', color: '#fff' }
|
||||
: { color: 'var(--jsm-ink-soft)' }
|
||||
}
|
||||
>
|
||||
{m === 'simple' ? '간단 모드' : 'Custom 모드'}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<span
|
||||
className="relative inline-block w-11 h-6 rounded-full transition"
|
||||
style={{ background: instrumental ? 'rgba(204,151,255,0.6)' : 'rgba(255,255,255,0.1)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
style={{ left: instrumental ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={instrumental}
|
||||
onChange={(e) => setInstrumental(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
{instrumental ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
|
||||
style={{
|
||||
background: submitting
|
||||
? 'rgba(204,151,255,0.2)'
|
||||
: 'linear-gradient(135deg, #cc97ff 0%, #7c3aed 50%, #53ddfc 100%)',
|
||||
color: '#0b1428',
|
||||
boxShadow: submitting ? 'none' : '0 12px 40px -12px rgba(204,151,255,0.6)',
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '▶ Generate Track'}
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-3 text-xs px-3 py-2 rounded-lg" style={{ background: 'rgba(215,51,87,0.12)', color: '#ff8ba7' }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
{mode === 'simple' ? (
|
||||
<div className="space-y-5">
|
||||
<Field label="프롬프트" hint="무드·장르·가사 아이디어를 한 줄로">
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
rows={5}
|
||||
placeholder="예: 비 오는 서울 새벽, 감성 시티팝 with 여성 보컬, 2010년대 무드"
|
||||
className={`${FIELD_INPUT} resize-none`}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<Field label="트랙 제목">
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="예: 새벽 세 시의 도시"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="가사" hint="Suno 포맷: [Verse] [Chorus] [Bridge] 등 태그 가능">
|
||||
<textarea
|
||||
value={lyrics}
|
||||
onChange={(e) => setLyrics(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={'[Verse]\n차가운 조명 아래 걷는 나\n새벽 세 시의 도시는 낯설어\n\n[Chorus]\n...'}
|
||||
className={`${FIELD_INPUT} resize-none font-mono text-sm leading-relaxed`}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="스타일 태그" hint="쉼표로 구분 · 장르·무드·악기·보컬 톤">
|
||||
<input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="city pop, female vocal, 120bpm, synth, nostalgic"
|
||||
className={FIELD_INPUT}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5 mt-3">
|
||||
{TAG_PRESETS.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => addTag(t)}
|
||||
className="text-xs px-2.5 py-1 rounded-full transition"
|
||||
style={{
|
||||
background: 'var(--jsm-surface-alt)',
|
||||
border: '1px solid var(--jsm-line)',
|
||||
color: 'var(--jsm-ink-soft)',
|
||||
}}
|
||||
>
|
||||
+ {t}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-6">
|
||||
<Field label="모델">
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
className={`${FIELD_INPUT} text-sm`}
|
||||
>
|
||||
{MODELS.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.label} — {m.desc}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Instrumental (가사 없음)">
|
||||
<ToggleSwitch checked={instrumental} onChange={setInstrumental} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
onClick={onManualSubmit}
|
||||
disabled={submitting}
|
||||
className="w-full py-4 rounded-xl font-extrabold text-base transition-all disabled:opacity-60"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#fff' }}
|
||||
>
|
||||
{submitting ? '생성 요청 중…' : '트랙 생성하기'}
|
||||
</button>
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
{authRequired && (
|
||||
<Link
|
||||
href={LOGIN_HREF}
|
||||
className="ml-2 font-semibold underline underline-offset-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
로그인하러 가기
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<p className="mt-3 text-[11px] leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
생성된 결과는 Suno 서비스 약관을 따릅니다. 상업 이용 전 플랜·저작권을 반드시 확인하세요.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 결과 */}
|
||||
<div
|
||||
className="rounded-2xl p-6 md:p-7"
|
||||
style={{
|
||||
background: 'rgba(9,17,36,0.7)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
}}
|
||||
>
|
||||
<div className="rounded-2xl p-6 md:p-7 bg-white border border-[var(--jsm-line)]">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<span className="kx-label">RECENT TRACKS</span>
|
||||
@@ -367,7 +645,7 @@ export default function StudioPage() {
|
||||
<button
|
||||
onClick={() => { setTasks([]); saveToLS([]); }}
|
||||
className="text-[11px] underline underline-offset-4"
|
||||
style={{ color: 'var(--kx-on-variant)' }}
|
||||
style={{ color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
기록 지우기
|
||||
</button>
|
||||
@@ -376,35 +654,31 @@ export default function StudioPage() {
|
||||
|
||||
{tasks.length === 0 ? (
|
||||
<div
|
||||
className="rounded-xl p-8 text-center text-sm"
|
||||
style={{ border: '1px dashed rgba(255,255,255,0.1)', color: 'var(--kx-on-variant)' }}
|
||||
className="rounded-xl p-8 text-center text-sm border border-dashed"
|
||||
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
아직 생성된 트랙이 없습니다.
|
||||
<br />왼쪽에서 프롬프트를 입력하고 Generate를 눌러보세요.
|
||||
<br />왼쪽에서 이야기를 들려주거나 프롬프트를 입력해보세요.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-4 max-h-[640px] overflow-y-auto pr-1">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task.taskId}
|
||||
className="rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(20,31,56,0.6)',
|
||||
border: '1px solid rgba(255,255,255,0.05)',
|
||||
}}
|
||||
className="rounded-xl p-4 border"
|
||||
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 mb-3">
|
||||
<span className="text-[11px] font-mono opacity-60">task: {task.taskId.slice(0, 10)}…</span>
|
||||
<span className="text-[11px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
task: {task.taskId.slice(0, 10)}…
|
||||
</span>
|
||||
<StatusBadge status={task.status} />
|
||||
</div>
|
||||
|
||||
{task.clips.length === 0 ? (
|
||||
<div
|
||||
className="h-9 rounded-md flex items-center justify-center text-xs"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(204,151,255,0.08) 0%, rgba(83,221,252,0.08) 100%)',
|
||||
color: 'var(--kx-on-variant)',
|
||||
}}
|
||||
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-ink-soft)' }}
|
||||
>
|
||||
{isFailed(task.status)
|
||||
? (task.errorMessage ?? '생성 실패')
|
||||
@@ -417,8 +691,8 @@ export default function StudioPage() {
|
||||
return (
|
||||
<div
|
||||
key={c.id}
|
||||
className="rounded-lg p-3"
|
||||
style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.04)' }}
|
||||
className="rounded-lg p-3 bg-white border"
|
||||
style={{ borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{c.imageUrl && (
|
||||
@@ -430,17 +704,17 @@ export default function StudioPage() {
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-semibold text-sm truncate" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<p className="font-semibold text-sm truncate" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{c.title || '제목 없음'}
|
||||
</p>
|
||||
{c.tags && (
|
||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<p className="text-[11px] truncate mt-0.5" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{c.tags}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{c.duration && (
|
||||
<span className="text-[10px] font-mono opacity-60">
|
||||
<span className="text-[10px] font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{Math.round(c.duration)}s
|
||||
</span>
|
||||
)}
|
||||
@@ -449,8 +723,13 @@ export default function StudioPage() {
|
||||
<audio controls src={src} className="w-full mt-2" style={{ height: 36 }} />
|
||||
) : null}
|
||||
{c.audioUrl && (
|
||||
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<a href={c.audioUrl} download className="underline underline-offset-4 hover:text-white">
|
||||
<div className="mt-1.5 text-[11px]" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<a
|
||||
href={c.audioUrl}
|
||||
download
|
||||
className="underline underline-offset-4"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
MP3 다운로드
|
||||
</a>
|
||||
</div>
|
||||
@@ -467,9 +746,9 @@ export default function StudioPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--kx-on-variant)' }}>
|
||||
<Tip title="① 간단 모드" body="한 줄 프롬프트로 즉시 생성. 결과물 다양성 높음." />
|
||||
<Tip title="② Custom 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||
<div className="mt-10 grid md:grid-cols-3 gap-4 text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<Tip title="① 스토리 모드" body="이야기를 적으면 AI가 제목·가사·스타일을 자동으로 제안합니다." />
|
||||
<Tip title="② 직접 입력 모드" body="가사·태그·보컬·악기까지 정밀 제어. 반복 생성에 유리." />
|
||||
<Tip title="③ 상업 이용" body="Suno Pro 이상 플랜에서 생성한 결과만 수익화 가능. 플랜 확인 필수." />
|
||||
</div>
|
||||
</div>
|
||||
@@ -487,41 +766,60 @@ function Field({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--kx-primary)' }}>
|
||||
<span className="text-[11px] font-semibold tracking-widest uppercase" style={{ color: 'var(--jsm-accent)' }}>
|
||||
{label}
|
||||
</span>
|
||||
{hint && <span className="text-[10px]" style={{ color: 'var(--kx-on-variant)' }}>{hint}</span>}
|
||||
{hint && <span className="text-[10px]" style={{ color: 'var(--jsm-ink-soft)' }}>{hint}</span>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleSwitch({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<span
|
||||
className="relative inline-block w-11 h-6 rounded-full transition"
|
||||
style={{ background: checked ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-all"
|
||||
style={{ left: checked ? '22px' : '2px' }}
|
||||
/>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<span className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{checked ? 'ON' : 'OFF'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const map: Record<string, { bg: string; fg: string; label: string }> = {
|
||||
SUCCESS: { bg: 'rgba(64,206,172,0.18)', fg: '#6cf0c6', label: '완료' },
|
||||
FIRST_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '첫 트랙 준비' },
|
||||
TEXT_SUCCESS: { bg: 'rgba(83,221,252,0.18)', fg: '#53ddfc', label: '가사 완료' },
|
||||
PENDING: { bg: 'rgba(204,151,255,0.18)', fg: '#cc97ff', label: '대기' },
|
||||
const map: Record<string, { bg: string; fg: string; border: string; label: string }> = {
|
||||
SUCCESS: { bg: '#ecfdf5', fg: '#047857', border: '#a7f3d0', label: '완료' },
|
||||
FIRST_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '첫 트랙 준비' },
|
||||
TEXT_SUCCESS: { bg: 'var(--jsm-accent-soft)', fg: 'var(--jsm-accent)', border: 'var(--jsm-accent)', label: '가사 완료' },
|
||||
PENDING: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: '대기' },
|
||||
};
|
||||
let entry = map[status];
|
||||
if (!entry) {
|
||||
entry = isFailed(status)
|
||||
? { bg: 'rgba(215,51,87,0.18)', fg: '#ff8ba7', label: '실패' }
|
||||
: { bg: 'rgba(255,255,255,0.06)', fg: 'rgba(255,255,255,0.6)', label: status };
|
||||
? { bg: '#fef2f2', fg: '#b91c1c', border: '#fecaca', label: '실패' }
|
||||
: { bg: 'var(--jsm-surface-alt)', fg: 'var(--jsm-ink-soft)', border: 'var(--jsm-line)', label: status };
|
||||
}
|
||||
return (
|
||||
<span
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap"
|
||||
style={{ background: entry.bg, color: entry.fg }}
|
||||
className="text-[10px] font-semibold px-2 py-0.5 rounded-full whitespace-nowrap border"
|
||||
style={{ background: entry.bg, color: entry.fg, borderColor: entry.border }}
|
||||
>
|
||||
{entry.label}
|
||||
</span>
|
||||
@@ -530,11 +828,8 @@ function StatusBadge({ status }: { status: string }) {
|
||||
|
||||
function Tip({ title, body }: { title: string; body: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl p-4"
|
||||
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
|
||||
>
|
||||
<p className="font-semibold mb-1" style={{ color: 'var(--kx-on-surface)' }}>
|
||||
<div className="rounded-xl p-4 bg-white border" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<p className="font-semibold mb-1" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{title}
|
||||
</p>
|
||||
<p className="leading-relaxed">{body}</p>
|
||||
|
||||
2259
app/mypage/page.tsx
2259
app/mypage/page.tsx
File diff suppressed because it is too large
Load Diff
316
app/outsourcing/page.tsx
Normal file
316
app/outsourcing/page.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
|
||||
|
||||
import ShowcaseGrid from '@/app/components/deepfield/ShowcaseGrid';
|
||||
import ScrollReveal from '@/app/components/deepfield/ScrollReveal';
|
||||
import MockWindow from '@/app/components/mock/MockWindow';
|
||||
import { FeedMock } from '@/app/components/mock/screens';
|
||||
import { SHOWCASE_SLOTS } from '@/lib/showcase';
|
||||
|
||||
// 외주 개발 의뢰 페이지 (서버 컴포넌트) — 라이트 고craft.
|
||||
// PublicShell의 단일 라이트 셸을 따르며, 메인(/)과 동일한 비주얼 언어
|
||||
// (surface↔surface-alt 교차 + accent 모노 라벨 헤더 + 카드 스펙)를 공유한다.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '외주 개발',
|
||||
description:
|
||||
'24시간 돌아가는 실서비스를 직접 설계·운영하는 손으로, 맞춤 소프트웨어를 만들어 드립니다. 웹 서비스·업무 자동화·API·백엔드·봇·AI 연동까지 기획부터 납품·하자보수까지 단독으로 책임집니다.',
|
||||
};
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const FIELDS = [
|
||||
{ t: '웹 서비스 개발', d: '회원·결제·관리자까지, 실제로 굴러가는 서비스를 기획부터 배포까지 만들어 드립니다.' },
|
||||
{ t: '웹사이트 제작', d: '기업 소개·포트폴리오·랜딩 페이지를 반응형·SEO까지 갖춰 제작합니다.' },
|
||||
{ t: '업무 자동화', d: 'RPA·엑셀 집계·웹 크롤링으로 반복 업무를 사람 손에서 떼어냅니다.' },
|
||||
{ t: 'API·백엔드', d: '데이터 모델 설계부터 인증·외부 연동까지 안정적인 서버를 구축합니다.' },
|
||||
{ t: '텔레그램·디스코드 봇', d: '알림·명령·자동 응답 봇으로 운영과 커뮤니티 관리를 자동화합니다.' },
|
||||
{ t: 'AI 연동 개발', d: 'LLM·생성형 AI를 업무 흐름에 붙여 초안 작성·분류·요약을 자동화합니다.' },
|
||||
];
|
||||
|
||||
const PROCESS = [
|
||||
{ n: '01', t: '무료 상담', d: '요구사항을 함께 정리하고 실현 가능성을 점검합니다. 기획이 안 잡혔어도 괜찮습니다.' },
|
||||
{ n: '02', t: '견적·범위 확정', d: '기능 범위와 일정을 정리해 영업일 2일 내 견적으로 회신드립니다.' },
|
||||
{ n: '03', t: '계약·착수', d: '계약서 체결 후 착수금 30%를 받고 개발을 시작합니다.' },
|
||||
{ n: '04', t: '개발·중간 공유', d: '주 1회 이상 진행 상황을 공유하며 방향을 맞춰 갑니다.' },
|
||||
{ n: '05', t: '납품·검수', d: '완성본을 인도하고 함께 검수합니다. 전체 소스와 배포 문서를 전달합니다.' },
|
||||
{ n: '06', t: '무상 하자보수 30일', d: '납품 후 30일간 결함·수정을 무상으로 대응해 안정화까지 책임집니다.' },
|
||||
];
|
||||
|
||||
const CASES = [
|
||||
{ t: '주식 자동매매 시스템', cat: '실시간 트레이딩 · 직접 운영 중', live: true, d: '텔레그램과 연동해 실시간으로 주문을 집행하고 체결·손익 리포트를 자동 전송합니다.', tags: ['Python', 'Telegram Bot', '실시간 주문'] },
|
||||
{ t: '부동산 청약 자동 수집·매칭', cat: '크롤링 · 직접 운영 중', live: true, d: '공고를 주기적으로 크롤링해 조건에 맞는 매물만 골라내고, 신규 매칭을 즉시 푸시합니다.', tags: ['Python', '크롤링', '조건 매칭'] },
|
||||
{ t: 'AI 콘텐츠 자동화 파이프라인', cat: 'AI 연동 · 직접 운영 중', live: true, d: '생성부터 검수, 발행까지 사람이 개입할 지점만 남기고 전 과정을 자동으로 연결합니다.', tags: ['AI 연동', '검수 워크플로우', '자동 발행'] },
|
||||
{ t: 'Gmail 자동화 RPA', cat: 'RPA · 납품 완료', live: false, d: '거래처 이메일 수신 시 자동 분류, 답장 초안 작성, 담당자 알림을 전송합니다.', tags: ['Python', 'Gmail API'] },
|
||||
{ t: '쇼핑몰 가격 모니터링 봇', cat: '웹 스크래핑 · 납품 완료', live: false, d: '경쟁사 상품 가격을 매일 모니터링해 변동 시 텔레그램으로 즉시 알립니다.', tags: ['Python', 'Selenium', 'Telegram Bot'] },
|
||||
{ t: '영업 일보 자동화 시스템', cat: '엑셀 자동화 · 납품 완료', live: false, d: '엑셀 데이터를 자동 집계해 일·주·월별 보고서 PDF를 생성하고 매일 09시 발송합니다.', tags: ['Python', 'OpenPyXL', 'ReportLab'] },
|
||||
];
|
||||
|
||||
const FAQ = [
|
||||
{ q: '견적은 어떻게 산정되나요?', a: '기능 범위와 구현 난이도를 기준으로 산정합니다. 상담에서 필요한 기능을 함께 정리한 뒤, 영업일 2일 내에 범위·일정·금액을 명시한 견적으로 회신드립니다. 추측으로 부풀리지 않고 실제 작업량 기준으로 잡습니다.' },
|
||||
{ q: '수정 요청은 몇 번까지 가능한가요?', a: '합의한 범위 안에서는 2회까지 무상으로 수정해 드립니다. 범위를 벗어나는 기능 추가나 방향 전환은 별도로 협의해 진행합니다. 무엇이 범위 안/밖인지는 착수 전 견적에 미리 명시합니다.' },
|
||||
{ q: '소스코드도 제공되나요?', a: '제공됩니다. 잔금 완납 시 전체 소스코드와 배포·실행 문서를 함께 전달합니다. 직접 운영하시거나 다른 개발자에게 이어 맡기셔도 문제없도록 인도합니다.' },
|
||||
{ q: '납품 후 유지보수는요?', a: '납품일로부터 30일간 결함·오류를 무상으로 하자보수합니다. 이후 기능 추가나 지속 운영이 필요하면 월 단위 유지보수 계약으로 이어갈 수 있습니다.' },
|
||||
];
|
||||
|
||||
function ArrowRight() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 5 7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Eyebrow({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OutsourcingPage() {
|
||||
return (
|
||||
<>
|
||||
{/* ─────────────────── 1. HERO ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto grid max-w-6xl items-center gap-12 px-6 pt-20 pb-16 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:pt-28 lg:pb-24">
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
|
||||
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
|
||||
outsourcing
|
||||
</span>
|
||||
<h1
|
||||
className="mt-6 font-extrabold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
|
||||
>
|
||||
맞춤 소프트웨어
|
||||
<br />
|
||||
외주 개발
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
|
||||
</h1>
|
||||
<p className="mt-7 max-w-xl break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
기획 정리가 안 됐어도 괜찮습니다. 상담에서 함께 정리합니다.
|
||||
</p>
|
||||
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href="#contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 font-semibold text-white transition-colors duration-200 hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
의뢰 내용 보내기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
<Link
|
||||
href="#showcase"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 font-semibold transition-colors duration-200 hover:bg-[var(--jsm-surface-alt)]"
|
||||
style={{ color: 'var(--jsm-ink)', borderColor: 'var(--jsm-line)', ...KOR_BODY }}
|
||||
>
|
||||
작업 화면 보기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lg:pl-4">
|
||||
<MockWindow title="telegram-bot.log">
|
||||
<FeedMock />
|
||||
</MockWindow>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 2. SHOWCASE (풀 그리드) ─────────────────── */}
|
||||
<section id="showcase" className="scroll-mt-20" style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
{/* 하위 호환: 기존 /outsourcing#portfolio 링크 앵커 유지 */}
|
||||
<div id="portfolio" className="scroll-mt-20" />
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>showcase</Eyebrow>
|
||||
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
우리가 만드는 화면들
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
<div className="mt-12">
|
||||
<ShowcaseGrid slots={SHOWCASE_SLOTS} variant="full" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 3. 운영 실사례 ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>in production</Eyebrow>
|
||||
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
직접 개발하고, 실제로 굴러가는 결과물
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
운영 중인 서비스와 납품 완료 프로젝트입니다. 의뢰하신 프로젝트도 같은 깊이로 만듭니다.
|
||||
</p>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{CASES.map((c, i) => (
|
||||
<ScrollReveal key={c.t} delay={i * 80}>
|
||||
<div className="flex h-full flex-col rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="mb-5 inline-flex items-center gap-1.5 self-start rounded-full px-2.5 py-1 text-[11px] font-semibold"
|
||||
style={c.live ? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' } : { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }}
|
||||
>
|
||||
{c.live && <span className="h-1.5 w-1.5 rounded-full" style={{ background: 'var(--jsm-accent)' }} />}
|
||||
{c.cat}
|
||||
</span>
|
||||
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{c.t}
|
||||
</h3>
|
||||
<p className="mt-2.5 flex-1 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{c.d}
|
||||
</p>
|
||||
<div className="mt-5 flex flex-wrap gap-1.5">
|
||||
{c.tags.map((tag) => (
|
||||
<span key={tag} className="rounded px-2.5 py-1 text-xs" style={{ color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)', ...KOR_BODY }}>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 4a. 제공 분야 ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>scope</Eyebrow>
|
||||
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
이런 것들을 만들어 드립니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{FIELDS.map((f, i) => (
|
||||
<ScrollReveal key={f.t} delay={i * 80}>
|
||||
<div className="h-full rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<h3 className="break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{f.t}
|
||||
</h3>
|
||||
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{f.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 4b. 진행 프로세스 ─────────────────── */}
|
||||
<section id="process" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>process</Eyebrow>
|
||||
<h2 className="max-w-2xl break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
상담부터 하자보수까지, 흐름이 분명합니다
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{PROCESS.map((s, i) => (
|
||||
<ScrollReveal key={s.n} delay={i * 80}>
|
||||
<div className="relative h-full rounded-2xl border p-7 lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="relative z-10 inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
|
||||
>
|
||||
{s.n}
|
||||
</span>
|
||||
<h3 className="mt-5 break-keep text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{s.t}
|
||||
</h3>
|
||||
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{s.d}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 5. FAQ ─────────────────── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-3xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>faq</Eyebrow>
|
||||
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
자주 묻는 질문
|
||||
</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<div className="mt-12 space-y-3">
|
||||
{FAQ.map((item, i) => (
|
||||
<ScrollReveal key={item.q} delay={i * 80}>
|
||||
<details className="group overflow-hidden rounded-2xl border" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 break-keep px-6 py-5 font-semibold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{item.q}
|
||||
<svg className="shrink-0 transition-transform duration-200 group-open:rotate-45" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" aria-hidden style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</summary>
|
||||
<p className="break-keep px-6 pb-5 text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{item.a}
|
||||
</p>
|
||||
</details>
|
||||
</ScrollReveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─────────────────── 6. 의뢰 폼 ─────────────────── */}
|
||||
<section id="contact" className="scroll-mt-20" style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-20 lg:px-8 lg:py-28">
|
||||
<div className="grid gap-10 lg:grid-cols-5 lg:gap-12">
|
||||
{/* 안내 */}
|
||||
<div className="lg:col-span-2">
|
||||
<ScrollReveal>
|
||||
<Eyebrow>contact</Eyebrow>
|
||||
<h2 className="break-keep text-3xl font-bold leading-tight lg:text-[2.4rem]" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
프로젝트 문의
|
||||
</h2>
|
||||
<p className="mt-5 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
영업일 2일 내에 회신드립니다. 아이디어 단계여도 괜찮습니다 — 상담에서 방향을 함께 잡아드립니다.
|
||||
</p>
|
||||
<div className="mt-8 space-y-3 border-t pt-8" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<a href="mailto:bgg8988@gmail.com" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Mail</span>
|
||||
bgg8988@gmail.com
|
||||
</a>
|
||||
<a href="tel:010-3907-1392" className="flex items-center gap-3 text-sm transition-colors hover:text-[var(--jsm-ink)]" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
<span className="w-12 font-mono text-xs uppercase tracking-wider" style={{ color: 'var(--jsm-accent)' }}>Tel</span>
|
||||
010-3907-1392
|
||||
</a>
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<div className="lg:col-span-3">
|
||||
<ScrollReveal delay={100}>
|
||||
<div className="rounded-2xl border p-6 shadow-[0_24px_60px_-32px_rgba(15,23,42,0.3)] lg:p-8" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<OutsourcingRequestForm />
|
||||
</div>
|
||||
</ScrollReveal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'SaaS 제품 · 월 구독 패키지',
|
||||
description:
|
||||
'현직 엔지니어가 실제 운영하며 검증한 자동화를 월 구독 SaaS 제품으로 제공합니다. 첫 제품 준비 중 — 출시 알림을 신청하세요.',
|
||||
keywords: ['SaaS', '자동화 구독', '월 구독 자동화', 'AI 자동화 제품', '쟁승메이드'],
|
||||
openGraph: {
|
||||
title: 'SaaS 제품 · 월 구독 패키지 | 쟁승메이드',
|
||||
description:
|
||||
'검증된 자동화를 SaaS로. 현직 엔지니어가 직접 운영·검증한 자동화 제품 카탈로그.',
|
||||
url: 'https://jaengseung-made.com/packages',
|
||||
},
|
||||
};
|
||||
|
||||
export default function PackagesLayout({ children }: { children: React.ReactNode }) {
|
||||
return children;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1032
app/page.tsx
1032
app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -1,62 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
function FailContent() {
|
||||
const params = useSearchParams();
|
||||
const message = params.get('message') ?? '결제가 취소되었거나 실패했습니다.';
|
||||
const code = params.get('code') ?? '';
|
||||
|
||||
return (
|
||||
<div className="text-center py-20 px-6">
|
||||
<div className="w-16 h-16 rounded-full bg-slate-100 border-2 border-slate-200 flex items-center justify-center mx-auto mb-5">
|
||||
<svg className="w-8 h-8 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="inline-block bg-slate-100 border border-slate-200 text-slate-600 text-xs font-bold px-3 py-1 rounded-full mb-4">
|
||||
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제 취소' : '결제 실패'}
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-[#04102b] mb-2">
|
||||
{code === 'USER_CANCEL' || code === 'PAY_PROCESS_CANCELED' ? '결제를 취소하셨습니다' : '결제에 실패했습니다'}
|
||||
</h2>
|
||||
<p className="text-slate-500 text-sm mb-8 max-w-xs mx-auto leading-relaxed">{message}</p>
|
||||
<div className="flex justify-center gap-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
|
||||
>
|
||||
다시 시도하기
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
|
||||
>
|
||||
홈으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentFailPage() {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
|
||||
<div className="bg-[#04102b] px-6 py-4" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#1a56db] flex items-center justify-center text-white font-bold text-xs">
|
||||
쟁
|
||||
</div>
|
||||
<span className="text-white font-bold text-sm">쟁승메이드 결제</span>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={<div className="py-20 text-center text-slate-400 text-sm">로딩 중...</div>}>
|
||||
<FailContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
|
||||
function SuccessContent() {
|
||||
const params = useSearchParams();
|
||||
const paymentId = params.get('paymentId');
|
||||
|
||||
return (
|
||||
<div className="text-center py-20 px-6">
|
||||
<div className="w-16 h-16 rounded-full bg-emerald-50 border-2 border-emerald-400 flex items-center justify-center mx-auto mb-5">
|
||||
<svg className="w-8 h-8 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="inline-block bg-emerald-50 border border-emerald-200 text-emerald-700 text-xs font-bold px-3 py-1 rounded-full mb-4">
|
||||
결제 완료
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-[#04102b] mb-2">결제가 완료되었습니다!</h2>
|
||||
{paymentId && (
|
||||
<p className="text-slate-400 text-xs mb-1">주문번호: {paymentId}</p>
|
||||
)}
|
||||
<p className="text-slate-500 text-sm mb-8">
|
||||
마이페이지에서 결제 내역과 서비스 이용 현황을 확인하세요.
|
||||
</p>
|
||||
<div className="flex justify-center gap-3 flex-wrap">
|
||||
<Link
|
||||
href="/mypage?tab=payments"
|
||||
className="inline-flex items-center gap-2 bg-[#1a56db] hover:bg-[#1e4fc2] text-white px-6 py-3 rounded-xl font-semibold text-sm shadow-lg shadow-blue-600/20 transition"
|
||||
>
|
||||
결제 내역 확인 →
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center gap-2 bg-white border border-[#dbe8ff] text-slate-600 px-6 py-3 rounded-xl font-semibold text-sm hover:bg-slate-50 transition"
|
||||
>
|
||||
홈으로
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentSuccessPage() {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center px-6 py-16">
|
||||
<div className="w-full max-w-md bg-white rounded-2xl border border-[#dbe8ff] shadow-lg overflow-hidden">
|
||||
<div className="bg-[#04102b] px-6 py-4" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-[#1a56db] flex items-center justify-center text-white font-bold text-xs">
|
||||
쟁
|
||||
</div>
|
||||
<span className="text-white font-bold text-sm">쟁승메이드 결제</span>
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="py-20 text-center">
|
||||
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin mx-auto" />
|
||||
</div>
|
||||
}>
|
||||
<SuccessContent />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,69 +23,78 @@ export default async function PortfolioGateway({ params }: Props) {
|
||||
const expires = new Date(payload.exp).toLocaleDateString('ko-KR');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white">
|
||||
<section
|
||||
className="relative overflow-hidden px-6 py-20 lg:px-14 lg:py-28"
|
||||
style={{
|
||||
background:
|
||||
'radial-gradient(circle at 30% 20%, #1e293b 0%, #020617 55%)',
|
||||
}}
|
||||
>
|
||||
<div className="min-h-screen" style={{ background: 'var(--jsm-bg)' }}>
|
||||
{/* 헤더 배너 — jsm-navy 사용 (푸터/다크 섹션 전용 토큰) */}
|
||||
<div className="px-6 py-4" style={{ background: 'var(--jsm-navy)' }}>
|
||||
<div className="max-w-4xl mx-auto flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-bold text-sm" style={{ background: 'var(--jsm-accent)' }}>
|
||||
쟁
|
||||
</div>
|
||||
<span className="text-white font-bold text-sm">쟁승메이드</span>
|
||||
<span className="ml-auto font-mono text-xs tracking-[0.2em] uppercase px-3 py-1 rounded-full border" style={{ color: 'rgba(255,255,255,0.7)', borderColor: 'rgba(255,255,255,0.2)', background: 'rgba(255,255,255,0.08)' }}>
|
||||
Private · {payload.memo || 'Confidential'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="px-6 py-16 lg:px-14 lg:py-24">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-400 animate-pulse" />
|
||||
<span className="font-mono text-xs text-emerald-300/80 tracking-[0.25em] uppercase">
|
||||
Private Portfolio · {payload.memo || 'Confidential'}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="inline-flex h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
개인 공유 포트폴리오
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-extrabold leading-[1.05] mb-6" style={{ wordBreak: 'keep-all' }}>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold leading-tight mb-4" style={{ color: 'var(--jsm-ink)', wordBreak: 'keep-all' }}>
|
||||
박재오
|
||||
<br />
|
||||
<span className="bg-gradient-to-r from-sky-300 via-blue-200 to-cyan-300 bg-clip-text text-transparent">
|
||||
외주 개발 포트폴리오
|
||||
</span>
|
||||
<span className="gradient-text">외주 개발 포트폴리오</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-slate-300 text-lg leading-relaxed max-w-2xl mb-10" style={{ wordBreak: 'keep-all' }}>
|
||||
<p className="text-lg leading-relaxed max-w-2xl mb-4" style={{ color: 'var(--jsm-ink-soft)', wordBreak: 'keep-all' }}>
|
||||
현직 실무 엔지니어 · 계약서 우선 · 납기 패널티 보장 · 소스코드 100% 인도.
|
||||
본 페이지는 {expires}까지 유효한 개별 공유 링크입니다.
|
||||
</p>
|
||||
<p className="text-sm mb-10 font-mono px-3 py-2 rounded-lg inline-block" style={{ color: 'var(--jsm-ink-faint)', background: 'var(--jsm-surface-alt)', border: '1px solid var(--jsm-line)' }}>
|
||||
이 링크는 {expires}까지 유효합니다
|
||||
</p>
|
||||
|
||||
<div className="grid sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/freelance"
|
||||
className="group border border-white/10 hover:border-sky-400/50 rounded-2xl p-6 bg-white/[0.02] hover:bg-white/[0.05] transition-all"
|
||||
className="group rounded-2xl p-6 transition-all hover:-translate-y-1"
|
||||
style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}
|
||||
>
|
||||
<p className="font-mono text-xs text-sky-300/70 uppercase tracking-widest mb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: 'var(--jsm-accent)' }}>
|
||||
Freelance
|
||||
</p>
|
||||
<h3 className="text-xl font-extrabold mb-2">외주 개발 · 전체 소개</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
<h3 className="text-xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}>외주 개발 · 전체 소개</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
계약 프로세스, 납기 패널티, 포트폴리오 사례, 견적 문의.
|
||||
</p>
|
||||
<span className="inline-block mt-4 text-sm font-bold text-sky-300 group-hover:underline">
|
||||
<span className="inline-block mt-4 text-sm font-bold group-hover:underline" style={{ color: 'var(--jsm-accent)' }}>
|
||||
자세히 보기 →
|
||||
</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/services/website"
|
||||
className="group border border-white/10 hover:border-violet-400/50 rounded-2xl p-6 bg-white/[0.02] hover:bg-white/[0.05] transition-all"
|
||||
className="group rounded-2xl p-6 transition-all hover:-translate-y-1"
|
||||
style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}
|
||||
>
|
||||
<p className="font-mono text-xs text-violet-300/70 uppercase tracking-widest mb-2">
|
||||
<p className="text-xs font-bold uppercase tracking-widest mb-2" style={{ color: '#7c3aed' }}>
|
||||
Website
|
||||
</p>
|
||||
<h3 className="text-xl font-extrabold mb-2">홈페이지·쇼핑몰 제작</h3>
|
||||
<p className="text-sm text-slate-400 leading-relaxed">
|
||||
<h3 className="text-xl font-extrabold mb-2" style={{ color: 'var(--jsm-ink)' }}>홈페이지·쇼핑몰 제작</h3>
|
||||
<p className="text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
Next.js 기반 반응형 웹, SEO 기본, 3개월 유지보수 포함.
|
||||
</p>
|
||||
<span className="inline-block mt-4 text-sm font-bold text-violet-300 group-hover:underline">
|
||||
<span className="inline-block mt-4 text-sm font-bold group-hover:underline" style={{ color: '#7c3aed' }}>
|
||||
자세히 보기 →
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 text-xs text-slate-500 font-mono">
|
||||
<div className="mt-10 text-xs font-mono" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
© 쟁승메이드 · 010-3907-1392 · bgg8988@gmail.com
|
||||
</div>
|
||||
</div>
|
||||
|
||||
36
app/products/[id]/BuySection.tsx
Normal file
36
app/products/[id]/BuySection.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import BankTransferModal from '@/app/components/BankTransferModal';
|
||||
|
||||
// 상세 페이지의 구매 버튼 + 모달 트리거 (클라이언트 경계).
|
||||
// 서버 페이지가 product 요약만 넘겨주고, 주문 금액은 서버(API)가 DB로 확정한다.
|
||||
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
interface Props {
|
||||
product: { id: string; name: string; price: number };
|
||||
}
|
||||
|
||||
export default function BuySection({ product }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center justify-center w-full sm:w-auto px-8 py-3.5 rounded-lg text-sm font-semibold transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', color: '#ffffff', ...KOR_BODY }}
|
||||
>
|
||||
구매하기
|
||||
</button>
|
||||
|
||||
<BankTransferModal
|
||||
product={product}
|
||||
isOpen={open}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
171
app/products/[id]/page.tsx
Normal file
171
app/products/[id]/page.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getProductById, type ProductRow } from '@/lib/supabase/product-files';
|
||||
import BuySection from './BuySection';
|
||||
|
||||
// 완성 소프트웨어 상세 (서버 컴포넌트).
|
||||
// 비노출/비활성/존재하지 않음/DB 예외 → notFound() 로 일관 처리해 500을 내지 않는다.
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
async function loadProduct(id: string): Promise<ProductRow | null> {
|
||||
try {
|
||||
return await getProductById(createAdminClient(), id);
|
||||
} catch (err) {
|
||||
// DB 장애·마이그레이션 미적용 등 — 상세 페이지는 404로 폴백
|
||||
console.error('[ProductDetail] getProductById failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const product = await loadProduct(id);
|
||||
if (!product || !product.is_listed || !product.is_active) {
|
||||
return { title: '완성 소프트웨어' };
|
||||
}
|
||||
return {
|
||||
title: product.name,
|
||||
description:
|
||||
product.description ??
|
||||
`${product.name} — 쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어. 입금 확인 후 마이페이지에서 즉시 다운로드.`,
|
||||
};
|
||||
}
|
||||
|
||||
function CheckMark() {
|
||||
return (
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="shrink-0 mt-0.5"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: Props) {
|
||||
const { id } = await params;
|
||||
const product = await loadProduct(id);
|
||||
|
||||
if (!product || !product.is_listed || !product.is_active) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const features = product.features ?? [];
|
||||
const longText = product.description_long ?? product.description ?? '';
|
||||
|
||||
return (
|
||||
<section style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-14 lg:py-20">
|
||||
{/* 브레드크럼 */}
|
||||
<nav className="mb-8" aria-label="breadcrumb">
|
||||
<Link
|
||||
href="/products"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium transition-colors hover:text-[var(--jsm-accent)]"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
소프트웨어
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* 제품명 · 가격 */}
|
||||
<header className="pb-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl lg:text-4xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{product.name}
|
||||
</h1>
|
||||
<p
|
||||
className="mt-4 text-2xl font-bold"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
₩{product.price.toLocaleString('ko-KR')}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* 상세 설명 */}
|
||||
{longText && (
|
||||
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<p
|
||||
className="text-base leading-relaxed break-keep whitespace-pre-line"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{longText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기능 리스트 */}
|
||||
{features.length > 0 && (
|
||||
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<h2
|
||||
className="text-sm font-semibold mb-4 uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
주요 기능
|
||||
</h2>
|
||||
<ul className="space-y-3">
|
||||
{features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2.5 text-sm sm:text-base break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>
|
||||
<CheckMark />
|
||||
</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구매 안내 + CTA */}
|
||||
<div className="pt-8">
|
||||
<div
|
||||
className="rounded-lg border px-4 py-3.5 mb-6"
|
||||
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
구매 후 마이페이지에서 즉시 다운로드 (입금 확인 후).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<BuySection
|
||||
product={{ id: product.id, name: product.name, price: product.price }}
|
||||
/>
|
||||
|
||||
<p className="mt-5 text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||
구매 전{' '}
|
||||
<Link href="/legal/refund" className="underline" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
환불 정책
|
||||
</Link>
|
||||
을 확인해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
203
app/products/page.tsx
Normal file
203
app/products/page.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
import Link from 'next/link';
|
||||
import type { Metadata } from 'next';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import { getListedProducts, type ProductRow } from '@/lib/supabase/product-files';
|
||||
|
||||
// 완성 소프트웨어 동적 카탈로그 (서버 컴포넌트). 라이트 고craft — 홈·외주와 동일 언어.
|
||||
// DB 장애·마이그레이션 미적용 시 빈 배열로 폴백해 페이지가 항상 200으로 생존한다.
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '완성 소프트웨어',
|
||||
description:
|
||||
'쟁승메이드가 직접 운영하며 검증한 완성 소프트웨어 목록. 계좌이체 결제 후 입금 확인 즉시 마이페이지에서 다운로드할 수 있습니다.',
|
||||
};
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
const HOW = [
|
||||
{ n: '01', t: '계좌이체 신청', d: '구매할 도구를 고르고 입금자명과 함께 신청합니다.' },
|
||||
{ n: '02', t: '입금 확인', d: '입금이 확인되면 승인합니다. 최대 24시간 내 처리됩니다.' },
|
||||
{ n: '03', t: '마이페이지 다운로드', d: '마이페이지의 내 제품에서 파일을 바로 내려받습니다.' },
|
||||
];
|
||||
|
||||
function ArrowRight() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 5 7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckMark() {
|
||||
return (
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="mt-0.5 shrink-0" aria-hidden>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadProducts(): Promise<ProductRow[]> {
|
||||
try {
|
||||
return await getListedProducts(createAdminClient());
|
||||
} catch (err) {
|
||||
console.error('[Products] getListedProducts failed, falling back to empty:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductsPage() {
|
||||
const products = await loadProducts();
|
||||
const hasProducts = products.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* ─── Hero ─── */}
|
||||
<section style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 pt-20 pb-16 lg:px-8 lg:pt-28 lg:pb-20">
|
||||
<div className="max-w-2xl">
|
||||
<span className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
|
||||
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
|
||||
software
|
||||
</span>
|
||||
<h1
|
||||
className="mt-6 font-extrabold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
|
||||
>
|
||||
직접 운영하며 검증한 도구를
|
||||
<br />
|
||||
그대로 가져가세요
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
|
||||
</h1>
|
||||
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
입금 확인 후 마이페이지에서 바로 다운로드할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 카탈로그 / 준비 중 ─── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
{hasProducts ? (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{products.map((p) => {
|
||||
const features = (p.features ?? []).slice(0, 3);
|
||||
return (
|
||||
<Link
|
||||
key={p.id}
|
||||
href={`/products/${p.id}`}
|
||||
className="group flex flex-col rounded-2xl border p-7 transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-[var(--jsm-accent)] hover:shadow-[0_24px_60px_-32px_rgba(15,23,42,0.4)] lg:p-8"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<h2 className="break-keep text-xl font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{p.name}
|
||||
</h2>
|
||||
{p.description && (
|
||||
<p className="mt-2.5 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{p.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{features.length > 0 && (
|
||||
<ul className="mt-5 space-y-2">
|
||||
{features.map((f) => (
|
||||
<li key={f} className="flex items-start gap-2 break-keep text-sm" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
<span style={{ color: 'var(--jsm-accent)' }}>
|
||||
<CheckMark />
|
||||
</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between border-t pt-5" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<span className="text-lg font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
₩{p.price.toLocaleString('ko-KR')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-sm font-semibold transition-colors duration-150 group-hover:text-[var(--jsm-accent-hover)]" style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}>
|
||||
자세히 보기
|
||||
<ArrowRight />
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border px-8 py-14 text-center lg:py-16" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
|
||||
coming soon
|
||||
</p>
|
||||
<h2 className="break-keep text-2xl font-bold lg:text-3xl" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
현재 상품을 정비하고 있습니다
|
||||
</h2>
|
||||
<p className="mx-auto mt-4 max-w-md break-keep leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
로또 분석 도구, 주식 자동매매 유틸리티 등 실제로 운영 중인 도구들을 구매 가능한
|
||||
형태로 순차 공개할 예정입니다. 출시 소식을 먼저 받고 싶다면 아래 링크로 문의해
|
||||
주세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── 구매 방식 안내 ─── */}
|
||||
<section style={{ background: 'var(--jsm-surface)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-24">
|
||||
<p className="mb-3 font-mono text-[11px] uppercase tracking-[0.22em]" style={{ color: 'var(--jsm-accent)' }}>
|
||||
how to buy
|
||||
</p>
|
||||
<h2 className="break-keep text-3xl font-bold lg:text-[2.6rem] lg:leading-[1.12]" style={{ color: 'var(--jsm-ink)', letterSpacing: '-0.03em' }}>
|
||||
구매 방식
|
||||
</h2>
|
||||
<div className="mt-12 grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
{HOW.map((step) => (
|
||||
<div key={step.n} className="rounded-2xl border p-7" style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="inline-flex h-12 w-12 items-center justify-center rounded-full font-mono text-sm font-bold"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-surface)', boxShadow: 'inset 0 0 0 1px var(--jsm-line)' }}
|
||||
>
|
||||
{step.n}
|
||||
</span>
|
||||
<p className="mt-5 break-keep font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{step.t}
|
||||
</p>
|
||||
<p className="mt-2 break-keep text-sm leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{step.d}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ─── CTA ─── */}
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-6xl px-6 py-16 lg:px-8 lg:py-20">
|
||||
<div className="flex flex-col gap-4 sm:flex-row">
|
||||
<Link
|
||||
href="/outsourcing#contact"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3.5 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
{hasProducts ? '맞춤 개발 문의' : '출시 소식 받기'}
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
<Link
|
||||
href="/outsourcing"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg border px-6 py-3.5 text-sm font-semibold transition-colors hover:bg-[var(--jsm-surface)]"
|
||||
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink)', background: 'var(--jsm-surface)', ...KOR_BODY }}
|
||||
>
|
||||
외주 개발 알아보기
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -20,8 +20,8 @@ interface Quote {
|
||||
}
|
||||
|
||||
const CATEGORY_COLORS: Record<string, string> = {
|
||||
기획: '#60a5fa', 디자인: '#f472b6', 개발: '#34d399', 인프라: '#fb923c',
|
||||
유지보수: '#a78bfa', 기타: '#94a3b8',
|
||||
기획: '#2563eb', 디자인: '#db2777', 개발: '#059669', 인프라: '#ea580c',
|
||||
유지보수: '#7c3aed', 기타: '#64748b',
|
||||
};
|
||||
|
||||
export default function QuotePage() {
|
||||
@@ -37,6 +37,8 @@ export default function QuotePage() {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'wbs' | 'quote' | 'maintenance'>('overview');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [rejected, setRejected] = useState(false);
|
||||
const [alreadyProcessed, setAlreadyProcessed] = useState(false);
|
||||
const [isPrinting, setIsPrinting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,21 +91,37 @@ export default function QuotePage() {
|
||||
if (!quote) return;
|
||||
setSubmitting(true);
|
||||
const selectedItems = quote.items.filter((i) => !i.optional || checkedOptional[i.id]).map((i) => i.id);
|
||||
await fetch(`/api/quote/${token}`, {
|
||||
const res = await fetch(`/api/quote/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selectedItems, selectedMaintenance, total: grandTotal }),
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.status === 409) { setAlreadyProcessed(true); return; }
|
||||
setSubmitted(true);
|
||||
}
|
||||
|
||||
async function handleReject() {
|
||||
if (!quote) return;
|
||||
const confirmed = window.confirm('견적을 거절하시겠습니까? 조건 조정이 필요하시면 회신으로 말씀해 주세요.');
|
||||
if (!confirmed) return;
|
||||
setSubmitting(true);
|
||||
const res = await fetch(`/api/quote/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'reject' }),
|
||||
});
|
||||
setSubmitting(false);
|
||||
if (res.status === 409) { setAlreadyProcessed(true); return; }
|
||||
setRejected(true);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ width: 40, height: 40, border: '3px solid rgba(99,102,241,0.3)', borderTopColor: '#6366f1', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px' }} />
|
||||
<p style={{ color: '#475569', fontFamily: 'sans-serif' }}>견적서를 불러오는 중...</p>
|
||||
<div style={{ width: 40, height: 40, border: '3px solid var(--jsm-accent-soft)', borderTopColor: 'var(--jsm-accent)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 16px' }} />
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif' }}>견적서를 불러오는 중...</p>
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
@@ -112,35 +130,59 @@ export default function QuotePage() {
|
||||
|
||||
if (notFound || !quote) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16 }}>
|
||||
<div style={{ fontSize: 64 }}>🔍</div>
|
||||
<h1 style={{ color: 'white', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif' }}>견적서를 찾을 수 없습니다</h1>
|
||||
<p style={{ color: '#475569', fontFamily: 'sans-serif' }}>링크가 만료되었거나 잘못된 주소입니다</p>
|
||||
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif' }}>견적서를 찾을 수 없습니다</h1>
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif' }}>링크가 만료되었거나 잘못된 주소입니다</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: '#0a0f1e', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
|
||||
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
|
||||
<style>{`@keyframes pop { 0% { transform: scale(0.5); opacity: 0; } 70% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }`}</style>
|
||||
<div style={{ fontSize: 80, animation: 'pop 0.5s ease forwards' }}>🎉</div>
|
||||
<h1 style={{ color: 'white', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}>견적을 수락해 주셨습니다!</h1>
|
||||
<p style={{ color: '#94a3b8', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
|
||||
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}>견적을 수락해 주셨습니다!</h1>
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
|
||||
담당자가 확인 후 빠른 시일 내에 연락드리겠습니다.<br />
|
||||
선택하신 내용을 기반으로 계약을 진행합니다.
|
||||
</p>
|
||||
<div style={{ background: '#0f172a', border: '1px solid rgba(99,102,241,0.3)', borderRadius: 16, padding: '24px 32px', textAlign: 'center' }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: 14, fontFamily: 'sans-serif', marginBottom: 8 }}>최종 견적 금액</div>
|
||||
<div style={{ color: 'white', fontSize: 36, fontWeight: 800, fontFamily: 'sans-serif' }}>{grandTotal.toLocaleString()}원</div>
|
||||
<div style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-accent-soft)', borderRadius: 16, padding: '24px 32px', textAlign: 'center', boxShadow: '0 4px 20px rgba(29,78,216,0.08)' }}>
|
||||
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, fontFamily: 'sans-serif', marginBottom: 8 }}>최종 견적 금액</div>
|
||||
<div style={{ color: 'var(--jsm-ink)', fontSize: 36, fontWeight: 800, fontFamily: 'sans-serif' }}>{grandTotal.toLocaleString()}원</div>
|
||||
{maintenanceTotal > 0 && (
|
||||
<div style={{ color: '#6366f1', fontSize: 14, fontFamily: 'sans-serif', marginTop: 6 }}>+ 유지보수 {maintenanceTotal.toLocaleString()}원/월</div>
|
||||
<div style={{ color: 'var(--jsm-accent)', fontSize: 14, fontFamily: 'sans-serif', marginTop: 6 }}>+ 유지보수 {maintenanceTotal.toLocaleString()}원/월</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (rejected) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 20, padding: 24 }}>
|
||||
<style>{`@keyframes pop { 0% { transform: scale(0.5); opacity: 0; } 70% { transform: scale(1.1); } 100% { transform: scale(1); opacity: 1; } }`}</style>
|
||||
<div style={{ fontSize: 80, animation: 'pop 0.5s ease forwards' }}>🙏</div>
|
||||
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 28, fontWeight: 800, fontFamily: 'sans-serif', textAlign: 'center' }}>의견 감사합니다</h1>
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center', lineHeight: 1.7 }}>
|
||||
조건 조정이 필요하시면 언제든 회신 주세요.<br />
|
||||
더 나은 견적으로 다시 찾아뵙겠습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (alreadyProcessed) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 16, padding: 24 }}>
|
||||
<div style={{ fontSize: 64 }}>📋</div>
|
||||
<h1 style={{ color: 'var(--jsm-ink)', fontSize: 24, fontWeight: 700, fontFamily: 'sans-serif', textAlign: 'center' }}>이미 처리된 견적입니다</h1>
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontFamily: 'sans-serif', textAlign: 'center' }}>이 견적은 이미 수락 또는 거절 처리되었습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ key: 'overview', label: '개요' },
|
||||
{ key: 'wbs', label: 'WBS', show: quote.wbs.length > 0 },
|
||||
@@ -149,13 +191,12 @@ export default function QuotePage() {
|
||||
].filter((t) => t.show !== false);
|
||||
|
||||
return (
|
||||
<div style={{ background: '#0a0f1e', minHeight: '100vh', color: 'white', fontFamily: "'Pretendard', 'Noto Sans KR', sans-serif" }}>
|
||||
<div style={{ background: 'var(--jsm-bg)', minHeight: '100vh', color: 'var(--jsm-ink)', fontFamily: "'Pretendard Variable', Pretendard, 'Noto Sans KR', sans-serif" }}>
|
||||
<style>{`
|
||||
@keyframes fadeUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes shimmer { from { background-position: -200% 0; } to { background-position: 200% 0; } }
|
||||
* { box-sizing: border-box; }
|
||||
input[type=checkbox] { accent-color: #6366f1; width: 18px; height: 18px; cursor: pointer; }
|
||||
input[type=radio] { accent-color: #6366f1; width: 18px; height: 18px; cursor: pointer; }
|
||||
input[type=checkbox] { accent-color: var(--jsm-accent); width: 18px; height: 18px; cursor: pointer; }
|
||||
input[type=radio] { accent-color: var(--jsm-accent); width: 18px; height: 18px; cursor: pointer; }
|
||||
@media print {
|
||||
html, body { height: auto !important; min-height: 0 !important; overflow: visible !important; background: white !important; color: #1e293b !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
* { overflow: visible !important; }
|
||||
@@ -173,17 +214,17 @@ export default function QuotePage() {
|
||||
`}</style>
|
||||
|
||||
{/* 헤더 */}
|
||||
<div style={{ background: 'linear-gradient(180deg, #0f172a 0%, #0a0f1e 100%)', borderBottom: '1px solid rgba(255,255,255,0.06)', padding: '32px 24px 0' }}>
|
||||
<div style={{ background: 'var(--jsm-navy)', borderBottom: '1px solid rgba(255,255,255,0.08)', padding: '32px 24px 0' }}>
|
||||
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||||
{/* 브랜드 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 32 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700 }}>쟁</div>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--jsm-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16, fontWeight: 700, color: 'white' }}>쟁</div>
|
||||
<div>
|
||||
<div style={{ color: 'white', fontWeight: 700, fontSize: 14 }}>쟁승메이드</div>
|
||||
<div style={{ color: '#475569', fontSize: 11 }}>jaengseung-made.com</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.45)', fontSize: 11 }}>jaengseung-made.com</div>
|
||||
</div>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ background: 'rgba(99,102,241,0.15)', border: '1px solid rgba(99,102,241,0.3)', color: '#818cf8', fontSize: 11, fontWeight: 600, padding: '4px 12px', borderRadius: 100 }}>
|
||||
<span style={{ background: 'rgba(255,255,255,0.12)', border: '1px solid rgba(255,255,255,0.2)', color: 'rgba(255,255,255,0.85)', fontSize: 11, fontWeight: 600, padding: '4px 12px', borderRadius: 100 }}>
|
||||
공식 견적서
|
||||
</span>
|
||||
<button
|
||||
@@ -197,13 +238,13 @@ export default function QuotePage() {
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6,
|
||||
background: 'rgba(255,255,255,0.08)', border: '1px solid rgba(255,255,255,0.12)',
|
||||
color: '#cbd5e1', fontSize: 13, fontWeight: 600,
|
||||
background: 'rgba(255,255,255,0.1)', border: '1px solid rgba(255,255,255,0.18)',
|
||||
color: 'rgba(255,255,255,0.8)', fontSize: 13, fontWeight: 600,
|
||||
padding: '6px 14px', borderRadius: 8, cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.14)'; e.currentTarget.style.color = 'white'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = '#cbd5e1'; }}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.18)'; e.currentTarget.style.color = 'white'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; e.currentTarget.style.color = 'rgba(255,255,255,0.8)'; }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
|
||||
@@ -220,29 +261,29 @@ export default function QuotePage() {
|
||||
</h1>
|
||||
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
{quote.client_name && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
|
||||
<span>👤</span> {quote.client_name} 고객님
|
||||
</div>
|
||||
)}
|
||||
{quote.valid_until && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
|
||||
<span>📅</span> 유효기간: {quote.valid_until.slice(0, 10)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: '#94a3b8', fontSize: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'rgba(255,255,255,0.6)', fontSize: 14 }}>
|
||||
<span>📄</span> 발행일: {new Date(quote.created_at).toLocaleDateString('ko-KR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="no-print" style={{ display: isPrinting ? 'none' : 'flex', gap: 0, borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<div className="no-print" style={{ display: isPrinting ? 'none' : 'flex', gap: 0, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||
{tabs.map((t) => (
|
||||
<button key={t.key} onClick={() => setActiveTab(t.key as typeof activeTab)}
|
||||
style={{
|
||||
padding: '12px 20px', fontSize: 14, fontWeight: 500, border: 'none', cursor: 'pointer',
|
||||
background: 'none', color: activeTab === t.key ? '#818cf8' : '#64748b',
|
||||
borderBottom: `2px solid ${activeTab === t.key ? '#6366f1' : 'transparent'}`,
|
||||
background: 'none', color: activeTab === t.key ? 'white' : 'rgba(255,255,255,0.5)',
|
||||
borderBottom: `2px solid ${activeTab === t.key ? 'var(--jsm-accent)' : 'transparent'}`,
|
||||
transition: 'all 0.2s', marginBottom: -1,
|
||||
}}>
|
||||
{t.label}
|
||||
@@ -255,10 +296,10 @@ export default function QuotePage() {
|
||||
{/* 만료 배너 */}
|
||||
{isExpired && (
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', padding: '16px 24px 0' }}>
|
||||
<div style={{ background: 'rgba(245,158,11,0.1)', border: '1px solid rgba(245,158,11,0.3)', borderRadius: 12, padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<div style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.3)', borderRadius: 12, padding: '14px 20px', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 18 }}>⚠</span>
|
||||
<div>
|
||||
<div style={{ color: '#f59e0b', fontWeight: 700, fontSize: 14 }}>이 견적서는 만료되었습니다</div>
|
||||
<div style={{ color: '#b45309', fontWeight: 700, fontSize: 14 }}>이 견적서는 만료되었습니다</div>
|
||||
<div style={{ color: '#92400e', fontSize: 13 }}>유효기간({quote.valid_until?.slice(0, 10)})이 지났습니다. 새 견적이 필요하시면 문의해 주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,16 +312,16 @@ export default function QuotePage() {
|
||||
{/* ── 개요 ── */}
|
||||
{(isPrinting || activeTab === 'overview') && (
|
||||
<div style={{ animation: 'fadeUp 0.4s ease' }}>
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}>개요</h2>}
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}>개요</h2>}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
|
||||
<StatCard label="총 필수 항목" value={requiredItems.length + '개'} sub="반드시 포함" color="#60a5fa" />
|
||||
<StatCard label="총 선택 항목" value={optionalItems.length + '개'} sub="고객 선택 가능" color="#a78bfa" />
|
||||
<StatCard label="필수 견적 합계" value={requiredTotal.toLocaleString() + '원'} sub={'정가 ' + requiredOriginal.toLocaleString() + '원 → 40% 할인'} color="#34d399" />
|
||||
<StatCard label="총 필수 항목" value={requiredItems.length + '개'} sub="반드시 포함" color="#2563eb" />
|
||||
<StatCard label="총 선택 항목" value={optionalItems.length + '개'} sub="고객 선택 가능" color="#7c3aed" />
|
||||
<StatCard label="필수 견적 합계" value={requiredTotal.toLocaleString() + '원'} sub={'정가 ' + requiredOriginal.toLocaleString() + '원 → 40% 할인'} color="#059669" />
|
||||
<StatCard
|
||||
label="선택 포함 합계"
|
||||
value={grandTotal.toLocaleString() + '원'}
|
||||
sub={optionalItems.filter(i => checkedOptional[i.id]).length + '개 선택됨'}
|
||||
color="#f59e0b"
|
||||
color="#d97706"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,16 +330,16 @@ export default function QuotePage() {
|
||||
{/* ── WBS ── */}
|
||||
{(isPrinting || activeTab === 'wbs') && quote.wbs.length > 0 && (
|
||||
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}>WBS (작업 분류 체계)</h2>}
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}>WBS (작업 분류 체계)</h2>}
|
||||
{quote.wbs.map((phase, pi) => (
|
||||
<div key={phase.id} style={{ marginBottom: 24 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'linear-gradient(135deg, #6366f1, #8b5cf6)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0 }}>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--jsm-accent)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, fontWeight: 700, flexShrink: 0, color: 'white' }}>
|
||||
{pi + 1}
|
||||
</div>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'white' }}>{phase.phase}</h3>
|
||||
<h3 style={{ fontSize: 18, fontWeight: 700, color: 'var(--jsm-ink)' }}>{phase.phase}</h3>
|
||||
</div>
|
||||
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflow: 'hidden' }}>
|
||||
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', overflow: 'hidden' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '28%' }} />
|
||||
@@ -306,7 +347,7 @@ export default function QuotePage() {
|
||||
<col style={{ width: '60%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
|
||||
<th style={thStyle}>작업명</th>
|
||||
<th style={thStyle}>기간</th>
|
||||
<th style={thStyle}>설명</th>
|
||||
@@ -314,10 +355,10 @@ export default function QuotePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{phase.tasks.map((task) => (
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<tr key={task.id} style={{ borderBottom: '1px solid var(--jsm-line)' }}>
|
||||
<td style={{ ...tdStyle, wordBreak: 'keep-all' }}>{task.name}</td>
|
||||
<td style={{ ...tdStyle, color: '#818cf8', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
|
||||
<td style={{ ...tdStyle, color: '#64748b' }}>{task.description || '—'}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--jsm-accent)', fontWeight: 600, whiteSpace: 'nowrap' }}>{task.duration}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{task.description || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -331,16 +372,16 @@ export default function QuotePage() {
|
||||
{/* ── 견적 항목 ── */}
|
||||
{(isPrinting || activeTab === 'quote') && (
|
||||
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}>견적 항목</h2>}
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}>견적 항목</h2>}
|
||||
{/* 필수 항목 */}
|
||||
{requiredItems.length > 0 && (
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#60a5fa', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#60a5fa', display: 'inline-block' }} />
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#2563eb', marginBottom: 12, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#2563eb', display: 'inline-block' }} />
|
||||
필수 항목
|
||||
<span style={{ background: 'linear-gradient(135deg, #ef4444, #f97316)', color: 'white', fontSize: 11, fontWeight: 700, padding: '2px 10px', borderRadius: 100, marginLeft: 4 }}>40% 할인 적용</span>
|
||||
</h3>
|
||||
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', overflowX: 'auto' }}>
|
||||
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '10%' }} />
|
||||
@@ -351,7 +392,7 @@ export default function QuotePage() {
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
|
||||
<th style={thStyle}>카테고리</th>
|
||||
<th style={thStyle}>항목명</th>
|
||||
<th style={thStyle}>설명</th>
|
||||
@@ -362,17 +403,17 @@ export default function QuotePage() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{requiredItems.map((item) => (
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid rgba(255,255,255,0.04)' }}>
|
||||
<tr key={item.id} style={{ borderBottom: '1px solid var(--jsm-line)' }}>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
|
||||
<span style={{ background: (CATEGORY_COLORS[item.category] || '#64748b') + '18', color: CATEGORY_COLORS[item.category] || '#64748b', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontWeight: 600, color: 'white' }}>{item.name}</td>
|
||||
<td style={{ ...tdStyle, color: '#64748b' }}>{item.description || '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', whiteSpace: 'nowrap' }}>{item.quantity}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: '#94a3b8', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'white', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}원</td>
|
||||
<td style={{ ...tdStyle, fontWeight: 600, color: 'var(--jsm-ink)' }}>{item.name}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{item.description || '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-soft)', whiteSpace: 'nowrap' }}>{item.quantity}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-soft)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{item.unitPrice.toLocaleString()}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: 'var(--jsm-ink)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>{(item.unitPrice * item.quantity).toLocaleString()}원</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -380,9 +421,9 @@ export default function QuotePage() {
|
||||
</div>
|
||||
{/* 필수 항목 할인 소계 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12, gap: 16, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ color: '#64748b', fontSize: 13, textDecoration: 'line-through', fontFamily: 'monospace' }}>정가 {requiredOriginal.toLocaleString()}원</span>
|
||||
<span style={{ color: 'var(--jsm-ink-faint)', fontSize: 13, textDecoration: 'line-through', fontFamily: 'monospace' }}>정가 {requiredOriginal.toLocaleString()}원</span>
|
||||
<span style={{ color: '#ef4444', fontSize: 13, fontWeight: 600 }}>−{(requiredOriginal - requiredTotal).toLocaleString()}원 할인</span>
|
||||
<span style={{ color: '#34d399', fontSize: 15, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}원</span>
|
||||
<span style={{ color: '#059669', fontSize: 15, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}원</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -390,12 +431,12 @@ export default function QuotePage() {
|
||||
{/* 선택 항목 */}
|
||||
{optionalItems.length > 0 && (
|
||||
<section style={{ marginBottom: 32 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#a78bfa', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#a78bfa', display: 'inline-block' }} />
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, color: '#7c3aed', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#7c3aed', display: 'inline-block' }} />
|
||||
선택 항목
|
||||
</h3>
|
||||
<p style={{ color: '#475569', fontSize: 13, marginBottom: 12 }}>아래 항목 중 원하시는 것을 선택하세요 — 총 금액에 실시간으로 반영됩니다</p>
|
||||
<div style={{ background: '#0f172a', borderRadius: 12, border: '1px solid rgba(167,139,250,0.2)', overflowX: 'auto' }}>
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 13, marginBottom: 12 }}>아래 항목 중 원하시는 것을 선택하세요 — 총 금액에 실시간으로 반영됩니다</p>
|
||||
<div style={{ background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid rgba(29,78,216,0.2)', overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed', minWidth: 700 }}>
|
||||
<colgroup>
|
||||
<col style={{ width: '6%' }} />
|
||||
@@ -406,7 +447,7 @@ export default function QuotePage() {
|
||||
<col style={{ width: '12%' }} />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<tr style={{ borderBottom: '1px solid var(--jsm-line)', background: 'var(--jsm-surface-alt)' }}>
|
||||
<th style={thStyle}>선택</th>
|
||||
<th style={thStyle}>카테고리</th>
|
||||
<th style={thStyle}>항목명</th>
|
||||
@@ -419,19 +460,19 @@ export default function QuotePage() {
|
||||
{optionalItems.map((item) => (
|
||||
<tr key={item.id}
|
||||
onClick={() => setCheckedOptional((prev) => ({ ...prev, [item.id]: !prev[item.id] }))}
|
||||
style={{ borderBottom: '1px solid rgba(255,255,255,0.04)', cursor: 'pointer', background: checkedOptional[item.id] ? 'rgba(167,139,250,0.05)' : 'transparent', transition: 'background 0.2s' }}>
|
||||
style={{ borderBottom: '1px solid var(--jsm-line)', cursor: 'pointer', background: checkedOptional[item.id] ? 'rgba(29,78,216,0.06)' : 'transparent', transition: 'background 0.2s' }}>
|
||||
<td style={{ ...tdStyle, textAlign: 'center' }}>
|
||||
<input type="checkbox" checked={!!checkedOptional[item.id]} onChange={() => {}} />
|
||||
</td>
|
||||
<td style={tdStyle}>
|
||||
<span style={{ background: (CATEGORY_COLORS[item.category] || '#94a3b8') + '20', color: CATEGORY_COLORS[item.category] || '#94a3b8', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
|
||||
<span style={{ background: (CATEGORY_COLORS[item.category] || '#64748b') + '18', color: CATEGORY_COLORS[item.category] || '#64748b', fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 100, whiteSpace: 'nowrap', display: 'inline-block' }}>
|
||||
{item.category}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'white' : '#64748b' }}>{item.name}</td>
|
||||
<td style={{ ...tdStyle, color: '#475569' }}>{item.description || '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: '#64748b', whiteSpace: 'nowrap' }}>{item.quantity}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#a78bfa' : '#475569', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
|
||||
<td style={{ ...tdStyle, fontWeight: 600, color: checkedOptional[item.id] ? 'var(--jsm-ink)' : 'var(--jsm-ink-soft)' }}>{item.name}</td>
|
||||
<td style={{ ...tdStyle, color: 'var(--jsm-ink-soft)' }}>{item.description || '—'}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', color: 'var(--jsm-ink-faint)', whiteSpace: 'nowrap' }}>{item.quantity}</td>
|
||||
<td style={{ ...tdStyle, textAlign: 'right', fontWeight: 700, color: checkedOptional[item.id] ? '#7c3aed' : 'var(--jsm-ink-faint)', fontFamily: 'monospace', whiteSpace: 'nowrap' }}>
|
||||
{(item.unitPrice * item.quantity).toLocaleString()}원
|
||||
</td>
|
||||
</tr>
|
||||
@@ -444,24 +485,24 @@ export default function QuotePage() {
|
||||
|
||||
{/* 합계 */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<div style={{ background: '#0f172a', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 16, padding: '24px 28px', width: 360 }}>
|
||||
<div style={{ background: 'var(--jsm-surface)', border: '1px solid var(--jsm-line)', borderRadius: 16, padding: '24px 28px', width: 360, boxShadow: '0 4px 16px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ color: '#64748b', fontSize: 14 }}>필수 항목 정가</span>
|
||||
<span style={{ color: '#64748b', fontSize: 13, fontFamily: 'monospace', textDecoration: 'line-through' }}>{requiredOriginal.toLocaleString()}원</span>
|
||||
<span style={{ color: 'var(--jsm-ink-soft)', fontSize: 14 }}>필수 항목 정가</span>
|
||||
<span style={{ color: 'var(--jsm-ink-faint)', fontSize: 13, fontFamily: 'monospace', textDecoration: 'line-through' }}>{requiredOriginal.toLocaleString()}원</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<span style={{ color: '#ef4444', fontSize: 13, fontWeight: 600 }}>40% 할인 적용</span>
|
||||
<span style={{ color: '#34d399', fontSize: 14, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}원</span>
|
||||
<span style={{ color: '#059669', fontSize: 14, fontWeight: 700, fontFamily: 'monospace' }}>{requiredTotal.toLocaleString()}원</span>
|
||||
</div>
|
||||
{optionalTotal > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||
<span style={{ color: '#64748b', fontSize: 14 }}>선택 항목</span>
|
||||
<span style={{ color: '#a78bfa', fontSize: 14, fontFamily: 'monospace' }}>+{optionalTotal.toLocaleString()}원</span>
|
||||
<span style={{ color: 'var(--jsm-ink-soft)', fontSize: 14 }}>선택 항목</span>
|
||||
<span style={{ color: '#7c3aed', fontSize: 14, fontFamily: 'monospace' }}>+{optionalTotal.toLocaleString()}원</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ color: 'white', fontWeight: 700, fontSize: 16 }}>합계 (VAT 별도)</span>
|
||||
<span style={{ color: 'white', fontWeight: 800, fontSize: 24, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}원</span>
|
||||
<div style={{ borderTop: '1px solid var(--jsm-line)', paddingTop: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
|
||||
<span style={{ color: 'var(--jsm-ink)', fontWeight: 700, fontSize: 16 }}>합계 (VAT 별도)</span>
|
||||
<span style={{ color: 'var(--jsm-ink)', fontWeight: 800, fontSize: 24, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}원</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -471,35 +512,36 @@ export default function QuotePage() {
|
||||
{/* ── 향후 관리 ── */}
|
||||
{(isPrinting || activeTab === 'maintenance') && quote.maintenance.length > 0 && (
|
||||
<div style={{ animation: 'fadeUp 0.4s ease', marginTop: isPrinting ? 40 : 0 }}>
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: '#818cf8', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid rgba(99,102,241,0.3)' }}>향후 관리</h2>}
|
||||
<p style={{ color: '#64748b', fontSize: 14, marginBottom: 20 }}>납품 후 유지보수 플랜을 선택해주세요 (선택 사항)</p>
|
||||
{isPrinting && <h2 className="print-section-title" style={{ fontSize: 20, fontWeight: 800, color: 'var(--jsm-accent)', marginBottom: 16, paddingBottom: 8, borderBottom: '2px solid var(--jsm-accent-soft)' }}>향후 관리</h2>}
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, marginBottom: 20 }}>납품 후 유지보수 플랜을 선택해주세요 (선택 사항)</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: 16 }}>
|
||||
{quote.maintenance.map((plan) => {
|
||||
const isSelected = selectedMaintenance === plan.id;
|
||||
return (
|
||||
<div key={plan.id} onClick={() => setSelectedMaintenance(isSelected ? null : plan.id)}
|
||||
style={{
|
||||
background: isSelected ? 'linear-gradient(135deg, rgba(99,102,241,0.15), rgba(139,92,246,0.1))' : '#0f172a',
|
||||
border: `1px solid ${isSelected ? '#6366f1' : 'rgba(255,255,255,0.06)'}`,
|
||||
background: isSelected ? 'rgba(29,78,216,0.06)' : 'var(--jsm-surface)',
|
||||
border: `1px solid ${isSelected ? 'var(--jsm-accent)' : 'var(--jsm-line)'}`,
|
||||
borderRadius: 16, padding: 24, cursor: 'pointer', transition: 'all 0.25s', position: 'relative',
|
||||
boxShadow: isSelected ? '0 4px 16px rgba(29,78,216,0.1)' : '0 2px 8px rgba(0,0,0,0.04)',
|
||||
}}>
|
||||
{plan.recommended && (
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, background: '#6366f1', color: 'white', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 100 }}>추천</div>
|
||||
<div style={{ position: 'absolute', top: 16, right: 16, background: 'var(--jsm-accent)', color: 'white', fontSize: 10, fontWeight: 700, padding: '3px 10px', borderRadius: 100 }}>추천</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<input type="radio" checked={isSelected} onChange={() => {}} />
|
||||
<div>
|
||||
<div style={{ color: 'white', fontWeight: 700, fontSize: 16 }}>{plan.name}</div>
|
||||
<div style={{ color: '#475569', fontSize: 13 }}>{plan.period}</div>
|
||||
<div style={{ color: 'var(--jsm-ink)', fontWeight: 700, fontSize: 16 }}>{plan.name}</div>
|
||||
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 13 }}>{plan.period}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 24, fontWeight: 800, color: isSelected ? '#818cf8' : 'white', marginBottom: 16, fontFamily: 'monospace' }}>
|
||||
<div style={{ fontSize: 24, fontWeight: 800, color: isSelected ? 'var(--jsm-accent)' : 'var(--jsm-ink)', marginBottom: 16, fontFamily: 'monospace' }}>
|
||||
{plan.monthlyFee === 0 ? '무료' : plan.monthlyFee.toLocaleString() + '원/월'}
|
||||
</div>
|
||||
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ borderTop: '1px solid var(--jsm-line)', paddingTop: 16, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{plan.includes.map((inc, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: '#94a3b8' }}>
|
||||
<span style={{ color: '#6366f1', flexShrink: 0, marginTop: 1 }}>✓</span>
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 8, fontSize: 13, color: 'var(--jsm-ink-soft)' }}>
|
||||
<span style={{ color: 'var(--jsm-accent)', flexShrink: 0, marginTop: 1 }}>✓</span>
|
||||
{inc}
|
||||
</div>
|
||||
))}
|
||||
@@ -513,44 +555,64 @@ export default function QuotePage() {
|
||||
|
||||
{/* 특이사항 */}
|
||||
{quote.notes && (
|
||||
<div style={{ marginTop: 40, background: '#0f172a', borderRadius: 12, border: '1px solid rgba(255,255,255,0.06)', padding: 24 }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: '#475569', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}>특이사항 및 참고사항</h3>
|
||||
<p style={{ color: '#64748b', fontSize: 14, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{quote.notes}</p>
|
||||
<div style={{ marginTop: 40, background: 'var(--jsm-surface)', borderRadius: 12, border: '1px solid var(--jsm-line)', padding: 24 }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--jsm-ink-soft)', marginBottom: 12, textTransform: 'uppercase', letterSpacing: '0.1em' }}>특이사항 및 참고사항</h3>
|
||||
<p style={{ color: 'var(--jsm-ink-soft)', fontSize: 14, lineHeight: 1.8, whiteSpace: 'pre-wrap' }}>{quote.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 고정 바 — 견적 수락 */}
|
||||
{quote.status !== 'accepted' && quote.status !== 'rejected' && !isExpired && (
|
||||
<div className="no-print" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(10,15,30,0.95)', backdropFilter: 'blur(12px)', borderTop: '1px solid rgba(255,255,255,0.08)', padding: '16px 24px' }}>
|
||||
<div className="no-print" style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--jsm-navy)', borderTop: '1px solid rgba(255,255,255,0.1)', padding: '16px 24px' }}>
|
||||
<div style={{ maxWidth: 900, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 16, flexWrap: 'wrap' }}>
|
||||
<div>
|
||||
<div style={{ color: '#64748b', fontSize: 13 }}>현재 선택된 견적 합계</div>
|
||||
<div style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>현재 선택된 견적 합계</div>
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8 }}>
|
||||
<span style={{ color: 'white', fontSize: 24, fontWeight: 800, fontFamily: 'monospace' }}>{grandTotal.toLocaleString()}원</span>
|
||||
{maintenanceTotal > 0 && selectedPlan && (
|
||||
<span style={{ color: '#6366f1', fontSize: 13 }}>+ {maintenanceTotal.toLocaleString()}원/월 ({selectedPlan.name})</span>
|
||||
<span style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>+ {maintenanceTotal.toLocaleString()}원/월 ({selectedPlan.name})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleAccept} disabled={submitting}
|
||||
style={{
|
||||
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
|
||||
boxShadow: '0 8px 32px rgba(99,102,241,0.4)',
|
||||
opacity: submitting ? 0.7 : 1,
|
||||
}}>
|
||||
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<button onClick={handleReject} disabled={submitting}
|
||||
style={{
|
||||
padding: '14px 24px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.25)', cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
color: 'rgba(255,255,255,0.75)', fontSize: 15, fontWeight: 600, transition: 'all 0.2s',
|
||||
opacity: submitting ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = 'white'; }}
|
||||
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.75)'; }}>
|
||||
정중히 거절
|
||||
</button>
|
||||
<button onClick={handleAccept} disabled={submitting}
|
||||
style={{
|
||||
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: 'var(--jsm-accent)',
|
||||
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
|
||||
boxShadow: '0 8px 32px rgba(29,78,216,0.4)',
|
||||
opacity: submitting ? 0.7 : 1,
|
||||
}}>
|
||||
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 수락된 경우 */}
|
||||
{quote.status === 'accepted' && (
|
||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(16,185,129,0.1)', backdropFilter: 'blur(12px)', borderTop: '1px solid rgba(16,185,129,0.3)', padding: '16px 24px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#34d399', fontWeight: 600, fontSize: 16 }}>✓ 이미 수락된 견적서입니다</p>
|
||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(5,150,105,0.08)', borderTop: '1px solid rgba(5,150,105,0.3)', padding: '16px 24px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#059669', fontWeight: 600, fontSize: 16 }}>✓ 이미 수락된 견적서입니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 거절된 경우 */}
|
||||
{quote.status === 'rejected' && (
|
||||
<div style={{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'rgba(100,116,139,0.08)', borderTop: '1px solid rgba(100,116,139,0.3)', padding: '16px 24px', textAlign: 'center' }}>
|
||||
<p style={{ color: '#64748b', fontWeight: 600, fontSize: 16 }}>✕ 거절된 견적서입니다 — 조정이 필요하시면 회신 주세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -562,13 +624,13 @@ export default function QuotePage() {
|
||||
|
||||
function StatCard({ label, value, sub, color }: { label: string; value: string; sub: string; color: string }) {
|
||||
return (
|
||||
<div style={{ background: '#0f172a', border: `1px solid ${color}20`, borderRadius: 16, padding: 24 }}>
|
||||
<div style={{ color: '#475569', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 10 }}>{label}</div>
|
||||
<div style={{ background: 'var(--jsm-surface)', border: `1px solid ${color}28`, borderRadius: 16, padding: 24, boxShadow: '0 2px 8px rgba(0,0,0,0.04)' }}>
|
||||
<div style={{ color: 'var(--jsm-ink-soft)', fontSize: 12, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 10 }}>{label}</div>
|
||||
<div style={{ color, fontSize: 28, fontWeight: 800, fontFamily: 'monospace', marginBottom: 4 }}>{value}</div>
|
||||
<div style={{ color: '#374151', fontSize: 12 }}>{sub}</div>
|
||||
<div style={{ color: 'var(--jsm-ink-faint)', fontSize: 12 }}>{sub}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: '#475569', textTransform: 'uppercase', letterSpacing: '0.08em' };
|
||||
const tdStyle: React.CSSProperties = { padding: '14px 16px', fontSize: 14, color: '#94a3b8' };
|
||||
const thStyle: React.CSSProperties = { padding: '12px 16px', textAlign: 'left', fontSize: 11, fontWeight: 600, color: 'var(--jsm-ink-soft)', textTransform: 'uppercase', letterSpacing: '0.08em' };
|
||||
const tdStyle: React.CSSProperties = { padding: '14px 16px', fontSize: 14, color: 'var(--jsm-ink-soft)' };
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,9 +6,8 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
return [
|
||||
{ url: base, lastModified: now, changeFrequency: 'weekly', priority: 1.0 },
|
||||
{ url: `${base}/packages`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: `${base}/services/music`, lastModified: now, changeFrequency: 'weekly', priority: 0.95 },
|
||||
{ url: `${base}/saju`, lastModified: now, changeFrequency: 'monthly', priority: 0.7 },
|
||||
{ url: `${base}/outsourcing`, lastModified: now, changeFrequency: 'weekly', priority: 0.9 },
|
||||
{ url: `${base}/products`, lastModified: now, changeFrequency: 'weekly', priority: 0.8 },
|
||||
{ url: `${base}/legal/terms`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${base}/legal/refund`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
|
||||
{ url: `${base}/legal/privacy`, lastModified: now, changeFrequency: 'yearly', priority: 0.3 },
|
||||
|
||||
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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
434
app/track/[token]/page.tsx
Normal file
434
app/track/[token]/page.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { createAdminClient } from '@/lib/supabase/admin';
|
||||
import {
|
||||
REQUEST_STATUS,
|
||||
TIMELINE_STEPS,
|
||||
timelineIndex,
|
||||
isRequestStatus,
|
||||
type RequestStatus,
|
||||
} from '@/lib/request-status';
|
||||
|
||||
// 비회원 의뢰 추적 페이지 (서버 컴포넌트).
|
||||
// 고객이 이메일의 추적 링크로 로그인 없이 의뢰 진행 상태를 확인한다.
|
||||
// PublicShell(TopNav+푸터) 안에서 렌더되므로 여기서는 콘텐츠 섹션만 그린다.
|
||||
// API(app/api/track/[token])와 동일한 조회를 페이지에서 직접 수행한다.
|
||||
// PII(이메일·전화·메시지 본문)는 select에서 제외하며, 모든 DB 예외는 notFound()로 폴백한다.
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '의뢰 진행 상태',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
|
||||
|
||||
interface Props {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
interface TrackRequest {
|
||||
id: string;
|
||||
name: string | null;
|
||||
service: string | null;
|
||||
status: string;
|
||||
project_type: string | null;
|
||||
budget: string | null;
|
||||
timeline: string | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
interface TrackQuote {
|
||||
public_token: string;
|
||||
title: string | null;
|
||||
status: string;
|
||||
valid_until: string | null;
|
||||
}
|
||||
|
||||
const QUOTE_BADGE: Record<string, { label: string; tone: 'accent' | 'muted' | 'danger' }> = {
|
||||
sent: { label: '확인 대기', tone: 'accent' },
|
||||
accepted: { label: '수락됨', tone: 'muted' },
|
||||
rejected: { label: '거절됨', tone: 'danger' },
|
||||
};
|
||||
|
||||
async function loadTrack(
|
||||
token: string,
|
||||
): Promise<{ request: TrackRequest; quote: TrackQuote | null } | null> {
|
||||
if (!token || token.length > 64) return null;
|
||||
try {
|
||||
const admin = createAdminClient();
|
||||
const { data: request, error } = await admin
|
||||
.from('contact_requests')
|
||||
.select('id, name, service, status, project_type, budget, timeline, created_at, updated_at')
|
||||
.eq('public_token', token)
|
||||
.maybeSingle();
|
||||
if (error || !request) return null;
|
||||
|
||||
const { data: quote } = await admin
|
||||
.from('quotes')
|
||||
.select('public_token, title, status, valid_until')
|
||||
.eq('contact_request_id', request.id)
|
||||
.in('status', ['sent', 'accepted', 'rejected'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
return { request: request as TrackRequest, quote: (quote as TrackQuote) ?? null };
|
||||
} catch (err) {
|
||||
// DB 장애·마이그레이션 미적용(42703 등) — 추적 페이지는 404로 폴백
|
||||
console.error('[Track] loadTrack failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDate(value: string | null): string | null {
|
||||
if (!value) return null;
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowRight() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 12h14" />
|
||||
<path d="m13 5 7 7-7 7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 진행 단계 타임라인 — 모바일 세로 / 데스크톱 가로 */
|
||||
function Timeline({ current }: { current: number }) {
|
||||
return (
|
||||
<ol className="flex flex-col md:flex-row">
|
||||
{TIMELINE_STEPS.map((step, i) => {
|
||||
const isDone = i < current;
|
||||
const isCurrent = i === current;
|
||||
const isLast = i === TIMELINE_STEPS.length - 1;
|
||||
const label = REQUEST_STATUS[step].label;
|
||||
|
||||
// 이 단계로 들어오는 연결선이 채워졌는지(이전 단계가 지났는지)
|
||||
const lineFilled = i <= current;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={step}
|
||||
className="flex md:flex-col md:flex-1 md:items-center md:text-center"
|
||||
>
|
||||
{/* 모바일: 세로 마커+연결선 / 데스크톱: 가로 */}
|
||||
<div className="flex flex-col items-center md:flex-row md:w-full md:items-center">
|
||||
{/* 데스크톱 좌측 연결선 (가로) */}
|
||||
{i > 0 && (
|
||||
<span
|
||||
className="hidden md:block h-0.5 flex-1"
|
||||
style={{ background: lineFilled ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 마커 원 */}
|
||||
<span
|
||||
className="relative z-10 flex items-center justify-center rounded-full shrink-0 transition-colors"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
background: isDone
|
||||
? 'var(--jsm-accent)'
|
||||
: isCurrent
|
||||
? 'var(--jsm-surface)'
|
||||
: 'var(--jsm-surface)',
|
||||
border: isCurrent
|
||||
? '2px solid var(--jsm-accent)'
|
||||
: isDone
|
||||
? '2px solid var(--jsm-accent)'
|
||||
: '2px solid var(--jsm-line)',
|
||||
color: isDone ? '#ffffff' : 'transparent',
|
||||
boxShadow: isCurrent ? '0 0 0 4px var(--jsm-accent-soft)' : 'none',
|
||||
}}
|
||||
aria-hidden
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckIcon />
|
||||
) : (
|
||||
<span
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: isCurrent ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{/* 데스크톱 우측 연결선 (가로) */}
|
||||
{!isLast && (
|
||||
<span
|
||||
className="hidden md:block h-0.5 flex-1"
|
||||
style={{ background: i < current ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 모바일 세로 연결선 */}
|
||||
{!isLast && (
|
||||
<span
|
||||
className="md:hidden w-0.5 flex-1 my-1"
|
||||
style={{
|
||||
minHeight: 28,
|
||||
background: i < current ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 라벨 */}
|
||||
<div className="pl-4 pb-6 md:pl-0 md:pb-0 md:mt-3">
|
||||
<span
|
||||
className="text-sm break-keep"
|
||||
style={{
|
||||
color: isDone || isCurrent ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)',
|
||||
fontWeight: isCurrent ? 700 : 500,
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{isCurrent && (
|
||||
<span
|
||||
className="block text-xs mt-0.5"
|
||||
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
진행 중
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function TrackPage({ params }: Props) {
|
||||
const { token } = await params;
|
||||
const data = await loadTrack(token);
|
||||
if (!data) notFound();
|
||||
|
||||
const { request, quote } = data;
|
||||
const status: RequestStatus = isRequestStatus(request.status) ? request.status : 'pending';
|
||||
const current = timelineIndex(status);
|
||||
const receivedAt = fmtDate(request.created_at);
|
||||
|
||||
const info: { label: string; value: string }[] = [];
|
||||
if (request.project_type) info.push({ label: '프로젝트 유형', value: request.project_type });
|
||||
if (request.budget) info.push({ label: '예산', value: request.budget });
|
||||
if (request.timeline) info.push({ label: '희망 일정', value: request.timeline });
|
||||
|
||||
const quoteBadge = quote ? QUOTE_BADGE[quote.status] ?? null : null;
|
||||
const quoteValidUntil = quote ? fmtDate(quote.valid_until) : null;
|
||||
|
||||
return (
|
||||
<section style={{ background: 'var(--jsm-bg)' }}>
|
||||
<div className="max-w-3xl mx-auto px-6 lg:px-8 py-14 lg:py-20">
|
||||
{/* ─── 헤더 ─── */}
|
||||
<header className="pb-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<span
|
||||
className="inline-block text-xs font-semibold mb-4 px-2.5 py-1 rounded"
|
||||
style={{ color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)', ...KOR_BODY }}
|
||||
>
|
||||
의뢰 진행 상태
|
||||
</span>
|
||||
<h1
|
||||
className="text-2xl sm:text-3xl font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{request.service ?? '의뢰하신 프로젝트'}
|
||||
</h1>
|
||||
{receivedAt && (
|
||||
<p className="mt-3 text-sm" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||
{receivedAt} 접수
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* ─── 진행 상태 ─── */}
|
||||
<div className="py-10 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
{status === 'cancelled' ? (
|
||||
<div
|
||||
className="rounded-2xl border px-6 py-8 text-center"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<h2
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
취소된 의뢰입니다
|
||||
</h2>
|
||||
<p
|
||||
className="mt-2 text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
이 의뢰는 취소 처리되었습니다. 다시 진행을 원하시면 회신해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{status === 'on_hold' && (
|
||||
<div
|
||||
className="mb-8 rounded-xl border px-4 py-3.5"
|
||||
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
|
||||
>
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
현재 보류 중입니다 — 조건 조정이 필요하면 회신 주세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<Timeline current={current} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ─── 의뢰 정보 ─── */}
|
||||
{info.length > 0 && (
|
||||
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<h2
|
||||
className="text-sm font-semibold mb-4 uppercase tracking-wider"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
의뢰 정보
|
||||
</h2>
|
||||
<dl className="grid sm:grid-cols-2 gap-x-8 gap-y-4">
|
||||
{info.map((item) => (
|
||||
<div key={item.label}>
|
||||
<dt
|
||||
className="text-xs mb-1"
|
||||
style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
>
|
||||
{item.label}
|
||||
</dt>
|
||||
<dd
|
||||
className="text-sm font-medium break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}
|
||||
>
|
||||
{item.value}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 견적 카드 ─── */}
|
||||
{quote && (
|
||||
<div className="py-8 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
<div
|
||||
className="rounded-2xl border p-6 lg:p-7"
|
||||
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-accent)' }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wider mb-2"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
견적서가 도착했습니다
|
||||
</p>
|
||||
<h2
|
||||
className="text-lg font-bold break-keep"
|
||||
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
|
||||
>
|
||||
{quote.title ?? '프로젝트 견적서'}
|
||||
</h2>
|
||||
</div>
|
||||
{quoteBadge && (
|
||||
<span
|
||||
className="shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full"
|
||||
style={
|
||||
quoteBadge.tone === 'accent'
|
||||
? { color: 'var(--jsm-accent)', background: 'var(--jsm-accent-soft)' }
|
||||
: quoteBadge.tone === 'danger'
|
||||
? { color: '#b91c1c', background: '#fee2e2' }
|
||||
: { color: 'var(--jsm-ink-soft)', background: 'var(--jsm-surface-alt)' }
|
||||
}
|
||||
>
|
||||
{quoteBadge.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quoteValidUntil && (
|
||||
<p className="mt-3 text-sm" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
유효기간 {quoteValidUntil}까지
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/quote/${quote.public_token}`}
|
||||
className="mt-5 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-lg font-semibold text-white transition-colors duration-150 hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
견적서 보기
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── 하단 안내 ─── */}
|
||||
<div className="pt-8">
|
||||
<p
|
||||
className="text-sm leading-relaxed break-keep"
|
||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
문의사항은{' '}
|
||||
<a
|
||||
href="mailto:bgg8988@gmail.com"
|
||||
className="font-medium underline"
|
||||
style={{ color: 'var(--jsm-accent)' }}
|
||||
>
|
||||
bgg8988@gmail.com
|
||||
</a>{' '}
|
||||
또는 접수하신 메일에 회신해 주세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user