Files
jaengseung-made/app/work/saju/result/SajuAISection.tsx

465 lines
19 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
import { usePathname, useSearchParams } from 'next/navigation';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { SajuIcon, SECTION_ICON_ORDER } from './SajuIcons';
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[];
};
}
// ── 섹션별 메타 (뱃지 라벨) — 아이콘은 SECTION_ICON_ORDER에서 동일 인덱스로 조회 ──
const SECTION_META: { badgeText: string }[] = [
{ badgeText: '기질' },
{ badgeText: '오행' },
{ badgeText: '지지' },
{ badgeText: '신살' },
{ badgeText: '재물' },
{ badgeText: '직업' },
{ badgeText: '애정' },
{ badgeText: '건강' },
{ badgeText: '대운' },
{ badgeText: '세운' },
{ badgeText: '황금기' },
{ 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, iconName, isOpen, onToggle }: {
section: ParsedSection;
meta: typeof SECTION_META[0];
iconName: (typeof SECTION_ICON_ORDER)[number];
isOpen: boolean;
onToggle: () => void;
}) {
return (
<div className="rounded-2xl border-2 border-[var(--jsm-line)] bg-[var(--jsm-surface)] overflow-hidden shadow-sm transition-all">
{/* 헤더 */}
<button
onClick={onToggle}
className="w-full flex items-center gap-3 p-4 text-left hover:bg-[var(--jsm-surface-alt)] transition-colors"
>
{/* 번호 아이콘 */}
<div className="w-10 h-10 rounded-xl bg-[var(--jsm-accent)] flex items-center justify-center text-white font-extrabold text-sm flex-shrink-0 shadow-sm">
{section.number > 0 ? section.number : <SajuIcon name={iconName} className="w-5 h-5" />}
</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 border-[var(--jsm-line)] bg-[var(--jsm-accent-soft)] text-[var(--jsm-accent)]">
{meta.badgeText}
</span>
<h3 className="font-extrabold text-[var(--jsm-ink)] text-sm leading-snug">
{section.title}
</h3>
</div>
</div>
{/* 토글 화살표 */}
<svg
className={`w-4 h-4 text-[var(--jsm-ink-faint)] 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-[var(--jsm-line)]">
<div className="text-[11px] font-semibold mb-3 flex items-center gap-1.5 text-[var(--jsm-ink-faint)]">
<SajuIcon name={iconName} className="w-4 h-4" />
</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-[var(--jsm-ink)] mt-4 mb-2">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-extrabold text-[var(--jsm-ink)] mt-3 mb-1.5">{children}</h2>,
h3: ({ children }) => <h3 className="text-sm font-bold text-[var(--jsm-ink)] 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-[var(--jsm-ink)]">{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-[var(--jsm-accent)] pl-4 py-1 my-3 text-slate-600 bg-[var(--jsm-accent-soft)] rounded-r-lg text-sm italic">
{children}
</blockquote>
),
hr: () => <hr className="border-slate-200 my-4" />,
code: ({ children }) => (
<code className="bg-slate-100 text-[var(--jsm-accent)] 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) {
const pathname = usePathname();
const searchParams = useSearchParams();
const search = searchParams.toString();
const loginHref = `/login?next=${encodeURIComponent(`${pathname}${search ? `?${search}` : ''}`)}`;
// 저장된 해석이 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 [errorMessage, setErrorMessage] = useState<string | null>(null);
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');
setErrorMessage(null);
fetch('/api/saju/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
})
.then(async r => {
if (r.status === 429) return { __rateLimited: true };
return r.json();
})
.then(data => {
if (data.__rateLimited) {
setErrorMessage('오늘 무료 횟수를 모두 사용했습니다. 내일 다시 시도해주세요.');
setStatus('error');
return;
}
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');
setErrorMessage(null);
fetch('/api/saju/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ saju: sajuData, daeun, daeunList, gender, engineData }),
})
.then(async r => {
if (r.status === 429) return { __rateLimited: true };
return r.json();
})
.then(data => {
if (data.__rateLimited) {
setErrorMessage('오늘 무료 횟수를 모두 사용했습니다. 내일 다시 시도해주세요.');
setStatus('error');
return;
}
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-[var(--jsm-navy)] rounded-2xl p-7 text-center">
<div className="inline-flex items-center gap-2 bg-[var(--jsm-accent)] text-white 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-white/70 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/10 rounded-lg px-2 py-1.5">
<SajuIcon name={SECTION_ICON_ORDER[i]} className="w-4 h-4 text-white/80" />
<span className="text-xs text-white/70 font-medium">{meta.badgeText}</span>
</div>
))}
</div>
<a
href={loginHref}
className="inline-flex items-center gap-2 bg-white hover:bg-white/90 text-[var(--jsm-navy)] font-semibold px-7 py-3 rounded-xl transition-colors"
>
AI
</a>
<p className="text-white/50 text-xs mt-3"> 1 · </p>
</div>
);
}
// ── 로딩 ──────────────────────────────────────────────────────────
if (status === 'loading') {
return (
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] p-8 text-center">
<div className="w-10 h-10 border-2 border-[var(--jsm-accent)] 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">
<SajuIcon name={SECTION_ICON_ORDER[i]} className="w-3.5 h-3.5" />{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">
{errorMessage ?? 'AI 해석 생성에 실패했습니다.'}
</p>
{!errorMessage && (
<button
onClick={() => { called.current = false; setStatus('idle'); }}
className="text-xs text-blue-600 underline"
>
</button>
)}
</div>
);
}
// ── 해석 완료 ─────────────────────────────────────────────────────
return (
<div className="bg-[var(--jsm-surface)] rounded-2xl border border-[var(--jsm-line)] overflow-hidden">
{/* 헤더 */}
<div className="flex items-center gap-2 px-6 py-4 bg-[var(--jsm-navy)]">
<div className="w-7 h-7 rounded-lg bg-[var(--jsm-accent)] 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-white/60 text-[11px]">12 · </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRegenerate}
title="AI 해석 재생성"
className="text-[11px] text-white/60 hover:text-white 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">
AI
</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-[var(--jsm-accent)] hover:text-[var(--jsm-accent-hover)] font-semibold px-3 py-1 rounded-lg border border-[var(--jsm-accent)] hover:bg-[var(--jsm-accent-soft)] 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}
iconName={SECTION_ICON_ORDER[metaIdx]}
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>
);
}