feat(phase2): 타로 UI(3카드 리딩) + 카드 이미지 78종
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
app/tarot/layout.tsx
Normal 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
@@ -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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
public/images/tarot/card_back.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/images/tarot/cards/ace-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ace-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/ace-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ace-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/death.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/eight-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/eight-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/eight-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/eight-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/five-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/five-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/five-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/five-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/four-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/four-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/four-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/four-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/judgement.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/justice.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/king-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/king-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/king-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/king-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/knight-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/knight-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/knight-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/knight-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/nine-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/nine-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/nine-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/nine-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/page-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/page-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/page-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/page-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/queen-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/queen-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/queen-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/queen-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/seven-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/seven-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/seven-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/seven-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/six-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/six-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/six-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/six-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/strength.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/temperance.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/ten-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ten-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/ten-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/ten-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/the-chariot.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-devil.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-emperor.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-empress.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-fool.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-hanged-man.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-hermit.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/images/tarot/cards/the-hierophant.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
public/images/tarot/cards/the-high-priestess.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-lovers.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-magician.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/the-moon.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-star.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/the-sun.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/the-tower.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/the-world.png
Normal file
|
After Width: | Height: | Size: 3.3 MiB |
BIN
public/images/tarot/cards/three-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/three-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/three-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/three-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/two-of-cups.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/two-of-pentacles.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |
BIN
public/images/tarot/cards/two-of-swords.png
Normal file
|
After Width: | Height: | Size: 3.0 MiB |
BIN
public/images/tarot/cards/two-of-wands.png
Normal file
|
After Width: | Height: | Size: 3.1 MiB |
BIN
public/images/tarot/cards/wheel-of-fortune.png
Normal file
|
After Width: | Height: | Size: 3.2 MiB |