From 124478e3d66bbb494d7f868087f111b38a8fc7f4 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 2 Jul 2026 21:50:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(phase2):=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20AI=20=EA=B8=B0=EB=A1=9D=20=ED=83=AD=20?= =?UTF-8?q?=E2=80=94=20=EC=82=AC=EC=A3=BC=C2=B7=ED=83=80=EB=A1=9C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saju_records와 GET /api/tarot/readings를 병합 조회해 마이페이지 5번째 탭으로 노출한다. 사주는 결과 페이지로 바로 돌아갈 수 있는 링크를, 타로는 뽑은 카드·요약·조언/주의 접이식을 제공한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/mypage/page.tsx | 296 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 294 insertions(+), 2 deletions(-) diff --git a/app/mypage/page.tsx b/app/mypage/page.tsx index f4a5d29..5d67015 100644 --- a/app/mypage/page.tsx +++ b/app/mypage/page.tsx @@ -7,6 +7,7 @@ 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, @@ -22,7 +23,7 @@ import { const KOR_TIGHT = { letterSpacing: '-0.02em' } as const; const KOR_BODY = { letterSpacing: '-0.01em' } as const; -type Tab = 'profile' | 'requests' | 'products' | 'orders'; +type Tab = 'profile' | 'requests' | 'products' | 'orders' | 'ai'; type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting'; // 구 탭 키 → 새 탭 키 매핑. 사주/구독/프로젝트 등 폐지 탭은 프로필로 폴백. @@ -36,6 +37,8 @@ function resolveTab(raw: string | null): Tab { case 'orders': case 'payments': return 'orders'; + case 'ai': + return 'ai'; case 'profile': case 'saju': case 'subscription': @@ -46,6 +49,30 @@ function resolveTab(raw: string | null): Tab { } } +// 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; @@ -125,6 +152,11 @@ function MyPageContent() { 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'); @@ -134,6 +166,27 @@ function MyPageContent() { } 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(); @@ -188,10 +241,13 @@ function MyPageContent() { // 발주·진행 (quotes 기반) 조회 await loadProjects(); + // AI 기록(사주·타로) 조회 + await loadAiRecords(); + setLoading(false); } init(); - }, [loadProjects]); + }, [loadProjects, loadAiRecords]); // ── 텔레그램 연결 ── const handleTelegramConnect = async () => { @@ -247,6 +303,15 @@ function MyPageContent() { }); } + 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; @@ -304,11 +369,18 @@ function MyPageContent() { // 입금 확인 대기 중인 주문 (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) { @@ -825,6 +897,56 @@ function MyPageContent() { )} + + {/* ===== AI 기록 (사주·타로 결과 통합) ===== */} + {tab === 'ai' && ( +
+ {aiRecords.length === 0 ? ( +
+
+ AI 기록이 없습니다 +
+
+ 사주 분석·타로 리딩 결과가 여기에 모아서 표시됩니다. +
+
+ + 사주 분석 하기 → + + + 타로 리딩 하기 → + +
+
+ ) : ( +
+ {aiRecords.map((item) => + item.kind === 'saju' ? ( + + ) : ( + toggleAiCard(item.data.id)} + /> + ) + )} +
+ )} +
+ )} ); @@ -1277,6 +1399,176 @@ function EmptyState({ ); } +// 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 (