feat(saju): 사주풀이 5 컴포넌트 + useSajuReading hook
This commit is contained in:
29
src/pages/saju/components/ElementBarChart.jsx
Normal file
29
src/pages/saju/components/ElementBarChart.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/pages/saju/components/HoryungQuote.jsx
Normal file
12
src/pages/saju/components/HoryungQuote.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/pages/saju/components/InterpretAccordion.jsx
Normal file
38
src/pages/saju/components/InterpretAccordion.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/pages/saju/components/MonthlyFlow.jsx
Normal file
26
src/pages/saju/components/MonthlyFlow.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/pages/saju/components/SajuPillars.jsx
Normal file
38
src/pages/saju/components/SajuPillars.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/pages/saju/hooks/useSajuReading.js
Normal file
33
src/pages/saju/hooks/useSajuReading.js
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user