'use client'; import { useEffect, useState } from 'react'; import type { CSSProperties } from 'react'; import Link from 'next/link'; import { TAROT_DECK, SPREADS, CATEGORIES } from '@/lib/tarot/cards'; import type { TarotCard } from '@/lib/tarot/cards'; import { buildShuffle } from '@/lib/tarot/shuffle'; import type { Pick } from '@/lib/tarot/shuffle'; import { buildReferenceBlock, buildContextMeta } from '@/lib/tarot/reference'; import type { TarotInterpretation } from '@/lib/tarot/prompt'; // 타로 3카드 리딩 클라이언트 — web-ui Reading.jsx의 구조·상태머신을 참고해 // 이 저장소의 라이트(--jsm-*) 디자인 언어로 새로 작성. // 3-step: setup(질문+카테고리) → pick(20장 부채꼴에서 3장 선택) → result(3장 + 2탭). const KOR_TIGHT = { letterSpacing: '-0.02em' } as const; const KOR_BODY = { letterSpacing: '-0.01em' } as const; const INPUT_STYLE = { background: 'var(--jsm-surface-alt)', border: '1px solid var(--jsm-line)', color: 'var(--jsm-ink)', } as const; const SPREAD = SPREADS[0]; const DEFAULT_CATEGORY = CATEGORIES[CATEGORIES.length - 1]; const QUESTION_MAX = 200; const DECK_SIZE = 20; type DeckCard = TarotCard & { reversed: boolean }; type Step = 'setup' | 'pick' | 'result'; type ResultTab = 'meaning' | 'ai'; type AiStatus = 'idle' | 'loading' | 'done' | 'auth' | 'limit' | 'error'; const STEP_LABELS: { key: Step; label: string }[] = [ { key: 'setup', label: '질문 설정' }, { key: 'pick', label: '카드 선택' }, { key: 'result', label: '리딩 결과' }, ]; // ── 카드 칩(카테고리) ──────────────────────────────────────────────── function Chip({ label, selected, onClick }: { label: string; selected: boolean; onClick: () => void }) { return ( ); } // ── 단계 인디케이터 ────────────────────────────────────────────────── function StepIndicator({ step }: { step: Step }) { const idx = STEP_LABELS.findIndex((s) => s.key === step); return (
{STEP_LABELS.map((s, i) => (
{i + 1} {s.label}
{i < STEP_LABELS.length - 1 && ( )}
))}
); } // ── 카드 앞면 — 이미지 실패 시 카드명·영문명 텍스트 폴백, 역방향은 180도 회전 ── function TarotFrontFace({ card, reversed, sizeClass }: { card: TarotCard; reversed: boolean; sizeClass: string }) { const [broken, setBroken] = useState(false); return (
{!broken ? ( {card.name} setBroken(true)} className="h-full w-full object-cover" style={{ transform: reversed ? 'rotate(180deg)' : undefined }} /> ) : (
{card.name} {card.nameEn}
)}
); } // ── 부채꼴 배치용 트랜스폼 계산 ────────────────────────────────────── function fanCardStyle(index: number, total: number): CSSProperties { const mid = (total - 1) / 2; const offset = index - mid; const rotate = offset * 3.4; const lift = Math.abs(offset) * 2.2; return { transform: `rotate(${rotate}deg) translateY(${lift}px)`, transformOrigin: 'bottom center', marginLeft: index === 0 ? 0 : -34, zIndex: index, }; } // ── 탭 1: 카드 해석(항상 표시, 정역 반영 로컬 데이터) ───────────────── function MeaningTab({ picks }: { picks: Pick[] }) { return (
{picks.map((p) => { const c = p.card; const keywords = p.reversed ? c.reversedKeywords : c.keywords; const meaning = p.reversed ? c.meaningReversed : c.meaningUpright; return (
{p.position}

{c.name} · {c.nameEn} ({p.reversed ? '역방향' : '정방향'})

{keywords.map((k) => ( {k} ))}

{meaning}

{c.symbols.map((s) => (

{s.label} {' '} — {s.meaning}

))}
); })}
); } const CONFIDENCE_LABEL: Record = { high: '높음', medium: '보통', low: '낮음', }; const CONFIDENCE_COLOR: Record = { high: 'var(--jsm-accent)', medium: 'var(--jsm-ink-soft)', low: '#b45309', }; const INTERACTION_LABEL: Record = { synergy: '시너지', conflict: '충돌', transition: '전환', }; // ── 탭 2: AI 인사이트 — idle/loading/auth/limit/error/done ─────────── function AiInsightTab({ status, errorMessage, interpretation, onStart, }: { status: AiStatus; errorMessage: string; interpretation: TarotInterpretation | null; onStart: () => void; }) { const panelStyle = { borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' } as const; if (status === 'idle') { return (

