348 lines
19 KiB
TypeScript
348 lines
19 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo } from 'react';
|
||
import { LottoIcon } from './SajuIcons';
|
||
|
||
// ── 천간 / 지지 ───────────────────────────────────────────────────────
|
||
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';
|
||
}
|
||
|
||
// ── 오늘 종합 점수 (0–100) ────────────────────────────────────────────
|
||
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; };
|
||
}
|
||
|
||
// ── 로컬 라인 아이콘 (이모지 대체, 파일 전용 — SajuIcons.tsx 미변경) ──────
|
||
type GlyphName = 'sun' | 'great' | 'good' | 'neutral' | 'caution' | 'money' | 'love' | 'career' | 'health' | 'social';
|
||
|
||
const GLYPH_PATHS: Record<GlyphName, string> = {
|
||
sun: 'M12 8a4 4 0 100 8 4 4 0 000-8zM12 2v2M12 20v2M4 12H2M22 12h-2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41',
|
||
great: 'M12 3l2.2 5.6L20 9l-4.5 3.9L17 19l-5-3-5 3 1.5-6.1L4 9l5.8-.4z',
|
||
good: 'M4 12l5 5L20 6',
|
||
neutral: 'M4 13c2-2 4-2 6 0s4 2 6 0 4-2 6 0',
|
||
caution: 'M12 3l9 16H3zM12 10v4M12 16.5h.01',
|
||
money: 'M12 4a8 4 0 1 0 0 8a8 4 0 1 0 0-8M4 8v8a8 4 0 0 0 16 0V8',
|
||
love: 'M12 20s-7-4.4-7-9a4 4 0 0 1 7-2.6A4 4 0 0 1 19 11c0 4.6-7 9-7 9z',
|
||
career: 'M4 8h16v11H4zM9 8V5h6v3',
|
||
health: 'M3 12h4l2-5 3 10 2-6 2 1h5',
|
||
social: 'M9 11a3 3 0 100-6 3 3 0 000 6zM3 20c0-3 3-5 6-5s6 2 6 5M17 11a3 3 0 100-6M15 20c0-2.5 1.5-4.5 4-5',
|
||
};
|
||
|
||
function Glyph({ name, className }: { name: GlyphName; className?: string }) {
|
||
return (
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6}
|
||
strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden>
|
||
<path d={GLYPH_PATHS[name]} />
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
// ── 운세 항목 빌드 ────────────────────────────────────────────────────
|
||
type Area = { icon: GlyphName; 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: GlyphName[] = ['money','love','career','health','social'];
|
||
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, { icon: GlyphName; label: string; bar: string; bg: string; border: string; text: string; badge: string }> = {
|
||
great: { icon:'great', 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: { icon:'good', 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: { icon:'neutral', 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: { icon:'caution', 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-[var(--jsm-line)] 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-[var(--jsm-line)]" />
|
||
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--jsm-accent-soft)] border border-[var(--jsm-line)] rounded-full text-[11px] font-bold text-[var(--jsm-accent)]">
|
||
사주 분석에서 이어지는 오늘의 운세
|
||
</div>
|
||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||
</div>
|
||
|
||
{/* ── 본문 카드 ── */}
|
||
<div id="today-fortune" className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] overflow-hidden shadow-sm">
|
||
{/* 헤더 */}
|
||
<div className="bg-[var(--jsm-navy)] 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-[var(--jsm-accent)] flex items-center justify-center flex-shrink-0">
|
||
<Glyph name="sun" className="w-5 h-5 text-white" />
|
||
</div>
|
||
<div>
|
||
<h2 className="text-sm font-extrabold text-white">오늘의 운세</h2>
|
||
<p className="text-white/60 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 flex items-center gap-1`}>
|
||
<Glyph name={meta.icon} className="w-3 h-3" /> {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: 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-[var(--jsm-ink)] 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 ${aMeta.bg} border ${aMeta.border} ${aMeta.text}`}>
|
||
<Glyph name={area.icon} className="w-4 h-4" />
|
||
</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-[var(--jsm-ink)]">{area.label}</span>
|
||
<span className={`text-[10px] font-bold px-1.5 py-0.5 rounded-full border ${aMeta.badge} flex items-center gap-1`}>
|
||
<Glyph name={aMeta.icon} className="w-2.5 h-2.5" />
|
||
</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-[var(--jsm-navy)] p-5">
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<LottoIcon className="w-4 h-4 text-[var(--jsm-accent)]" />
|
||
<span className="text-xs font-extrabold text-white">
|
||
{level === 'great' ? '오늘 운이 아주 좋습니다! 로또도 한 번 도전해보세요.' : '사주 기반 행운 번호도 확인해보세요.'}
|
||
</span>
|
||
</div>
|
||
<p className="text-xs text-white/70 leading-relaxed mb-4">
|
||
용신 <strong className="text-[var(--jsm-accent-soft)]">{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-white hover:bg-white/90 text-[var(--jsm-navy)] text-sm font-extrabold px-4 py-2.5 rounded-xl transition-colors cursor-pointer"
|
||
>
|
||
오늘의 로또 번호 추천 보기 ↓
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 하단 연결 */}
|
||
<div className="flex flex-col items-center gap-0 py-1">
|
||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||
<div className="flex items-center gap-2 px-4 py-1.5 bg-[var(--jsm-accent-soft)] border border-[var(--jsm-line)] rounded-full text-[11px] font-bold text-[var(--jsm-accent)]">
|
||
<LottoIcon className="w-4 h-4 text-[var(--jsm-accent)]" /> 오늘의 운세에서 이어지는 사주 로또 추천
|
||
</div>
|
||
<div className="w-px h-5 bg-[var(--jsm-line)]" />
|
||
</div>
|
||
</>
|
||
);
|
||
}
|