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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user