feat: checkout unificado com PaymentCheckoutCard planos dinamicos e PIX para assinaturas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s Details

This commit is contained in:
Sidney Gomes 2026-07-24 20:48:08 -03:00
parent 445caaa994
commit 0c83b67025
4 changed files with 857 additions and 842 deletions

View File

@ -986,8 +986,18 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic
asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`;
invoiceUrlStr = payment.invoiceUrl || '';
asaasDueDate = payment.dueDate || asaasDueDate;
// Also fetch PIX QR code for the first installment of recurring plans
if (billingType === 'PIX' && asaasPaymentId && !asaasPaymentId.startsWith('pay_')) {
try {
pixData = await AsaasService.getPixQrCode(asaasPaymentId);
} catch (pixErr) {
console.warn('Could not fetch PIX QR Code for subscription payment:', pixErr);
}
}
}
const newPayment = await prisma.payment.create({
data: {
id: asaasPaymentId,

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers, Clock } from 'lucide-react';
import { Check, Layers, Loader2 } from 'lucide-react';
import { Course, Plan } from '../types';
import PaymentCheckoutCard from './PaymentCheckoutCard';
interface CourseUnlockModalProps {
course: Course & { price?: number };
@ -11,607 +12,153 @@ interface CourseUnlockModalProps {
}
export default function CourseUnlockModal({ course, user, onClose, onSuccess, token }: CourseUnlockModalProps) {
const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [isPaying, setIsPaying] = useState(false);
const [pixCopied, setPixCopied] = useState(false);
const [unlocked, setUnlocked] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentData, setPaymentData] = useState<any>(null);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
useEffect(() => {
if (timeLeft === null) return;
if (timeLeft <= 0) {
const cancelPayment = async () => {
if (!paymentData?.payment?.id) return;
try {
await fetch(`/api/payments/${paymentData.payment.id}/cancel`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`
}
});
} catch (e) {
console.error('Failed to cancel expired payment:', e);
}
};
cancelPayment();
setPaymentData(null);
setError('Tempo limite do PIX esgotado (5 minutos). O pagamento foi cancelado automaticamente.');
setTimeLeft(null);
return;
}
const interval = setInterval(() => {
setTimeLeft(prev => (prev !== null ? prev - 1 : null));
}, 1000);
return () => clearInterval(interval);
}, [timeLeft, paymentData, token]);
// Form states
const [ccNumber, setCcNumber] = useState('');
const [ccName, setCcName] = useState('');
const [ccExpiry, setCcExpiry] = useState('');
const [ccCvv, setCcCvv] = useState('');
const [installments, setInstallments] = useState(1);
const [checkoutCpf, setCheckoutCpf] = useState(user?.cpf && user?.cpf !== '000.000.000-00' ? user.cpf : '');
// Pricing & Plans Selection
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null means 'Avulso'
const [loadingPlans, setLoadingPlans] = useState(true);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null = Avulso
const coursePrice = course.price || 49.90;
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const handleCopyPix = () => {
if (paymentData?.pixData?.payload) {
navigator.clipboard.writeText(paymentData.pixData.payload);
setPixCopied(true);
setTimeout(() => setPixCopied(false), 2000);
}
};
const handleCopyBoleto = () => {
if (paymentData?.payment?.invoiceUrl) {
window.open(paymentData.payment.invoiceUrl, '_blank');
}
};
const [enableBoleto, setEnableBoleto] = useState<boolean>(true);
const [enableInstallmentBoleto, setEnableInstallmentBoleto] = useState<boolean>(false);
const [maxBoletoInstallments, setMaxBoletoInstallments] = useState<number>(3);
useEffect(() => {
if (token) {
fetch('/api/settings', {
headers: { 'Authorization': `Bearer ${token}` }
if (!token) return;
fetch('/api/plans', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then((data: Plan[]) => {
// Show plans that include this course or are global (no specific courses)
const relevant = data.filter(
p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id)
);
setAvailablePlans(relevant);
})
.then(res => res.json())
.then(data => {
if (data) {
if (data.enableBoleto === false) {
setEnableBoleto(false);
}
if (data.enableInstallmentBoleto === true) {
setEnableInstallmentBoleto(true);
}
if (data.maxBoletoInstallments) {
setMaxBoletoInstallments(data.maxBoletoInstallments);
}
}
})
.catch(() => {});
// Fetch available plans for this course
fetch('/api/plans', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
const plans = data as Plan[];
// Filter plans that include this course or are global (empty includedCourses)
const relevant = plans.filter(p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id));
setAvailablePlans(relevant);
setLoadingPlans(false);
})
.catch(() => setLoadingPlans(false));
}
.catch(() => {})
.finally(() => setLoadingPlans(false));
}, [token, course.id]);
const handleConfirmPayment = async (e: React.FormEvent) => {
e.preventDefault();
setIsPaying(true);
setError(null);
const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
const currentItem = selectedPlanObj
? { id: selectedPlanObj.id, type: 'plan' as const, title: selectedPlanObj.title, price: selectedPlanObj.price, cycle: selectedPlanObj.cycle as any, description: selectedPlanObj.description || undefined }
: { id: course.id, type: 'course' as const, title: course.title, price: coursePrice, cycle: 'LIFETIME' as const, description: undefined };
const creditCardInfo = paymentMethod === 'CREDIT_CARD' ? {
creditCard: {
holderName: ccName,
number: ccNumber.replace(/\s+/g, ''),
expiryMonth: ccExpiry.split('/')[0]?.trim(),
expiryYear: '20' + ccExpiry.split('/')[1]?.trim(),
ccv: ccCvv
},
creditCardHolderInfo: {
name: ccName,
email: 'aluno@microtecflix.com', // Will be fetched by server
cpfCnpj: '000.000.000-00',
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999'
}
} : undefined;
if (!checkoutCpf || checkoutCpf.replace(/\D/g, '').length < 11) {
setError('Por favor, informe um CPF ou CNPJ válido.');
setIsPaying(false);
return;
}
try {
let endpoint = '';
let bodyData: any = {
billingType: paymentMethod,
creditCardInfo,
cpf: checkoutCpf
};
if (selectedPlanId) {
endpoint = `/api/subscription/subscribe`;
bodyData.planId = selectedPlanId;
} else {
endpoint = `/api/courses/${course.id}/unlock`;
bodyData.installmentCount = (paymentMethod === 'CREDIT_CARD' || (paymentMethod === 'BOLETO' && enableInstallmentBoleto)) ? installments : 1;
}
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(bodyData)
});
const contentType = res.headers.get('content-type');
let data: any = {};
if (contentType && contentType.includes('application/json')) {
data = await res.json();
} else {
const text = await res.text();
console.error('Non-JSON response received:', text);
throw new Error(`Ocorreu um erro no servidor de pagamentos (${res.status}).`);
}
if (!res.ok) {
throw new Error(data.error || data.message || 'Erro ao processar pagamento com o Asaas');
}
if (paymentMethod === 'PIX') {
setPaymentData(data);
setTimeLeft(300); // 5 minutes countdown
} else if (paymentMethod === 'BOLETO') {
setPaymentData(data);
setTimeLeft(null);
} else {
setUnlocked(true); // Credit Card assumes instant in sandbox
}
} catch (err: any) {
setError(err.message || 'Erro de conexão.');
} finally {
setIsPaying(false);
const getCycleLabel = (cycle: string) => {
switch (cycle) {
case 'MONTHLY': return '/ mês';
case 'YEARLY': return '/ ano';
case 'LIFETIME': return 'taxa única';
default: return '';
}
};
const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
const currentPrice = selectedPlanObj ? selectedPlanObj.price : coursePrice;
return (
<div id="unlock-modal-overlay" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in overflow-y-auto">
<div
id="unlock-modal-overlay"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in overflow-y-auto"
>
<div className="relative w-full max-w-4xl bg-[#141414]/95 border border-white/10 rounded-2xl shadow-2xl overflow-hidden md:flex my-8">
{/* Close Button */}
<button
onClick={onClose}
className="absolute top-4 right-4 z-20 bg-black/40 text-zinc-400 hover:text-white p-2 rounded-full border border-white/5 transition-colors cursor-pointer"
>
<X className="w-4 h-4" />
</button>
{!unlocked ? (
<>
{/* Left: Options & Selection */}
<div className="w-full md:w-1/2 bg-zinc-950 p-6 md:p-8 flex flex-col border-r border-white/5 overflow-y-auto custom-scrollbar max-h-[85vh]">
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<img src={course.thumbnail} alt={course.title} className="w-20 h-20 object-cover rounded-lg border border-white/10" />
<div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block mb-1.5">
Adquirir Acesso
</span>
<h3 className="font-display font-black text-lg text-white leading-tight line-clamp-2">
{course.title}
</h3>
</div>
</div>
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.</p>
{/* LEFT: Course info + Plan selection */}
<div className="w-full md:w-5/12 bg-zinc-950 p-6 md:p-8 flex flex-col border-r border-white/5 overflow-y-auto custom-scrollbar max-h-[85vh]">
{/* Course header */}
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<img
src={course.thumbnail}
alt={course.title}
className="w-20 h-20 object-cover rounded-lg border border-white/10"
/>
<div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block mb-1.5">
Adquirir Acesso
</span>
<h3 className="font-display font-black text-lg text-white leading-tight line-clamp-2">
{course.title}
</h3>
</div>
</div>
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você.</p>
</div>
<div className="space-y-4">
{/* Option 1: Avulso */}
{/* Option list */}
<div className="space-y-3">
{/* Avulso */}
<div
onClick={() => setSelectedPlanId(null)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
selectedPlanId === null
? 'bg-red-950/20 border-[#E50914]'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}
>
{selectedPlanId === null && (
<div className="absolute top-3 right-3 text-[#E50914]">
<Check className="w-5 h-5" />
</div>
)}
<h4 className="font-bold text-white mb-1">Compra Avulsa (Apenas este curso)</h4>
<p className="text-xs text-zinc-400 mb-3">Pague uma vez e tenha acesso vitalício ao curso.</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{coursePrice.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">taxa única</span>
</div>
</div>
{/* Plans/Combos */}
{loadingPlans ? (
<div className="p-8 flex justify-center">
<Loader2 className="w-6 h-6 animate-spin text-[#E50914]" />
</div>
) : (
availablePlans.map(plan => (
<div
onClick={() => setSelectedPlanId(null)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
selectedPlanId === null
? 'bg-red-950/20 border-[#E50914]'
key={plan.id}
onClick={() => setSelectedPlanId(plan.id)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${
selectedPlanId === plan.id
? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}
>
{selectedPlanId === null && (
<div className="absolute top-3 right-3 text-[#E50914]"><Check className="w-5 h-5" /></div>
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg rounded-tr-lg uppercase tracking-wider">
Recomendado
</div>
{selectedPlanId === plan.id && (
<div className="absolute top-3 right-3 mt-4 text-purple-400">
<Check className="w-5 h-5" />
</div>
)}
<h4 className="font-bold text-white mb-1">Compra Avulsa (Apenas este curso)</h4>
<p className="text-xs text-zinc-400 mb-3">Pague uma vez e tenha acesso vitalício ao curso selecionado.</p>
<h4 className="font-bold text-white mb-1 flex items-center gap-2 mt-2">
<Layers className="w-4 h-4 text-purple-400" />
{plan.title}
</h4>
<p className="text-xs text-zinc-400 mb-3">
{plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')}
</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{coursePrice.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">taxa única</span>
<span className="text-2xl font-black text-white">{plan.price.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">{getCycleLabel(plan.cycle)}</span>
</div>
</div>
{/* Option 2+: Plans/Combos */}
{loadingPlans ? (
<div className="p-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-[#E50914]" /></div>
) : availablePlans.map(plan => (
<div
key={plan.id}
onClick={() => setSelectedPlanId(plan.id)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${
selectedPlanId === plan.id
? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}
>
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg uppercase tracking-wider">
Recomendado
</div>
{selectedPlanId === plan.id && (
<div className="absolute top-3 right-3 mt-3 text-purple-400"><Check className="w-5 h-5" /></div>
)}
<h4 className="font-bold text-white mb-1 flex items-center gap-2 mt-2">
<Layers className="w-4 h-4 text-purple-400" />
{plan.title}
</h4>
<p className="text-xs text-zinc-400 mb-3">{plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')}</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{plan.price.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">
{plan.cycle === 'MONTHLY' ? '/ mês' : plan.cycle === 'YEARLY' ? '/ ano' : 'taxa única'}
</span>
</div>
</div>
))}
</div>
</div>
{/* Right: Payment Forms */}
<div className="w-full md:w-1/2 p-6 md:p-8 flex flex-col space-y-6 max-h-[85vh] overflow-y-auto custom-scrollbar bg-[#111] notranslate" translate="no">
<div>
<h4 className="font-display font-bold text-sm text-white uppercase tracking-wider mb-4">Escolha a forma de pagamento</h4>
{/* Tabs */}
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 bg-zinc-900/80 rounded-xl border border-white/5 mb-6`}>
<button
type="button"
onClick={() => { setPaymentMethod('PIX'); setInstallments(1); }}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'PIX' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<QrCode className="w-4 h-4 mb-1" />
<span>PIX</span>
</button>
<button
type="button"
onClick={() => { setPaymentMethod('CREDIT_CARD'); setInstallments(1); }}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'CREDIT_CARD' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<CreditCard className="w-4 h-4 mb-1" />
<span>Cartão</span>
</button>
{enableBoleto && (
<button
type="button"
onClick={() => { setPaymentMethod('BOLETO'); setInstallments(1); }}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${paymentMethod === 'BOLETO' ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'}`}
>
<FileText className="w-4 h-4 mb-1" />
<span>Boleto</span>
</button>
)}
</div>
{error && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-xl text-xs text-center mb-4">
{error}
</div>
)}
{/* Tab Contents */}
<form onSubmit={handleConfirmPayment} className="space-y-4">
{paymentMethod === 'PIX' && (
<div className="space-y-4 text-center">
{!paymentData ? (
<div className="text-zinc-400 text-xs py-4 text-center">
<span>Clique em Confirmar para gerar seu QR Code PIX no valor de R$ </span>
<span className="font-bold">{currentPrice.toFixed(2)}</span>
</div>
) : (
<>
<div className="bg-white p-3 rounded-xl inline-block shadow-xl border border-zinc-200">
{paymentData.pixData?.encodedImage && paymentData.pixData.encodedImage.length > 20 ? (
<img
src={paymentData.pixData.encodedImage.startsWith('data:') ? paymentData.pixData.encodedImage : `data:image/png;base64,${paymentData.pixData.encodedImage}`}
alt="QR Code PIX"
className="w-40 h-40 object-contain mx-auto"
/>
) : paymentData.pixData?.payload ? (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(paymentData.pixData.payload)}`}
alt="QR Code PIX"
className="w-40 h-40 object-contain mx-auto"
/>
) : (
<div className="w-40 h-40 bg-zinc-100 flex items-center justify-center text-zinc-500 text-xs font-bold rounded-lg">Gerando QR Code...</div>
)}
</div>
<div className="space-y-2">
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Escaneie o QR Code acima com o app do seu banco ou copie a chave Pix Copia e Cola abaixo.
</p>
<div className="flex items-center space-x-2 bg-zinc-900 rounded-lg p-2.5 border border-white/5">
<input
type="text"
readOnly
value={paymentData.pixData?.payload || ""}
className="bg-transparent text-zinc-300 text-xs font-mono select-all focus:outline-none flex-grow"
/>
<button
type="button"
onClick={handleCopyPix}
className="bg-zinc-800 text-xs text-white hover:bg-zinc-700 px-3 py-1.5 rounded-md font-bold transition-all cursor-pointer flex items-center space-x-1"
>
{pixCopied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : null}
<span>{pixCopied ? 'Copiado!' : 'Copiar'}</span>
</button>
</div>
{timeLeft !== null && (
<div className="flex items-center justify-center space-x-1.5 text-xs font-semibold text-zinc-400 bg-zinc-900/60 border border-white/5 py-2 px-4 rounded-xl w-fit mx-auto mt-3">
<Clock className="w-3.5 h-3.5 text-[#E50914] animate-pulse" />
<span>O QR Code expira em:</span>
<span className="text-white font-mono font-bold">{formatTime(timeLeft)}</span>
</div>
)}
</div>
</>
)}
</div>
)}
{paymentMethod === 'CREDIT_CARD' && (
<div className="space-y-3.5">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Número do Cartão</label>
<input
type="text"
required
value={ccNumber}
onChange={(e) => setCcNumber(e.target.value.replace(/\D/g, '').replace(/(\d{4})/g, '$1 ').trim().slice(0, 19))}
placeholder="0000 0000 0000 0000"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nome Impresso no Cartão</label>
<input
type="text"
required
value={ccName}
onChange={(e) => setCcName(e.target.value.toUpperCase())}
placeholder="NOME IGUAL DO CARTÃO"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Validade</label>
<input
type="text"
required
value={ccExpiry}
onChange={(e) => setCcExpiry(e.target.value.replace(/\D/g, '').replace(/(\d{2})/g, '$1/').slice(0, 5))}
placeholder="MM/AA"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">CVV</label>
<input
type="password"
required
value={ccCvv}
onChange={(e) => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="123"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
</div>
{/* Parcelamento (apenas para Vitalício/Pagamento Único) */}
{(!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && (
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Opções de Parcelamento</label>
<select
value={installments}
onChange={(e) => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => {
const val = (currentPrice / n).toFixed(2);
return (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {val} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
</option>
);
})}
</select>
</div>
)}
</div>
)}
{paymentMethod === 'BOLETO' && (
<div className="space-y-4 text-center py-4 bg-zinc-900/40 rounded-xl border border-white/5">
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-full inline-block">
<FileText className="w-8 h-8" />
</div>
<div className="space-y-2">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
{!paymentData ? (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {currentPrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis.
</p>
) : (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Boleto gerado com sucesso no valor de R$ {currentPrice.toFixed(2)} {paymentData.payment?.installmentCount > 1 ? `parcelado em ${paymentData.payment.installmentCount}x` : ''}.
</p>
)}
{!paymentData && enableInstallmentBoleto && (!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && (
<div className="space-y-1 max-w-xs mx-auto text-left mt-2 pb-2">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block mb-1">Opções de Parcelamento</label>
<select
value={installments}
onChange={(e) => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{Array.from({ length: maxBoletoInstallments }, (_, i) => i + 1).map(n => {
const val = (currentPrice / n).toFixed(2);
return (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {val} {n === 1 ? '(À vista)' : '(parcelado)'}
</option>
);
})}
</select>
</div>
)}
<div className="pt-2">
{paymentData?.payment?.invoiceUrl ? (
<button
type="button"
onClick={handleCopyBoleto}
className="glass-btn-secondary text-xs text-white font-semibold py-2 px-4 rounded-lg inline-flex items-center space-x-2"
>
<FileText className="w-4 h-4" />
<span>Visualizar e Imprimir Boleto</span>
</button>
) : (
<div className="text-xs text-zinc-500">Clique abaixo para gerar o boleto</div>
)}
</div>
</div>
</div>
)}
{/* CPF/CNPJ Input (Required for all Asaas payments) */}
{!paymentData && (
<div className="space-y-1 mt-4">
<label className="text-zinc-400 text-xs font-bold block">CPF ou CNPJ do Titular</label>
<input
type="text"
placeholder="000.000.000-00"
value={checkoutCpf}
onChange={(e) => setCheckoutCpf(e.target.value)}
className="w-full bg-zinc-900 border border-white/10 rounded-xl py-3 px-4 text-xs text-white focus:border-[#E50914] focus:outline-none transition-colors"
required
/>
<p className="text-[10px] text-zinc-500">Necessário para emissão do Pix, boleto ou cobrança de cartão via Asaas.</p>
</div>
)}
{/* Submit button */}
{!paymentData && (
<div className="pt-4 mt-auto">
<button
type="submit"
disabled={isPaying}
className={`w-full text-white text-xs font-bold py-4 px-4 rounded-xl flex items-center justify-center space-x-2 cursor-pointer disabled:opacity-50 transition-all ${
selectedPlanId ? 'bg-purple-600 hover:bg-purple-700 shadow-[0_0_20px_rgba(147,51,234,0.4)]' : 'bg-[#E50914] hover:bg-red-700 shadow-[0_0_20px_rgba(229,9,20,0.4)]'
}`}
>
{isPaying ? (
<>
<Loader2 className="w-4 h-4 animate-spin text-white" />
<span>Processando Pagamento Segurizado...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-yellow-400" />
<span className="text-sm">Confirmar: R$ {currentPrice.toFixed(2)}</span>
</>
)}
</button>
<div className="text-center text-[10px] text-zinc-500 mt-4">
Pagamento via API Oficial Sandbox Asaas.
</div>
</div>
)}
</form>
</div>
</div>
</>
) : (
/* Success Step */
<div className="w-full p-8 md:p-12 text-center flex flex-col items-center justify-center space-y-6">
<div className="bg-emerald-500 text-white p-4 rounded-full shadow-[0_0_25px_rgba(16,185,129,0.5)] border border-emerald-400 animate-bounce">
<Check className="w-10 h-10 stroke-[3]" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="font-display font-black text-2xl text-white">
Pagamento Confirmado!
</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Parabéns! O seu pagamento foi aprovado com sucesso na plataforma. Seu acesso está liberado!
</p>
</div>
<button
onClick={onSuccess}
className="glass-btn-primary text-white text-xs font-bold py-3 px-8 rounded-lg flex items-center justify-center space-x-2 shadow-lg transition-transform hover:scale-105 cursor-pointer bg-emerald-600 border-none"
>
<Play className="w-4 h-4 fill-white" />
<span>Começar a Estudar Agora</span>
</button>
))
)}
</div>
)}
</div>
{/* RIGHT: Unified checkout form via PaymentCheckoutCard */}
<div className="w-full md:w-7/12 p-6 md:p-8 max-h-[85vh] overflow-y-auto custom-scrollbar bg-[#111]">
<PaymentCheckoutCard
itemId={currentItem.id}
itemType={currentItem.type}
title={currentItem.title}
price={currentItem.price}
cycle={currentItem.cycle}
description={currentItem.description}
user={user}
token={token}
embedded={true}
onClose={onClose}
onSuccess={onSuccess}
/>
</div>
</div>
</div>
);

View File

@ -0,0 +1,590 @@
import React, { useState, useEffect } from 'react';
import {
X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Clock, RefreshCw, AlertTriangle
} from 'lucide-react';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type PaymentItemType = 'course' | 'plan';
export type BillingCycle = 'MONTHLY' | 'YEARLY' | 'LIFETIME';
export type BillingMethod = 'PIX' | 'CREDIT_CARD' | 'BOLETO';
export interface PaymentCheckoutCardProps {
/** Unique ID (course id or plan id) */
itemId: string;
itemType: PaymentItemType;
/** Display title shown on the checkout */
title: string;
price: number;
/** Billing cycle LIFETIME = single payment */
cycle?: BillingCycle;
/** Optional extra description / subtitle */
description?: string;
user: { name: string; email: string; cpf?: string } | null;
token: string | null;
onSuccess?: () => void;
/** Close button handler (modal use-case) */
onClose?: () => void;
/** When true, renders without the modal overlay wrapper */
embedded?: boolean;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
const s = (seconds % 60).toString().padStart(2, '0');
return `${m}:${s}`;
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
const PaymentCheckoutCard: React.FC<PaymentCheckoutCardProps> = ({
itemId,
itemType,
title,
price,
cycle = 'LIFETIME',
description,
user,
token,
onSuccess,
onClose,
embedded = false,
}) => {
// ── Settings ──────────────────────────────────────────────────────────────
const [enableBoleto, setEnableBoleto] = useState(true);
const [enableInstallmentBoleto, setEnableInstallmentBoleto] = useState(false);
const [maxBoletoInstallments, setMaxBoletoInstallments] = useState(3);
// ── Payment method & form fields ──────────────────────────────────────────
const [billingType, setBillingType] = useState<BillingMethod>('PIX');
const [installments, setInstallments] = useState(1);
const [ccNumber, setCcNumber] = useState('');
const [ccName, setCcName] = useState('');
const [ccExpiry, setCcExpiry] = useState('');
const [ccCvv, setCcCvv] = useState('');
const [checkoutCpf, setCheckoutCpf] = useState(
user?.cpf && user.cpf !== '000.000.000-00' ? user.cpf : ''
);
// ── State ─────────────────────────────────────────────────────────────────
const [isPaying, setIsPaying] = useState(false);
const [error, setError] = useState<string | null>(null);
const [paymentData, setPaymentData] = useState<any>(null);
const [pixCopied, setPixCopied] = useState(false);
const [timeLeft, setTimeLeft] = useState<number | null>(null);
const [unlocked, setUnlocked] = useState(false);
// ── Fetch settings ────────────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
fetch('/api/settings', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.json())
.then(d => {
if (d?.enableBoleto === false) setEnableBoleto(false);
if (d?.enableInstallmentBoleto === true) setEnableInstallmentBoleto(true);
if (d?.maxBoletoInstallments) setMaxBoletoInstallments(d.maxBoletoInstallments);
})
.catch(() => {});
}, [token]);
// ── PIX countdown ─────────────────────────────────────────────────────────
useEffect(() => {
if (timeLeft === null) return;
if (timeLeft <= 0) {
const cancel = async () => {
if (!paymentData?.payment?.id) return;
try {
await fetch(`/api/payments/${paymentData.payment.id}/cancel`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
} catch {}
};
cancel();
setPaymentData(null);
setError('Tempo limite do PIX esgotado (5 minutos). O pagamento foi cancelado automaticamente.');
setTimeLeft(null);
return;
}
const t = setInterval(() => setTimeLeft(p => (p !== null ? p - 1 : null)), 1000);
return () => clearInterval(t);
}, [timeLeft, paymentData, token]);
// ── Handlers ──────────────────────────────────────────────────────────────
const handleCopyPix = () => {
if (paymentData?.pixData?.payload) {
navigator.clipboard.writeText(paymentData.pixData.payload);
setPixCopied(true);
setTimeout(() => setPixCopied(false), 2000);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsPaying(true);
setError(null);
if (!checkoutCpf || checkoutCpf.replace(/\D/g, '').length < 11) {
setError('Por favor, informe um CPF ou CNPJ válido.');
setIsPaying(false);
return;
}
const creditCardInfo =
billingType === 'CREDIT_CARD'
? {
creditCard: {
holderName: ccName,
number: ccNumber.replace(/\s+/g, ''),
expiryMonth: ccExpiry.split('/')[0]?.trim(),
expiryYear: '20' + ccExpiry.split('/')[1]?.trim(),
ccv: ccCvv,
},
creditCardHolderInfo: {
name: ccName,
email: user?.email || '',
cpfCnpj: checkoutCpf,
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999',
},
}
: undefined;
const endpoint =
itemType === 'course'
? `/api/courses/${itemId}/unlock`
: `/api/subscription/subscribe`;
const bodyData: Record<string, any> = { billingType, creditCardInfo, cpf: checkoutCpf };
if (itemType === 'plan') bodyData.planId = itemId;
if (
itemType === 'course' &&
(billingType === 'CREDIT_CARD' || (billingType === 'BOLETO' && enableInstallmentBoleto))
) {
bodyData.installmentCount = installments;
}
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(bodyData),
});
const ct = res.headers.get('content-type');
let data: any = {};
if (ct?.includes('application/json')) {
data = await res.json();
} else {
const text = await res.text();
console.error('Non-JSON response:', text);
throw new Error(`Erro no servidor de pagamentos (${res.status}).`);
}
if (!res.ok) throw new Error(data.error || data.message || 'Erro ao processar pagamento.');
if (billingType === 'PIX') {
setPaymentData(data);
setTimeLeft(300);
} else if (billingType === 'BOLETO') {
setPaymentData(data);
} else {
setUnlocked(true);
}
} catch (err: any) {
setError(err.message || 'Erro de conexão. Tente novamente.');
} finally {
setIsPaying(false);
}
};
// Single-payment: course avulso or LIFETIME combo (allows installments)
const isSinglePayment = cycle === 'LIFETIME' || itemType === 'course';
const inputClass =
'w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40 border border-white/10 focus:border-[#E50914] transition-colors';
// ── SUCCESS SCREEN ─────────────────────────────────────────────────────────
if (unlocked) {
return (
<div className="w-full p-8 text-center flex flex-col items-center justify-center space-y-6">
<div className="bg-emerald-500 text-white p-4 rounded-full shadow-[0_0_25px_rgba(16,185,129,0.5)] border border-emerald-400 animate-bounce">
<Check className="w-10 h-10 stroke-[3]" />
</div>
<div className="space-y-2 max-w-md">
<h3 className="font-display font-black text-2xl text-white">Pagamento Confirmado!</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Parabéns! Seu pagamento foi aprovado. O acesso está liberado!
</p>
</div>
{onSuccess && (
<button
onClick={onSuccess}
className="glass-btn-primary text-white text-xs font-bold py-3 px-8 rounded-lg flex items-center space-x-2 shadow-lg hover:scale-105 transition-transform cursor-pointer bg-emerald-600 border-none"
>
<Play className="w-4 h-4 fill-white" />
<span>Começar a Estudar Agora</span>
</button>
)}
{onClose && (
<button onClick={onClose} className="text-zinc-500 hover:text-white text-xs cursor-pointer underline">
Fechar
</button>
)}
</div>
);
}
// ── MAIN CARD ─────────────────────────────────────────────────────────────
const inner = (
<div className="relative w-full space-y-6" translate="no">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block mb-2">
{itemType === 'plan'
? cycle === 'LIFETIME'
? 'Combo — Pagamento Único'
: cycle === 'YEARLY'
? 'Plano Anual'
: 'Assinatura Mensal'
: 'Compra Avulsa do Curso'}
</span>
<h3 className="font-display font-black text-xl text-white leading-tight">{title}</h3>
{description && <p className="text-xs text-zinc-400 mt-1">{description}</p>}
<div className="flex items-baseline gap-1 mt-3">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-3xl font-black text-white">{price.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">
{cycle === 'MONTHLY' ? '/ mês' : cycle === 'YEARLY' ? '/ ano' : 'taxa única'}
</span>
</div>
</div>
{onClose && (
<button
onClick={onClose}
className="bg-black/40 text-zinc-400 hover:text-white p-2 rounded-full border border-white/5 transition-colors cursor-pointer ml-4 flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
)}
</div>
{/* Payment Method Tabs */}
<div
className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 bg-zinc-900/80 rounded-xl border border-white/5`}
>
{(['PIX', 'CREDIT_CARD', ...(enableBoleto ? ['BOLETO'] : [])] as BillingMethod[]).map(method => {
const icons: Record<BillingMethod, React.ReactNode> = {
PIX: <QrCode className="w-4 h-4 mb-1" />,
CREDIT_CARD: <CreditCard className="w-4 h-4 mb-1" />,
BOLETO: <FileText className="w-4 h-4 mb-1" />,
};
const labels: Record<BillingMethod, string> = { PIX: 'PIX', CREDIT_CARD: 'Cartão', BOLETO: 'Boleto' };
return (
<button
key={method}
type="button"
onClick={() => {
setBillingType(method);
setError(null);
setPaymentData(null);
setTimeLeft(null);
setInstallments(1);
}}
className={`flex flex-col items-center justify-center py-2.5 rounded-lg text-xs font-semibold transition-all cursor-pointer ${
billingType === method ? 'bg-[#E50914] text-white shadow-lg' : 'text-zinc-400 hover:text-white'
}`}
>
{icons[method]}
<span>{labels[method]}</span>
</button>
);
})}
</div>
{/* Error banner */}
{error && (
<div className="bg-red-950/40 border border-red-900/50 text-red-400 p-3 rounded-xl text-xs flex items-center gap-2">
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* ── PIX ────────────────────────────────────────────────────────── */}
{billingType === 'PIX' && (
<div className="space-y-4 text-center">
{!paymentData ? (
<div className="text-zinc-400 text-xs py-4">
Clique em <span className="font-bold text-white">Confirmar</span> para gerar seu QR Code PIX de{' '}
<span className="font-bold text-white">R$ {price.toFixed(2)}</span>.
</div>
) : (
<>
<div className="bg-white p-3 rounded-xl inline-block shadow-xl border border-zinc-200">
{paymentData.pixData?.encodedImage && paymentData.pixData.encodedImage.length > 20 ? (
<img
src={
paymentData.pixData.encodedImage.startsWith('data:')
? paymentData.pixData.encodedImage
: `data:image/png;base64,${paymentData.pixData.encodedImage}`
}
alt="QR Code PIX"
className="w-44 h-44 object-contain mx-auto"
/>
) : paymentData.pixData?.payload ? (
<img
src={`https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${encodeURIComponent(paymentData.pixData.payload)}`}
alt="QR Code PIX"
className="w-44 h-44 object-contain mx-auto"
/>
) : (
<div className="w-44 h-44 bg-zinc-100 flex items-center justify-center text-zinc-500 text-xs font-bold rounded-lg">
Gerando QR Code...
</div>
)}
</div>
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Escaneie o QR Code com o app do seu banco ou copie a chave Pix Copia e Cola abaixo.
</p>
<div className="flex items-center space-x-2 bg-zinc-900 rounded-lg p-2.5 border border-white/5">
<input
type="text"
readOnly
value={paymentData.pixData?.payload || ''}
className="bg-transparent text-zinc-300 text-xs font-mono select-all focus:outline-none flex-grow"
/>
<button
type="button"
onClick={handleCopyPix}
className="bg-zinc-800 text-xs text-white hover:bg-zinc-700 px-3 py-1.5 rounded-md font-bold transition-all cursor-pointer flex items-center space-x-1"
>
{pixCopied ? <Check className="w-3.5 h-3.5 text-emerald-500" /> : null}
<span>{pixCopied ? 'Copiado!' : 'Copiar'}</span>
</button>
</div>
{timeLeft !== null && (
<div className="flex items-center justify-center space-x-1.5 text-xs font-semibold text-zinc-400 bg-zinc-900/60 border border-white/5 py-2 px-4 rounded-xl w-fit mx-auto">
<Clock className="w-3.5 h-3.5 text-[#E50914] animate-pulse" />
<span>Expira em:</span>
<span className="text-white font-mono font-bold">{formatTime(timeLeft)}</span>
</div>
)}
</>
)}
</div>
)}
{/* ── CREDIT CARD ──────────────────────────────────────────────── */}
{billingType === 'CREDIT_CARD' && (
<div className="space-y-3.5">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Número do Cartão</label>
<input
type="text"
required
value={ccNumber}
onChange={e =>
setCcNumber(
e.target.value
.replace(/\D/g, '')
.replace(/(\d{4})/g, '$1 ')
.trim()
.slice(0, 19)
)
}
placeholder="0000 0000 0000 0000"
className={inputClass}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Nome impresso no Cartão</label>
<input
type="text"
required
value={ccName}
onChange={e => setCcName(e.target.value.toUpperCase())}
placeholder="NOME IGUAL DO CARTÃO"
className={inputClass}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Validade</label>
<input
type="text"
required
value={ccExpiry}
onChange={e =>
setCcExpiry(
e.target.value
.replace(/\D/g, '')
.replace(/(\d{2})(\d)/, '$1/$2')
.slice(0, 5)
)
}
placeholder="MM/AA"
className={inputClass}
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">CVV</label>
<input
type="password"
required
value={ccCvv}
onChange={e => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
placeholder="123"
className={inputClass}
/>
</div>
</div>
{/* Installments only for single-payment items */}
{isSinglePayment && (
<div className="space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Parcelamento</label>
<select
value={installments}
onChange={e => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {(price / n).toFixed(2)} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
</option>
))}
</select>
</div>
)}
</div>
)}
{/* ── BOLETO ───────────────────────────────────────────────────── */}
{billingType === 'BOLETO' && (
<div className="space-y-4 text-center py-4 bg-zinc-900/40 rounded-xl border border-white/5">
<div className="bg-[#E50914]/10 text-[#E50914] p-3 rounded-full inline-block">
<FileText className="w-8 h-8" />
</div>
<div className="space-y-2 px-4">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
{!paymentData ? (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {price.toFixed(2)}. Prazo de compensação: 12 dias úteis.
</p>
) : (
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
Boleto gerado no valor de R$ {price.toFixed(2)}.
</p>
)}
{!paymentData && enableInstallmentBoleto && isSinglePayment && (
<div className="max-w-xs mx-auto text-left mt-2 pb-2 space-y-1">
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider block">Parcelamento</label>
<select
value={installments}
onChange={e => setInstallments(Number(e.target.value))}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none cursor-pointer bg-zinc-900 border-white/10"
>
{Array.from({ length: maxBoletoInstallments }, (_, i) => i + 1).map(n => (
<option key={n} value={n} className="bg-zinc-900 text-white">
{n}x de R$ {(price / n).toFixed(2)} {n === 1 ? '(À vista)' : '(parcelado)'}
</option>
))}
</select>
</div>
)}
{paymentData?.payment?.invoiceUrl && (
<button
type="button"
onClick={() => window.open(paymentData.payment.invoiceUrl, '_blank')}
className="glass-btn-secondary text-xs text-white font-semibold py-2 px-4 rounded-lg inline-flex items-center space-x-2 mt-2 cursor-pointer"
>
<FileText className="w-4 h-4" />
<span>Visualizar e Imprimir Boleto</span>
</button>
)}
</div>
</div>
)}
{/* CPF field */}
{!paymentData && (
<div className="space-y-1">
<label className="text-zinc-400 text-xs font-bold block">CPF ou CNPJ do Titular</label>
<input
type="text"
placeholder="000.000.000-00"
value={checkoutCpf}
onChange={e => setCheckoutCpf(e.target.value)}
className="w-full bg-zinc-900 border border-white/10 rounded-xl py-3 px-4 text-xs text-white focus:border-[#E50914] focus:outline-none transition-colors"
required
/>
<p className="text-[10px] text-zinc-500">Necessário para emissão do Pix, boleto ou cartão via Asaas.</p>
</div>
)}
{/* Submit button */}
{!paymentData && (
<div className="pt-2">
<button
type="submit"
disabled={isPaying}
className="w-full bg-[#E50914] hover:bg-red-700 shadow-[0_0_20px_rgba(229,9,20,0.35)] text-white text-sm font-bold py-4 px-4 rounded-xl flex items-center justify-center space-x-2 cursor-pointer disabled:opacity-50 transition-all"
>
{isPaying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Processando pagamento segurizado...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-yellow-400" />
<span>Confirmar R$ {price.toFixed(2)}</span>
</>
)}
</button>
<p className="text-center text-[10px] text-zinc-500 mt-3">
Pagamento via API Oficial Asaas. Seus dados são criptografados e protegidos.
</p>
</div>
)}
</form>
</div>
);
// ── Embedded vs Modal wrapper ──────────────────────────────────────────────
if (embedded) {
return <div className="bg-zinc-900/60 border border-white/10 rounded-2xl p-6">{inner}</div>;
}
return (
<div
id="payment-checkout-overlay"
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in overflow-y-auto"
>
<div className="relative w-full max-w-lg bg-[#141414]/98 border border-white/10 rounded-2xl shadow-2xl p-6 md:p-8 my-8">
{inner}
</div>
</div>
);
};
export default PaymentCheckoutCard;

View File

@ -1,14 +1,17 @@
import React, { useState, useEffect } from 'react';
import {
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Play, Calendar, DollarSign,
ShieldCheck, ArrowRight, ExternalLink, QrCode
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Calendar, ExternalLink,
ShieldCheck, Layers, Sparkles, Clock3
} from 'lucide-react';
import { SubscriptionStatus, SubscriptionPayment } from '../types';
import { Plan } from '../types';
import PaymentCheckoutCard from './PaymentCheckoutCard';
interface SubscriptionViewProps {
user: {
name: string;
email: string;
cpf?: string;
subscriptionStatus: SubscriptionStatus;
} | null;
token: string | null;
@ -20,42 +23,21 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
const [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(user ? user.subscriptionStatus : null);
const [isLoading, setIsLoading] = useState(true);
// Checkout flow state
const [billingType, setBillingType] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [cardName, setCardName] = useState('');
const [cardNumber, setCardNumber] = useState('');
const [cardExpiry, setCardExpiry] = useState('');
const [cardCvv, setCardCvv] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [successPayload, setSuccessPayload] = useState<any>(null);
const [checkoutError, setCheckoutError] = useState<string | null>(null);
const [enableBoleto, setEnableBoleto] = useState<boolean>(true);
// Plans
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
const [loadingPlans, setLoadingPlans] = useState(true);
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
useEffect(() => {
fetchSubscriptionDetails();
if (token) {
fetch('/api/settings', {
headers: { 'Authorization': `Bearer ${token}` }
})
.then(res => res.json())
.then(data => {
if (data && data.enableBoleto === false) {
setEnableBoleto(false);
}
})
.catch(() => {});
}
fetchPlans();
}, []);
const fetchSubscriptionDetails = async () => {
setIsLoading(true);
try {
const res = await fetch('/api/subscription/status', {
headers: {
'Authorization': `Bearer ${token}`
}
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
@ -70,55 +52,20 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
}
};
const handleCheckout = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setCheckoutError(null);
setSuccessPayload(null);
const creditCardInfo = billingType === 'CREDIT_CARD' ? {
creditCard: {
holderName: cardName,
number: cardNumber.replace(/\s+/g, ''),
expiryMonth: cardExpiry.split('/')[0]?.trim(),
expiryYear: '20' + cardExpiry.split('/')[1]?.trim(),
ccv: cardCvv
},
creditCardHolderInfo: {
name: cardName,
email: user?.email,
cpfCnpj: '000.000.000-00',
postalCode: '01001-000',
addressNumber: '123',
phone: '11999999999'
}
} : undefined;
const fetchPlans = async () => {
setLoadingPlans(true);
try {
const res = await fetch('/api/subscription/subscribe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
billingType,
creditCardInfo
})
const res = await fetch('/api/plans', {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Erro ao processar assinatura');
if (res.ok) {
const data: Plan[] = await res.json();
setAvailablePlans(data);
}
setSuccessPayload(data);
// Wait a moment and fetch updated profile status
await fetchSubscriptionDetails();
} catch (err: any) {
setCheckoutError(err.message || 'Erro de rede. Tente novamente.');
} catch (err) {
console.error('Error fetching plans:', err);
} finally {
setIsSubmitting(false);
setLoadingPlans(false);
}
};
@ -142,6 +89,15 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
}
};
const getCycleLabel = (cycle: string) => {
switch (cycle) {
case 'MONTHLY': return '/ mês';
case 'YEARLY': return '/ ano';
case 'LIFETIME': return 'pagamento único';
default: return '';
}
};
const getPaymentStatusTag = (status: string) => {
switch (status) {
case 'CONFIRMED':
@ -165,27 +121,51 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
);
}
const hasPlans = availablePlans.length > 0;
return (
<div className="space-y-8 max-w-4xl mx-auto">
{/* Top Title */}
{/* Title */}
<div className="border-b border-white/10 pb-4">
<h1 className="text-2xl md:text-3xl font-display font-extrabold text-white tracking-tight flex items-center space-x-2">
<CreditCard className="w-8 h-8 text-[#E50914]" />
<span>Gestão de Assinatura & Cobranças</span>
<span>Minha Assinatura & Cobranças</span>
</h1>
<p className="text-xs text-zinc-500 mt-1">Veja seus comprovantes, históricos de faturamento e mude seu plano de estudos.</p>
<p className="text-xs text-zinc-500 mt-1">
Veja seus comprovantes, histórico de faturamento e adquira planos ou cursos.
</p>
</div>
{/* Main Grid: Status summary left, checkout right */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{/* Checkout Modal (when a plan is selected) */}
{selectedPlan && (
<PaymentCheckoutCard
itemId={selectedPlan.id}
itemType="plan"
title={selectedPlan.title}
price={selectedPlan.price}
cycle={selectedPlan.cycle as any}
description={selectedPlan.description || undefined}
user={user}
token={token}
onClose={() => setSelectedPlan(null)}
onSuccess={() => {
setSelectedPlan(null);
fetchSubscriptionDetails();
}}
/>
)}
{/* Left Hand: Current Plan details & invoice history */}
<div className={`grid grid-cols-1 ${hasPlans ? 'md:grid-cols-2' : ''} gap-8`}>
{/* LEFT: Account status + Invoice history */}
<div className="space-y-6">
{/* Account Status Card */}
<div className="glass-card p-6 rounded-2xl space-y-5">
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Sua Conta</h3>
<div className="flex items-center space-x-4">
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center text-zinc-300">
<div className="w-12 h-12 rounded-full glass-badge flex items-center justify-center">
<ShieldCheck className="w-6 h-6 text-[#E50914]" />
</div>
<div className="space-y-0.5">
@ -194,7 +174,7 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
</div>
</div>
<div className="border-t border-zinc-850 pt-4 space-y-2">
<div className="border-t border-zinc-800 pt-4 space-y-2">
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Status Atual do Acesso</p>
{subStatus && (
<div className={`inline-block px-3 py-1.5 rounded-lg border text-sm font-extrabold ${getStatusColor(subStatus)}`}>
@ -204,37 +184,33 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
{subStatus === 'TRIAL' && (
<p className="text-xs text-zinc-400 font-medium pt-1">
Seu período de teste está ativo. Para continuar com acesso ilimitado a todos os cursos, módulos e certificados, ative sua assinatura mensal abaixo.
Seu período de teste está ativo. Para continuar com acesso completo, adquira um dos planos abaixo.
</p>
)}
{subStatus === 'ACTIVE' && (
<p className="text-xs text-emerald-500/80 font-bold flex items-center space-x-1 pt-1">
<CheckCircle className="w-4 h-4 fill-emerald-500/10" />
<span>Acesso integral liberado a todos os cursos de informática, hardware e servidores!</span>
<span>Acesso integral liberado a todos os cursos!</span>
</p>
)}
{subStatus === 'OVERDUE' && (
<p className="text-xs text-red-500/80 font-semibold flex items-center space-x-1 pt-1">
<AlertTriangle className="w-4 h-4 fill-red-500/10" />
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via Pix ou Cartão para liberar.</span>
<span>Seu acesso foi bloqueado por falta de pagamento. Regularize via um dos planos abaixo.</span>
</p>
)}
</div>
</div>
{/* Past Payments History */}
{/* Invoice History */}
<div className="glass-card p-6 rounded-2xl space-y-4">
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Faturas e Segunda Via</h3>
<div className="space-y-3">
{payments.length === 0 ? (
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
) : (
payments.map(pay => (
<div
key={pay.id}
className="p-3.5 glass-badge rounded-xl flex items-center justify-between"
>
<div key={pay.id} className="p-3.5 glass-badge rounded-xl flex items-center justify-between">
<div className="space-y-1">
<div className="flex items-center space-x-2">
<span className="text-white text-xs font-bold">R$ {pay.value.toFixed(2)}</span>
@ -245,14 +221,13 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
<span>Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}</span>
</p>
</div>
<a
href={pay.invoiceUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-zinc-400 hover:text-[#E50914] glass-btn-secondary px-3 py-1.5 rounded-lg font-bold transition-all flex items-center space-x-1"
>
<span>Boleto / Pix / Detalhes</span>
<span>Detalhes</span>
<ExternalLink className="w-3 h-3" />
</a>
</div>
@ -262,177 +237,70 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
</div>
</div>
{/* Right Hand: Checkout Form (Create Subscription) */}
<div className="space-y-6">
<div className="glass-card p-6 rounded-2xl space-y-6">
<div className="space-y-1">
<h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3>
<p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1">
<span>Trilha completa da Plataforma por apenas</span>
<span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span>
{/* RIGHT: Plans (only shown if plans exist) */}
{hasPlans && (
<div className="space-y-6">
<div className="glass-card p-6 rounded-2xl space-y-4">
<div className="flex items-center space-x-2">
<Sparkles className="w-5 h-5 text-[#E50914]" />
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">
Planos & Combos Disponíveis
</h3>
</div>
<p className="text-xs text-zinc-500">
Escolha o plano ideal e adquira acesso aos conteúdos da plataforma.
</p>
</div>
{/* Billing Types Toggle */}
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 glass-badge rounded-lg`}>
<button
type="button"
onClick={() => { setBillingType('PIX'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'PIX' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
PIX
</button>
<button
type="button"
onClick={() => { setBillingType('CREDIT_CARD'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'BOLETO' && !enableBoleto ? 'bg-[#E50914] text-white' : billingType === 'CREDIT_CARD' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Cartão de Crédito
</button>
{enableBoleto && (
<button
type="button"
onClick={() => { setBillingType('BOLETO'); setCheckoutError(null); }}
className={`py-2 text-xs font-bold rounded-md transition-all cursor-pointer ${billingType === 'BOLETO' ? 'bg-[#E50914] text-white' : 'text-zinc-400 hover:text-zinc-200'}`}
>
Boleto
</button>
{loadingPlans ? (
<div className="flex justify-center py-8">
<RefreshCw className="w-6 h-6 animate-spin text-[#E50914]" />
</div>
) : (
<div className="space-y-3">
{availablePlans.map(plan => (
<div
key={plan.id}
className="relative p-4 rounded-xl border border-white/10 hover:border-[#E50914]/50 bg-zinc-900/50 hover:bg-zinc-800/60 transition-all cursor-pointer group"
onClick={() => setSelectedPlan(plan)}
>
{plan.cycle === 'LIFETIME' && (
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg rounded-tr-lg uppercase tracking-wider">
Combo
</div>
)}
<div className="flex items-start gap-3">
<div className="mt-0.5">
<Layers className="w-5 h-5 text-[#E50914]" />
</div>
<div className="flex-1 min-w-0">
<h4 className="font-bold text-white text-sm leading-tight">{plan.title}</h4>
{plan.description && (
<p className="text-xs text-zinc-400 mt-0.5">{plan.description}</p>
)}
{plan.includedCourses && plan.includedCourses.length === 0 && (
<p className="text-xs text-emerald-400 mt-0.5 font-medium"> Acesso global a todos os cursos</p>
)}
<div className="flex items-baseline gap-1 mt-2">
<span className="text-[11px] text-zinc-500 font-bold">R$</span>
<span className="text-xl font-black text-white">{plan.price.toFixed(2)}</span>
<span className="text-[11px] text-zinc-500 ml-1">{getCycleLabel(plan.cycle)}</span>
</div>
</div>
<div className="flex-shrink-0 flex items-center justify-center">
<div className="text-[10px] font-bold text-[#E50914] bg-[#E50914]/10 border border-[#E50914]/20 px-3 py-1.5 rounded-lg group-hover:bg-[#E50914] group-hover:text-white transition-all whitespace-nowrap flex items-center gap-1">
<Clock3 className="w-3 h-3" />
Contratar
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Error alerts */}
{checkoutError && (
<div className="p-3 bg-red-950/40 border border-red-900/50 rounded-lg text-xs text-red-400">
{checkoutError}
</div>
)}
{/* SUCCESS PAYLOAD (PIX QR CODE OR CREDIT CARD CLEAR) */}
{successPayload ? (
<div className="bg-zinc-900/60 border border-emerald-950 p-5 rounded-xl space-y-4 text-center">
<div className="w-10 h-10 bg-emerald-500/10 text-emerald-500 rounded-full flex items-center justify-center mx-auto">
<CheckCircle className="w-6 h-6 fill-emerald-500/10" />
</div>
<div className="space-y-1">
<h4 className="text-sm font-bold text-white">Inscrição criada com sucesso!</h4>
<p className="text-xs text-zinc-400">
Sua assinatura está sendo faturada e integrada com o Asaas.
</p>
</div>
{billingType === 'PIX' && (
<div className="space-y-3 pt-2">
<p className="text-[11px] text-zinc-400 max-w-sm mx-auto">
Para sua segurança, os pagamentos recorrentes via PIX e Boleto são processados diretamente no ambiente seguro do Asaas. Clique no botão abaixo para escanear o QR Code oficial ou copiar a linha digitável.
</p>
</div>
)}
<div className="pt-2">
<a
href={successPayload.payment?.invoiceUrl}
target="_blank"
rel="noreferrer"
className="w-full bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold py-2.5 px-4 rounded-lg transition-colors flex items-center justify-center space-x-1.5"
>
<span>Abrir Fatura Oficial (Asaas)</span>
<ExternalLink className="w-4 h-4" />
</a>
</div>
</div>
) : (
/* ACTIVE CHECKOUT FORM */
<form onSubmit={handleCheckout} className="space-y-4">
{billingType === 'CREDIT_CARD' ? (
<div className="space-y-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Nome impresso no Cartão</label>
<input
type="text"
required
value={cardName}
onChange={(e) => setCardName(e.target.value)}
placeholder="Ex: SIDNEY GOMES"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Número do Cartão de Crédito</label>
<input
type="text"
required
maxLength={19}
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value.replace(/\s?/g, '').replace(/(\d{4})/g, '$1 ').trim())}
placeholder="4551 1234 5678 9012"
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">Vencimento (MM/AA)</label>
<input
type="text"
required
maxLength={5}
placeholder="12/29"
value={cardExpiry}
onChange={(e) => setCardExpiry(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-zinc-500 font-bold uppercase">CVV</label>
<input
type="password"
required
maxLength={4}
placeholder="123"
value={cardCvv}
onChange={(e) => setCardCvv(e.target.value)}
className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none"
/>
</div>
</div>
</div>
) : (
/* PIX or BOLETO Info */
<div className="p-4 glass-badge rounded-xl text-xs text-zinc-400 space-y-2 leading-relaxed">
<p className="font-semibold text-white">Informações da Assinatura:</p>
<p>
- Faturamento recorrente mensal (R$ 49,90) via {billingType}.
</p>
<p>
- O link para pagamento, Pix Copia e Cola e código de barras serão gerados dinamicamente de forma integrada com a API oficial do Asaas.
</p>
</div>
)}
<button
type="submit"
disabled={isSubmitting}
className="w-full glass-btn-primary text-white text-xs font-bold py-3 px-4 rounded-lg flex items-center justify-center space-x-1.5 cursor-pointer disabled:opacity-50"
>
{isSubmitting ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
<span>Registrando no Asaas...</span>
</>
) : (
<>
<span>Confirmar Assinatura - R$ 49,90/mês</span>
<ArrowRight className="w-4 h-4" />
</>
)}
</button>
</form>
)}
</div>
</div>
)}
</div>
</div>
);