Files
saju-web/components/TokenPurchaseModal.tsx

293 lines
9.7 KiB
TypeScript

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