From f5cfb8bd6f43298f040cb6507ce7c647e1275ae9 Mon Sep 17 00:00:00 2001 From: gahusb Date: Fri, 12 Jun 2026 05:13:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(portal):=20/track/[token]=20=EB=B9=84?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=9D=98=EB=A2=B0=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- app/api/track/[token]/route.ts | 31 +++ app/track/[token]/page.tsx | 434 +++++++++++++++++++++++++++++++++ 2 files changed, 465 insertions(+) create mode 100644 app/api/track/[token]/route.ts create mode 100644 app/track/[token]/page.tsx diff --git a/app/api/track/[token]/route.ts b/app/api/track/[token]/route.ts new file mode 100644 index 0000000..fa30a86 --- /dev/null +++ b/app/api/track/[token]/route.ts @@ -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 }); +} diff --git a/app/track/[token]/page.tsx b/app/track/[token]/page.tsx new file mode 100644 index 0000000..919db8d --- /dev/null +++ b/app/track/[token]/page.tsx @@ -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 = { + 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 ( + + + + ); +} + +function ArrowRight() { + return ( + + + + + ); +} + +/** 진행 단계 타임라인 — 모바일 세로 / 데스크톱 가로 */ +function Timeline({ current }: { current: number }) { + return ( +
    + {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 ( +
  1. + {/* 모바일: 세로 마커+연결선 / 데스크톱: 가로 */} +
    + {/* 데스크톱 좌측 연결선 (가로) */} + {i > 0 && ( + + )} + + {/* 마커 원 */} + + {isDone ? ( + + ) : ( + + )} + + + {/* 데스크톱 우측 연결선 (가로) */} + {!isLast && ( + + )} + + {/* 모바일 세로 연결선 */} + {!isLast && ( + + )} +
    + + {/* 라벨 */} +
    + + {label} + + {isCurrent && ( + + 진행 중 + + )} +
    +
  2. + ); + })} +
+ ); +} + +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 ( +
+
+ {/* ─── 헤더 ─── */} +
+ + 의뢰 진행 상태 + +

+ {request.service ?? '의뢰하신 프로젝트'} +

+ {receivedAt && ( +

+ {receivedAt} 접수 +

+ )} +
+ + {/* ─── 진행 상태 ─── */} +
+ {status === 'cancelled' ? ( +
+

+ 취소된 의뢰입니다 +

+

+ 이 의뢰는 취소 처리되었습니다. 다시 진행을 원하시면 회신해 주세요. +

+
+ ) : ( + <> + {status === 'on_hold' && ( +
+

+ 현재 보류 중입니다 — 조건 조정이 필요하면 회신 주세요. +

+
+ )} + + + )} +
+ + {/* ─── 의뢰 정보 ─── */} + {info.length > 0 && ( +
+

+ 의뢰 정보 +

+
+ {info.map((item) => ( +
+
+ {item.label} +
+
+ {item.value} +
+
+ ))} +
+
+ )} + + {/* ─── 견적 카드 ─── */} + {quote && ( +
+
+
+
+

+ 견적서가 도착했습니다 +

+

+ {quote.title ?? '프로젝트 견적서'} +

+
+ {quoteBadge && ( + + {quoteBadge.label} + + )} +
+ + {quoteValidUntil && ( +

+ 유효기간 {quoteValidUntil}까지 +

+ )} + + + 견적서 보기 + + +
+
+ )} + + {/* ─── 하단 안내 ─── */} +
+

+ 문의사항은{' '} + + bgg8988@gmail.com + {' '} + 또는 접수하신 메일에 회신해 주세요. +

+
+
+
+ ); +}