feat(saju): 사주풀이 5 컴포넌트 + useSajuReading hook

This commit is contained in:
2026-05-26 08:31:10 +09:00
parent 2dd92d025f
commit 36665ec308
6 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
import React from 'react';
const ELEMENT_ORDER = ['木', '火', '土', '金', '水'];
const ELEMENT_KR = { '木': '목', '火': '화', '土': '토', '金': '금', '水': '수' };
export default function ElementBarChart({ scores }) {
if (!scores) return null;
const max = Math.max(...Object.values(scores), 1);
return (
<div className="saju-element-bars">
{ELEMENT_ORDER.map((e) => {
const value = scores[e] || 0;
const widthPct = (value / max) * 100;
return (
<div key={e} className="saju-element-bar">
<div className="saju-element-bar__label">{e} ({ELEMENT_KR[e]})</div>
<div className="saju-element-bar__track">
<div
className={`saju-element-bar__fill saju-element-bar__fill--${e}`}
style={{ width: `${widthPct}%` }}
/>
</div>
<div className="saju-element-bar__value">{value.toFixed(1)}%</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import React from 'react';
import HoryungMascot from './HoryungMascot';
export default function HoryungQuote({ pose = 'thinking', text }) {
if (!text) return null;
return (
<div className="saju-horyung-quote">
<HoryungMascot pose={pose} size="sm" />
<div className="saju-horyung-quote__text">{text}</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React, { useState } from 'react';
export default function InterpretAccordion({ items }) {
const [openKey, setOpenKey] = useState(items?.[0]?.key);
if (!items || items.length === 0) return null;
return (
<div className="saju-interpret-accordion">
{items.map((it) => {
const isOpen = openKey === it.key;
return (
<div key={it.key} className="saju-interpret-item">
<div
className="saju-interpret-item__header"
onClick={() => setOpenKey(isOpen ? null : it.key)}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setOpenKey(isOpen ? null : it.key); }}
>
<span>{it.title || it.key}</span>
<span aria-hidden>{isOpen ? '▾' : '▸'}</span>
</div>
{isOpen && (
<div className="saju-interpret-item__body">
<p style={{ margin: 0 }}>{it.content}</p>
{it.evidence && (
<div className="saju-interpret-item__evidence">
<strong>근거:</strong> {it.evidence.saju_element}<br />
<strong>해석 논리:</strong> {it.evidence.reasoning}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,26 @@
import React from 'react';
const LABEL_COLOR = {
'성장': '#4B7065',
'안정': '#D4A574',
'변동': '#6A5285',
'도전': '#C58F76',
'정체': '#888',
};
export default function MonthlyFlow({ flow }) {
if (!flow || flow.length === 0) return null;
return (
<div className="saju-monthly-flow">
{flow.map((m) => (
<div key={m.month} className="saju-monthly-flow__cell">
<span className="saju-monthly-flow__month">{m.month}</span>
<span className="saju-monthly-flow__score" style={{ color: LABEL_COLOR[m.label] }}>
{m.score}
</span>
<span className="saju-monthly-flow__label">{m.label}</span>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
export default function SajuPillars({ saju }) {
if (!saju) return null;
const pillars = ['year', 'month', 'day', 'hour'];
return (
<div className="saju-pillars">
{pillars.map((p) => {
const data = saju[p];
if (!data) {
return (
<div key={p} className="saju-pillar">
<div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
<div style={{ opacity: 0.4 }}>-</div>
</div>
);
}
return (
<div key={p} className="saju-pillar">
<div className="saju-pillar__label">{PILLAR_LABELS[p]}</div>
<div>
<span className="saju-pillar__stem">{data.stem}</span>
<span className="saju-pillar__stem-kr"> ({data.stem_kr})</span>
</div>
<div>
<span className="saju-pillar__branch">{data.branch}</span>
<span className="saju-pillar__branch-kr"> ({data.branch_kr})</span>
</div>
<div className="saju-pillar__ten-god">{data.ten_god}</div>
<div className="saju-pillar__fortune">{data.fortune}</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useState, useEffect } from 'react';
import { sajuGetReading } from '../../../api';
export default function useSajuReading(readingId) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (!readingId) {
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
sajuGetReading(readingId)
.then((d) => {
if (!cancelled) {
setData(d);
setLoading(false);
}
})
.catch((e) => {
if (!cancelled) {
setError(e.message || '사주 결과를 불러올 수 없습니다.');
setLoading(false);
}
});
return () => { cancelled = true; };
}, [readingId]);
return { data, loading, error };
}