Files
jaengseung-made/app/mypage/page.tsx

1103 lines
42 KiB
TypeScript

'use client';
import { Suspense, useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link';
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)이 상단 내비·로그아웃을 제공하므로 여기서는 콘텐츠만 렌더한다.
// 디자인은 메인(/)·외주(/outsourcing) 페이지의 --jsm-* 토큰·타이포 패턴과 일관되게 구성한다.
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
type Tab = 'profile' | 'requests' | 'products' | 'orders';
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
// 구 탭 키 → 새 탭 키 매핑. 사주/구독/프로젝트 등 폐지 탭은 프로필로 폴백.
function resolveTab(raw: string | null): Tab {
switch (raw) {
case 'requests':
return 'requests';
case 'products':
case 'packs':
return 'products';
case 'orders':
case 'payments':
return 'orders';
case 'profile':
case 'saju':
case 'subscription':
case 'projects':
return 'profile';
default:
return 'requests';
}
}
interface Payment {
id: string;
created_at: string;
amount: number;
status: string;
product_name: string;
}
interface Order {
id: string;
created_at: string;
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 응답)
interface ProductFileItem {
id: string;
label: string;
}
interface ProductGroup {
id: string;
name: string;
files: ProductFileItem[];
}
// orders 테이블(결제 단일 소스) — pending 안내용
interface ProductOrder {
id: string;
product_id: string | null;
status: string;
created_at: string;
}
function MyPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const supabase = createClient();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>(() => resolveTab(searchParams.get('tab')));
const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
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);
const [telegramLinkState, setTelegramLinkState] = useState<TelegramLinkState>('idle');
const [telegramDeepLink, setTelegramDeepLink] = useState<string>('');
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
useEffect(() => {
async function init() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
router.push('/login');
return;
}
setUser(user);
// 결제 내역 조회
const { data: pay } = await supabase
.from('payments')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
setPayments(pay || []);
// 의뢰 내역 조회
const { data: ord } = await supabase
.from('contact_requests')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
setOrders(ord || []);
// 텔레그램 chat_id 조회
const { data: profile } = await supabase
.from('profiles')
.select('telegram_chat_id')
.eq('id', user.id)
.maybeSingle();
setTelegramChatId(profile?.telegram_chat_id ?? null);
// 구매 제품 자료 그룹 조회 (orders paid 단일 소스)
const filesRes = await fetch('/api/packs/list-mine');
if (filesRes.ok) {
const { products } = await filesRes.json();
setProductGroups(products ?? []);
}
// 결제 주문(orders 테이블) 조회 — pending 안내 / 주문 내역 공유
const { data: prodOrders } = await supabase
.from('orders')
.select('id, product_id, status, created_at')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(50);
setProductOrders(prodOrders || []);
setLoading(false);
}
init();
}, []);
// ── 텔레그램 연결 ──
const handleTelegramConnect = async () => {
setTelegramLinkState('generating');
try {
const res = await fetch('/api/telegram/connect', { method: 'POST' });
if (!res.ok) throw new Error('API_ERROR');
const data = await res.json();
setTelegramDeepLink(data.deepLink);
setTelegramLinkExpiry(new Date(data.expiresAt).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }));
setTelegramLinkState('waiting');
// 15분 후 자동으로 idle 복귀
setTimeout(() => setTelegramLinkState('idle'), 15 * 60 * 1000);
} catch {
setTelegramLinkState('idle');
alert('연결 코드 발급 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
};
// 연결 후 상태 새로고침 (버튼 클릭 시)
const handleTelegramRefresh = async () => {
const { data: profile } = await supabase
.from('profiles')
.select('telegram_chat_id')
.eq('id', user!.id)
.maybeSingle();
const chatId = profile?.telegram_chat_id ?? null;
setTelegramChatId(chatId);
if (chatId) setTelegramLinkState('idle');
};
// ── 텔레그램 연결 해제 ──
const handleTelegramDisconnect = async () => {
if (!confirm('텔레그램 연결을 해제하시겠습니까?')) return;
setTelegramLinkState('disconnecting');
try {
await fetch('/api/telegram/connect', { method: 'DELETE' });
setTelegramChatId(null);
setTelegramDeepLink('');
} catch {
alert('연결 해제 중 오류가 발생했습니다.');
}
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 {
const res = await fetch('/api/packs/sign-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileId }),
});
const data = await res.json();
if (!res.ok || !data.url) {
throw new Error(data.error ?? '링크 발급 실패');
}
window.location.href = data.url;
} catch (e) {
alert(e instanceof Error ? e.message : '다운로드 준비 중 오류가 발생했습니다');
} finally {
setDownloading(null);
}
}
if (loading) {
return (
<div
className="min-h-[60vh] flex items-center justify-center"
style={{ background: 'var(--jsm-bg)' }}
>
<div
className="w-7 h-7 rounded-full animate-spin"
style={{ border: '2px solid var(--jsm-accent)', borderTopColor: 'transparent' }}
/>
</div>
);
}
if (!user) return null;
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
const pendingOrders = productOrders.filter((o) => o.status === 'pending');
const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'profile', label: '프로필' },
{ key: 'requests', label: '내 의뢰', count: orders.length || undefined },
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.length) || undefined },
];
function selectTab(key: Tab) {
setTab(key);
const params = new URLSearchParams(searchParams.toString());
params.set('tab', key);
router.replace(`/mypage?${params.toString()}`, { scroll: false });
}
return (
<div style={{ background: 'var(--jsm-bg)' }} className="min-h-[calc(100vh-4rem)]">
{/* 텔레그램 가이드 모달 */}
{showTelegramGuide && (
<TelegramGuideModal onClose={() => setShowTelegramGuide(false)} />
)}
{/* ─── 페이지 헤더 ─── */}
<div className="border-b" style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}>
<div className="max-w-5xl mx-auto px-6 lg:px-8 pt-12 pb-6">
<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>
<div className="flex items-center gap-4">
<div
aria-hidden="true"
className="w-12 h-12 rounded-xl flex items-center justify-center text-white text-lg font-bold flex-shrink-0"
style={{ background: 'var(--jsm-accent)' }}
>
{user.email?.[0].toUpperCase()}
</div>
<div className="min-w-0">
<div
className="font-bold text-lg leading-tight truncate"
style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}
>
{user.email}
</div>
<div className="text-xs mt-0.5" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(user.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
</div>
</div>
{/* ─── 탭 바 (상단 가로 탭 · 모바일 스크롤) ─── */}
<div className="max-w-5xl mx-auto px-6 lg:px-8">
<div className="flex gap-1 overflow-x-auto scrollbar-hide -mb-px">
{tabs.map((t) => {
const active = tab === t.key;
return (
<button
key={t.key}
onClick={() => selectTab(t.key)}
className="flex items-center gap-1.5 px-4 py-3 text-sm font-semibold whitespace-nowrap transition-colors duration-150 border-b-2"
style={{
color: active ? 'var(--jsm-ink)' : 'var(--jsm-ink-soft)',
borderColor: active ? 'var(--jsm-accent)' : 'transparent',
...KOR_BODY,
}}
>
{t.label}
{t.count !== undefined && t.count > 0 && (
<span
className="text-xs px-1.5 py-0.5 rounded-full font-semibold"
style={
active
? { background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }
: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }
}
>
{t.count}
</span>
)}
</button>
);
})}
</div>
</div>
</div>
{/* ─── 탭 콘텐츠 ─── */}
<div className="px-6 lg:px-8 py-8 max-w-5xl mx-auto">
{/* ===== 프로필 ===== */}
{tab === 'profile' && (
<div className="space-y-5">
<Card>
<CardTitle> </CardTitle>
<div className="mt-4">
<Row label="이메일" value={user.email ?? '-'} />
<Row
label="로그인 방법"
value={user.app_metadata?.provider === 'google' ? 'Google' : '이메일'}
/>
<Row
label="가입일"
value={new Date(user.created_at).toLocaleDateString('ko-KR', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
last
/>
</div>
</Card>
{/* 텔레그램 연동 카드 */}
<Card>
<div className="flex items-center gap-2">
<CardTitle inline> </CardTitle>
<button
onClick={() => setShowTelegramGuide(true)}
className="w-5 h-5 rounded-full text-xs font-bold flex items-center justify-center transition-colors"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-faint)' }}
title="연결 방법 보기"
>
?
</button>
<span className="ml-auto text-xs font-normal" style={{ color: 'var(--jsm-ink-faint)' }}>
·
</span>
</div>
<div className="mt-4">
{telegramChatId ? (
/* ── 연결됨 ── */
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 border"
style={{ background: 'var(--jsm-accent-soft)', borderColor: 'var(--jsm-line)' }}
>
<TelegramIcon className="w-5 h-5" style={{ color: 'var(--jsm-accent)' }} />
</div>
<div>
<div
className="text-sm font-semibold flex items-center gap-1.5"
style={{ color: 'var(--jsm-ink)' }}
>
<span className="w-2 h-2 rounded-full inline-block" style={{ background: '#16a34a' }} />
</div>
<div className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
Chat ID: {telegramChatId}
</div>
</div>
</div>
<button
onClick={handleTelegramDisconnect}
disabled={telegramLinkState === 'disconnecting'}
className="px-4 py-2 text-xs font-semibold rounded-lg border transition-colors disabled:opacity-50"
style={{ color: '#dc2626', borderColor: '#fecaca' }}
>
{telegramLinkState === 'disconnecting' ? '해제 중...' : '연결 해제'}
</button>
</div>
) : telegramLinkState === 'waiting' ? (
/* ── 연결 대기 중 ── */
<div className="space-y-4">
<div
className="rounded-xl p-4 border"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<p className="text-sm font-semibold mb-2" style={{ color: 'var(--jsm-ink)' }}>
</p>
<ol
className="text-xs space-y-1 list-decimal list-inside"
style={{ color: 'var(--jsm-ink-soft)' }}
>
<li> </li>
<li> <strong></strong> </li>
<li> &quot; &quot; </li>
</ol>
<p className="text-xs mt-2" style={{ color: 'var(--jsm-ink-faint)' }}>
: {telegramLinkExpiry}
</p>
</div>
<div className="flex gap-2 flex-wrap">
<a
href={telegramDeepLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-semibold rounded-lg text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)' }}
>
<TelegramIcon className="w-4 h-4" />
</a>
<button
onClick={handleTelegramRefresh}
className="px-4 py-2.5 text-sm font-semibold rounded-lg border transition-colors hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink-soft)', borderColor: 'var(--jsm-line)' }}
>
</button>
<button
onClick={() => setTelegramLinkState('idle')}
className="px-4 py-2.5 text-sm rounded-lg transition-colors"
style={{ color: 'var(--jsm-ink-faint)' }}
>
</button>
</div>
</div>
) : (
/* ── 미연결 ── */
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 border"
style={{ background: 'var(--jsm-surface-alt)', borderColor: 'var(--jsm-line)' }}
>
<TelegramIcon className="w-5 h-5" style={{ color: 'var(--jsm-ink-faint)' }} />
</div>
<div>
<div className="text-sm font-semibold" style={{ color: 'var(--jsm-ink)' }}>
</div>
<div className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
</div>
</div>
</div>
<button
onClick={handleTelegramConnect}
disabled={telegramLinkState === 'generating'}
className="px-5 py-2.5 text-sm font-semibold rounded-lg text-white transition-colors hover:bg-[var(--jsm-accent-hover)] disabled:opacity-60"
style={{ background: 'var(--jsm-accent)' }}
>
{telegramLinkState === 'generating' ? '생성 중...' : '텔레그램 연결하기'}
</button>
</div>
)}
</div>
</Card>
{/* 빠른 메뉴 */}
<Card>
<CardTitle> </CardTitle>
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
<QuickLink href="/outsourcing#contact" title="외주 문의" sub="프로젝트 문의" />
<QuickLink href="/products" title="소프트웨어 보기" sub="완성 소프트웨어 목록" />
<QuickLink href="/outsourcing#contact" title="외주 의뢰" sub="맞춤 개발 문의" />
</div>
</Card>
</div>
)}
{/* ===== 내 의뢰 ===== */}
{tab === 'requests' && (
<div>
{orders.length === 0 ? (
<EmptyState
title="의뢰 내역이 없습니다"
desc="외주 개발·서비스 문의 내역이 여기에 표시됩니다."
linkHref="/outsourcing#contact"
linkLabel="외주 문의하기"
/>
) : (
<div className="space-y-3">
{orders.map((o) => (
<RequestCard
key={o.id}
order={o}
expanded={expandedRequests.has(o.id)}
onToggle={() => toggleRequest(o.id)}
/>
))}
</div>
)}
</div>
)}
{/* ===== 내 제품 (구매한 제품 자료) ===== */}
{tab === 'products' && (
<div className="space-y-4">
{/* 입금 확인 대기 안내 */}
{pendingOrders.length > 0 && (
<div
className="rounded-xl px-4 py-3 border flex items-start gap-3"
style={{ background: 'var(--jsm-accent-soft)', borderColor: 'var(--jsm-line)' }}
>
<span
className="text-xs font-semibold px-2 py-0.5 rounded-full flex-shrink-0 mt-0.5"
style={{ background: 'var(--jsm-surface)', color: 'var(--jsm-accent)' }}
>
</span>
<div className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{pendingOrders.length} . .
{' '}
<a
href={KAKAO_OPENCHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="font-semibold hover:underline"
style={{ color: 'var(--jsm-accent)' }}
>
</a>
</div>
</div>
)}
{productGroups.length === 0 ? (
<EmptyState
title="구매한 제품이 없습니다"
desc="소프트웨어·자료를 구매하시면 여기서 다운로드할 수 있습니다."
linkHref="/products"
linkLabel="소프트웨어 보기"
/>
) : (
productGroups.map((group) => (
<Card key={group.id}>
<div className="flex items-start justify-between gap-3 mb-4">
<div className="font-bold text-base break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{group.name}
</div>
</div>
<div className="border-t pt-4" style={{ borderColor: 'var(--jsm-line)' }}>
<div className="text-sm font-semibold mb-3" style={{ color: 'var(--jsm-ink)' }}>
({group.files.length})
</div>
{group.files.length === 0 ? (
<p className="text-xs" style={{ color: 'var(--jsm-ink-soft)' }}>
. 1:1로 .
</p>
) : (
<>
<ul className="space-y-2 mb-3">
{group.files.map((f) => (
<li key={f.id} className="flex items-center justify-between gap-2 text-sm">
<span className="flex-1 break-keep" style={{ color: 'var(--jsm-ink)' }}>
{f.label}
</span>
<button
onClick={() => handleDownload(f.id)}
disabled={downloading === f.id}
className="px-3 py-1.5 rounded-lg text-xs font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)] disabled:opacity-50"
style={{ background: 'var(--jsm-accent)' }}
>
{downloading === f.id ? '준비중...' : '다운로드'}
</button>
</li>
))}
</ul>
<p className="text-xs leading-relaxed" style={{ color: 'var(--jsm-ink-soft)' }}>
4 .
</p>
</>
)}
</div>
</Card>
))
)}
</div>
)}
{/* ===== 주문 내역 (의뢰 + 결제 완료) ===== */}
{tab === 'orders' && (
<div className="space-y-8">
{/* 주문 목록 (contact_requests) */}
<section>
<SectionHeading> </SectionHeading>
{orders.length === 0 ? (
<EmptyState
title="주문 내역이 없습니다"
desc="서비스 신청·외주 문의 내역이 여기에 표시됩니다."
linkHref="/outsourcing#contact"
linkLabel="외주 문의하기"
/>
) : (
<div className="space-y-3">
{orders.map((o) => (
<Card key={o.id} compact>
<div className="flex items-start justify-between gap-3 mb-1.5">
<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-1 break-keep"
style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
>
{o.message}
</p>
<div className="text-xs mt-2" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(o.created_at).toLocaleDateString('ko-KR')}
</div>
</Card>
))}
</div>
)}
</section>
{/* 결제 완료 내역 (payments) */}
<section>
<SectionHeading> </SectionHeading>
{payments.length === 0 ? (
<div
className="rounded-2xl border px-6 py-8 text-center text-sm"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-soft)' }}
>
.
</div>
) : (
<div
className="rounded-2xl border overflow-hidden"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<table className="w-full text-sm">
<thead style={{ background: 'var(--jsm-surface-alt)' }}>
<tr style={{ borderBottom: '1px solid var(--jsm-line)' }}>
<th className="px-5 py-3 text-left font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}></th>
<th className="px-5 py-3 text-left font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}></th>
<th className="px-5 py-3 text-left font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}></th>
<th className="px-5 py-3 text-left font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}></th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} style={{ borderTop: '1px solid var(--jsm-line)' }}>
<td className="px-5 py-3 font-medium" style={{ color: 'var(--jsm-ink)' }}>{p.product_name}</td>
<td className="px-5 py-3" style={{ color: 'var(--jsm-ink)' }}>{p.amount?.toLocaleString()}</td>
<td className="px-5 py-3">
<span
className="px-2 py-0.5 rounded-full text-xs font-semibold"
style={
p.status === 'paid'
? { background: '#dcfce7', color: '#166534' }
: { background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }
}
>
{p.status === 'paid' ? '결제완료' : p.status}
</span>
</td>
<td className="px-5 py-3 text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(p.created_at).toLocaleDateString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
)}
</div>
</div>
);
}
export default function MyPage() {
return (
<Suspense
fallback={
<div
className="min-h-[60vh] flex items-center justify-center"
style={{ background: 'var(--jsm-bg)' }}
>
<div
className="w-7 h-7 rounded-full animate-spin"
style={{ border: '2px solid var(--jsm-accent)', borderTopColor: 'transparent' }}
/>
</div>
}
>
<MyPageContent />
</Suspense>
);
}
/* ─────────── 공통 프레젠테이션 컴포넌트 ─────────── */
function Card({
children,
compact = false,
}: {
children: React.ReactNode;
compact?: boolean;
}) {
return (
<div
className={`rounded-2xl border ${compact ? 'p-5' : 'p-6'}`}
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
{children}
</div>
);
}
function CardTitle({ children }: { children: React.ReactNode; inline?: boolean }) {
return (
<h2 className="font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{children}
</h2>
);
}
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h3
className="text-xs font-semibold uppercase tracking-wider mb-3"
style={{ color: 'var(--jsm-accent)' }}
>
{children}
</h3>
);
}
function Row({ label, value, last = false }: { label: string; value: string; last?: boolean }) {
return (
<div
className="flex items-center justify-between py-3"
style={last ? undefined : { borderBottom: '1px solid var(--jsm-line)' }}
>
<span className="text-sm" style={{ color: 'var(--jsm-ink-soft)' }}>{label}</span>
<span className="text-sm font-semibold" style={{ color: 'var(--jsm-ink)' }}>{value}</span>
</div>
);
}
function QuickLink({ href, title, sub }: { href: string; title: string; sub: string }) {
return (
<Link
href={href}
className="flex flex-col gap-1 p-4 rounded-xl border transition-colors hover:bg-[var(--jsm-surface-alt)]"
style={{ borderColor: 'var(--jsm-line)' }}
>
<span className="text-sm font-semibold" style={{ color: 'var(--jsm-ink)' }}>{title}</span>
<span className="text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>{sub}</span>
</Link>
);
}
// 상태 뱃지 — 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 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={style}
>
{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,
linkHref,
linkLabel,
}: {
title: string;
desc: string;
linkHref: string;
linkLabel: string;
}) {
return (
<div
className="text-center px-6 py-16 rounded-2xl border"
style={{ background: 'var(--jsm-surface)', borderColor: 'var(--jsm-line)' }}
>
<div className="font-bold text-lg mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{title}
</div>
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{desc}
</div>
<Link
href={linkHref}
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
style={{ background: 'var(--jsm-accent)' }}
>
{linkLabel}
</Link>
</div>
);
}
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
return (
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.894 8.221-1.97 9.28c-.145.658-.537.818-1.084.508l-3-2.21-1.447 1.394c-.16.16-.295.295-.605.295l.213-3.053 5.56-5.023c.242-.213-.054-.333-.373-.12L7.17 13.667l-2.95-.924c-.64-.203-.654-.64.136-.954l11.566-4.458c.538-.194 1.006.131.972.89z" />
</svg>
);
}