feat(phase2): 타로 UI(3카드 리딩) + 카드 이미지 78종

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:17:52 +09:00
parent b3d845a532
commit a9f5d8cee6
82 changed files with 770 additions and 0 deletions

View 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>
);
}

14
app/tarot/layout.tsx Normal file
View File

@@ -0,0 +1,14 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: '타로 리딩 | 쟁승메이드',
description: '3카드(과거·현재·미래) 타로 스프레드. AI가 카드 상징을 근거로 해석합니다.',
openGraph: {
title: '타로 리딩 | 쟁승메이드',
url: 'https://jaengseung-made.com/tarot',
},
};
export default function TarotLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

37
app/tarot/page.tsx Normal file
View File

@@ -0,0 +1,37 @@
import TarotReadingClient from './TarotReadingClient';
// 타로 리딩 공개 라우트 — 서버 Hero(라이트 관용구, app/showcase 참고) + 클라이언트 리딩 마운트.
const KOR_BODY = { letterSpacing: '-0.01em' } as const;
export default function TarotPage() {
return (
<>
<section style={{ background: 'var(--jsm-surface)' }}>
<div className="mx-auto max-w-6xl px-6 pt-20 pb-16 lg:px-8 lg:pt-28 lg:pb-20">
<div className="max-w-2xl">
<span
className="inline-flex items-center gap-2 font-mono text-[11px] uppercase tracking-[0.22em]"
style={{ color: 'var(--jsm-accent)' }}
>
<span className="inline-block h-1 w-1 rounded-full" style={{ background: 'var(--jsm-accent)' }} />
tarot reading
</span>
<h1
className="mt-6 font-extrabold break-keep"
style={{ color: 'var(--jsm-ink)', fontSize: 'clamp(2.3rem, 6vw, 3.6rem)', lineHeight: 1.1, letterSpacing: '-0.035em' }}
>
<span style={{ color: 'var(--jsm-accent)' }}>.</span>
</h1>
<p className="mt-7 break-keep text-lg leading-relaxed" style={{ color: 'var(--jsm-ink-soft)', ...KOR_BODY }}>
3 ·· .
</p>
</div>
</div>
</section>
<TarotReadingClient />
</>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB