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