feat(phase2): 마이페이지 AI 기록 탭 — 사주·타로 결과 통합

saju_records와 GET /api/tarot/readings를 병합 조회해 마이페이지 5번째
탭으로 노출한다. 사주는 결과 페이지로 바로 돌아갈 수 있는 링크를,
타로는 뽑은 카드·요약·조언/주의 접이식을 제공한다.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:50:12 +09:00
parent 96a0b06706
commit 124478e3d6

View File

@@ -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<string, unknown>;
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<string>('');
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
// AI 기록 탭 — 타로 리딩 / 사주 기록
const [tarotReadings, setTarotReadings] = useState<TarotReadingRow[]>([]);
const [sajuRecords, setSajuRecords] = useState<SajuRecordRow[]>([]);
const [expandedAiCards, setExpandedAiCards] = useState<Set<string>>(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() {
</section>
</div>
)}
{/* ===== AI 기록 (사주·타로 결과 통합) ===== */}
{tab === 'ai' && (
<div>
{aiRecords.length === 0 ? (
<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 }}>
AI
</div>
<div className="text-sm mb-6 break-keep max-w-sm mx-auto" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
· .
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<Link
href="/work/saju"
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)' }}
>
</Link>
<Link
href="/tarot"
className="inline-flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-colors hover:bg-[var(--jsm-surface-alt)]"
style={{ color: 'var(--jsm-ink)', border: '1px solid var(--jsm-line)' }}
>
</Link>
</div>
</div>
) : (
<div className="space-y-3">
{aiRecords.map((item) =>
item.kind === 'saju' ? (
<SajuAiCard key={`saju-${item.data.id}`} record={item.data} />
) : (
<TarotAiCard
key={`tarot-${item.data.id}`}
reading={item.data}
expanded={expandedAiCards.has(item.data.id)}
onToggle={() => toggleAiCard(item.data.id)}
/>
)
)}
</div>
)}
</div>
)}
</div>
</div>
);
@@ -1277,6 +1399,176 @@ function EmptyState({
);
}
// saju_data(jsonb)에서 생년월일 요약 문자열 구성 — birth_year/month/day 없으면 안내 문구로 폴백
function formatSajuBirth(data: Record<string, unknown>): 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, unknown>): 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 (
<Card compact>
<div className="flex items-center justify-between gap-3 mb-3">
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
>
</span>
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(record.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
<div className="text-sm font-semibold mb-3 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{formatSajuBirth(record.saju_data)}
</div>
<Link
href={sajuResultHref(record.saju_data)}
className="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>
</Card>
);
}
// AI 기록 — 타로 카드. 날짜·카테고리·질문·3장 카드명·요약 + 조언/주의 접이식.
function TarotAiCard({
reading,
expanded,
onToggle,
}: {
reading: TarotReadingRow;
expanded: boolean;
onToggle: () => void;
}) {
const hasDetail = Boolean(reading.interpretation?.advice || reading.interpretation?.warning);
return (
<Card compact>
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex flex-wrap items-center gap-2">
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
</span>
{reading.category && (
<span
className="text-xs font-semibold px-2.5 py-1 rounded-full whitespace-nowrap"
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)' }}
>
{reading.category}
</span>
)}
</div>
<span className="text-xs flex-shrink-0" style={{ color: 'var(--jsm-ink-faint)' }}>
{new Date(reading.created_at).toLocaleDateString('ko-KR')}
</span>
</div>
{reading.question && (
<p className="text-sm font-semibold mb-2 break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
{reading.question}
</p>
)}
<div className="flex flex-wrap gap-1.5 mb-3">
{reading.cards.map((c, i) => {
const card = c.card_id ? findCard(c.card_id) : undefined;
return (
<span
key={`${c.position}-${i}`}
className="text-xs px-2 py-0.5 rounded-full break-keep"
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)' }}
>
{c.position}: {card?.name ?? '알 수 없음'}
{c.reversed ? ' (역방향)' : ''}
</span>
);
})}
</div>
{reading.summary && (
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
{reading.summary}
</p>
)}
{hasDetail && (
<>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="mt-3 inline-flex items-center gap-1.5 text-xs font-semibold transition-colors hover:underline"
style={{ color: 'var(--jsm-accent)', ...KOR_BODY }}
>
{expanded ? '조언·주의 접기' : '조언·주의 더보기'}
<Chevron open={expanded} />
</button>
{expanded && (
<div className="mt-3 pt-3 border-t space-y-2" style={{ borderColor: 'var(--jsm-line)' }}>
{reading.interpretation?.advice && (
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
{reading.interpretation.advice}
</p>
)}
{reading.interpretation?.warning && (
<p className="text-sm leading-relaxed break-keep" style={{ color: '#b45309', ...KOR_BODY }}>
{reading.interpretation.warning}
</p>
)}
</div>
)}
</>
)}
</Card>
);
}
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
return (
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>