'use client'; import { Suspense, useCallback, 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 { findCard } from '@/lib/tarot/cards'; 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' | 'ai'; 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 'ai': return 'ai'; case 'profile': case 'saju': case 'subscription': case 'projects': return 'profile'; default: return 'requests'; } } // AI 기록 탭 — 타로 리딩 (app/api/tarot/readings 응답) type TarotReadingRow = { id: string; category: string | null; question: string | null; cards: { position: string; card_id?: string; reversed?: boolean }[]; interpretation: { summary?: string; advice?: string; warning?: string | null }; summary: string | null; created_at: string; }; // AI 기록 탭 — 사주 기록 (saju_records 테이블, 본인 조회) type SajuRecordRow = { id: string; saju_data: Record; created_at: string; is_paid: boolean; }; // AI 기록 탭 — 사주·타로 병합 렌더용 판별 유니언 type AiRecordItem = | { kind: 'saju'; data: SajuRecordRow } | { kind: 'tarot'; data: TarotReadingRow }; 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; } // 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환, /api/projects) type ProjectMilestone = { quote_id: string; step_number: number; title: string; status: 'pending' | 'in_progress' | 'completed' }; type Project = { id: string; title: string; status: string; total: number; created_at: string; milestones: ProjectMilestone[] }; const QUOTE_STATUS_LABELS: Record = { sent: '견적 발송', accepted: '발주 확정', in_progress: '진행중', completed: '완료', delivered: '납품 완료', }; const PROJECT_ORDERED_STATUSES = ['accepted', 'in_progress', 'completed', 'delivered']; function MyPageContent() { const router = useRouter(); const searchParams = useSearchParams(); const supabase = createClient(); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [tab, setTab] = useState(() => resolveTab(searchParams.get('tab'))); const [payments, setPayments] = useState([]); const [orders, setOrders] = useState([]); const [productGroups, setProductGroups] = useState([]); const [productOrders, setProductOrders] = useState([]); const [downloading, setDownloading] = useState(null); // 내 의뢰 탭 — 펼친 카드 id 집합 (기본 접힘) const [expandedRequests, setExpandedRequests] = useState>(new Set()); // 발주·진행 (quotes 기반) const [projects, setProjects] = useState([]); const [linkCode, setLinkCode] = useState(''); const [linkMsg, setLinkMsg] = useState(null); const [linking, setLinking] = useState(false); const [showLinkForm, setShowLinkForm] = useState(false); // 텔레그램 연동 상태 const [telegramChatId, setTelegramChatId] = useState(null); const [telegramLinkState, setTelegramLinkState] = useState('idle'); const [telegramDeepLink, setTelegramDeepLink] = useState(''); const [telegramLinkExpiry, setTelegramLinkExpiry] = useState(''); const [showTelegramGuide, setShowTelegramGuide] = useState(false); // AI 기록 탭 — 타로 리딩 / 사주 기록 const [tarotReadings, setTarotReadings] = useState([]); const [sajuRecords, setSajuRecords] = useState([]); const [expandedAiCards, setExpandedAiCards] = useState>(new Set()); const loadProjects = useCallback(async () => { try { const res = await fetch('/api/projects'); if (!res.ok) return; const d = await res.json(); setProjects(d.projects ?? []); } catch { /* 미로그인/네트워크 — 무시 */ } }, []); // 사주·타로 결과 통합 로드 — 둘 다 실패해도 서로 영향 없이 무시(best-effort) const loadAiRecords = useCallback(async () => { try { const tr = await fetch('/api/tarot/readings'); if (tr.ok) setTarotReadings((await tr.json()).readings ?? []); } catch { /* 무시 */ } try { // 사주: 세션 클라이언트로 본인 saju_records 조회 (result 페이지와 동일 패턴) const { data: { user: authUser } } = await supabase.auth.getUser(); if (authUser) { const { data } = await supabase .from('saju_records') .select('id, saju_data, created_at, is_paid') .eq('user_id', authUser.id) .order('created_at', { ascending: false }); setSajuRecords(data ?? []); } } catch { /* 무시 */ } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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 || []); // 발주·진행 (quotes 기반) 조회 await loadProjects(); // AI 기록(사주·타로) 조회 await loadAiRecords(); setLoading(false); } init(); }, [loadProjects, loadAiRecords]); // ── 텔레그램 연결 ── 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; }); } function toggleAiCard(id: string) { setExpandedAiCards((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); } // 견적서 코드 연결 (공개 견적 페이지에서 발급된 public_token) const handleLink = async () => { if (!linkCode.trim() || linking) return; setLinking(true); setLinkMsg(null); try { const res = await fetch('/api/projects/link', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: linkCode.trim() }), }); const d = await res.json(); if (!res.ok) { setLinkMsg(d.error ?? '연결에 실패했습니다.'); return; } setLinkMsg(d.alreadyLinked ? '이미 연결된 견적서입니다.' : '견적서가 연결되었습니다.'); setLinkCode(''); await loadProjects(); } catch { setLinkMsg('연결에 실패했습니다. 다시 시도해주세요.'); } finally { setLinking(false); } }; 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 (
); } if (!user) return null; // 입금 확인 대기 중인 주문 (orders 테이블 pending) const pendingOrders = productOrders.filter((o) => o.status === 'pending'); // AI 기록 탭 — 사주·타로 결과를 created_at 기준 내림차순 병합 const aiRecords: AiRecordItem[] = [ ...sajuRecords.map((r): AiRecordItem => ({ kind: 'saju', data: r })), ...tarotReadings.map((r): AiRecordItem => ({ kind: 'tarot', data: r })), ].sort((a, b) => new Date(b.data.created_at).getTime() - new Date(a.data.created_at).getTime()); 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 }, { key: 'ai', label: 'AI 기록', count: (sajuRecords.length + tarotReadings.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 (
{/* 텔레그램 가이드 모달 */} {showTelegramGuide && ( setShowTelegramGuide(false)} /> )} {/* ─── 페이지 헤더 ─── */}
마이페이지
{user.email}
가입일 {new Date(user.created_at).toLocaleDateString('ko-KR')}
{/* ─── 탭 바 (상단 가로 탭 · 모바일 스크롤) ─── */}
{tabs.map((t) => { const active = tab === t.key; return ( ); })}
{/* ─── 탭 콘텐츠 ─── */}
{/* ===== 프로필 ===== */} {tab === 'profile' && (
계정 정보
{/* 텔레그램 연동 카드 */}
텔레그램 알림 연동 플래티넘 · 다이아 전용
{telegramChatId ? ( /* ── 연결됨 ── */
연결됨
Chat ID: {telegramChatId}
) : telegramLinkState === 'waiting' ? ( /* ── 연결 대기 중 ── */

아래 순서로 진행하세요

  1. 아래 버튼을 클릭해 텔레그램 봇을 엽니다
  2. 텔레그램에서 시작 버튼을 누릅니다
  3. 봇이 "연결 완료" 메시지를 보내면 새로고침을 눌러주세요

유효시간: {telegramLinkExpiry}까지

텔레그램 봇 열기
) : ( /* ── 미연결 ── */
연결 안 됨
텔레그램으로 알림을 바로 받아보세요
)}
{/* 빠른 메뉴 */} 빠른 메뉴
)} {/* ===== 발주·진행 + 내 의뢰 ===== */} {tab === 'requests' && (
{/* 발주·진행 (quotes 기반 — 견적 수락 시 발주서로 전환) */}

발주·진행

{showLinkForm && (
setLinkCode(e.target.value)} placeholder="견적서 코드를 입력하세요" className="flex-1 min-w-0 px-3 py-2 rounded-lg border text-sm" style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink)' }} />
{linkMsg && (

{linkMsg}

)}
)} {projects.length === 0 ? (

진행 중인 발주가 없습니다. 견적서 코드를 입력해 연결하거나 새로 의뢰해 보세요.

) : (
{projects.map((p) => ( ))}
)}
{/* 기존 의뢰 카드 리스트 (contact_requests 기반) */} {orders.length === 0 ? ( ) : (
{orders.map((o) => ( toggleRequest(o.id)} /> ))}
)}
)} {/* ===== 내 제품 (구매한 제품 자료) ===== */} {tab === 'products' && (
{/* 입금 확인 대기 안내 */} {pendingOrders.length > 0 && (
대기중
입금 확인 대기 중인 주문이 {pendingOrders.length}건 있습니다. 입금이 확인되면 자료 다운로드가 활성화됩니다. {' '} 카톡 오픈채팅 →
)} {productGroups.length === 0 ? ( ) : ( productGroups.map((group) => (
{group.name}
자료 패키지 ({group.files.length}개)
{group.files.length === 0 ? (

자료 준비 중입니다. 카톡 1:1로 문의해주세요.

) : ( <>
    {group.files.map((f) => (
  • {f.label}
  • ))}

다운로드 링크는 발급 후 4시간 동안 유효합니다.

)}
)) )}
)} {/* ===== 주문 내역 (의뢰 + 결제 완료) ===== */} {tab === 'orders' && (
{/* 주문 목록 (contact_requests) */}
주문 목록 {orders.length === 0 ? ( ) : (
{orders.map((o) => (
{o.service}

{o.message}

{new Date(o.created_at).toLocaleDateString('ko-KR')}
))}
)}
{/* 결제 완료 내역 (payments) */}
결제 완료 내역 {payments.length === 0 ? (
결제 완료된 내역이 아직 없습니다.
) : (
{payments.map((p) => ( ))}
서비스 금액 상태 일시
{p.product_name} ₩{p.amount?.toLocaleString()} {p.status === 'paid' ? '결제완료' : p.status} {new Date(p.created_at).toLocaleDateString('ko-KR')}
)}
)} {/* ===== AI 기록 (사주·타로 결과 통합) ===== */} {tab === 'ai' && (
{aiRecords.length === 0 ? (
AI 기록이 없습니다
사주 분석·타로 리딩 결과가 여기에 모아서 표시됩니다.
사주 분석 하기 → 타로 리딩 하기 →
) : (
{aiRecords.map((item) => item.kind === 'saju' ? ( ) : ( toggleAiCard(item.data.id)} /> ) )}
)}
)}
); } export default function MyPage() { return (
} >
); } /* ─────────── 공통 프레젠테이션 컴포넌트 ─────────── */ function Card({ children, compact = false, }: { children: React.ReactNode; compact?: boolean; }) { return (
{children}
); } function CardTitle({ children }: { children: React.ReactNode; inline?: boolean }) { return (

{children}

); } function SectionHeading({ children }: { children: React.ReactNode }) { return (

{children}

); } function Row({ label, value, last = false }: { label: string; value: string; last?: boolean }) { return (
{label} {value}
); } function QuickLink({ href, title, sub }: { href: string; title: string; sub: string }) { return ( {title} {sub} ); } // 상태 뱃지 — REQUEST_STATUS 8종. // completed=성공 그린(예외 허용) / accepted·quoted·in_progress=accent / pending·reviewing=surface-alt // on_hold·cancelled=faint. 알 수 없는 값(다른 도메인 status 등)은 원문 라벨+기본 스타일 폴백. const STATUS_BADGE_STYLE: Record = { 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 ( {label} ); } // 펼침 토글 셰브론 function Chevron({ open }: { open: boolean }) { return ( ); } function TimelineCheck() { return ( ); } // 컴팩트 가로 미니 타임라인 — track 페이지 타임라인의 축소판. // 모바일에서는 라벨을 숨기고 도트만 노출(라벨 축약 허용). function MiniTimeline({ 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; return (
  1. {/* 좌측 연결선 */} {/* 마커 */} {isDone ? ( ) : ( )} {/* 우측 연결선 */}
    {/* 라벨 — 모바일 숨김 */} {label}
  2. ); })}
); } // 내 의뢰 카드 — 접힘 기본, 펼치면 타임라인 + 의뢰 정보 + 추적 링크 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 ( {/* 헤더 — 클릭 토글 */} {/* 펼침 영역 */} {expanded && (
{status === 'cancelled' ? (

취소된 의뢰입니다.

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

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

)}
)} {/* 의뢰 정보 */} {info.length > 0 && (
{info.map((item) => (
{item.label}
{item.value}
))}
)} {/* 상세 추적 페이지 링크 */} {order.public_token && ( 상세 추적 페이지 )}
)}
); } // 발주서 뱃지 — accepted 이후 상태(발주 확정~납품 완료)에는 "발주서" 뱃지를 병기한다. function isProjectOrder(status: string): boolean { return PROJECT_ORDERED_STATUSES.includes(status); } // 발주·진행 카드 — quotes 기반. 총액 + 마일스톤 타임라인(스텝 순서대로 진행 상태 표시). function ProjectCard({ project }: { project: Project }) { return (
{project.title}
{isProjectOrder(project.status) && ( 발주서 )} {QUOTE_STATUS_LABELS[project.status] ?? project.status}
{project.total.toLocaleString('ko-KR')}원
{project.milestones.length > 0 && (
    {project.milestones .slice() .sort((a, b) => a.step_number - b.step_number) .map((m) => { const done = m.status === 'completed'; const active = m.status === 'in_progress'; return (
  1. {m.step_number} {m.title}
  2. ); })}
)}
); } function EmptyState({ title, desc, linkHref, linkLabel, }: { title: string; desc: string; linkHref: string; linkLabel: string; }) { return (
{title}
{desc}
{linkLabel} →
); } // saju_data(jsonb)에서 생년월일 요약 문자열 구성 — birth_year/month/day 없으면 안내 문구로 폴백 function formatSajuBirth(data: Record): string { const year = data.birth_year; const month = data.birth_month; const day = data.birth_day; const hour = data.birth_hour; const gender = data.gender; if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') { return '생년월일 정보 없음'; } const parts = [`${year}.${month}.${day}`]; if (typeof hour === 'number') parts.push(`${hour}시`); if (gender === 'female') parts.push('여성'); else if (gender === 'male') parts.push('남성'); return parts.join(' · '); } // saju_data → /work/saju/result 쿼리 재구성. calendarType은 saju_data에 없으면 solar 기본값 // (result 페이지는 saju_data 저장 시 이미 양력으로 변환된 birth_year/month/day를 사용하기 때문). function sajuResultHref(data: Record): string { const year = data.birth_year; const month = data.birth_month; const day = data.birth_day; const hour = data.birth_hour; const gender = data.gender; if (typeof year !== 'number' || typeof month !== 'number' || typeof day !== 'number') return '/work/saju'; const calendarType = (typeof data.calendarType === 'string' && data.calendarType) || (typeof data.calendar === 'string' && data.calendar) || 'solar'; const params = new URLSearchParams({ year: String(year), month: String(month), day: String(day), gender: typeof gender === 'string' ? gender : 'male', calendarType, }); if (typeof hour === 'number') params.set('hour', String(hour)); return `/work/saju/result?${params.toString()}`; } // AI 기록 — 사주 카드. 날짜 + 생년월일 요약 + 결과 다시 보기 링크. function SajuAiCard({ record }: { record: SajuRecordRow }) { return (
사주 {new Date(record.created_at).toLocaleDateString('ko-KR')}
{formatSajuBirth(record.saju_data)}
결과 다시 보기
); } // AI 기록 — 타로 카드. 날짜·카테고리·질문·3장 카드명·요약 + 조언/주의 접이식. function TarotAiCard({ reading, expanded, onToggle, }: { reading: TarotReadingRow; expanded: boolean; onToggle: () => void; }) { const hasDetail = Boolean(reading.interpretation?.advice || reading.interpretation?.warning); return (
타로 {reading.category && ( {reading.category} )}
{new Date(reading.created_at).toLocaleDateString('ko-KR')}
{reading.question && (

{reading.question}

)}
{reading.cards.map((c, i) => { const card = c.card_id ? findCard(c.card_id) : undefined; return ( {c.position}: {card?.name ?? '알 수 없음'} {c.reversed ? ' (역방향)' : ''} ); })}
{reading.summary && (

{reading.summary}

)} {hasDetail && ( <> {expanded && (
{reading.interpretation?.advice && (

{reading.interpretation.advice}

)} {reading.interpretation?.warning && (

주의 — {reading.interpretation.warning}

)}
)} )}
); } function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) { return ( ); }