Files
jaengseung-made/app/mypage/page.tsx
gahusb 25b682b7cb refactor(mypage): kakao URL 상수 + 옛 URL 정돈 + 탭 가로 스크롤
D 트랙 3/4. 잔여 정돈:
- kakao 오픈채팅 URL hardcoded → KAKAO_OPENCHAT_URL import (lib/contact)
- EmptyState linkHref + 기타 잔존 옛 URL 새 URL로 (/services/* → /music|work/*)
- 탭 바: flex-wrap → flex-nowrap + overflow-x-auto + scrollbar-hide
  → 모바일 7-tab을 한 줄 가로 스크롤 (wrap 시 2줄 불규칙 배치 해소)
- globals.css에 scrollbar-hide 유틸리티 추가

P2 Task 4 review M-5 (mobile 7-tab orphan) 해소.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 04:12:19 +09:00

1115 lines
54 KiB
TypeScript

'use client';
import { useEffect, useState } from 'react';
import { useRouter } 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 { PACK_TIER_NAMES, extractPackTier, type PackTier } from '@/lib/pack-assets';
import type { PackFile } from '@/lib/supabase/pack-files';
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
function buildSajuResultUrl(rec: SajuRecord) {
const { birth_year, birth_month, birth_day, birth_hour, gender } = rec.saju_data;
if (!birth_year || !birth_month || !birth_day) return '/work/saju/input';
let url = `/work/saju/result?year=${birth_year}&month=${birth_month}&day=${birth_day}&gender=${gender}&calendarType=solar`;
if (birth_hour != null) url += `&hour=${birth_hour}`;
return url;
}
type Tab = 'profile' | 'projects' | 'subscription' | 'saju' | 'payments' | 'orders' | 'packs';
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
interface SajuRecord {
id: number;
created_at: string;
saju_data: {
birth_year: number;
birth_month: number;
birth_day: number;
birth_hour?: number;
gender: string;
};
interpretation: string | null;
is_paid: boolean;
}
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;
}
interface ProjectMilestone {
id: string;
step_number: number;
title: string;
description: string;
status: 'pending' | 'in_progress' | 'completed';
note: string;
completed_at: string | null;
}
interface Project {
id: string;
title: string;
status: string;
total: number;
created_at: string;
milestones: ProjectMilestone[];
}
interface ActiveSubscription {
id: string;
product_id: string;
status: string;
auto_renew: boolean;
started_at: string;
expires_at: string;
cancelled_at: string | null;
}
export default function MyPage() {
const router = useRouter();
const supabase = createClient();
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>('projects');
const [sajuRecords, setSajuRecords] = useState<SajuRecord[]>([]);
const [payments, setPayments] = useState<Payment[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [activeSubscriptions, setActiveSubscriptions] = useState<ActiveSubscription[]>([]);
const [projects, setProjects] = useState<Project[]>([]);
const [linkToken, setLinkToken] = useState('');
const [linking, setLinking] = useState(false);
const [linkMessage, setLinkMessage] = useState('');
const [packFiles, setPackFiles] = useState<PackFile[]>([]);
const [downloading, setDownloading] = useState<string | null>(null);
// 텔레그램 연동 상태
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: saju } = await supabase
.from('saju_records')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
setSajuRecords(saju || []);
// 결제 내역 조회
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);
// 구독 목록 조회 (subscriptions 테이블)
const subRes = await fetch('/api/subscription');
if (subRes.ok) {
const subData = await subRes.json();
setActiveSubscriptions(subData.subscriptions ?? []);
}
// 프로젝트 진행 현황 조회
const projRes = await fetch('/api/projects');
if (projRes.ok) {
const projData = await projRes.json();
setProjects(projData.projects ?? []);
}
// 구매한 팩 자료 파일 조회
const filesRes = await fetch('/api/packs/list-mine');
if (filesRes.ok) {
const { files } = await filesRes.json();
setPackFiles(files ?? []);
}
setLoading(false);
}
init();
}, []);
// ── 구독 해지 ──
const handleCancelSubscription = async (subId: string) => {
if (!confirm('구독을 해지하시겠습니까?\n만료일까지는 서비스를 계속 이용할 수 있습니다.')) return;
const res = await fetch(`/api/subscription/${subId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'cancel' }),
});
if (res.ok) {
setActiveSubscriptions((prev) =>
prev.map((s) => s.id === subId ? { ...s, status: 'cancelled', auto_renew: false, cancelled_at: new Date().toISOString() } : s)
);
} else {
alert('해지 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.');
}
};
// ── 자동갱신 토글 ──
const handleToggleAutoRenew = async (subId: string) => {
const res = await fetch(`/api/subscription/${subId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'toggle_autorenew' }),
});
if (res.ok) {
const data = await res.json();
setActiveSubscriptions((prev) =>
prev.map((s) => s.id === subId ? { ...s, auto_renew: data.auto_renew } : s)
);
}
};
// ── 텔레그램 연결 ──
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');
};
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);
}
}
const handleLinkProject = async (e: React.FormEvent) => {
e.preventDefault();
if (!linkToken.trim()) return;
setLinking(true);
setLinkMessage('');
try {
const res = await fetch('/api/projects/link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: linkToken.trim() }),
});
const data = await res.json();
if (res.ok) {
setLinkMessage('프로젝트가 연결되었습니다!');
setLinkToken('');
const projRes = await fetch('/api/projects');
if (projRes.ok) setProjects((await projRes.json()).projects ?? []);
} else {
setLinkMessage(data.error ?? '연결 중 오류가 발생했습니다.');
}
} catch {
setLinkMessage('연결 중 오류가 발생했습니다.');
}
setLinking(false);
};
if (loading) {
return (
<div className="min-h-full flex items-center justify-center bg-slate-50">
<div className="w-8 h-8 border-2 border-violet-600 border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!user) return null;
const activeSubs = activeSubscriptions.filter((s) => s.status === 'active' || s.status === 'cancelled');
const packOrders = orders
.map((o) => ({ order: o, tier: extractPackTier(o.service) }))
.filter((x): x is { order: Order; tier: PackTier } => x.tier !== null);
const tabs: { key: Tab; label: string; count?: number }[] = [
{ key: 'projects', label: '프로젝트 현황', count: projects.length || undefined },
{ key: 'orders', label: '의뢰 내역', count: orders.length || undefined },
{ key: 'payments', label: '결제 내역', count: payments.length || undefined },
{ key: 'packs', label: '구매한 팩', count: packOrders.length || undefined },
{ key: 'profile', label: '내 정보' },
{ key: 'subscription', label: '구독 관리', count: activeSubs.length || undefined },
{ key: 'saju', label: '사주 기록', count: sajuRecords.length || undefined },
];
return (
<div className="min-h-screen bg-slate-50">
{/* 텔레그램 가이드 모달 */}
{showTelegramGuide && (
<TelegramGuideModal onClose={() => setShowTelegramGuide(false)} />
)}
{/* 헤더 — kx-surface 다크 톤, 축소판. 로그아웃은 TopNav에서 담당 */}
<div
className="px-6 py-8 border-b border-white/5"
style={{
background: 'var(--kx-surface)',
backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.015) 0px, rgba(255,255,255,0.015) 1px, transparent 1px, transparent 40px)',
}}
>
<div className="max-w-4xl mx-auto">
<div className="flex items-center gap-4">
<div
aria-hidden="true"
className="w-12 h-12 rounded-full flex items-center justify-center text-white text-lg font-bold flex-shrink-0"
style={{ background: 'var(--kx-primary)' }}
>
{user.email?.[0].toUpperCase()}
</div>
<div>
<div className="kx-display text-white font-bold text-lg leading-tight">{user.email}</div>
<div className="text-white/50 text-xs mt-0.5">
{new Date(user.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
</div>
</div>
</div>
<div className="px-6 py-8 max-w-4xl mx-auto">
{/* 탭 */}
<div className="flex flex-nowrap gap-1 bg-white border border-slate-200 rounded-xl p-1 mb-6 overflow-x-auto scrollbar-hide">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`flex-1 min-w-[100px] flex items-center justify-center gap-1.5 px-4 py-2 rounded-lg text-sm font-semibold transition-all ${
tab === t.key
? 'bg-violet-600 text-white shadow'
: 'text-slate-500 hover:text-violet-600'
}`}
>
{t.label}
{t.count !== undefined && t.count > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-bold ${
tab === t.key ? 'bg-white/20 text-white' : 'bg-slate-100 text-slate-600'
}`}>
{t.count}
</span>
)}
</button>
))}
</div>
{/* 탭 콘텐츠 */}
{/* 내 정보 */}
{tab === 'profile' && (
<div className="space-y-4">
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<h2 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-violet-600 rounded-full" />
</h2>
<div className="space-y-3">
<div className="flex items-center justify-between py-3 border-b border-slate-100">
<span className="text-sm text-slate-500"></span>
<span className="text-sm font-semibold text-slate-900">{user.email}</span>
</div>
<div className="flex items-center justify-between py-3 border-b border-slate-100">
<span className="text-sm text-slate-500"> </span>
<span className="text-sm font-semibold text-slate-900 capitalize">
{user.app_metadata?.provider === 'google' ? 'Google' : '이메일'}
</span>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-sm text-slate-500"></span>
<span className="text-sm font-semibold text-slate-900">
{new Date(user.created_at).toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
</div>
</div>
</div>
{/* 구독 중인 서비스 - 요약 (탭으로 유도) */}
{activeSubs.length > 0 && (
<div className="bg-violet-50 rounded-2xl border border-violet-200 p-5 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-2xl">🎟</span>
<div>
<div className="text-sm font-bold text-slate-900">
</div>
<div className="text-xs text-violet-600 mt-0.5">
{Math.max(0, Math.ceil((new Date(activeSubs[0].expires_at).getTime() - Date.now()) / 86400000))}
{activeSubs[0].status === 'cancelled' && ' · 해지 예정'}
</div>
</div>
</div>
<button onClick={() => setTab('subscription')}
className="text-xs font-bold text-violet-700 bg-violet-100 hover:bg-violet-200 px-3 py-1.5 rounded-lg transition">
</button>
</div>
)}
{/* 텔레그램 연동 카드 */}
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<h2 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-violet-600 rounded-full" />
<button
onClick={() => setShowTelegramGuide(true)}
className="ml-1 w-5 h-5 rounded-full bg-slate-100 hover:bg-sky-100 text-slate-400 hover:text-sky-500 text-xs font-bold flex items-center justify-center transition"
title="연결 방법 보기"
>
?
</button>
<span className="ml-auto text-xs text-slate-400 font-normal"> · </span>
</h2>
{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 bg-sky-50 border border-sky-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-sky-500" viewBox="0 0 24 24" fill="currentColor">
<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>
</div>
<div>
<div className="text-sm font-semibold text-slate-900 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-400 inline-block" />
</div>
<div className="text-xs text-slate-500">Chat ID: {telegramChatId}</div>
</div>
</div>
<button
onClick={handleTelegramDisconnect}
disabled={telegramLinkState === 'disconnecting'}
className="px-4 py-2 text-xs font-semibold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition disabled:opacity-50"
>
{telegramLinkState === 'disconnecting' ? '해제 중...' : '연결 해제'}
</button>
</div>
) : telegramLinkState === 'waiting' ? (
/* ── 연결 대기 중 ── */
<div className="space-y-4">
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4">
<p className="text-sm font-semibold text-sky-700 mb-1">📱 </p>
<ol className="text-xs text-sky-600 space-y-1 list-decimal list-inside">
<li> </li>
<li> <strong></strong> </li>
<li> &quot; &quot; </li>
</ol>
<p className="text-xs text-sky-500 mt-2"> : {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 bg-sky-500 hover:bg-sky-400 text-white text-sm font-bold rounded-xl transition shadow-sm shadow-sky-200"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
<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>
</a>
<button
onClick={handleTelegramRefresh}
className="px-4 py-2.5 text-sm font-semibold text-slate-600 border border-slate-200 rounded-xl hover:bg-slate-50 transition"
>
</button>
<button
onClick={() => setTelegramLinkState('idle')}
className="px-4 py-2.5 text-sm text-slate-400 rounded-xl hover:text-slate-600 transition"
>
</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 bg-slate-50 border border-slate-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-slate-400" viewBox="0 0 24 24" fill="currentColor">
<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>
</div>
<div>
<div className="text-sm font-semibold text-slate-900"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</div>
<button
onClick={handleTelegramConnect}
disabled={telegramLinkState === 'generating'}
className="px-5 py-2.5 text-sm font-bold text-white bg-violet-600 hover:bg-violet-500 rounded-xl shadow-sm shadow-sky-200 transition disabled:opacity-60"
>
{telegramLinkState === 'generating' ? '생성 중...' : '텔레그램 연결하기'}
</button>
</div>
)}
</div>
<div className="bg-white rounded-2xl border border-slate-200 p-6">
<h2 className="font-bold text-slate-900 mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-violet-600 rounded-full" />
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Link href="/work/saju/input" className="flex items-center gap-3 p-4 rounded-xl border border-slate-200 hover:border-violet-300 hover:bg-violet-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-slate-900"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link href="/work/freelance" className="flex items-center gap-3 p-4 rounded-xl border border-slate-200 hover:border-violet-300 hover:bg-violet-50/50 transition group">
<div className="w-9 h-9 rounded-xl bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-slate-900"> </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
<Link
href="/music/studio"
className="flex items-center gap-3 p-4 rounded-xl border border-slate-200 hover:border-violet-300 hover:bg-violet-50/50 transition group"
>
<div className="w-9 h-9 rounded-xl bg-violet-50 border border-violet-200 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-5 text-violet-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19V6l12-3v13M9 19a3 3 0 11-6 0 3 3 0 016 0zm12-3a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</div>
<div>
<div className="text-sm font-semibold text-slate-900">AI </div>
<div className="text-xs text-slate-500"> </div>
</div>
</Link>
</div>
</div>
</div>
)}
{/* 구독 관리 */}
{tab === 'subscription' && (
<div className="space-y-4">
{activeSubscriptions.length === 0 ? (
<EmptyState
icon="📦"
title="활성 구독이 없습니다"
desc="구독 중인 서비스가 없습니다"
linkHref="/music/packs"
linkLabel="서비스 둘러보기"
/>
) : (
activeSubscriptions.map((sub) => {
const expiresDate = new Date(sub.expires_at);
const daysLeft = Math.max(0, Math.ceil((expiresDate.getTime() - Date.now()) / 86400000));
const isExpired = sub.status === 'expired';
const isCancelled = sub.status === 'cancelled';
const isActive = sub.status === 'active';
return (
<div key={sub.id} className={`bg-white rounded-2xl border p-6 ${isExpired ? 'border-slate-200 opacity-60' : isCancelled ? 'border-orange-200' : 'border-slate-200'}`}>
{/* 헤더 */}
<div className="flex items-start justify-between mb-5">
<div className="flex items-center gap-3">
<span className="text-3xl">🎟</span>
<div>
<div className="font-bold text-slate-900 text-base">
{sub.product_id}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{new Date(sub.started_at).toLocaleDateString('ko-KR')}
</div>
</div>
</div>
<span className={`text-xs font-bold px-2.5 py-1 rounded-full ${
isActive ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' :
isCancelled ? 'bg-orange-50 text-orange-600 border border-orange-200' :
'bg-slate-100 text-slate-500'
}`}>
{isActive ? '이용 중' : isCancelled ? '해지 예정' : '만료됨'}
</span>
</div>
{/* 만료 정보 */}
<div className="grid grid-cols-2 gap-3 mb-5">
<div className="bg-slate-50 rounded-xl p-3">
<div className="text-xs text-slate-400 mb-1"></div>
<div className="text-sm font-bold text-slate-900">
{expiresDate.toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' })}
</div>
</div>
<div className={`rounded-xl p-3 ${daysLeft <= 5 && !isExpired ? 'bg-red-50' : 'bg-slate-50'}`}>
<div className="text-xs text-slate-400 mb-1"> </div>
<div className={`text-sm font-bold ${isExpired ? 'text-slate-400' : daysLeft <= 5 ? 'text-red-500' : 'text-emerald-600'}`}>
{isExpired ? '만료됨' : `D-${daysLeft}`}
</div>
</div>
</div>
{/* 자동갱신 토글 */}
{!isExpired && (
<div className="flex items-center justify-between py-3 border-t border-slate-100 mb-4">
<div>
<div className="text-sm font-semibold text-slate-900"> </div>
<div className="text-xs text-slate-400 mt-0.5">
{sub.auto_renew ? '만료 시 자동으로 갱신됩니다' : '만료 시 자동 갱신되지 않습니다'}
</div>
</div>
<button
onClick={() => handleToggleAutoRenew(sub.id)}
disabled={isCancelled}
className={`relative w-11 h-6 rounded-full transition-colors duration-200 focus:outline-none disabled:opacity-40 ${sub.auto_renew ? 'bg-emerald-500' : 'bg-slate-200'}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform duration-200 ${sub.auto_renew ? 'translate-x-5' : 'translate-x-0'}`} />
</button>
</div>
)}
{/* 해지 취소 버튼 */}
{isCancelled && (
<div className="bg-orange-50 border border-orange-200 rounded-xl p-3 mb-4 text-xs text-orange-700">
· {expiresDate.toLocaleDateString('ko-KR')} .
{sub.cancelled_at && ` (해지일: ${new Date(sub.cancelled_at).toLocaleDateString('ko-KR')})`}
</div>
)}
{/* 액션 버튼 */}
<div className="flex gap-2 flex-wrap">
<a href="/work/freelance"
className="flex-1 text-center py-2 text-sm font-bold text-white bg-violet-600 hover:bg-violet-700 rounded-xl transition shadow-sm">
</a>
{isActive && (
<button
onClick={() => handleCancelSubscription(sub.id)}
className="px-4 py-2 text-sm font-semibold text-red-500 border border-red-200 rounded-xl hover:bg-red-50 transition"
>
</button>
)}
</div>
</div>
);
})
)}
{/* 서비스 이동 */}
<div className="text-center py-2">
<a href="/music/packs" className="text-sm text-slate-400 hover:text-slate-600 transition">
</a>
</div>
</div>
)}
{/* 사주 기록 */}
{tab === 'saju' && (
<div>
{sajuRecords.length === 0 ? (
<EmptyState
icon="✨"
title="저장된 사주 기록이 없습니다"
desc="사주 분석 후 결과를 저장하면 여기서 다시 확인할 수 있습니다"
linkHref="/work/saju/input"
linkLabel="사주 분석 시작"
/>
) : (
<div className="grid md:grid-cols-2 gap-4">
{sajuRecords.map((rec) => (
<div key={rec.id} className="bg-white rounded-2xl border border-slate-200 p-5">
<div className="flex items-start justify-between mb-3">
<div>
<div className="text-xs text-slate-400 mb-1">{new Date(rec.created_at).toLocaleDateString('ko-KR')}</div>
<div className="font-bold text-slate-900">
{rec.saju_data?.birth_year ?? '?'}{' '}
{rec.saju_data?.birth_month ?? '?'}{' '}
{rec.saju_data?.birth_day ?? '?'}
</div>
<div className="text-sm text-slate-500 mt-0.5">
{rec.saju_data?.gender === 'male' ? '남성' : '여성'}
{rec.saju_data?.birth_hour != null ? ` · ${rec.saju_data.birth_hour}시생` : ''}
</div>
</div>
<span className={`text-xs font-bold px-2 py-1 rounded-lg ${rec.is_paid ? 'bg-amber-50 text-amber-600 border border-amber-200' : 'bg-slate-100 text-slate-500'}`}>
{rec.is_paid ? '유료' : '무료'}
</span>
</div>
{rec.interpretation && (
<p className="text-xs text-slate-500 line-clamp-2 bg-slate-50 rounded-lg px-3 py-2 mb-3">
{rec.interpretation.replace(/[#*]/g, '').substring(0, 80)}...
</p>
)}
<Link
href={buildSajuResultUrl(rec)}
className="block w-full text-center py-2 rounded-xl text-xs font-bold bg-[#060e20] hover:bg-[#0a1f5c] text-white transition"
>
{rec.is_paid && rec.interpretation ? 'AI 해석 다시 보기 →' : '결과 보기 →'}
</Link>
</div>
))}
</div>
)}
</div>
)}
{/* 결제 내역 */}
{tab === 'payments' && (
<div>
{payments.length === 0 ? (
<EmptyState
icon="💳"
title="결제 내역이 없습니다"
desc="서비스 구매 후 결제 내역이 여기에 표시됩니다"
linkHref="/work/saju"
linkLabel="서비스 보기"
/>
) : (
<div className="bg-white rounded-2xl border border-slate-200 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
<th className="px-5 py-3 text-left font-semibold text-slate-600"></th>
</tr>
</thead>
<tbody>
{payments.map((p, i) => (
<tr key={p.id} className={i % 2 === 0 ? '' : 'bg-slate-50/50'}>
<td className="px-5 py-3 font-medium text-slate-900">{p.product_name}</td>
<td className="px-5 py-3 text-slate-900">{p.amount?.toLocaleString()}</td>
<td className="px-5 py-3">
<span className={`px-2 py-0.5 rounded-full text-xs font-bold ${
p.status === 'paid' ? 'bg-emerald-50 text-emerald-600' : 'bg-slate-100 text-slate-500'
}`}>
{p.status === 'paid' ? '결제완료' : p.status}
</span>
</td>
<td className="px-5 py-3 text-slate-500 text-xs">
{new Date(p.created_at).toLocaleDateString('ko-KR')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* 구매한 팩 */}
{tab === 'packs' && (
<div className="space-y-4">
{packOrders.length === 0 ? (
<EmptyState
icon="🎵"
title="구매한 팩이 없습니다"
desc="AI 음악 팩을 구매하시면 자료가 여기에 표시됩니다"
linkHref="/music/packs"
linkLabel="Music 팩 보기"
/>
) : (
packOrders.map(({ order, tier }) => {
const statusLabel =
order.status === 'completed' ? '자료 발송 완료' :
order.status === 'in_progress' ? '결제 처리 중' :
'입금 대기';
const statusColor =
order.status === 'completed' ? 'bg-violet-50 text-violet-600 border-violet-200' :
order.status === 'in_progress' ? 'bg-amber-50 text-amber-600 border-amber-200' :
'bg-slate-100 text-slate-500 border-slate-200';
return (
<div key={order.id} className="bg-white rounded-2xl border border-slate-200 p-6">
<div className="flex items-start justify-between mb-4">
<div>
<div className="font-bold text-slate-900 text-base">{PACK_TIER_NAMES[tier]}</div>
<div className="text-xs text-slate-500 mt-1">
{new Date(order.created_at).toLocaleDateString('ko-KR')}
</div>
</div>
<span className={`text-xs font-bold px-2.5 py-1 rounded-full border ${statusColor}`}>
{statusLabel}
</span>
</div>
{/* 자료 리스트 — DB가 SSOT */}
{(() => {
const filesForTier = packFiles.filter((pf) => {
if (tier === 'starter') return pf.min_tier === 'starter';
if (tier === 'pro') return pf.min_tier === 'starter' || pf.min_tier === 'pro';
return true; // master
});
return (
<div className="border-t border-slate-100 pt-4">
<div className="text-sm font-semibold text-slate-700 mb-3">
📦 ({filesForTier.length})
</div>
{filesForTier.length === 0 ? (
<p className="text-xs text-slate-500"> . 1:1로 .</p>
) : (
<ul className="space-y-2 mb-3">
{filesForTier.map((f) => (
<li key={f.id} className="flex items-center justify-between gap-2 text-sm">
<span className="text-slate-700 flex-1">{f.label}</span>
{order.status === 'completed' ? (
<button
onClick={() => handleDownload(f.id)}
disabled={downloading === f.id}
className="px-3 py-1.5 rounded-lg text-xs font-bold bg-violet-600 hover:bg-violet-500 disabled:bg-slate-300 text-white transition"
>
{downloading === f.id ? '준비중...' : '다운로드'}
</button>
) : (
<span className="text-xs text-slate-400"> </span>
)}
</li>
))}
</ul>
)}
{order.status === 'completed' && filesForTier.length > 0 && (
<p className="text-xs text-slate-500 leading-relaxed">
4 .
</p>
)}
{order.status !== 'completed' && (
<p className="text-xs text-slate-500 mt-2 text-center leading-relaxed">
{order.status === 'in_progress' ? '결제 처리 중. 자료는 결제 확인 후 활성화됩니다.' : '입금 대기 중. 카톡 1:1로 안내드립니다.'}
<br />
<a
href={KAKAO_OPENCHAT_URL}
target="_blank"
rel="noopener noreferrer"
className="text-violet-600 hover:underline font-semibold"
>
</a>
</p>
)}
</div>
);
})()}
</div>
);
})
)}
</div>
)}
{/* 프로젝트 진행 현황 */}
{tab === 'projects' && (
<div className="space-y-4">
{projects.length === 0 ? (
<div className="bg-white rounded-2xl border border-slate-200 p-8 text-center">
<div className="w-16 h-16 bg-violet-50 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<h3 className="font-bold text-slate-900 text-lg mb-2"> </h3>
<p className="text-slate-500 text-sm mb-6 max-w-sm mx-auto"> .</p>
<Link href="/work/freelance" className="inline-flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white px-6 py-3 rounded-xl font-semibold text-sm transition">
</Link>
</div>
) : (
<div className="space-y-4">
{projects.map((project) => {
const totalSteps = project.milestones.length;
const completedSteps = project.milestones.filter((m) => m.status === 'completed').length;
const currentStep = project.milestones.find((m) => m.status === 'in_progress');
const progressPct = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
return (
<div key={project.id} className="bg-white rounded-2xl border border-slate-200 overflow-hidden">
{/* 헤더 */}
<div className="bg-[#060e20] px-6 py-4 flex items-center justify-between" style={{ backgroundImage: 'repeating-linear-gradient(135deg, rgba(255,255,255,0.012) 0px, rgba(255,255,255,0.012) 1px, transparent 1px, transparent 40px)' }}>
<div>
<h3 className="font-bold text-white text-base">{project.title}</h3>
<p className="text-white/50 text-xs mt-0.5">
{project.total > 0 ? `${project.total.toLocaleString()}` : '금액 협의 중'} · {new Date(project.created_at).toLocaleDateString('ko-KR')}
</p>
</div>
<span className={`text-xs font-bold px-3 py-1.5 rounded-full ${
project.status === 'accepted' ? 'bg-emerald-400/20 text-emerald-300 border border-emerald-400/30' :
project.status === 'in_progress' ? 'bg-sky-400/20 text-sky-300 border border-sky-400/30' :
project.status === 'completed' ? 'bg-violet-400/20 text-violet-300 border border-violet-400/30' :
'bg-slate-400/20 text-slate-300 border border-slate-400/30'
}`}>
{project.status === 'sent' ? '견적 검토 중' :
project.status === 'accepted' ? '계약 완료' :
project.status === 'in_progress' ? '개발 진행 중' :
project.status === 'completed' ? '납품 완료' : project.status}
</span>
</div>
<div className="p-6">
{/* 진행률 바 */}
{totalSteps > 0 && (
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-semibold text-slate-500"> </span>
<span className="text-xs font-bold text-violet-600">{progressPct}% ({completedSteps}/{totalSteps})</span>
</div>
<div className="h-2 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full bg-violet-600 rounded-full transition-all duration-500"
style={{ width: `${progressPct}%` }}
/>
</div>
</div>
)}
{/* 현재 진행 단계 */}
{currentStep && (
<div className="bg-violet-50 border border-violet-200 rounded-xl p-4 mb-5">
<div className="flex items-center gap-2 mb-1">
<span className="w-2 h-2 rounded-full bg-violet-500 animate-pulse" />
<span className="text-xs font-bold text-violet-600"> </span>
</div>
<p className="font-bold text-slate-900 text-sm">{currentStep.title}</p>
{currentStep.note && (
<p className="text-slate-600 text-xs mt-1 leading-relaxed">{currentStep.note}</p>
)}
</div>
)}
{/* 단계별 타임라인 */}
{project.milestones.length > 0 && (
<div className="space-y-2">
{project.milestones.map((m, idx) => (
<div key={m.id} className="flex items-start gap-3">
{/* 아이콘 */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 text-xs font-bold border-2 ${
m.status === 'completed' ? 'bg-emerald-500 border-emerald-500 text-white' :
m.status === 'in_progress'? 'bg-violet-600 border-violet-600 text-white' :
'bg-white border-slate-200 text-slate-400'
}`}>
{m.status === 'completed' ? (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : m.status === 'in_progress' ? (
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 8v4l3 3" />
<circle cx="12" cy="12" r="9" />
</svg>
) : m.step_number}
</div>
{/* 수직 연결선 */}
<div className="flex flex-col flex-1 min-w-0" style={{ marginTop: idx === project.milestones.length - 1 ? 0 : undefined }}>
<div className="flex items-center gap-2 py-1">
<span className={`text-sm font-semibold ${
m.status === 'completed' ? 'text-emerald-700' :
m.status === 'in_progress'? 'text-violet-600' :
'text-slate-400'
}`}>{m.title}</span>
{m.status === 'completed' && m.completed_at && (
<span className="text-xs text-slate-400 ml-auto flex-shrink-0">
{new Date(m.completed_at).toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' })}
</span>
)}
</div>
{m.note && m.status !== 'pending' && (
<p className="text-xs text-slate-500 leading-relaxed pb-1">{m.note}</p>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
);
})}
</div>
)}
{/* 견적서 연결 폼 */}
<div className="bg-slate-50 rounded-2xl border border-slate-200 p-5">
<p className="text-sm font-bold text-slate-900 mb-1"> </p>
<p className="text-xs text-slate-500 mb-3"> ? URL .</p>
<form onSubmit={handleLinkProject} className="flex gap-2">
<input
value={linkToken}
onChange={(e) => setLinkToken(e.target.value)}
placeholder="예: abc123xyz"
className="flex-1 px-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:outline-none focus:border-violet-400 min-w-0"
/>
<button
type="submit"
disabled={linking || !linkToken.trim()}
className="px-4 py-2 bg-violet-600 hover:bg-violet-500 text-white rounded-xl font-semibold text-sm disabled:opacity-50 transition flex-shrink-0"
>
{linking ? '연결 중...' : '연결'}
</button>
</form>
{linkMessage && (
<p className={`text-xs mt-2 font-medium ${linkMessage.includes('연결되었') ? 'text-emerald-600' : 'text-red-500'}`}>
{linkMessage}
</p>
)}
</div>
</div>
)}
{/* 의뢰 내역 */}
{tab === 'orders' && (
<div>
{orders.length === 0 ? (
<EmptyState
icon="📋"
title="의뢰 내역이 없습니다"
desc="외주 개발, 서비스 문의 내역이 여기에 표시됩니다"
linkHref="/work/freelance"
linkLabel="외주 의뢰하기"
/>
) : (
<div className="space-y-3">
{orders.map((o) => (
<div key={o.id} className="bg-white rounded-2xl border border-slate-200 p-5">
<div className="flex items-start justify-between mb-2">
<div className="font-bold text-slate-900">{o.service}</div>
<span className={`text-xs font-bold px-2 py-1 rounded-lg ${
o.status === 'completed' ? 'bg-emerald-50 text-emerald-600 border border-emerald-200' :
o.status === 'in_progress' ? 'bg-violet-50 text-violet-600 border border-violet-200' :
'bg-slate-100 text-slate-500'
}`}>
{o.status === 'completed' ? '완료' : o.status === 'in_progress' ? '진행중' : '대기중'}
</span>
</div>
<p className="text-sm text-slate-600 line-clamp-2">{o.message}</p>
<div className="text-xs text-slate-400 mt-2">{new Date(o.created_at).toLocaleDateString('ko-KR')}</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
}
function EmptyState({
icon, title, desc, linkHref, linkLabel,
}: {
icon: string; title: string; desc: string; linkHref: string; linkLabel: string;
}) {
return (
<div className="text-center py-16 bg-white rounded-2xl border border-slate-200">
<div className="text-5xl mb-4">{icon}</div>
<div className="font-bold text-slate-900 text-lg mb-2">{title}</div>
<div className="text-slate-500 text-sm mb-6">{desc}</div>
<Link
href={linkHref}
className="inline-flex items-center gap-2 bg-violet-600 hover:bg-violet-500 text-white px-6 py-3 rounded-xl font-semibold text-sm transition-all shadow-lg shadow-violet-600/20"
>
{linkLabel}
</Link>
</div>
);
}