diff --git a/src/pages/saju/components/ElementBarChart.jsx b/src/pages/saju/components/ElementBarChart.jsx
new file mode 100644
index 0000000..cfd3f6b
--- /dev/null
+++ b/src/pages/saju/components/ElementBarChart.jsx
@@ -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 (
+
+ {ELEMENT_ORDER.map((e) => {
+ const value = scores[e] || 0;
+ const widthPct = (value / max) * 100;
+ return (
+
+
{e} ({ELEMENT_KR[e]})
+
+
{value.toFixed(1)}%
+
+ );
+ })}
+
+ );
+}
diff --git a/src/pages/saju/components/HoryungQuote.jsx b/src/pages/saju/components/HoryungQuote.jsx
new file mode 100644
index 0000000..0dd8927
--- /dev/null
+++ b/src/pages/saju/components/HoryungQuote.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import HoryungMascot from './HoryungMascot';
+
+export default function HoryungQuote({ pose = 'thinking', text }) {
+ if (!text) return null;
+ return (
+
+ );
+}
diff --git a/src/pages/saju/components/InterpretAccordion.jsx b/src/pages/saju/components/InterpretAccordion.jsx
new file mode 100644
index 0000000..681b97c
--- /dev/null
+++ b/src/pages/saju/components/InterpretAccordion.jsx
@@ -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 (
+
+ {items.map((it) => {
+ const isOpen = openKey === it.key;
+ return (
+
+
setOpenKey(isOpen ? null : it.key)}
+ role="button"
+ tabIndex={0}
+ onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') setOpenKey(isOpen ? null : it.key); }}
+ >
+ {it.title || it.key}
+ {isOpen ? '▾' : '▸'}
+
+ {isOpen && (
+
+
{it.content}
+ {it.evidence && (
+
+ 근거: {it.evidence.saju_element}
+ 해석 논리: {it.evidence.reasoning}
+
+ )}
+
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/pages/saju/components/MonthlyFlow.jsx b/src/pages/saju/components/MonthlyFlow.jsx
new file mode 100644
index 0000000..6d90f19
--- /dev/null
+++ b/src/pages/saju/components/MonthlyFlow.jsx
@@ -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 (
+
+ {flow.map((m) => (
+
+ {m.month}월
+
+ {m.score}
+
+ {m.label}
+
+ ))}
+
+ );
+}
diff --git a/src/pages/saju/components/SajuPillars.jsx b/src/pages/saju/components/SajuPillars.jsx
new file mode 100644
index 0000000..b847e2a
--- /dev/null
+++ b/src/pages/saju/components/SajuPillars.jsx
@@ -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 (
+
+ {pillars.map((p) => {
+ const data = saju[p];
+ if (!data) {
+ return (
+
+
{PILLAR_LABELS[p]}
+
-
+
+ );
+ }
+ return (
+
+
{PILLAR_LABELS[p]}
+
+ {data.stem}
+ ({data.stem_kr})
+
+
+ {data.branch}
+ ({data.branch_kr})
+
+
{data.ten_god}
+
{data.fortune}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/pages/saju/hooks/useSajuReading.js b/src/pages/saju/hooks/useSajuReading.js
new file mode 100644
index 0000000..cbaa6f4
--- /dev/null
+++ b/src/pages/saju/hooks/useSajuReading.js
@@ -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 };
+}