'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 ? (

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' && (
질문을 정리해보세요
구체적일수록 카드의 의미가 선명하게 연결됩니다. 비워두어도 리딩은 진행됩니다.
)}
{/* ── pick: 20장 부채꼴에서 3장 선택 ── */}
{step === 'pick' && (
{currentPosition} 카드를 골라보세요
펼쳐진 카드 중 마음이 끌리는 카드를 선택하세요. ({picks.length}/{SPREAD.positions.length})
{SPREAD.positions.map((pos, i) => {
const pick = picks[i];
return (
{pos}
{pick ? (
) : (
대기
)}
);
})}
{deck.length === 0 ? (
카드를 준비하는 중입니다...
) : (
{availableDeck.map((card, i) => (
))}
)}
)}
{/* ── result: 3장 공개 + 2탭 ── */}
{step === 'result' && (
선택한 카드가 스프레드에 놓였습니다
과거·현재·미래 순서로 세 장의 카드가 이 리딩의 흐름을 보여줍니다.
{picks.map((p) => (
{p.position}
{p.card.name}
{p.reversed ? ' (역방향)' : ''}
))}
{(['meaning', 'ai'] as const).map((t) => {
const active = resultTab === t;
return (
);
})}
{resultTab === 'meaning' ? (
) : (
)}
)}
);
}