feat(work): /work/saju + input + result — 현 /saju 컨텐츠 이동

- saju 페이지 + 입력 폼 + 결과 + AI 해석 + 사주 컴포넌트 모두 이동
- depth 변경 → 모든 import @/ 절대 경로
- 내부 Link href + router.push 새 URL로
- 카탈로그 spec(49만 코어 + 11 모듈)은 보류 — 무료 사주 분석만 마이그
- API route /api/saju/* 변경 없음

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 03:39:14 +09:00
parent f9f8882710
commit 31c376da07
7 changed files with 2015 additions and 0 deletions

View File

@@ -0,0 +1,443 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import PaymentButton from '@/app/components/PaymentButton';
interface BirthKey {
birth_year: number;
birth_month: number;
birth_day: number;
birth_hour?: number;
gender: string;
}
interface SajuAISectionProps {
hasPaid: boolean;
savedInterpretation: string | null;
sajuData: object;
daeun: object | null;
daeunList: object[];
gender: string;
birthKey: BirthKey;
currentUrl: string;
engineData?: {
interactions?: any[];
shinsal?: any[];
gongmang?: any;
hiddenStems?: any[];
};
}
// ── 섹션별 메타 (아이콘·색상) ──────────────────────────────────────────
const SECTION_META: {
icon: string;
gradient: string;
border: string;
badge: string;
badgeText: string;
}[] = [
{ icon: '🌟', gradient: 'from-violet-500 to-purple-600', border: 'border-violet-100', badge: 'bg-violet-50 border-violet-200 text-violet-700', badgeText: '기질' },
{ icon: '⚖️', gradient: 'from-emerald-500 to-teal-600', border: 'border-emerald-100', badge: 'bg-emerald-50 border-emerald-200 text-emerald-700', badgeText: '오행' },
{ icon: '🔗', gradient: 'from-blue-500 to-indigo-600', border: 'border-blue-100', badge: 'bg-blue-50 border-blue-200 text-blue-700', badgeText: '지지' },
{ icon: '✨', gradient: 'from-amber-500 to-orange-500', border: 'border-amber-100', badge: 'bg-amber-50 border-amber-200 text-amber-700', badgeText: '신살' },
{ icon: '💰', gradient: 'from-yellow-500 to-amber-600', border: 'border-yellow-100', badge: 'bg-yellow-50 border-yellow-200 text-yellow-700', badgeText: '재물' },
{ icon: '🎯', gradient: 'from-rose-500 to-pink-600', border: 'border-rose-100', badge: 'bg-rose-50 border-rose-200 text-rose-700', badgeText: '직업' },
{ icon: '💕', gradient: 'from-pink-500 to-rose-500', border: 'border-pink-100', badge: 'bg-pink-50 border-pink-200 text-pink-700', badgeText: '애정' },
{ icon: '🌿', gradient: 'from-green-500 to-emerald-600', border: 'border-green-100', badge: 'bg-green-50 border-green-200 text-green-700', badgeText: '건강' },
{ icon: '🗺️', gradient: 'from-cyan-500 to-blue-600', border: 'border-cyan-100', badge: 'bg-cyan-50 border-cyan-200 text-cyan-700', badgeText: '대운' },
{ icon: '📅', gradient: 'from-indigo-500 to-violet-600', border: 'border-indigo-100', badge: 'bg-indigo-50 border-indigo-200 text-indigo-700', badgeText: '세운' },
{ icon: '🏆', gradient: 'from-amber-400 to-yellow-500', border: 'border-amber-100', badge: 'bg-amber-50 border-amber-200 text-amber-700', badgeText: '황금기' },
{ icon: '💌', gradient: 'from-slate-600 to-slate-800', border: 'border-slate-100', badge: 'bg-slate-50 border-slate-200 text-slate-700', badgeText: '종합' },
];
// ── 마크다운 → 섹션 파싱 ──────────────────────────────────────────────
interface ParsedSection {
number: number;
title: string;
content: string;
}
function parseInterpretation(text: string): ParsedSection[] {
// "## 숫자. 제목" 패턴으로 분리
const parts = text.split(/\n(?=##\s+\d+[\.\s])/).filter(Boolean);
const sections: ParsedSection[] = [];
for (const part of parts) {
const lines = part.trim().split('\n');
const headerLine = lines[0] ?? '';
const match = headerLine.match(/^##\s+(\d+)[.\s]\s*(.+)$/);
if (match) {
sections.push({
number: parseInt(match[1], 10),
title: match[2].trim(),
content: lines.slice(1).join('\n').trim(),
});
}
}
// 파싱 실패 시 전체를 하나의 섹션으로
if (sections.length === 0 && text.trim()) {
sections.push({ number: 0, title: 'AI 해석', content: text.trim() });
}
return sections;
}
// ── 섹션 카드 컴포넌트 ────────────────────────────────────────────────
function SectionCard({ section, meta, isOpen, onToggle }: {
section: ParsedSection;
meta: typeof SECTION_META[0];
isOpen: boolean;
onToggle: () => void;
}) {
return (
<div className={`rounded-2xl border-2 ${meta.border} bg-white overflow-hidden shadow-sm transition-all`}>
{/* 헤더 */}
<button
onClick={onToggle}
className="w-full flex items-center gap-3 p-4 text-left hover:bg-slate-50 transition-colors"
>
{/* 번호 아이콘 */}
<div className={`w-10 h-10 rounded-xl bg-gradient-to-br ${meta.gradient} flex items-center justify-center text-white font-extrabold text-sm flex-shrink-0 shadow-sm`}>
{section.number > 0 ? section.number : meta.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-full border ${meta.badge}`}>
{meta.badgeText}
</span>
<h3 className="font-extrabold text-[#04102b] text-sm leading-snug">
{section.title}
</h3>
</div>
</div>
{/* 토글 화살표 */}
<svg
className={`w-4 h-4 text-slate-400 flex-shrink-0 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* 내용 (아코디언) */}
{isOpen && (
<div className="px-5 pb-5 pt-1 border-t border-slate-100">
<div className={`text-[11px] font-semibold mb-3 flex items-center gap-1.5 ${meta.badge.includes('violet') ? 'text-violet-400' : 'text-slate-400'}`}>
<span className="text-base">{meta.icon}</span>
</div>
<div className="prose prose-sm max-w-none text-slate-700 leading-relaxed">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className="text-base font-extrabold text-[#04102b] mt-4 mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-extrabold text-[#04102b] mt-3 mb-1.5">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-bold text-[#04102b] mt-2 mb-1">{children}</h3>,
p: ({ children }) => <p className="mb-3 text-sm leading-relaxed text-slate-700">{children}</p>,
strong: ({ children }) => <strong className="font-bold text-[#04102b]">{children}</strong>,
em: ({ children }) => <em className="italic text-slate-600">{children}</em>,
ul: ({ children }) => <ul className="list-disc list-inside space-y-1.5 mb-3 text-sm text-slate-700 pl-1">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal list-inside space-y-1.5 mb-3 text-sm text-slate-700 pl-1">{children}</ol>,
li: ({ children }) => <li className="leading-relaxed">{children}</li>,
blockquote: ({ children }) => (
<blockquote className="border-l-4 border-violet-300 pl-4 py-1 my-3 text-slate-600 bg-violet-50 rounded-r-lg text-sm italic">
{children}
</blockquote>
),
hr: () => <hr className="border-slate-200 my-4" />,
code: ({ children }) => (
<code className="bg-slate-100 text-violet-700 px-1.5 py-0.5 rounded text-xs font-mono">
{children}
</code>
),
}}
>
{section.content}
</ReactMarkdown>
</div>
</div>
)}
</div>
);
}
// mock 데이터 여부 감지 (저장된 해석이 예시 데이터인 경우 재생성 필요)
function isMockInterpretation(text: string | null): boolean {
if (!text) return false;
return (
text.includes('API 키 문제 또는 할당량 초과') ||
text.includes('GEMINI_API_KEY 환경변수를 설정') ||
text.includes('예시 데이터를 보여드립니다') ||
text.includes('API 설정이 필요합니다')
);
}
// ── 메인 컴포넌트 ──────────────────────────────────────────────────────
export default function SajuAISection({
hasPaid,
savedInterpretation,
sajuData,
daeun,
daeunList,
gender,
birthKey,
currentUrl,
engineData,
}: SajuAISectionProps) {
// 저장된 해석이 mock 데이터면 재생성 필요
const isMock = isMockInterpretation(savedInterpretation);
const validSaved = savedInterpretation && !isMock ? savedInterpretation : null;
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>(
validSaved ? 'done' : 'idle'
);
const [interpretation, setInterpretation] = useState(validSaved ?? '');
const [openSections, setOpenSections] = useState<Set<number>>(new Set([0]));
const called = useRef(false);
const sections = parseInterpretation(interpretation);
const toggleSection = (idx: number) => {
setOpenSections(prev => {
const next = new Set(prev);
if (next.has(idx)) next.delete(idx);
else next.add(idx);
return next;
});
};
const expandAll = () => setOpenSections(new Set(sections.map((_, i) => i)));
const collapseAll = () => setOpenSections(new Set());
// 재생성: called ref 초기화 후 다시 API 호출
const handleRegenerate = () => {
called.current = false;
setStatus('idle');
setInterpretation('');
// idle → useEffect가 다시 실행되도록 상태 전환 트리거
setTimeout(() => {
called.current = false;
setStatus('loading');
fetch('/api/saju/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
})
.then(r => r.json())
.then(data => {
if (data.interpretation && !isMockInterpretation(data.interpretation)) {
setInterpretation(data.interpretation);
setStatus('done');
setOpenSections(new Set([0]));
// DB에 실제 해석으로 덮어쓰기
const { birth_year, birth_month, birth_day } = birthKey;
if (typeof birth_year === 'number' && typeof birth_month === 'number' && typeof birth_day === 'number') {
fetch('/api/saju/save-interpretation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interpretation: data.interpretation, birthKey }),
}).catch(() => {});
}
} else {
setStatus('error');
}
})
.catch(() => setStatus('error'));
}, 0);
};
useEffect(() => {
if (!hasPaid || validSaved || called.current) return;
called.current = true;
setStatus('loading');
fetch('/api/saju/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
})
.then(r => r.json())
.then(data => {
if (data.interpretation) {
setInterpretation(data.interpretation);
setStatus('done');
// 첫 번째 섹션 자동 열기
setOpenSections(new Set([0]));
const { birth_year, birth_month, birth_day } = birthKey;
if (
typeof birth_year === 'number' && !isNaN(birth_year) &&
typeof birth_month === 'number' && !isNaN(birth_month) &&
typeof birth_day === 'number' && !isNaN(birth_day)
) {
fetch('/api/saju/save-interpretation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interpretation: data.interpretation, birthKey }),
}).catch(() => {});
}
} else {
setStatus('error');
}
})
.catch(() => setStatus('error'));
}, [hasPaid]);
// ── 미결제 ──────────────────────────────────────────────────────────
if (!hasPaid) {
return (
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] rounded-2xl border border-[#1a3a7a] p-7 text-center relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.05]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '22px 22px' }} />
<div className="relative">
<div className="inline-flex items-center gap-2 bg-amber-400/10 border border-amber-400/25 text-amber-300 text-xs font-semibold px-3 py-1 rounded-full mb-3">
AI PREMIUM
</div>
<h3 className="text-xl font-extrabold text-white mb-2">AI (12 )</h3>
<p className="text-blue-200/60 text-sm mb-6">
, , , , , <br />
Gemini 2.5 Pro가 .
</p>
{/* 미리보기 섹션 목록 */}
<div className="grid grid-cols-3 gap-2 mb-6 text-left">
{SECTION_META.map((meta, i) => (
<div key={i} className="flex items-center gap-1.5 bg-white/5 rounded-lg px-2 py-1.5">
<span className="text-sm">{meta.icon}</span>
<span className="text-xs text-blue-200/70 font-medium">{meta.badgeText}</span>
</div>
))}
</div>
<PaymentButton
productId="saju_detail"
className="inline-flex items-center gap-2 bg-amber-400 hover:bg-amber-300 text-[#04102b] font-bold px-7 py-3 rounded-xl transition-all"
>
AI 1,000
</PaymentButton>
<p className="text-blue-200/40 text-xs mt-3"> AI · </p>
</div>
</div>
);
}
// ── 로딩 ──────────────────────────────────────────────────────────
if (status === 'loading') {
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-8 text-center">
<div className="w-10 h-10 border-2 border-violet-600 border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-slate-500 text-sm font-medium">AI가 ...</p>
<p className="text-slate-400 text-xs mt-1"> 20~30 </p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
{SECTION_META.map((meta, i) => (
<span key={i} className="flex items-center gap-1 text-xs text-slate-400 animate-pulse">
<span>{meta.icon}</span>{meta.badgeText}
</span>
))}
</div>
</div>
);
}
// ── 오류 ──────────────────────────────────────────────────────────
if (status === 'error') {
return (
<div className="bg-white rounded-2xl border border-red-200 p-6 text-center">
<p className="text-red-500 text-sm font-medium mb-3">AI .</p>
<button
onClick={() => { called.current = false; setStatus('idle'); }}
className="text-xs text-blue-600 underline"
>
</button>
</div>
);
}
// ── 해석 완료 ─────────────────────────────────────────────────────
return (
<div className="bg-white rounded-2xl border border-[#dbe8ff] overflow-hidden">
{/* 헤더 */}
<div className="flex items-center gap-2 px-6 py-4 border-b border-slate-100 bg-gradient-to-r from-[#04102b] to-[#0a1f5c]">
<div className="w-7 h-7 rounded-lg bg-gradient-to-br from-violet-400 to-amber-400 flex items-center justify-center flex-shrink-0">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div className="flex-1">
<h2 className="text-sm font-extrabold text-white">AI </h2>
<p className="text-blue-300/60 text-[11px]">12 · </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRegenerate}
title="AI 해석 재생성"
className="text-[11px] text-blue-300/60 hover:text-blue-200 px-2 py-1 rounded-lg hover:bg-white/10 transition-all flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<span className="text-xs bg-emerald-400/20 border border-emerald-400/30 text-emerald-300 font-bold px-2.5 py-1 rounded-full">
</span>
</div>
</div>
{/* 섹션 컨트롤 + 목록 */}
<div className="p-5">
{/* 전체 펼치기/접기 */}
{sections.length > 1 && (
<div className="flex items-center justify-between mb-4">
<span className="text-xs text-slate-400 font-medium">
{sections.length}
</span>
<div className="flex gap-2">
<button
onClick={expandAll}
className="text-xs text-violet-600 hover:text-violet-800 font-semibold px-3 py-1 rounded-lg border border-violet-200 hover:bg-violet-50 transition-colors"
>
</button>
<button
onClick={collapseAll}
className="text-xs text-slate-500 hover:text-slate-700 font-semibold px-3 py-1 rounded-lg border border-slate-200 hover:bg-slate-50 transition-colors"
>
</button>
</div>
</div>
)}
{/* 섹션 카드 목록 */}
<div className="space-y-3">
{sections.map((section, idx) => {
const metaIdx = section.number > 0 ? Math.min(section.number - 1, SECTION_META.length - 1) : idx % SECTION_META.length;
const meta = SECTION_META[metaIdx];
return (
<SectionCard
key={idx}
section={section}
meta={meta}
isOpen={openSections.has(idx)}
onToggle={() => toggleSection(idx)}
/>
);
})}
</div>
{/* 하단 안내 */}
{sections.length > 0 && (
<p className="text-center text-xs text-slate-400 mt-5">
AI가 . .
</p>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,323 @@
'use client';
import { useMemo } from 'react';
// ── 천간 / 지지 ───────────────────────────────────────────────────────
const STEMS = ['甲','乙','丙','丁','戊','己','庚','辛','壬','癸'];
const STEMS_KR = ['갑','을','병','정','무','기','경','신','임','계'];
const BRANCHES = ['子','丑','寅','卯','辰','巳','午','未','申','酉','戌','亥'];
const BRANCHES_KR= ['자','축','인','묘','진','사','오','미','신','유','술','해'];
const STEM_ELEM: Record<string,string> = { '甲':'木','乙':'木','丙':'火','丁':'火','戊':'土','己':'土','庚':'金','辛':'金','壬':'水','癸':'水' };
const BRANCH_ELEM: Record<string,string> = { '子':'水','亥':'水','寅':'木','卯':'木','巳':'火','午':'火','申':'金','酉':'金','丑':'土','辰':'土','未':'土','戌':'土' };
// 1900-01-01 = 甲戌 (stem=0, branch=10) — CLAUDE.md 검증 완료
const BASE_MS = Date.UTC(1900, 0, 1);
function getTodayPillar() {
const now = new Date();
const todayMs = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
const diff = Math.round((todayMs - BASE_MS) / 86400000);
const si = ((0 + diff) % 10 + 10) % 10;
const bi = ((10 + diff) % 12 + 12) % 12;
return {
stem: STEMS[si], stemKr: STEMS_KR[si],
branch: BRANCHES[bi], branchKr: BRANCHES_KR[bi],
stemElem: STEM_ELEM[STEMS[si]] ?? '木',
branchElem: BRANCH_ELEM[BRANCHES[bi]] ?? '水',
year: now.getFullYear(), month: now.getMonth() + 1, date: now.getDate(),
};
}
// ── 오행 상생·상극 ────────────────────────────────────────────────────
const GENERATES: Record<string,string> = { '木':'火','火':'土','土':'金','金':'水','水':'木' };
const OVERCOMES: Record<string,string> = { '木':'土','火':'金','土':'水','金':'木','水':'火' };
type Rel = 'same'|'generates'|'generated'|'overcomes'|'overcome'|'neutral';
function getRelation(a: string, b: string): Rel {
if (a === b) return 'same';
if (GENERATES[a] === b) return 'generates';
if (GENERATES[b] === a) return 'generated';
if (OVERCOMES[a] === b) return 'overcomes';
if (OVERCOMES[b] === a) return 'overcome';
return 'neutral';
}
// ── 오늘 종합 점수 (0100) ────────────────────────────────────────────
function calcOverallScore(stemElem: string, branchElem: string, yongShin: string, heeShin: string) {
let score = 50;
const add = (rel: Rel, weight: number) => {
if (rel === 'same') score += 25 * weight;
else if (rel === 'generates' || rel === 'generated') score += 15 * weight;
else if (rel === 'overcomes') score -= 20 * weight;
else if (rel === 'overcome') score -= 8 * weight;
};
add(getRelation(stemElem, yongShin), 1);
add(getRelation(branchElem, yongShin), 0.8);
add(getRelation(stemElem, heeShin), 0.3);
add(getRelation(branchElem, heeShin), 0.2);
return Math.round(Math.max(10, Math.min(100, score)));
}
type Level = 'great'|'good'|'neutral'|'caution';
function toLevel(s: number): Level {
if (s >= 78) return 'great';
if (s >= 58) return 'good';
if (s >= 38) return 'neutral';
return 'caution';
}
// ── 결정론적 랜덤 ────────────────────────────────────────────────────
function seededRand(seed: number) {
let s = seed;
return () => { s = (s * 1664525 + 1013904223) & 0xffffffff; return (s >>> 0) / 0xffffffff; };
}
// ── 운세 항목 빌드 ────────────────────────────────────────────────────
type Area = { icon: string; label: string; score: number; desc: string };
const DESCS: Record<string, Record<Level, string>> = {
money: {
great: '재물 흐름이 활발합니다. 작은 투자나 구매 결정에 긍정적인 시기입니다.',
good: '재물 운이 순조롭습니다. 무리하지 않는 범위에서 움직이면 이익이 납니다.',
neutral: '수입·지출이 균형을 이루는 날. 큰 결정은 잠시 미루세요.',
caution: '충동 지출에 주의하세요. 중요한 금전 거래는 신중히 검토하세요.',
},
love: {
great: '감정 교류가 잘 이루어지는 날. 마음을 전하기 좋은 타이밍입니다.',
good: '관계에 따뜻한 기운이 감돕니다. 오래 연락 못 했던 사람에게 먼저 다가가 보세요.',
neutral: '평온한 관계를 유지하는 날입니다. 억지로 변화를 만들 필요 없습니다.',
caution: '오해가 생기기 쉬운 날입니다. 중요한 대화는 감정이 차분해진 후에 하세요.',
},
career: {
great: '능력이 잘 발휘되는 날. 중요한 프레젠테이션이나 면담에 최적입니다.',
good: '업무 효율이 올라가는 날입니다. 오늘 마무리한 과제는 좋은 결과로 이어집니다.',
neutral: '꾸준히 하던 일을 이어가는 날. 새 프로젝트보다 마무리에 집중하세요.',
caution: '실수가 생기기 쉬운 날입니다. 중요한 결재·계약은 하루 늦춰보세요.',
},
health: {
great: '체력·집중력 모두 좋은 날. 평소보다 활동량을 늘려도 괜찮습니다.',
good: '컨디션이 안정적입니다. 가벼운 운동으로 기운을 더 끌어올리세요.',
neutral: '무리하지 않는 것이 최선. 충분한 수분과 수면을 챙겨주세요.',
caution: '피로가 쌓이기 쉬운 날입니다. 무리한 약속은 피하고 충분히 쉬세요.',
},
social: {
great: '대인관계 운이 열린 날. 중요한 만남·협상에 유리한 시기입니다.',
good: '사교적 기운이 넘칩니다. 새 인맥을 만들거나 협업을 제안해보세요.',
neutral: '조용히 자신의 일에 집중하는 날. 복잡한 인간관계는 잠시 내려놓으세요.',
caution: '갈등이 생기기 쉬운 날입니다. 중요한 협상은 다음 기회로 미루는 것이 현명합니다.',
},
};
function buildAreas(
overall: number,
yongShin: string, heeShin: string,
yearNum: number, monthNum: number, dayNum: number,
): Area[] {
const now = new Date();
const seed = yearNum * 1_000_000 + monthNum * 10_000 + dayNum * 100 + now.getFullYear() % 100 * 10 + now.getMonth();
const rand = seededRand(seed);
const roll = () => Math.round(Math.max(15, Math.min(98, rand() * 40 + overall - 20)));
const keys = ['money','love','career','health','social'] as const;
const icons = ['💰','💕','🎯','🌿','🤝'];
const labels = ['재물운','애정운','직업운','건강운','사회운'];
return keys.map((k, i) => {
const s = roll();
return { icon: icons[i], label: labels[i], score: s, desc: DESCS[k][toLevel(s)] };
});
}
// ── 레벨별 색상/라벨 ─────────────────────────────────────────────────
const LEVEL_META: Record<Level, { emoji: string; label: string; bar: string; bg: string; border: string; text: string; badge: string }> = {
great: { emoji:'🌟', label:'아주 좋은 날', bar:'#f59e0b', bg:'bg-amber-50', border:'border-amber-300', text:'text-amber-800', badge:'bg-amber-100 text-amber-700 border-amber-300' },
good: { emoji:'✨', label:'좋은 날', bar:'#22c55e', bg:'bg-emerald-50',border:'border-emerald-300',text:'text-emerald-800',badge:'bg-emerald-100 text-emerald-700 border-emerald-300' },
neutral: { emoji:'🌤️', label:'평온한 날', bar:'#64748b', bg:'bg-slate-50', border:'border-slate-200', text:'text-slate-700', badge:'bg-slate-100 text-slate-600 border-slate-200' },
caution: { emoji:'⚠️', label:'조심하는 날', bar:'#f97316', bg:'bg-orange-50', border:'border-orange-300',text:'text-orange-800', badge:'bg-orange-100 text-orange-700 border-orange-300' },
};
const REL_DESC: (yongShin: string, yongShinKr: string) => Record<Rel, string> = (y, yk) => ({
same: `오늘 기운이 당신의 용신 ${y}(${yk})과 같은 오행으로 강하게 공명합니다.`,
generates: `오늘 기운이 용신 ${y}(${yk})을 생(生)해줍니다. 순조롭게 힘이 실리는 날.`,
generated: `용신 ${y}(${yk})이 오늘 기운을 생(生)해주고 있어 에너지를 베풀기 좋은 날입니다.`,
overcomes: `오늘 기운이 용신 ${y}(${yk})을 극(克)합니다. 신중하게 움직이는 것이 좋습니다.`,
overcome: `용신 ${y}(${yk})이 오늘 기운을 극(克)합니다. 주도적으로 판단하기 좋은 날.`,
neutral: `오늘 기운과 용신 ${y}(${yk})은 독립적으로 작용합니다. 차분하게 나아가세요.`,
});
// ── 점수 바 ──────────────────────────────────────────────────────────
function ScoreBar({ score, color }: { score: number; color: string }) {
return (
<div className="flex items-center gap-2 mt-1">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div style={{ width: `${score}%`, background: color, transition: 'width 0.8s ease' }} className="h-full rounded-full" />
</div>
<span className="text-[10px] font-bold w-6 text-right" style={{ color }}>{score}</span>
</div>
);
}
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────
interface Props {
yongShin: string;
yongShinKr: string;
heeShin: string;
heeShinKr: string;
yearNum: number;
monthNum: number;
dayNum: number;
hasLottoSubscription: boolean;
}
export default function SajuFortuneSection({
yongShin, yongShinKr, heeShin, heeShinKr,
yearNum, monthNum, dayNum,
hasLottoSubscription,
}: Props) {
const today = useMemo(getTodayPillar, []);
const overall = useMemo(() => calcOverallScore(today.stemElem, today.branchElem, yongShin, heeShin), [today, yongShin, heeShin]);
const level = toLevel(overall);
const meta = LEVEL_META[level];
const areas = useMemo(() => buildAreas(overall, yongShin, heeShin, yearNum, monthNum, dayNum), [overall, yongShin, heeShin, yearNum, monthNum, dayNum]);
const stemRel = getRelation(today.stemElem, yongShin);
const relDesc = REL_DESC(yongShin, yongShinKr)[stemRel];
return (
<>
{/* ── 상단 연결 화살표 ── */}
<div className="flex flex-col items-center gap-0 py-1">
<div className="w-px h-5 bg-gradient-to-b from-blue-200 to-amber-300" />
<div className="flex items-center gap-2 px-4 py-1.5 bg-amber-50 border border-amber-200 rounded-full text-[11px] font-bold text-amber-700">
<span></span>
</div>
<div className="w-px h-5 bg-gradient-to-b from-amber-300 to-amber-100" />
</div>
{/* ── 본문 카드 ── */}
<div id="today-fortune" className="bg-white rounded-2xl border border-amber-200 overflow-hidden shadow-sm">
{/* 헤더 */}
<div className="bg-gradient-to-r from-[#1a0a00] via-[#3d1a00] to-[#1a0a00] px-6 py-5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center flex-shrink-0 shadow-md text-xl">
</div>
<div>
<h2 className="text-sm font-extrabold text-white"> </h2>
<p className="text-amber-300/70 text-[11px] mt-0.5">
{today.year} {today.month} {today.date} · {today.stem}{today.branch} ({today.stemKr}{today.branchKr})
</p>
</div>
</div>
<span className={`text-[11px] font-extrabold px-3 py-1.5 rounded-full border ${meta.badge} flex-shrink-0`}>
{meta.emoji} {meta.label}
</span>
</div>
</div>
<div className="p-5 space-y-5">
{/* 일진 × 용신 분석 */}
<div className={`rounded-xl border p-4 ${meta.bg} ${meta.border}`}>
<div className="flex items-start gap-3">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center font-bold text-lg flex-shrink-0 ${meta.text}`}
style={{ background: 'rgba(255,255,255,0.65)' }}>
{today.stem}{today.branch}
</div>
<div className="flex-1">
<div className={`text-xs font-extrabold mb-1 ${meta.text}`}>
{yongShin}({yongShinKr})
</div>
<p className={`text-xs leading-relaxed ${meta.text}`} style={{ opacity: 0.88 }}>
{relDesc}
</p>
</div>
</div>
{/* 종합 점수 바 */}
<div className="mt-3 flex items-center gap-3">
<span className="text-[11px] font-bold text-slate-500"> </span>
<div className="flex-1 h-2.5 bg-white/70 rounded-full overflow-hidden border border-white/50">
<div
style={{ width: `${overall}%`, background: `linear-gradient(90deg, ${meta.bar}cc, ${meta.bar})` }}
className="h-full rounded-full"
/>
</div>
<span className="text-sm font-extrabold" style={{ color: meta.bar }}>{overall}</span>
</div>
</div>
{/* 5대 운세 그리드 */}
<div>
<h3 className="text-xs font-extrabold text-[#04102b] mb-3"> </h3>
<div className="space-y-3">
{areas.map((area) => {
const aLevel = toLevel(area.score);
const aMeta = LEVEL_META[aLevel];
return (
<div key={area.label} className="flex gap-3 items-start">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 text-sm ${aMeta.bg} border ${aMeta.border}`}>
{area.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-0.5">
<span className="text-xs font-bold text-[#04102b]">{area.label}</span>
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${aMeta.badge}`}>{aMeta.emoji}</span>
</div>
<ScoreBar score={area.score} color={aMeta.bar} />
<p className="text-[11px] text-slate-500 mt-1 leading-relaxed">{area.desc}</p>
</div>
</div>
);
})}
</div>
</div>
{/* 면책 */}
<p className="text-center text-[11px] text-slate-400 leading-relaxed">
({yongShinKr}·{yongShin}) .<br />
.
</p>
{/* 로또 CTA */}
<div className="rounded-2xl bg-gradient-to-br from-[#04102b] via-[#0d1f5c] to-[#04102b] border border-[#1a3a7a] p-5 relative overflow-hidden">
<div className="absolute inset-0 opacity-[0.04]"
style={{ backgroundImage: 'radial-gradient(circle, #a78bfa 1px, transparent 1px)', backgroundSize: '20px 20px' }} />
<div className="relative">
<div className="flex items-center gap-2 mb-2">
<span className="text-base">🎱</span>
<span className="text-xs font-extrabold text-amber-300">
{level === 'great' ? '오늘 운이 아주 좋습니다! 로또도 한 번 도전해보세요.' : '사주 기반 행운 번호도 확인해보세요.'}
</span>
</div>
<p className="text-xs text-blue-200/70 leading-relaxed mb-4">
<strong className="text-amber-300">{yongShin}({yongShinKr})</strong>
.
{hasLottoSubscription
? ' 구독 중이신 로또 서비스의 매주 최신 추천 번호도 함께 확인하세요.'
: ' 로또 구독 시 대운 교차 분석으로 더 정밀한 번호를 매주 받을 수 있어요.'}
</p>
<a
href="#saju-lotto-section"
onClick={e => {
e.preventDefault();
document.getElementById('saju-lotto-section')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}}
className="block w-full text-center bg-gradient-to-r from-amber-500 to-amber-400 hover:from-amber-400 hover:to-amber-300 text-[#04102b] text-sm font-extrabold px-4 py-2.5 rounded-xl transition-all shadow-lg cursor-pointer"
>
</a>
</div>
</div>
</div>
</div>
{/* 하단 연결 */}
<div className="flex flex-col items-center gap-0 py-1">
<div className="w-px h-5 bg-gradient-to-b from-amber-200 to-blue-300" />
<div className="flex items-center gap-2 px-4 py-1.5 bg-blue-50 border border-blue-200 rounded-full text-[11px] font-bold text-blue-700">
<span>🎱</span>
</div>
<div className="w-px h-5 bg-gradient-to-b from-blue-200 to-transparent" />
</div>
</>
);
}

View File

@@ -0,0 +1,627 @@
import { calculateSaju } from '@/lib/saju-calculator';
import Link from 'next/link';
import { calculateDaeun, getCurrentDaeun, getDaeunDescription } from '@/lib/daeun-calculator';
import { getCurrentSolarTerm, getSolarTermName, getSolarTermMonthBranch } from '@/lib/solar-terms';
import { EARTHLY_BRANCHES_KR, FIVE_ELEMENTS_KR, FIVE_ELEMENTS } from '@/lib/saju-calculator';
import { calculateElementScore, performFullAnalysis } from '@/lib/ai-interpretation';
import { createClient } from '@/lib/supabase/server';
import SajuAISection from './SajuAISection';
import SajuFortuneSection from './SajuFortuneSection';
interface PageProps {
searchParams: Promise<{
year: string;
month: string;
day: string;
hour?: string;
gender: 'male' | 'female';
calendarType: 'solar' | 'lunar';
originalYear?: string;
originalMonth?: string;
originalDay?: string;
isLeapMonth?: string;
}>;
}
export default async function SajuResultPage({ searchParams }: PageProps) {
const params = await searchParams;
const { year, month, day, hour, gender, calendarType, originalYear, originalMonth, originalDay, isLeapMonth } = params;
const yearNum = parseInt(year, 10);
const monthNum = parseInt(month, 10);
const dayNum = parseInt(day, 10);
const hourNum = hour ? parseInt(hour, 10) : null;
if (isNaN(yearNum) || isNaN(monthNum) || isNaN(dayNum)) {
return (
<div className="min-h-full bg-[#f0f5ff] flex items-center justify-center">
<div className="text-center py-20">
<p className="text-slate-500 text-sm mb-4"> . .</p>
<a href="/work/saju/input" className="text-blue-600 underline text-sm"> </a>
</div>
</div>
);
}
const inputYear = originalYear ? parseInt(originalYear) : yearNum;
const inputMonth = originalMonth ? parseInt(originalMonth) : monthNum;
const inputDay = originalDay ? parseInt(originalDay) : dayNum;
const isLunar = calendarType === 'lunar';
const isLeap = isLeapMonth === 'true';
// ── 사주팔자 계산 (TypeScript — lunar-javascript 기반 정밀 절기 계산) ──
const sajuData = calculateSaju(yearNum, monthNum, dayNum, hourNum, gender);
// 추가 분석 (신강신약, 용신, 오행균형, 세운)
const analysis = performFullAnalysis(sajuData);
const elementScores = analysis.elementScores;
// 대운
const currentYear = new Date().getFullYear();
const daeunList = calculateDaeun(
yearNum, monthNum, dayNum, gender,
sajuData.month.stem, sajuData.month.branch
);
const currentDaeun = getCurrentDaeun(daeunList, currentYear);
// 지지 상호작용 / 신살 / 공망 / 지장간
const branchInteractions = analysis.branchInteractions;
const shinsal = analysis.shinsal;
const gongmang = analysis.gongmang;
const hiddenStems = analysis.hiddenStems;
// ── 절기 정보 (표시용) ────────────────────────────────────────────────
const solarTermIndex = getCurrentSolarTerm(yearNum, monthNum, dayNum);
const solarTermName = getSolarTermName(solarTermIndex);
// ── 결제 여부 + 저장된 AI 해석 + 로또 구독 확인 ─────────────────────
let hasPaid = false;
let savedInterpretation: string | null = null;
let hasLottoSubscription = false;
try {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (user) {
// 사주 결제 확인 (anon client — 본인 orders는 RLS 허용 가정)
const { data: order } = await supabase
.from('orders').select('id')
.eq('user_id', user.id).eq('product_id', 'saju_detail').eq('status', 'paid')
.maybeSingle();
hasPaid = !!order;
if (hasPaid) {
// 1차: birth_hour 포함 정확한 키로 조회
const birthKey: Record<string, unknown> = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender };
if (hourNum !== null) birthKey.birth_hour = hourNum;
const { data: record } = await supabase
.from('saju_records').select('interpretation')
.eq('user_id', user.id).eq('is_paid', true)
.contains('saju_data', birthKey).maybeSingle();
savedInterpretation = record?.interpretation ?? null;
// 2차 폴백: birth_hour 없이 조회 (시간 입력 안 한 케이스 or 불일치 방지)
if (!savedInterpretation) {
const birthKeyNoHour: Record<string, unknown> = { birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender };
const { data: record2 } = await supabase
.from('saju_records').select('interpretation')
.eq('user_id', user.id).eq('is_paid', true)
.contains('saju_data', birthKeyNoHour).maybeSingle();
savedInterpretation = record2?.interpretation ?? null;
}
}
// 로또 구독 확인 — subscriptions 테이블 (세션 클라이언트로 RLS select_own 통과)
const { data: lottoSub } = await supabase
.from('subscriptions')
.select('id')
.eq('user_id', user.id)
.eq('status', 'active')
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
.maybeSingle();
hasLottoSubscription = !!lottoSub;
// subscriptions에서 못 찾으면 orders 테이블로 폴백 (구독 마이그레이션 전 데이터)
if (!hasLottoSubscription) {
const now = new Date().toISOString();
const thirtyOneDaysAgo = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
const { data: lottoOrder } = await supabase
.from('orders')
.select('id, created_at')
.eq('user_id', user.id)
.eq('status', 'paid')
.in('product_id', ['lotto_gold', 'lotto_platinum', 'lotto_diamond', 'lotto_annual'])
.gte('created_at', thirtyOneDaysAgo)
.maybeSingle();
hasLottoSubscription = !!lottoOrder;
}
}
} catch {
// 미로그인 시 무시
}
// ── 오행 색상 ──────────────────────────────────────────────────────────
const elementColors: { [k: string]: string } = {
'木': 'text-green-700', '火': 'text-red-600', '土': 'text-yellow-700',
'金': 'text-amber-600', '水': 'text-blue-700',
};
const elementBgColors: { [k: string]: string } = {
'木': 'bg-green-50 border-green-400', '火': 'bg-red-50 border-red-400',
'土': 'bg-yellow-50 border-yellow-400', '金': 'bg-amber-50 border-amber-400',
'水': 'bg-blue-50 border-blue-400',
};
// ── 띠 계산 ────────────────────────────────────────────────────────────
const zodiacAnimals = ['쥐', '소', '호랑이', '토끼', '용', '뱀', '말', '양', '원숭이', '닭', '개', '돼지'];
const zodiacIdx = (yearNum - 4) % 12;
const zodiacAnimal = zodiacAnimals[zodiacIdx >= 0 ? zodiacIdx : zodiacIdx + 12];
const engineBadge = <span className="text-[10px] bg-blue-50 border border-blue-200 text-blue-600 px-2 py-0.5 rounded-full font-semibold">TS </span>;
return (
<div className="min-h-full bg-[#f0f5ff]">
{/* 헤더 */}
<div className="bg-gradient-to-br from-[#04102b] via-[#0a1f5c] to-[#04102b] px-6 py-10">
<div className="max-w-4xl mx-auto text-center">
<div className="inline-flex items-center gap-2 bg-violet-400/10 border border-violet-400/25 text-violet-300 text-xs font-semibold px-4 py-1.5 rounded-full mb-4">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" />
</div>
<h1 className="text-3xl font-extrabold text-white mb-2"> </h1>
<p className="text-blue-200/60 text-sm"> AI </p>
</div>
</div>
<div className="px-6 py-8 max-w-4xl mx-auto">
<div className="grid lg:grid-cols-[280px_1fr] gap-6">
{/* 사이드바 */}
<aside className="lg:sticky lg:top-6 h-fit">
<div className="bg-[#04102b] rounded-2xl p-6 text-white">
<h2 className="text-base font-bold mb-5 text-center pb-4 border-b border-white/10"> </h2>
<div className="space-y-4 text-sm">
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">
{isLunar ? (
<div>
<div> {inputYear}.{inputMonth}.{inputDay}{isLeap ? ' (윤달)' : ''}</div>
<div className="text-xs text-blue-300/50 mt-0.5"> {yearNum}.{monthNum}.{dayNum}</div>
</div>
) : (
<div>{yearNum}.{monthNum}.{dayNum}</div>
)}
</div>
</div>
{hourNum !== null && (
<div>
<div className="text-blue-300/60 mb-1"> </div>
<div className="font-bold">{hourNum}</div>
</div>
)}
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">{gender === 'male' ? '남성' : '여성'}</div>
</div>
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold">{zodiacAnimal}</div>
</div>
<div>
<div className="text-blue-300/60 mb-1"> </div>
<div className="font-bold text-amber-300">{solarTermName} </div>
</div>
<div>
<div className="text-blue-300/60 mb-1"></div>
<div className="font-bold text-2xl text-amber-400">
{sajuData.day.stem} ({sajuData.day.stemKr})
</div>
<div className="text-xs text-blue-300/60 mt-1">
{FIVE_ELEMENTS_KR[sajuData.day.element as keyof typeof FIVE_ELEMENTS_KR]}({sajuData.day.element})
</div>
</div>
<div className="flex items-center gap-2">
<div className="text-blue-300/60 text-xs"> </div>
{engineBadge}
</div>
</div>
<div className="mt-5 pt-5 border-t border-white/10 space-y-2">
<Link href="/work/saju/input"
className="block w-full text-center bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition text-sm font-medium">
</Link>
<Link href="/work/saju"
className="block w-full text-center bg-violet-500/20 hover:bg-violet-500/30 text-violet-300 px-4 py-2 rounded-lg transition text-sm font-medium">
</Link>
</div>
</div>
</aside>
{/* 메인 */}
<main className="space-y-6">
{/* 사주팔자 표 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="text-xl font-extrabold text-[#04102b] mb-5 text-center"> ()</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="bg-[#04102b] text-white">
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
{sajuData.hour && <th className="py-2.5 px-3 text-center font-bold text-xs"></th>}
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
<th className="py-2.5 px-3 text-center font-bold text-xs"></th>
</tr>
</thead>
<tbody>
{/* 천간 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.stemKr}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.stemKr}</div>
<div className="text-xs text-amber-600 font-bold mt-0.5"></div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.stemKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.stem}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.stemKr}</div>
</td>
</tr>
{/* 지지 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.hour.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.hour.branchKr}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xl font-bold text-[#04102b]">{sajuData.day.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.day.branchKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.month.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.month.branchKr}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xl font-bold text-[#04102b]">{sajuData.year.branch}</div>
<div className="text-xs text-slate-500 mt-0.5">{sajuData.year.branchKr}</div>
</td>
</tr>
{/* 지장간 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs">
<div></div>
<div className="text-[10px] text-slate-400 font-normal"> </div>
</td>
{(() => {
const order = sajuData.hour
? ['시주', '일주', '월주', '년주']
: ['일주', '월주', '년주'];
return order.map((pillarName, idx) => {
const h = hiddenStems.find((hs: any) => hs.pillar === pillarName);
return (
<td key={idx} className={`py-2 px-2 text-center ${pillarName === '일주' ? 'bg-amber-50' : ''}`}>
{h && (
<div className="flex flex-wrap justify-center gap-1">
{h.stems.map((s: any, si: number) => (
<span key={si}
className={`inline-block px-1.5 py-0.5 rounded text-xs font-semibold border ${elementBgColors[s.element] || 'bg-gray-100'}`}
title={s.role}>
{s.stemKr}
</span>
))}
</div>
)}
</td>
);
});
})()}
</tr>
{/* 십성 */}
<tr className="border-b border-slate-100">
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.tenGod}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.tenGod}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.tenGod}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.tenGod}</div>
</td>
</tr>
{/* 십이운성 */}
<tr>
<td className="py-2.5 px-3 text-center font-semibold text-[#04102b] bg-[#f0f5ff] text-xs"></td>
{sajuData.hour && (
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.hour.fortune}</div>
</td>
)}
<td className="py-2.5 px-3 text-center bg-amber-50">
<div className="text-xs font-bold text-[#04102b]">{sajuData.day.fortune}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.month.fortune}</div>
</td>
<td className="py-2.5 px-3 text-center">
<div className="text-xs font-bold text-[#04102b]">{sajuData.year.fortune}</div>
</td>
</tr>
</tbody>
</table>
</div>
{/* 지지 상호작용 */}
{branchInteractions.length > 0 && (
<div className="mt-5 pt-5 border-t border-slate-100">
<h3 className="text-sm font-bold text-[#04102b] mb-3 text-center"> </h3>
<div className="flex flex-wrap justify-center gap-2">
{branchInteractions.map((inter: any, idx: number) => {
const isPositive = inter.type.includes('합');
const isNegative = inter.type.includes('충') || inter.type.includes('형');
const colorClass = isPositive
? 'bg-emerald-50 border-emerald-400 text-emerald-800'
: isNegative
? 'bg-red-50 border-red-400 text-red-800'
: 'bg-amber-50 border-amber-400 text-amber-800';
return (
<span key={idx} className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-bold border ${colorClass}`}
title={inter.description}>
{inter.type} {inter.branchesKr.join('')}
{inter.resultElement && `${FIVE_ELEMENTS_KR[inter.resultElement as keyof typeof FIVE_ELEMENTS_KR]}`}
</span>
);
})}
</div>
</div>
)}
{/* 오행 균형 */}
<div className="mt-5 pt-5 border-t border-slate-100">
<h3 className="text-sm font-bold text-[#04102b] mb-4 text-center"> </h3>
<div className="grid grid-cols-5 gap-2">
{Object.entries(elementScores).map(([element, score]) => (
<div key={element} className="text-center">
<div className={`text-lg font-bold mb-1 ${elementColors[element] || ''}`}>{element}</div>
<div className="text-xs text-slate-500 mb-2">
{FIVE_ELEMENTS_KR[element as keyof typeof FIVE_ELEMENTS_KR]}
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5 mb-1">
<div
className={`h-1.5 rounded-full transition-all ${element === sajuData.day.element
? 'bg-gradient-to-r from-[#1a56db] to-[#7c3aed]'
: 'bg-slate-400'
}`}
style={{ width: `${Math.max(score, 5)}%` }}
/>
</div>
<div className="text-xs font-bold text-[#04102b]">{score}%</div>
</div>
))}
</div>
</div>
</div>
{/* 분석 카드 그리드 */}
<div className="grid md:grid-cols-2 gap-6">
{/* 신강/신약 + 용신 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h3 className="text-base font-extrabold text-[#04102b] mb-4"> </h3>
<div className="flex items-center gap-3 mb-4">
<span className={`inline-block px-4 py-1.5 rounded-xl text-sm font-bold ${
analysis.dayMasterStrength.result === '신강'
? 'bg-red-100 text-red-700 border-2 border-red-400'
: analysis.dayMasterStrength.result === '신약'
? 'bg-blue-100 text-blue-700 border-2 border-blue-400'
: 'bg-green-100 text-green-700 border-2 border-green-400'
}`}>
{analysis.dayMasterStrength.result}
</span>
<span className="text-slate-500 text-xs">: {analysis.dayMasterStrength.score}</span>
</div>
<ul className="space-y-1 text-xs text-slate-500 mb-5">
{analysis.dayMasterStrength.reasons.map((r: string, i: number) => (
<li key={i} className="flex items-start">
<span className="text-amber-500 mr-1.5">-</span>
<span>{r}</span>
</li>
))}
</ul>
<div className="border-t border-slate-100 pt-4">
<h4 className="font-bold text-[#04102b] mb-2.5 text-sm"> / / </h4>
<div className="flex flex-wrap gap-2 mb-3">
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.yongShin] || 'bg-gray-100'}`}>
: {analysis.yongShin.yongShinKr}
</span>
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.yongShin.heeShin] || 'bg-gray-100'}`}>
: {analysis.yongShin.heeShinKr}
</span>
<span className="px-2.5 py-1 rounded-lg text-xs font-bold bg-slate-100 border border-slate-300 text-slate-700">
: {analysis.yongShin.giShinKr}
</span>
</div>
<p className="text-xs text-slate-500 leading-relaxed">{analysis.yongShin.explanation}</p>
</div>
</div>
{/* 신살 + 공망 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h3 className="text-base font-extrabold text-[#04102b] mb-4"> ()</h3>
{shinsal.length > 0 ? (
<div className="space-y-2 mb-5">
{shinsal.map((s: any, i: number) => (
<div key={i} className="flex items-start gap-2 p-3 rounded-xl bg-[#f0f5ff]">
<span className="inline-block px-2 py-0.5 bg-[#04102b] text-white rounded-lg text-xs font-bold whitespace-nowrap">
{s.name}
</span>
<div>
<div className="text-xs font-semibold text-[#04102b]">
{s.pillar} {s.branchKr}
</div>
<div className="text-xs text-slate-500 mt-0.5">{s.description}</div>
</div>
</div>
))}
</div>
) : (
<p className="text-slate-500 text-xs mb-5"> .</p>
)}
<div className="border-t border-slate-100 pt-4">
<h4 className="font-bold text-[#04102b] mb-2 text-sm"> ()</h4>
<div className="flex gap-2 mb-2">
{gongmang.branchesKr.map((bk: string, i: number) => (
<span key={i} className="px-2.5 py-1 bg-[#04102b] text-white rounded-lg text-xs font-bold">
{bk}
</span>
))}
</div>
<p className="text-xs text-slate-500 leading-relaxed">{gongmang.description}</p>
</div>
{/* 세운 정보 */}
<div className="border-t border-slate-100 pt-4 mt-4">
<h4 className="font-bold text-[#04102b] mb-2 text-sm">
{analysis.seun.year}
</h4>
<div className="flex items-center gap-2 mb-2">
<span className={`px-2.5 py-1 rounded-lg text-xs font-bold border ${elementBgColors[analysis.seun.element] || 'bg-gray-100'}`}>
{analysis.seun.stemKr}{analysis.seun.branchKr}
</span>
<span className="text-xs text-slate-500">{analysis.seun.elementKr} </span>
</div>
{analysis.seun.interactions.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{analysis.seun.interactions.map((si: any, i: number) => (
<span key={i} className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
si.type.includes('합') ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'
}`}>
{si.type} {si.branchesKr.join('')}
</span>
))}
</div>
)}
</div>
</div>
</div>
{/* AI 상세 해석 섹션 */}
{(() => {
const birthKey = {
birth_year: yearNum, birth_month: monthNum, birth_day: dayNum, gender,
...(hourNum !== null ? { birth_hour: hourNum } : {}),
};
const currentUrl = `/work/saju/result?year=${yearNum}&month=${monthNum}&day=${dayNum}${hourNum !== null ? `&hour=${hourNum}` : ''}&gender=${gender}&calendarType=${calendarType}${originalYear ? `&originalYear=${originalYear}&originalMonth=${originalMonth}&originalDay=${originalDay}` : ''}${isLeap ? '&isLeapMonth=true' : ''}`;
return (
<SajuAISection
hasPaid={hasPaid}
savedInterpretation={savedInterpretation}
sajuData={sajuData}
daeun={currentDaeun}
daeunList={daeunList}
gender={gender}
birthKey={birthKey}
currentUrl={currentUrl}
engineData={undefined}
/>
);
})()}
{/* 오늘의 운세 (사주 결제 시 표시) */}
{hasPaid && (
<SajuFortuneSection
yongShin={analysis.yongShin.yongShin}
yongShinKr={analysis.yongShin.yongShinKr}
heeShin={analysis.yongShin.heeShin}
heeShinKr={analysis.yongShin.heeShinKr}
yearNum={yearNum}
monthNum={monthNum}
dayNum={dayNum}
hasLottoSubscription={hasLottoSubscription}
/>
)}
{/* 대운 */}
<div className="bg-white rounded-2xl border border-[#dbe8ff] p-6">
<h2 className="text-lg font-extrabold text-[#04102b] mb-5 text-center">
() 10
</h2>
{currentDaeun && (
<div className="bg-gradient-to-r from-[#04102b] to-[#0a2060] rounded-2xl p-5 mb-5 text-white">
<h3 className="text-sm font-bold mb-3 text-center text-blue-300"> </h3>
<div className="text-center mb-3">
<div className="text-3xl font-bold mb-1">
{currentDaeun.stem}{currentDaeun.branch}
</div>
<div className="text-base text-blue-200">
{currentDaeun.stemKr}{currentDaeun.branchKr}
</div>
<div className="text-xs text-blue-300/70 mt-1">
{currentDaeun.age} ~ {currentDaeun.age + 9} ({currentDaeun.startYear} ~ {currentDaeun.endYear})
</div>
</div>
<p className="text-center leading-relaxed text-xs text-blue-200/80">
{getDaeunDescription(currentDaeun, sajuData.day.stem)}
</p>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{daeunList.map((daeun: any, index: number) => {
const isCurrent = currentDaeun &&
daeun.startYear === currentDaeun.startYear &&
daeun.endYear === currentDaeun.endYear;
return (
<div key={index}
className={`rounded-xl p-3 border-2 transition ${isCurrent ? 'bg-amber-50 border-amber-400' : 'bg-white border-[#dbe8ff]'}`}>
<div className="text-center">
<div className="text-xl font-bold text-[#04102b] mb-0.5">{daeun.stem}{daeun.branch}</div>
<div className="text-xs text-slate-500 mb-1.5">{daeun.stemKr}{daeun.branchKr}</div>
<div className="text-xs text-slate-400">{daeun.age} ~ {daeun.age + 9}</div>
<div className="text-xs text-slate-400">{daeun.startYear} ~ {daeun.endYear}</div>
{isCurrent && (
<div className="mt-1.5">
<span className="inline-block bg-[#04102b] text-white text-xs px-2.5 py-0.5 rounded-full font-semibold"></span>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</main>
</div>
</div>
</div>
);
}