Migrate saju service UI

This commit is contained in:
2026-05-28 03:16:42 +09:00
parent 86f020182a
commit d8dcf682c4
23 changed files with 1800 additions and 170 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 KiB

View File

@@ -25,6 +25,16 @@
margin-left: var(--sidebar-w); margin-left: var(--sidebar-w);
} }
.app-shell--immersive {
height: 100vh;
overflow: hidden;
background: #F7F2E8;
}
.app-content--immersive {
margin-left: 0;
}
/* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */ /* ── Layout: Top Bar (mobile only) ──────────────────────────────────── */
.app-topbar { .app-topbar {
@@ -59,6 +69,11 @@
position: relative; position: relative;
} }
.site-main--immersive {
padding: 0;
background: #F7F2E8;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.site-main { .site-main {
padding: 16px; padding: 16px;
@@ -491,6 +506,17 @@
overflow: visible; overflow: visible;
flex: none; flex: none;
} }
.app-shell--immersive {
height: auto;
min-height: 100vh;
overflow: visible;
}
.site-main--immersive {
padding: 0;
padding-bottom: 0;
}
} }
/* ── Accessibility: Reduced Motion ──────────────────────────────────── */ /* ── Accessibility: Reduced Motion ──────────────────────────────────── */

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import Navbar from './components/Navbar'; import Navbar from './components/Navbar';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import PageHeader from './components/PageHeader'; import PageHeader from './components/PageHeader';
@@ -9,19 +9,21 @@ import './App.css';
function App() { function App() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { pathname } = useLocation();
const isImmersiveRoute = pathname.startsWith('/saju');
return ( return (
<div className="app-shell"> <div className={`app-shell${isImmersiveRoute ? ' app-shell--immersive' : ''}`}>
<Navbar /> {!isImmersiveRoute && <Navbar />}
<div className="app-content"> <div className={`app-content${isImmersiveRoute ? ' app-content--immersive' : ''}`}>
<main className="site-main"> <main className={`site-main${isImmersiveRoute ? ' site-main--immersive' : ''}`}>
<PageHeader /> {!isImmersiveRoute && <PageHeader />}
<React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}> <React.Suspense fallback={<div className="suspend-loading"><Loading /></div>}>
<Outlet /> <Outlet />
</React.Suspense> </React.Suspense>
</main> </main>
</div> </div>
{isMobile && <BottomNav />} {isMobile && !isImmersiveRoute && <BottomNav />}
</div> </div>
); );
} }

View File

@@ -12,6 +12,7 @@ import MascotBubble from './_shell/MascotBubble';
import OrnateFrame from './_shell/OrnateFrame'; import OrnateFrame from './_shell/OrnateFrame';
import PrimaryButton from './_shell/PrimaryButton'; import PrimaryButton from './_shell/PrimaryButton';
import GhostButton from './_shell/GhostButton'; import GhostButton from './_shell/GhostButton';
import MatchResultDesktop from './views/match-result.desktop.jsx';
import { compatGetReading } from '../../api'; import { compatGetReading } from '../../api';
export default function CompatibilityResult() { export default function CompatibilityResult() {
@@ -35,9 +36,12 @@ export default function CompatibilityResult() {
return ( return (
<div className="saju-v2"> <div className="saju-v2">
{mode === 'desktop' && <DesktopHeader />} {mode === 'desktop' && <DesktopHeader />}
<main className="page paper-bg screen-in"> {result && mode === 'desktop' ? (
<TopRibbon color="#4E6B5C" opacity={0.6} /> <MatchResultDesktop result={result} />
<div style={{ maxWidth: mode === 'desktop' ? 720 : 'none', margin: '0 auto', padding: '24px 20px 40px' }}> ) : (
<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 && ( {!cid && (
<> <>
<TitleBlock title="궁합 결과" gold="#4E6B5C" /> <TitleBlock title="궁합 결과" gold="#4E6B5C" />
@@ -100,8 +104,9 @@ export default function CompatibilityResult() {
)} )}
</> </>
)} )}
</div> </div>
</main> </main>
)}
{mode === 'mobile' && <BottomNav theme="ivory" />} {mode === 'mobile' && <BottomNav theme="ivory" />}
</div> </div>
); );

View File

