diff --git a/app/tarot/TarotReadingClient.tsx b/app/tarot/TarotReadingClient.tsx new file mode 100644 index 0000000..bf756b8 --- /dev/null +++ b/app/tarot/TarotReadingClient.tsx @@ -0,0 +1,719 @@ +'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' && ( +
+

+ 질문을 정리해보세요 +

+

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

+ + +