Compare commits

...

16 Commits

Author SHA1 Message Date
76fb722a27 fix(docs): CLAUDE.md 사실 정정
- Next.js 버전 15 → 16 (package.json ^16.2.6 기준)
- GET /api/packs/sign-link → POST (실제 route.ts export async function POST)

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 01:29:36 +09:00
f7d26c4c3f feat(portal): 의뢰 상태 머신(TDD) + 의뢰/견적 메일 2026-06-12 01:21:30 +09:00
5077f6ad17 feat(db): 고객 포털 — contact_requests 상태머신·토큰 + quotes FK 2026-06-12 01:18:51 +09:00
5751cddcea docs(plan): 리뉴얼 Phase 3 구현 계획 — 외주 고객 포털 + 레거시 정리
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:16:46 +09:00
20 changed files with 2762 additions and 153 deletions

196
CLAUDE.md
View File

@@ -2,7 +2,7 @@
## 프로젝트 개요
7년차 대기업 백엔드 개발자 **박재오**가 운영하는 개발 부업 사이트.
고객 맞춤형 서비스를 개발·판매하거나, 이미 완성된 솔루션을 구독 형태로 제공한다.
고객 맞춤형 서비스를 외주 개발하거나, 이미 완성된 솔루션을 계좌이체 구매 형태로 제공한다.
## 운영자 정보
- 이름: 박재오
@@ -11,51 +11,159 @@
- 연락처: 010-3907-1392
- NAS 개인 서버: 로또 랩, 주식 자동매매 프로그램 등 실제 서비스 운영 중
## 핵심 서비스
| 서비스 | 경로 | 설명 |
|--------|------|------|
| 로또 번호 추천 | `/services/lotto` | 빅데이터/통계 기반 로또 번호 분석 제공 |
| 주식 자동 매매 | `/services/stock` | 텔레그램 연동 주식 자동 매매 프로그램 |
| 프롬프트 엔지니어링 | `/services/prompt` | 업무 특화 AI 프롬프트 설계 서비스 |
| 업무 자동화 | `/services/automation` | RPA·엑셀·이메일 등 일상 업무 자동화 개발 |
| 외주 개발 | `/freelance` | 맞춤형 소프트웨어 외주 (포트폴리오 + 문의) |
## 핵심 IA (공개 라우트)
| 경로 | 설명 |
|------|------|
| `/` | 메인 — 외주 개발 + 완성 소프트웨어 2축 소개 |
| `/outsourcing` | 외주 개발 — 4단계 의뢰 폼 · 프로세스 · 포트폴리오 · FAQ |
| `/products` | 완성 소프트웨어 목록 — 계좌이체 구매 |
| `/products/[id]` | 제품 상세 — 구매 신청·결제 안내 |
| `/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)
- **Styling**: Tailwind CSS v4
- **Email**: Resend (API key: 환경변수 `RESEND_API_KEY`)
- **DB**: Supabase (클라우드 + NAS self-host 이중 운영)
- **Email**: Resend (`RESEND_API_KEY`) — 문의 접수·주문 확인·견적 발송 메일
- **Analytics**: Google Analytics G-WG77RNHXRK
- **Deployment**: Vercel
- **Test**: vitest (`npm test`) — lib 단위 테스트
- **Deployment**: Vercel (NAS self-host 전환 진행 중, 컷오버 전 Vercel 운영)
## 디자인 시스템
- **Primary**: Blue (`#1d4ed8` blue-700, `#2563eb` blue-600)
- **Secondary**: Violet/Purple (`#7c3aed` violet-600, `#8b5cf6` violet-500)
- **Layout**: 대시보드형 — 왼쪽 고정 사이드바 + 오른쪽 스크롤 콘텐츠
- **Sidebar bg**: `#0f172a` (slate-900)
- **Main bg**: `#f1f5f9` (slate-100)
- **Cards**: white + 그림자
## 디자인 시스템 (`--jsm-*` 토큰)
### CSS 변수
| 토큰 | 값 | 역할 |
|------|----|------|
| `--jsm-bg` | `#f8fafc` | 페이지 배경 |
| `--jsm-surface` | `#ffffff` | 카드·패널 배경 |
| `--jsm-ink` | `#0f172a` | 본문 텍스트 |
| `--jsm-line` | `#e2e8f0` | 구분선·테두리 |
| `--jsm-navy` | `#0b1f3a` | 헤더·강조 배경 |
| `--jsm-accent` | `#1d4ed8` | 단일 포인트 컬러 (버튼·링크) |
### 레이아웃
- 상단 네비(`TopNav`) + 푸터 포함 `PublicShell` 기업형 레이아웃
- Pretendard 폰트
### 금지 가이드레일
- gradient / blur / 보라(violet/purple) 계열 색상 사용 금지
- 이모지 사용 금지 (UI 내)
- `--jsm-*` 토큰 외 임의 색상 변수 추가 금지
## 파일 구조
```
app/
layout.tsx — 루트 레이아웃 (메타데이터, 폰트, GA, DashboardShell 래핑)
page.tsx — 홈 대시보드 (서비스 카드 그리드)
globals.css — 전역 스타일 + CSS 변수
components/
DashboardShell.tsx — 클라이언트: 사이드바 + 메인 영역 레이아웃 래퍼
Sidebar.tsx — 클라이언트: 왼쪽 사이드바 내비게이션
ContactForm.tsx — 클라이언트: 문의 폼 (Resend 연동)
services/
lotto/page.tsx — 로또 번호 추천 서비스 상세
stock/page.tsx — 주식 자동 매매 서비스 상세
prompt/page.tsx — 프롬프트 엔지니어링 서비스 상세
automation/page.tsx — 업무 자동화 서비스 상세
freelance/
page.tsx외주 개발 포트폴리오 + 문의 폼
layout.tsx — 루트 레이아웃 (메타데이터·폰트·GA·PublicShell)
page.tsx — 메인 (2축 랜딩)
globals.css — 전역 스타일 + --jsm-* CSS 변수
components/ — 공용 UI (TopNav, PublicShell, ContactForm 등)
outsourcing/page.tsx — 외주 의뢰 페이지
products/
page.tsx — 완성 소프트웨어 목록
[id]/page.tsx — 제품 상세 + 구매 신청
track/[token]/page.tsx — 비회원 의뢰 추적
quote/[token]/page.tsx — 공개 견적 수락/거절
login/page.tsx — 로그인 (?next= 지원)
mypage/page.tsx — 마이페이지 4탭
legal/ — privacy / terms / refund
admin/ 관리자 전용 (dashboard·members·services·orders·products·contacts·quotes·packs·...)
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/`)
쟁승메이드는 **회사 단위 AI 팀**으로 운영됩니다.
@@ -104,16 +212,9 @@ app/
---
## 개발 규칙
- 서비스 페이지 공통 구조: Hero → Features → Pricing → FAQ → CTA
- 구매/신청 CTA는 `/freelance` 페이지 ContactForm으로 연결 (service 파라미터로 pre-fill)
- 사이드바는 `usePathname`으로 활성 경로 감지
- 모바일: 햄버거 메뉴로 사이드바 토글 (overlay 포함)
- 이미지 없이 아이콘·그래디언트·SVG로 시각적 완성도 유지
## 사주 시스템 (`/app/work/saju`, `/lib/saju-*.ts`)
---
## 사주 시스템 (`/app/saju`, `/lib/saju-*.ts`)
> **서비스는 현재 숨김 — `/admin/services` 토글로 복귀 가능**
### AI 연동 (`app/api/saju/analyze/route.ts`)
- **AI**: Google Gemini (`@google/generative-ai`)
@@ -124,7 +225,7 @@ app/
- **Vercel 타임아웃**: `export const maxDuration = 60` (Pro 플랜 기준)
- **Mock 감지**: `isMockInterpretation()` 함수로 DB에 캐시된 예시 데이터 판별
- `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 처리)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 });
}

View 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>
);
}

View File

@@ -7,6 +7,13 @@ import { createClient } from '@/lib/supabase/client';
import type { User } from '@supabase/supabase-js';
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
import {
REQUEST_STATUS,
TIMELINE_STEPS,
timelineIndex,
isRequestStatus,
type RequestStatus,
} from '@/lib/request-status';
// 마이페이지 — 4탭 재구성 (프로필 / 내 의뢰 / 내 제품 / 주문 내역).
// PublicShell(TopNav)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
@@ -53,6 +60,12 @@ interface Order {
service: string;
message: 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 응답)
@@ -86,6 +99,8 @@ function MyPageContent() {
const [productGroups, setProductGroups] = useState<ProductGroup[]>([]);
const [productOrders, setProductOrders] = useState<ProductOrder[]>([]);
const [downloading, setDownloading] = useState<string | null>(null);
// 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘)
const [expandedRequests, setExpandedRequests] = useState<Set<string>>(new Set());
// 텔레그램 연동 상태
const [telegramChatId, setTelegramChatId] = useState<string | null>(null);
@@ -195,6 +210,15 @@ function MyPageContent() {
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) {
setDownloading(fileId);
try {
@@ -503,23 +527,12 @@ function MyPageContent() {
) : (
<div className="space-y-3">
{orders.map((o) => (
<Card key={o.id} compact>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{o.service}
</div>
<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>
<RequestCard
key={o.id}
order={o}
expanded={expandedRequests.has(o.id)}
onToggle={() => toggleRequest(o.id)}
/>
))}
</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 }) {
const map: Record<string, { label: string; style: React.CSSProperties }> = {
completed: { label: '완료', style: { background: '#dcfce7', color: '#166534' } },
in_progress: { label: '진행중', style: { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' } },
pending: { label: '대기중', style: { 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)' },
};
const known = isRequestStatus(status);
const label = known ? REQUEST_STATUS[status].label : status;
const style = known
? STATUS_BADGE_STYLE[status]
: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' };
return (
<span
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>
);
}
// 펼침 토글 셰브론
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({
title,
desc,

View File

@@ -1,6 +1,6 @@
import Link from 'next/link';
import type { Metadata } from 'next';
import ContactForm from '@/app/components/ContactForm';
import OutsourcingRequestForm from '@/app/components/OutsourcingRequestForm';
// 외주 개발 의뢰 페이지 (서버 컴포넌트)
// PublicShell이 TopNav(h-16)·푸터·main 배경을 제공하므로 여기서는 콘텐츠 섹션만 렌더한다.
@@ -528,7 +528,7 @@ export default function OutsourcingPage() {
className="rounded-2xl p-6 lg:p-8"
style={{ background: 'var(--jsm-surface)' }}
>
<ContactForm />
<OutsourcingRequestForm />
</div>
</div>
</div>

View File

@@ -37,6 +37,8 @@ export default function QuotePage() {
const [activeTab, setActiveTab] = useState<'overview' | 'wbs' | 'quote' | 'maintenance'>('overview');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [rejected, setRejected] = useState(false);
const [alreadyProcessed, setAlreadyProcessed] = useState(false);
const [isPrinting, setIsPrinting] = useState(false);
useEffect(() => {
@@ -89,15 +91,31 @@ export default function QuotePage() {
if (!quote) return;
setSubmitting(true);
const selectedItems = quote.items.filter((i) => !i.optional || checkedOptional[i.id]).map((i) => i.id);
await fetch(`/api/quote/${token}`, {
const res = await fetch(`/api/quote/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ selectedItems, selectedMaintenance, total: grandTotal }),
});
setSubmitting(false);
if (res.status === 409) { setAlreadyProcessed(true); return; }
setSubmitted(true);
}
async function handleReject() {
if (!quote) return;
const confirmed = window.confirm('견적을 거절하시겠습니까? 조건 조정이 필요하시면 회신으로 말씀해 주세요.');
if (!confirmed) return;
setSubmitting(true);
const res = await fetch(`/api/quote/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'reject' }),
});
setSubmitting(false);
if (res.status === 409) { setAlreadyProcessed(true); return; }
setRejected(true);
}
if (loading) {
return (
<div style={{ minHeight: '100vh', background: '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 = [
{ key: 'overview', label: '개요' },
{ key: 'wbs', label: 'WBS', show: quote.wbs.length > 0 },
@@ -533,16 +575,29 @@ export default function QuotePage() {
)}
</div>
</div>
<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 style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<button onClick={handleReject} disabled={submitting}
style={{
padding: '14px 24px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.25)', cursor: 'pointer',
background: 'transparent',
color: 'rgba(255,255,255,0.75)', fontSize: 15, fontWeight: 600, transition: 'all 0.2s',
opacity: submitting ? 0.5 : 1,
}}
onMouseEnter={(e) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.color = 'white'; }}
onMouseLeave={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'rgba(255,255,255,0.75)'; }}>
</button>
<button onClick={handleAccept} disabled={submitting}
style={{
padding: '14px 36px', borderRadius: 12, border: 'none', cursor: 'pointer',
background: 'var(--jsm-accent)',
color: 'white', fontSize: 16, fontWeight: 700, transition: 'all 0.2s',
boxShadow: '0 8px 32px rgba(29,78,216,0.4)',
opacity: submitting ? 0.7 : 1,
}}>
{submitting ? '처리 중...' : '이 견적으로 진행하겠습니다 →'}
</button>
</div>
</div>
</div>
)}
@@ -554,6 +609,13 @@ export default function QuotePage() {
</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>

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

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

View File

@@ -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가 없는 컬럼으로 실패할 수 있음 → **반드시 선적용**

View 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
View 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
View 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;
}

View File

@@ -36,7 +36,9 @@ const nextConfig: NextConfig = {
async redirects() {
return [
// 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: '/studio', destination: '/music/studio', permanent: true },
// 커스텀 외주 마이그 (2026-06-11 리뉴얼: work 라우트 → /outsourcing 통합)

View 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);