사주 풀이 고도화, 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,71 @@
'use client';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export function AccordionItem({ title, content, icon, defaultOpen = false }: {
title: string;
content: string;
icon: string;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
return (
<div className={`rounded-2xl border transition-all duration-300 ${isOpen ? 'border-indigo-200 shadow-lg bg-white' : 'border-gray-100 bg-gray-50/50 hover:bg-white hover:border-gray-200'}`}>
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full flex items-center gap-3 p-4 md:p-5 text-left cursor-pointer"
>
<span className="text-2xl flex-shrink-0">{icon}</span>
<span className={`flex-1 font-bold text-base md:text-lg transition-colors ${isOpen ? 'text-indigo-900' : 'text-gray-700'}`}>
{title}
</span>
<svg
className={`w-5 h-5 text-gray-400 transition-transform duration-300 flex-shrink-0 ${isOpen ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className={`overflow-hidden transition-all duration-300 ${isOpen ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0'}`}>
<div className="px-4 md:px-5 pb-5 pt-0">
<div className="border-t border-gray-100 pt-4">
<article className="prose prose-base max-w-none prose-indigo prose-p:text-gray-700 prose-li:text-gray-700 prose-p:leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{content}
</ReactMarkdown>
</article>
</div>
</div>
</div>
</div>
);
}
export function parseSections(markdown: string): { title: string; content: string }[] {
const sections: { title: string; content: string }[] = [];
const lines = markdown.split('\n');
let currentTitle = '';
let currentContent: string[] = [];
for (const line of lines) {
const headerMatch = line.match(/^## \d+\.\s*(.+)$/);
if (headerMatch) {
if (currentTitle) {
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
}
currentTitle = headerMatch[1];
currentContent = [];
} else if (currentTitle) {
currentContent.push(line);
}
}
if (currentTitle) {
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
}
return sections;
}
export const SECTION_ICONS = ['🌟', '⚖️', '🔗', '✨', '💰', '💼', '💕', '🏥', '🌊', '📅', '👑', '💌'];

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>
);
}

View File

@@ -0,0 +1,292 @@
'use client';
import { useState, useEffect } from 'react';
import { ensureProfile } from '@/lib/ensure-profile';
declare global {
interface Window {
IMP: any;
}
}
interface TokenPackage {
id: string;
name: string;
price: number;
tokens: number;
pricePerToken: number;
badge?: string;
highlight?: boolean;
}
interface TokenPurchaseModalProps {
isOpen: boolean;
onClose: () => void;
onPurchaseComplete: () => void;
user: any;
supabase: any;
}
export default function TokenPurchaseModal({ isOpen, onClose, onPurchaseComplete, user, supabase }: TokenPurchaseModalProps) {
const [isFirstPurchase, setIsFirstPurchase] = useState(false);
const [loading, setLoading] = useState(true);
const [purchasing, setPurchasing] = useState(false);
const [credits, setCredits] = useState(0);
useEffect(() => {
if (!isOpen || !user) return;
const checkFirstPurchase = async () => {
setLoading(true);
// 프로필 확인 (없으면 자동 생성)
const currentCredits = await ensureProfile(supabase, user);
setCredits(currentCredits);
// 결제 내역 확인
const { data: payments, error } = await supabase
.from('payments')
.select('id')
.eq('user_id', user.id)
.eq('status', 'paid')
.limit(1);
if (!error) {
setIsFirstPurchase(!payments || payments.length === 0);
}
setLoading(false);
};
checkFirstPurchase();
}, [isOpen, user]);
const getPackages = (): TokenPackage[] => {
const packages: TokenPackage[] = [];
if (isFirstPurchase) {
packages.push({
id: 'first_990',
name: '첫 결제 특별 혜택',
price: 990,
tokens: 3,
pricePerToken: 330,
badge: '첫 결제 한정',
highlight: true,
});
}
packages.push(
{
id: 'basic_990',
name: '기본 패키지',
price: 990,
tokens: 1,
pricePerToken: 990,
},
{
id: 'standard_2500',
name: '스탠다드 패키지',
price: 2500,
tokens: 3,
pricePerToken: 833,
badge: '인기',
},
{
id: 'premium_9000',
name: '프리미엄 패키지',
price: 9000,
tokens: 10,
pricePerToken: 900,
badge: '최고 가성비',
},
);
return packages;
};
const handlePurchase = async (pkg: TokenPackage) => {
if (!window.IMP) {
alert('결제 모듈을 불러오는 중입니다. 잠시 후 다시 시도해주세요.');
return;
}
setPurchasing(true);
const { IMP } = window;
IMP.init(process.env.NEXT_PUBLIC_PORTONE_IMP_CODE);
const merchantUid = `token_${pkg.id}_${new Date().getTime()}`;
const data = {
pg: 'kakaopay',
pay_method: 'card',
merchant_uid: merchantUid,
name: `사주보기 토큰 ${pkg.tokens}개 (${pkg.name})`,
amount: pkg.price,
buyer_email: user.email,
};
IMP.request_pay(data, async (rsp: any) => {
if (rsp.success) {
try {
// 1. 결제 기록 저장 (token_amount 컬럼 없을 수 있으므로 fallback)
const paymentData: any = {
user_id: user.id,
imp_uid: rsp.imp_uid,
merchant_uid: rsp.merchant_uid,
amount: pkg.price,
status: 'paid',
};
const { error: paymentError } = await supabase
.from('payments')
.insert(paymentData);
if (paymentError) {
console.warn('결제 기록 저장 경고:', paymentError.message);
// merchant_uid 중복이면 이미 처리된 결제이므로 무시
if (!paymentError.message.includes('duplicate') && !paymentError.message.includes('unique')) {
console.error('결제 기록 저장 실패:', paymentError);
}
}
// 2. 크레딧 업데이트 - RPC 함수 시도 (atomic increment)
let newCredits = credits + pkg.tokens;
const { data: rpcResult, error: rpcError } = await supabase
.rpc('add_credits', { user_id_input: user.id, amount_input: pkg.tokens });
if (rpcError) {
console.warn('RPC add_credits 실패, 직접 업데이트 시도:', rpcError.message);
// RPC 함수가 아직 없는 경우 직접 업데이트
// 현재 최신 credits를 다시 조회 후 업데이트
const { data: freshProfile } = await supabase
.from('profiles')
.select('credits')
.eq('id', user.id)
.single();
const currentCredits = freshProfile?.credits || 0;
newCredits = currentCredits + pkg.tokens;
const { error: updateError } = await supabase
.from('profiles')
.update({ credits: newCredits })
.eq('id', user.id);
if (updateError) {
alert(`토큰 충전에 실패했습니다: ${updateError.message}\n고객센터에 문의해주세요.`);
setPurchasing(false);
return;
}
} else {
// RPC 성공 시 반환값이 최신 크레딧
newCredits = rpcResult ?? newCredits;
}
setCredits(newCredits);
alert(`결제가 완료되었습니다! 토큰 ${pkg.tokens}개가 충전되었어요.`);
setPurchasing(false);
onPurchaseComplete();
} catch (err) {
console.error('충전 처리 중 오류:', err);
alert('결제는 완료되었으나 토큰 충전 중 오류가 발생했습니다. 페이지를 새로고침해주세요.');
setPurchasing(false);
}
} else {
alert(`결제에 실패하였습니다: ${rsp.error_msg}`);
setPurchasing(false);
}
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-white rounded-3xl shadow-2xl max-w-lg w-full max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="p-6 border-b border-gray-100">
<div className="flex justify-between items-center">
<div>
<h2 className="text-2xl font-bold text-gray-900"> </h2>
<p className="text-sm text-gray-500 mt-1">
: <span className="font-bold text-indigo-600">{credits}</span>
</p>
</div>
<button
onClick={onClose}
className="w-10 h-10 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center transition"
>
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="p-6">
{loading ? (
<div className="flex justify-center py-12">
<div className="w-10 h-10 border-4 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
</div>
) : (
<div className="space-y-4">
{/* 토큰 설명 */}
<div className="bg-indigo-50 rounded-xl p-4 mb-6">
<p className="text-sm text-indigo-800">
1 , , .
</p>
</div>
{getPackages().map((pkg) => (
<button
key={pkg.id}
onClick={() => handlePurchase(pkg)}
disabled={purchasing}
className={`w-full rounded-2xl p-5 border-2 text-left transition-all hover:shadow-lg disabled:opacity-50 ${
pkg.highlight
? 'border-pink-400 bg-gradient-to-r from-pink-50 to-purple-50 hover:border-pink-500'
: 'border-gray-200 bg-white hover:border-indigo-300'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className={`font-bold text-lg ${pkg.highlight ? 'text-pink-700' : 'text-gray-900'}`}>
{pkg.name}
</h3>
{pkg.badge && (
<span className={`text-xs px-2 py-0.5 rounded-full font-semibold ${
pkg.highlight ? 'bg-pink-500 text-white' : 'bg-indigo-100 text-indigo-700'
}`}>
{pkg.badge}
</span>
)}
</div>
<p className="text-sm text-gray-500">
{pkg.tokens} | {pkg.pricePerToken.toLocaleString()}
</p>
</div>
<div className="text-right">
<div className={`text-2xl font-bold ${pkg.highlight ? 'text-pink-600' : 'text-indigo-600'}`}>
{pkg.price.toLocaleString()}
</div>
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="p-6 border-t border-gray-100">
<p className="text-xs text-gray-400 text-center">
. .
</p>
</div>
</div>
</div>
);
}

73
components/UserMenu.tsx Normal file
View File

@@ -0,0 +1,73 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { createBrowserClient } from '@supabase/ssr' // Use ssr browser client
import { User } from '@supabase/supabase-js';
import { useRouter } from 'next/navigation';
export default function UserMenu() {
const [user, setUser] = useState<User | null>(null);
const router = useRouter();
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
useEffect(() => {
// Check active session
const getUser = async () => {
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
};
getUser();
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, []);
const handleLogout = async () => {
await supabase.auth.signOut();
setUser(null);
router.push('/');
router.refresh();
};
if (user) {
return (
<div className="flex items-center space-x-4">
<Link
href="/mypage"
className="text-gray-700 hover:text-indigo-600 transition font-medium"
>
</Link>
<button
onClick={handleLogout}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg transition text-sm font-medium"
>
</button>
</div>
);
}
return (
<div className="flex items-center space-x-4">
<Link
href="/login"
className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition text-sm font-bold shadow-md hover:shadow-lg"
>
/
</Link>
</div>
);
}