@@ -8,6 +8,10 @@ import TopRibbon from './_shell/TopRibbon';
import Mascot from './_shell/Mascot'; import Mascot from './_shell/Mascot';
import MascotBubble from './_shell/MascotBubble'; import MascotBubble from './_shell/MascotBubble';
import OrnateFrame from './_shell/OrnateFrame'; import OrnateFrame from './_shell/OrnateFrame';
import DesktopHero from './_shell/DesktopHero';
import DesktopFooter from './_shell/DesktopFooter';
import PrimaryButton from './_shell/PrimaryButton';
import { IconPaw } from './_shell/Icons';
const DISABLED_CARDS = [ const DISABLED_CARDS = [
{ title: '내 사주 이력', desc: '저장된 풀이를 한 번에' }, { title: '내 사주 이력', desc: '저장된 풀이를 한 번에' },
@@ -21,6 +25,31 @@ export default function Me() {
return ( return (
<div className="saju-v2"> <div className="saju-v2">
{mode === 'desktop' && <DesktopHeader />} {mode === 'desktop' && <DesktopHeader />}
{mode === 'desktop' ? (
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
<DesktopHero
title="상담안내"
subtitle="필요할 때 언제든 1:1 상담으로 함께 합니다."
accent="#1F2A44"
bubble={<div>걱정 마세요!<br />저와 함께 차근차근<br />풀어가요.</div>}
/>
<div style={{ maxWidth: 1000, margin: '0 auto', padding: '0 36px 32px' }}>
<div className="k-frame" style={{ padding: '42px 48px', textAlign: 'center' }}>
<div className="font-title" style={{ fontSize: 24, color: '#1F2A44', letterSpacing: '-0.03em' }}>
전문가와 1:1 맞춤 상담
</div>
<div style={{ marginTop: 12, fontSize: 15, color: '#6B6B6B', lineHeight: 1.75 }}>
사주풀이를 넘어, 인생의 결정 앞에서 길잡이가 필요하실 <br />
검증된 명리학 전문가가 30분간 깊이 있게 풀어드립니다.
</div>
<PrimaryButton color="#1F2A44" full={false} style={{ margin: '24px auto 0', borderRadius: 999 }}>
상담 신청하기 <IconPaw size={13} color="#E8C76B" />
</PrimaryButton>
</div>
</div>
<DesktopFooter />
</main>
) : (
<main className="page paper-bg screen-in"> <main className="page paper-bg screen-in">
<TopRibbon /> <TopRibbon />
<div style={{ <div style={{
@@ -45,6 +74,7 @@ export default function Me() {
</div> </div>
</div> </div>
</main> </main>
)}
{mode === 'mobile' && <BottomNav theme="ivory" />} {mode === 'mobile' && <BottomNav theme="ivory" />}
</div> </div>
); );

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useSearchParams, Link } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import './_shell/tokens.css'; import './_shell/tokens.css';
import './_shell/shell.css'; import './_shell/shell.css';
import useViewportMode from './_shell/useViewportMode'; import useViewportMode from './_shell/useViewportMode';
@@ -8,10 +8,10 @@ import BottomNav from './_shell/BottomNav';
import DesktopHeader from './_shell/DesktopHeader'; import DesktopHeader from './_shell/DesktopHeader';
import Mascot from './_shell/Mascot'; import Mascot from './_shell/Mascot';
import MascotBubble from './_shell/MascotBubble'; import MascotBubble from './_shell/MascotBubble';
import PrimaryButton from './_shell/PrimaryButton';
import GhostButton from './_shell/GhostButton'; import GhostButton from './_shell/GhostButton';
import SajuMobile from './views/saju.mobile.jsx'; import SajuMobile from './views/saju.mobile.jsx';
import SajuDesktop from './views/saju.desktop.jsx'; import SajuDesktop from './views/saju.desktop.jsx';
import sampleReading from './sampleReading';
export default function SajuResult() { export default function SajuResult() {
const mode = useViewportMode(); const mode = useViewportMode();
@@ -23,7 +23,10 @@ export default function SajuResult() {
return ( return (
<div className="saju-v2"> <div className="saju-v2">
{mode === 'desktop' && <DesktopHeader />} {mode === 'desktop' && <DesktopHeader />}
{!rid && <EmptyState />} {!rid && (mode === 'desktop'
? <SajuDesktop reading={sampleReading} />
: <SajuMobile reading={sampleReading} />
)}
{rid && loading && <LoadingState />} {rid && loading && <LoadingState />}
{rid && error && <ErrorState />} {rid && error && <ErrorState />}
{rid && data && (mode === 'desktop' {rid && data && (mode === 'desktop'
@@ -35,20 +38,6 @@ export default function SajuResult() {
); );
} }
function EmptyState() {
return (
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
<Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} />
<MascotBubble tone="ivory" tail={false}
text="사주를 먼저 입력해주세요."
style={{ margin: '0 auto 24px' }} />
<Link to="/saju" style={{ display: 'inline-block' }}>
<PrimaryButton color="#6A4C7C" full={false}>사주 입력하러 가기</PrimaryButton>
</Link>
</main>
);
}
function LoadingState() { function LoadingState() {
return ( return (
<main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}> <main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}>

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useSearchParams, Link } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import './_shell/tokens.css'; import './_shell/tokens.css';
import './_shell/shell.css'; import './_shell/shell.css';
import useViewportMode from './_shell/useViewportMode'; import useViewportMode from './_shell/useViewportMode';
@@ -8,10 +8,10 @@ import BottomNav from './_shell/BottomNav';
import DesktopHeader from './_shell/DesktopHeader'; import DesktopHeader from './_shell/DesktopHeader';
import Mascot from './_shell/Mascot'; import Mascot from './_shell/Mascot';
import MascotBubble from './_shell/MascotBubble'; import MascotBubble from './_shell/MascotBubble';
import PrimaryButton from './_shell/PrimaryButton';
import GhostButton from './_shell/GhostButton'; import GhostButton from './_shell/GhostButton';
import TodayMobile from './views/today.mobile.jsx'; import TodayMobile from './views/today.mobile.jsx';
import TodayDesktop from './views/today.desktop.jsx'; import TodayDesktop from './views/today.desktop.jsx';
import sampleReading from './sampleReading';
export default function Today() { export default function Today() {
const mode = useViewportMode(); const mode = useViewportMode();
@@ -23,7 +23,10 @@ export default function Today() {
return ( return (
<div className="saju-v2"> <div className="saju-v2">
{mode === 'desktop' && <DesktopHeader />} {mode === 'desktop' && <DesktopHeader />}
{!rid && <EmptyState />} {!rid && (mode === 'desktop'
? <TodayDesktop reading={sampleReading} />
: <TodayMobile reading={sampleReading} />
)}
{rid && loading && <LoadingState />} {rid && loading && <LoadingState />}
{rid && error && <ErrorState />} {rid && error && <ErrorState />}
{rid && data && (mode === 'desktop' {rid && data && (mode === 'desktop'
@@ -35,20 +38,6 @@ export default function Today() {
); );
} }
function EmptyState() {
return (
<main className="page paper-bg screen-in" style={{ padding: '40px 24px', textAlign: 'center' }}>
<Mascot variant="greeting" size={160} style={{ margin: '0 auto 16px' }} />
<MascotBubble tone="ivory" tail={false}
text="사주를 먼저 입력해주세요."
style={{ margin: '0 auto 24px' }} />
<Link to="/saju" style={{ display: 'inline-block' }}>
<PrimaryButton color="#D4AF37" full={false}>사주 입력하러 가기</PrimaryButton>
</Link>
</main>
);
}
function LoadingState() { function LoadingState() {
return ( return (
<main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}> <main className="page paper-bg screen-in" style={{ padding: '60px 24px', textAlign: 'center' }}>

View File

@@ -0,0 +1,13 @@
import React from 'react';
export default function BrandMark({ size = 36 }) {
return (
<svg width={size} height={size} viewBox="0 0 40 40" fill="none" aria-hidden="true">
<circle cx="20" cy="20" r="18" stroke="#B89530" strokeWidth="1.2" />
<circle cx="20" cy="20" r="14" stroke="#D4AF37" strokeWidth="1" opacity="0.7" />
<path d="M20 5v30M5 20h30M9 9l22 22M31 9 9 31" stroke="#D4AF37" strokeWidth="0.7" opacity="0.4" />
<circle cx="20" cy="20" r="4" fill="#D4AF37" />
<circle cx="20" cy="20" r="1.8" fill="#FBF7EF" />
</svg>
);
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { IconSparkle, IconSun, IconUser } from './Icons';
function ShieldIcon() {
return (
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#D4AF37" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z" />
<path d="M9 12l2 2 4-4" />
</svg>
);
}
const FOOTER_ITEMS = [
{ label: '전통 명리학 기반', desc: '깊이 있는 전통 해석', icon: <IconSun size={22} stroke="#D4AF37" /> },
{ label: 'AI 맞춤 인사이트', desc: '데이터 기반 정확도 향상', icon: <IconSparkle size={20} color="#D4AF37" /> },
{ label: '1:1 상담 연계', desc: '필요시 전문가 상담 연결', icon: <IconUser size={22} stroke="#D4AF37" /> },
{ label: '안전한 개인정보 관리', desc: '철저한 보안과 비식별 처리', icon: <ShieldIcon /> },
];
export default function DesktopFooter() {
return (
<footer style={{
marginTop: 48,
borderTop: '1px solid rgba(31,42,68,0.08)',
background: 'rgba(247,242,232,0.6)',
}}>
<div style={{
maxWidth: 1400, margin: '0 auto', padding: '24px 36px',
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 24,
}}>
{FOOTER_ITEMS.map((item) => (
<div key={item.label} style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: 'rgba(212,175,55,0.10)', border: '1px solid rgba(212,175,55,0.3)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>{item.icon}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44', letterSpacing: '-0.02em' }}>{item.label}</div>
<div style={{ fontSize: 11, color: '#6B6B6B', marginTop: 2, letterSpacing: '-0.01em' }}>{item.desc}</div>
</div>
</div>
))}
</div>
<div style={{ textAlign: 'center', padding: '14px 0 28px', fontSize: 11, color: '#9A968D', letterSpacing: '0.04em' }}>
© 2026 호령사주 · BAEKHO SAJU DOSA
</div>
</footer>
);
}

View File

@@ -1,9 +1,16 @@
import React from 'react'; import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { NAV_ITEMS } from './BottomNav'; import BrandMark from './BrandMark';
import { IconChevron } from './Icons';
const NAV_ITEMS = [
{ id: 'today', to: '/saju/today', label: '오늘의 운세' },
{ id: 'match', to: '/saju/compatibility', label: '궁합보기' },
{ id: 'saju', to: '/saju/result', label: '사주풀이' },
{ id: 'me', to: '/saju/me', label: '상담안내' },
];
function pathToCurrent(pathname) { function pathToCurrent(pathname) {
if (pathname === '/saju' || pathname === '/saju/') return 'home';
if (pathname.startsWith('/saju/today')) return 'today'; if (pathname.startsWith('/saju/today')) return 'today';
if (pathname.startsWith('/saju/compatibility')) return 'match'; if (pathname.startsWith('/saju/compatibility')) return 'match';
if (pathname.startsWith('/saju/result')) return 'saju'; if (pathname.startsWith('/saju/result')) return 'saju';
@@ -18,40 +25,70 @@ export default function DesktopHeader() {
return ( return (
<header style={{ <header style={{
position: 'sticky', top: 0, zIndex: 30, height: 64, position: 'sticky', top: 10, zIndex: 40,
background: '#FBF7EF', borderBottom: '1px solid rgba(31,42,68,0.10)', width: 'calc(100% - 72px)', maxWidth: 1368, height: 68,
display: 'flex', alignItems: 'center', padding: '0 32px', margin: '10px auto 0',
backdropFilter: 'blur(14px)', background: 'rgba(251,247,239,0.88)',
border: '1px solid rgba(31,42,68,0.13)',
borderRadius: 999,
display: 'flex', alignItems: 'center', padding: '0 22px 0 28px',
backdropFilter: 'blur(18px) saturate(150%)',
WebkitBackdropFilter: 'blur(18px) saturate(150%)',
boxShadow: '0 8px 24px rgba(31,42,68,0.06), inset 0 1px 0 rgba(255,255,255,0.75)',
}}> }}>
<button onClick={() => navigate('/saju')} style={{ <button onClick={() => navigate('/saju')} style={{
background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: 10, background: 'transparent', border: 'none', display: 'flex', alignItems: 'center', gap: 12,
padding: 0, minWidth: 220,
}}> }}>
<BrandMark size={40} />
<span className="font-title" style={{ <span className="font-title" style={{
fontSize: 26, color: '#D4AF37', lineHeight: 1, fontSize: 26, color: '#1F2A44', letterSpacing: '-0.03em', lineHeight: 1,
}}></span>
<span className="font-title" style={{
fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em',
}}>호령사주</span> }}>호령사주</span>
</button> </button>
<nav aria-label="사주 메뉴" style={{ marginLeft: 40, display: 'flex', gap: 8 }}>
<nav aria-label="사주 메뉴" style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
gap: 24, flex: 1,
}}>
{NAV_ITEMS.map((item) => { {NAV_ITEMS.map((item) => {
const active = item.id === current; const active = item.id === current;
return ( return (
<button key={item.id} onClick={() => navigate(item.to)} <button key={item.id} onClick={() => navigate(item.to)}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
style={{ style={{
background: active ? 'rgba(31,42,68,0.06)' : 'transparent', border: 'none', background: active ? 'rgba(31,42,68,0.07)' : 'transparent',
padding: '8px 14px', borderRadius: 8, border: 'none',
color: active ? item.accent : '#6B6B6B', borderRadius: active ? 18 : 0,
fontSize: 13, fontWeight: active ? 700 : 500, letterSpacing: '-0.02em', padding: active ? '11px 24px' : '11px 10px',
display: 'flex', alignItems: 'center', gap: 6, color: active ? '#1F2A44' : '#202638',
fontSize: 16,
fontWeight: active ? 800 : 700,
letterSpacing: '-0.03em',
}}> }}>
<item.Icon size={16} stroke={active ? item.accent : '#9A968D'} strokeWidth={active ? 1.8 : 1.5} />
{item.label} {item.label}
</button> </button>
); );
})} })}
</nav> </nav>
<button onClick={() => navigate('/saju')} style={{
padding: '13px 22px 13px 26px',
borderRadius: 999,
background: '#1F2A44',
color: '#F7F2E8',
border: '1px solid rgba(212,175,55,0.5)',
display: 'flex',
alignItems: 'center',
gap: 10,
fontSize: 14,
fontWeight: 800,
letterSpacing: '-0.02em',
boxShadow: '0 6px 18px rgba(31,42,68,0.18), inset 0 1px 0 rgba(212,175,55,0.3)',
whiteSpace: 'nowrap',
}}>
사주풀이 시작하기
<IconChevron dir="right" size={14} color="#E8C76B" />
</button>
</header> </header>
); );
} }

View File

@@ -0,0 +1,68 @@
import React from 'react';
import Mascot from './Mascot';
import OrnamentBloom from './OrnamentBloom';
import { IconPaw } from './Icons';
export default function DesktopHero({
title,
subtitle,
accent = '#D4AF37',
bubble,
mascotVariant = 'full',
}) {
return (
<section className="mt-wash" style={{ position: 'relative', padding: '56px 36px 64px', overflow: 'hidden' }}>
<div style={{ maxWidth: 1400, margin: '0 auto', position: 'relative', zIndex: 2 }}>
<div style={{ textAlign: 'center' }}>
<div className="bloom-row" style={{ color: accent, display: 'inline-flex', alignItems: 'center', gap: 12 }}>
<svg width="60" height="6" viewBox="0 0 60 6">
<path d="M0 3 L56 3" stroke={accent} strokeWidth="1" />
<circle cx="58" cy="3" r="2" fill={accent} />
</svg>
<OrnamentBloom size={22} color={accent} />
<svg width="60" height="6" viewBox="0 0 60 6">
<circle cx="2" cy="3" r="2" fill={accent} />
<path d="M4 3 L60 3" stroke={accent} strokeWidth="1" />
</svg>
</div>
<h1 className="font-title" style={{
margin: '14px 0 0', fontSize: 72, color: '#1F2A44',
letterSpacing: '-0.04em', lineHeight: 1,
}}>{title}</h1>
<div style={{
marginTop: 18, fontSize: 16, color: '#6B6B6B',
letterSpacing: '-0.01em',
}}>{subtitle}</div>
</div>
{bubble && (
<div style={{ position: 'absolute', right: 220, top: 36, maxWidth: 210 }}>
<div style={{
background: '#FBF7EF', border: '1px solid rgba(31,42,68,0.12)',
borderRadius: 18, padding: '15px 17px',
fontSize: 13, color: '#1F2A44', lineHeight: 1.65, letterSpacing: '-0.01em',
boxShadow: '0 4px 14px rgba(31,42,68,0.08)', position: 'relative',
}}>
{bubble}
<div style={{
position: 'absolute', right: -7, bottom: 18,
width: 14, height: 14, background: '#FBF7EF',
borderRight: '1px solid rgba(31,42,68,0.12)',
borderBottom: '1px solid rgba(31,42,68,0.12)',
transform: 'rotate(-45deg)',
}} />
<div style={{ textAlign: 'right', marginTop: 4, color: '#B89530', opacity: 0.7 }}>
<IconPaw size={11} />
</div>
</div>
</div>
)}
<Mascot variant={mascotVariant} size={220} style={{
position: 'absolute', right: -10, top: -8, width: 220, pointerEvents: 'none',
filter: 'drop-shadow(0 8px 24px rgba(31,42,68,0.18))',
}} />
</div>
</section>
);
}

View File

@@ -50,6 +50,43 @@ export function IconUser({ size = 20, stroke = 'currentColor', strokeWidth }) {
); );
} }
export function IconStar({ size = 16, filled = true, color = '#D4AF37' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24"
fill={filled ? color : 'none'} stroke={color} strokeWidth="1.4"
strokeLinejoin="round">
<path d="M12 3l2.7 5.7 6.3.9-4.6 4.4 1.1 6.2L12 17.3 6.5 20.2l1.1-6.2L3 9.6l6.3-.9z" />
</svg>
);
}
export function IconMoney({ size = 20, stroke = 'currentColor', strokeWidth }) {
return (
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
<path d="M9 5c-2 0-3 1.5-3 3l6 2c2 .7 3 1.5 3 3 0 1.8-1.5 3-4 3" />
<path d="M12 4v2M12 18v2" />
</svg>
);
}
export function IconCalendar({ size = 20, stroke = 'currentColor', strokeWidth }) {
return (
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
<rect x="4" y="6" width="16" height="14" rx="2" />
<path d="M4 10h16M9 4v4M15 4v4" />
</svg>
);
}
export function IconClock({ size = 20, stroke = 'currentColor', strokeWidth }) {
return (
<svg viewBox="0 0 24 24" {...base(size, stroke, strokeWidth)}>
<circle cx="12" cy="12" r="8.5" />
<path d="M12 7.5V12l3 2" />
</svg>
);
}
export function IconPaw({ size = 12, color = 'currentColor' }) { export function IconPaw({ size = 12, color = 'currentColor' }) {
return ( return (
<svg viewBox="0 0 24 24" width={size} height={size} fill={color}> <svg viewBox="0 0 24 24" width={size} height={size} fill={color}>

View File

@@ -2,8 +2,8 @@ import React from 'react';
const VARIANT_TO_SRC = { const VARIANT_TO_SRC = {
full: '/images/saju/horyung/horyung-main.png', full: '/images/saju/horyung/horyung-main.png',
head: '/images/saju/horyung/horyung-bust.png', head: '/images/saju/horyung/horyung-head.png',
upper: '/images/saju/horyung/horyung-front.png', upper: '/images/saju/horyung/horyung-upper.png',
greeting: '/images/saju/horyung/horyung-greeting.png', greeting: '/images/saju/horyung/horyung-greeting.png',
thinking: '/images/saju/horyung/horyung-thinking.png', thinking: '/images/saju/horyung/horyung-thinking.png',
pointing: '/images/saju/horyung/horyung-pointing.png', pointing: '/images/saju/horyung/horyung-pointing.png',

View File

@@ -0,0 +1,21 @@
import React from 'react';
import OrnamentBloom from './OrnamentBloom';
export default function PanelHeader({
title,
color = '#1F2A44',
accent = '#D4AF37',
right = null,
icon = null,
}) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
{icon || <OrnamentBloom size={20} color={accent} />}
<h3 className="font-title" style={{
margin: 0, fontSize: 18, color, letterSpacing: '-0.02em',
}}>{title}</h3>
<div style={{ flex: 1 }} />
{right}
</div>
);
}

View File

@@ -3,6 +3,8 @@
/* paper texture */ /* paper texture */
.saju-v2 .paper-bg { .saju-v2 .paper-bg {
background: background:
linear-gradient(rgba(247, 242, 232, 0.86), rgba(251, 247, 239, 0.92)),
url('/images/saju/horyung/background.png') center top / cover no-repeat,
radial-gradient(ellipse at top, rgba(212, 175, 55, 0.06), transparent 60%), 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%), radial-gradient(ellipse at bottom, rgba(106, 76, 124, 0.04), transparent 60%),
linear-gradient(180deg, var(--ivory) 0%, var(--ivory-soft) 100%); linear-gradient(180deg, var(--ivory) 0%, var(--ivory-soft) 100%);
@@ -30,6 +32,8 @@
.saju-v2 .mt-wash { .saju-v2 .mt-wash {
position: relative; position: relative;
background: background:
linear-gradient(rgba(251, 247, 239, 0.82), rgba(244, 236, 219, 0.9)),
url('/images/saju/horyung/background.png') center top / cover no-repeat,
radial-gradient(ellipse 70% 50% at 10% 80%, rgba(31, 42, 68, 0.06), transparent 65%), 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 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%), radial-gradient(ellipse 100% 60% at 50% 100%, rgba(212, 175, 55, 0.04), transparent 70%),
@@ -49,6 +53,34 @@
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>"); 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>");
} }
.saju-v2 .k-frame {
position: relative;
background: rgba(251, 247, 239, 0.9);
border: 1px solid rgba(31, 42, 68, 0.10);
border-radius: 14px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.6) inset, 0 8px 28px rgba(31, 42, 68, 0.05);
}
.saju-v2 .k-frame::before {
content: '';
position: absolute;
inset: 6px;
border: 1px solid rgba(212, 175, 55, 0.16);
border-radius: 10px;
pointer-events: none;
}
.saju-v2 .k-frame.dark {
background: #1F2A44;
border: 1px solid rgba(212, 175, 55, 0.4);
color: #F7F2E8;
box-shadow: 0 1px 0 rgba(212, 175, 55, 0.2) inset, 0 12px 40px rgba(31, 42, 68, 0.2);
}
.saju-v2 .k-frame.dark::before {
border-color: rgba(212, 175, 55, 0.25);
}
/* screen entry */ /* screen entry */
@keyframes saju-screen-in { @keyframes saju-screen-in {
from { transform: translateY(6px); opacity: 0.8; } from { transform: translateY(6px); opacity: 0.8; }
@@ -76,6 +108,6 @@
@media (min-width: 1024px) { @media (min-width: 1024px) {
.saju-v2 .page { .saju-v2 .page {
padding-bottom: 0; padding-bottom: 0;
padding-top: var(--desktop-header-h); padding-top: 0;
} }
} }

View File

@@ -0,0 +1,51 @@
const sampleReading = {
id: null,
name: '홍길동',
birth_year: 1990,
birth_month: 5,
birth_day: 20,
birth_hour: 10,
gender: 'male',
calendar_type: 'solar',
birth_place: '서울특별시',
saju_data: {
year: { stem: '己', stem_kr: '음토', branch: '巳', branch_kr: '사화', ten_god: '정인', fortune: '丙 庚 戊' },
month: { stem: '丙', stem_kr: '양화', branch: '子', branch_kr: '자수', ten_god: '편관', fortune: '壬 癸' },
day: { stem: '庚', stem_kr: '양금', branch: '申', branch_kr: '신금', ten_god: '-', fortune: '庚 壬 戊' },
hour: { stem: '辛', stem_kr: '음금', branch: '巳', branch_kr: '사화', ten_god: '겁재', fortune: '丙 庚 戊' },
},
analysis_data: {
element_scores: { '木': 20, '火': 35, '土': 25, '金': 55, '水': 30 },
day_master_strength: { result: '강함', score: 78, reasons: ['금 기운 우세', '일간 중심 안정'] },
},
fortune_scores: {
overall: 78,
wealth: 80,
romance: 70,
social: 75,
career: 82,
},
lucky: {
color: ['#1F2A44', '#E8C76B', '#6B4423', '#D89098', '#F7F2E8'],
number: 8,
direction: '동쪽',
time: '오전 10시 ~ 12시',
good_signs: ['작은 기회가 큰 흐름으로 이어질 수 있어요.'],
warnings: ['충동적인 결정은 피하고 여유를 가지세요.'],
},
daeun_data: [
{ age: 0, start_year: 1990, end_year: 1999, stem: '戊', branch: '戌' },
{ age: 10, start_year: 2000, end_year: 2009, stem: '丁', branch: '酉' },
{ age: 20, start_year: 2010, end_year: 2019, stem: '丙', branch: '申' },
{ age: 30, start_year: 2020, end_year: 2029, stem: '乙', branch: '未' },
{ age: 40, start_year: 2030, end_year: 2039, stem: '甲', branch: '午' },
{ age: 50, start_year: 2040, end_year: 2049, stem: '癸', branch: '巳' },
{ age: 60, start_year: 2050, end_year: 2059, stem: '壬', branch: '辰' },
{ age: 70, start_year: 2060, end_year: 2069, stem: '辛', branch: '卯' },
],
interpretation_json: {
summary: '당신은 강한 의지와 추진력을 가진 분입니다. 새로운 것을 두려워하지 않고 도전하는 용기가 큰 장점이며, 주변 사람에게 신뢰감을 주는 리더형의 흐름이 보입니다.',
},
};
export default sampleReading;

View File

@@ -1,24 +1,19 @@
import React from 'react'; import React from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import TitleBlock from '../_shell/TitleBlock';
import Mascot from '../_shell/Mascot'; import Mascot from '../_shell/Mascot';
import MascotBubble from '../_shell/MascotBubble';
import OrnateFrame from '../_shell/OrnateFrame'; import OrnateFrame from '../_shell/OrnateFrame';
import PanelHeader from '../_shell/PanelHeader';
import DesktopFooter from '../_shell/DesktopFooter';
import PrimaryButton from '../_shell/PrimaryButton'; import PrimaryButton from '../_shell/PrimaryButton';
import InputRow from '../_shell/InputRow'; import {
import { IconSparkle, IconChevron, IconSun, IconHeart, IconYinYang } from '../_shell/Icons'; IconChevron, IconHeart, IconMoney, IconPaw, IconSparkle, IconSun, IconUser, IconYinYang,
} from '../_shell/Icons';
import useSajuForm from '../hooks/useSajuForm'; 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 = { const inputStyle = {
flex: 1, padding: '8px 10px', border: '1px solid rgba(31,42,68,0.12)', flex: 1, padding: '8px 10px', border: '1px solid rgba(247,242,232,0.16)',
borderRadius: 8, background: '#FBF7EF', fontSize: 13, color: '#1F2A44', borderRadius: 8, background: 'rgba(247,242,232,0.08)', color: '#F7F2E8',
fontFamily: 'inherit', fontSize: 13, fontFamily: 'inherit',
}; };
function pad(n) { return String(n).padStart(2, '0'); } function pad(n) { return String(n).padStart(2, '0'); }
@@ -31,103 +26,233 @@ function timeValue(form) {
return `${pad(form.hour)}:00`; return `${pad(form.hour)}:00`;
} }
const FEATURES = [
{ to: '/saju/today', icon: IconSun, title: '오늘의 운세', desc: '오늘의 흐름과 운세를 한눈에 확인하세요.', color: '#D4AF37' },
{ to: '/saju/compatibility', icon: IconHeart, title: '궁합보기', desc: '소중한 인연과 궁합을 확인해 보세요.', color: '#D89098' },
{ to: '/saju/result', icon: IconYinYang, title: '사주풀이', desc: '내 사주의 구조와 운세를 자세히 풀이해 드립니다.', color: '#3A5A8C' },
];
export default function HomeDesktop() { export default function HomeDesktop() {
const navigate = useNavigate(); const navigate = useNavigate();
const { form, handleChange, handleSubmit, loading, error } = useSajuForm(); const { form, handleChange, handleSubmit, loading, error } = useSajuForm();
const onDate = (e) => { const onDate = (e) => {
const v = e.target.value; const value = e.target.value;
if (!v) { handleChange('year', ''); handleChange('month', ''); handleChange('day', ''); return; } if (!value) { handleChange('year', ''); handleChange('month', ''); handleChange('day', ''); return; }
const [y, m, d] = v.split('-'); const [year, month, day] = value.split('-');
handleChange('year', y); handleChange('year', year);
handleChange('month', String(parseInt(m, 10))); handleChange('month', String(parseInt(month, 10)));
handleChange('day', String(parseInt(d, 10))); handleChange('day', String(parseInt(day, 10)));
}; };
const onTime = (e) => { const onTime = (e) => {
const v = e.target.value; const value = e.target.value;
if (!v) { handleChange('hour', ''); return; } if (!value) { handleChange('hour', ''); return; }
const [h] = v.split(':'); const [hour] = value.split(':');
handleChange('hour', String(parseInt(h, 10))); handleChange('hour', String(parseInt(hour, 10)));
}; };
return ( return (
<main className="page mt-wash screen-in"> <main className="page mt-wash screen-in" style={{ marginTop: -78, paddingTop: 88 }}>
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '48px 32px' }}> <section style={{
<TitleBlock title="호령이 안내하는 사주" maxWidth: 1400, margin: '0 auto', minHeight: 540,
subtitle="오랜 명리학 지혜와 AI 인사이트로 당신만의 길을 비춥니다." /> padding: '36px 48px 0', position: 'relative', overflow: 'visible',
<div style={{ border: '1px solid rgba(31,42,68,0.10)', borderRadius: 32,
marginTop: 32, display: 'grid', gridTemplateColumns: '1fr 480px', gap: 40, alignItems: 'start', background:
}}> "linear-gradient(90deg, rgba(251,247,239,0.62) 0%, rgba(251,247,239,0.82) 52%, rgba(251,247,239,0.94) 100%), url('/images/saju/horyung/background.png') center top / cover no-repeat",
<div> boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.75)',
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16 }}> }}>
<Mascot variant="full" size={260} /> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1.15fr', gap: 34, alignItems: 'center', minHeight: 480 }}>
<MascotBubble tone="ivory" align="left" <div style={{ position: 'relative', alignSelf: 'stretch' }}>
text={'안녕하세요!\n저는 호령이에요.\n사주를 입력해 보실래요?'} <div style={{
style={{ marginBottom: 20 }} position: 'absolute', left: 0, top: 142, zIndex: 2,
/> background: 'rgba(251,247,239,0.86)', border: '1px solid rgba(31,42,68,0.12)',
</div> borderRadius: 24, padding: '18px 22px', width: 210,
<div style={{ marginTop: 32, display: 'grid', gap: 12 }}> boxShadow: '0 8px 22px rgba(31,42,68,0.08)',
{ACTIONS.map((a) => ( color: '#1F2A44', fontSize: 14, lineHeight: 1.7, letterSpacing: '-0.02em',
<button key={a.to} onClick={() => navigate(a.to)} style={{ }}>
display: 'flex', alignItems: 'center', gap: 16, 안녕하세요!<br />저는 호령이에요.<br />당신의 길을 비춰드릴게요.
background: '#FBF7EF', border: `1px solid ${a.color}40`, <div style={{ textAlign: 'right', color: '#B89530', marginTop: 4 }}><IconPaw size={12} /></div>
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>
<Mascot variant="full" size={430} style={{
position: 'absolute', left: 110, bottom: -20,
filter: 'drop-shadow(0 16px 44px rgba(31,42,68,0.18))',
}} />
</div> </div>
<OrnateFrame color="#D4AF37" bg="#FBF7EF" double radius={16} padding="24px 22px"> <div style={{ padding: '50px 0 134px' }}>
<form onSubmit={handleSubmit}> <div style={{ display: 'flex', alignItems: 'center', gap: 10, color: '#A67B3F', marginBottom: 14 }}>
<div className="font-title" style={{ <IconSparkle size={14} color="#B89530" />
fontSize: 18, color: '#1F2A44', marginBottom: 12, textAlign: 'center', <span style={{ fontSize: 15, fontWeight: 800, letterSpacing: '-0.01em' }}>전통 명리학 × AI 인사이트</span>
}}>사주 입력</div> </div>
<InputRow label="이름"> <h1 className="font-title" style={{
<input value={form.name} onChange={(e) => handleChange('name', e.target.value)} margin: 0, fontSize: 56, lineHeight: 1.18,
placeholder="홍길동" style={inputStyle} /> color: '#1F2A44', letterSpacing: '-0.055em',
</InputRow> }}>
<InputRow label="생년월일"> 호령이 반갑게<br />
맞이하는<br />
<span style={{ color: '#A67B3F', whiteSpace: 'nowrap' }}>오늘의 사주</span>
</h1>
<p style={{
margin: '22px 0 0', maxWidth: 560, fontSize: 17,
color: '#202638', lineHeight: 1.75, letterSpacing: '-0.02em',
}}>
오랜 지혜와 AI 분석으로 정확하고 깊이 있는 당신만의 운명을 안내해 드립니다.
</p>
<div style={{
marginTop: 34, display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 0,
maxWidth: 520, border: '1px solid rgba(31,42,68,0.10)',
borderRadius: 28, background: 'rgba(251,247,239,0.78)', overflow: 'hidden',
}}>
<MiniTrust icon={<IconYinYang size={22} stroke="#B89530" />} title="전통 명리학 기반" desc="정통 사주 해석" />
<MiniTrust icon={<IconSparkle size={20} color="#3A5A8C" />} title="AI 분석 인사이트" desc="정확한 인사이트" />
<MiniTrust icon={<IconUser size={22} stroke="#3A5A8C" />} title="개인정보 보호" desc="안심 서비스" />
</div>
</div>
</div>
<div style={{
position: 'absolute', left: 48, right: 48, bottom: -40,
background: '#1F2A44', borderRadius: 22, padding: '18px 22px',
border: '1px solid rgba(212,175,55,0.35)',
display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16,
boxShadow: '0 18px 36px rgba(31,42,68,0.22)',
zIndex: 3,
}}>
{FEATURES.map((feature) => (
<button key={feature.title} onClick={() => navigate(feature.to)} style={{
display: 'flex', alignItems: 'center', gap: 18, textAlign: 'left',
background: '#FBF7EF', color: '#1F2A44',
border: '1px solid rgba(212,175,55,0.42)', borderRadius: 16,
padding: '18px 20px',
}}>
<span style={{
width: 58, height: 58, borderRadius: '50%',
background: `${feature.color}26`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
border: `1px solid ${feature.color}66`,
flexShrink: 0,
}}>
<feature.icon size={30} stroke={feature.color} strokeWidth={1.6} />
</span>
<span style={{ flex: 1 }}>
<span className="font-title" style={{ display: 'block', fontSize: 24, letterSpacing: '-0.04em' }}>{feature.title}</span>
<span style={{ display: 'block', marginTop: 5, fontSize: 13, lineHeight: 1.5, color: '#3E4456', letterSpacing: '-0.02em' }}>{feature.desc}</span>
</span>
<IconChevron dir="right" size={16} color="#B89530" />
</button>
))}
</div>
</section>
<section style={{
maxWidth: 1160, margin: '88px auto 0', padding: '0 24px',
display: 'grid', gridTemplateColumns: '1.15fr 0.85fr', gap: 24,
}}>
<OrnateFrame color="#D4AF37" bg="rgba(251,247,239,0.86)" radius={18} padding="22px 22px" double>
<PanelHeader title="오늘의 운세 한눈에 보기" />
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 20, alignItems: 'center' }}>
<div style={{ textAlign: 'center', borderRight: '1px solid rgba(31,42,68,0.08)' }}>
<div className="font-title" style={{ fontSize: 58, color: '#1F2A44', lineHeight: 1 }}>78</div>
<div style={{ fontSize: 18, color: '#1F2A44' }}>/100</div>
<div style={{ marginTop: 8, fontSize: 14, fontWeight: 700, color: '#1F2A44' }}>종합운</div>
</div>
<div>
<div className="font-title" style={{ fontSize: 22, color: '#B89530', letterSpacing: '-0.03em' }}>
새로운 기회가 찾아오는 날입니다.
</div>
<p style={{ margin: '8px 0 14px', color: '#3E4456', fontSize: 13, lineHeight: 1.7 }}>
작은 실천이 변화를 만듭니다. 주변의 조언에 기울여 보세요.
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
<ScorePill icon={<IconMoney size={15} stroke="#D4AF37" />} label="재물운" value="80" />
<ScorePill icon={<IconHeart size={15} stroke="#D89098" />} label="연애운" value="70" />
<ScorePill icon={<IconSun size={15} stroke="#4E6B5C" />} label="건강운" value="75" />
<ScorePill icon={<IconUser size={15} stroke="#3A5A8C" />} label="직장운" value="82" />
</div>
</div>
</div>
</OrnateFrame>
<OrnateFrame color="#D4AF37" bg="#1F2A44" radius={18} padding="22px 28px" double>
<form onSubmit={handleSubmit} style={{ color: '#F7F2E8' }}>
<div className="font-title" style={{ fontSize: 22, color: '#E8C76B', textAlign: 'center', letterSpacing: '-0.03em' }}>
사주풀이를 시작해 보세요
</div>
<div style={{ marginTop: 6, color: '#D9D2C0', fontSize: 13, textAlign: 'center' }}>
정확한 사주 분석을 위해 생년월일시를 입력해 주세요.
</div>
<div style={{ marginTop: 16, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 10px' }}>
<DarkInputRow label="이름">
<input value={form.name} onChange={(e) => handleChange('name', e.target.value)} placeholder="홍길동" style={inputStyle} />
</DarkInputRow>
<DarkInputRow label="생년월일">
<input type="date" value={dateValue(form)} onChange={onDate} style={inputStyle} /> <input type="date" value={dateValue(form)} onChange={onDate} style={inputStyle} />
</InputRow> </DarkInputRow>
<InputRow label="시간"> <DarkInputRow label="시간">
<input type="time" value={timeValue(form)} onChange={onTime} style={inputStyle} /> <input type="time" value={timeValue(form)} onChange={onTime} style={inputStyle} />
</InputRow> </DarkInputRow>
<InputRow label="성별"> <DarkInputRow label="성별">
<select value={form.gender} onChange={(e) => handleChange('gender', e.target.value)} <select value={form.gender} onChange={(e) => handleChange('gender', e.target.value)} style={inputStyle}>
style={inputStyle}>
<option value="male"></option> <option value="male"></option>
<option value="female"></option> <option value="female"></option>
</select> </select>
</InputRow> </DarkInputRow>
<InputRow label="달력"> </div>
<select value={form.calendar_type} <DarkInputRow label="달력">
onChange={(e) => handleChange('calendar_type', e.target.value)} style={inputStyle}> <select value={form.calendar_type} onChange={(e) => handleChange('calendar_type', e.target.value)} style={inputStyle}>
<option value="solar">양력</option> <option value="solar">양력</option>
<option value="lunar">음력</option> <option value="lunar">음력</option>
</select> </select>
</InputRow> </DarkInputRow>
{error && ( {error && <div style={{ marginTop: 10, fontSize: 12, color: '#F2C7CD', textAlign: 'center' }}>{error}</div>}
<div style={{ padding: '10px 14px', color: '#C04A4A', fontSize: 12 }}>{error}</div> <div style={{ marginTop: 14 }}>
)} <PrimaryButton color="#D9AD61" type="submit" style={{ color: '#1F2A44' }}>
<div style={{ padding: '14px 14px 6px' }}> {loading ? '호령이 풀이 중...' : '사주풀이 시작하기'}
<PrimaryButton color="#6A4C7C" type="submit"> {!loading && <IconPaw size={14} color="#1F2A44" />}
{loading ? '호령이 풀이 중...' : '내 사주 보기'} </PrimaryButton>
{!loading && <IconSparkle size={12} color="#E8C76B" />} </div>
</PrimaryButton> </form>
</div> </OrnateFrame>
</form> </section>
</OrnateFrame>
</div> <DesktopFooter />
</div>
</main> </main>
); );
} }
function MiniTrust({ icon, title, desc }) {
return (
<div style={{ padding: '14px 18px', display: 'flex', alignItems: 'center', gap: 12, borderRight: '1px solid rgba(31,42,68,0.08)' }}>
<span style={{ width: 38, height: 38, borderRadius: '50%', background: 'rgba(31,42,68,0.05)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>{icon}</span>
<span>
<span style={{ display: 'block', fontSize: 12, color: '#1F2A44', fontWeight: 800, whiteSpace: 'nowrap' }}>{title}</span>
<span style={{ display: 'block', marginTop: 2, fontSize: 11, color: '#6B6B6B', whiteSpace: 'nowrap' }}>{desc}</span>
</span>
</div>
);
}
function ScorePill({ icon, label, value }) {
return (
<div style={{
border: '1px solid rgba(31,42,68,0.10)', borderRadius: 12,
background: 'rgba(251,247,239,0.82)', padding: '9px 10px',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{icon}
<span style={{ fontSize: 12, color: '#1F2A44', fontWeight: 700 }}>{label}</span>
<span className="font-title" style={{ marginLeft: 'auto', color: '#1F2A44', fontSize: 17 }}>{value}</span>
</div>
);
}
function DarkInputRow({ label, children }) {
return (
<label style={{ display: 'grid', gap: 5 }}>
<span style={{ fontSize: 11, color: '#D9D2C0', fontWeight: 700 }}>{label}</span>
{children}
</label>
);
}

View File

@@ -0,0 +1,232 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import DesktopHero from '../_shell/DesktopHero';
import DesktopFooter from '../_shell/DesktopFooter';
import PanelHeader from '../_shell/PanelHeader';
import {
IconChevron, IconHeart, IconPaw, IconSparkle, IconSun, IconUser,
} from '../_shell/Icons';
import hexA from '../_shell/helpers/hexA';
export default function MatchResultDesktop({ result }) {
const navigate = useNavigate();
const interp = result?.interpretation_json || {};
const score = Math.round(result?.score || interp.score || 86);
const names = {
a: result?.person_a?.name || '나',
b: result?.person_b?.name || '상대방',
};
const strengths = interp.strengths?.length ? interp.strengths : ['서로에게 긍정적인 영향을 주며 함께 목표를 이루기 좋아요.'];
const challenges = interp.challenges?.length ? interp.challenges : ['감정 표현 방식이 달라 오해가 생길 수 있으니 배려가 필요해요.'];
const summary = interp.summary || '두 분은 서로의 부족한 부분을 채워주며 함께 성장해 나갈 수 있는 좋은 인연이에요. 서로의 다름을 인정하고 존중한다면 더욱 길고 단단한 관계로 발전할 수 있습니다.';
return (
<main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
<DesktopHero
title="궁합보기"
subtitle="소중한 인연의 흐름을 살펴보세요."
accent="#4E6B5C"
bubble={<div> 분의 인연을<br />제가 살펴봤어요!<br />함께 행복한 길을 걸어가세요.</div>}
/>
<div style={{ maxWidth: 1320, margin: '0 auto', padding: '0 36px 36px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto 1fr', gap: 18, alignItems: 'center' }}>
<PersonSummary label="나" name={names.a} chipColor="#4E6B5C" chipBg="#E6EBE5" />
<div style={{
width: 70, height: 70, borderRadius: '50%',
background: 'linear-gradient(135deg, #F2C7CD, #D89098)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(216,144,152,0.38), inset 0 1px 0 rgba(255,255,255,0.5)',
}}>
<IconHeart size={34} stroke="#FFF" strokeWidth={2} />
</div>
<PersonSummary label="상대방" name={names.b} chipColor="#D89098" chipBg="#FBE8EB" />
</div>
<div style={{ marginTop: 18, display: 'grid', gridTemplateColumns: '360px 1fr', gap: 18 }}>
<div className="k-frame dark" style={{
padding: 28, textAlign: 'center', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#E8C76B' }}>
<span style={{ width: 28, height: 1, background: '#E8C76B' }} />
<span style={{ fontSize: 13, fontWeight: 800, letterSpacing: '0.12em' }}>궁합 점수</span>
<span style={{ width: 28, height: 1, background: '#E8C76B' }} />
</div>
<div className="font-title" style={{ fontSize: 82, color: '#F7F2E8', letterSpacing: '-0.05em', lineHeight: 1 }}>
{score}<span style={{ fontSize: 28, color: '#E8C76B', fontWeight: 400 }}></span>
</div>
<div style={{ fontSize: 13, color: '#D9D2C0' }}>
상위권의 좋은 궁합이에요.
</div>
<div style={{ width: '84%', height: 7, borderRadius: 999, background: 'rgba(247,242,232,0.1)', marginTop: 8, overflow: 'hidden' }}>
<div style={{ width: `${Math.min(100, score)}%`, height: '100%', background: 'linear-gradient(90deg, #B89530, #E8C76B, #D89098)', borderRadius: 999 }} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
<SubScoreCard color="#D4AF37" icon={<IconSun size={18} stroke="#D4AF37" />} label="성향 궁합" score={Math.min(100, score + 2)} desc="가치관과 성향이 조화를 이룹니다." />
<SubScoreCard color="#3A5A8C" icon={<SpeechIcon size={18} stroke="#3A5A8C" />} label="대화 궁합" score={Math.max(0, score - 4)} desc="편안한 대화를 나눌 수 있어요." />
<SubScoreCard color="#D89098" icon={<IconHeart size={18} stroke="#D89098" />} label="연애 궁합" score={Math.min(100, score + 4)} desc="설렘과 안정감을 함께 줍니다." />
<SubScoreCard color="#A67B3F" icon={<RingIcon />} label="결혼 궁합" score={Math.max(0, score - 2)} desc="함께 미래를 그리기 좋은 균형입니다." />
</div>
</div>
<div style={{ marginTop: 18, display: 'grid', gridTemplateColumns: '1fr 1.4fr 1fr', gap: 18 }}>
<div className="k-frame" style={{ padding: '22px 24px' }}>
<PanelHeader title="오행 균형" accent="#4E6B5C" />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', position: 'relative', padding: '8px 0 18px' }}>
<Circle label="나" sub="목(木)" color="#4E6B5C" />
<Circle label="상생의" sub="흐름" color="#1F2A44" center />
<Circle label="상대방" sub="화(火)" color="#D89098" />
</div>
<div style={{ fontSize: 13, color: '#6B6B6B', lineHeight: 1.7, textAlign: 'center' }}>
서로를 북돋우는 상생의 기운이 강합니다.
</div>
</div>
<div className="k-frame" style={{ padding: '22px 24px' }}>
<PanelHeader title="궁합 해석" accent="#4E6B5C" />
<div style={{ fontSize: 14, color: '#1F2A44', lineHeight: 1.85, whiteSpace: 'pre-line' }}>{summary}</div>
<div style={{
marginTop: 14, padding: '14px 16px', borderRadius: 12,
background: 'rgba(78,107,92,0.06)', border: '1px dashed rgba(78,107,92,0.3)',
fontSize: 14, color: '#4E6B5C', lineHeight: 1.65, textAlign: 'center',
}}>
서로에게 따뜻한 빛이 되어주는 인연입니다.
</div>
</div>
<div className="k-frame" style={{ padding: '22px 24px' }}>
<PanelHeader title="한눈에 보는 궁합 요약" accent="#4E6B5C" />
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<SummaryItem color="#4E6B5C" title="좋은 점" desc={strengths[0]} />
<SummaryItem color="#D89098" title="조심할 점" desc={challenges[0]} />
<SummaryItem color="#3A5A8C" title="추천 대화법" desc="감정을 솔직히 표현하고 상대의 이야기를 끝까지 들어주세요." />
</div>
</div>
</div>
<div style={{ marginTop: 20, display: 'flex', justifyContent: 'center', gap: 14 }}>
<button onClick={() => navigate('/saju/compatibility')} style={buttonGhost()}>
<IconChevron dir="left" size={13} color="#1F2A44" /> 새로운 궁합 보기
</button>
<button onClick={() => navigate('/saju')} style={buttonPrimary()}>
사주풀이 시작하기 <IconPaw size={13} color="#E8C76B" />
</button>
</div>
</div>
<DesktopFooter />
</main>
);
}
function PersonSummary({ label, name, chipColor, chipBg }) {
return (
<div className="k-frame" style={{ padding: '20px 22px', display: 'flex', alignItems: 'center', gap: 14 }}>
<span style={{
padding: '5px 16px', borderRadius: 999,
background: chipBg, color: chipColor, fontSize: 13, fontWeight: 800,
border: `1px solid ${hexA(chipColor, 0.4)}`,
}}>{label}</span>
<span style={{ fontSize: 18, fontWeight: 800, color: '#1F2A44' }}>{name}</span>
<span style={{ padding: '3px 9px', borderRadius: 8, background: 'rgba(212,175,55,0.10)', color: '#B89530', fontSize: 11, fontWeight: 800 }}>양력</span>
<div style={{ flex: 1 }} />
<div style={{
width: 48, height: 48, borderRadius: '50%',
background: chipBg, border: `1px solid ${hexA(chipColor, 0.35)}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<IconUser size={24} stroke={chipColor} />
</div>
</div>
);
}
function SubScoreCard({ color, icon, label, score, desc }) {
return (
<div className="k-frame" style={{ padding: '20px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
width: 34, height: 34, borderRadius: '50%',
background: hexA(color, 0.10), border: `1px solid ${hexA(color, 0.35)}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>{icon}</div>
<span className="font-title" style={{ fontSize: 18, color: '#1F2A44', letterSpacing: '-0.03em' }}>{label}</span>
</div>
<div style={{ height: 6, marginTop: 16, borderRadius: 999, background: 'rgba(31,42,68,0.06)', overflow: 'hidden' }}>
<div style={{ width: `${score}%`, height: '100%', background: color, borderRadius: 999 }} />
</div>
<div className="font-title" style={{ marginTop: 10, fontSize: 24, color, textAlign: 'center' }}>
{score}<span style={{ fontSize: 13, color: '#1F2A44', fontWeight: 400 }}></span>
</div>
<div style={{ marginTop: 7, fontSize: 12, color: '#6B6B6B', lineHeight: 1.55, textAlign: 'center' }}>{desc}</div>
</div>
);
}
function Circle({ label, sub, color, center }) {
return (
<div style={{
width: center ? 108 : 104, height: center ? 108 : 104, borderRadius: '50%',
background: color, color: '#F7F2E8',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexDirection: 'column',
marginLeft: center ? -20 : 0, marginRight: center ? -20 : 0, zIndex: center ? 2 : 1,
border: center ? '2px solid #D4AF37' : 'none',
boxShadow: center ? '0 10px 24px rgba(31,42,68,0.18)' : 'none',
}}>
<span className="font-title" style={{ fontSize: center ? 14 : 18, color: center ? '#E8C76B' : '#F7F2E8' }}>{label}</span>
<span style={{ fontSize: 12, opacity: 0.9 }}>{sub}</span>
</div>
);
}
function SummaryItem({ color, title, desc }) {
return (
<div style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: hexA(color, 0.12), border: `1px solid ${hexA(color, 0.35)}`,
flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
color, fontSize: 12, fontWeight: 800,
}}>{title[0]}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 800, color: '#1F2A44', marginBottom: 3 }}>{title}</div>
<div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.6 }}>{desc}</div>
</div>
</div>
);
}
function buttonPrimary() {
return {
padding: '14px 26px', borderRadius: 999, background: '#1F2A44', color: '#F7F2E8',
border: '1px solid rgba(212,175,55,0.4)', fontSize: 14, fontWeight: 800,
display: 'flex', alignItems: 'center', gap: 8,
};
}
function buttonGhost() {
return {
padding: '14px 26px', borderRadius: 999, background: '#FBF7EF', color: '#1F2A44',
border: '1px solid rgba(31,42,68,0.22)', fontSize: 14, fontWeight: 800,
display: 'flex', alignItems: 'center', gap: 8,
};
}
function SpeechIcon({ size = 16, stroke = '#3A5A8C' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 5h16v11H10l-4 4v-4H4z" />
</svg>
);
}
function RingIcon({ size = 18, stroke = '#A67B3F' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7">
<path d="M9 6l3-3 3 3" />
<circle cx="12" cy="16" r="5" />
</svg>
);
}

View File

@@ -1,10 +1,209 @@
import React from 'react'; import React from 'react';
import MatchMobile from './match.mobile.jsx'; import DesktopHero from '../_shell/DesktopHero';
import DesktopFooter from '../_shell/DesktopFooter';
import PanelHeader from '../_shell/PanelHeader';
import PrimaryButton from '../_shell/PrimaryButton';
import {
IconCalendar, IconClock, IconHeart, IconPaw, IconSparkle, IconUser,
} from '../_shell/Icons';
import hexA from '../_shell/helpers/hexA';
export default function MatchDesktop(props) { function pad(n) { return String(n).padStart(2, '0'); }
function dateValue(person) {
if (!person.year || !person.month || !person.day) return '';
return `${person.year}-${pad(person.month)}-${pad(person.day)}`;
}
function timeValue(person) {
if (person.hour === '' || person.hour == null) return '';
return `${pad(person.hour)}:00`;
}
function onDate(person, onChange, event) {
const value = event.target.value;
if (!value) return onChange({ ...person, year: '', month: '', day: '' });
const [year, month, day] = value.split('-');
return onChange({ ...person, year: parseInt(year, 10), month: parseInt(month, 10), day: parseInt(day, 10) });
}
function onTime(person, onChange, event) {
const value = event.target.value;
if (!value) return onChange({ ...person, hour: null });
const [hour] = value.split(':');
return onChange({ ...person, hour: parseInt(hour, 10) });
}
export default function MatchDesktop({
personA, personB, onChangeA, onChangeB, onSubmit, loading, error,
}) {
return ( return (
<div style={{ maxWidth: 900, margin: '0 auto' }}> <main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
<MatchMobile {...props} /> <DesktopHero
title="궁합보기"
subtitle="소중한 인연의 흐름을 살펴보세요."
accent="#4E6B5C"
bubble={<div> 분의 인연을<br />제가 살펴봐드릴게요!<br />함께 행복한 길을 걸어가시길 바라요.</div>}
/>
<form onSubmit={onSubmit} style={{ maxWidth: 1320, margin: '0 auto', padding: '0 36px 36px' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 72px 1fr', gap: 18, alignItems: 'center' }}>
<PersonCard
label="나"
chipColor="#4E6B5C"
chipBg="#E6EBE5"
avatarBg="#E7ECF3"
person={personA}
onChange={onChangeA}
/>
<div style={{
width: 68, height: 68, borderRadius: '50%',
background: 'linear-gradient(135deg, #F2C7CD, #D89098)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: '0 8px 24px rgba(216,144,152,0.38), inset 0 1px 0 rgba(255,255,255,0.5)',
border: '1px solid rgba(255,255,255,0.5)',
}}>
<IconHeart size={34} stroke="#FFF" strokeWidth={2} />
</div>
<PersonCard
label="상대방"
chipColor="#D89098"
chipBg="#FBE8EB"
avatarBg="#FBE8EB"
person={personB}
onChange={onChangeB}
/>
</div>
{error && (
<div style={{ marginTop: 16, textAlign: 'center', color: '#C04A4A', fontSize: 13, fontWeight: 700 }}>
{error}
</div>
)}
<div className="k-frame" style={{ marginTop: 18, padding: '22px 28px', display: 'grid', gridTemplateColumns: '1fr auto', gap: 24, alignItems: 'center' }}>
<div>
<PanelHeader title="궁합 분석 준비" accent="#4E6B5C" />
<div style={{ marginTop: -8, fontSize: 13, color: '#6B6B6B', lineHeight: 1.7 }}>
사람의 사주를 바탕으로 성향, 대화, 연애, 결혼 가능성까지 다양한 측면에서 조화와 흐름을 분석합니다.
</div>
</div>
<PrimaryButton color="#1F2A44" type="submit" full={false} style={{ borderRadius: 999, minWidth: 220 }}>
{loading ? '호령이 비교 중...' : '궁합보기 시작'}
{!loading && <IconPaw size={13} color="#E8C76B" />}
</PrimaryButton>
</div>
</form>
<DesktopFooter />
</main>
);
}
function PersonCard({ label, chipColor, chipBg, avatarBg, person, onChange }) {
const activeGender = person.gender || 'male';
return (
<div className="k-frame" style={{ padding: '22px 24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
<span style={{
padding: '5px 16px', borderRadius: 999,
background: chipBg, color: chipColor, fontSize: 14, fontWeight: 800,
border: `1px solid ${hexA(chipColor, 0.4)}`, letterSpacing: '-0.02em',
}}>{label}</span>
<input
value={person.name || ''}
onChange={(event) => onChange({ ...person, name: event.target.value })}
placeholder="이름"
style={{
flex: 1, minWidth: 120, padding: '12px 14px', borderRadius: 10,
border: '1px solid rgba(31,42,68,0.12)', background: '#FBF7EF',
color: '#1F2A44', fontSize: 15, fontWeight: 700,
}}
/>
<span style={{
padding: '4px 10px', borderRadius: 8, background: 'rgba(212,175,55,0.10)',
color: '#B89530', fontSize: 11, fontWeight: 800,
}}>{person.calendar_type === 'lunar' ? '음력' : '양력'}</span>
<div style={{
width: 64, height: 64, borderRadius: '50%',
background: avatarBg, border: `1px solid ${hexA(chipColor, 0.28)}`,
display: 'flex', alignItems: 'center', justifyContent: 'center', color: chipColor,
flexShrink: 0,
}}>
<IconUser size={30} stroke={chipColor} />
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 170px', gap: 10 }}>
<FieldPill icon={<IconCalendar size={15} stroke="#B89530" />}>
<input type="date" value={dateValue(person)} onChange={(event) => onDate(person, onChange, event)} style={fieldInputStyle} />
</FieldPill>
<select
value={person.calendar_type}
onChange={(event) => onChange({ ...person, calendar_type: event.target.value })}
style={selectStyle}
>
<option value="solar">양력</option>
<option value="lunar">음력</option>
</select>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 140px', gap: 10, marginTop: 10 }}>
<FieldPill icon={<IconClock size={15} stroke="#B89530" />}>
<input type="time" value={timeValue(person)} onChange={(event) => onTime(person, onChange, event)} style={fieldInputStyle} />
</FieldPill>
<div style={{ display: 'flex', borderRadius: 10, overflow: 'hidden', border: '1px solid rgba(31,42,68,0.12)' }}>
{[
['male', '남'],
['female', '여'],
].map(([value, text]) => {
const active = activeGender === value;
return (
<button key={value} type="button" onClick={() => onChange({ ...person, gender: value })} style={{
flex: 1, border: 'none', padding: '11px 0',
background: active ? chipColor : '#FBF7EF',
color: active ? '#F7F2E8' : '#6B6B6B',
fontSize: 13, fontWeight: 800,
}}>{text}</button>
);
})}
</div>
</div>
<button type="button" onClick={() => onChange({ name: '', year: '', month: '', day: '', hour: null, gender: activeGender, calendar_type: 'solar' })} style={{
margin: '14px auto 0', display: 'flex', alignItems: 'center', gap: 6,
background: 'transparent', border: 'none', color: '#6B6B6B', fontSize: 12, fontWeight: 700,
}}>
<IconSparkle size={11} color="#B89530" /> 다시 입력하기
</button>
</div> </div>
); );
} }
function FieldPill({ icon, children }) {
return (
<div style={{
padding: '0 12px', borderRadius: 10,
background: '#FBF7EF', border: '1px solid rgba(31,42,68,0.12)',
display: 'flex', alignItems: 'center', gap: 8,
}}>
{icon}
{children}
</div>
);
}
const fieldInputStyle = {
flex: 1,
minWidth: 0,
border: 'none',
background: 'transparent',
color: '#1F2A44',
fontSize: 14,
fontFamily: 'inherit',
padding: '11px 0',
};
const selectStyle = {
border: '1px solid rgba(31,42,68,0.12)',
borderRadius: 10,
background: '#FBF7EF',
color: '#1F2A44',
fontSize: 14,
fontFamily: 'inherit',
padding: '0 12px',
};

View File

@@ -1,10 +1,499 @@
import React from 'react'; import React from 'react';
import SajuMobile from './saju.mobile.jsx'; import { useNavigate } from 'react-router-dom';
import DesktopHero from '../_shell/DesktopHero';
import DesktopFooter from '../_shell/DesktopFooter';
import PanelHeader from '../_shell/PanelHeader';
import OrnamentBloom from '../_shell/OrnamentBloom';
import { IconChevron, IconPaw } from '../_shell/Icons';
import deriveTraits from '../_shell/helpers/deriveTraits';
import daeunLabel from '../_shell/helpers/daeunLabel';
import hexA from '../_shell/helpers/hexA';
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',
};
const STEM_EL = { '甲': 'wood', '乙': 'wood', '丙': 'fire', '丁': 'fire', '戊': 'earth', '己': 'earth', '庚': 'metal', '辛': 'metal', '壬': 'water', '癸': 'water' };
const BRANCH_EL = { '子': 'water', '丑': 'earth', '寅': 'wood', '卯': 'wood', '辰': 'earth', '巳': 'fire', '午': 'fire', '未': 'earth', '申': 'metal', '酉': 'metal', '戌': 'earth', '亥': 'water' };
const PILLAR_LABELS = { year: '년주', month: '월주', day: '일주', hour: '시주' };
function elementsByEngId(scores = {}) {
const out = {};
for (const [key, value] of Object.entries(scores || {})) {
const id = HANJA_TO_ID[key] || key;
if (ID_TO_KO[id]) out[id] = Number(value) || 0;
}
return out;
}
function maxElement(elementsObj) {
return ['wood', 'fire', 'earth', 'metal', 'water']
.map((id) => ({ id, value: Math.round(elementsObj[id] || 0), color: ID_TO_COLOR[id] }))
.reduce((best, item) => (item.value > best.value ? item : best), { id: 'metal', value: 0, color: '#D4AF37' });
}
function normalizePillar(pillar = {}, key) {
const stem = pillar.stem || '-';
const branch = pillar.branch || '-';
return {
id: key,
label: PILLAR_LABELS[key],
cheongan: {
ch: stem,
ko: pillar.stem_kr || '',
mark: stem && STEM_EL[stem] ? `(${ID_TO_KO[STEM_EL[stem]]})` : '',
color: ID_TO_COLOR[STEM_EL[stem]] || '#1F2A44',
},
jiji: {
ch: branch,
ko: pillar.branch_kr || '',
mark: branch && BRANCH_EL[branch] ? `(${ID_TO_KO[BRANCH_EL[branch]]})` : '',
color: ID_TO_COLOR[BRANCH_EL[branch]] || '#1F2A44',
},
sipsin: pillar.ten_god || '-',
jijang: pillar.hidden_stems || pillar.fortune || '-',
};
}
function readingToDesktopData(reading) {
const saju = reading?.saju_data || {};
const elementsObj = elementsByEngId(reading?.analysis_data?.element_scores);
const strongest = maxElement(elementsObj);
const pillars = ['year', 'month', 'day', 'hour'].map((key) => normalizePillar(saju[key], key));
const daeun = (reading?.daeun_data || []).map((item) => ({
age: `${item.age}~${item.age + 9}`,
rawAge: item.age,
gan: item.stem || item.gan || '-',
label: daeunLabel(item.age),
current: item.start_year <= new Date().getFullYear() && new Date().getFullYear() <= item.end_year,
startYear: item.start_year,
endYear: item.end_year,
}));
const fallbackDaeun = [0, 10, 20, 30, 40, 50, 60, 70].map((age, index) => ({
age: `${age}~${age + 9}`,
rawAge: age,
gan: ['戊', '丁', '丙', '乙', '甲', '癸', '壬', '辛'][index],
label: daeunLabel(age),
current: age === 30,
}));
return {
name: reading?.name || '백호',
gender: reading?.gender === 'female' ? '여' : '남',
birth: `${reading?.birth_year || '1990'}${reading?.birth_month || '01'}${reading?.birth_day || '01'}${reading?.birth_hour ?? '10'}:00`,
lunar: reading?.calendar_type === 'lunar' ? '음력 입력' : '양력 입력',
birthPlace: reading?.birth_place || '서울특별시',
ilgan: pillars[2]?.cheongan || { ch: '庚', color: '#3A5A8C' },
pillars,
elementsObj,
ohaeng: ['wood', 'fire', 'earth', 'metal', 'water'].map((id) => ({
id, ko: ID_TO_KO[id], ch: ID_TO_CH[id],
value: Math.round(elementsObj[id] || ({ wood: 20, fire: 35, earth: 25, metal: 55, water: 30 }[id])),
color: ID_TO_COLOR[id],
})),
strongest,
summary: reading?.interpretation_json?.summary || '의리가 강하고 책임감이 뛰어난 흐름입니다. 목표를 정하면 끝까지 해내는 추진력과 원칙을 중시하는 태도가 장점으로 드러납니다.',
traits: deriveTraits(elementsObj, []),
daeun: daeun.length ? daeun : fallbackDaeun,
dayMasterStrength: reading?.analysis_data?.day_master_strength,
};
}
export default function SajuDesktop({ reading }) { export default function SajuDesktop({ reading }) {
const navigate = useNavigate();
const data = readingToDesktopData(reading);
return ( return (
<div style={{ maxWidth: 900, margin: '0 auto' }}> <main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 78 }}>
<SajuMobile reading={reading} /> <DesktopHero
title="사주풀이"
subtitle="당신의 사주 구조와 흐름을 깊이 있게 풀어드립니다."
accent="#D4AF37"
bubble={<div>사주의 흐름을 읽고,<br />당신의 길을 밝혀드립니다.</div>}
/>
<div style={{ maxWidth: 1400, margin: '0 auto', padding: '0 36px 32px' }}>
<BasicInfoBar data={data} onEdit={() => navigate('/saju')} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 360px', gap: 18, marginTop: 20 }}>
<SajuStructureCard data={data} />
<OhaengCard data={data} />
<HoryungInsightCard data={data} />
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 12, marginTop: 18 }}>
<TraitDeskCard color="#A67B3F" iconName="will" title="핵심 성향" body={data.summary} />
<TraitDeskCard color="#4E6B5C" iconName="adapt" title="강점" bullets={data.traits.slice(0, 4).map((t) => t.ko || t.label)} />
<TraitDeskCard color="#C04A4A" iconName="challenge" title="주의할 점" bullets={['고집이 강할 수 있음', '완벽주의 경향', '휴식이 부족해지기 쉬움']} />
<TraitDeskCard color="#3A5A8C" iconName="lead" title="직업운" body={`${ID_TO_KO[data.strongest.id]}(${ID_TO_CH[data.strongest.id]}) 기운을 중심으로 체계적이고 집중력이 필요한 분야에서 강점이 드러납니다.`} />
<TraitDeskCard color="#D89098" iconName="heart" title="연애운" body="신뢰와 안정감을 중시하며 깊이 있는 관계를 만들어갑니다. 따뜻한 표현이 관계의 열쇠입니다." />
</div>
<DaeunDeskCard data={data} />
<ConsultCTA onClick={() => navigate('/saju/me')} />
</div>
<DesktopFooter />
</main>
);
}
function BasicInfoBar({ data, onEdit }) {
return (
<div className="k-frame" style={{ padding: '18px 24px', display: 'flex', alignItems: 'center', gap: 32 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{
width: 40, height: 40, borderRadius: 10,
background: 'rgba(212,175,55,0.10)', border: '1px solid rgba(212,175,55,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#B89530',
}}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#B89530" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M8 8h8M8 12h8M8 16h5" />
</svg>
</div>
<div className="font-title" style={{ fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em' }}>기본 정보</div>
</div>
<InfoCol label="이름" value={data.name} />
<InfoCol label="성별" value={data.gender} />
<InfoCol label="양력" value={data.birth} />
<InfoCol label="음력" value={data.lunar} />
<InfoCol label="출생지" value={data.birthPlace} />
<InfoCol label="사주명리" value={<span> · 일간 <span className="font-title" style={{ color: data.ilgan.color, fontSize: 14 }}>{data.ilgan.ch}</span></span>} />
<div style={{ flex: 1 }} />
<button onClick={onEdit} style={{
padding: '8px 18px', borderRadius: 999,
background: 'transparent', border: '1px solid rgba(31,42,68,0.2)',
color: '#6B6B6B', fontSize: 12, fontWeight: 700, whiteSpace: 'nowrap',
}}>정보 수정</button>
</div>
);
}
function InfoCol({ label, value }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
<div style={{ fontSize: 10, color: '#9A968D', letterSpacing: '-0.01em', fontWeight: 700 }}>{label}</div>
<div style={{ fontSize: 13, color: '#1F2A44', whiteSpace: 'nowrap', letterSpacing: '-0.01em' }}>{value}</div>
</div>
);
}
function SajuStructureCard({ data }) {
return (
<div className="k-frame" style={{ padding: '20px 22px' }}>
<PanelHeader title="사주 구조" />
<table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: 0 }}>
<thead>
<tr>
<th style={thStyle()} />
{data.pillars.map((pillar) => (
<th key={pillar.id} style={thStyle({ active: pillar.id === 'day' })}>
{pillar.id === 'day' && (
<div style={{
fontSize: 9, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
padding: '2px 8px', borderRadius: 99, display: 'inline-block', marginBottom: 4,
}}>일간</div>
)}
<div style={{ fontSize: 12, color: '#1F2A44', fontWeight: 700 }}>{pillar.label}</div>
</th>
))}
</tr>
</thead>
<tbody>
<Row label="천간" cells={data.pillars.map((pillar) => pillar.cheongan)} day />
<Row label="지지" cells={data.pillars.map((pillar) => pillar.jiji)} day />
<RowText label="십신" cells={data.pillars.map((pillar) => pillar.sipsin)} />
<RowText label="지장간" cells={data.pillars.map((pillar) => pillar.jijang)} mono />
</tbody>
</table>
<div style={{
marginTop: 12, padding: '10px 14px',
background: 'rgba(106,76,124,0.06)', borderRadius: 8,
border: '1px dashed rgba(106,76,124,0.25)',
fontSize: 11.5, color: '#6B6B6B', lineHeight: 1.6,
}}>
일간() 중심으로 사주의 흐름과 균형을 해석합니다.
</div>
</div>
);
}
const thStyle = ({ active = false } = {}) => ({
padding: '8px 4px 12px',
textAlign: 'center',
borderBottom: '1px solid rgba(31,42,68,0.08)',
background: active ? 'rgba(106,76,124,0.06)' : 'transparent',
position: 'relative',
});
function Row({ label, cells, day }) {
return (
<tr>
<td style={{ fontSize: 11, color: '#9A968D', fontWeight: 700, padding: '14px 8px', textAlign: 'center' }}>{label}</td>
{cells.map((cell, index) => {
const isDay = index === 2 && day;
return (
<td key={`${cell.ch}-${index}`} style={{
padding: '10px 4px', textAlign: 'center',
background: isDay ? 'rgba(106,76,124,0.06)' : 'transparent',
borderTop: '1px solid rgba(31,42,68,0.04)',
}}>
<div className="font-title" style={{ fontSize: 28, color: cell.color, lineHeight: 1, letterSpacing: 0 }}>{cell.ch}</div>
<div style={{ fontSize: 9.5, color: hexA(cell.color, 0.9), fontWeight: 700, marginTop: 3, letterSpacing: '-0.02em' }}>
{cell.ko} <span style={{ color: '#9A968D', fontWeight: 500 }}>{cell.mark}</span>
</div>
</td>
);
})}
</tr>
);
}
function RowText({ label, cells, mono }) {
return (
<tr>
<td style={{ fontSize: 11, color: '#9A968D', fontWeight: 700, padding: '10px 8px', textAlign: 'center' }}>{label}</td>
{cells.map((cell, index) => (
<td key={`${cell}-${index}`} style={{
padding: '8px 4px', textAlign: 'center',
background: index === 2 ? 'rgba(106,76,124,0.06)' : 'transparent',
borderTop: '1px solid rgba(31,42,68,0.04)',
fontFamily: mono ? 'var(--font-title)' : 'inherit',
fontSize: mono ? 13 : 12,
color: '#1F2A44',
letterSpacing: mono ? '0.1em' : '-0.01em',
}}>{String(cell || '-')}</td>
))}
</tr>
);
}
function OhaengCard({ data }) {
const strongest = maxElement(data.elementsObj);
return (
<div className="k-frame" style={{ padding: '20px 22px' }}>
<PanelHeader title="오행 분석" />
<div style={{ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-around', height: 160, gap: 8, padding: '0 8px' }}>
{data.ohaeng.map((element) => {
const height = Math.min(100, Math.max(8, (element.value / 60) * 100));
return (
<div key={element.id} 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: 11, color: element.color, fontWeight: 700, textAlign: 'center', marginBottom: 4 }}>{element.value}%</div>
<div style={{
width: 28, margin: '0 auto', height: `${height}%`, minHeight: 6,
background: `linear-gradient(180deg, ${hexA(element.color, 0.85)}, ${element.color})`,
borderRadius: '6px 6px 2px 2px',
boxShadow: `0 -2px 8px ${hexA(element.color, 0.3)}, inset 0 1px 0 rgba(255,255,255,0.3)`,
}} />
</div>
<div style={{ marginTop: 8, fontSize: 12, color: '#1F2A44', fontWeight: 700, display: 'flex', alignItems: 'baseline', gap: 3 }}>
{element.ko}<span style={{ fontSize: 10, color: '#9A968D' }}>({element.ch})</span>
</div>
</div>
);
})}
</div>
<div style={{
marginTop: 16, padding: '12px 14px',
background: hexA(strongest.color, 0.08), borderRadius: 8,
border: `1px solid ${hexA(strongest.color, 0.2)}`,
}}>
<div style={{ fontSize: 13, fontWeight: 700, color: strongest.color, marginBottom: 4 }}>
{ID_TO_KO[strongest.id]}({ID_TO_CH[strongest.id]}) 기운이 강한 사주입니다.
</div>
<div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.6 }}>
강한 기운을 바탕으로 장점을 살리고 부족한 기운은 생활 습관과 관계에서 보완해 보세요.
</div>
</div>
</div>
);
}
function HoryungInsightCard({ data }) {
const strongest = maxElement(data.elementsObj);
const items = [
{ title: `일간이 ${data.ilgan.ch}이시네요.`, desc: '단단한 중심과 자기 기준을 갖고 흐름을 읽는 힘이 있습니다.' },
{ title: `${ID_TO_KO[strongest.id]}(${ID_TO_CH[strongest.id]})의 기운이 두드러져요.`, desc: '해당 기운의 장점을 생활과 일의 방향으로 살려보세요.' },
{ title: '균형을 보완하면 더욱 좋아요.', desc: '강한 기운만 밀어붙이기보다 부족한 기운을 의식하면 흐름이 부드러워집니다.' },
{ title: '지금의 선택이 미래의 나를 만듭니다.', desc: '작은 실천을 꾸준히 쌓는 시기로 삼아보세요.' },
];
return (
<div className="k-frame dark" style={{ padding: '22px 22px', display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10, color: '#E8C76B' }}>
<svg width="32" height="6" viewBox="0 0 32 6"><path d="M0 3 L28 3" stroke="#E8C76B" strokeWidth="1" /><circle cx="30" cy="3" r="1.5" fill="#E8C76B" /></svg>
<h3 className="font-title" style={{ margin: 0, fontSize: 17, color: '#E8C76B', letterSpacing: '-0.01em' }}>호령이의 해설</h3>
<svg width="32" height="6" viewBox="0 0 32 6"><circle cx="2" cy="3" r="1.5" fill="#E8C76B" /><path d="M4 3 L32 3" stroke="#E8C76B" strokeWidth="1" /></svg>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 4 }}>
{items.map((item, index) => (
<div key={item.title} style={{ display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: 'rgba(212,175,55,0.12)', border: '1px solid rgba(212,175,55,0.35)',
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
color: '#E8C76B', fontSize: 13, fontWeight: 800,
}}>{index + 1}</div>
<div>
<div style={{ fontSize: 13, fontWeight: 700, color: '#F7F2E8', marginBottom: 3 }}>{item.title}</div>
<div style={{ fontSize: 11.5, color: '#D9D2C0', lineHeight: 1.55 }}>{item.desc}</div>
</div>
</div>
))}
</div>
<div style={{
marginTop: 6, padding: '14px 16px', borderRadius: 10,
background: 'rgba(212,175,55,0.08)', border: '1px solid rgba(212,175,55,0.3)',
textAlign: 'center',
}}>
<div className="font-title" style={{ fontSize: 14, color: '#E8C76B', lineHeight: 1.5 }}>
지금의 선택이<br />미래의 나를 만듭니다.
<span style={{ marginLeft: 4, opacity: 0.7 }}><IconPaw size={11} color="#E8C76B" /></span>
</div>
</div>
</div>
);
}
function TraitDeskCard({ color, iconName, title, body, bullets }) {
return (
<div className="k-frame" style={{ padding: '18px 18px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 10 }}>
<div style={{
width: 32, height: 32, borderRadius: '50%',
background: hexA(color, 0.10), border: `1px solid ${hexA(color, 0.35)}`,
display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
}}>
<TraitIcon name={iconName} color={color} size={16} />
</div>
<div className="font-title" style={{ fontSize: 15, color: '#1F2A44', letterSpacing: '-0.02em' }}>{title}</div>
</div>
{body && <div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.65 }}>{body}</div>}
{bullets && (
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'flex', flexDirection: 'column', gap: 6 }}>
{bullets.map((item) => (
<li key={item} style={{ fontSize: 12, color: '#1F2A44', display: 'flex', gap: 6 }}>
<span style={{ color, flexShrink: 0 }}>·</span> {item}
</li>
))}
</ul>
)}
</div>
);
}
function TraitIcon({ name, color, size }) {
const common = { width: size, height: size, viewBox: '0 0 24 24', fill: 'none', stroke: color, strokeWidth: '1.7', strokeLinecap: 'round', strokeLinejoin: 'round' };
if (name === 'heart') return <svg {...common}><path d="M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.6A4 4 0 0 1 19 10c0 5.5-7 10-7 10z" /></svg>;
if (name === 'challenge') return <svg {...common}><path d="M12 3l10 17H2z" /><path d="M12 10v5M12 18v.5" /></svg>;
if (name === 'lead') return <svg {...common}><path d="M4 16c4-6 8-6 16 0" /><path d="M8 12l4-4 4 4" /></svg>;
if (name === 'adapt') return <svg {...common}><path d="M4 12c2-4 5-6 8-6s6 2 8 6c-2 4-5 6-8 6s-6-2-8-6z" /><circle cx="12" cy="12" r="2" /></svg>;
return <svg {...common}><path d="M12 3v18M5 9l7-6 7 6M6 17h12" /></svg>;
}
function DaeunDeskCard({ data }) {
const current = data.daeun.find((item) => item.current) || data.daeun[0];
return (
<div className="k-frame" style={{ padding: '22px 24px', marginTop: 18 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 14 }}>
<OrnamentBloom size={20} color="#D4AF37" />
<h3 className="font-title" style={{ margin: 0, fontSize: 18, color: '#1F2A44', letterSpacing: '-0.02em' }}>대운 흐름</h3>
<span style={{ fontSize: 12, color: '#9A968D' }}>10 단위 운의 흐름을 살펴보세요.</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1.2fr', gap: 20, alignItems: 'flex-start' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{data.daeun.map((item, index) => (
<React.Fragment key={`${item.age}-${index}`}>
<DaeunNodeDesk {...item} />
{index < data.daeun.length - 1 && (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minWidth: 12 }}>
<IconChevron dir="right" size={12} color={item.current || data.daeun[index + 1].current ? '#6A4C7C' : '#D4AF37'} />
</div>
)}
</React.Fragment>
))}
</div>
<div style={{
background: 'rgba(212,175,55,0.06)', borderRadius: 10,
border: '1px dashed rgba(212,175,55,0.4)', padding: '14px 16px',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8 }}>
<span style={{
fontSize: 10, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
padding: '2px 8px', borderRadius: 99,
}}>현재</span>
<span style={{ fontSize: 13, fontWeight: 700, color: '#1F2A44' }}>대운 해설 ({current?.age})</span>
</div>
<div style={{ fontSize: 12, color: '#6B6B6B', lineHeight: 1.7 }}>
자기 확장과 기반을 다지는 시기입니다.<br />
꾸준한 노력과 인내가 결실을 맺고, 커리어와 재정적 성장이 기대됩니다.
</div>
</div>
</div>
</div>
);
}
function DaeunNodeDesk({ age, gan, label, current }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, position: 'relative', minWidth: 64 }}>
{current && (
<div style={{
position: 'absolute', top: -8, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 700, color: '#F7F2E8', background: '#6A4C7C',
padding: '2px 8px', borderRadius: 99, zIndex: 1, whiteSpace: 'nowrap',
}}>현재</div>
)}
<div style={{ fontSize: 10, color: '#9A968D', marginTop: current ? 12 : 4, fontWeight: 700, whiteSpace: 'nowrap' }}>{age}</div>
<div style={{
width: 48, height: 58, borderRadius: '50% 50% 40% 40%',
background: current ? '#1F2A44' : '#FBF7EF',
border: current ? '2px solid #D4AF37' : '1px solid rgba(31,42,68,0.12)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
boxShadow: current ? '0 4px 14px rgba(31,42,68,0.3)' : 'none',
}}>
<span className="font-title" style={{ fontSize: 22, color: current ? '#E8C76B' : '#1F2A44' }}>{gan}</span>
</div>
<div style={{ fontSize: 11, color: current ? '#6A4C7C' : '#6B6B6B', fontWeight: current ? 700 : 500 }}>{label}</div>
</div>
);
}
function ConsultCTA({ onClick }) {
return (
<div style={{
marginTop: 18, padding: '28px 32px',
background: '#1F2A44', color: '#F7F2E8',
borderRadius: 14, border: '1px solid rgba(212,175,55,0.4)',
display: 'grid', gridTemplateColumns: '1fr auto', alignItems: 'center', gap: 24,
boxShadow: '0 12px 40px rgba(31,42,68,0.18)',
}}>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6, color: '#E8C76B' }}>
<OrnamentBloom size={16} color="#E8C76B" />
<span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.1em' }}>1:1 PERSONAL CONSULT</span>
</div>
<div className="font-title" style={{ fontSize: 22, color: '#F7F2E8', letterSpacing: '-0.02em' }}>
깊은 해석이 필요하신가요?
</div>
<div style={{ marginTop: 6, fontSize: 13, color: '#D9D2C0' }}>
개인 맞춤 상담을 통해 당신의 사주를 깊이 이해하고 명확한 방향을 찾아보세요.
</div>
</div>
<button onClick={onClick} style={{
padding: '14px 24px', borderRadius: 99,
background: '#E8C76B', color: '#1F2A44',
border: 'none', fontSize: 14, fontWeight: 800,
boxShadow: '0 6px 18px rgba(232,199,107,0.4), inset 0 1px 0 rgba(255,255,255,0.4)',
display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
}}>
1:1 상담 신청하기 <IconPaw size={14} color="#1F2A44" />
</button>
</div> </div>
); );
} }

View File

@@ -1,10 +1,245 @@
import React from 'react'; import React from 'react';
import TodayMobile from './today.mobile.jsx'; import { useNavigate } from 'react-router-dom';
import DesktopFooter from '../_shell/DesktopFooter';
import Mascot from '../_shell/Mascot';
import PanelHeader from '../_shell/PanelHeader';
import {
IconClock, IconHeart, IconMoney, IconPaw, IconSparkle, IconStar, IconSun,
} from '../_shell/Icons';
import hexA from '../_shell/helpers/hexA';
const SCORE_LABELS = [
{ key: 'wealth', label: '재물운', color: '#D4AF37', icon: IconMoney, desc: '안정적인 흐름, 수입에 긍정적인 변화가 있어요.' },
{ key: 'romance', label: '연애운', color: '#D89098', icon: IconHeart, desc: '진심이 통하는 하루, 관계가 한층 가까워져요.' },
{ key: 'social', label: '건강운', color: '#4E6B5C', icon: LeafIcon, desc: '컨디션이 무난해요. 규칙적인 관리가 필요해요.' },
{ key: 'career', label: '직장운', color: '#3A5A8C', icon: BriefcaseIcon, desc: '업무 성과가 좋아요. 기획력이 빛을 발합니다.' },
];
export default function TodayDesktop({ reading }) { export default function TodayDesktop({ reading }) {
const navigate = useNavigate();
const scores = reading?.fortune_scores || {};
const lucky = reading?.lucky || {};
const overall = Math.round(scores.overall || 78);
const today = new Date().toLocaleDateString('ko-KR', {
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
});
return ( return (
<div style={{ maxWidth: 720, margin: '0 auto' }}> <main className="page paper-bg screen-in" style={{ marginTop: -78, paddingTop: 92 }}>
<TodayMobile reading={reading} /> <div style={{ maxWidth: 1400, margin: '0 auto', padding: '0 36px 0' }}>
<div style={{ fontSize: 12, color: '#9A968D', marginBottom: 16, letterSpacing: '-0.01em' }}>
&nbsp;&nbsp; <span style={{ color: '#1F2A44', fontWeight: 700 }}>오늘의 운세</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '320px 1fr', gap: 24, alignItems: 'flex-start' }}>
<aside className="k-frame" style={{ padding: 24, textAlign: 'center', overflow: 'hidden' }}>
<div style={{
background: '#FBF7EF', border: '1px solid rgba(31,42,68,0.10)',
borderRadius: 18, padding: '14px 16px',
fontSize: 13, color: '#1F2A44', lineHeight: 1.75, letterSpacing: '-0.01em',
}}>
안녕하세요!<br />오늘의 운세를 정성껏<br />전해드릴게요.
<span style={{ marginLeft: 4, color: '#B89530', opacity: 0.7 }}><IconPaw size={11} /></span>
</div>
<Mascot variant="full" size={260} style={{ margin: '10px auto 0' }} />
<div className="k-frame dark" style={{ marginTop: 8, padding: '17px 14px', textAlign: 'center' }}>
<div style={{ fontSize: 11, color: '#E8C76B', fontWeight: 700, letterSpacing: '0.16em', marginBottom: 7 }}>오늘의 한마디</div>
<div className="font-title" style={{ fontSize: 17, color: '#F7F2E8', lineHeight: 1.65 }}>
흐름을 읽는 자가<br />기회를 얻습니다.
</div>
<div style={{ marginTop: 7, fontSize: 12, color: '#D9D2C0', letterSpacing: '-0.01em' }}>
작은 선택이 변화를 만듭니다.
</div>
</div>
</aside>
<section style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
<div className="k-frame" style={{ padding: '0', overflow: 'hidden' }}>
<div style={{
padding: '32px 40px',
background:
'linear-gradient(90deg, rgba(31,42,68,0.96) 0%, rgba(31,42,68,0.78) 34%, rgba(251,247,239,0.92) 72%), url(/images/saju/horyung/background.png) center / cover',
minHeight: 150,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div>
<h1 className="font-title" style={{ margin: 0, fontSize: 46, color: '#F7F2E8', letterSpacing: '-0.035em' }}>오늘의 운세</h1>
<div style={{ marginTop: 8, fontSize: 15, color: '#F1E8D6', letterSpacing: '-0.01em' }}>오늘의 흐름을 한눈에 확인해 보세요.</div>
</div>
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 15, color: '#1F2A44', fontWeight: 800 }}>{today}</div>
<button style={{
marginTop: 12, padding: '9px 16px', borderRadius: 999,
border: '1px solid rgba(166,123,63,0.35)', background: 'rgba(251,247,239,0.7)',
color: '#6B4423', fontSize: 12, fontWeight: 700,
}}>간지 정보 보기</button>
</div>
</div>
</div>
<div className="k-frame" style={{ padding: '24px 28px', display: 'grid', gridTemplateColumns: '320px 1fr', gap: 28, alignItems: 'center' }}>
<div style={{
textAlign: 'center', padding: '24px 0', borderRadius: 14,
background: 'rgba(212,175,55,0.06)', border: '1px dashed rgba(212,175,55,0.4)',
}}>
<div style={{ fontSize: 15, color: '#1F2A44', fontWeight: 800, marginBottom: 8 }}>오늘의 종합운</div>
<div className="font-title" style={{ fontSize: 72, color: '#1F2A44', lineHeight: 1, letterSpacing: '-0.05em' }}>
{overall}<span style={{ fontSize: 28, color: '#1F2A44', fontWeight: 400 }}>/100</span>
</div>
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'center', gap: 3 }}>
{[1, 2, 3, 4, 5].map((i) => <IconStar key={i} filled={i <= Math.round(overall / 20)} size={18} color="#D4AF37" />)}
</div>
</div>
<div>
<div className="font-title" style={{ fontSize: 26, color: '#B89530', letterSpacing: '-0.03em' }}>
새로운 기회가 찾아오는 날입니다.
</div>
<div style={{ marginTop: 12, fontSize: 14, color: '#3E4456', lineHeight: 1.8, letterSpacing: '-0.01em' }}>
작은 실천이 변화를 만듭니다. 주변의 조언에 기울여 보세요.<br />
따뜻한 한마디가 당신의 하루를 빛나게 것입니다.
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
{SCORE_LABELS.map((item) => (
<FortuneCard key={item.key} {...item} value={Math.round(scores[item.key] || (item.key === 'career' ? 82 : item.key === 'wealth' ? 80 : item.key === 'romance' ? 70 : 75))} />
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
<SmallCard color="#D4AF37" icon={IconSun} title="행운의 색" sub="오늘의 기운을 높여주는 색상">
<div style={{ display: 'flex', gap: 8, marginTop: 10 }}>
{(Array.isArray(lucky.color) ? lucky.color : ['#1F2A44', '#E8C76B', '#6B4423', '#D89098', '#F7F2E8']).map((color) => (
<div key={color} style={{ width: 26, height: 26, borderRadius: '50%', background: color, border: '1px solid rgba(31,42,68,0.15)' }} />
))}
</div>
</SmallCard>
<SmallCard color="#A67B3F" icon={IconClock} title="행운의 시간" sub="기운이 상승하는 시간대">
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 10, padding: '9px 12px', background: 'rgba(166,123,63,0.08)', borderRadius: 999, border: '1px dashed rgba(166,123,63,0.35)' }}>
<IconClock size={14} stroke="#A67B3F" />
<span style={{ fontSize: 13, color: '#1F2A44', fontWeight: 700 }}>{lucky.time || '오전 10시 ~ 12시'}</span>
</div>
</SmallCard>
<SmallCard color="#4E6B5C" icon={LeafIcon} title="오늘의 조언" sub="오늘 마음에 새기면 좋은 말">
<div style={{ marginTop: 10, fontSize: 13, color: '#1F2A44', lineHeight: 1.6 }}>
기회는 준비된 마음을<br /> 찾아옵니다.
</div>
</SmallCard>
<SmallCard color="#C04A4A" icon={WarnIcon} title="주의할 점" sub="조심하면 좋은 부분">
<div style={{ marginTop: 10, fontSize: 13, color: '#1F2A44', lineHeight: 1.6 }}>
충동적인 결정은 피하고,<br />여유를 가지세요.
</div>
</SmallCard>
</div>
<div className="k-frame" style={{
padding: '24px 30px',
display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 14, alignItems: 'center',
}}>
<div>
<div className="font-title" style={{ fontSize: 22, color: '#1F2A44', letterSpacing: '-0.03em' }}> 깊이 알고 싶으신가요?</div>
<div style={{ fontSize: 13, color: '#6B6B6B', marginTop: 4 }}>
오늘의 운세를 넘어, 당신만을 위한 정밀한 사주 분석으로 인생의 방향을 찾아드려요.
</div>
</div>
<button onClick={() => navigate(reading?.id ? `/saju/result?rid=${reading.id}` : '/saju/result')} style={buttonPrimary()}>
사주풀이 시작하기 <IconPaw size={13} color="#E8C76B" />
</button>
<button style={buttonGhost()}>
<IconSparkle size={13} color="#B89530" /> AI 맞춤 인사이트 보기
</button>
</div>
</section>
</div>
</div>
<DesktopFooter />
</main>
);
}
function FortuneCard({ label, value, icon: IconComponent, desc, color }) {
return (
<div className="k-frame" style={{ padding: '20px 20px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<div style={{
width: 42, height: 42, borderRadius: '50%',
background: hexA(color, 0.12), border: `1px solid ${hexA(color, 0.35)}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{React.createElement(IconComponent, { size: 20, stroke: color })}
</div>
<div className="font-title" style={{ fontSize: 19, color: '#1F2A44', letterSpacing: '-0.03em' }}>{label}</div>
</div>
<div className="font-title" style={{ fontSize: 30, color: '#1F2A44', lineHeight: 1 }}>
{value}<span style={{ fontSize: 16, color: '#1F2A44', fontWeight: 400 }}>/100</span>
</div>
<div style={{ marginTop: 12, fontSize: 12.5, color: '#6B6B6B', lineHeight: 1.55 }}>{desc}</div>
</div> </div>
); );
} }
function SmallCard({ color, icon: IconComponent, title, sub, children }) {
return (
<div className="k-frame" style={{ padding: '18px 18px' }}>
<PanelHeader title={title} color="#1F2A44" accent={color} icon={(
<div style={{
width: 30, height: 30, borderRadius: '50%',
background: hexA(color, 0.10), border: `1px solid ${hexA(color, 0.35)}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{React.createElement(IconComponent, { size: 15, stroke: color })}
</div>
)} />
<div style={{ marginTop: -10, fontSize: 11, color: '#9A968D' }}>{sub}</div>
{children}
</div>
);
}
function buttonPrimary() {
return {
padding: '14px 24px', borderRadius: 999, background: '#1F2A44', color: '#F7F2E8',
border: '1px solid rgba(212,175,55,0.4)', fontSize: 14, fontWeight: 800,
boxShadow: '0 4px 14px rgba(31,42,68,0.25), inset 0 1px 0 rgba(212,175,55,0.3)',
display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
};
}
function buttonGhost() {
return {
padding: '14px 24px', borderRadius: 999, background: 'transparent', color: '#1F2A44',
border: '1px solid rgba(31,42,68,0.25)', fontSize: 14, fontWeight: 800,
display: 'flex', alignItems: 'center', gap: 8, whiteSpace: 'nowrap',
};
}
function LeafIcon({ size = 16, stroke = '#4E6B5C' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20V10" />
<path d="M12 10c-4 0-7-2-8-6 5 0 8 2 8 6z" />
<path d="M12 13c4 0 7-2 8-6-5 0-8 2-8 6z" />
</svg>
);
}
function BriefcaseIcon({ size = 16, stroke = '#3A5A8C' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="7" width="18" height="13" rx="2" />
<path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 13h18" />
</svg>
);
}
function WarnIcon({ size = 14, stroke = '#C04A4A' }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={stroke} strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l10 17H2z" />
<path d="M12 10v5M12 18v.5" />
</svg>
);
}

View File

@@ -70,7 +70,7 @@ export default function TodayMobile({ reading }) {
)} )}
<div style={{ padding: '0 20px 40px' }}> <div style={{ padding: '0 20px 40px' }}>
<PrimaryButton color="#D4AF37" onClick={() => navigate(`/saju/result?rid=${reading?.id || ''}`)}> <PrimaryButton color="#D4AF37" onClick={() => navigate(reading?.id ? `/saju/result?rid=${reading.id}` : '/saju/result')}>
사주 자세히 보기 <IconChevron dir="right" size={14} color="#1F2A44" /> 사주 자세히 보기 <IconChevron dir="right" size={14} color="#1F2A44" />
</PrimaryButton> </PrimaryButton>
</div> </div>