사주 풀이 고도화, NAS 배포 자동화

This commit is contained in:
2026-02-16 19:02:04 +09:00
parent d513c063cf
commit 7042373448
44 changed files with 6280 additions and 978 deletions

View File

@@ -0,0 +1,335 @@
'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>
);
}