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 type { User } from '@supabase/supabase-js';
|
||||||
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
import TelegramGuideModal from '@/app/components/TelegramGuideModal';
|
||||||
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
import { KAKAO_OPENCHAT_URL } from '@/lib/contact';
|
||||||
|
import { findCard } from '@/lib/tarot/cards';
|
||||||
import {
|
import {
|
||||||
REQUEST_STATUS,
|
REQUEST_STATUS,
|
||||||
TIMELINE_STEPS,
|
TIMELINE_STEPS,
|
||||||
@@ -22,7 +23,7 @@ import {
|
|||||||
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
const KOR_TIGHT = { letterSpacing: '-0.02em' } as const;
|
||||||
const KOR_BODY = { letterSpacing: '-0.01em' } 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';
|
type TelegramLinkState = 'idle' | 'generating' | 'waiting' | 'disconnecting';
|
||||||
|
|
||||||
// 구 탭 키 → 새 탭 키 매핑. 사주/구독/프로젝트 등 폐지 탭은 프로필로 폴백.
|
// 구 탭 키 → 새 탭 키 매핑. 사주/구독/프로젝트 등 폐지 탭은 프로필로 폴백.
|
||||||
@@ -36,6 +37,8 @@ function resolveTab(raw: string | null): Tab {
|
|||||||
case 'orders':
|
case 'orders':
|
||||||
case 'payments':
|
case 'payments':
|
||||||
return 'orders';
|
return 'orders';
|
||||||
|
case 'ai':
|
||||||
|
return 'ai';
|
||||||
case 'profile':
|
case 'profile':
|
||||||
case 'saju':
|
case 'saju':
|
||||||
case 'subscription':
|
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 {
|
interface Payment {
|
||||||
id: string;
|
id: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -125,6 +152,11 @@ function MyPageContent() {
|
|||||||
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
const [telegramLinkExpiry, setTelegramLinkExpiry] = useState<string>('');
|
||||||
const [showTelegramGuide, setShowTelegramGuide] = useState(false);
|
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 () => {
|
const loadProjects = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/projects');
|
const res = await fetch('/api/projects');
|
||||||
@@ -134,6 +166,27 @@ function MyPageContent() {
|
|||||||
} catch { /* 미로그인/네트워크 — 무시 */ }
|
} 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(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
@@ -188,10 +241,13 @@ function MyPageContent() {
|
|||||||
// 발주·진행 (quotes 기반) 조회
|
// 발주·진행 (quotes 기반) 조회
|
||||||
await loadProjects();
|
await loadProjects();
|
||||||
|
|
||||||
|
// AI 기록(사주·타로) 조회
|
||||||
|
await loadAiRecords();
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
init();
|
init();
|
||||||
}, [loadProjects]);
|
}, [loadProjects, loadAiRecords]);
|
||||||
|
|
||||||
// ── 텔레그램 연결 ──
|
// ── 텔레그램 연결 ──
|
||||||
const handleTelegramConnect = async () => {
|
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)
|
// 견적서 코드 연결 (공개 견적 페이지에서 발급된 public_token)
|
||||||
const handleLink = async () => {
|
const handleLink = async () => {
|
||||||
if (!linkCode.trim() || linking) return;
|
if (!linkCode.trim() || linking) return;
|
||||||
@@ -304,11 +369,18 @@ function MyPageContent() {
|
|||||||
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
|
// 입금 확인 대기 중인 주문 (orders 테이블 pending)
|
||||||
const pendingOrders = productOrders.filter((o) => o.status === '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 }[] = [
|
const tabs: { key: Tab; label: string; count?: number }[] = [
|
||||||
{ key: 'profile', label: '프로필' },
|
{ key: 'profile', label: '프로필' },
|
||||||
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
|
{ key: 'requests', label: '발주·진행', count: orders.length || undefined },
|
||||||
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
|
{ key: 'products', label: '내 제품', count: productGroups.length || undefined },
|
||||||
{ key: 'orders', label: '주문 내역', count: (orders.length + payments.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) {
|
function selectTab(key: Tab) {
|
||||||
@@ -825,6 +897,56 @@ function MyPageContent() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</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>
|
||||||
</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 }) {
|
function TelegramIcon({ className, style }: { className?: string; style?: React.CSSProperties }) {
|
||||||
return (
|
return (
|
||||||
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
<svg className={className} style={style} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
|||||||
Reference in New Issue
Block a user