293 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|