카드의 상징과 위치를 근거로 AI가 3장의 흐름을 해석합니다. 로그인 후 하루 3회까지 무료로 이용할 수 있습니다.

); } if (status === 'loading') { return (

AI가 카드를 해석하는 중입니다...

최대 45초 정도 걸릴 수 있습니다.

); } if (status === 'auth') { return (

로그인하면 AI 해석을 무료로 받을 수 있습니다. (일 3회)

로그인하고 해석 보기
); } if (status === 'limit') { return (

{errorMessage || '오늘의 무료 AI 해석 횟수를 모두 사용했습니다.'}

내일 다시 시도해주세요.

); } if (status === 'error') { return (

{errorMessage || 'AI 해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'}

); } if (!interpretation) return null; return (

종합 요약

신뢰도 {CONFIDENCE_LABEL[interpretation.confidence]}

{interpretation.summary}

{interpretation.cards.map((c) => (
{c.position}

{c.card} ({c.reversed ? '역방향' : '정방향'})

{c.interpretation}

근거 · 카드 의미 {' '} — {c.evidence.card_meaning_used}

근거 · 위치 논리 {' '} — {c.evidence.position_logic}

근거 · 카테고리 관점 {' '} — {c.evidence.category_lens}

{c.advice}

))}
{interpretation.interactions.length > 0 && (

카드 간 상호작용

{interpretation.interactions.map((it, i) => (

{INTERACTION_LABEL[it.type]} {it.between.join(' · ')} — {it.explanation}

))}
)}

종합 조언

{interpretation.advice}

{interpretation.warning && (

주의 — {interpretation.warning}

)}
); } // ── 메인 컴포넌트 ────────────────────────────────────────────────── export default function TarotReadingClient() { // hydration mismatch 방지 — 최초 렌더는 빈 배열, 마운트 후 클라에서만 셔플 const [deck, setDeck] = useState([]); useEffect(() => { setDeck(buildShuffle(TAROT_DECK, DECK_SIZE)); }, []); const [step, setStep] = useState('setup'); const [question, setQuestion] = useState(''); const [category, setCategory] = useState(DEFAULT_CATEGORY); const [picks, setPicks] = useState([]); const [resultTab, setResultTab] = useState('meaning'); const [aiStatus, setAiStatus] = useState('idle'); const [aiErrorMessage, setAiErrorMessage] = useState(''); const [interpretation, setInterpretation] = useState(null); const availableDeck = deck.filter((c) => !picks.some((p) => p.card.slug === c.slug)); const currentPosition = SPREAD.positions[picks.length]; function startPicking() { setPicks([]); setResultTab('meaning'); setAiStatus('idle'); setAiErrorMessage(''); setInterpretation(null); setStep('pick'); } function handlePick(card: DeckCard) { if (picks.length >= SPREAD.positions.length) return; const position = SPREAD.positions[picks.length]; const next: Pick[] = [...picks, { card, position, reversed: card.reversed }]; setPicks(next); if (next.length === SPREAD.positions.length) setStep('result'); } function restart() { setDeck(buildShuffle(TAROT_DECK, DECK_SIZE)); setPicks([]); setResultTab('meaning'); setAiStatus('idle'); setAiErrorMessage(''); setInterpretation(null); setStep('setup'); } async function handleInterpret() { if (picks.length < SPREAD.positions.length) return; setAiStatus('loading'); setAiErrorMessage(''); const cards = picks.map((p) => ({ position: p.position, card_id: p.card.slug, reversed: p.reversed })); const payload = { spread_type: 'three_card', category, question: question.trim() || null, cards, cards_reference: buildReferenceBlock(picks), context_meta: buildContextMeta(picks), }; try { const res = await fetch('/api/tarot/interpret', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); let body: { interpretation_json?: TarotInterpretation; model?: string; error?: string } = {}; try { body = await res.json(); } catch { body = {}; } if (res.status === 401) { setAiStatus('auth'); return; } if (res.status === 429) { setAiErrorMessage(body.error ?? '오늘의 무료 AI 해석 횟수를 모두 사용했습니다.'); setAiStatus('limit'); return; } if (!res.ok || !body.interpretation_json) { setAiErrorMessage(body.error ?? 'AI 해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'); setAiStatus('error'); return; } setInterpretation(body.interpretation_json); setAiStatus('done'); // 리딩 저장은 best-effort — 실패해도 이미 렌더된 해석은 유지한다. fetch('/api/tarot/readings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spread_type: 'three_card', category, question: question.trim() || null, cards, interpretation_json: body.interpretation_json, }), }).catch(() => {}); } catch { setAiErrorMessage('네트워크 오류로 해석을 가져오지 못했습니다.'); setAiStatus('error'); } } return (
{/* ── setup: 질문 + 카테고리 ── */} {step === 'setup' && (

질문을 정리해보세요

구체적일수록 카드의 의미가 선명하게 연결됩니다. 비워두어도 리딩은 진행됩니다.