Files
saju-web/components/AiInterpretationSection.tsx
2026-02-17 08:15:41 +09:00

336 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { SajuData } from '@/lib/saju-calculator';
import type { DaeunPillar } from '@/lib/daeun-calculator';
import { createBrowserClient } from '@supabase/ssr'
import { useRouter } from 'next/navigation';
import { AccordionItem, parseSections, SECTION_ICONS } from './AccordionItem';
import TokenPurchaseModal from './TokenPurchaseModal';
import { ensureProfile } from '@/lib/ensure-profile';
interface AiInterpretationSectionProps {
sajuData: SajuData;
currentDaeun: DaeunPillar | null;
daeunList?: DaeunPillar[];
initialInterpretation?: string | null;
}
export default function AiInterpretationSection({ sajuData, currentDaeun, daeunList, initialInterpretation }: AiInterpretationSectionProps) {
const [interpretation, setInterpretation] = useState<string | null>(initialInterpretation || null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isUnlocked, setIsUnlocked] = useState(!!initialInterpretation);
const [user, setUser] = useState<any>(null);
const [credits, setCredits] = useState(0);
const [checkingRecord, setCheckingRecord] = useState(false);
const [showTokenModal, setShowTokenModal] = useState(false);
const router = useRouter();
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Check auth status on mount and listen for changes
useEffect(() => {
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
if (user) {
const currentCredits = await ensureProfile(supabase, user);
setCredits(currentCredits);
}
};
getUser();
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, []);
// Check for existing saju_records when user is available
useEffect(() => {
if (!user || isUnlocked || initialInterpretation) return;
const checkExistingRecord = async () => {
setCheckingRecord(true);
try {
const { data: records } = await supabase
.from('saju_records')
.select('interpretation, saju_data')
.eq('user_id', user.id);
if (records && records.length > 0) {
const match = records.find((r: any) => {
const rd = r.saju_data;
return (
rd.birthDate?.year === sajuData.birthDate.year &&
rd.birthDate?.month === sajuData.birthDate.month &&
rd.birthDate?.day === sajuData.birthDate.day &&
rd.gender === sajuData.gender
);
});
if (match) {
setInterpretation(match.interpretation);
setIsUnlocked(true);
}
}
} catch (err) {
console.error('Failed to check existing records:', err);
} finally {
setCheckingRecord(false);
}
};
checkExistingRecord();
}, [user]);
const handleTokenUse = async () => {
if (!user) {
if (confirm('로그인이 필요합니다. 로그인 페이지로 이동하시겠습니까?')) {
router.push('/login');
}
return;
}
if (credits < 1) {
setShowTokenModal(true);
return;
}
// 토큰 차감
const { error: updateError } = await supabase
.from('profiles')
.update({ credits: credits - 1 })
.eq('id', user.id);
if (updateError) {
alert('토큰 차감에 실패했습니다. 다시 시도해주세요.');
return;
}
setCredits(credits - 1);
setLoading(true);
setIsUnlocked(true);
await fetchInterpretationAndSave();
};
const handlePurchaseComplete = async () => {
setShowTokenModal(false);
if (user) {
const { data: profile } = await supabase
.from('profiles')
.select('credits')
.eq('id', user.id)
.single();
if (profile) setCredits(profile.credits || 0);
}
};
const fetchInterpretationAndSave = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
saju: sajuData,
daeun: currentDaeun,
daeunList: daeunList || [],
gender: sajuData.gender
}),
});
if (!response.ok) throw new Error('Analysis failed');
const data = await response.json();
const newInterp = data.interpretation;
setInterpretation(newInterp);
if (user) {
// 프로필 존재 보장 (FK 위반 방지)
await ensureProfile(supabase, user);
// Save Record
const { error: insertError } = await supabase.from('saju_records').insert({
user_id: user.id,
saju_data: sajuData,
interpretation: newInterp,
});
if (insertError) {
console.error('saju_records 저장 실패:', insertError.message, insertError.details);
}
}
} catch (err) {
console.error(err);
setError('분석을 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
// If initialInterpretation is provided, we simulate "already unlocked"
useEffect(() => {
if (initialInterpretation) {
setIsUnlocked(true);
setInterpretation(initialInterpretation);
}
}, [initialInterpretation]);
// Loading View
if (loading || checkingRecord) {
return (
<div className="glass-panel-light rounded-2xl p-8 md:p-12 mb-6 animate-pulse">
<div className="flex flex-col items-center justify-center space-y-6 py-12">
<div className="w-16 h-16 border-4 border-[#173658]/20 border-t-[#173658] rounded-full animate-spin"></div>
<div className="text-center space-y-2">
<h3 className="text-xl font-bold text-[#173658]">
{checkingRecord ? '기존 기록을 확인하고 있습니다...' : '사주를 분석하고 있습니다...'}
</h3>
<p className="text-[#5d6d7e]">
{checkingRecord
? '잠시만 기다려주세요.'
: <> , .<br /> .</>
}
</p>
</div>
</div>
</div>
);
}
// Locked View (Preview)
if (!isUnlocked) {
return (
<>
<div className="relative glass-panel-light rounded-2xl p-8 md:p-12 mb-6 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#F3E7E3]/80 to-[#F3E7E3] z-10 flex flex-col items-center justify-center pb-12">
<div className="text-center p-8 bg-white/90 backdrop-blur-md rounded-2xl shadow-xl border-2 border-[#173658]/20 max-w-sm mx-auto">
<div className="text-4xl mb-4">🔐</div>
<h3 className="text-2xl font-bold text-[#173658] mb-2"> </h3>
<p className="text-[#5d6d7e] mb-2">
AI .
</p>
<p className="text-sm text-[#5d6d7e] mb-4">
10 | : <span className="font-bold text-[#173658]">{credits}</span>
</p>
<button
onClick={handleTokenUse}
className="w-full bg-[#173658] hover:bg-[#1e426a] text-white font-bold py-3 px-6 rounded-xl hover:shadow-lg hover:scale-105 transition transform flex items-center justify-center gap-2"
>
{credits >= 10 ? (
<>
<span> 10 </span>
</>
) : (
<span> </span>
)}
</button>
<p className="text-xs text-[#5d6d7e] mt-4">
* .<br />
* .
</p>
</div>
</div>
{/* Blurred Content Placeholder */}
<div className="filter blur-sm select-none opacity-50 pointer-events-none">
<h2 className="text-2xl font-bold text-[#173658] mb-8 text-center flex items-center justify-center">
<span className="text-3xl mr-3"></span>
</h2>
<div className="space-y-4">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-5/6"></div>
<div className="h-4 bg-gray-200 rounded w-4/6"></div>
<div className="h-32 bg-gray-100 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
</div>
<TokenPurchaseModal
isOpen={showTokenModal}
onClose={() => setShowTokenModal(false)}
onPurchaseComplete={handlePurchaseComplete}
user={user}
supabase={supabase}
/>
</>
);
}
// Unlocked View
if (error) {
return (
<div className="bg-red-50 rounded-3xl shadow-lg p-8 text-center text-red-600">
<p> {error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 px-4 py-2 bg-red-100 hover:bg-red-200 rounded-lg transition"
>
</button>
</div>
);
}
const sections = interpretation ? parseSections(interpretation) : [];
return (
<div className="glass-panel-light rounded-2xl p-6 md:p-10 mb-6">
<div className="text-center mb-8">
<h2 className="text-2xl md:text-3xl font-bold gradient-text mb-2">
</h2>
<p className="text-[#5d6d7e]">AI .</p>
{user && <span className="inline-block mt-2 px-3 py-1 bg-emerald-100 text-emerald-700 text-xs rounded-full font-bold"> / </span>}
</div>
{sections.length > 0 ? (
<div className="space-y-3">
{sections.map((section, idx) => (
<AccordionItem
key={idx}
title={section.title}
content={section.content}
icon={SECTION_ICONS[idx] || '📌'}
defaultOpen={idx === 0}
/>
))}
</div>
) : (
<article className="prose prose-lg max-w-none prose-headings:text-[#173658] prose-p:text-[#5d6d7e] prose-li:text-[#5d6d7e]">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{interpretation || ''}
</ReactMarkdown>
</article>
)}
<div className="mt-10 p-5 bg-[#F3E7E3] rounded-2xl border-2 border-[#173658]/20 flex items-start gap-4">
<span className="text-2xl pt-1">💡</span>
<div>
<h4 className="font-bold text-[#173658] mb-1"></h4>
<p className="text-sm text-[#5d6d7e] leading-relaxed">
AI를 .
,
.
</p>
</div>
</div>
</div>
);
}