Files
saju-web/components/AiInterpretationSection.tsx

336 lines
14 KiB
TypeScript
Raw 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="bg-white rounded-3xl shadow-xl p-8 md:p-12 mb-8 animate-pulse border border-gray-100">
<div className="flex flex-col items-center justify-center space-y-6 py-12">
<div className="w-16 h-16 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
<div className="text-center space-y-2">
<h3 className="text-xl font-bold text-gray-800">
{checkingRecord ? '기존 기록을 확인하고 있습니다...' : '사주를 분석하고 있습니다...'}
</h3>
<p className="text-gray-500">
{checkingRecord
? '잠시만 기다려주세요.'
: <> , .<br /> .</>
}
</p>
</div>
</div>
</div>
);
}
// Locked View (Preview)
if (!isUnlocked) {
return (
<>
<div className="relative bg-white rounded-3xl shadow-2xl p-8 md:p-12 mb-8 border border-gray-100 overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-white/80 to-white 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 border-indigo-100 max-w-sm mx-auto">
<div className="text-4xl mb-4">🔐</div>
<h3 className="text-2xl font-bold text-gray-900 mb-2"> </h3>
<p className="text-gray-600 mb-2">
AI .
</p>
<p className="text-sm text-gray-500 mb-4">
1 | : <span className="font-bold text-indigo-600">{credits}</span>
</p>
<button
onClick={handleTokenUse}
className="w-full bg-gradient-to-r from-indigo-600 to-purple-600 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 >= 1 ? (
<>
<span> 1 </span>
</>
) : (
<span> </span>
)}
</button>
<p className="text-xs text-gray-400 mt-4">
* .<br />
* .
</p>
</div>
</div>
{/* Blurred Content Placeholder */}
<div className="filter blur-sm select-none opacity-50 pointer-events-none">
<h2 className="text-3xl font-bold text-gray-900 mb-8 text-center flex items-center justify-center">
<span className="text-4xl 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="bg-white rounded-3xl shadow-2xl p-6 md:p-10 mb-8 border border-gray-100">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold bg-gradient-to-r from-indigo-600 to-purple-600 bg-clip-text text-transparent mb-2">
</h2>
<p className="text-gray-500">AI .</p>
{user && <span className="inline-block mt-2 px-3 py-1 bg-green-100 text-green-700 text-xs rounded-full"> / </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-indigo prose-headings:text-indigo-900 prose-p:text-gray-700 prose-li:text-gray-700">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{interpretation || ''}
</ReactMarkdown>
</article>
)}
<div className="mt-10 p-5 bg-indigo-50/50 rounded-2xl border border-indigo-100 flex items-start gap-4">
<span className="text-2xl pt-1">💡</span>
<div>
<h4 className="font-bold text-indigo-900 mb-1"></h4>
<p className="text-sm text-indigo-800/80 leading-relaxed">
AI를 .
,
.
</p>
</div>
</div>
</div>
);
}