feat(phase2): 타로 UI(3카드 리딩) + 카드 이미지 78종
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
719
app/tarot/TarotReadingClient.tsx
Normal file
719
app/tarot/TarotReadingClient.tsx
Normal file
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
className="rounded-lg px-4 py-2.5 text-sm font-semibold break-keep transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{
|
||||
border: selected ? '1px solid var(--jsm-accent)' : '1px solid var(--jsm-line)',
|
||||
background: selected ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
|
||||
color: selected ? 'var(--jsm-accent)' : 'var(--jsm-ink)',
|
||||
...KOR_BODY,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 단계 인디케이터 ──────────────────────────────────────────────────
|
||||
function StepIndicator({ step }: { step: Step }) {
|
||||
const idx = STEP_LABELS.findIndex((s) => s.key === step);
|
||||
return (
|
||||
<div className="mb-8 flex items-center">
|
||||
{STEP_LABELS.map((s, i) => (
|
||||
<div key={s.key} className="flex flex-1 items-center last:flex-none">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full text-xs font-bold"
|
||||
style={{
|
||||
background: i <= idx ? 'var(--jsm-accent)' : 'var(--jsm-surface-alt)',
|
||||
color: i <= idx ? '#ffffff' : 'var(--jsm-ink-faint)',
|
||||
border: i <= idx ? 'none' : '1px solid var(--jsm-line)',
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
<span
|
||||
className="hidden text-xs font-semibold whitespace-nowrap sm:inline"
|
||||
style={{ color: i <= idx ? 'var(--jsm-ink)' : 'var(--jsm-ink-faint)', ...KOR_BODY }}
|
||||
>
|
||||
{s.label}
|
||||
</span>
|
||||
</div>
|
||||
{i < STEP_LABELS.length - 1 && (
|
||||
<span
|
||||
className="mx-3 h-px flex-1"
|
||||
style={{ background: i < idx ? 'var(--jsm-accent)' : 'var(--jsm-line)' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 카드 앞면 — 이미지 실패 시 카드명·영문명 텍스트 폴백, 역방향은 180도 회전 ──
|
||||
function TarotFrontFace({ card, reversed, sizeClass }: { card: TarotCard; reversed: boolean; sizeClass: string }) {
|
||||
const [broken, setBroken] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className={`relative flex-shrink-0 overflow-hidden rounded-xl border ${sizeClass}`}
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
{!broken ? (
|
||||
<img
|
||||
src={card.image}
|
||||
alt={card.name}
|
||||
draggable={false}
|
||||
onError={() => setBroken(true)}
|
||||
className="h-full w-full object-cover"
|
||||
style={{ transform: reversed ? 'rotate(180deg)' : undefined }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-full w-full flex-col items-center justify-center gap-1 px-2 text-center"
|
||||
style={{ background: 'var(--jsm-surface-alt)' }}
|
||||
>
|
||||
<span className="text-xs font-bold break-keep" style={{ color: 'var(--jsm-ink)' }}>
|
||||
{card.name}
|
||||
</span>
|
||||
<span className="text-[10px]" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{card.nameEn}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 부채꼴 배치용 트랜스폼 계산 ──────────────────────────────────────
|
||||
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 (
|
||||
<div className="space-y-5">
|
||||
{picks.map((p) => {
|
||||
const c = p.card;
|
||||
const keywords = p.reversed ? c.reversedKeywords : c.keywords;
|
||||
const meaning = p.reversed ? c.meaningReversed : c.meaningUpright;
|
||||
return (
|
||||
<div
|
||||
key={p.position}
|
||||
className="rounded-2xl border p-5"
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}
|
||||
>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
{p.position}
|
||||
</span>
|
||||
<h3 className="text-sm font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{c.name} · {c.nameEn} ({p.reversed ? '역방향' : '정방향'})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{keywords.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="rounded-full px-2 py-0.5 text-xs"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink-soft)', ...KOR_BODY }}
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{meaning}
|
||||
</p>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{c.symbols.map((s) => (
|
||||
<p key={s.label} className="text-xs leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
{s.label}
|
||||
</span>{' '}
|
||||
— {s.meaning}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const CONFIDENCE_LABEL: Record<TarotInterpretation['confidence'], string> = {
|
||||
high: '높음',
|
||||
medium: '보통',
|
||||
low: '낮음',
|
||||
};
|
||||
const CONFIDENCE_COLOR: Record<TarotInterpretation['confidence'], string> = {
|
||||
high: 'var(--jsm-accent)',
|
||||
medium: 'var(--jsm-ink-soft)',
|
||||
low: '#b45309',
|
||||
};
|
||||
const INTERACTION_LABEL: Record<TarotInterpretation['interactions'][number]['type'], string> = {
|
||||
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 (
|
||||
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
|
||||
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
카드의 상징과 위치를 근거로 AI가 3장의 흐름을 해석합니다. 로그인 후 하루 3회까지 무료로 이용할 수 있습니다.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStart}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
AI 인사이트 보기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
|
||||
<div
|
||||
className="mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-2"
|
||||
style={{ borderColor: 'var(--jsm-line)', borderTopColor: 'var(--jsm-accent)' }}
|
||||
/>
|
||||
<p className="text-sm font-medium" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
AI가 카드를 해석하는 중입니다...
|
||||
</p>
|
||||
<p className="mt-1 text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
최대 45초 정도 걸릴 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'auth') {
|
||||
return (
|
||||
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
|
||||
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
로그인하면 AI 해석을 무료로 받을 수 있습니다. (일 3회)
|
||||
</p>
|
||||
<Link
|
||||
href="/login?next=/tarot"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-lg px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
로그인하고 해석 보기
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'limit') {
|
||||
return (
|
||||
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
{errorMessage || '오늘의 무료 AI 해석 횟수를 모두 사용했습니다.'}
|
||||
</p>
|
||||
<p className="mt-2 text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
내일 다시 시도해주세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="rounded-2xl border p-8 text-center" style={panelStyle}>
|
||||
<p className="mb-4 text-sm font-medium break-keep" style={{ color: '#b91c1c' }}>
|
||||
{errorMessage || 'AI 해석 생성에 실패했습니다. 잠시 후 다시 시도해주세요.'}
|
||||
</p>
|
||||
<button type="button" onClick={onStart} className="text-sm font-semibold underline" style={{ color: 'var(--jsm-accent)' }}>
|
||||
다시 시도하기
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!interpretation) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-2xl border p-5" style={panelStyle}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
종합 요약
|
||||
</h3>
|
||||
<span
|
||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: CONFIDENCE_COLOR[interpretation.confidence], ...KOR_BODY }}
|
||||
>
|
||||
신뢰도 {CONFIDENCE_LABEL[interpretation.confidence]}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{interpretation.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{interpretation.cards.map((c) => (
|
||||
<div key={c.position} className="rounded-2xl border p-5" style={panelStyle}>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className="rounded-full px-2.5 py-1 text-xs font-semibold"
|
||||
style={{ background: 'var(--jsm-accent-soft)', color: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
{c.position}
|
||||
</span>
|
||||
<h4 className="text-sm font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{c.card} ({c.reversed ? '역방향' : '정방향'})
|
||||
</h4>
|
||||
</div>
|
||||
<p className="mb-3 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{c.interpretation}
|
||||
</p>
|
||||
<div className="mb-3 space-y-1 text-xs leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
<p>
|
||||
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
근거 · 카드 의미
|
||||
</span>{' '}
|
||||
— {c.evidence.card_meaning_used}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
근거 · 위치 논리
|
||||
</span>{' '}
|
||||
— {c.evidence.position_logic}
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold" style={{ color: 'var(--jsm-ink-soft)' }}>
|
||||
근거 · 카테고리 관점
|
||||
</span>{' '}
|
||||
— {c.evidence.category_lens}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
{c.advice}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{interpretation.interactions.length > 0 && (
|
||||
<div className="rounded-2xl border p-5" style={panelStyle}>
|
||||
<h3 className="mb-3 text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
카드 간 상호작용
|
||||
</h3>
|
||||
<div className="space-y-2.5">
|
||||
{interpretation.interactions.map((it, i) => (
|
||||
<p key={i} className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
<span
|
||||
className="mr-2 rounded-full px-2 py-0.5 text-xs font-semibold"
|
||||
style={{ background: 'var(--jsm-surface-alt)', color: 'var(--jsm-ink)' }}
|
||||
>
|
||||
{INTERACTION_LABEL[it.type]}
|
||||
</span>
|
||||
{it.between.join(' · ')} — {it.explanation}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-2xl border p-5" style={panelStyle}>
|
||||
<h3 className="mb-2 text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
종합 조언
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
{interpretation.advice}
|
||||
</p>
|
||||
{interpretation.warning && (
|
||||
<p className="mt-3 text-sm leading-relaxed break-keep" style={{ color: '#b45309', ...KOR_BODY }}>
|
||||
주의 — {interpretation.warning}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 메인 컴포넌트 ──────────────────────────────────────────────────
|
||||
export default function TarotReadingClient() {
|
||||
// hydration mismatch 방지 — 최초 렌더는 빈 배열, 마운트 후 클라에서만 셔플
|
||||
const [deck, setDeck] = useState<DeckCard[]>([]);
|
||||
useEffect(() => {
|
||||
setDeck(buildShuffle(TAROT_DECK, DECK_SIZE));
|
||||
}, []);
|
||||
|
||||
const [step, setStep] = useState<Step>('setup');
|
||||
const [question, setQuestion] = useState('');
|
||||
const [category, setCategory] = useState<string>(DEFAULT_CATEGORY);
|
||||
const [picks, setPicks] = useState<Pick[]>([]);
|
||||
|
||||
const [resultTab, setResultTab] = useState<ResultTab>('meaning');
|
||||
const [aiStatus, setAiStatus] = useState<AiStatus>('idle');
|
||||
const [aiErrorMessage, setAiErrorMessage] = useState('');
|
||||
const [interpretation, setInterpretation] = useState<TarotInterpretation | null>(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 (
|
||||
<section style={{ background: 'var(--jsm-surface-alt)' }}>
|
||||
<div className="mx-auto max-w-5xl px-6 py-14 lg:px-8 lg:py-20">
|
||||
<div className="rounded-2xl border p-6 sm:p-10" style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)' }}>
|
||||
<StepIndicator step={step} />
|
||||
|
||||
{/* ── setup: 질문 + 카테고리 ── */}
|
||||
{step === 'setup' && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-lg font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
질문을 정리해보세요
|
||||
</h2>
|
||||
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
구체적일수록 카드의 의미가 선명하게 연결됩니다. 비워두어도 리딩은 진행됩니다.
|
||||
</p>
|
||||
|
||||
<label htmlFor="tarot-question" className="sr-only">
|
||||
질문
|
||||
</label>
|
||||
<textarea
|
||||
id="tarot-question"
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value.slice(0, QUESTION_MAX))}
|
||||
rows={4}
|
||||
maxLength={QUESTION_MAX}
|
||||
placeholder="예: 지금 준비 중인 이직, 시도해도 괜찮을까요?"
|
||||
className="w-full resize-none rounded-lg px-3.5 py-3 text-sm leading-relaxed outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{ ...INPUT_STYLE, ...KOR_BODY }}
|
||||
/>
|
||||
<p className="mt-1.5 text-right text-xs" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{question.length}/{QUESTION_MAX}
|
||||
</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<p className="mb-2.5 text-sm font-semibold" style={{ color: 'var(--jsm-ink)', ...KOR_BODY }}>
|
||||
카테고리
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
{CATEGORIES.map((c) => (
|
||||
<Chip key={c} label={c} selected={category === c} onClick={() => setCategory(c)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={startPicking}
|
||||
className="mt-8 inline-flex w-full items-center justify-center gap-2 rounded-lg py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--jsm-accent-hover)]"
|
||||
style={{ background: 'var(--jsm-accent)', ...KOR_BODY }}
|
||||
>
|
||||
카드 뽑기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── pick: 20장 부채꼴에서 3장 선택 ── */}
|
||||
{step === 'pick' && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-lg font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{currentPosition} 카드를 골라보세요
|
||||
</h2>
|
||||
<p className="mb-5 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
펼쳐진 카드 중 마음이 끌리는 카드를 선택하세요. ({picks.length}/{SPREAD.positions.length})
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{SPREAD.positions.map((pos, i) => {
|
||||
const pick = picks[i];
|
||||
return (
|
||||
<div
|
||||
key={pos}
|
||||
className="flex flex-col items-center gap-2 rounded-xl border p-3"
|
||||
style={{
|
||||
borderColor: pick ? 'var(--jsm-accent)' : 'var(--jsm-line)',
|
||||
background: pick ? 'var(--jsm-accent-soft)' : 'var(--jsm-surface-alt)',
|
||||
}}
|
||||
>
|
||||
<span className="text-xs font-bold" style={{ color: pick ? 'var(--jsm-accent)' : 'var(--jsm-ink-faint)', ...KOR_BODY }}>
|
||||
{pos}
|
||||
</span>
|
||||
{pick ? (
|
||||
<TarotFrontFace card={pick.card} reversed={pick.reversed} sizeClass="h-20 w-14" />
|
||||
) : (
|
||||
<span
|
||||
className="flex h-20 w-14 items-center justify-center rounded-lg border border-dashed text-[10px]"
|
||||
style={{ borderColor: 'var(--jsm-line)', color: 'var(--jsm-ink-faint)' }}
|
||||
>
|
||||
대기
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{deck.length === 0 ? (
|
||||
<p className="mt-10 text-center text-sm" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
카드를 준비하는 중입니다...
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-8 overflow-x-auto pt-4 pb-6">
|
||||
<div className="flex justify-center px-8" style={{ minWidth: 'max-content' }}>
|
||||
{availableDeck.map((card, i) => (
|
||||
<button
|
||||
key={card.slug}
|
||||
type="button"
|
||||
onClick={() => handlePick(card)}
|
||||
aria-label={`카드 ${i + 1} 선택`}
|
||||
className="relative h-24 w-16 flex-shrink-0 rounded-lg border transition-shadow duration-150 hover:shadow-md focus-visible:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--jsm-accent)]"
|
||||
style={{ borderColor: 'var(--jsm-line)', background: 'var(--jsm-surface)', ...fanCardStyle(i, availableDeck.length) }}
|
||||
>
|
||||
<img
|
||||
src="/images/tarot/card_back.png"
|
||||
alt=""
|
||||
aria-hidden
|
||||
draggable={false}
|
||||
className="h-full w-full rounded-lg object-cover"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── result: 3장 공개 + 2탭 ── */}
|
||||
{step === 'result' && (
|
||||
<div>
|
||||
<h2 className="mb-1 text-lg font-bold break-keep" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
선택한 카드가 스프레드에 놓였습니다
|
||||
</h2>
|
||||
<p className="mb-6 text-sm leading-relaxed break-keep" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
|
||||
과거·현재·미래 순서로 세 장의 카드가 이 리딩의 흐름을 보여줍니다.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 sm:gap-6">
|
||||
{picks.map((p) => (
|
||||
<div key={p.position} className="flex flex-col items-center gap-3">
|
||||
<TarotFrontFace card={p.card} reversed={p.reversed} sizeClass="h-40 w-28 sm:h-52 sm:w-36" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-bold" style={{ color: 'var(--jsm-ink)', ...KOR_TIGHT }}>
|
||||
{p.position}
|
||||
</p>
|
||||
<p className="text-xs break-keep" style={{ color: 'var(--jsm-ink-faint)' }}>
|
||||
{p.card.name}
|
||||
{p.reversed ? ' (역방향)' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button type="button" onClick={restart} className="text-sm font-semibold underline" style={{ color: 'var(--jsm-accent)' }}>
|
||||
새 리딩 시작하기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex gap-1 border-b" style={{ borderColor: 'var(--jsm-line)' }}>
|
||||
{(['meaning', 'ai'] as const).map((t) => {
|
||||
const active = resultTab === t;
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setResultTab(t)}
|
||||
className="border-b-2 px-4 py-3 text-sm font-semibold transition-colors duration-150"
|
||||
style={{ color: active ? 'var(--jsm-ink)' : 'var(--jsm-ink-soft)', borderColor: active ? 'var(--jsm-accent)' : 'transparent', ...KOR_BODY }}
|
||||
>
|
||||
{t === 'meaning' ? '카드 해석' : 'AI 인사이트'}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
{resultTab === 'meaning' ? (
|
||||
<MeaningTab picks={picks} />
|
||||
) : (
|
||||
<AiInsightTab status={aiStatus} errorMessage={aiErrorMessage} interpretation={interpretation} onStart={handleInterpret} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user