Compare commits
31 Commits
6e5aabc94c
...
d29fdac4a0
| Author | SHA1 | Date | |
|---|---|---|---|
| d29fdac4a0 | |||
| be762e1ee8 | |||
| 1664fbda09 | |||
| 3c64a4604f | |||
| 29f37a1642 | |||
| e1804ad181 | |||
| 6fdc2593be | |||
| 9bc31d23f5 | |||
| 0d1e8b3c2d | |||
| f8874b2aea | |||
| da694266d4 | |||
| 1bf1f1405b | |||
| e0834b1275 | |||
| 5acf7db27c | |||
| 76c7bcc62b | |||
| 9453474c69 | |||
| f924c25f16 | |||
| 7d89a664aa | |||
| 50ec52ab6e | |||
| 78e7e68bb0 | |||
| fd84e17f0b | |||
| a6d52c9725 | |||
| cc9028ac3d | |||
| 47b5eab3ff | |||
| 7f42c40efc | |||
| d34bedcb4c | |||
| 5f7e66c220 | |||
| 6040d5fd7f | |||
| dd719f5b2e | |||
| e91b7feada | |||
| ac098faeea |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -26,3 +26,6 @@ dist-ssr
|
|||||||
|
|
||||||
# Superpowers visual companion (mockup files)
|
# Superpowers visual companion (mockup files)
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# git worktrees (subagent-driven 격리 작업)
|
||||||
|
.worktrees/
|
||||||
|
|||||||
10
index.html
10
index.html
@@ -5,9 +5,13 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
<link rel="icon" type="image/svg+xml" href="/main_logo.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<title>가후습 개인기록</title>
|
<title>가후습 개인기록</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+KR:wght@500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Nanum+Myeongjo:wght@400;700;800&family=Nanum+Gothic:wght@400;700;800&family=Gowun+Batang:wght@400;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
public/images/saju/horyung/background.png
Normal file
BIN
public/images/saju/horyung/background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
public/images/saju/horyung/horyung-main.png
Normal file
BIN
public/images/saju/horyung/horyung-main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,19 +1,55 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import './Saju.css';
|
import './_shell/tokens.css';
|
||||||
import SajuNav from './components/SajuNav';
|
import './_shell/shell.css';
|
||||||
import HoryungMascot from './components/HoryungMascot';
|
import useViewportMode from './_shell/useViewportMode';
|
||||||
|
import BottomNav from './_shell/BottomNav';
|
||||||
|
import DesktopHeader from './_shell/DesktopHeader';
|
||||||
|
import MatchMobile from './views/match.mobile.jsx';
|
||||||
|
import MatchDesktop from './views/match.desktop.jsx';
|
||||||
|
import { compatInterpret } from '../../api';
|
||||||
|
|
||||||
|
const EMPTY_PERSON = {
|
||||||
|
name: '', year: '', month: '', day: '', hour: null,
|
||||||
|
gender: 'male', calendar_type: 'solar',
|
||||||
|
};
|
||||||
|
|
||||||
export default function Compatibility() {
|
export default function Compatibility() {
|
||||||
|
const mode = useViewportMode();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [personA, setPersonA] = useState({ ...EMPTY_PERSON });
|
||||||
|
const [personB, setPersonB] = useState({ ...EMPTY_PERSON });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
if (e && e.preventDefault) e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!personA.year || !personA.month || !personA.day ||
|
||||||
|
!personB.year || !personB.month || !personB.day) {
|
||||||
|
setError('두 사람의 생년월일을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await compatInterpret({ person_a: personA, person_b: personB });
|
||||||
|
navigate(`/saju/compatibility/result?cid=${res.reading_id}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message || '궁합 풀이에 실패했어요.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = {
|
||||||
|
personA, personB, onChangeA: setPersonA, onChangeB: setPersonB,
|
||||||
|
onSubmit: handleSubmit, loading, error,
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className="saju-page">
|
<div className="saju-v2">
|
||||||
<SajuNav />
|
{mode === 'desktop' && <DesktopHeader />}
|
||||||
<div className="saju-stub">
|
{mode === 'desktop' ? <MatchDesktop {...props} /> : <MatchMobile {...props} />}
|
||||||
<HoryungMascot pose="thinking" />
|
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||||
<h2 className="saju-h2">궁합보기는 곧 만나요</h2>
|
|
||||||
<p>두 사람의 사주를 함께 풀어보는 기능을 준비 중입니다.<br />조금만 기다려 주세요.</p>
|
|
||||||
<Link to="/saju">메인으로 돌아가기</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,108 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
|
import './_shell/tokens.css';
|
||||||
|
import './_shell/shell.css';
|
||||||
|
import useViewportMode from './_shell/useViewportMode';
|
||||||
|
import BottomNav from './_shell/BottomNav';
|
||||||
|
import DesktopHeader from './_shell/DesktopHeader';
|
||||||
|
import TopRibbon from './_shell/TopRibbon';
|
||||||
|
import TitleBlock from './_shell/TitleBlock';
|
||||||
|
import Mascot from './_shell/Mascot';
|
||||||
|
import MascotBubble from './_shell/MascotBubble';
|
||||||
|
import OrnateFrame from './_shell/OrnateFrame';
|
||||||
|
import PrimaryButton from './_shell/PrimaryButton';
|
||||||
|
import GhostButton from './_shell/GhostButton';
|
||||||
|
import { compatGetReading } from '../../api';
|
||||||
|
|
||||||
export default function CompatibilityResult() {
|
export default function CompatibilityResult() {
|
||||||
|
const mode = useViewportMode();
|
||||||
|
const [params] = useSearchParams();
|
||||||
|
const cid = params.get('cid');
|
||||||
|
const [result, setResult] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!cid) return;
|
||||||
|
compatGetReading(parseInt(cid, 10))
|
||||||
|
.then(setResult)
|
||||||
|
.catch((e) => setError(e?.message || '결과를 가져오지 못했어요.'));
|
||||||
|
}, [cid]);
|
||||||
|
|
||||||
|
const interp = result?.interpretation_json || {};
|
||||||
|
const strengths = interp.strengths || [];
|
||||||
|
const challenges = interp.challenges || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '2rem', color: '#fff' }}>
|
<div className="saju-v2">
|
||||||
<h1>궁합 분석 결과</h1>
|
{mode === 'desktop' && <DesktopHeader />}
|
||||||
<p>UI 시안 적용 대기 중...</p>
|
<main className="page paper-bg screen-in">
|
||||||
|
<TopRibbon color="#4E6B5C" opacity={0.6} />
|
||||||
|
<div style={{ maxWidth: mode === 'desktop' ? 720 : 'none', margin: '0 auto', padding: '24px 20px 40px' }}>
|
||||||
|
{!cid && (
|
||||||
|
<>
|
||||||
|
<TitleBlock title="궁합 결과" gold="#4E6B5C" />
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 24 }}>
|
||||||
|
<MascotBubble tone="green" tail={false} text="궁합을 먼저 보세요." style={{ margin: '0 auto 20px' }} />
|
||||||
|
<Link to="/saju/compatibility">
|
||||||
|
<PrimaryButton color="#4E6B5C" full={false}>궁합 입력하러 가기</PrimaryButton>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{cid && !result && !error && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||||||
|
<MascotBubble tone="green" tail={false}
|
||||||
|
text="호령이 두 사주를 비교 중이에요..."
|
||||||
|
style={{ margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cid && error && (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||||||
|
<MascotBubble tone="green" tail={false}
|
||||||
|
text="궁합 결과를 가져오지 못했어요."
|
||||||
|
style={{ margin: '0 auto 20px' }} />
|
||||||
|
<GhostButton color="#4E6B5C" full={false} onClick={() => window.location.reload()}>다시 시도</GhostButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result && (
|
||||||
|
<>
|
||||||
|
<TitleBlock title="궁합 결과" gold="#4E6B5C"
|
||||||
|
subtitle={`${result.person_a?.name || '사람 A'} × ${result.person_b?.name || '사람 B'}`} />
|
||||||
|
<div style={{ marginTop: 20, textAlign: 'center' }}>
|
||||||
|
<div className="font-title" style={{ fontSize: 48, color: '#4E6B5C' }}>
|
||||||
|
{result.score}<span style={{ fontSize: 18, color: '#9A968D', fontWeight: 500 }}>점</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{interp.summary && (
|
||||||
|
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 20 }}>
|
||||||
|
<div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', textAlign: 'center', marginBottom: 6 }}>요약</div>
|
||||||
|
<div style={{ fontSize: 13, color: '#1F2A44', lineHeight: 1.7, whiteSpace: 'pre-line' }}>
|
||||||
|
{interp.summary}
|
||||||
|
</div>
|
||||||
|
</OrnateFrame>
|
||||||
|
)}
|
||||||
|
{strengths.length > 0 && (
|
||||||
|
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}>
|
||||||
|
<div className="font-title" style={{ fontSize: 13, color: '#4E6B5C', marginBottom: 8 }}>강점</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}>
|
||||||
|
{strengths.map((s, i) => (<li key={i}>{s}</li>))}
|
||||||
|
</ul>
|
||||||
|
</OrnateFrame>
|
||||||
|
)}
|
||||||
|
{challenges.length > 0 && (
|
||||||
|
<OrnateFrame color="#C04A4A" bg="#FBF7EF" radius={14} padding="16px 18px" style={{ marginTop: 14 }}>
|
||||||
|
<div className="font-title" style={{ fontSize: 13, color: '#C04A4A', marginBottom: 8 }}>주의할 점</div>
|
||||||
|
<ul style={{ margin: 0, paddingLeft: 18, color: '#1F2A44', fontSize: 13, lineHeight: 1.7 }}>
|
||||||
|
{challenges.map((s, i) => (<li key={i}>{s}</li>))}
|
||||||
|
</ul>
|
||||||
|
</OrnateFrame>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/pages/saju/Me.jsx
Normal file
51
src/pages/saju/Me.jsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import './_shell/tokens.css';
|
||||||
|
import './_shell/shell.css';
|
||||||
|
import useViewportMode from './_shell/useViewportMode';
|
||||||
|
import BottomNav from './_shell/BottomNav';
|
||||||
|
import DesktopHeader from './_shell/DesktopHeader';
|
||||||
|
import TopRibbon from './_shell/TopRibbon';
|
||||||
|
import Mascot from './_shell/Mascot';
|
||||||
|
import MascotBubble from './_shell/MascotBubble';
|
||||||
|
import OrnateFrame from './_shell/OrnateFrame';
|
||||||
|
|
||||||
|
const DISABLED_CARDS = [
|
||||||
|
{ title: '내 사주 이력', desc: '저장된 풀이를 한 번에' },
|
||||||
|
{ title: '북마크', desc: '관심 가는 해석 즐겨찾기' },
|
||||||
|
{ title: '설정', desc: '알림·테마·계정' },
|
||||||
|
{ title: '문의', desc: '호령이 듣고 있어요' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Me() {
|
||||||
|
const mode = useViewportMode();
|
||||||
|
return (
|
||||||
|
<div className="saju-v2">
|
||||||
|
{mode === 'desktop' && <DesktopHeader />}
|
||||||
|
<main className="page paper-bg screen-in">
|
||||||
|
<TopRibbon />
|
||||||
|
<div style={{
|
||||||
|
maxWidth: mode === 'desktop' ? 720 : 'none',
|
||||||
|
margin: '0 auto', padding: '24px 20px 40px',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 12px' }} />
|
||||||
|
<MascotBubble tone="purple" tail={false}
|
||||||
|
text={'마이페이지는 곧 만나요.\n조금만 기다려주세요.'}
|
||||||
|
style={{ margin: '0 auto 24px' }} />
|
||||||
|
<div style={{ display: 'grid', gap: 12 }}>
|
||||||
|
{DISABLED_CARDS.map((card) => (
|
||||||
|
<OrnateFrame key={card.title} color="#6A4C7C" bg="#FBF7EF" padding="18px 16px"
|
||||||
|
style={{ opacity: 0.55, textAlign: 'left' }}>
|
||||||
|
<div className="font-title" style={{ fontSize: 16, color: '#1F2A44' }}>
|
||||||
|
{card.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 12, color: '#6B6B6B' }}>{card.desc}</div>
|
||||||
|
</OrnateFrame>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
/* saju-page scope — 다른 페이지에 영향 없음 */
|
|
||||||
.saju-page {
|
|
||||||
/* 베이스 */
|
|
||||||
--saju-cream: #FAF6EE;
|
|
||||||
--saju-paper: #F2EAD8;
|
|
||||||
--saju-ink: #2E2D45;
|
|
||||||
--saju-ink-deep: #1F1D38;
|
|
||||||
|
|
||||||
/* 액센트 */
|
|
||||||
--saju-gold: #D4A574;
|
|
||||||
--saju-gold-deep: #B5874E;
|
|
||||||
--saju-apricot: #C58F76;
|
|
||||||
--saju-rose: #D9A2A6;
|
|
||||||
--saju-jade: #4B7065;
|
|
||||||
--saju-violet: #6A5285;
|
|
||||||
|
|
||||||
/* 카테고리 (3 ActionCard) */
|
|
||||||
--saju-today-bg: #4B7065;
|
|
||||||
--saju-gunghab-bg: #A8736E;
|
|
||||||
--saju-saju-bg: #4F4A78;
|
|
||||||
|
|
||||||
/* 점수 카테고리 (4 ScoreCard) */
|
|
||||||
--saju-wealth: #D4A574;
|
|
||||||
--saju-romance: #D9A2A6;
|
|
||||||
--saju-social: #4B7065;
|
|
||||||
--saju-career: #6A5285;
|
|
||||||
|
|
||||||
min-height: 100vh;
|
|
||||||
background: var(--saju-cream);
|
|
||||||
color: var(--saju-ink);
|
|
||||||
font-family: 'Pretendard', sans-serif;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saju-page * { box-sizing: border-box; }
|
|
||||||
|
|
||||||
.saju-page .saju-h1,
|
|
||||||
.saju-page .saju-h2,
|
|
||||||
.saju-page .saju-h3 {
|
|
||||||
font-family: 'Noto Serif KR', 'Pretendard', serif;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
color: var(--saju-ink);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saju-page .saju-h1 { font-size: clamp(2.5rem, 4vw, 3.5rem); line-height: 1.2; }
|
|
||||||
.saju-page .saju-h2 { font-size: clamp(1.8rem, 3vw, 2.5rem); line-height: 1.3; }
|
|
||||||
.saju-page .saju-h3 { font-size: clamp(1.2rem, 2vw, 1.5rem); }
|
|
||||||
|
|
||||||
/* 호령 마스코트 */
|
|
||||||
.horyung-mascot { display: block; object-fit: contain; }
|
|
||||||
.horyung-mascot--sm { width: 80px; height: auto; }
|
|
||||||
.horyung-mascot--md { width: 180px; height: auto; }
|
|
||||||
.horyung-mascot--lg { width: 320px; height: auto; }
|
|
||||||
|
|
||||||
/* 상단 네비게이션 */
|
|
||||||
.saju-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
background: var(--saju-ink);
|
|
||||||
color: var(--saju-cream);
|
|
||||||
}
|
|
||||||
.saju-nav__logo {
|
|
||||||
font-family: 'Noto Serif KR', serif;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--saju-cream);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
.saju-nav__links {
|
|
||||||
display: flex;
|
|
||||||
gap: 1.5rem;
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.saju-nav__links a {
|
|
||||||
color: var(--saju-cream);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
.saju-nav__links a:hover { opacity: 1; }
|
|
||||||
.saju-nav__cta {
|
|
||||||
background: var(--saju-gold);
|
|
||||||
color: var(--saju-ink);
|
|
||||||
border: none;
|
|
||||||
padding: 0.5rem 1.25rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: 'Pretendard', sans-serif;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero */
|
|
||||||
.saju-hero {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1.4fr;
|
|
||||||
gap: 3rem;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
.saju-hero__left {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
.saju-quote-box {
|
|
||||||
background: var(--saju-paper);
|
|
||||||
padding: 1rem 1.25rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--saju-gold-deep);
|
|
||||||
color: var(--saju-ink);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
max-width: 280px;
|
|
||||||
}
|
|
||||||
.saju-hero__right {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.saju-sub {
|
|
||||||
color: var(--saju-ink);
|
|
||||||
opacity: 0.7;
|
|
||||||
margin: 0;
|
|
||||||
line-height: 1.6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ActionCard */
|
|
||||||
.saju-action-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
|
||||||
.saju-action-card {
|
|
||||||
background: var(--saju-saju-bg);
|
|
||||||
color: var(--saju-cream);
|
|
||||||
padding: 1.5rem 1rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
text-decoration: none;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
font-family: 'Pretendard', sans-serif;
|
|
||||||
}
|
|
||||||
.saju-action-card:hover { transform: translateY(-4px); }
|
|
||||||
.saju-action-card--today { background: var(--saju-today-bg); }
|
|
||||||
.saju-action-card--gunghab { background: var(--saju-gunghab-bg); }
|
|
||||||
.saju-action-card--saju { background: var(--saju-saju-bg); }
|
|
||||||
.saju-action-card[aria-disabled="true"] { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
.saju-action-card__icon { font-size: 2rem; }
|
|
||||||
.saju-action-card__title { font-size: 1.1rem; font-weight: 700; }
|
|
||||||
.saju-action-card__desc { font-size: 0.85rem; opacity: 0.85; text-align: center; }
|
|
||||||
|
|
||||||
/* Bottom */
|
|
||||||
.saju-bottom {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 3rem;
|
|
||||||
padding: 3rem 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
background: var(--saju-ink);
|
|
||||||
color: var(--saju-cream);
|
|
||||||
border-radius: 24px 24px 0 0;
|
|
||||||
}
|
|
||||||
.saju-form { display: flex; flex-direction: column; gap: 1rem; }
|
|
||||||
.saju-form input,
|
|
||||||
.saju-form select {
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
border: 1px solid var(--saju-gold-deep);
|
|
||||||
background: var(--saju-ink-deep);
|
|
||||||
color: var(--saju-cream);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.saju-form button {
|
|
||||||
background: var(--saju-gold);
|
|
||||||
color: var(--saju-ink);
|
|
||||||
border: none;
|
|
||||||
padding: 0.875rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-weight: 700;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.saju-form button:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
||||||
.saju-form__error {
|
|
||||||
background: rgba(217, 162, 166, 0.2);
|
|
||||||
color: var(--saju-rose);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fortune ring */
|
|
||||||
.saju-fortune-ring {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.saju-fortune-ring svg { width: 200px; height: 200px; }
|
|
||||||
.saju-fortune-ring__score {
|
|
||||||
position: absolute;
|
|
||||||
font-family: 'Noto Serif KR', serif;
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--saju-ink);
|
|
||||||
}
|
|
||||||
.saju-fortune-ring__total { font-size: 0.9rem; color: var(--saju-ink); opacity: 0.6; }
|
|
||||||
|
|
||||||
/* ScoreCard */
|
|
||||||
.saju-score-card {
|
|
||||||
background: var(--saju-cream);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.25rem;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
border: 1px solid var(--saju-paper);
|
|
||||||
}
|
|
||||||
.saju-score-card__head { display: flex; align-items: center; gap: 0.5rem; }
|
|
||||||
.saju-score-card__icon { font-size: 1.5rem; }
|
|
||||||
.saju-score-card__title { font-weight: 700; font-size: 0.95rem; }
|
|
||||||
.saju-score-card__value {
|
|
||||||
font-family: 'Noto Serif KR', serif;
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--saju-ink);
|
|
||||||
}
|
|
||||||
.saju-score-card__bar {
|
|
||||||
height: 6px;
|
|
||||||
background: var(--saju-paper);
|
|
||||||
border-radius: 3px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.saju-score-card__bar > div { height: 100%; background: var(--saju-gold); transition: width 0.5s; }
|
|
||||||
|
|
||||||
/* Lucky box */
|
|
||||||
.saju-lucky-box {
|
|
||||||
background: var(--saju-paper);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
.saju-lucky-box__item { text-align: center; }
|
|
||||||
.saju-lucky-box__label { font-size: 0.8rem; color: var(--saju-ink); opacity: 0.7; margin-bottom: 0.25rem; }
|
|
||||||
.saju-lucky-box__value {
|
|
||||||
font-family: 'Noto Serif KR', serif;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--saju-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SajuPillars */
|
|
||||||
.saju-pillars {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(4, 1fr);
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.saju-pillar {
|
|
||||||
background: var(--saju-paper);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
.saju-pillar__label { font-size: 0.8rem; color: var(--saju-ink); opacity: 0.6; margin-bottom: 0.5rem; }
|
|
||||||
.saju-pillar__stem,
|
|
||||||
.saju-pillar__branch {
|
|
||||||
font-family: 'Noto Serif KR', serif;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.saju-pillar__stem-kr,
|
|
||||||
.saju-pillar__branch-kr { font-size: 0.85rem; opacity: 0.7; }
|
|
||||||
.saju-pillar__ten-god,
|
|
||||||
.saju-pillar__fortune { font-size: 0.75rem; margin-top: 0.25rem; opacity: 0.7; }
|
|
||||||
|
|
||||||
/* Element bars */
|
|
||||||
.saju-element-bars {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
background: var(--saju-cream);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
.saju-element-bar {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 60px 1fr 50px;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
}
|
|
||||||
.saju-element-bar__label { font-size: 0.9rem; font-weight: 700; }
|
|
||||||
.saju-element-bar__track {
|
|
||||||
height: 12px;
|
|
||||||
background: var(--saju-paper);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.saju-element-bar__fill {
|
|
||||||
height: 100%;
|
|
||||||
border-radius: 6px;
|
|
||||||
transition: width 0.5s;
|
|
||||||
}
|
|
||||||
.saju-element-bar__fill--木 { background: #4B7065; }
|
|
||||||
.saju-element-bar__fill--火 { background: #C56F5C; }
|
|
||||||
.saju-element-bar__fill--土 { background: #D4A574; }
|
|
||||||
.saju-element-bar__fill--金 { background: #B8B5A8; }
|
|
||||||
.saju-element-bar__fill--水 { background: #4A5878; }
|
|
||||||
.saju-element-bar__value { text-align: right; font-size: 0.85rem; opacity: 0.7; }
|
|
||||||
|
|
||||||
/* Monthly flow */
|
|
||||||
.saju-monthly-flow {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(12, 1fr);
|
|
||||||
gap: 0.25rem;
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--saju-cream);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
.saju-monthly-flow__cell {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.5rem 0.25rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: var(--saju-paper);
|
|
||||||
}
|
|
||||||
.saju-monthly-flow__month { font-size: 0.7rem; opacity: 0.7; }
|
|
||||||
.saju-monthly-flow__score {
|
|
||||||
font-family: 'Noto Serif KR', serif;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
.saju-monthly-flow__label { font-size: 0.7rem; opacity: 0.8; margin-top: 0.25rem; }
|
|
||||||
|
|
||||||
/* Horyung quote */
|
|
||||||
.saju-horyung-quote {
|
|
||||||
background: var(--saju-ink);
|
|
||||||
color: var(--saju-cream);
|
|
||||||
padding: 1.5rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
.saju-horyung-quote__text { font-size: 0.95rem; line-height: 1.6; }
|
|
||||||
|
|
||||||
/* Interpret accordion */
|
|
||||||
.saju-interpret-accordion { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
||||||
.saju-interpret-item {
|
|
||||||
background: var(--saju-cream);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--saju-paper);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.saju-interpret-item__header {
|
|
||||||
padding: 1rem;
|
|
||||||
background: var(--saju-paper);
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
font-weight: 700;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.saju-interpret-item__body { padding: 1rem; font-size: 0.95rem; line-height: 1.6; }
|
|
||||||
.saju-interpret-item__evidence {
|
|
||||||
background: var(--saju-paper);
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
margin-top: 0.75rem;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stub */
|
|
||||||
.saju-stub {
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 5rem auto;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
background: var(--saju-paper);
|
|
||||||
border-radius: 24px;
|
|
||||||
}
|
|
||||||
.saju-stub a {
|
|
||||||
display: inline-block;
|
|
||||||
margin-top: 1.5rem;
|
|
||||||
background: var(--saju-gold);
|
|
||||||
color: var(--saju-ink);
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 */
|
|
||||||
@media (max-width: 1280px) {
|
|
||||||
.saju-hero { grid-template-columns: 1fr; text-align: center; }
|
|
||||||
.saju-hero__left { order: 2; }
|
|
||||||
.saju-hero__right { order: 1; }
|
|
||||||
.saju-bottom { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.saju-nav { padding: 0.75rem 1rem; flex-wrap: wrap; gap: 0.5rem; }
|
|
||||||
.saju-nav__links { display: none; }
|
|
||||||
.saju-action-cards { grid-template-columns: 1fr; }
|
|
||||||
.saju-pillars { grid-template-columns: repeat(2, 1fr); }
|
|
||||||
.saju-monthly-flow { grid-template-columns: repeat(4, 1fr); }
|
|
||||||
.horyung-mascot--lg { width: 220px; }
|
|
||||||
}
|
|
||||||
@@ -1,80 +1,19 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import './Saju.css';
|
import './_shell/tokens.css';
|
||||||
import SajuNav from './components/SajuNav';
|
import './_shell/shell.css';
|
||||||
import HoryungMascot from './components/HoryungMascot';
|
import useViewportMode from './_shell/useViewportMode';
|
||||||
import SajuInputForm from './components/SajuInputForm';
|
import BottomNav from './_shell/BottomNav';
|
||||||
import ActionCard from './components/ActionCard';
|
import DesktopHeader from './_shell/DesktopHeader';
|
||||||
import useSajuForm from './hooks/useSajuForm';
|
import HomeMobile from './views/home.mobile.jsx';
|
||||||
|
import HomeDesktop from './views/home.desktop.jsx';
|
||||||
|
|
||||||
export default function Saju() {
|
export default function Saju() {
|
||||||
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
const mode = useViewportMode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="saju-page saju-page--main">
|
<div className="saju-v2">
|
||||||
<SajuNav />
|
{mode === 'desktop' ? <DesktopHeader /> : null}
|
||||||
|
{mode === 'desktop' ? <HomeDesktop /> : <HomeMobile />}
|
||||||
<section className="saju-hero">
|
{mode === 'mobile' ? <BottomNav theme="navy" /> : null}
|
||||||
<div className="saju-hero__left">
|
|
||||||
<HoryungMascot pose="greeting" size="lg" />
|
|
||||||
<div className="saju-quote-box">
|
|
||||||
<p style={{ margin: 0 }}>
|
|
||||||
전통 사주명리학 + AI 인사이트로<br />
|
|
||||||
당신의 오늘을 풀어드립니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="saju-hero__right">
|
|
||||||
<h1 className="saju-h1">호령이 반갑게<br />맞이하는 오늘의 사주</h1>
|
|
||||||
<p className="saju-sub">
|
|
||||||
오랜 지혜와 정성으로 다듬어진 사주명리학을 호령이 풀어드립니다.<br />
|
|
||||||
당신의 사주 8자에 담긴 운명을 만나보세요.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="saju-action-cards">
|
|
||||||
<ActionCard
|
|
||||||
to="/saju/today"
|
|
||||||
icon="today"
|
|
||||||
title="오늘의 운세"
|
|
||||||
desc="오늘 하루의 흐름을 확인하세요"
|
|
||||||
variant="today"
|
|
||||||
/>
|
|
||||||
<ActionCard
|
|
||||||
to="/saju/compatibility"
|
|
||||||
icon="heart"
|
|
||||||
title="궁합보기"
|
|
||||||
desc="두 사람의 인연을 풀어보세요"
|
|
||||||
variant="gunghab"
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
<ActionCard
|
|
||||||
to="/saju/result"
|
|
||||||
icon="book"
|
|
||||||
title="사주풀이"
|
|
||||||
desc="당신의 사주 8자를 자세히"
|
|
||||||
variant="saju"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="saju-bottom">
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ color: 'var(--saju-cream)' }}>
|
|
||||||
오늘의 운세 미리보기
|
|
||||||
</h2>
|
|
||||||
<p style={{ color: 'var(--saju-cream)', opacity: 0.7, lineHeight: 1.6 }}>
|
|
||||||
사주 8자를 입력하시면 오늘의 종합점수, 4가지 카테고리 분석, 럭키 정보를 한 번에<br />
|
|
||||||
확인하실 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<SajuInputForm
|
|
||||||
form={form}
|
|
||||||
onChange={handleChange}
|
|
||||||
onSubmit={handleSubmit}
|
|
||||||
loading={loading}
|
|
||||||
error={error}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +1,73 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSearchParams, Link } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import './Saju.css';
|
import './_shell/tokens.css';
|
||||||
import SajuNav from './components/SajuNav';
|
import './_shell/shell.css';
|
||||||
import HoryungMascot from './components/HoryungMascot';
|
import useViewportMode from './_shell/useViewportMode';
|
||||||
import SajuPillars from './components/SajuPillars';
|
|
||||||
import ElementBarChart from './components/ElementBarChart';
|
|
||||||
import InterpretAccordion from './components/InterpretAccordion';
|
|
||||||
import HoryungQuote from './components/HoryungQuote';
|
|
||||||
import MonthlyFlow from './components/MonthlyFlow';
|
|
||||||
import useSajuReading from './hooks/useSajuReading';
|
import useSajuReading from './hooks/useSajuReading';
|
||||||
|
import BottomNav from './_shell/BottomNav';
|
||||||
|
import DesktopHeader from './_shell/DesktopHeader';
|
||||||
|
import Mascot from './_shell/Mascot';
|
||||||
|
import MascotBubble from './_shell/MascotBubble';
|
||||||
|
import PrimaryButton from './_shell/PrimaryButton';
|
||||||
|
import GhostButton from './_shell/GhostButton';
|
||||||
|
import SajuMobile from './views/saju.mobile.jsx';
|
||||||
|
import SajuDesktop from './views/saju.desktop.jsx';
|
||||||
|
|
||||||
export default function SajuResult() {
|
export default function SajuResult() {
|
||||||
|
const mode = useViewportMode();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const rid = params.get('rid');
|
const rid = params.get('rid');
|
||||||
const ridNum = rid ? parseInt(rid, 10) : null;
|
const ridNum = rid ? parseInt(rid, 10) : null;
|
||||||
const { data, loading, error } = useSajuReading(ridNum);
|
const { data, loading, error } = useSajuReading(ridNum);
|
||||||
|
|
||||||
if (!rid) {
|
|
||||||
return (
|
return (
|
||||||
<div className="saju-page">
|
<div className="saju-v2">
|
||||||
<SajuNav />
|
{mode === 'desktop' && <DesktopHeader />}
|
||||||
<div className="saju-stub">
|
{!rid && <EmptyState />}
|
||||||
<HoryungMascot pose="thinking" />
|
{rid && loading && <LoadingState />}
|
||||||
<h2 className="saju-h2">사주 정보가 없어요</h2>
|
{rid && error && <ErrorState />}
|
||||||
<p>먼저 메인 페이지에서 사주를 입력해주세요.</p>
|
{rid && data && (mode === 'desktop'
|
||||||
<Link to="/saju">메인으로 가기</Link>
|
? <SajuDesktop reading={data} />
|
||||||
</div>
|
: <SajuMobile reading={data} />
|
||||||
|
)}
|
||||||
|
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="saju-page">
|
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||||||
<SajuNav />
|
<Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} />
|
||||||
<div className="saju-stub">
|
<MascotBubble tone="ivory" tail={false}
|
||||||
<HoryungMascot pose="thinking" />
|
text="사주를 먼저 입력해주세요."
|
||||||
<p>호령이 사주를 풀어보는 중...</p>
|
style={{ margin: '0 auto 24px' }} />
|
||||||
</div>
|
<Link to="/saju" style={{ display: 'inline-block' }}>
|
||||||
</div>
|
<PrimaryButton color="#6A4C7C" full={false}>사주 입력하러 가기</PrimaryButton>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<div className="saju-page">
|
|
||||||
<SajuNav />
|
|
||||||
<div className="saju-stub">
|
|
||||||
<h2 className="saju-h2">사주 결과를 찾을 수 없어요</h2>
|
|
||||||
<p>{error || '다시 입력해주세요.'}</p>
|
|
||||||
<Link to="/saju">메인으로 가기</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const saju = data.saju_data;
|
|
||||||
const analysis = data.analysis_data;
|
|
||||||
const interp = data.interpretation_json;
|
|
||||||
const monthlyFlow = data.monthly_flow;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="saju-page">
|
|
||||||
<SajuNav />
|
|
||||||
|
|
||||||
<section className="saju-hero">
|
|
||||||
<div className="saju-hero__left">
|
|
||||||
<HoryungMascot pose="thinking" size="lg" />
|
|
||||||
</div>
|
|
||||||
<div className="saju-hero__right">
|
|
||||||
<h1 className="saju-h1">사주풀이</h1>
|
|
||||||
<p className="saju-sub">
|
|
||||||
{data.birth_year}년 {data.birth_month}월 {data.birth_day}일
|
|
||||||
{data.birth_hour !== null ? ` ${data.birth_hour}시` : ' (시간 미상)'} ·{' '}
|
|
||||||
{data.gender === 'male' ? '남' : '여'} ·{' '}
|
|
||||||
{data.calendar_type === 'lunar' ? '음력' : '양력'}
|
|
||||||
</p>
|
|
||||||
{interp?.summary && (
|
|
||||||
<HoryungQuote pose="thinking" text={interp.summary} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>사주 4기둥</h2>
|
|
||||||
<SajuPillars saju={saju} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오행 분석</h2>
|
|
||||||
<ElementBarChart scores={analysis?.element_scores} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{analysis?.day_master_strength && (
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>일간 강도</h2>
|
|
||||||
<div className="saju-quote-box" style={{ maxWidth: 'none' }}>
|
|
||||||
<p style={{ margin: 0 }}>
|
|
||||||
<strong>{analysis.day_master_strength.result}</strong> · 점수 {analysis.day_master_strength.score}<br />
|
|
||||||
{(analysis.day_master_strength.reasons || []).join(' · ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{monthlyFlow && (
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>12개월 운세 흐름</h2>
|
|
||||||
<MonthlyFlow flow={monthlyFlow} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{interp?.items && (
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>AI 12항목 해석</h2>
|
|
||||||
<InterpretAccordion items={interp.items} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{interp?.advice && (
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>호령의 조언</h2>
|
|
||||||
<HoryungQuote pose="happy" text={interp.advice} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
|
||||||
<Link to={`/saju/today?rid=${rid}`} className="saju-action-card saju-action-card--today" style={{ maxWidth: 240 }}>
|
|
||||||
<span className="saju-action-card__icon">☀</span>
|
|
||||||
<span className="saju-action-card__title">오늘의 운세</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/saju" className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
|
</main>
|
||||||
<span className="saju-action-card__icon">📖</span>
|
);
|
||||||
<span className="saju-action-card__title">새 사주 보기</span>
|
}
|
||||||
</Link>
|
|
||||||
</section>
|
function LoadingState() {
|
||||||
</div>
|
return (
|
||||||
|
<main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}>
|
||||||
|
<Mascot variant="thinking" size={160} style={{ margin: '0 auto 16px' }} />
|
||||||
|
<MascotBubble tone="purple" tail={false}
|
||||||
|
text={'호령이 풀이 중이에요...\n(최대 1분 정도 걸려요)'}
|
||||||
|
style={{ margin: '0 auto' }} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState() {
|
||||||
|
return (
|
||||||
|
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||||||
|
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||||||
|
<MascotBubble tone="purple" tail={false}
|
||||||
|
text="아이고, 풀이를 가져오지 못했어요. 다시 시도해주세요."
|
||||||
|
style={{ margin: '0 auto 20px' }} />
|
||||||
|
<GhostButton color="#6A4C7C" full={false} onClick={() => window.location.reload()}>다시 시도</GhostButton>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,138 +1,73 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSearchParams, Link } from 'react-router-dom';
|
import { useSearchParams, Link } from 'react-router-dom';
|
||||||
import './Saju.css';
|
import './_shell/tokens.css';
|
||||||
import SajuNav from './components/SajuNav';
|
import './_shell/shell.css';
|
||||||
import HoryungMascot from './components/HoryungMascot';
|
import useViewportMode from './_shell/useViewportMode';
|
||||||
import FortuneRing from './components/FortuneRing';
|
|
||||||
import ScoreCard from './components/ScoreCard';
|
|
||||||
import LuckyBox from './components/LuckyBox';
|
|
||||||
import HoryungQuote from './components/HoryungQuote';
|
|
||||||
import useSajuReading from './hooks/useSajuReading';
|
import useSajuReading from './hooks/useSajuReading';
|
||||||
|
import BottomNav from './_shell/BottomNav';
|
||||||
|
import DesktopHeader from './_shell/DesktopHeader';
|
||||||
|
import Mascot from './_shell/Mascot';
|
||||||
|
import MascotBubble from './_shell/MascotBubble';
|
||||||
|
import PrimaryButton from './_shell/PrimaryButton';
|
||||||
|
import GhostButton from './_shell/GhostButton';
|
||||||
|
import TodayMobile from './views/today.mobile.jsx';
|
||||||
|
import TodayDesktop from './views/today.desktop.jsx';
|
||||||
|
|
||||||
export default function Today() {
|
export default function Today() {
|
||||||
|
const mode = useViewportMode();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const rid = params.get('rid');
|
const rid = params.get('rid');
|
||||||
const ridNum = rid ? parseInt(rid, 10) : null;
|
const ridNum = rid ? parseInt(rid, 10) : null;
|
||||||
const { data, loading, error } = useSajuReading(ridNum);
|
const { data, loading, error } = useSajuReading(ridNum);
|
||||||
|
|
||||||
if (!rid) {
|
|
||||||
return (
|
return (
|
||||||
<div className="saju-page">
|
<div className="saju-v2">
|
||||||
<SajuNav />
|
{mode === 'desktop' && <DesktopHeader />}
|
||||||
<div className="saju-stub">
|
{!rid && <EmptyState />}
|
||||||
<HoryungMascot pose="thinking" />
|
{rid && loading && <LoadingState />}
|
||||||
<h2 className="saju-h2">사주가 필요해요</h2>
|
{rid && error && <ErrorState />}
|
||||||
<p>오늘의 운세를 보려면 먼저 사주를 입력해주세요.</p>
|
{rid && data && (mode === 'desktop'
|
||||||
<Link to="/saju">사주 입력하러 가기</Link>
|
? <TodayDesktop reading={data} />
|
||||||
</div>
|
: <TodayMobile reading={data} />
|
||||||
|
)}
|
||||||
|
{mode === 'mobile' && <BottomNav theme="ivory" />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
function EmptyState() {
|
||||||
return (
|
return (
|
||||||
<div className="saju-page">
|
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||||||
<SajuNav />
|
<Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} />
|
||||||
<div className="saju-stub">
|
<MascotBubble tone="ivory" tail={false}
|
||||||
<HoryungMascot pose="thinking" />
|
text="사주를 먼저 입력해주세요."
|
||||||
<p>오늘의 운세를 풀어보는 중...</p>
|
style={{ margin: '0 auto 24px' }} />
|
||||||
</div>
|
<Link to="/saju" style={{ display: 'inline-block' }}>
|
||||||
</div>
|
<PrimaryButton color="#D4AF37" full={false}>사주 입력하러 가기</PrimaryButton>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
return (
|
|
||||||
<div className="saju-page">
|
|
||||||
<SajuNav />
|
|
||||||
<div className="saju-stub">
|
|
||||||
<h2 className="saju-h2">결과를 찾을 수 없어요</h2>
|
|
||||||
<p>{error || '다시 입력해주세요.'}</p>
|
|
||||||
<Link to="/saju">메인으로 가기</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const scores = data.fortune_scores;
|
|
||||||
const lucky = data.lucky;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="saju-page">
|
|
||||||
<SajuNav />
|
|
||||||
|
|
||||||
<section className="saju-hero">
|
|
||||||
<div className="saju-hero__left">
|
|
||||||
<HoryungMascot pose="pointing" size="lg" />
|
|
||||||
</div>
|
|
||||||
<div className="saju-hero__right">
|
|
||||||
<h1 className="saju-h1">오늘의 운세</h1>
|
|
||||||
<p className="saju-sub">
|
|
||||||
오늘 하루 어떤 흐름이 호령을 따라올지 확인해보세요.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ padding: '0 2rem', maxWidth: 1400, margin: '0 auto', display: 'grid', gap: '2rem' }}>
|
|
||||||
{scores && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: '2rem', alignItems: 'center' }}>
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 종합점</h2>
|
|
||||||
<FortuneRing score={scores.overall} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1rem' }}>
|
|
||||||
<ScoreCard category="wealth" score={scores.wealth} />
|
|
||||||
<ScoreCard category="romance" score={scores.romance} />
|
|
||||||
<ScoreCard category="social" score={scores.social} />
|
|
||||||
<ScoreCard category="career" score={scores.career} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{lucky && (
|
|
||||||
<div>
|
|
||||||
<h2 className="saju-h2" style={{ marginBottom: '1rem' }}>오늘의 럭키</h2>
|
|
||||||
<LuckyBox lucky={lucky} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(lucky?.good_signs?.length || lucky?.warnings?.length) ? (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
||||||
{lucky.good_signs?.length > 0 && (
|
|
||||||
<div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(75, 112, 101, 0.15)' }}>
|
|
||||||
<strong style={{ color: 'var(--saju-jade)' }}>✦ 행운 알림</strong>
|
|
||||||
<ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
|
|
||||||
{lucky.good_signs.map((s, i) => <li key={i}>{s}</li>)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{lucky.warnings?.length > 0 && (
|
|
||||||
<div className="saju-quote-box" style={{ maxWidth: 'none', background: 'rgba(197, 143, 118, 0.15)' }}>
|
|
||||||
<strong style={{ color: 'var(--saju-apricot)' }}>⚠ 주의사항</strong>
|
|
||||||
<ul style={{ marginTop: '0.5rem', paddingLeft: '1.2rem' }}>
|
|
||||||
{lucky.warnings.map((s, i) => <li key={i}>{s}</li>)}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<HoryungQuote
|
|
||||||
pose="happy"
|
|
||||||
text="오늘 하루도 호령과 함께 평안하시길 바라요. 작은 신호에도 귀 기울이세요."
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section style={{ padding: '3rem 2rem', display: 'flex', gap: '1rem', justifyContent: 'center' }}>
|
|
||||||
<Link to={`/saju/result?rid=${rid}`} className="saju-action-card saju-action-card--saju" style={{ maxWidth: 240 }}>
|
|
||||||
<span className="saju-action-card__icon">📖</span>
|
|
||||||
<span className="saju-action-card__title">사주풀이 보기</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
<Link to="/saju" className="saju-action-card saju-action-card--gunghab" style={{ maxWidth: 240 }} aria-disabled="true">
|
</main>
|
||||||
<span className="saju-action-card__icon">♥</span>
|
);
|
||||||
<span className="saju-action-card__title">궁합 (준비 중)</span>
|
}
|
||||||
</Link>
|
|
||||||
</section>
|
function LoadingState() {
|
||||||
</div>
|
return (
|
||||||
|
<main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}>
|
||||||
|
<Mascot variant="thinking" size={160} style={{ margin: '0 auto 16px' }} />
|
||||||
|
<MascotBubble tone="ivory" tail={false}
|
||||||
|
text="호령이 오늘 운세를 살펴보고 있어요..."
|
||||||
|
style={{ margin: '0 auto' }} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorState() {
|
||||||
|
return (
|
||||||
|
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
|
||||||
|
<Mascot variant="thinking" size={140} style={{ margin: '0 auto 16px' }} />
|
||||||
|
<MascotBubble tone="ivory" tail={false}
|
||||||
|
text="오늘 운세를 가져오지 못했어요."
|
||||||
|
style={{ margin: '0 auto 20px' }} />
|
||||||
|
<GhostButton color="#D4AF37" full={false} onClick={() => window.location.reload()}>다시 시도</GhostButton>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
77
src/pages/saju/_shell/BottomNav.jsx
Normal file
77
src/pages/saju/_shell/BottomNav.jsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { IconHome, IconSun, IconHeart, IconYinYang, IconUser } from './Icons';
|
||||||
|
import hexA from './helpers/hexA';
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ id: 'home', to: '/saju', label: '홈', Icon: IconHome, accent: '#1F2A44' },
|
||||||
|
{ id: 'today', to: '/saju/today', label: '오늘의 운세', Icon: IconSun, accent: '#D4AF37' },
|
||||||
|
{ id: 'match', to: '/saju/compatibility', label: '궁합보기', Icon: IconHeart, accent: '#4E6B5C' },
|
||||||
|
{ id: 'saju', to: '/saju/result', label: '사주풀이', Icon: IconYinYang, accent: '#6A4C7C' },
|
||||||
|
{ id: 'me', to: '/saju/me', label: '마이페이지', Icon: IconUser, accent: '#6B6B6B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function pathToCurrent(pathname) {
|
||||||
|
if (pathname === '/saju' || pathname === '/saju/') return 'home';
|
||||||
|
if (pathname.startsWith('/saju/today')) return 'today';
|
||||||
|
if (pathname.startsWith('/saju/compatibility')) return 'match';
|
||||||
|
if (pathname.startsWith('/saju/result')) return 'saju';
|
||||||
|
if (pathname.startsWith('/saju/me')) return 'me';
|
||||||
|
return 'home';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BottomNav({ theme = 'ivory' }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const current = pathToCurrent(pathname);
|
||||||
|
const isDark = theme === 'navy';
|
||||||
|
const bg = isDark ? 'rgba(20,27,48,0.92)' : '#FBF7EF';
|
||||||
|
const border = isDark ? 'rgba(212,175,55,0.18)' : 'rgba(31,42,68,0.08)';
|
||||||
|
const inactive = isDark ? 'rgba(247,242,232,0.55)' : '#9A968D';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="사주 메뉴" style={{
|
||||||
|
position: 'fixed', left: 0, right: 0, bottom: 0,
|
||||||
|
paddingBottom: 'max(16px, env(safe-area-inset-bottom))', paddingTop: 8,
|
||||||
|
background: bg, borderTop: `1px solid ${border}`,
|
||||||
|
backdropFilter: 'blur(14px) saturate(140%)',
|
||||||
|
WebkitBackdropFilter: 'blur(14px) saturate(140%)',
|
||||||
|
zIndex: 30,
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-around', alignItems: 'flex-end', padding: '0 6px' }}>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = item.id === current;
|
||||||
|
const activeColor = isDark
|
||||||
|
? (item.id === 'today' ? '#E8C76B' : item.accent === '#1F2A44' ? '#F7F2E8' : item.accent)
|
||||||
|
: item.accent;
|
||||||
|
const color = active ? activeColor : inactive;
|
||||||
|
return (
|
||||||
|
<button key={item.id} onClick={() => navigate(item.to)} aria-label={item.label}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
style={{
|
||||||
|
background: 'transparent', border: 'none', padding: '6px 4px',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4,
|
||||||
|
color, flex: 1, minWidth: 0, position: 'relative',
|
||||||
|
transition: 'color .2s',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
width: 36, height: 28, borderRadius: 999,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: active ? hexA(item.accent, isDark ? 0.18 : 0.10) : 'transparent',
|
||||||
|
transition: 'background .2s',
|
||||||
|
}}>
|
||||||
|
<item.Icon size={20} stroke={color} strokeWidth={active ? 1.8 : 1.5} />
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9.5, fontWeight: active ? 700 : 500, letterSpacing: '-0.04em',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NAV_ITEMS };
|
||||||
57
src/pages/saju/_shell/DesktopHeader.jsx
Normal file
57
src/pages/saju/_shell/DesktopHeader.jsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { NAV_ITEMS } from './BottomNav';
|
||||||
|
|
||||||
|
function pathToCurrent(pathname) {
|
||||||
|
if (pathname === '/saju' || pathname === '/saju/') return 'home';
|
||||||
|
if (pathname.startsWith('/saju/today')) return 'today';
|
||||||
|
if (pathname.startsWith('/saju/compatibility')) return 'match';
|
||||||
|
if (pathname.startsWith('/saju/result')) return 'saju';
|
||||||
|
if (pathname.startsWith('/saju/me')) return 'me';
|
||||||
|
return 'home';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DesktopHeader() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const current = pathToCurrent(pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
position: 'sticky', top: 0, zIndex: 30, height: 64,
|
||||||
|
background: '#FBF7EF', borderBottom: '1px solid rgba(31,42,68,0.10)',
|
||||||
|
display: 'flex', alignItems: 'center', padding: '0 32px',
|
||||||
|
backdropFilter: 'blur(14px)',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => navigate('/saju')} style={{
|
||||||
|
background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<span className="font-title" style={{
|
||||||
|
fontSize: 26, color: '#D4AF37', lineHeight: 1,
|
||||||
|
}}>壽</span>
|
||||||
|
<span className="font-title" style={{
|
||||||
|
fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em',
|
||||||
|
}}>호령사주</span>
|
||||||
|
</button>
|
||||||
|
<nav aria-label="사주 메뉴" style={{ marginLeft: 40, display: 'flex', gap: 8 }}>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = item.id === current;
|
||||||
|
return (
|
||||||
|
<button key={item.id} onClick={() => navigate(item.to)}
|
||||||
|
aria-current={active ? 'page' : undefined}
|
||||||
|
style={{
|
||||||
|
background: active ? 'rgba(31,42,68,0.06)' : 'transparent', border: 'none',
|
||||||
|
padding: '8px 14px', borderRadius: 8,
|
||||||
|
color: active ? item.accent : '#6B6B6B',
|
||||||
|
fontSize: 13, fontWeight: active ? 700 : 500, letterSpacing: '-0.02em',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}>
|
||||||
|
<item.Icon size={16} stroke={active ? item.accent : '#9A968D'} strokeWidth={active ? 1.8 : 1.5} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/pages/saju/_shell/GhostButton.jsx
Normal file
18
src/pages/saju/_shell/GhostButton.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import hexA from './helpers/hexA';
|
||||||
|
|
||||||
|
export default function GhostButton({
|
||||||
|
children, color = '#1F2A44', onClick, full = true, style = {}, type = 'button',
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type={type} onClick={onClick} style={{
|
||||||
|
width: full ? '100%' : 'auto', padding: '13px 22px',
|
||||||
|
background: 'transparent', color, border: `1px solid ${hexA(color, 0.4)}`,
|
||||||
|
borderRadius: 12, fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
src/pages/saju/_shell/Icons.jsx
Normal file
82
src/pages/saju/_shell/Icons.jsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const base = (size, stroke, strokeWidth = 1.5) => ({
|
||||||
|
width: size, height: size, fill: 'none', stroke,
|
||||||
|
strokeWidth, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function IconHome({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||||
|
<path d="M3 11l9-7 9 7v9a2 2 0 0 1-2 2h-3v-6h-8v6H5a2 2 0 0 1-2-2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSun({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconHeart({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||||
|
<path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 7a5.5 5.5 0 0 1 9.5 5c-2.5 4.5-9.5 9-9.5 9z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconYinYang({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||||
|
<circle cx="12" cy="12" r="9" />
|
||||||
|
<path d="M12 3a4.5 4.5 0 0 0 0 9 4.5 4.5 0 0 1 0 9" />
|
||||||
|
<circle cx="12" cy="7.5" r="1" fill={stroke} />
|
||||||
|
<circle cx="12" cy="16.5" r="1" fill={stroke} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconUser({ size = 20, stroke = 'currentColor', strokeWidth }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
|
||||||
|
<circle cx="12" cy="8" r="4" />
|
||||||
|
<path d="M4 21c1-4 5-6 8-6s7 2 8 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconPaw({ size = 12, color = 'currentColor' }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} fill={color}>
|
||||||
|
<ellipse cx="6" cy="10" rx="2" ry="2.5" />
|
||||||
|
<ellipse cx="10" cy="6" rx="2" ry="2.5" />
|
||||||
|
<ellipse cx="14" cy="6" rx="2" ry="2.5" />
|
||||||
|
<ellipse cx="18" cy="10" rx="2" ry="2.5" />
|
||||||
|
<path d="M8 14c0-2 2-3 4-3s4 1 4 3-2 5-4 5-4-3-4-5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconChevron({ size = 14, color = 'currentColor', dir = 'right' }) {
|
||||||
|
const rotate = { right: 0, down: 90, left: 180, up: 270 }[dir] || 0;
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size}
|
||||||
|
fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
style={{ transform: `rotate(${rotate}deg)` }}>
|
||||||
|
<path d="M9 6l6 6-6 6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconSparkle({ size = 12, color = 'currentColor' }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} fill={color}>
|
||||||
|
<path d="M12 2l2 7 7 2-7 2-2 7-2-7-7-2 7-2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/pages/saju/_shell/InputRow.jsx
Normal file
32
src/pages/saju/_shell/InputRow.jsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function InputRow({
|
||||||
|
label, name, type = 'text', value, onChange, placeholder, error, children,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', padding: '12px 14px',
|
||||||
|
borderBottom: '1px solid rgba(31,42,68,0.06)', gap: 12,
|
||||||
|
}}>
|
||||||
|
<label htmlFor={name} style={{
|
||||||
|
width: 80, fontSize: 12, color: '#6B6B6B', fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
}}>{label}</label>
|
||||||
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{children || (
|
||||||
|
<input
|
||||||
|
id={name} name={name} type={type} value={value} onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||||||
|
borderRadius: 8, background: '#FBF7EF',
|
||||||
|
fontSize: 13, color: '#1F2A44', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<span style={{ fontSize: 11, color: '#C04A4A', fontWeight: 700 }}>{error}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/pages/saju/_shell/Mascot.jsx
Normal file
18
src/pages/saju/_shell/Mascot.jsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const VARIANT_TO_SRC = {
|
||||||
|
full: '/images/saju/horyung/horyung-main.png',
|
||||||
|
head: '/images/saju/horyung/horyung-bust.png',
|
||||||
|
upper: '/images/saju/horyung/horyung-front.png',
|
||||||
|
greeting: '/images/saju/horyung/horyung-greeting.png',
|
||||||
|
thinking: '/images/saju/horyung/horyung-thinking.png',
|
||||||
|
pointing: '/images/saju/horyung/horyung-pointing.png',
|
||||||
|
happy: '/images/saju/horyung/horyung-happy.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Mascot({ variant = 'full', size = 200, style = {}, alt = '호령' }) {
|
||||||
|
const src = VARIANT_TO_SRC[variant] || VARIANT_TO_SRC.full;
|
||||||
|
return (
|
||||||
|
<img src={src} alt={alt} width={size} loading="lazy" style={{ display: 'block', ...style }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/pages/saju/_shell/Mascot.test.jsx
Normal file
16
src/pages/saju/_shell/Mascot.test.jsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import Mascot from './Mascot';
|
||||||
|
|
||||||
|
describe('Mascot', () => {
|
||||||
|
const VARIANTS = ['full', 'head', 'upper', 'greeting', 'thinking', 'pointing', 'happy'];
|
||||||
|
VARIANTS.forEach((v) => {
|
||||||
|
it(`renders variant=${v} with correct src`, () => {
|
||||||
|
const { container } = render(<Mascot variant={v} size={100} />);
|
||||||
|
const img = container.querySelector('img');
|
||||||
|
expect(img).toBeTruthy();
|
||||||
|
expect(img.getAttribute('src')).toContain('/images/saju/horyung/');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
40
src/pages/saju/_shell/MascotBubble.jsx
Normal file
40
src/pages/saju/_shell/MascotBubble.jsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { IconPaw } from './Icons';
|
||||||
|
|
||||||
|
const PALETTES = {
|
||||||
|
ivory: { bg: '#FBF7EF', border: 'rgba(31,42,68,0.12)', text: '#1F2A44', paw: '#B89530' },
|
||||||
|
navy: { bg: 'rgba(255,255,255,0.06)', border: 'rgba(212,175,55,0.35)', text: '#F7F2E8', paw: '#D4AF37' },
|
||||||
|
green: { bg: '#FBF7EF', border: 'rgba(78,107,92,0.30)', text: '#1F2A44', paw: '#B89530' },
|
||||||
|
purple: { bg: '#FBF7EF', border: 'rgba(106,76,124,0.30)', text: '#1F2A44', paw: '#B89530' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MascotBubble({
|
||||||
|
text, align = 'left', tone = 'ivory', tail = true, paw = true, style = {},
|
||||||
|
}) {
|
||||||
|
const p = PALETTES[tone] || PALETTES.ivory;
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', background: p.bg, color: p.text,
|
||||||
|
border: `1px solid ${p.border}`, borderRadius: 14,
|
||||||
|
padding: '12px 14px',
|
||||||
|
fontSize: 13, lineHeight: 1.55, letterSpacing: '-0.01em',
|
||||||
|
maxWidth: 240, boxShadow: '0 2px 6px rgba(31,42,68,0.04)',
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
<div style={{ whiteSpace: 'pre-line' }}>{text}</div>
|
||||||
|
{paw && (
|
||||||
|
<div style={{ marginTop: 4, textAlign: 'right', color: p.paw, opacity: 0.8 }}>
|
||||||
|
<span className="paw-bob"><IconPaw size={12} color={p.paw} /></span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{tail && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: -7, [align]: 22,
|
||||||
|
width: 14, height: 14, background: p.bg,
|
||||||
|
borderRight: `1px solid ${p.border}`, borderBottom: `1px solid ${p.border}`,
|
||||||
|
transform: 'rotate(45deg)',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/pages/saju/_shell/OrnamentBloom.jsx
Normal file
13
src/pages/saju/_shell/OrnamentBloom.jsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function OrnamentBloom({ size = 18, color = '#D4AF37' }) {
|
||||||
|
return (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 18 18" fill="none">
|
||||||
|
<circle cx="9" cy="9" r="2.4" fill={color} />
|
||||||
|
{[0, 60, 120, 180, 240, 300].map((angle) => (
|
||||||
|
<ellipse key={angle} cx="9" cy="4" rx="1.6" ry="3" fill={color} opacity="0.7"
|
||||||
|
transform={`rotate(${angle} 9 9)`} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/pages/saju/_shell/OrnateFrame.jsx
Normal file
36
src/pages/saju/_shell/OrnateFrame.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import hexA from './helpers/hexA';
|
||||||
|
|
||||||
|
export default function OrnateFrame({
|
||||||
|
children, color = '#D4AF37', bg = 'transparent', radius = 14, padding = '20px',
|
||||||
|
style = {}, double = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'relative', borderRadius: radius,
|
||||||
|
background: bg, padding,
|
||||||
|
border: `1px solid ${hexA(color, 0.45)}`,
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
{double && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 4, borderRadius: radius - 4,
|
||||||
|
border: `1px solid ${hexA(color, 0.3)}`, pointerEvents: 'none',
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{[[0,0,0],[0,1,90],[1,1,180],[1,0,270]].map(([x,y,r], i) => (
|
||||||
|
<svg key={i} width="12" height="12" viewBox="0 0 12 12" style={{
|
||||||
|
position: 'absolute',
|
||||||
|
[x ? 'right' : 'left']: 6,
|
||||||
|
[y ? 'bottom' : 'top']: 6,
|
||||||
|
transform: `rotate(${r}deg)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
<path d="M0 4 L0 0 L4 0" stroke={color} strokeWidth="1.2" fill="none" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
))}
|
||||||
|
<div style={{ position: 'relative', zIndex: 1 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/pages/saju/_shell/PrimaryButton.jsx
Normal file
22
src/pages/saju/_shell/PrimaryButton.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import hexA from './helpers/hexA';
|
||||||
|
|
||||||
|
export default function PrimaryButton({
|
||||||
|
children, color = '#1F2A44', onClick, full = true, style = {}, gold = true, type = 'button',
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type={type} onClick={onClick} style={{
|
||||||
|
width: full ? '100%' : 'auto', padding: '14px 22px',
|
||||||
|
background: color, color: '#F7F2E8',
|
||||||
|
border: 'none', borderRadius: 12,
|
||||||
|
fontSize: 15, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
boxShadow: gold
|
||||||
|
? `0 2px 0 ${hexA(color, 0.4)}, 0 6px 18px ${hexA(color, 0.25)}, inset 0 1px 0 rgba(212,175,55,0.4)`
|
||||||
|
: '0 4px 14px rgba(31,42,68,0.18)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
|
...style,
|
||||||
|
}}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/pages/saju/_shell/TitleBlock.jsx
Normal file
37
src/pages/saju/_shell/TitleBlock.jsx
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import OrnamentBloom from './OrnamentBloom';
|
||||||
|
|
||||||
|
export default function TitleBlock({
|
||||||
|
title, subtitle, color = '#1F2A44', subColor = '#6B6B6B',
|
||||||
|
center = true, withBloom = true, gold = '#D4AF37',
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: center ? 'center' : 'left' }}>
|
||||||
|
{withBloom && center && (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', justifyContent: 'center', gap: 12,
|
||||||
|
alignItems: 'center', marginBottom: 10, color: gold,
|
||||||
|
}}>
|
||||||
|
<svg width="40" height="6" viewBox="0 0 40 6">
|
||||||
|
<path d="M0 3 L36 3" stroke={gold} strokeWidth="1" />
|
||||||
|
<circle cx="38" cy="3" r="1.5" fill={gold} />
|
||||||
|
</svg>
|
||||||
|
<OrnamentBloom size={18} color={gold} />
|
||||||
|
<svg width="40" height="6" viewBox="0 0 40 6">
|
||||||
|
<circle cx="2" cy="3" r="1.5" fill={gold} />
|
||||||
|
<path d="M4 3 L40 3" stroke={gold} strokeWidth="1" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h1 className="font-title" style={{
|
||||||
|
margin: 0, fontSize: 30, color, letterSpacing: '-0.02em',
|
||||||
|
}}>{title}</h1>
|
||||||
|
{subtitle && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 6, fontSize: 13, color: subColor, lineHeight: 1.55,
|
||||||
|
letterSpacing: '-0.01em',
|
||||||
|
}}>{subtitle}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/pages/saju/_shell/TopRibbon.jsx
Normal file
21
src/pages/saju/_shell/TopRibbon.jsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
function CloudOrnament({ width = 90, color = '#D4AF37', opacity = 0.85 }) {
|
||||||
|
return (
|
||||||
|
<svg width={width} height={width / 3.5} viewBox="0 0 90 26" fill="none" opacity={opacity}>
|
||||||
|
<path d="M5 18 Q12 6 24 12 Q36 4 48 14 Q60 6 72 14 Q82 8 88 18"
|
||||||
|
stroke={color} strokeWidth="1" fill="none" />
|
||||||
|
<circle cx="24" cy="12" r="1.4" fill={color} />
|
||||||
|
<circle cx="48" cy="14" r="1.4" fill={color} />
|
||||||
|
<circle cx="72" cy="14" r="1.4" fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TopRibbon({ color = '#D4AF37', opacity = 0.5 }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 0', opacity }}>
|
||||||
|
<CloudOrnament width={90} color={color} opacity={0.85} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
src/pages/saju/_shell/helpers/colorMap.js
Normal file
20
src/pages/saju/_shell/helpers/colorMap.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
const ELEMENT_TO_VAR = {
|
||||||
|
wood: 'var(--el-wood)',
|
||||||
|
fire: 'var(--el-fire)',
|
||||||
|
earth: 'var(--el-earth)',
|
||||||
|
metal: 'var(--el-metal)',
|
||||||
|
water: 'var(--el-water)',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELEMENT_KO = { wood: '목', fire: '화', earth: '토', metal: '금', water: '수' };
|
||||||
|
const ELEMENT_CH = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' };
|
||||||
|
|
||||||
|
export function elementColor(id) {
|
||||||
|
return ELEMENT_TO_VAR[id] || 'var(--navy)';
|
||||||
|
}
|
||||||
|
export function elementKo(id) {
|
||||||
|
return ELEMENT_KO[id] || '';
|
||||||
|
}
|
||||||
|
export function elementCh(id) {
|
||||||
|
return ELEMENT_CH[id] || '';
|
||||||
|
}
|
||||||
10
src/pages/saju/_shell/helpers/daeunLabel.js
Normal file
10
src/pages/saju/_shell/helpers/daeunLabel.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function daeunLabel(age) {
|
||||||
|
if (age < 10) return '성장기';
|
||||||
|
if (age < 20) return '학습기';
|
||||||
|
if (age < 30) return '도전기';
|
||||||
|
if (age < 40) return '성장기';
|
||||||
|
if (age < 50) return '전성기';
|
||||||
|
if (age < 60) return '안정기';
|
||||||
|
if (age < 70) return '정리기';
|
||||||
|
return '여유기';
|
||||||
|
}
|
||||||
31
src/pages/saju/_shell/helpers/deriveTraits.js
Normal file
31
src/pages/saju/_shell/helpers/deriveTraits.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const TRAIT_DEFS = {
|
||||||
|
fire: { id: 'challenge', ko: '도전정신', icon: 'challenge', color: 'var(--el-fire)' },
|
||||||
|
metal: { id: 'lead', ko: '리더십', icon: 'lead', color: 'var(--el-metal)' },
|
||||||
|
wood: { id: 'adapt', ko: '적응력', icon: 'adapt', color: 'var(--el-wood)' },
|
||||||
|
water: { id: 'wisdom', ko: '지혜', icon: 'wisdom', color: 'var(--el-water)' },
|
||||||
|
earth: { id: 'wealth', ko: '풍부함', icon: 'wealth', color: 'var(--el-earth)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const WILL_TRAIT = { id: 'will', ko: '의지', icon: 'will', color: 'var(--purple)' };
|
||||||
|
|
||||||
|
export default function deriveTraits(elements, sipsin = []) {
|
||||||
|
const sorted = Object.entries(elements || {})
|
||||||
|
.filter(([, v]) => typeof v === 'number')
|
||||||
|
.sort((a, b) => b[1] - a[1]);
|
||||||
|
|
||||||
|
const traits = [];
|
||||||
|
for (const [el, score] of sorted) {
|
||||||
|
if (score >= 30 && TRAIT_DEFS[el]) {
|
||||||
|
traits.push(TRAIT_DEFS[el]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!traits.find((t) => t.id === 'will')) traits.push(WILL_TRAIT);
|
||||||
|
|
||||||
|
for (const [el] of sorted) {
|
||||||
|
if (traits.length >= 6) break;
|
||||||
|
if (TRAIT_DEFS[el] && !traits.find((t) => t.id === TRAIT_DEFS[el].id)) {
|
||||||
|
traits.push(TRAIT_DEFS[el]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return traits.slice(0, 6);
|
||||||
|
}
|
||||||
48
src/pages/saju/_shell/helpers/helpers.test.js
Normal file
48
src/pages/saju/_shell/helpers/helpers.test.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import hexA from './hexA';
|
||||||
|
import daeunLabel from './daeunLabel';
|
||||||
|
import deriveTraits from './deriveTraits';
|
||||||
|
import { elementColor } from './colorMap';
|
||||||
|
|
||||||
|
describe('hexA', () => {
|
||||||
|
it('converts hex with alpha', () => {
|
||||||
|
expect(hexA('#1F2A44', 0.5)).toBe('rgba(31,42,68,0.5)');
|
||||||
|
});
|
||||||
|
it('handles 3-digit hex', () => {
|
||||||
|
expect(hexA('#abc', 1)).toBe('rgba(170,187,204,1)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('daeunLabel', () => {
|
||||||
|
it('maps age ranges', () => {
|
||||||
|
expect(daeunLabel(5)).toBe('성장기');
|
||||||
|
expect(daeunLabel(15)).toBe('학습기');
|
||||||
|
expect(daeunLabel(25)).toBe('도전기');
|
||||||
|
expect(daeunLabel(35)).toBe('성장기');
|
||||||
|
expect(daeunLabel(45)).toBe('전성기');
|
||||||
|
expect(daeunLabel(55)).toBe('안정기');
|
||||||
|
expect(daeunLabel(65)).toBe('정리기');
|
||||||
|
expect(daeunLabel(75)).toBe('여유기');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deriveTraits', () => {
|
||||||
|
it('derives strong-element traits (sorted by score)', () => {
|
||||||
|
const traits = deriveTraits({ fire: 55, metal: 40, wood: 35, earth: 15, water: 20 }, []);
|
||||||
|
expect(traits.length).toBeLessThanOrEqual(6);
|
||||||
|
expect(traits[0].id).toBe('challenge');
|
||||||
|
expect(traits.map((t) => t.id)).toContain('lead');
|
||||||
|
});
|
||||||
|
it('always includes will trait', () => {
|
||||||
|
const traits = deriveTraits({ fire: 50, metal: 30, wood: 30, earth: 30, water: 30 }, []);
|
||||||
|
expect(traits.map((t) => t.id)).toContain('will');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('elementColor', () => {
|
||||||
|
it('maps element ids to CSS vars', () => {
|
||||||
|
expect(elementColor('wood')).toBe('var(--el-wood)');
|
||||||
|
expect(elementColor('fire')).toBe('var(--el-fire)');
|
||||||
|
expect(elementColor('unknown')).toBe('var(--navy)');
|
||||||
|
});
|
||||||
|
});
|
||||||
6
src/pages/saju/_shell/helpers/hexA.js
Normal file
6
src/pages/saju/_shell/helpers/hexA.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default function hexA(hex, alpha) {
|
||||||
|
const h = hex.replace('#', '');
|
||||||
|
const expanded = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
||||||
|
const n = parseInt(expanded, 16);
|
||||||
|
return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${alpha})`;
|
||||||
|
}
|
||||||
81
src/pages/saju/_shell/shell.css
Normal file
81
src/pages/saju/_shell/shell.css
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/* 호령 사주 v2 — 배경 + ornament + animation */
|
||||||
|
|
||||||
|
/* paper texture */
|
||||||
|
.saju-v2 .paper-bg {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse at top, rgba(212, 175, 55, 0.06), transparent 60%),
|
||||||
|
radial-gradient(ellipse at bottom, rgba(106, 76, 124, 0.04), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--ivory) 0%, var(--ivory-soft) 100%);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.saju-v2 .paper-bg::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute; inset: 0; pointer-events: none;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 20% 30%, rgba(180, 140, 80, 0.04) 0, transparent 40%),
|
||||||
|
radial-gradient(circle at 80% 70%, rgba(180, 140, 80, 0.04) 0, transparent 40%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* night sky */
|
||||||
|
.saju-v2 .night-bg {
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 30% 20%, rgba(232, 199, 107, 0.18), transparent 60%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 80% 80%, rgba(106, 76, 124, 0.3), transparent 60%),
|
||||||
|
linear-gradient(180deg, var(--navy-deep) 0%, var(--navy) 55%, #1A2238 100%);
|
||||||
|
position: relative;
|
||||||
|
color: var(--ivory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* mountain wash (desktop hero) */
|
||||||
|
.saju-v2 .mt-wash {
|
||||||
|
position: relative;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 70% 50% at 10% 80%, rgba(31, 42, 68, 0.06), transparent 65%),
|
||||||
|
radial-gradient(ellipse 60% 40% at 90% 70%, rgba(31, 42, 68, 0.05), transparent 65%),
|
||||||
|
radial-gradient(ellipse 100% 60% at 50% 100%, rgba(212, 175, 55, 0.04), transparent 70%),
|
||||||
|
linear-gradient(180deg, var(--ivory-soft) 0%, #F4ECDB 100%);
|
||||||
|
}
|
||||||
|
.saju-v2 .mt-wash::before,
|
||||||
|
.saju-v2 .mt-wash::after {
|
||||||
|
content: ''; position: absolute; pointer-events: none;
|
||||||
|
background-repeat: no-repeat; opacity: 0.35; background-size: contain;
|
||||||
|
}
|
||||||
|
.saju-v2 .mt-wash::before {
|
||||||
|
left: 0; bottom: 0; width: 320px; height: 160px;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 320 160' fill='none' stroke='%231F2A44' stroke-width='1' opacity='0.45'><path d='M0 150 L40 90 L80 120 L130 60 L180 110 L220 80 L260 120 L310 70 L320 100 L320 160 L0 160 Z'/><path d='M30 130 L70 100 L110 130 L150 95 L200 120 L240 100 L280 120 L320 110' opacity='0.6'/></svg>");
|
||||||
|
}
|
||||||
|
.saju-v2 .mt-wash::after {
|
||||||
|
right: 0; bottom: 0; width: 380px; height: 180px;
|
||||||
|
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 380 180' fill='none' stroke='%231F2A44' stroke-width='1' opacity='0.4'><path d='M0 160 L50 100 L100 140 L160 70 L220 130 L280 90 L330 140 L380 110 L380 180 L0 180 Z'/></svg>");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* screen entry */
|
||||||
|
@keyframes saju-screen-in {
|
||||||
|
from { transform: translateY(6px); opacity: 0.8; }
|
||||||
|
to { transform: translateY(0); opacity: 1; }
|
||||||
|
}
|
||||||
|
.saju-v2 .screen-in {
|
||||||
|
animation: saju-screen-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* paw bob */
|
||||||
|
@keyframes saju-paw-bob {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
.saju-v2 .paw-bob {
|
||||||
|
animation: saju-paw-bob 2.4s ease-in-out infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* page container */
|
||||||
|
.saju-v2 .page {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: var(--bottom-nav-h);
|
||||||
|
}
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.saju-v2 .page {
|
||||||
|
padding-bottom: 0;
|
||||||
|
padding-top: var(--desktop-header-h);
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/pages/saju/_shell/tokens.css
Normal file
73
src/pages/saju/_shell/tokens.css
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/* 호령 사주 v2 — 디자인 토큰 */
|
||||||
|
.saju-v2 {
|
||||||
|
/* Brand palette */
|
||||||
|
--navy: #1F2A44;
|
||||||
|
--navy-deep: #141B30;
|
||||||
|
--navy-soft: #2E3B5A;
|
||||||
|
--ivory: #F7F2E8;
|
||||||
|
--ivory-soft: #FBF7EF;
|
||||||
|
--ivory-warm: #F0E9D9;
|
||||||
|
--gold: #D4AF37;
|
||||||
|
--gold-soft: #E8C76B;
|
||||||
|
--gold-dim: #B89530;
|
||||||
|
--green: #4E6B5C;
|
||||||
|
--green-soft: #6E8B7C;
|
||||||
|
--green-bg: #E6EBE5;
|
||||||
|
--purple: #6A4C7C;
|
||||||
|
--purple-soft: #8B6C9C;
|
||||||
|
--purple-bg: #ECE6F0;
|
||||||
|
--pink: #F2C7CD;
|
||||||
|
--pink-deep: #D89098;
|
||||||
|
--pink-bg: #FBE8EB;
|
||||||
|
--gray: #6B6B6B;
|
||||||
|
--gray-soft: #9A968D;
|
||||||
|
--gray-line: rgba(31, 42, 68, 0.10);
|
||||||
|
--gray-line-strong: rgba(31, 42, 68, 0.18);
|
||||||
|
|
||||||
|
/* Element colors (오행) */
|
||||||
|
--el-wood: #4E6B5C;
|
||||||
|
--el-fire: #C04A4A;
|
||||||
|
--el-earth: #A67B3F;
|
||||||
|
--el-metal: #D4AF37;
|
||||||
|
--el-water: #3A5A8C;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-card: 0 2px 8px rgba(31, 42, 68, 0.04), 0 8px 24px rgba(31, 42, 68, 0.06);
|
||||||
|
--shadow-pop: 0 8px 28px rgba(31, 42, 68, 0.16);
|
||||||
|
--shadow-dark: 0 4px 20px rgba(0, 0, 0, 0.35);
|
||||||
|
|
||||||
|
/* Fonts */
|
||||||
|
--font-title: 'Nanum Myeongjo', 'Gowun Batang', serif;
|
||||||
|
--font-body: 'Nanum Gothic', system-ui, -apple-system, sans-serif;
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
--content-max-desktop: 1200px;
|
||||||
|
--bottom-nav-h: 72px;
|
||||||
|
--desktop-header-h: 64px;
|
||||||
|
|
||||||
|
color: var(--navy);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saju-v2 * { box-sizing: border-box; }
|
||||||
|
|
||||||
|
.saju-v2 .font-title {
|
||||||
|
font-family: var(--font-title);
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.saju-v2 button {
|
||||||
|
font-family: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.saju-v2 button:focus-visible {
|
||||||
|
outline: 2px solid var(--gold);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* hide scrollbar utility */
|
||||||
|
.saju-v2 .no-scrollbar::-webkit-scrollbar { display: none; }
|
||||||
|
.saju-v2 .no-scrollbar { scrollbar-width: none; }
|
||||||
16
src/pages/saju/_shell/useViewportMode.js
Normal file
16
src/pages/saju/_shell/useViewportMode.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function useViewportMode() {
|
||||||
|
const [mode, setMode] = useState(() =>
|
||||||
|
typeof window !== 'undefined' && window.innerWidth >= 1024 ? 'desktop' : 'mobile'
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const onResize = () => {
|
||||||
|
const next = window.innerWidth >= 1024 ? 'desktop' : 'mobile';
|
||||||
|
setMode((prev) => (prev === next ? prev : next));
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', onResize);
|
||||||
|
return () => window.removeEventListener('resize', onResize);
|
||||||
|
}, []);
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
32
src/pages/saju/_shell/useViewportMode.test.js
Normal file
32
src/pages/saju/_shell/useViewportMode.test.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import useViewportMode from './useViewportMode';
|
||||||
|
|
||||||
|
describe('useViewportMode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
window.innerWidth = 800;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns mobile when width < 1024', () => {
|
||||||
|
window.innerWidth = 1023;
|
||||||
|
const { result } = renderHook(() => useViewportMode());
|
||||||
|
expect(result.current).toBe('mobile');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns desktop when width >= 1024', () => {
|
||||||
|
window.innerWidth = 1024;
|
||||||
|
const { result } = renderHook(() => useViewportMode());
|
||||||
|
expect(result.current).toBe('desktop');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates on resize', () => {
|
||||||
|
window.innerWidth = 800;
|
||||||
|
const { result } = renderHook(() => useViewportMode());
|
||||||
|
expect(result.current).toBe('mobile');
|
||||||
|
act(() => {
|
||||||
|
window.innerWidth = 1200;
|
||||||
|
window.dispatchEvent(new Event('resize'));
|
||||||
|
});
|
||||||
|
expect(result.current).toBe('desktop');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
|
|
||||||
const ICON = {
|
|
||||||
today: '☀',
|
|
||||||
heart: '♥',
|
|
||||||
book: '📖',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ActionCard({ to, icon, title, desc, variant = 'saju', disabled = false }) {
|
|
||||||
const cls = `saju-action-card saju-action-card--${variant}`;
|
|
||||||
if (disabled) {
|
|
||||||
return (
|
|
||||||
<span className={cls} aria-disabled="true">
|
|
||||||
<span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
|
|
||||||
<span className="saju-action-card__title">{title}</span>
|
|
||||||
<span className="saju-action-card__desc">{desc || '준비 중'}</span>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Link to={to} className={cls}>
|
|
||||||
<span className="saju-action-card__icon">{ICON[icon] || '✦'}</span>
|
|
||||||
<span className="saju-action-card__title">{title}</span>
|
|
||||||
<span className="saju-action-card__desc">{desc}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function FortuneRing({ score, max = 100 }) {
|
|
||||||
const radius = 80;
|
|
||||||
const circumference = 2 * Math.PI * radius;
|
|
||||||
const safe = Math.max(0, Math.min(score || 0, max));
|
|
||||||
const dashOffset = circumference - (safe / max) * circumference;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="saju-fortune-ring">
|
|
||||||
<svg viewBox="0 0 200 200">
|
|
||||||
<circle
|
|
||||||
cx="100" cy="100" r={radius}
|
|
||||||
stroke="var(--saju-paper)" strokeWidth="14" fill="none"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
cx="100" cy="100" r={radius}
|
|
||||||
stroke="var(--saju-gold)" strokeWidth="14" fill="none"
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
strokeDashoffset={dashOffset}
|
|
||||||
strokeLinecap="round"
|
|
||||||
transform="rotate(-90 100 100)"
|
|
||||||
style={{ transition: 'stroke-dashoffset 0.6s ease' }}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div style={{ position: 'absolute', textAlign: 'center' }}>
|
|
||||||
<div className="saju-fortune-ring__score">{safe}</div>
|
|
||||||
<div className="saju-fortune-ring__total">/ {max}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const POSE_TO_FILE = {
|
|
||||||
front: '/images/saju/horyung/horyung-front.png',
|
|
||||||
bust: '/images/saju/horyung/horyung-bust.png',
|
|
||||||
greeting: '/images/saju/horyung/horyung-greeting.png',
|
|
||||||
thinking: '/images/saju/horyung/horyung-thinking.png',
|
|
||||||
pointing: '/images/saju/horyung/horyung-pointing.png',
|
|
||||||
happy: '/images/saju/horyung/horyung-happy.png',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HoryungMascot({ pose = 'front', size = 'lg', className = '' }) {
|
|
||||||
const src = POSE_TO_FILE[pose] || POSE_TO_FILE.front;
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
src={src}
|
|
||||||
alt="호령"
|
|
||||||
className={`horyung-mascot horyung-mascot--${size} ${className}`}
|
|
||||||
onError={(e) => { e.target.style.visibility = 'hidden'; }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function LuckyBox({ lucky }) {
|
|
||||||
if (!lucky) return null;
|
|
||||||
return (
|
|
||||||
<div className="saju-lucky-box">
|
|
||||||
<div className="saju-lucky-box__item">
|
|
||||||
<div className="saju-lucky-box__label">럭키 컬러</div>
|
|
||||||
<div className="saju-lucky-box__value">{(lucky.color || []).join(' · ')}</div>
|
|
||||||
</div>
|
|
||||||
<div className="saju-lucky-box__item">
|
|
||||||
<div className="saju-lucky-box__label">럭키 숫자</div>
|
|
||||||
<div className="saju-lucky-box__value">{lucky.number}</div>
|
|
||||||
</div>
|
|
||||||
<div className="saju-lucky-box__item">
|
|
||||||
<div className="saju-lucky-box__label">럭키 방향</div>
|
|
||||||
<div className="saju-lucky-box__value">{lucky.direction}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export default function SajuInputForm({ form, onChange, onSubmit, loading, error }) {
|
|
||||||
return (
|
|
||||||
<form className="saju-form" onSubmit={onSubmit}>
|
|
||||||
<h3 className="saju-h3" style={{ color: 'var(--saju-cream)', marginBottom: '0.5rem' }}>
|
|
||||||
사주풀이 시작하기
|
|
||||||
</h3>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="이름 (선택)"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => onChange('name', e.target.value)}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
|
||||||
<input type="number" placeholder="년 (1900-2100)" value={form.year}
|
|
||||||
onChange={(e) => onChange('year', e.target.value)} disabled={loading} min="1900" max="2100" />
|
|
||||||
<input type="number" placeholder="월" value={form.month}
|
|
||||||
onChange={(e) => onChange('month', e.target.value)} disabled={loading} min="1" max="12" />
|
|
||||||
<input type="number" placeholder="일" value={form.day}
|
|
||||||
onChange={(e) => onChange('day', e.target.value)} disabled={loading} min="1" max="31" />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.5rem' }}>
|
|
||||||
<input type="number" placeholder="시 (선택, 0-23)" value={form.hour}
|
|
||||||
onChange={(e) => onChange('hour', e.target.value)} disabled={loading} min="0" max="23" />
|
|
||||||
<select value={form.gender} onChange={(e) => onChange('gender', e.target.value)} disabled={loading}>
|
|
||||||
<option value="male">남</option>
|
|
||||||
<option value="female">여</option>
|
|
||||||
</select>
|
|
||||||
<select value={form.calendar_type} onChange={(e) => onChange('calendar_type', e.target.value)} disabled={loading}>
|
|
||||||
<option value="solar">양력</option>
|
|
||||||
<option value="lunar">음력</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{error && <div className="saju-form__error">{error}</div>}
|
|
||||||
<button type="submit" disabled={loading}>
|
|
||||||
{loading ? '호령이 풀어보는 중...' : '사주풀이 시작하기 ✦'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Link, NavLink } from 'react-router-dom';
|
|
||||||
|
|
||||||
export default function SajuNav() {
|
|
||||||
return (
|
|
||||||
<nav className="saju-nav" aria-label="호령 사주">
|
|
||||||
<Link to="/saju" className="saju-nav__logo">호령사주</Link>
|
|
||||||
<ul className="saju-nav__links">
|
|
||||||
<li><NavLink to="/saju/today">오늘의 운세</NavLink></li>
|
|
||||||
<li><NavLink to="/saju/compatibility">궁합보기</NavLink></li>
|
|
||||||
<li><NavLink to="/saju/result">사주풀이</NavLink></li>
|
|
||||||
</ul>
|
|
||||||
<Link to="/saju" className="saju-nav__cta">사주풀이 시작하기</Link>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const ICON_BY_CATEGORY = {
|
|
||||||
wealth: '💰',
|
|
||||||
romance: '💖',
|
|
||||||
social: '🤝',
|
|
||||||
career: '💼',
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_VAR_BY_CATEGORY = {
|
|
||||||
wealth: 'var(--saju-wealth)',
|
|
||||||
romance: 'var(--saju-romance)',
|
|
||||||
social: 'var(--saju-social)',
|
|
||||||
career: 'var(--saju-career)',
|
|
||||||
};
|
|
||||||
|
|
||||||
const TITLE_BY_CATEGORY = {
|
|
||||||
wealth: '재물운',
|
|
||||||
romance: '연애운',
|
|
||||||
social: '인간관계',
|
|
||||||
career: '직장운',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ScoreCard({ category, score }) {
|
|
||||||
const safe = Math.max(0, Math.min(score || 0, 100));
|
|
||||||
return (
|
|
||||||
<div className="saju-score-card">
|
|
||||||
<div className="saju-score-card__head">
|
|
||||||
<span className="saju-score-card__icon">{ICON_BY_CATEGORY[category]}</span>
|
|
||||||
<span className="saju-score-card__title">{TITLE_BY_CATEGORY[category]}</span>
|
|
||||||
</div>
|
|
||||||
<div className="saju-score-card__value">{safe}<small style={{ fontSize: '1rem', opacity: 0.5 }}>/100</small></div>
|
|
||||||
<div className="saju-score-card__bar">
|
|
||||||
<div style={{ width: `${safe}%`, background: COLOR_VAR_BY_CATEGORY[category] }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
133
src/pages/saju/views/home.desktop.jsx
Normal file
133
src/pages/saju/views/home.desktop.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import TitleBlock from '../_shell/TitleBlock';
|
||||||
|
import Mascot from '../_shell/Mascot';
|
||||||
|
import MascotBubble from '../_shell/MascotBubble';
|
||||||
|
import OrnateFrame from '../_shell/OrnateFrame';
|
||||||
|
import PrimaryButton from '../_shell/PrimaryButton';
|
||||||
|
import InputRow from '../_shell/InputRow';
|
||||||
|
import { IconSparkle, IconChevron, IconSun, IconHeart, IconYinYang } from '../_shell/Icons';
|
||||||
|
import useSajuForm from '../hooks/useSajuForm';
|
||||||
|
|
||||||
|
const ACTIONS = [
|
||||||
|
{ to: '/saju/today', icon: IconSun, label: '오늘의 운세', desc: '오늘 한 줄로 보는 운세', color: '#D4AF37' },
|
||||||
|
{ to: '/saju/compatibility', icon: IconHeart, label: '궁합보기', desc: '두 사람의 만남 풀이', color: '#4E6B5C' },
|
||||||
|
{ to: '/saju/result', icon: IconYinYang, label: '사주풀이', desc: '내 사주 자세히', color: '#6A4C7C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||||||
|
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
function dateValue(form) {
|
||||||
|
if (!form.year || !form.month || !form.day) return '';
|
||||||
|
return `${form.year}-${pad(form.month)}-${pad(form.day)}`;
|
||||||
|
}
|
||||||
|
function timeValue(form) {
|
||||||
|
if (form.hour === '' || form.hour == null) return '';
|
||||||
|
return `${pad(form.hour)}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeDesktop() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||||||
|
|
||||||
|
const onDate = (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) { handleChange('year', ''); handleChange('month', ''); handleChange('day', ''); return; }
|
||||||
|
const [y, m, d] = v.split('-');
|
||||||
|
handleChange('year', y);
|
||||||
|
handleChange('month', String(parseInt(m, 10)));
|
||||||
|
handleChange('day', String(parseInt(d, 10)));
|
||||||
|
};
|
||||||
|
const onTime = (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) { handleChange('hour', ''); return; }
|
||||||
|
const [h] = v.split(':');
|
||||||
|
handleChange('hour', String(parseInt(h, 10)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page mt-wash screen-in">
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '48px 32px' }}>
|
||||||
|
<TitleBlock title="호령이 안내하는 사주"
|
||||||
|
subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다." />
|
||||||
|
<div style={{
|
||||||
|
marginTop: 32, display: 'grid', gridTemplateColumns: '1fr 480px', gap: 40, alignItems: 'start',
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}>
|
||||||
|
<Mascot variant="full" size={260} />
|
||||||
|
<MascotBubble tone="ivory" align="left"
|
||||||
|
text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'}
|
||||||
|
style={{ marginBottom: 20 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 32, display: 'grid', gap: 12 }}>
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<button key={a.to} onClick={() => navigate(a.to)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 16,
|
||||||
|
background: '#FBF7EF', border: `1px solid ${a.color}40`,
|
||||||
|
borderRadius: 12, padding: '16px 20px', color: '#1F2A44',
|
||||||
|
fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em', textAlign: 'left',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
}}>
|
||||||
|
<a.icon size={24} stroke={a.color} strokeWidth={1.8} />
|
||||||
|
<span style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 15 }}>{a.label}</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#6B6B6B', fontWeight: 500, marginTop: 2 }}>{a.desc}</div>
|
||||||
|
</span>
|
||||||
|
<IconChevron dir="right" size={16} color="#B89530" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16} padding="24px 22px">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="font-title" style={{
|
||||||
|
fontSize: 18, color: '#1F2A44', marginBottom: 12, textAlign: 'center',
|
||||||
|
}}>사주 입력</div>
|
||||||
|
<InputRow label="이름">
|
||||||
|
<input value={form.name} onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
placeholder="홍길동" style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="생년월일">
|
||||||
|
<input type="date" value={dateValue(form)} onChange={onDate} style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="시간">
|
||||||
|
<input type="time" value={timeValue(form)} onChange={onTime} style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="성별">
|
||||||
|
<select value={form.gender} onChange={(e) => handleChange('gender', e.target.value)}
|
||||||
|
style={inputStyle}>
|
||||||
|
<option value="male">남</option>
|
||||||
|
<option value="female">여</option>
|
||||||
|
</select>
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="달력">
|
||||||
|
<select value={form.calendar_type}
|
||||||
|
onChange={(e) => handleChange('calendar_type', e.target.value)} style={inputStyle}>
|
||||||
|
<option value="solar">양력</option>
|
||||||
|
<option value="lunar">음력</option>
|
||||||
|
</select>
|
||||||
|
</InputRow>
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: '14px 14px 6px' }}>
|
||||||
|
<PrimaryButton color="#6A4C7C" type="submit">
|
||||||
|
{loading ? '호령이 풀이 중...' : '내 사주 보기'}
|
||||||
|
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OrnateFrame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/pages/saju/views/home.mobile.jsx
Normal file
133
src/pages/saju/views/home.mobile.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import TopRibbon from '../_shell/TopRibbon';
|
||||||
|
import TitleBlock from '../_shell/TitleBlock';
|
||||||
|
import Mascot from '../_shell/Mascot';
|
||||||
|
import MascotBubble from '../_shell/MascotBubble';
|
||||||
|
import OrnateFrame from '../_shell/OrnateFrame';
|
||||||
|
import PrimaryButton from '../_shell/PrimaryButton';
|
||||||
|
import InputRow from '../_shell/InputRow';
|
||||||
|
import { IconChevron, IconSparkle, IconSun, IconHeart, IconYinYang } from '../_shell/Icons';
|
||||||
|
import useSajuForm from '../hooks/useSajuForm';
|
||||||
|
|
||||||
|
const ACTIONS = [
|
||||||
|
{ to: '/saju/today', icon: IconSun, label: '오늘의 운세', color: '#D4AF37' },
|
||||||
|
{ to: '/saju/compatibility', icon: IconHeart, label: '궁합보기', color: '#4E6B5C' },
|
||||||
|
{ to: '/saju/result', icon: IconYinYang, label: '사주풀이', color: '#6A4C7C' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||||||
|
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
|
||||||
|
function dateValue(form) {
|
||||||
|
if (!form.year || !form.month || !form.day) return '';
|
||||||
|
return `${form.year}-${pad(form.month)}-${pad(form.day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeValue(form) {
|
||||||
|
if (form.hour === '' || form.hour == null) return '';
|
||||||
|
return `${pad(form.hour)}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HomeMobile() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
|
||||||
|
|
||||||
|
const onDate = (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) { handleChange('year', ''); handleChange('month', ''); handleChange('day', ''); return; }
|
||||||
|
const [y, m, d] = v.split('-');
|
||||||
|
handleChange('year', y);
|
||||||
|
handleChange('month', String(parseInt(m, 10)));
|
||||||
|
handleChange('day', String(parseInt(d, 10)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTime = (e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) { handleChange('hour', ''); return; }
|
||||||
|
const [h] = v.split(':');
|
||||||
|
handleChange('hour', String(parseInt(h, 10)));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page night-bg screen-in" style={{ paddingTop: 24 }}>
|
||||||
|
<TopRibbon color="#D4AF37" opacity={0.7} />
|
||||||
|
<div style={{ padding: '8px 24px 0', textAlign: 'center', color: '#F7F2E8' }}>
|
||||||
|
<TitleBlock color="#F7F2E8" subColor="rgba(247,242,232,0.7)"
|
||||||
|
title="호령이 안내하는 사주"
|
||||||
|
subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '24px 20px 0', display: 'flex', gap: 12, alignItems: 'flex-end' }}>
|
||||||
|
<MascotBubble tone="navy" align="left"
|
||||||
|
text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'}
|
||||||
|
style={{ flex: 1, marginBottom: 8 }}
|
||||||
|
/>
|
||||||
|
<Mascot variant="full" size={140} style={{ marginRight: -8 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px 20px 0', display: 'grid', gap: 10 }}>
|
||||||
|
{ACTIONS.map((a) => (
|
||||||
|
<button key={a.to} onClick={() => navigate(a.to)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 12,
|
||||||
|
background: 'rgba(247,242,232,0.06)', border: `1px solid ${a.color}55`,
|
||||||
|
borderRadius: 12, padding: '14px 16px', color: '#F7F2E8',
|
||||||
|
fontSize: 14, fontWeight: 700, letterSpacing: '-0.01em',
|
||||||
|
}}>
|
||||||
|
<a.icon size={20} stroke={a.color} strokeWidth={1.8} />
|
||||||
|
<span style={{ flex: 1, textAlign: 'left' }}>{a.label}</span>
|
||||||
|
<IconChevron dir="right" size={14} color="#E8C76B" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '24px 20px 40px' }}>
|
||||||
|
<OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16}>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="font-title" style={{
|
||||||
|
fontSize: 16, color: '#1F2A44', marginBottom: 8, textAlign: 'center',
|
||||||
|
}}>사주 입력</div>
|
||||||
|
<InputRow label="이름">
|
||||||
|
<input value={form.name} onChange={(e) => handleChange('name', e.target.value)}
|
||||||
|
placeholder="홍길동" style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="생년월일">
|
||||||
|
<input type="date" value={dateValue(form)} onChange={onDate} style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="시간">
|
||||||
|
<input type="time" value={timeValue(form)} onChange={onTime} style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="성별">
|
||||||
|
<select value={form.gender} onChange={(e) => handleChange('gender', e.target.value)}
|
||||||
|
style={inputStyle}>
|
||||||
|
<option value="male">남</option>
|
||||||
|
<option value="female">여</option>
|
||||||
|
</select>
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="달력">
|
||||||
|
<select value={form.calendar_type}
|
||||||
|
onChange={(e) => handleChange('calendar_type', e.target.value)} style={inputStyle}>
|
||||||
|
<option value="solar">양력</option>
|
||||||
|
<option value="lunar">음력</option>
|
||||||
|
</select>
|
||||||
|
</InputRow>
|
||||||
|
{error && (
|
||||||
|
<div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<div style={{ padding: '14px 14px 6px' }}>
|
||||||
|
<PrimaryButton color="#6A4C7C" type="submit">
|
||||||
|
{loading ? '호령이 풀이 중...' : '내 사주 보기'}
|
||||||
|
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</OrnateFrame>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/pages/saju/views/match.desktop.jsx
Normal file
10
src/pages/saju/views/match.desktop.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import MatchMobile from './match.mobile.jsx';
|
||||||
|
|
||||||
|
export default function MatchDesktop(props) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||||||
|
<MatchMobile {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
src/pages/saju/views/match.mobile.jsx
Normal file
108
src/pages/saju/views/match.mobile.jsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TopRibbon from '../_shell/TopRibbon';
|
||||||
|
import TitleBlock from '../_shell/TitleBlock';
|
||||||
|
import Mascot from '../_shell/Mascot';
|
||||||
|
import MascotBubble from '../_shell/MascotBubble';
|
||||||
|
import OrnateFrame from '../_shell/OrnateFrame';
|
||||||
|
import PrimaryButton from '../_shell/PrimaryButton';
|
||||||
|
import InputRow from '../_shell/InputRow';
|
||||||
|
import { IconHeart, IconSparkle } from '../_shell/Icons';
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)',
|
||||||
|
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
};
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2, '0'); }
|
||||||
|
function dateValue(p) {
|
||||||
|
if (!p.year || !p.month || !p.day) return '';
|
||||||
|
return `${p.year}-${pad(p.month)}-${pad(p.day)}`;
|
||||||
|
}
|
||||||
|
function timeValue(p) {
|
||||||
|
if (p.hour === '' || p.hour == null) return '';
|
||||||
|
return `${pad(p.hour)}:00`;
|
||||||
|
}
|
||||||
|
function onDate(p, set, e) {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) return set({ ...p, year: '', month: '', day: '' });
|
||||||
|
const [y, m, d] = v.split('-');
|
||||||
|
set({ ...p, year: parseInt(y, 10), month: parseInt(m, 10), day: parseInt(d, 10) });
|
||||||
|
}
|
||||||
|
function onTime(p, set, e) {
|
||||||
|
const v = e.target.value;
|
||||||
|
if (!v) return set({ ...p, hour: null });
|
||||||
|
const [h] = v.split(':');
|
||||||
|
set({ ...p, hour: parseInt(h, 10) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonForm({ label, person, onChange }) {
|
||||||
|
return (
|
||||||
|
<OrnateFrame color="#4E6B5C" bg="#FBF7EF" radius={14} padding="14px 16px">
|
||||||
|
<div className="font-title" style={{
|
||||||
|
fontSize: 14, color: '#4E6B5C', textAlign: 'center', marginBottom: 8,
|
||||||
|
}}>{label}</div>
|
||||||
|
<InputRow label="이름">
|
||||||
|
<input value={person.name || ''}
|
||||||
|
onChange={(e) => onChange({ ...person, name: e.target.value })}
|
||||||
|
placeholder="홍길동" style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="생년월일">
|
||||||
|
<input type="date" value={dateValue(person)}
|
||||||
|
onChange={(e) => onDate(person, onChange, e)} style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="시간">
|
||||||
|
<input type="time" value={timeValue(person)}
|
||||||
|
onChange={(e) => onTime(person, onChange, e)} style={inputStyle} />
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="성별">
|
||||||
|
<select value={person.gender}
|
||||||
|
onChange={(e) => onChange({ ...person, gender: e.target.value })}
|
||||||
|
style={inputStyle}>
|
||||||
|
<option value="male">남</option>
|
||||||
|
<option value="female">여</option>
|
||||||
|
</select>
|
||||||
|
</InputRow>
|
||||||
|
<InputRow label="달력">
|
||||||
|
<select value={person.calendar_type}
|
||||||
|
onChange={(e) => onChange({ ...person, calendar_type: e.target.value })}
|
||||||
|
style={inputStyle}>
|
||||||
|
<option value="solar">양력</option>
|
||||||
|
<option value="lunar">음력</option>
|
||||||
|
</select>
|
||||||
|
</InputRow>
|
||||||
|
</OrnateFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MatchMobile({ personA, personB, onChangeA, onChangeB, onSubmit, loading, error }) {
|
||||||
|
return (
|
||||||
|
<main className="page paper-bg screen-in">
|
||||||
|
<TopRibbon color="#4E6B5C" opacity={0.6} />
|
||||||
|
<div style={{ padding: '8px 24px 0', textAlign: 'center' }}>
|
||||||
|
<TitleBlock title="궁합 보기" gold="#4E6B5C"
|
||||||
|
subtitle="두 사람의 사주를 입력하면 만남의 흐름을 알려드려요." />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||||
|
<MascotBubble tone="green"
|
||||||
|
text={'두 사주를 비교해\n어울리는 결을\n읽어드릴게요.'}
|
||||||
|
style={{ flex: 1, marginBottom: 8 }} />
|
||||||
|
<Mascot variant="upper" size={120} style={{ marginRight: -8 }} />
|
||||||
|
</div>
|
||||||
|
<form onSubmit={onSubmit} style={{ padding: '24px 20px 40px', display: 'grid', gap: 14 }}>
|
||||||
|
<PersonForm label="사람 A" person={personA} onChange={onChangeA} />
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<IconHeart size={28} stroke="#4E6B5C" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<PersonForm label="사람 B" person={personB} onChange={onChangeB} />
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: '#C04A4A', fontSize: 12, textAlign: 'center' }}>{error}</div>
|
||||||
|
)}
|
||||||
|
<PrimaryButton color="#4E6B5C" type="submit">
|
||||||
|
{loading ? '호령이 두 사주를 비교 중...' : '궁합 보기'}
|
||||||
|
{!loading && <IconSparkle size={12} color="#E8C76B" />}
|
||||||
|
</PrimaryButton>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/pages/saju/views/saju.desktop.jsx
Normal file
10
src/pages/saju/views/saju.desktop.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import SajuMobile from './saju.mobile.jsx';
|
||||||
|
|
||||||
|
export default function SajuDesktop({ reading }) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 900, margin: '0 auto' }}>
|
||||||
|
<SajuMobile reading={reading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
424
src/pages/saju/views/saju.mobile.jsx
Normal file
424
src/pages/saju/views/saju.mobile.jsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import TopRibbon from '../_shell/TopRibbon';
|
||||||
|
import TitleBlock from '../_shell/TitleBlock';
|
||||||
|
import Mascot from '../_shell/Mascot';
|
||||||
|
import MascotBubble from '../_shell/MascotBubble';
|
||||||
|
import OrnateFrame from '../_shell/OrnateFrame';
|
||||||
|
import OrnamentBloom from '../_shell/OrnamentBloom';
|
||||||
|
import PrimaryButton from '../_shell/PrimaryButton';
|
||||||
|
import { IconChevron, IconSparkle } from '../_shell/Icons';
|
||||||
|
import deriveTraits from '../_shell/helpers/deriveTraits';
|
||||||
|
import daeunLabel from '../_shell/helpers/daeunLabel';
|
||||||
|
import hexA from '../_shell/helpers/hexA';
|
||||||
|
|
||||||
|
// 한자 element key → english id (deriveTraits 입력 표준화)
|
||||||
|
const HANJA_TO_ID = { '木': 'wood', '火': 'fire', '土': 'earth', '金': 'metal', '水': 'water' };
|
||||||
|
const ID_TO_KO = { wood: '목', fire: '화', earth: '토', metal: '금', water: '수' };
|
||||||
|
const ID_TO_CH = { wood: '木', fire: '火', earth: '土', metal: '金', water: '水' };
|
||||||
|
const ID_TO_COLOR = {
|
||||||
|
wood: '#4E6B5C', fire: '#C04A4A', earth: '#A67B3F',
|
||||||
|
metal: '#D4AF37', water: '#3A5A8C',
|
||||||
|
};
|
||||||
|
|
||||||
|
function elementsByEngId(scores) {
|
||||||
|
if (!scores) return {};
|
||||||
|
const out = {};
|
||||||
|
for (const [hanja, val] of Object.entries(scores)) {
|
||||||
|
const id = HANJA_TO_ID[hanja];
|
||||||
|
if (id) out[id] = val;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillarStemColor(saju, pillarKey) {
|
||||||
|
const stem = saju?.[pillarKey]?.stem;
|
||||||
|
// 천간 → 오행 매핑 (간략 — 핵심 색만)
|
||||||
|
const STEM_EL = { '甲':'wood','乙':'wood','丙':'fire','丁':'fire','戊':'earth','己':'earth','庚':'metal','辛':'metal','壬':'water','癸':'water' };
|
||||||
|
return ID_TO_COLOR[STEM_EL[stem]] || '#1F2A44';
|
||||||
|
}
|
||||||
|
|
||||||
|
function pillarBranchColor(saju, pillarKey) {
|
||||||
|
const branch = saju?.[pillarKey]?.branch;
|
||||||
|
const BRANCH_EL = { '子':'water','丑':'earth','寅':'wood','卯':'wood','辰':'earth','巳':'fire','午':'fire','未':'earth','申':'metal','酉':'metal','戌':'earth','亥':'water' };
|
||||||
|
return ID_TO_COLOR[BRANCH_EL[branch]] || '#1F2A44';
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS = [
|
||||||
|
['basic', '기본정보'],
|
||||||
|
['chart', '사주명식'],
|
||||||
|
['flow', '운세흐름'],
|
||||||
|
['traits', '성향분석'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SajuMobile({ reading }) {
|
||||||
|
const [tab, setTab] = useState('basic');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const elementsObj = elementsByEngId(reading?.analysis_data?.element_scores);
|
||||||
|
const traits = deriveTraits(elementsObj, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page paper-bg screen-in">
|
||||||
|
<TopRibbon color="#6A4C7C" opacity={0.6} />
|
||||||
|
<div style={{ padding: '8px 24px 0', textAlign: 'center' }}>
|
||||||
|
<TitleBlock gold="#6A4C7C" title="사주풀이"
|
||||||
|
subtitle="당신의 사주를 자세히 풀이해드립니다." />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||||
|
<MascotBubble tone="purple"
|
||||||
|
text={'당신이 가진 타고난\n기운과 운명의 흐름을\n알려드릴게요.'}
|
||||||
|
style={{ flex: 1, marginBottom: 8 }} />
|
||||||
|
<Mascot variant="full" size={130} style={{ marginRight: -8 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 16px 0' }}>
|
||||||
|
<div className="no-scrollbar" style={{
|
||||||
|
display: 'flex', gap: 6, overflowX: 'auto',
|
||||||
|
background: 'rgba(247,242,232,0.7)', borderRadius: 999,
|
||||||
|
padding: 4, border: '1px solid rgba(31,42,68,0.08)',
|
||||||
|
}}>
|
||||||
|
{TABS.map(([id, label]) => {
|
||||||
|
const active = tab === id;
|
||||||
|
return (
|
||||||
|
<button key={id} onClick={() => setTab(id)} style={{
|
||||||
|
flex: 1, padding: '10px 8px', borderRadius: 999, border: 'none',
|
||||||
|
background: active ? '#1F2A44' : 'transparent',
|
||||||
|
color: active ? '#F7F2E8' : '#6B6B6B',
|
||||||
|
fontSize: 12, fontWeight: 700, letterSpacing: '-0.02em', whiteSpace: 'nowrap',
|
||||||
|
boxShadow: active ? '0 2px 8px rgba(31,42,68,0.25), inset 0 1px 0 rgba(212,175,55,0.3)' : 'none',
|
||||||
|
transition: 'all .2s',
|
||||||
|
}}>{label}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '14px 20px 0' }}>
|
||||||
|
{tab === 'basic' && <BasicTab reading={reading} traits={traits} onResult={() => setTab('chart')} />}
|
||||||
|
{tab === 'chart' && <ChartTab reading={reading} elementsObj={elementsObj} />}
|
||||||
|
{tab === 'flow' && <FlowTab reading={reading} />}
|
||||||
|
{tab === 'traits' && <TraitsTab traits={traits} onToday={() => navigate(`/saju/today?rid=${reading?.id || ''}`)} />}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BasicTab({ reading, traits, onResult }) {
|
||||||
|
const r = reading || {};
|
||||||
|
const rows = [
|
||||||
|
['생년월일', `${r.birth_year}년 ${r.birth_month}월 ${r.birth_day}일 (${r.calendar_type === 'lunar' ? '음력' : '양력'})`],
|
||||||
|
['시간', r.birth_hour != null ? `${r.birth_hour}시` : '시간 미상'],
|
||||||
|
['성별', r.gender === 'female' ? '여' : '남'],
|
||||||
|
['사주', [r.saju_data?.year, r.saju_data?.month, r.saju_data?.day, r.saju_data?.hour].filter(Boolean).map((p) => `${p.stem}${p.branch}`).join(' ') || '-'],
|
||||||
|
];
|
||||||
|
const summary = reading?.interpretation_json?.summary || '풀이 결과를 준비 중입니다.';
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
background: '#FBF7EF', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)',
|
||||||
|
boxShadow: 'var(--shadow-card)', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{rows.map(([label, value], idx) => (
|
||||||
|
<div key={label} style={{
|
||||||
|
display: 'flex', alignItems: 'center', padding: '13px 16px',
|
||||||
|
borderBottom: idx === rows.length - 1 ? 'none' : '1px solid rgba(31,42,68,0.06)',
|
||||||
|
}}>
|
||||||
|
<div style={{ width: 80, fontSize: 12, color: '#6B6B6B', fontWeight: 700 }}>{label}</div>
|
||||||
|
<div style={{ flex: 1, fontSize: 13, color: '#1F2A44' }}>{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrnateFrame color="#6A4C7C" bg="#FBF7EF" radius={14} padding="20px 18px 16px" style={{ marginTop: 14 }}>
|
||||||
|
<div className="font-title" style={{
|
||||||
|
fontSize: 13, color: '#6A4C7C', textAlign: 'center', marginBottom: 6,
|
||||||
|
}}>사주 요약</div>
|
||||||
|
<div style={{
|
||||||
|
fontSize: 13, color: '#1F2A44', lineHeight: 1.75, textAlign: 'center', whiteSpace: 'pre-line',
|
||||||
|
}}>{summary}</div>
|
||||||
|
</OrnateFrame>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, background: '#FBF7EF', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)', padding: '16px 12px',
|
||||||
|
boxShadow: 'var(--shadow-card)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 6 }}>
|
||||||
|
{traits.slice(0, 5).map((t) => (<TraitChip key={t.id} {...t} />))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<PrimaryButton color="#6A4C7C" onClick={onResult}>
|
||||||
|
상세 풀이 보러가기
|
||||||
|
<IconChevron dir="right" size={14} color="#E8C76B" />
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraitChip({ ko, color }) {
|
||||||
|
// color는 'var(--el-fire)' 같은 CSS var. swatch에 직접 사용.
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, padding: '10px 4px 8px',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 42, height: 42, borderRadius: '50%',
|
||||||
|
background: 'rgba(106,76,124,0.06)',
|
||||||
|
border: `1px solid ${color}`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
color, fontSize: 18, fontWeight: 800,
|
||||||
|
}}>●</div>
|
||||||
|
<span style={{ fontSize: 11, color: '#1F2A44', fontWeight: 700, letterSpacing: '-0.02em' }}>{ko}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartTab({ reading, elementsObj }) {
|
||||||
|
const saju = reading?.saju_data || {};
|
||||||
|
const ohaengArr = ['wood', 'fire', 'earth', 'metal', 'water'].map((id) => ({
|
||||||
|
id, ko: ID_TO_KO[id], ch: ID_TO_CH[id],
|
||||||
|
value: Math.round(elementsObj?.[id] || 0),
|
||||||
|
color: ID_TO_COLOR[id],
|
||||||
|
}));
|
||||||
|
const strongest = ohaengArr.reduce((a, b) => (a.value > b.value ? a : b), { value: 0 });
|
||||||
|
const dms = reading?.analysis_data?.day_master_strength;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
background: '#FBF7EF', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||||||
|
padding: '14px 12px 12px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10, padding: '0 6px' }}>
|
||||||
|
<OrnamentBloom size={14} color="#6A4C7C" />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>사주 명식</span>
|
||||||
|
<span style={{ fontSize: 10, color: '#9A968D', marginLeft: 'auto' }}>일간 중심 해석</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 }}>
|
||||||
|
{['year', 'month', 'day', 'hour'].map((pk) => (
|
||||||
|
<PillarColumn key={pk} pillarKey={pk}
|
||||||
|
pillar={saju[pk]}
|
||||||
|
stemColor={pillarStemColor(saju, pk)}
|
||||||
|
branchColor={pillarBranchColor(saju, pk)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, background: '#FBF7EF', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||||||
|
padding: '16px 16px 14px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12 }}>
|
||||||
|
<OrnamentBloom size={14} color="#6A4C7C" />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>오행 분석</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between',
|
||||||
|
height: 110, gap: 4,
|
||||||
|
}}>
|
||||||
|
{ohaengArr.map((o) => (<OhaengBar key={o.id} {...o} />))}
|
||||||
|
</div>
|
||||||
|
{strongest.value > 0 && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 12, padding: '10px 12px',
|
||||||
|
background: hexA('#C04A4A', 0.06), borderRadius: 8,
|
||||||
|
border: `1px solid ${hexA(strongest.color, 0.25)}`,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: strongest.color, marginBottom: 4 }}>
|
||||||
|
{strongest.ko}({strongest.ch})의 기운이 강한 사주입니다.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dms && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 10, padding: '10px 12px',
|
||||||
|
background: 'rgba(106,76,124,0.06)', borderRadius: 8,
|
||||||
|
border: '1px dashed rgba(106,76,124,0.25)',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: '#6A4C7C', marginBottom: 4 }}>
|
||||||
|
일간 강도: {dms.result} · {dms.score}점
|
||||||
|
</div>
|
||||||
|
{dms.reasons && dms.reasons.length > 0 && (
|
||||||
|
<div style={{ fontSize: 11, color: '#6B6B6B', lineHeight: 1.55 }}>
|
||||||
|
{dms.reasons.join(' · ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
|
||||||
|
|
||||||
|
function PillarColumn({ pillarKey, pillar, stemColor, branchColor }) {
|
||||||
|
const isDay = pillarKey === 'day';
|
||||||
|
if (!pillar) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '8px 4px 10px', textAlign: 'center', color: '#9A968D', fontSize: 11 }}>
|
||||||
|
{PILLAR_LABELS[pillarKey]}<br />-
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
borderRadius: 10, padding: '8px 4px 10px',
|
||||||
|
background: isDay ? '#FBF7EF' : 'transparent',
|
||||||
|
border: isDay ? '1.5px solid #6A4C7C' : '1px solid rgba(31,42,68,0.06)',
|
||||||
|
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6, position: 'relative',
|
||||||
|
}}>
|
||||||
|
{isDay && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -10, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
background: '#6A4C7C', color: '#F7F2E8',
|
||||||
|
fontSize: 10, fontWeight: 700, padding: '2px 10px', borderRadius: 99,
|
||||||
|
}}>일간</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 10, color: '#6B6B6B', fontWeight: 700, marginTop: 2 }}>{PILLAR_LABELS[pillarKey]}</div>
|
||||||
|
<CharBox char={pillar.stem} sub={pillar.stem_kr} color={stemColor} />
|
||||||
|
<CharBox char={pillar.branch} sub={pillar.branch_kr} color={branchColor} />
|
||||||
|
<div style={{ width: '100%', height: 1, background: 'rgba(31,42,68,0.08)' }} />
|
||||||
|
<div style={{ fontSize: 10, color: '#6B6B6B' }}>{pillar.ten_god || '-'}</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#9A968D' }}>{pillar.fortune || ''}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CharBox({ char, sub, color }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1 }}>
|
||||||
|
<div className="font-title" style={{ fontSize: 24, color, lineHeight: 1, fontWeight: 800 }}>{char || '?'}</div>
|
||||||
|
<div style={{ fontSize: 8.5, color, opacity: 0.85, fontWeight: 700 }}>{sub || ''}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OhaengBar({ ko, ch, value, color }) {
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
|
||||||
|
<div style={{ flex: 1, width: '100%', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end' }}>
|
||||||
|
<div style={{ fontSize: 10, color, fontWeight: 700, textAlign: 'center', marginBottom: 2 }}>{value}%</div>
|
||||||
|
<div style={{
|
||||||
|
width: '70%', margin: '0 auto', height: `${value}%`, minHeight: 4,
|
||||||
|
background: color, borderRadius: '6px 6px 2px 2px',
|
||||||
|
boxShadow: `0 -2px 6px rgba(0,0,0,0.1), inset 0 1px 0 rgba(255,255,255,0.3)`,
|
||||||
|
}} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6, fontSize: 11, color: '#1F2A44', fontWeight: 700 }}>
|
||||||
|
{ko}<span style={{ fontSize: 9, color: '#9A968D', marginLeft: 2 }}>({ch})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowTab({ reading }) {
|
||||||
|
const daeun = reading?.daeun_data || [];
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const enriched = daeun.map((d) => ({
|
||||||
|
...d,
|
||||||
|
label: daeunLabel(d.age),
|
||||||
|
current: d.start_year <= currentYear && currentYear <= d.end_year,
|
||||||
|
}));
|
||||||
|
const current = enriched.find((x) => x.current);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
background: '#FBF7EF', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||||||
|
padding: '16px 14px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||||
|
<OrnamentBloom size={14} color="#6A4C7C" />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>대운 흐름</span>
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 10, color: '#9A968D' }}>10년 단위</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B6B6B', marginBottom: 12 }}>
|
||||||
|
10년 주기로 변화하는 운의 흐름을 확인하세요.
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||||
|
{enriched.map((du, i) => (<DaeunNode key={i} {...du} />))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{current && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: 14, background: '#1F2A44', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(212,175,55,0.4)',
|
||||||
|
padding: '16px 16px 18px', color: '#F7F2E8',
|
||||||
|
boxShadow: '0 8px 24px rgba(31,42,68,0.2)', position: 'relative',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -10, left: 16,
|
||||||
|
background: '#6A4C7C', color: '#F7F2E8',
|
||||||
|
fontSize: 10, fontWeight: 700, padding: '3px 10px',
|
||||||
|
borderRadius: 99, border: '1px solid rgba(212,175,55,0.5)',
|
||||||
|
}}>현재 대운 · {current.age}~{current.age + 9}세</div>
|
||||||
|
<div className="font-title" style={{ marginTop: 8, fontSize: 18, color: '#E8C76B' }}>
|
||||||
|
{current.stem}{current.branch} · {current.label}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12.5, color: '#D9D2C0', lineHeight: 1.7 }}>
|
||||||
|
{current.start_year}년 ~ {current.end_year}년 — 이 시기는 {current.label} 단계로,
|
||||||
|
10년 간의 운기 흐름을 차분히 살펴보세요.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DaeunNode({ age, stem, label, current }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, position: 'relative' }}>
|
||||||
|
{current && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: -6, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
fontSize: 8, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
|
||||||
|
padding: '1px 6px', borderRadius: 99, zIndex: 1,
|
||||||
|
}}>현재</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 9.5, color: '#9A968D', marginTop: current ? 8 : 0, fontWeight: 700 }}>{age}세</div>
|
||||||
|
<div style={{
|
||||||
|
width: 42, height: 50, borderRadius: '50% 50% 40% 40%',
|
||||||
|
background: current ? '#6A4C7C' : '#FBF7EF',
|
||||||
|
border: current ? '1.5px solid #D4AF37' : '1px solid rgba(31,42,68,0.12)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
boxShadow: current ? '0 4px 12px rgba(106,76,124,0.4)' : 'none',
|
||||||
|
}}>
|
||||||
|
<span className="font-title" style={{ fontSize: 20, color: current ? '#E8C76B' : '#1F2A44' }}>{stem}</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 10, color: current ? '#6A4C7C' : '#6B6B6B', fontWeight: current ? 700 : 500 }}>{label}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TraitsTab({ traits, onToday }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{
|
||||||
|
background: '#FBF7EF', borderRadius: 14,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||||||
|
padding: '16px 12px',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 12, padding: '0 6px' }}>
|
||||||
|
<OrnamentBloom size={14} color="#6A4C7C" />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>타고난 성향</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
{traits.map((t) => (<TraitChip key={t.id} {...t} />))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<PrimaryButton color="#6A4C7C" onClick={onToday}>
|
||||||
|
오늘의 운세 확인하기
|
||||||
|
<IconSparkle size={12} color="#E8C76B" />
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/pages/saju/views/today.desktop.jsx
Normal file
10
src/pages/saju/views/today.desktop.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TodayMobile from './today.mobile.jsx';
|
||||||
|
|
||||||
|
export default function TodayDesktop({ reading }) {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 720, margin: '0 auto' }}>
|
||||||
|
<TodayMobile reading={reading} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
src/pages/saju/views/today.mobile.jsx
Normal file
145
src/pages/saju/views/today.mobile.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import TopRibbon from '../_shell/TopRibbon';
|
||||||
|
import TitleBlock from '../_shell/TitleBlock';
|
||||||
|
import Mascot from '../_shell/Mascot';
|
||||||
|
import MascotBubble from '../_shell/MascotBubble';
|
||||||
|
import OrnateFrame from '../_shell/OrnateFrame';
|
||||||
|
import OrnamentBloom from '../_shell/OrnamentBloom';
|
||||||
|
import PrimaryButton from '../_shell/PrimaryButton';
|
||||||
|
import { IconChevron } from '../_shell/Icons';
|
||||||
|
|
||||||
|
const SCORE_LABELS = {
|
||||||
|
wealth: { ko: '재물운', icon: '財' },
|
||||||
|
romance: { ko: '연애운', icon: '愛' },
|
||||||
|
social: { ko: '인간관계', icon: '人' },
|
||||||
|
career: { ko: '직장운', icon: '職' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function TodayMobile({ reading }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const scores = reading?.fortune_scores || {};
|
||||||
|
const lucky = reading?.lucky || {};
|
||||||
|
const signs = lucky.good_signs || [];
|
||||||
|
const warnings = lucky.warnings || [];
|
||||||
|
const overall = Math.round(scores.overall || 0);
|
||||||
|
const todayStr = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page paper-bg screen-in">
|
||||||
|
<TopRibbon color="#D4AF37" opacity={0.7} />
|
||||||
|
<div style={{ padding: '8px 24px 0', textAlign: 'center' }}>
|
||||||
|
<TitleBlock title="오늘의 운세" gold="#D4AF37" subtitle={todayStr} />
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '14px 20px 0', display: 'flex', gap: 8, alignItems: 'flex-end' }}>
|
||||||
|
<MascotBubble tone="ivory"
|
||||||
|
text={'오늘 하루도\n좋은 흐름이 있어요.'}
|
||||||
|
style={{ flex: 1, marginBottom: 8 }} />
|
||||||
|
<Mascot variant="happy" size={130} style={{ marginRight: -8 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '20px', display: 'flex', justifyContent: 'center' }}>
|
||||||
|
<FortuneRing value={overall} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px', display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 10 }}>
|
||||||
|
{Object.entries(SCORE_LABELS).map(([key, { ko, icon }]) => (
|
||||||
|
<ScoreCard key={key} ko={ko} icon={icon} value={Math.round(scores[key] || 0)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<OrnateFrame color="#D4AF37" bg="#FBF7EF" radius={14} padding="16px 18px" double>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||||
|
<OrnamentBloom size={14} color="#D4AF37" />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>오늘의 럭키</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
|
||||||
|
<LuckyItem label="색" value={Array.isArray(lucky.color) ? lucky.color.join(', ') : lucky.color} />
|
||||||
|
<LuckyItem label="숫자" value={lucky.number} />
|
||||||
|
<LuckyItem label="방향" value={lucky.direction} />
|
||||||
|
</div>
|
||||||
|
</OrnateFrame>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(signs.length > 0 || warnings.length > 0) && (
|
||||||
|
<div style={{ padding: '0 20px 20px', display: 'grid', gap: 12 }}>
|
||||||
|
{signs.length > 0 && <SignList title="좋은 징조" items={signs} color="#4E6B5C" />}
|
||||||
|
{warnings.length > 0 && <SignList title="주의할 점" items={warnings} color="#C04A4A" />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ padding: '0 20px 40px' }}>
|
||||||
|
<PrimaryButton color="#D4AF37" onClick={() => navigate(`/saju/result?rid=${reading?.id || ''}`)}>
|
||||||
|
내 사주 자세히 보기 <IconChevron dir="right" size={14} color="#1F2A44" />
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FortuneRing({ value }) {
|
||||||
|
const R = 60;
|
||||||
|
const C = 2 * Math.PI * R;
|
||||||
|
const offset = C - (C * value) / 100;
|
||||||
|
return (
|
||||||
|
<svg width="160" height="160" viewBox="0 0 160 160">
|
||||||
|
<circle cx="80" cy="80" r={R} stroke="#F0E9D9" strokeWidth="14" fill="none" />
|
||||||
|
<circle cx="80" cy="80" r={R} stroke="#D4AF37" strokeWidth="14" fill="none"
|
||||||
|
strokeDasharray={C} strokeDashoffset={offset} strokeLinecap="round"
|
||||||
|
transform="rotate(-90 80 80)" />
|
||||||
|
<text x="80" y="86" textAnchor="middle" className="font-title"
|
||||||
|
style={{ fontSize: 32, fill: '#1F2A44', fontWeight: 800 }}>
|
||||||
|
{value}<tspan style={{ fontSize: 14, fill: '#9A968D' }}>점</tspan>
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScoreCard({ ko, icon, value }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#FBF7EF', borderRadius: 12,
|
||||||
|
border: '1px solid rgba(31,42,68,0.10)', boxShadow: 'var(--shadow-card)',
|
||||||
|
padding: '12px 14px', display: 'flex', alignItems: 'center', gap: 10,
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
background: 'rgba(212,175,55,0.12)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 14, fontWeight: 800, color: '#B89530',
|
||||||
|
}}>{icon}</div>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B6B6B', fontWeight: 700 }}>{ko}</div>
|
||||||
|
<div className="font-title" style={{ fontSize: 20, color: '#1F2A44' }}>
|
||||||
|
{value}<span style={{ fontSize: 12, color: '#9A968D', fontWeight: 500 }}>점</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LuckyItem({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 11, color: '#6B6B6B', fontWeight: 700 }}>{label}</div>
|
||||||
|
<div className="font-title" style={{ fontSize: 18, color: '#D4AF37', marginTop: 4 }}>{value || '-'}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SignList({ title, items, color }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
background: '#FBF7EF', borderRadius: 12,
|
||||||
|
border: `1px solid ${color}40`, padding: '14px 16px',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color, marginBottom: 8 }}>{title}</div>
|
||||||
|
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'grid', gap: 6 }}>
|
||||||
|
{items.map((s, i) => (
|
||||||
|
<li key={i} style={{ fontSize: 12.5, color: '#1F2A44', lineHeight: 1.6 }}>• {s}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ const SajuResult = lazy(() => import('./pages/saju/SajuResult'));
|
|||||||
const SajuToday = lazy(() => import('./pages/saju/Today'));
|
const SajuToday = lazy(() => import('./pages/saju/Today'));
|
||||||
const Compatibility = lazy(() => import('./pages/saju/Compatibility'));
|
const Compatibility = lazy(() => import('./pages/saju/Compatibility'));
|
||||||
const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult'));
|
const CompatibilityResult = lazy(() => import('./pages/saju/CompatibilityResult'));
|
||||||
|
const SajuMe = lazy(() => import('./pages/saju/Me'));
|
||||||
|
|
||||||
export const navLinks = [
|
export const navLinks = [
|
||||||
{
|
{
|
||||||
@@ -275,4 +276,8 @@ export const appRoutes = [
|
|||||||
path: 'saju/compatibility/result',
|
path: 'saju/compatibility/result',
|
||||||
element: <CompatibilityResult />,
|
element: <CompatibilityResult />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'saju/me',
|
||||||
|
element: <SajuMe />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user