Compare commits
16 Commits
a3933c1081
...
76fb722a27
| Author | SHA1 | Date | |
|---|---|---|---|
| 76fb722a27 | |||
| 7f5c7fcb20 | |||
| dbd4bbf21b | |||
| 5e90295d26 | |||
| 32b07e31fa | |||
| d62653e834 | |||
| 5ceae7e90b | |||
| 70abad31b7 | |||
| f5cfb8bd6f | |||
| b4f57c85ec | |||
| 429780d65d | |||
| 8e820760e2 | |||
| 146836f56b | |||
| f7d26c4c3f | |||
| 5077f6ad17 | |||
| 5751cddcea |
196
CLAUDE.md
196
CLAUDE.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 프로젝트 개요
|
## 프로젝트 개요
|
||||||
7년차 대기업 백엔드 개발자 **박재오**가 운영하는 개발 부업 사이트.
|
7년차 대기업 백엔드 개발자 **박재오**가 운영하는 개발 부업 사이트.
|
||||||
고객 맞춤형 서비스를 개발·판매하거나, 이미 완성된 솔루션을 구독 형태로 제공한다.
|
고객 맞춤형 서비스를 외주 개발하거나, 이미 완성된 솔루션을 계좌이체 구매 형태로 제공한다.
|
||||||
|
|
||||||
## 운영자 정보
|
## 운영자 정보
|
||||||
- 이름: 박재오
|
- 이름: 박재오
|
||||||
@@ -11,51 +11,159 @@
|
|||||||
- 연락처: 010-3907-1392
|
- 연락처: 010-3907-1392
|
||||||
- NAS 개인 서버: 로또 랩, 주식 자동매매 프로그램 등 실제 서비스 운영 중
|
- NAS 개인 서버: 로또 랩, 주식 자동매매 프로그램 등 실제 서비스 운영 중
|
||||||
|
|
||||||
## 핵심 서비스
|
## 핵심 IA (공개 라우트)
|
||||||
| 서비스 | 경로 | 설명 |
|
| 경로 | 설명 |
|
||||||
|--------|------|------|
|
|------|------|
|
||||||
| 로또 번호 추천 | `/services/lotto` | 빅데이터/통계 기반 로또 번호 분석 제공 |
|
| `/` | 메인 — 외주 개발 + 완성 소프트웨어 2축 소개 |
|
||||||
| 주식 자동 매매 | `/services/stock` | 텔레그램 연동 주식 자동 매매 프로그램 |
|
| `/outsourcing` | 외주 개발 — 4단계 의뢰 폼 · 프로세스 · 포트폴리오 · FAQ |
|
||||||
| 프롬프트 엔지니어링 | `/services/prompt` | 업무 특화 AI 프롬프트 설계 서비스 |
|
| `/products` | 완성 소프트웨어 목록 — 계좌이체 구매 |
|
||||||
| 업무 자동화 | `/services/automation` | RPA·엑셀·이메일 등 일상 업무 자동화 개발 |
|
| `/products/[id]` | 제품 상세 — 구매 신청·결제 안내 |
|
||||||
| 외주 개발 | `/freelance` | 맞춤형 소프트웨어 외주 (포트폴리오 + 문의) |
|
| `/track/[token]` | 비회원 의뢰 진행 추적 |
|
||||||
|
| `/quote/[token]` | 공개 견적 — 고객 수락/거절 |
|
||||||
|
| `/login` | 로그인 (`?next=` 리다이렉트 지원) |
|
||||||
|
| `/mypage` | 4탭: 프로필 / 내 의뢰(타임라인) / 내 제품(다운로드) / 주문 내역 |
|
||||||
|
| `/legal/*` | 이용약관 · 개인정보처리방침 · 환불정책 |
|
||||||
|
|
||||||
|
## 숨김 서비스 (admin_token 세션 전용)
|
||||||
|
`service_settings` 테이블 토글 + `lib/service-visibility.ts` 가드로 접근 제한.
|
||||||
|
admin/services 패널에서 ON/OFF 전환 가능.
|
||||||
|
|
||||||
|
| 경로 | 서비스 |
|
||||||
|
|------|--------|
|
||||||
|
| `/work/saju*` | 사주 분석 |
|
||||||
|
| `/music/*` | 음악 팩 (단, `/music/packs`는 `/products`로 308 리다이렉트) |
|
||||||
|
| `/gyeol` | CONTOUR PMF 설문 |
|
||||||
|
| `/packages` | 레거시 패키지 |
|
||||||
|
|
||||||
## 기술 스택
|
## 기술 스택
|
||||||
- **Framework**: Next.js 16 (App Router, TypeScript)
|
- **Framework**: Next.js 16 (App Router, TypeScript)
|
||||||
- **Styling**: Tailwind CSS v4
|
- **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
|
- **Analytics**: Google Analytics G-WG77RNHXRK
|
||||||
- **Deployment**: Vercel
|
- **Test**: vitest (`npm test`) — lib 단위 테스트
|
||||||
|
- **Deployment**: Vercel (NAS self-host 전환 진행 중, 컷오버 전 Vercel 운영)
|
||||||
|
|
||||||
## 디자인 시스템
|
## 디자인 시스템 (`--jsm-*` 토큰)
|
||||||
- **Primary**: Blue (`#1d4ed8` blue-700, `#2563eb` blue-600)
|
|
||||||
- **Secondary**: Violet/Purple (`#7c3aed` violet-600, `#8b5cf6` violet-500)
|
### CSS 변수
|
||||||
- **Layout**: 대시보드형 — 왼쪽 고정 사이드바 + 오른쪽 스크롤 콘텐츠
|
| 토큰 | 값 | 역할 |
|
||||||
- **Sidebar bg**: `#0f172a` (slate-900)
|
|------|----|------|
|
||||||
- **Main bg**: `#f1f5f9` (slate-100)
|
| `--jsm-bg` | `#f8fafc` | 페이지 배경 |
|
||||||
- **Cards**: white + 그림자
|
| `--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/
|
app/
|
||||||
layout.tsx — 루트 레이아웃 (메타데이터, 폰트, GA, DashboardShell 래핑)
|
layout.tsx — 루트 레이아웃 (메타데이터·폰트·GA·PublicShell)
|
||||||
page.tsx — 홈 대시보드 (서비스 카드 그리드)
|
page.tsx — 메인 (2축 랜딩)
|
||||||
globals.css — 전역 스타일 + CSS 변수
|
globals.css — 전역 스타일 + --jsm-* CSS 변수
|
||||||
components/
|
components/ — 공용 UI (TopNav, PublicShell, ContactForm 등)
|
||||||
DashboardShell.tsx — 클라이언트: 사이드바 + 메인 영역 레이아웃 래퍼
|
outsourcing/page.tsx — 외주 의뢰 페이지
|
||||||
Sidebar.tsx — 클라이언트: 왼쪽 사이드바 내비게이션
|
products/
|
||||||
ContactForm.tsx — 클라이언트: 문의 폼 (Resend 연동)
|
page.tsx — 완성 소프트웨어 목록
|
||||||
services/
|
[id]/page.tsx — 제품 상세 + 구매 신청
|
||||||
lotto/page.tsx — 로또 번호 추천 서비스 상세
|
track/[token]/page.tsx — 비회원 의뢰 추적
|
||||||
stock/page.tsx — 주식 자동 매매 서비스 상세
|
quote/[token]/page.tsx — 공개 견적 수락/거절
|
||||||
prompt/page.tsx — 프롬프트 엔지니어링 서비스 상세
|
login/page.tsx — 로그인 (?next= 지원)
|
||||||
automation/page.tsx — 업무 자동화 서비스 상세
|
mypage/page.tsx — 마이페이지 4탭
|
||||||
freelance/
|
legal/ — privacy / terms / refund
|
||||||
page.tsx — 외주 개발 포트폴리오 + 문의 폼
|
admin/ — 관리자 전용 (dashboard·members·services·orders·products·contacts·quotes·packs·...)
|
||||||
api/
|
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' 상태 동기화)
|
||||||
|
saju/analyze/route.ts — 사주 AI 분석 (Gemini)
|
||||||
|
payment/ — PortOne 연동 (보존 전용, 미활성)
|
||||||
|
work/saju/ — 숨김: 사주 서비스
|
||||||
|
music/ — 숨김: 음악 팩 (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 해석·용신 추정
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 외주 플로우 (의뢰 상태 머신)
|
||||||
|
|
||||||
|
```
|
||||||
|
고객 의뢰 (/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)
|
||||||
|
```
|
||||||
|
|
||||||
|
- PG(PortOne) 코드는 `products.pay_method` 플래그 기반으로 보존만, 현재 미활성
|
||||||
|
- `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/`)
|
## 쟁승메이드 Co. — AI 에이전트 팀 (`.claude/commands/`)
|
||||||
|
|
||||||
쟁승메이드는 **회사 단위 AI 팀**으로 운영됩니다.
|
쟁승메이드는 **회사 단위 AI 팀**으로 운영됩니다.
|
||||||
@@ -104,16 +212,9 @@ app/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 개발 규칙
|
## 사주 시스템 (`/app/work/saju`, `/lib/saju-*.ts`)
|
||||||
- 서비스 페이지 공통 구조: Hero → Features → Pricing → FAQ → CTA
|
|
||||||
- 구매/신청 CTA는 `/freelance` 페이지 ContactForm으로 연결 (service 파라미터로 pre-fill)
|
|
||||||
- 사이드바는 `usePathname`으로 활성 경로 감지
|
|
||||||
- 모바일: 햄버거 메뉴로 사이드바 토글 (overlay 포함)
|
|
||||||
- 이미지 없이 아이콘·그래디언트·SVG로 시각적 완성도 유지
|
|
||||||
|
|
||||||
---
|
> **서비스는 현재 숨김 — `/admin/services` 토글로 복귀 가능**
|
||||||
|
|
||||||
## 사주 시스템 (`/app/saju`, `/lib/saju-*.ts`)
|
|
||||||
|
|
||||||
### AI 연동 (`app/api/saju/analyze/route.ts`)
|
### AI 연동 (`app/api/saju/analyze/route.ts`)
|
||||||
- **AI**: Google Gemini (`@google/generative-ai`)
|
- **AI**: Google Gemini (`@google/generative-ai`)
|
||||||
@@ -124,7 +225,7 @@ app/
|
|||||||
- **Vercel 타임아웃**: `export const maxDuration = 60` (Pro 플랜 기준)
|
- **Vercel 타임아웃**: `export const maxDuration = 60` (Pro 플랜 기준)
|
||||||
- **Mock 감지**: `isMockInterpretation()` 함수로 DB에 캐시된 예시 데이터 판별
|
- **Mock 감지**: `isMockInterpretation()` 함수로 DB에 캐시된 예시 데이터 판별
|
||||||
- `SajuAISection.tsx`에서 mock이면 `validSaved = null`로 처리 → API 재호출
|
- `SajuAISection.tsx`에서 mock이면 `validSaved = null`로 처리 → API 재호출
|
||||||
- 재생성 버튼(🔄)으로 수동 재생성 가능
|
- 재생성 버튼으로 수동 재생성 가능
|
||||||
|
|
||||||
### 사주팔자 계산 원칙 (검증 완료)
|
### 사주팔자 계산 원칙 (검증 완료)
|
||||||
|
|
||||||
@@ -159,3 +260,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,6 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
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 {
|
interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,16 +16,35 @@ interface Contact {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
service: string;
|
service: string;
|
||||||
message: string;
|
message: string;
|
||||||
status: 'pending' | 'in_progress' | 'completed';
|
status: string;
|
||||||
created_at: 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 }> = {
|
/** 상태별 색상 매핑 — admin 다크 톤 bg-*-900/40 text-*-400 */
|
||||||
pending: { label: '미처리', color: 'bg-yellow-900/40 text-yellow-400' },
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
in_progress: { label: '처리중', color: 'bg-blue-900/40 text-blue-400' },
|
pending: 'bg-yellow-900/40 text-yellow-400',
|
||||||
completed: { label: '완료', color: 'bg-green-900/40 text-green-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> = {
|
const SERVICE_LABELS: Record<string, string> = {
|
||||||
lotto: '로또 추천',
|
lotto: '로또 추천',
|
||||||
stock: '주식 자동매매',
|
stock: '주식 자동매매',
|
||||||
@@ -28,12 +55,68 @@ const SERVICE_LABELS: Record<string, string> = {
|
|||||||
general: '일반 문의',
|
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() {
|
export default function AdminContactsPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selected, setSelected] = useState<Contact | null>(null);
|
const [selected, setSelected] = useState<Contact | null>(null);
|
||||||
const [updating, setUpdating] = useState<string | null>(null);
|
const [updating, setUpdating] = useState<string | null>(null);
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/contacts')
|
fetch('/api/admin/contacts')
|
||||||
@@ -53,10 +136,10 @@ export default function AdminContactsPage() {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setContacts((prev) =>
|
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) {
|
if (selected?.id === id) {
|
||||||
setSelected((prev) => prev ? { ...prev, status: status as Contact['status'] } : null);
|
setSelected((prev) => prev ? { ...prev, status } : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
const pendingCount = contacts.filter((c) => c.status === 'pending').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,8 +174,8 @@ export default function AdminContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 탭 */}
|
{/* 필터 탭 */}
|
||||||
<div className="flex gap-2 mb-4">
|
<div className="flex gap-2 mb-4 flex-wrap">
|
||||||
{[['all', '전체'], ['pending', '미처리'], ['in_progress', '처리중'], ['completed', '완료']].map(([val, label]) => (
|
{FILTER_TABS.map(({ val, label }) => (
|
||||||
<button
|
<button
|
||||||
key={val}
|
key={val}
|
||||||
onClick={() => setFilterStatus(val)}
|
onClick={() => setFilterStatus(val)}
|
||||||
@@ -98,7 +188,7 @@ export default function AdminContactsPage() {
|
|||||||
{label}
|
{label}
|
||||||
{val !== 'all' && (
|
{val !== 'all' && (
|
||||||
<span className="ml-1.5 text-xs opacity-70">
|
<span className="ml-1.5 text-xs opacity-70">
|
||||||
{contacts.filter((c) => c.status === val).length}
|
{filterCount(contacts, val)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -121,7 +211,10 @@ export default function AdminContactsPage() {
|
|||||||
filtered.map((contact) => (
|
filtered.map((contact) => (
|
||||||
<button
|
<button
|
||||||
key={contact.id}
|
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 ${
|
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'
|
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>
|
<p className="text-slate-400 text-xs truncate">{contact.message}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<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}`}>
|
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(contact.status)}`}>
|
||||||
{STATUS_LABELS[contact.status]?.label}
|
{getStatusLabel(contact.status)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-slate-600 text-xs">
|
<span className="text-slate-600 text-xs">
|
||||||
{new Date(contact.created_at).toLocaleDateString('ko-KR')}
|
{new Date(contact.created_at).toLocaleDateString('ko-KR')}
|
||||||
@@ -189,27 +282,85 @@ export default function AdminContactsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{/* 상태 변경 */}
|
{/* 프로젝트 정보 */}
|
||||||
<div>
|
{(selected.project_type || selected.budget || selected.timeline) && (
|
||||||
<p className="text-slate-500 text-xs mb-2">상태 변경</p>
|
<div className="mb-4 p-3 bg-slate-800 rounded-lg text-sm space-y-1.5">
|
||||||
<div className="flex gap-2">
|
<p className="text-slate-400 font-medium mb-2">프로젝트 정보</p>
|
||||||
{(['pending', 'in_progress', 'completed'] as const).map((s) => (
|
{selected.project_type && (
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
key={s}
|
<span className="text-slate-500 w-16 flex-shrink-0">유형</span>
|
||||||
onClick={() => updateStatus(selected.id, s)}
|
<span className="text-slate-200">{selected.project_type}</span>
|
||||||
disabled={selected.status === s || updating === selected.id}
|
</div>
|
||||||
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition ${
|
)}
|
||||||
selected.status === s
|
{selected.budget && (
|
||||||
? STATUS_LABELS[s].color + ' opacity-100'
|
<div className="flex gap-2">
|
||||||
: 'bg-slate-700 text-slate-400 hover:bg-slate-600'
|
<span className="text-slate-500 w-16 flex-shrink-0">예산</span>
|
||||||
} disabled:opacity-50`}
|
<span className="text-slate-200">{selected.budget}</span>
|
||||||
>
|
</div>
|
||||||
{STATUS_LABELS[s].label}
|
)}
|
||||||
</button>
|
{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>
|
</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>
|
</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
|
<a
|
||||||
href={`mailto:${selected.email}?subject=[쟁승메이드] 문의 답변&body=안녕하세요, 쟁승메이드입니다.%0A%0A`}
|
href={`mailto:${selected.email}?subject=[쟁승메이드] 문의 답변&body=안녕하세요, 쟁승메이드입니다.%0A%0A`}
|
||||||
@@ -221,6 +372,23 @@ export default function AdminContactsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
이메일 답장하기
|
이메일 답장하기
|
||||||
</a>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export default function QuoteEditorPage() {
|
|||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
const [milestones, setMilestones] = useState<Milestone[]>([]);
|
||||||
const [mileSaving, setMileSaving] = useState<string | null>(null);
|
const [mileSaving, setMileSaving] = useState<string | null>(null);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetch(`/api/admin/quotes/${id}`)
|
fetch(`/api/admin/quotes/${id}`)
|
||||||
@@ -125,6 +126,39 @@ export default function QuoteEditorPage() {
|
|||||||
setMileSaving(null);
|
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 ────────────────────────────
|
// ── helpers ────────────────────────────
|
||||||
const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
const setField = (k: keyof QuoteForm, v: unknown) => setForm((f) => ({ ...f, [k]: v }));
|
||||||
|
|
||||||
@@ -255,6 +289,27 @@ export default function QuoteEditorPage() {
|
|||||||
PDF 저장
|
PDF 저장
|
||||||
</a>
|
</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}
|
<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`}>
|
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`}>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { createAdminClient } from '@/lib/supabase/admin';
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
import { verifyAdminTokenNode } from '@/lib/admin-auth';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
|
import { isRequestStatus } from '@/lib/request-status';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const supabase = createAdminClient();
|
const supabase = createAdminClient();
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data: contacts, error } = await supabase
|
||||||
.from('contact_requests')
|
.from('contact_requests')
|
||||||
.select('*')
|
.select('*')
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
@@ -28,7 +29,35 @@ export async function GET() {
|
|||||||
return NextResponse.json({ error: error.message }, { status: 500 });
|
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) {
|
export async function PATCH(request: Request) {
|
||||||
@@ -37,11 +66,16 @@ export async function PATCH(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { id, status } = await request.json();
|
const { id, status } = await request.json();
|
||||||
|
|
||||||
|
if (typeof id !== 'string' || !isRequestStatus(status)) {
|
||||||
|
return NextResponse.json({ error: 'invalid request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
const supabase = createAdminClient();
|
const supabase = createAdminClient();
|
||||||
|
|
||||||
const { error } = await supabase
|
const { error } = await supabase
|
||||||
.from('contact_requests')
|
.from('contact_requests')
|
||||||
.update({ status })
|
.update({ status, updated_at: new Date().toISOString() })
|
||||||
.eq('id', id);
|
.eq('id', id);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
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 body = await request.json();
|
||||||
const supabase = createAdminClient();
|
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
|
const { data, error } = await supabase
|
||||||
.from('quotes')
|
.from('quotes')
|
||||||
.insert({
|
.insert(insertData)
|
||||||
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',
|
|
||||||
})
|
|
||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/lib/security';
|
} from '@/lib/security';
|
||||||
import { createAdminClient } from '@/lib/supabase/admin';
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
import { createClient } from '@/lib/supabase/server';
|
import { createClient } from '@/lib/supabase/server';
|
||||||
|
import { sendRequestReceivedEmail } from '@/lib/request-emails';
|
||||||
|
|
||||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
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 body = await request.json();
|
||||||
|
|
||||||
// ── 입력 정제 + 길이 제한 ─────────────────────────────────
|
// ── 입력 정제 + 길이 제한 ─────────────────────────────────
|
||||||
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
|
const name = sanitizeStr(body.name, INPUT_LIMITS.NAME);
|
||||||
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
|
const phone = sanitizeStr(body.phone, INPUT_LIMITS.PHONE);
|
||||||
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
|
const email = sanitizeStr(body.email, INPUT_LIMITS.EMAIL);
|
||||||
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
|
const service = sanitizeStr(body.service, INPUT_LIMITS.SERVICE);
|
||||||
const message = sanitizeStr(body.message, INPUT_LIMITS.MESSAGE);
|
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) {
|
if (!name || !email || !message) {
|
||||||
@@ -99,21 +104,74 @@ export async function POST(request: Request) {
|
|||||||
emailSent = false;
|
emailSent = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 추적 토큰 생성 ────────────────────────────────────────
|
||||||
|
let publicToken: string;
|
||||||
|
try {
|
||||||
|
publicToken = globalThis.crypto.randomUUID();
|
||||||
|
} catch {
|
||||||
|
const { randomUUID } = await import('crypto');
|
||||||
|
publicToken = randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
// ── DB 저장 (이메일 성공/실패 무관) ──────────────────────
|
// ── DB 저장 (이메일 성공/실패 무관) ──────────────────────
|
||||||
|
// 신규 컬럼 포함 insert 시도 → 컬럼 부재(42703) 시 기존 필드만으로 재시도
|
||||||
|
let tokenStored = false;
|
||||||
try {
|
try {
|
||||||
const admin = createAdminClient();
|
const admin = createAdminClient();
|
||||||
await admin.from('contact_requests').insert({
|
const { error: insertError } = await admin.from('contact_requests').insert({
|
||||||
name,
|
name,
|
||||||
email,
|
email,
|
||||||
phone: phone || null,
|
phone: phone || null,
|
||||||
service: service || null,
|
service: service || null,
|
||||||
message,
|
message,
|
||||||
user_id: userId,
|
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) {
|
} catch (dbError) {
|
||||||
console.error('[Contact] DB insert error:', 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) {
|
if (!emailSent) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: '메일 전송에 실패했습니다. 다시 시도해주세요.' },
|
{ error: '메일 전송에 실패했습니다. 다시 시도해주세요.' },
|
||||||
@@ -122,7 +180,11 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ success: true, message: '문의가 성공적으로 전송되었습니다!' },
|
{
|
||||||
|
success: true,
|
||||||
|
message: '문의가 성공적으로 전송되었습니다!',
|
||||||
|
trackUrl: tokenStored ? `/track/${publicToken}` : null,
|
||||||
|
},
|
||||||
{ status: 200 }
|
{ status: 200 }
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { createAdminClient } from '@/lib/supabase/admin';
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
import { sendQuoteDecisionEmail } from '@/lib/request-emails';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
@@ -24,31 +25,79 @@ export async function GET(_req: Request, { params }: { params: Promise<{ token:
|
|||||||
return NextResponse.json({ quote: data, expired });
|
return NextResponse.json({ quote: data, expired });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 고객이 견적 수락
|
// 고객이 견적 수락/거절
|
||||||
export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
|
export async function POST(request: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||||
const { token } = await params;
|
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 supabase = createAdminClient();
|
||||||
|
|
||||||
const { data: quote, error: findErr } = await supabase
|
const { data: quote, error: findErr } = await supabase
|
||||||
.from('quotes')
|
.from('quotes')
|
||||||
.select('id, title, client_name, client_email')
|
.select('id, title, client_name, client_email, status, contact_request_id')
|
||||||
.eq('public_token', token)
|
.eq('public_token', token)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (findErr || !quote) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
if (findErr || !quote) return NextResponse.json({ error: 'Not found' }, { status: 404 });
|
||||||
|
|
||||||
// 상태를 accepted로 변경
|
// 이미 처리된 견적 중복 처리 방지
|
||||||
await supabase
|
if (quote.status === 'accepted' || quote.status === 'rejected') {
|
||||||
.from('quotes')
|
return NextResponse.json({ error: '이미 처리된 견적입니다' }, { status: 409 });
|
||||||
.update({
|
}
|
||||||
status: 'accepted',
|
|
||||||
accepted_items: body.selectedItems,
|
const now = new Date().toISOString();
|
||||||
accepted_maintenance: body.selectedMaintenance,
|
|
||||||
accepted_total: body.total,
|
if (action === 'accept') {
|
||||||
updated_at: new Date().toISOString(),
|
// 상태를 accepted로 변경 (기존 로직 유지)
|
||||||
})
|
await supabase
|
||||||
.eq('id', quote.id);
|
.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 });
|
return NextResponse.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
31
app/api/track/[token]/route.ts
Normal file
31
app/api/track/[token]/route.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// 비회원 의뢰 추적 API — 향후 클라이언트 측 폴링/갱신용.
|
||||||
|
// PII(이메일·전화·메시지 본문)는 select에서 제외한다.
|
||||||
|
// DB 예외(마이그레이션 미적용 42703 포함)는 모두 404로 폴백한다.
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
if (!token || token.length > 64) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data: request, error } = await admin
|
||||||
|
.from('contact_requests')
|
||||||
|
.select('id, name, service, status, project_type, budget, timeline, created_at, updated_at')
|
||||||
|
.eq('public_token', token)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error || !request) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const { data: quote } = await admin
|
||||||
|
.from('quotes')
|
||||||
|
.select('public_token, title, status, valid_until')
|
||||||
|
.eq('contact_request_id', request.id)
|
||||||
|
.in('status', ['sent', 'accepted', 'rejected'])
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
return NextResponse.json({ request, quote: quote ?? null });
|
||||||
|
}
|
||||||
624
app/components/OutsourcingRequestForm.tsx
Normal file
624
app/components/OutsourcingRequestForm.tsx
Normal file
@@ -0,0 +1,624 @@
|
|||||||
|
'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)',
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goNext = useCallback(() => {
|
||||||
|
if (!stepValid(step)) return;
|
||||||
|
setError('');
|
||||||
|
setStep((s) => Math.min(s + 1, STEPS.length));
|
||||||
|
}, [step]);
|
||||||
|
|
||||||
|
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-faint)', ...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-faint)' }
|
||||||
|
: { 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-faint)' : '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)',
|
||||||
|
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-faint)', ...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-ink-faint)'
|
||||||
|
: '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-ink-faint)',
|
||||||
|
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)',
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,13 @@ import { createClient } from '@/lib/supabase/client';
|
|||||||
import type { User } from '@supabase/supabase-js';
|
import type { User } from '@supabase/supabase-js';
|
||||||
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
||||||
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
||||||
|
import {
|
||||||
|
REQUEST_STATUS,
|
||||||
|
TIMELINE_STEPS,
|
||||||
|
timelineIndex,
|
||||||
|
isRequestStatus,
|
||||||
|
type RequestStatus,
|
||||||
|
} from '@/lib/request-status';
|
||||||
|
|
||||||
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
|
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
|
||||||
// PublicShell(TopNav)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
|
// PublicShell(TopNav)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
|
||||||
@@ -53,6 +60,12 @@ interface Order {
|
|||||||
service: string;
|
service: string;
|
||||||
message: string;
|
message: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
// 2026-06-12-client-portal 마이그레이션 신규 컬럼 — 미적용 환경에선 undefined
|
||||||
|
public_token?: string | null;
|
||||||
|
project_type?: string | null;
|
||||||
|
budget?: string | null;
|
||||||
|
timeline?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 구매 제품 자료 그룹 (/api/packs/list-mine 응답)
|
// 구매 제품 자료 그룹 (/api/packs/list-mine 응답)
|
||||||
@@ -86,6 +99,8 @@ function MyPageContent() {
|
|||||||
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
|
||||||
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
|
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
const [downloading, setDownloading] = useState<string | null>(null);
|
||||||
|
// 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘)
|
||||||
|
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 텔레그램 연동 상태
|
// 텔레그램 연동 상태
|
||||||
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
|
||||||
@@ -195,6 +210,15 @@ function MyPageContent() {
|
|||||||
setTelegramLinkState('idle');
|
setTelegramLinkState('idle');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function toggleRequest(id: string) {
|
||||||
|
setExpandedRequests((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDownload(fileId: string) {
|
async function handleDownload(fileId: string) {
|
||||||
setDownloading(fileId);
|
setDownloading(fileId);
|
||||||
try {
|
try {
|
||||||
@@ -503,23 +527,12 @@ function MyPageContent() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{orders.map((o) => (
|
{orders.map((o) => (
|
||||||
<Card key={o.id} compact>
|
<RequestCard
|
||||||
<div className="flex items-start justify-between gap-3 mb-2">
|
key={o.id}
|
||||||
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
order={o}
|
||||||
{o.service}
|
expanded={expandedRequests.has(o.id)}
|
||||||
</div>
|
onToggle={() => toggleRequest(o.id)}
|
||||||
<StatusBadge status={o.status} />
|
/>
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className="text-sm line-clamp-2 break-keep"
|
|
||||||
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
|
||||||
>
|
|
||||||
{o.message}
|
|
||||||
</p>
|
|
||||||
<div className="text-xs mt-2" style={{ color: 'var(--jsm-ink-faint)' }}>
|
|
||||||
{new Date(o.created_at).toLocaleDateString('ko-KR')}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -792,27 +805,261 @@ function QuickLink({ href, title, sub }: { href: string; title: string; sub: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 뱃지 — pending=surface-alt / in_progress=accent-soft / completed=성공 그린(예외 허용)
|
// 상태 뱃지 — REQUEST_STATUS 8종.
|
||||||
|
// completed=성공 그린(예외 허용) / accepted·quoted·in_progress=accent / pending·reviewing=surface-alt
|
||||||
|
// on_hold·cancelled=faint. 알 수 없는 값(다른 도메인 status 등)은 원문 라벨+기본 스타일 폴백.
|
||||||
|
const STATUS_BADGE_STYLE: Record<RequestStatus, React.CSSProperties> = {
|
||||||
|
completed: { background: '#dcfce7', color: '#166534' },
|
||||||
|
accepted: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' },
|
||||||
|
in_progress: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' },
|
||||||
|
quoted: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' },
|
||||||
|
pending: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' },
|
||||||
|
reviewing: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' },
|
||||||
|
on_hold: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' },
|
||||||
|
cancelled: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' },
|
||||||
|
};
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: string }) {
|
||||||
const map: Record<string, { label: string; style: React.CSSProperties }> = {
|
const known = isRequestStatus(status);
|
||||||
completed: { label: '완료', style: { background: '#dcfce7', color: '#166534' } },
|
const label = known ? REQUEST_STATUS[status].label : status;
|
||||||
in_progress: { label: '진행중', style: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' } },
|
const style = known
|
||||||
pending: { label: '대기중', style: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' } },
|
? STATUS_BADGE_STYLE[status]
|
||||||
};
|
: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' };
|
||||||
const conf = map[status] ?? {
|
|
||||||
label: status,
|
|
||||||
style: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' },
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap flex-shrink-0"
|
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap flex-shrink-0"
|
||||||
style={conf.style}
|
style={style}
|
||||||
>
|
>
|
||||||
{conf.label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 펼침 토글 셰브론
|
||||||
|
function Chevron({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
transform: open ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
transition: 'transform 0.2s ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimelineCheck() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M20 6 9 17l-5-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컴팩트 가로 미니 타임라인 — track 페이지 타임라인의 축소판.
|
||||||
|
// 모바일에서는 라벨을 숨기고 도트만 노출(라벨 축약 허용).
|
||||||
|
function MiniTimeline({ current }: { current: number }) {
|
||||||
|
return (
|
||||||
|
<ol className="flex items-start">
|
||||||
|
{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;
|
||||||
|
return (
|
||||||
|
<li key={step} className="flex-1 flex flex-col items-center min-w-0">
|
||||||
|
<div className="flex items-center w-full">
|
||||||
|
{/* 좌측 연결선 */}
|
||||||
|
<span
|
||||||
|
className="h-0.5 flex-1"
|
||||||
|
style={{
|
||||||
|
background: i === 0 ? 'transparent' : i <= current ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{/* 마커 */}
|
||||||
|
<span
|
||||||
|
className="relative z-10 flex items-center justify-center rounded-full shrink-0"
|
||||||
|
style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
background: isDone ? 'var(--jsm-accent)' : 'var(--jsm-surface)',
|
||||||
|
border: isDone || isCurrent ? '2px solid var(--jsm-accent)' : '2px solid var(--jsm-line)',
|
||||||
|
color: isDone ? '#ffffff' : 'transparent',
|
||||||
|
boxShadow: isCurrent ? '0 0 0 3px var(--jsm-accent-soft)' : 'none',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
{isDone ? (
|
||||||
|
<TimelineCheck />
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="rounded-full"
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
background: isCurrent ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{/* 우측 연결선 */}
|
||||||
|
<span
|
||||||
|
className="h-0.5 flex-1"
|
||||||
|
style={{
|
||||||
|
background: isLast ? 'transparent' : i < current ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/* 라벨 — 모바일 숨김 */}
|
||||||
|
<span
|
||||||
|
className="hidden sm:block mt-1.5 text-[11px] text-center break-keep"
|
||||||
|
style={{
|
||||||
|
color: isDone || isCurrent ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)',
|
||||||
|
fontWeight: isCurrent ? 700 : 500,
|
||||||
|
...KOR_BODY,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내 의뢰 카드 — 접힘 기본, 펼치면 타임라인 + 의뢰 정보 + 추적 링크
|
||||||
|
function RequestCard({
|
||||||
|
order,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
order: Order;
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
const status: RequestStatus = isRequestStatus(order.status) ? order.status : 'pending';
|
||||||
|
const current = timelineIndex(status);
|
||||||
|
|
||||||
|
const info: { label: string; value: string }[] = [];
|
||||||
|
if (order.project_type) info.push({ label: '프로젝트 유형', value: order.project_type });
|
||||||
|
if (order.budget) info.push({ label: '예산', value: order.budget });
|
||||||
|
if (order.timeline) info.push({ label: '희망 일정', value: order.timeline });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card compact>
|
||||||
|
{/* 헤더 — 클릭 토글 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="w-full text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3 mb-2">
|
||||||
|
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||||
|
{order.service}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<StatusBadge status={order.status} />
|
||||||
|
<span style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||||
|
<Chevron open={expanded} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className={`text-sm break-keep ${expanded ? '' : 'line-clamp-2'}`}
|
||||||
|
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
{order.message}
|
||||||
|
</p>
|
||||||
|
<div className="text-xs mt-2" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||||
|
{new Date(order.created_at).toLocaleDateString('ko-KR')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 펼침 영역 */}
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-4 pt-4 border-t" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||||
|
{status === 'cancelled' ? (
|
||||||
|
<p className="text-sm break-keep" style={{ color: 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||||
|
취소된 의뢰입니다.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{status === 'on_hold' && (
|
||||||
|
<div
|
||||||
|
className="mb-4 rounded-lg px-3 py-2.5"
|
||||||
|
style={{ background: 'var(--jsm-surface-alt)' }}
|
||||||
|
>
|
||||||
|
<p className="text-xs break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||||
|
현재 보류 중입니다 — 조건 조정이 필요하면 회신 주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="px-1 py-1">
|
||||||
|
<MiniTimeline current={current} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 의뢰 정보 */}
|
||||||
|
{info.length > 0 && (
|
||||||
|
<dl className="mt-5 grid grid-cols-2 gap-x-6 gap-y-3">
|
||||||
|
{info.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<dt className="text-xs mb-0.5" 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 상세 추적 페이지 링크 */}
|
||||||
|
{order.public_token && (
|
||||||
|
<Link
|
||||||
|
href={`/track/${order.public_token}`}
|
||||||
|
className="mt-5 inline-flex items-center gap-1.5 text-sm font-semibold transition-colors hover:underline"
|
||||||
|
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||||
|
>
|
||||||
|
상세 추적 페이지
|
||||||
|
<span aria-hidden>→</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmptyState({
|
function EmptyState({
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import ContactForm from '@/app/components/ContactForm';
|
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
|
||||||
|
|
||||||
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
|
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
|
||||||
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
|
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
|
||||||
@@ -528,7 +528,7 @@ export default function OutsourcingPage() {
|
|||||||
className="rounded-2xl p-6 lg:p-8"
|
className="rounded-2xl p-6 lg:p-8"
|
||||||
style={{ background: 'var(--jsm-surface)' }}
|
style={{ background: 'var(--jsm-surface)' }}
|
||||||
>
|
>
|
||||||
<ContactForm />
|
<OutsourcingRequestForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export default function QuotePage() {
|
|||||||
const [activeTab, setActiveTab] = useState<'overview' | 'wbs' | 'quote' | 'maintenance'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'wbs' | 'quote' | 'maintenance'>('overview');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [submitted, setSubmitted] = useState(false);
|
const [submitted, setSubmitted] = useState(false);
|
||||||
|
const [rejected, setRejected] = useState(false);
|
||||||
|
const [alreadyProcessed, setAlreadyProcessed] = useState(false);
|
||||||
const [isPrinting, setIsPrinting] = useState(false);
|
const [isPrinting, setIsPrinting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -89,15 +91,31 @@ export default function QuotePage() {
|
|||||||
if (!quote) return;
|
if (!quote) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const selectedItems = quote.items.filter((i) => !i.optional || checkedOptional[i.id]).map((i) => i.id);
|
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',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ selectedItems, selectedMaintenance, total: grandTotal }),
|
body: JSON.stringify({ selectedItems, selectedMaintenance, total: grandTotal }),
|
||||||
});
|
});
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
|
if (res.status === 409) { setAlreadyProcessed(true); return; }
|
||||||
setSubmitted(true);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ minHeight: '100vh', background: 'var(--jsm-bg)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -141,6 +159,30 @@ export default function QuotePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = [
|
const tabs = [
|
||||||
{ key: 'overview', label: '개요' },
|
{ key: 'overview', label: '개요' },
|
||||||
{ key: 'wbs', label: 'WBS', show: quote.wbs.length > 0 },
|
{ key: 'wbs', label: 'WBS', show: quote.wbs.length > 0 },
|
||||||
@@ -533,16 +575,29 @@ export default function QuotePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleAccept} disabled={submitting}
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
style={{
|
<button onClick={handleReject} disabled={submitting}
|
||||||
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
style={{
|
||||||
background: 'var(--jsm-accent)',
|
padding: '14px 24px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.25)', cursor: 'pointer',
|
||||||
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
|
background: 'transparent',
|
||||||
boxShadow: '0 8px 32px rgba(29,78,216,0.4)',
|
color: 'rgba(255,255,255,0.75)', fontSize: 15, fontWeight: 600, transition: 'all 0.2s',
|
||||||
opacity: submitting ? 0.7 : 1,
|
opacity: submitting ? 0.5 : 1,
|
||||||
}}>
|
}}
|
||||||
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
|
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = 'white'; }}
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -554,6 +609,13 @@ export default function QuotePage() {
|
|||||||
</div>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 하단 여백 */}
|
{/* 하단 여백 */}
|
||||||
<div style={{ height: 80 }} />
|
<div style={{ height: 80 }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
# 사이트 리뉴얼 Phase 3 — 외주 고객 포털 구현 계획
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
> **UI 태스크(4·5·8·9)는 구현 시 `designer` + `soft-skill` 스킬 로드 필수.** 노출 페이지 토큰은 `--jsm-*`만, gradient/blur/보라/이모지 금지. admin은 기존 admin 톤 유지.
|
||||||
|
|
||||||
|
**Goal:** 외주 의뢰를 접수→검토→견적→수락→진행→완료의 상태 머신으로 관리하고, 고객이 `/track/[token]`(비회원)·mypage(회원)에서 추적·견적 수락/거절할 수 있는 포털 구축. 잔여 정리(레거시 음악 구매 경로 차단, CLAUDE.md 갱신) 포함.
|
||||||
|
|
||||||
|
**Architecture:** `contact_requests`에 상태 머신·`public_token`·구조화 필드(project_type/budget/timeline)를 확장하고 `quotes.contact_request_id` FK로 연결 (스펙 §1-1 A안). 견적 발송/수락/거절 시 양 테이블 상태를 서버에서 동기화. 메일은 기존 Resend 패턴(`lib/order-emails.ts`) 준용한 `lib/request-emails.ts`.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router, Supabase, Resend, vitest
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-11-site-renewal-outsourcing-products-design.md` §5·§6
|
||||||
|
**Branch:** `feature/renewal-phase3`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 코드 기준점 (탐색 검증됨)
|
||||||
|
|
||||||
|
- `contact_requests`: id/user_id/email/name/phone/service/message/status(default 'pending', **CHECK 없음**)/created_at. RLS: 본인 SELECT + 누구나 INSERT
|
||||||
|
- `quotes`: title/client_name/client_email/client_phone/status(draft|sent|accepted|rejected)/public_token/valid_until/wbs/items/maintenance/notes/discount/accepted_* — **contact_request_id 없음**, 견적 발송 메일 기능 없음
|
||||||
|
- `/api/contact` (`app/api/contact/route.ts`): sanitizeStr+INPUT_LIMITS 검증(34-38행), IP rate limit 5/분(19-29행), 관리자 메일(76-96행), insert(104-112행, user_id 포함)
|
||||||
|
- `ContactForm.tsx`: 단일 폼, `?service=` 프리필. `/outsourcing` `#contact`에서 props 없이 사용
|
||||||
|
- `/quote/[token]`: 열람+**수락만** 있음(거절 없음). 수락 POST가 quotes만 갱신, **contact_requests 동기화 없음**
|
||||||
|
- `admin/contacts`: 3종 status(pending/in_progress/completed) 토글. `admin/quotes`: CRUD + `[id]` 편집 페이지 존재(주장 충돌 있음 — 구현 시 직접 확인), public_token 생성 로직은 명시적으로 없음(DB default 추정 — **구현 시 확인 필수**)
|
||||||
|
- `mypage` 내 의뢰 탭: contact_requests 카드 목록(StatusBadge 3종)
|
||||||
|
- 메일 패턴: `lib/order-emails.ts` (Resend, FROM '쟁승메이드 <noreply@jaengseung-made.com>', ADMIN bgg8988@gmail.com, escapeHtml)
|
||||||
|
- 고아 경로: `/music/packs`(숨김)가 PurchaseAgreementModal로 contact 문자열 구매 신청 생성 — orders에 안 잡힘
|
||||||
|
- 사이트 URL 상수: `https://jaengseung-made.com`
|
||||||
|
|
||||||
|
## 상태 머신 (단일 정의 — Task 2의 lib가 유일한 소스)
|
||||||
|
|
||||||
|
```
|
||||||
|
pending(접수) → reviewing(검토중) → quoted(견적 발송) → accepted(수주 확정) → in_progress(진행중) → completed(완료)
|
||||||
|
↘ on_hold(보류) (어느 단계서든) cancelled(취소)
|
||||||
|
```
|
||||||
|
레거시 값(pending/in_progress/completed)은 그대로 유효 — 기존 행 변환 불필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: DB 마이그레이션 — contact_requests 확장 + quotes FK
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `supabase/migrations/2026-06-12-client-portal.sql`
|
||||||
|
|
||||||
|
- [ ] **Step 1: SQL 작성** (멱등)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 2026-06-12 Phase 3: 외주 고객 포털
|
||||||
|
-- (1) contact_requests 확장
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS public_token text UNIQUE;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS project_type text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS budget text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS timeline text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
|
||||||
|
|
||||||
|
-- 기존 행 토큰 백필 (멱등 — NULL만)
|
||||||
|
UPDATE contact_requests SET public_token = gen_random_uuid()::text WHERE public_token IS NULL;
|
||||||
|
|
||||||
|
-- 상태 머신 CHECK (레거시 3종 포함 8종)
|
||||||
|
ALTER TABLE contact_requests DROP CONSTRAINT IF EXISTS contact_requests_status_check;
|
||||||
|
ALTER TABLE contact_requests ADD CONSTRAINT contact_requests_status_check
|
||||||
|
CHECK (status IN ('pending','reviewing','quoted','accepted','on_hold','in_progress','completed','cancelled'));
|
||||||
|
|
||||||
|
-- (2) quotes ↔ contact_requests 연결
|
||||||
|
ALTER TABLE quotes ADD COLUMN IF NOT EXISTS contact_request_id uuid REFERENCES contact_requests(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotes_contact_request ON quotes (contact_request_id);
|
||||||
|
|
||||||
|
-- (3) quotes.public_token 기본값 보장 (기존 default 없을 때만 의미, 멱등)
|
||||||
|
ALTER TABLE quotes ALTER COLUMN public_token SET DEFAULT gen_random_uuid()::text;
|
||||||
|
UPDATE quotes SET public_token = gen_random_uuid()::text WHERE public_token IS NULL;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: schema.sql 대조** — contact_requests/quotes 컬럼명 정합 확인. quotes에 public_token 컬럼이 실제 존재하는지 확인(없으면 ADD COLUMN IF NOT EXISTS 추가)
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add supabase/migrations/2026-06-12-client-portal.sql
|
||||||
|
git commit -m "feat(db): 고객 포털 — contact_requests 상태머신·토큰 + quotes FK"
|
||||||
|
```
|
||||||
|
|
||||||
|
(운영 적용은 배포 전 — 클라우드+NAS 양쪽. Phase 2와 동일 절차)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `lib/request-status.ts` (상태 머신, TDD) + `lib/request-emails.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `lib/request-status.ts`
|
||||||
|
- Test: `lib/__tests__/request-status.test.ts`
|
||||||
|
- Create: `lib/request-emails.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 실패하는 테스트** — `lib/__tests__/request-status.test.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { REQUEST_STATUS, TIMELINE_STEPS, timelineIndex, isRequestStatus } from '@/lib/request-status';
|
||||||
|
|
||||||
|
describe('request-status', () => {
|
||||||
|
it('8개 상태 라벨 정의', () => {
|
||||||
|
expect(Object.keys(REQUEST_STATUS)).toHaveLength(8);
|
||||||
|
expect(REQUEST_STATUS.quoted.label).toBe('견적 발송');
|
||||||
|
});
|
||||||
|
it('타임라인은 정주행 6단계', () => {
|
||||||
|
expect(TIMELINE_STEPS).toEqual(['pending','reviewing','quoted','accepted','in_progress','completed']);
|
||||||
|
});
|
||||||
|
it('timelineIndex — 정주행 상태는 해당 인덱스', () => {
|
||||||
|
expect(timelineIndex('pending')).toBe(0);
|
||||||
|
expect(timelineIndex('completed')).toBe(5);
|
||||||
|
});
|
||||||
|
it('timelineIndex — on_hold는 quoted 위치(2), cancelled는 -1', () => {
|
||||||
|
expect(timelineIndex('on_hold')).toBe(2);
|
||||||
|
expect(timelineIndex('cancelled')).toBe(-1);
|
||||||
|
});
|
||||||
|
it('isRequestStatus 가드', () => {
|
||||||
|
expect(isRequestStatus('quoted')).toBe(true);
|
||||||
|
expect(isRequestStatus('nope')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 실패 확인** — `npm test` → FAIL
|
||||||
|
- [ ] **Step 3: 구현** — `lib/request-status.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/** 외주 의뢰 상태 머신 — DB CHECK(2026-06-12-client-portal.sql)와 단일 동기 소스 */
|
||||||
|
export type RequestStatus =
|
||||||
|
| 'pending' | 'reviewing' | 'quoted' | 'accepted'
|
||||||
|
| 'on_hold' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export const REQUEST_STATUS: Record<RequestStatus, { label: string }> = {
|
||||||
|
pending: { label: '접수' },
|
||||||
|
reviewing: { label: '검토중' },
|
||||||
|
quoted: { label: '견적 발송' },
|
||||||
|
accepted: { label: '수주 확정' },
|
||||||
|
on_hold: { label: '보류' },
|
||||||
|
in_progress: { label: '진행중' },
|
||||||
|
completed: { label: '완료' },
|
||||||
|
cancelled: { label: '취소' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 고객 타임라인 정주행 단계 (on_hold/cancelled는 별도 표기) */
|
||||||
|
export const TIMELINE_STEPS: RequestStatus[] = [
|
||||||
|
'pending', 'reviewing', 'quoted', 'accepted', 'in_progress', 'completed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 타임라인에서 현재 위치. on_hold→quoted 위치, cancelled→-1 */
|
||||||
|
export function timelineIndex(status: RequestStatus): number {
|
||||||
|
if (status === 'cancelled') return -1;
|
||||||
|
if (status === 'on_hold') return TIMELINE_STEPS.indexOf('quoted');
|
||||||
|
return TIMELINE_STEPS.indexOf(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRequestStatus(v: unknown): v is RequestStatus {
|
||||||
|
return typeof v === 'string' && v in REQUEST_STATUS;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 통과 확인** — `npm test` → 기존 5 + 신규 5 = 10 passed
|
||||||
|
- [ ] **Step 5: `lib/request-emails.ts`** (escapeHtml은 `@/lib/security`에서):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Resend } from 'resend';
|
||||||
|
import { escapeHtml } from '@/lib/security';
|
||||||
|
|
||||||
|
const FROM = '쟁승메이드 <noreply@jaengseung-made.com>';
|
||||||
|
const ADMIN_EMAIL = 'bgg8988@gmail.com';
|
||||||
|
const SITE = 'https://jaengseung-made.com';
|
||||||
|
|
||||||
|
function resend() {
|
||||||
|
return new Resend(process.env.RESEND_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 의뢰 접수 확인 — 고객에게 추적 링크 발송 */
|
||||||
|
export async function sendRequestReceivedEmail(opts: {
|
||||||
|
name: string; email: string; service: string; publicToken: string;
|
||||||
|
}) {
|
||||||
|
const { name, email, service, publicToken } = opts;
|
||||||
|
await resend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: [email],
|
||||||
|
subject: '[쟁승메이드] 의뢰가 접수되었습니다',
|
||||||
|
html: `
|
||||||
|
<h2>의뢰가 접수되었습니다</h2>
|
||||||
|
<p>${escapeHtml(name)}님, <strong>${escapeHtml(service)}</strong> 의뢰가 정상 접수되었습니다.</p>
|
||||||
|
<p>영업일 2일 내에 회신드리며, 아래 링크에서 진행 상태를 언제든 확인하실 수 있습니다.</p>
|
||||||
|
<p><a href="${SITE}/track/${publicToken}">의뢰 진행 상태 확인하기</a></p>
|
||||||
|
<hr />
|
||||||
|
<p style="color:#666;font-size:12px;">이 링크는 본인 확인용입니다. 타인과 공유하지 마세요.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 견적 발송 — 고객에게 견적 링크 */
|
||||||
|
export async function sendQuoteSentEmail(opts: {
|
||||||
|
clientName: string; clientEmail: string; quoteTitle: string; quoteToken: string; validUntil: string | null;
|
||||||
|
}) {
|
||||||
|
const { clientName, clientEmail, quoteTitle, quoteToken, validUntil } = opts;
|
||||||
|
await resend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: [clientEmail],
|
||||||
|
subject: `[쟁승메이드] 견적서가 도착했습니다 — ${escapeHtml(quoteTitle)}`,
|
||||||
|
html: `
|
||||||
|
<h2>견적서를 보내드립니다</h2>
|
||||||
|
<p>${escapeHtml(clientName)}님, 요청하신 건의 견적서가 준비되었습니다.</p>
|
||||||
|
<p><a href="${SITE}/quote/${quoteToken}">견적서 확인하기</a></p>
|
||||||
|
${validUntil ? `<p style="color:#666;font-size:13px;">유효기간: ${escapeHtml(validUntil.slice(0, 10))}</p>` : ''}
|
||||||
|
<p>견적서 페이지에서 바로 수락하시거나, 회신으로 문의 주세요.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 견적 수락/거절 — 관리자 알림 */
|
||||||
|
export async function sendQuoteDecisionEmail(opts: {
|
||||||
|
decision: 'accepted' | 'rejected'; quoteTitle: string; clientName: string; total?: number;
|
||||||
|
}) {
|
||||||
|
const { decision, quoteTitle, clientName, total } = opts;
|
||||||
|
const label = decision === 'accepted' ? '수락' : '거절';
|
||||||
|
await resend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: [ADMIN_EMAIL],
|
||||||
|
subject: `[쟁승메이드] 견적 ${label} — ${escapeHtml(quoteTitle)}`,
|
||||||
|
html: `
|
||||||
|
<h2>고객이 견적을 ${label}했습니다</h2>
|
||||||
|
<p>견적: ${escapeHtml(quoteTitle)} / 고객: ${escapeHtml(clientName)}</p>
|
||||||
|
${typeof total === 'number' ? `<p>수락 금액: ₩${total.toLocaleString('ko-KR')}</p>` : ''}
|
||||||
|
<p><a href="${SITE}/admin/quotes">견적 관리로 이동</a></p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 빌드 확인 + Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test && npm run build
|
||||||
|
git add lib/request-status.ts lib/__tests__/request-status.test.ts lib/request-emails.ts
|
||||||
|
git commit -m "feat(portal): 의뢰 상태 머신(TDD) + 의뢰/견적 메일"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `/api/contact` 확장 — 구조화 필드 + 토큰 + 고객 접수 메일
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/api/contact/route.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 기존 검증·rate limit·관리자 메일 **무수정** 유지하고:
|
||||||
|
1. body에서 `projectType`/`budget`/`timeline`도 sanitizeStr(각 100자)로 수신 (없으면 null — 기존 호출자 호환)
|
||||||
|
2. `const publicToken = crypto.randomUUID();` (Node crypto — `import crypto from 'crypto'` 또는 Web Crypto `globalThis.crypto.randomUUID()`)
|
||||||
|
3. insert에 `public_token: publicToken, project_type, budget, timeline` 추가
|
||||||
|
4. **insert 성공 후** 고객 접수 확인 메일: `sendRequestReceivedEmail({ name, email, service: service || '외주 문의', publicToken })` — try/catch로 실패 격리(console.error)
|
||||||
|
5. 성공 응답에 `trackUrl: '/track/' + publicToken` 포함 (폼 완료 화면에서 안내용)
|
||||||
|
- [ ] **Step 2:** `npm run build` + dev에서 curl로 기존 검증(빈 body 400) 회귀 확인
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/api/contact/route.ts
|
||||||
|
git commit -m "feat(contact): 구조화 필드 + 추적 토큰 + 고객 접수 확인 메일"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 단계형 의뢰 폼 — `OutsourcingRequestForm`
|
||||||
|
|
||||||
|
> **designer + soft-skill 로드 필수.**
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/components/OutsourcingRequestForm.tsx`
|
||||||
|
- Modify: `app/outsourcing/page.tsx` (#contact 섹션의 ContactForm → 신규 폼 교체)
|
||||||
|
- 보존: `app/components/ContactForm.tsx` (레거시 페이지 사용 가능성 — 무수정)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 4단계 폼 구현** ('use client', 진행 표시기 포함):
|
||||||
|
|
||||||
|
| 단계 | 필드 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| ① 프로젝트 유형 | 카드 선택 1개: 웹 서비스 / 웹사이트 / 업무 자동화 / API·백엔드 / 봇 개발 / AI 연동 / 기타 | `projectType` |
|
||||||
|
| ② 예산·일정 | 예산 선택(100만원 미만 / 100~300 / 300~1,000 / 1,000만원 이상 / 미정) + 희망 일정 선택(1개월 내 / 1~3개월 / 3개월+ / 미정) | `budget`, `timeline` |
|
||||||
|
| ③ 상세 내용 | textarea (필수, "참고 서비스·기능·현재 상황을 자유롭게") | `message` |
|
||||||
|
| ④ 연락처 | 이름(필수)·이메일(필수)·연락처(선택) — **로그인 상태면 createClient().auth.getUser()로 이메일 자동 채움** | |
|
||||||
|
|
||||||
|
- 단계 이동: [다음]/[이전], 각 단계 유효성 검사 후 진행. 제출 시 `POST /api/contact`에 `{ name, phone, email, service: '외주 개발 문의 — ' + projectType, message, projectType, budget, timeline }`
|
||||||
|
- 완료 화면: "의뢰가 접수되었습니다. 영업일 2일 내 회신드립니다." + 응답의 `trackUrl`로 [진행 상태 확인하기] 버튼 + "메일로도 추적 링크를 보내드렸습니다"
|
||||||
|
- 디자인: `--jsm-*` 토큰, outsourcing 페이지 톤과 일관. 단계 표시기는 숫자+라벨의 절제된 형태
|
||||||
|
|
||||||
|
- [ ] **Step 2:** `app/outsourcing/page.tsx` #contact 섹션에서 `<ContactForm />` → `<OutsourcingRequestForm />` 교체 (섹션 제목·안내 문구 유지)
|
||||||
|
- [ ] **Step 3:** build + dev: /outsourcing 200 + 폼 마크업 존재. 가능하면 실제 제출 1회(메일 2통: 관리자+고객 확인)
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/components/OutsourcingRequestForm.tsx app/outsourcing/page.tsx
|
||||||
|
git commit -m "feat(outsourcing): 4단계 의뢰 폼 + 접수 완료 추적 안내"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: `/track/[token]` 비회원 추적 페이지
|
||||||
|
|
||||||
|
> **designer + soft-skill 로드 필수.**
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `app/api/track/[token]/route.ts`
|
||||||
|
- Create: `app/track/[token]/page.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: API** — GET, 서버 admin client로 토큰 조회 (RLS 우회 — 토큰 자체가 비밀):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { createAdminClient } from '@/lib/supabase/admin';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ token: string }> }) {
|
||||||
|
const { token } = await params;
|
||||||
|
if (!token || token.length > 64) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data: request } = await admin
|
||||||
|
.from('contact_requests')
|
||||||
|
.select('id, name, service, status, project_type, budget, timeline, created_at, updated_at')
|
||||||
|
.eq('public_token', token)
|
||||||
|
.maybeSingle();
|
||||||
|
if (!request) return NextResponse.json({ error: 'not found' }, { status: 404 });
|
||||||
|
|
||||||
|
// 연결된 견적 (sent 이상만 노출 — draft는 비공개)
|
||||||
|
const { data: quote } = await admin
|
||||||
|
.from('quotes')
|
||||||
|
.select('public_token, title, status, valid_until')
|
||||||
|
.eq('contact_request_id', request.id)
|
||||||
|
.in('status', ['sent', 'accepted', 'rejected'])
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
return NextResponse.json({ request, quote: quote ?? null });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(이메일 등 PII는 응답에서 제외 — name·service·상태만)
|
||||||
|
|
||||||
|
- [ ] **Step 2: 페이지** — 서버 컴포넌트(자체 fetch 대신 위 로직을 직접 호출해도 됨 — admin client 직접 사용 가능). 구성:
|
||||||
|
- 헤더: "의뢰 진행 상태" + 의뢰명(service)·접수일
|
||||||
|
- **타임라인**: `TIMELINE_STEPS` × `timelineIndex(status)` — 완료 단계는 accent 채움, 현재 단계 강조, 미래 단계는 회색. `on_hold`면 "보류 중" 배너, `cancelled`면 "취소됨" 배너
|
||||||
|
- **견적 카드** (quote 있을 때): 제목·상태·유효기간 + [견적서 보기 → /quote/[quote.public_token]]
|
||||||
|
- 하단: "문의: bgg8988@gmail.com" + 회원이면 mypage 안내
|
||||||
|
- 토큰 불일치 → `notFound()`
|
||||||
|
- metadata: `robots: { index: false }` (비공개 페이지)
|
||||||
|
- [ ] **Step 3:** build + dev: 존재하지 않는 토큰 404, (마이그레이션 적용 전 로컬은 컬럼 없음 에러 가능 → try/catch notFound 폴백 확인)
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/api/track/ app/track/
|
||||||
|
git commit -m "feat(portal): /track/[token] 비회원 의뢰 추적 페이지"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: 견적 연계 — contact 연결·발송 메일·상태 동기화 (admin)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/api/admin/quotes/route.ts` (POST에 contact_request_id·프리필)
|
||||||
|
- Create: `app/api/admin/quotes/[id]/send/route.ts` (견적 발송)
|
||||||
|
- Modify: `app/admin/contacts/page.tsx` (상세 패널에 [견적서 작성] 버튼)
|
||||||
|
|
||||||
|
- [ ] **Step 1: quotes POST 확장** — body에 `contact_request_id?`, `client_name?`, `client_email?` 허용. `contact_request_id`가 오면 insert에 포함. (기존 무인자 호출 호환 유지)
|
||||||
|
- [ ] **Step 2: 발송 API** — `app/api/admin/quotes/[id]/send/route.ts` POST (checkAuth 패턴):
|
||||||
|
1. quotes에서 id로 조회 — client_email 없으면 400 "고객 이메일을 먼저 입력하세요"
|
||||||
|
2. `public_token` 없으면 `crypto.randomUUID()` 생성·저장
|
||||||
|
3. `quotes.status = 'sent'` + updated_at
|
||||||
|
4. `contact_request_id` 있으면 `contact_requests.status = 'quoted'` + updated_at 동기화
|
||||||
|
5. `sendQuoteSentEmail({ clientName: quote.client_name, clientEmail, quoteTitle: quote.title, quoteToken, validUntil })` — try/catch, 실패 시 응답에 `emailSent: false` 표시 (상태 변경은 유지)
|
||||||
|
6. `{ success: true, emailSent }`
|
||||||
|
- [ ] **Step 3: admin/contacts 상세 패널에 [견적서 작성] 버튼** — 클릭 시 `POST /api/admin/quotes`에 `{ title: contact.service + ' — ' + (contact.name ?? ''), contact_request_id: contact.id, client_name: contact.name, client_email: contact.email }` → 응답 quote.id로 `/admin/quotes/[id]` 이동(기존 편집 페이지). 이미 연결된 견적이 있으면(추가 GET 필요 없이 단순 생성 허용 — 다건 연결 가능, 최신 sent만 고객 노출)
|
||||||
|
- [ ] **Step 4:** admin/quotes 편집 페이지(`app/admin/quotes/[id]/page.tsx`)에 **[고객에게 발송]** 버튼 추가 — `POST /api/admin/quotes/[id]/send` 호출, 성공 시 "발송됨 + 메일 전송 여부" 토스트/배너. (편집 페이지가 실제로 존재하는지 먼저 확인 — 없으면 BLOCKED 보고 말고 quotes 목록 페이지에 발송 버튼 추가로 대체하고 보고에 명시)
|
||||||
|
- [ ] **Step 5:** build + 비인증 curl 401 확인 + Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/api/admin/quotes/ app/admin/contacts/page.tsx app/admin/quotes/
|
||||||
|
git commit -m "feat(admin): 의뢰→견적 연결 생성 + 견적 발송(메일·상태 동기화)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: 견적 수락/거절 — 고객 측 + 동기화
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/api/quote/[token]/route.ts` (수락 POST에 동기화 + 거절 처리)
|
||||||
|
- Modify: `app/quote/[token]/page.tsx` (거절 버튼)
|
||||||
|
|
||||||
|
- [ ] **Step 1: API 확장** — 기존 POST(수락)에:
|
||||||
|
1. body에 `action: 'accept' | 'reject'` 추가 (기존 호출 호환: action 없으면 'accept')
|
||||||
|
2. reject면 `quotes.status='rejected'` + updated_at만 (accepted_* 미기록)
|
||||||
|
3. 공통: 해당 quote의 `contact_request_id`가 있으면 `contact_requests.status` 동기화 — accept→`'accepted'`, reject→`'on_hold'` (+updated_at)
|
||||||
|
4. `sendQuoteDecisionEmail({ decision, quoteTitle: quote.title, clientName: quote.client_name, total: acceptedTotal })` — try/catch 격리
|
||||||
|
5. 이미 accepted/rejected 상태면 409 "이미 처리된 견적입니다"
|
||||||
|
- [ ] **Step 2: 페이지** — 하단 고정 바에 [정중히 거절] 보조 버튼(고스트) 추가 — confirm("견적을 거절하시겠습니까? 다른 조건이 필요하시면 회신 주세요.") → POST {action:'reject'} → "의견 감사합니다. 조정이 필요하시면 언제든 회신 주세요" 화면. 수락 버튼·계산 로직 무수정
|
||||||
|
- [ ] **Step 3:** build + dev: 존재하지 않는 토큰 404 회귀 + Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add "app/api/quote/[token]/route.ts" "app/quote/[token]/page.tsx"
|
||||||
|
git commit -m "feat(quote): 거절 액션 + 의뢰 상태 동기화 + 관리자 알림"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: admin/contacts 상태 머신 고도화
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/admin/contacts/page.tsx`
|
||||||
|
- Modify: `app/api/admin/contacts/route.ts` (PATCH status 검증)
|
||||||
|
|
||||||
|
- [ ] **Step 1: API PATCH** — `isRequestStatus(status)` 검증 추가(불통과 400), update에 `updated_at` 포함
|
||||||
|
- [ ] **Step 2: 페이지** —
|
||||||
|
- STATUS 매핑을 `REQUEST_STATUS`(lib) 기반 8종으로 교체 (필터 탭: 전체/접수/검토중/견적 발송/수주 확정/진행중/완료 — on_hold·cancelled는 '기타' 묶음 또는 개별, 카운트 표시)
|
||||||
|
- 상세 패널: 상태 변경을 8종 드롭다운(또는 버튼 그룹)으로, project_type/budget/timeline 표시(있을 때), **/track 링크 복사 버튼**(public_token 있을 때), 연결 견적 존재 시 [견적 보기 → /admin/quotes/[id]] (GET 응답에 견적 join이 없으므로 — `/api/admin/contacts` GET에서 quotes(contact_request_id 매칭 id,title,status)를 2쿼리 머지로 포함)
|
||||||
|
- [ ] **Step 3:** build + 비인증 401 회귀 + Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/admin/contacts/page.tsx app/api/admin/contacts/route.ts
|
||||||
|
git commit -m "feat(admin): 의뢰 관리 8종 상태 머신 + 견적 연결·추적 링크 표시"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: mypage '내 의뢰' 타임라인
|
||||||
|
|
||||||
|
> **designer + soft-skill 로드 필수.**
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/mypage/page.tsx` (내 의뢰 탭만)
|
||||||
|
|
||||||
|
- [ ] **Step 1:** 의뢰 카드 확장 —
|
||||||
|
- StatusBadge를 `REQUEST_STATUS` 8종 라벨로 교체 (기존 3종 매핑 대체, 색상: completed 그린/in_progress·accepted accent/quoted accent-soft/cancelled·on_hold 회색 계열)
|
||||||
|
- 카드 클릭(또는 펼침) 시 **미니 타임라인** (`TIMELINE_STEPS`+`timelineIndex` — track 페이지와 동일 로직, 컴팩트 렌더)
|
||||||
|
- `public_token` 있으면 [상세 추적 → /track/[token]] 링크
|
||||||
|
- 연결 견적은 track API처럼 client에서 quotes를 직접 조회하지 않고(RLS) — 간단히 /track 링크로 유도 (YAGNI)
|
||||||
|
- [ ] **Step 2:** contact_requests select에 신규 컬럼(public_token, project_type, budget, timeline, updated_at) 포함되는지 확인(`select('*')`라 자동). 다른 탭 무수정
|
||||||
|
- [ ] **Step 3:** build + /mypage 200 + Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add app/mypage/page.tsx
|
||||||
|
git commit -m "feat(mypage): 내 의뢰 타임라인 + 추적 링크"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: 레거시 정리 — music 구매 고아 경로 차단 + CLAUDE.md 갱신
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `next.config.ts`
|
||||||
|
- Modify: `CLAUDE.md` (jaengseung-made)
|
||||||
|
|
||||||
|
- [ ] **Step 1: redirect 추가** — `/music/packs` → `/products` (permanent). `/music/samples`·`/music/studio`는 숨김 가드 유지(admin 열람용). 기존 `/services/music` redirect의 destination도 `/products`로 갱신(체인 방지)
|
||||||
|
- [ ] **Step 2: CLAUDE.md 갱신** — 다음 섹션을 현행화:
|
||||||
|
- 핵심 서비스 표: `/outsourcing`(외주)·`/products`(완성 소프트웨어)·숨김 서비스 목록(admin 토글)
|
||||||
|
- 디자인 시스템: `--jsm-*` 토큰 체계(slate+딥블루, Pretendard, 상단 네비 기업형) — 구 사이드바/보라 서술 교체
|
||||||
|
- 파일 구조 트리: products/outsourcing/track/admin(orders·products)/lib(product-access·product-files·order-emails·request-status·request-emails) 반영
|
||||||
|
- 결제: "계좌이체 orders 단일 소스(PG 보류, pay_method 플래그)" 명시
|
||||||
|
- 운영 주의에 추가: "마이그레이션은 클라우드+NAS 양쪽 적용", "2026-06-12-products-extend.sql의 pack_files 백필 UPDATE는 재실행 금지"
|
||||||
|
- 사주 시스템 섹션은 유지하되 상단에 "(현재 숨김 — admin 토글로 복귀 가능)" 1줄
|
||||||
|
- [ ] **Step 3:** build + `/music/packs` redirect 확인 + Commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add next.config.ts CLAUDE.md
|
||||||
|
git commit -m "chore: music 구매 고아 경로 차단(→/products) + CLAUDE.md 현행화"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Phase 3 E2E 검증
|
||||||
|
|
||||||
|
- [ ] **Step 1: 자동** — `npm test`(10 passed) + `npm run build` + prod 서버 curl:
|
||||||
|
- `/outsourcing` 200 + 단계형 폼 / `/track/없는토큰` 404 / `/quote/없는토큰` 404(회귀)
|
||||||
|
- POST `/api/contact` 빈 body 400(회귀) / `/api/admin/quotes/x/send` 비인증 401
|
||||||
|
- `/music/packs` → 308 `/products`
|
||||||
|
- Phase 1·2 회귀: `/` 200, `/products` 200, `/work/saju` 404, `/api/orders` 비로그인 401
|
||||||
|
- [ ] **Step 2: 수동 (운영 DB 마이그레이션 적용 후)** — 시나리오 A 전 과정:
|
||||||
|
의뢰 폼 4단계 제출 → 고객 접수 메일(추적 링크)+관리자 메일 → /track 타임라인 확인 → admin/contacts에서 검토중 전환·[견적서 작성] → 견적 편집·[고객에게 발송] → 고객 메일 링크로 /quote 열람 → 수락 → /track에 '수주 확정' 반영 + 관리자 수락 메일 / (별건) 거절 → on_hold 반영
|
||||||
|
- [ ] **Step 3: 최종 보고**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 운영 노트
|
||||||
|
|
||||||
|
- **배포 전**: `2026-06-12-client-portal.sql`을 클라우드+NAS 양쪽 적용 (Phase 2와 동일 절차 — heredoc 명령 제공 예정)
|
||||||
|
- 미적용 상태로 코드만 배포되면: 신규 의뢰 insert가 없는 컬럼으로 실패할 수 있음 → **반드시 선적용**
|
||||||
24
lib/__tests__/request-status.test.ts
Normal file
24
lib/__tests__/request-status.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { REQUEST_STATUS, TIMELINE_STEPS, timelineIndex, isRequestStatus } from '@/lib/request-status';
|
||||||
|
|
||||||
|
describe('request-status', () => {
|
||||||
|
it('8개 상태 라벨 정의', () => {
|
||||||
|
expect(Object.keys(REQUEST_STATUS)).toHaveLength(8);
|
||||||
|
expect(REQUEST_STATUS.quoted.label).toBe('견적 발송');
|
||||||
|
});
|
||||||
|
it('타임라인은 정주행 6단계', () => {
|
||||||
|
expect(TIMELINE_STEPS).toEqual(['pending','reviewing','quoted','accepted','in_progress','completed']);
|
||||||
|
});
|
||||||
|
it('timelineIndex — 정주행 상태는 해당 인덱스', () => {
|
||||||
|
expect(timelineIndex('pending')).toBe(0);
|
||||||
|
expect(timelineIndex('completed')).toBe(5);
|
||||||
|
});
|
||||||
|
it('timelineIndex — on_hold는 quoted 위치(2), cancelled는 -1', () => {
|
||||||
|
expect(timelineIndex('on_hold')).toBe(2);
|
||||||
|
expect(timelineIndex('cancelled')).toBe(-1);
|
||||||
|
});
|
||||||
|
it('isRequestStatus 가드', () => {
|
||||||
|
expect(isRequestStatus('quoted')).toBe(true);
|
||||||
|
expect(isRequestStatus('nope')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
lib/request-emails.ts
Normal file
68
lib/request-emails.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Resend } from 'resend';
|
||||||
|
import { escapeHtml } from '@/lib/security';
|
||||||
|
|
||||||
|
const FROM = '쟁승메이드 <noreply@jaengseung-made.com>';
|
||||||
|
const ADMIN_EMAIL = 'bgg8988@gmail.com';
|
||||||
|
const SITE = 'https://jaengseung-made.com';
|
||||||
|
|
||||||
|
function resend() {
|
||||||
|
return new Resend(process.env.RESEND_API_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 의뢰 접수 확인 — 고객에게 추적 링크 발송 */
|
||||||
|
export async function sendRequestReceivedEmail(opts: {
|
||||||
|
name: string; email: string; service: string; publicToken: string;
|
||||||
|
}) {
|
||||||
|
const { name, email, service, publicToken } = opts;
|
||||||
|
await resend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: [email],
|
||||||
|
subject: '[쟁승메이드] 의뢰가 접수되었습니다',
|
||||||
|
html: `
|
||||||
|
<h2>의뢰가 접수되었습니다</h2>
|
||||||
|
<p>${escapeHtml(name)}님, <strong>${escapeHtml(service)}</strong> 의뢰가 정상 접수되었습니다.</p>
|
||||||
|
<p>영업일 2일 내에 회신드리며, 아래 링크에서 진행 상태를 언제든 확인하실 수 있습니다.</p>
|
||||||
|
<p><a href="${SITE}/track/${publicToken}">의뢰 진행 상태 확인하기</a></p>
|
||||||
|
<hr />
|
||||||
|
<p style="color:#666;font-size:12px;">이 링크는 본인 확인용입니다. 타인과 공유하지 마세요.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 견적 발송 — 고객에게 견적 링크 */
|
||||||
|
export async function sendQuoteSentEmail(opts: {
|
||||||
|
clientName: string; clientEmail: string; quoteTitle: string; quoteToken: string; validUntil: string | null;
|
||||||
|
}) {
|
||||||
|
const { clientName, clientEmail, quoteTitle, quoteToken, validUntil } = opts;
|
||||||
|
await resend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: [clientEmail],
|
||||||
|
subject: `[쟁승메이드] 견적서가 도착했습니다 — ${quoteTitle}`,
|
||||||
|
html: `
|
||||||
|
<h2>견적서를 보내드립니다</h2>
|
||||||
|
<p>${escapeHtml(clientName)}님, 요청하신 건의 견적서가 준비되었습니다.</p>
|
||||||
|
<p><a href="${SITE}/quote/${quoteToken}">견적서 확인하기</a></p>
|
||||||
|
${validUntil ? `<p style="color:#666;font-size:13px;">유효기간: ${escapeHtml(validUntil.slice(0, 10))}</p>` : ''}
|
||||||
|
<p>견적서 페이지에서 바로 수락하시거나, 회신으로 문의 주세요.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 견적 수락/거절 — 관리자 알림 */
|
||||||
|
export async function sendQuoteDecisionEmail(opts: {
|
||||||
|
decision: 'accepted' | 'rejected'; quoteTitle: string; clientName: string; total?: number;
|
||||||
|
}) {
|
||||||
|
const { decision, quoteTitle, clientName, total } = opts;
|
||||||
|
const label = decision === 'accepted' ? '수락' : '거절';
|
||||||
|
await resend().emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to: [ADMIN_EMAIL],
|
||||||
|
subject: `[쟁승메이드] 견적 ${label} — ${quoteTitle}`,
|
||||||
|
html: `
|
||||||
|
<h2>고객이 견적을 ${label}했습니다</h2>
|
||||||
|
<p>견적: ${escapeHtml(quoteTitle)} / 고객: ${escapeHtml(clientName)}</p>
|
||||||
|
${typeof total === 'number' ? `<p>수락 금액: ₩${total.toLocaleString('ko-KR')}</p>` : ''}
|
||||||
|
<p><a href="${SITE}/admin/quotes">견적 관리로 이동</a></p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
31
lib/request-status.ts
Normal file
31
lib/request-status.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/** 외주 의뢰 상태 머신 — DB CHECK(2026-06-12-client-portal.sql)와 단일 동기 소스 */
|
||||||
|
export type RequestStatus =
|
||||||
|
| 'pending' | 'reviewing' | 'quoted' | 'accepted'
|
||||||
|
| 'on_hold' | 'in_progress' | 'completed' | 'cancelled';
|
||||||
|
|
||||||
|
export const REQUEST_STATUS: Record<RequestStatus, { label: string }> = {
|
||||||
|
pending: { label: '접수' },
|
||||||
|
reviewing: { label: '검토중' },
|
||||||
|
quoted: { label: '견적 발송' },
|
||||||
|
accepted: { label: '수주 확정' },
|
||||||
|
on_hold: { label: '보류' },
|
||||||
|
in_progress: { label: '진행중' },
|
||||||
|
completed: { label: '완료' },
|
||||||
|
cancelled: { label: '취소' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 고객 타임라인 정주행 단계 (on_hold/cancelled는 별도 표기) */
|
||||||
|
export const TIMELINE_STEPS: RequestStatus[] = [
|
||||||
|
'pending', 'reviewing', 'quoted', 'accepted', 'in_progress', 'completed',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 타임라인에서 현재 위치. on_hold→quoted 위치, cancelled→-1 */
|
||||||
|
export function timelineIndex(status: RequestStatus): number {
|
||||||
|
if (status === 'cancelled') return -1;
|
||||||
|
if (status === 'on_hold') return TIMELINE_STEPS.indexOf('quoted');
|
||||||
|
return TIMELINE_STEPS.indexOf(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRequestStatus(v: unknown): v is RequestStatus {
|
||||||
|
return typeof v === 'string' && v in REQUEST_STATUS;
|
||||||
|
}
|
||||||
@@ -36,7 +36,9 @@ const nextConfig: NextConfig = {
|
|||||||
async redirects() {
|
async redirects() {
|
||||||
return [
|
return [
|
||||||
// Music 사업부 마이그
|
// Music 사업부 마이그
|
||||||
{ source: '/services/music', destination: '/music/packs', permanent: true },
|
{ source: '/services/music', destination: '/products', permanent: true },
|
||||||
|
// music/packs 고아 구매 경로 차단 — Phase 2 orders 시스템으로 통합(2026-06-12)
|
||||||
|
{ source: '/music/packs', destination: '/products', permanent: true },
|
||||||
{ source: '/services/music/samples', destination: '/music/samples', permanent: true },
|
{ source: '/services/music/samples', destination: '/music/samples', permanent: true },
|
||||||
{ source: '/studio', destination: '/music/studio', permanent: true },
|
{ source: '/studio', destination: '/music/studio', permanent: true },
|
||||||
// 커스텀 외주 마이그 (2026-06-11 리뉴얼: work 라우트 → /outsourcing 통합)
|
// 커스텀 외주 마이그 (2026-06-11 리뉴얼: work 라우트 → /outsourcing 통합)
|
||||||
|
|||||||
28
supabase/migrations/2026-06-12-client-portal.sql
Normal file
28
supabase/migrations/2026-06-12-client-portal.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- 2026-06-12 Phase 3: 외주 고객 포털
|
||||||
|
-- (1) contact_requests 확장
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS public_token text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS project_type text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS budget text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS timeline text;
|
||||||
|
ALTER TABLE contact_requests ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
|
||||||
|
|
||||||
|
-- 기존 행 토큰 백필 (멱등 — NULL만)
|
||||||
|
UPDATE contact_requests SET public_token = gen_random_uuid()::text WHERE public_token IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE contact_requests ALTER COLUMN public_token SET DEFAULT gen_random_uuid()::text;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_requests_public_token_unique ON contact_requests (public_token);
|
||||||
|
|
||||||
|
-- 상태 머신 CHECK (레거시 3종 포함 8종)
|
||||||
|
ALTER TABLE contact_requests DROP CONSTRAINT IF EXISTS contact_requests_status_check;
|
||||||
|
ALTER TABLE contact_requests ADD CONSTRAINT contact_requests_status_check
|
||||||
|
CHECK (status IN ('pending','reviewing','quoted','accepted','on_hold','in_progress','completed','cancelled'));
|
||||||
|
|
||||||
|
-- (2) quotes ↔ contact_requests 연결
|
||||||
|
ALTER TABLE quotes ADD COLUMN IF NOT EXISTS contact_request_id uuid REFERENCES contact_requests(id) ON DELETE SET NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_quotes_contact_request ON quotes (contact_request_id);
|
||||||
|
|
||||||
|
-- (3) quotes.public_token 컬럼 보장 (live DB에 직접 생성된 경우 대비)
|
||||||
|
ALTER TABLE quotes ADD COLUMN IF NOT EXISTS public_token text;
|
||||||
|
ALTER TABLE quotes ALTER COLUMN public_token SET DEFAULT gen_random_uuid()::text;
|
||||||
|
UPDATE quotes SET public_token = gen_random_uuid()::text WHERE public_token IS NULL;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_quotes_public_token_unique ON quotes (public_token);
|
||||||
Reference in New Issue
Block a user