feat(tarot): TarotCard·CardGrid·SpreadSlots·InterpretationPanel 컴포넌트 (T11)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 00:39:22 +09:00
parent d91be529eb
commit 4c82fa9b21
4 changed files with 190 additions and 0 deletions

View File

@@ -0,0 +1,21 @@
import React from 'react';
import TarotCard from './TarotCard';
export default function CardGrid({ slice, onPick, disabledIds = [] }) {
return (
<div className="tarot-grid">
{slice.map((card) => {
const disabled = disabledIds.includes(card.slug);
return (
<TarotCard
key={card.slug}
card={card}
faceDown
clickable={!disabled}
onClick={() => !disabled && onPick(card)}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
function ConfidenceBadge({ level }) {
if (!level) return null;
const cls = level === 'high' ? 'is-high' : level === 'low' ? 'is-low' : 'is-medium';
const text = level === 'high' ? '높음' : level === 'low' ? '낮음' : '보통';
return <span className={`tarot-confidence ${cls}`}>확신 {text}</span>;
}
export default function InterpretationPanel({ interpretation, selectedCard, focusCardId }) {
const [showEvidence, setShowEvidence] = useState(true);
if (!interpretation) {
return (
<aside className="tarot-panel tarot-panel--empty">
<p>카드를 모두 뽑은 AI 해석을 시작하세요.</p>
</aside>
);
}
const cardDetail = focusCardId
? (interpretation.cards || []).find((c) => c.card === focusCardId)
: null;
return (
<aside className="tarot-panel">
{selectedCard && (
<header className="tarot-panel__head">
<h3 className="tarot-panel__title">{selectedCard.name}</h3>
<p className="tarot-panel__sub">{selectedCard.nameEn}</p>
<div className="tarot-panel__chips">
{(selectedCard.keywords || []).slice(0, 4).map((k) => (
<span key={k} className="tarot-chip">{k}</span>
))}
</div>
</header>
)}
{cardDetail && (
<section className="tarot-panel__section">
<h4> 위치의 해석</h4>
<p>{cardDetail.interpretation}</p>
<p className="tarot-panel__advice">💡 {cardDetail.advice}</p>
<button
type="button"
className="tarot-panel__toggle"
onClick={() => setShowEvidence((v) => !v)}
>
{showEvidence ? '근거 접기' : '근거 펼치기'}
</button>
{showEvidence && cardDetail.evidence && (
<dl className="tarot-evidence">
<dt>카드 의미</dt>
<dd>{cardDetail.evidence.card_meaning_used}</dd>
<dt>위치 결합</dt>
<dd>{cardDetail.evidence.position_logic}</dd>
<dt>카테고리 관점</dt>
<dd>{cardDetail.evidence.category_lens}</dd>
</dl>
)}
</section>
)}
<section className="tarot-panel__section">
<h4>종합 해석 <ConfidenceBadge level={interpretation.confidence} /></h4>
<p>{interpretation.summary}</p>
<p className="tarot-panel__advice">💡 {interpretation.advice}</p>
{interpretation.warning && (
<p className="tarot-panel__warning"> {interpretation.warning}</p>
)}
</section>
{(interpretation.interactions || []).length > 0 && (
<section className="tarot-panel__section">
<h4>카드 상호작용</h4>
<ul className="tarot-interactions">
{interpretation.interactions.map((it, i) => (
<li key={i}>
<span className={`tarot-interaction-type tarot-interaction-type--${it.type}`}>
{it.type === 'synergy' ? '시너지' : it.type === 'conflict' ? '충돌' : '전환'}
</span>
{' '}
<strong>{(it.between || []).join(' ↔ ')}</strong>
<p>{it.explanation}</p>
</li>
))}
</ul>
</section>
)}
</aside>
);
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import TarotCard from './TarotCard';
export default function SpreadSlots({ spread, picks, onCardClick }) {
return (
<div className="tarot-slots">
{spread.positions.map((pos) => {
const pick = picks[pos.idx];
return (
<div key={pos.idx} className="tarot-slots__cell">
<div className="tarot-slots__label">{pos.label}</div>
{pick ? (
<TarotCard
card={pick.card}
reversed={pick.reversed}
clickable
onClick={() => onCardClick(pos.idx)}
/>
) : (
<div className="tarot-slots__empty">_</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
export default function TarotCard({
card, reversed = false, size = 'md', faceDown = false,
clickable = false, onClick, label,
}) {
const sizeClass = size === 'sm' ? 'tarot-card--sm' : size === 'lg' ? 'tarot-card--lg' : 'tarot-card--md';
const handleClick = (e) => {
if (!clickable) return;
onClick?.(card, e);
};
if (faceDown || !card) {
return (
<button
type="button"
className={`tarot-card tarot-card--back ${sizeClass} ${clickable ? 'is-clickable' : ''}`}
onClick={handleClick}
aria-label={label || '카드 뒷면'}
disabled={!clickable}
>
<img src="/images/tarot/card_back.svg" alt="" draggable={false} />
</button>
);
}
const styleClass = reversed ? 'tarot-card--reversed' : '';
const onImgError = (e) => { e.currentTarget.style.display = 'none'; };
return (
<button
type="button"
className={`tarot-card tarot-card--face ${sizeClass} ${styleClass} ${clickable ? 'is-clickable' : ''}`}
onClick={handleClick}
aria-label={`${card.name}${reversed ? ' 역방향' : ''}`}
disabled={!clickable}
>
<div className="tarot-card__inner">
<img src={card.image} alt="" onError={onImgError} draggable={false} />
<div className="tarot-card__fallback">
<div className="tarot-card__symbol">{card.arcana === 'major' ? '✦' : '◆'}</div>
<div className="tarot-card__name">{card.name}</div>
<div className="tarot-card__name-en">{card.nameEn}</div>
</div>
</div>
{label && <div className="tarot-card__label">{label}</div>}
</button>
);
}