feat: checkout unificado com PaymentCheckoutCard planos dinamicos e PIX para assinaturas
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Successful in 1m2s
Details
This commit is contained in:
parent
445caaa994
commit
0c83b67025
10
server.ts
10
server.ts
|
|
@ -986,7 +986,17 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic
|
||||||
asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`;
|
asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`;
|
||||||
invoiceUrlStr = payment.invoiceUrl || '';
|
invoiceUrlStr = payment.invoiceUrl || '';
|
||||||
asaasDueDate = payment.dueDate || asaasDueDate;
|
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({
|
const newPayment = await prisma.payment.create({
|
||||||
data: {
|
data: {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
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 { Course, Plan } from '../types';
|
||||||
|
import PaymentCheckoutCard from './PaymentCheckoutCard';
|
||||||
|
|
||||||
interface CourseUnlockModalProps {
|
interface CourseUnlockModalProps {
|
||||||
course: Course & { price?: number };
|
course: Course & { price?: number };
|
||||||
|
|
@ -11,229 +12,58 @@ interface CourseUnlockModalProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CourseUnlockModal({ course, user, onClose, onSuccess, token }: 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 [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
|
||||||
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null means 'Avulso'
|
|
||||||
const [loadingPlans, setLoadingPlans] = useState(true);
|
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||||
|
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null = Avulso
|
||||||
|
|
||||||
const coursePrice = course.price || 49.90;
|
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(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (!token) return;
|
||||||
fetch('/api/settings', {
|
fetch('/api/plans', { headers: { Authorization: `Bearer ${token}` } })
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
.then(r => r.json())
|
||||||
})
|
.then((data: Plan[]) => {
|
||||||
.then(res => res.json())
|
// Show plans that include this course or are global (no specific courses)
|
||||||
.then(data => {
|
const relevant = data.filter(
|
||||||
if (data) {
|
p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id)
|
||||||
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);
|
setAvailablePlans(relevant);
|
||||||
setLoadingPlans(false);
|
|
||||||
})
|
})
|
||||||
.catch(() => setLoadingPlans(false));
|
.catch(() => {})
|
||||||
}
|
.finally(() => setLoadingPlans(false));
|
||||||
}, [token, course.id]);
|
}, [token, course.id]);
|
||||||
|
|
||||||
const handleConfirmPayment = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsPaying(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
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 selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
|
const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
|
||||||
const currentPrice = selectedPlanObj ? selectedPlanObj.price : coursePrice;
|
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 getCycleLabel = (cycle: string) => {
|
||||||
|
switch (cycle) {
|
||||||
|
case 'MONTHLY': return '/ mês';
|
||||||
|
case 'YEARLY': return '/ ano';
|
||||||
|
case 'LIFETIME': return 'taxa única';
|
||||||
|
default: return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* LEFT: Course info + Plan selection */}
|
||||||
<button
|
<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]">
|
||||||
onClick={onClose}
|
{/* Course header */}
|
||||||
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="mb-6">
|
||||||
<div className="flex items-center gap-4 mb-4">
|
<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" />
|
<img
|
||||||
|
src={course.thumbnail}
|
||||||
|
alt={course.title}
|
||||||
|
className="w-20 h-20 object-cover rounded-lg border border-white/10"
|
||||||
|
/>
|
||||||
<div>
|
<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">
|
<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
|
Adquirir Acesso
|
||||||
|
|
@ -243,11 +73,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.</p>
|
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
{/* Option list */}
|
||||||
{/* Option 1: Avulso */}
|
<div className="space-y-3">
|
||||||
|
{/* Avulso */}
|
||||||
<div
|
<div
|
||||||
onClick={() => setSelectedPlanId(null)}
|
onClick={() => setSelectedPlanId(null)}
|
||||||
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
|
||||||
|
|
@ -257,10 +88,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{selectedPlanId === null && (
|
{selectedPlanId === null && (
|
||||||
<div className="absolute top-3 right-3 text-[#E50914]"><Check className="w-5 h-5" /></div>
|
<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>
|
<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>
|
<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">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-sm text-zinc-500 font-bold">R$</span>
|
<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-2xl font-black text-white">{coursePrice.toFixed(2)}</span>
|
||||||
|
|
@ -268,10 +101,13 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Option 2+: Plans/Combos */}
|
{/* Plans/Combos */}
|
||||||
{loadingPlans ? (
|
{loadingPlans ? (
|
||||||
<div className="p-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-[#E50914]" /></div>
|
<div className="p-8 flex justify-center">
|
||||||
) : availablePlans.map(plan => (
|
<Loader2 className="w-6 h-6 animate-spin text-[#E50914]" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availablePlans.map(plan => (
|
||||||
<div
|
<div
|
||||||
key={plan.id}
|
key={plan.id}
|
||||||
onClick={() => setSelectedPlanId(plan.id)}
|
onClick={() => setSelectedPlanId(plan.id)}
|
||||||
|
|
@ -281,337 +117,48 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
||||||
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
|
: '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">
|
<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
|
Recomendado
|
||||||
</div>
|
</div>
|
||||||
{selectedPlanId === plan.id && (
|
{selectedPlanId === plan.id && (
|
||||||
<div className="absolute top-3 right-3 mt-3 text-purple-400"><Check className="w-5 h-5" /></div>
|
<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 flex items-center gap-2 mt-2">
|
<h4 className="font-bold text-white mb-1 flex items-center gap-2 mt-2">
|
||||||
<Layers className="w-4 h-4 text-purple-400" />
|
<Layers className="w-4 h-4 text-purple-400" />
|
||||||
{plan.title}
|
{plan.title}
|
||||||
</h4>
|
</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>
|
<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">
|
<div className="flex items-baseline gap-1">
|
||||||
<span className="text-sm text-zinc-500 font-bold">R$</span>
|
<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-2xl font-black text-white">{plan.price.toFixed(2)}</span>
|
||||||
<span className="text-xs text-zinc-500 font-medium ml-1">
|
<span className="text-xs text-zinc-500 font-medium ml-1">{getCycleLabel(plan.cycle)}</span>
|
||||||
{plan.cycle === 'MONTHLY' ? '/ mês' : plan.cycle === 'YEARLY' ? '/ ano' : 'taxa única'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{timeLeft !== null && (
|
{/* RIGHT: Unified checkout form via PaymentCheckoutCard */}
|
||||||
<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">
|
<div className="w-full md:w-7/12 p-6 md:p-8 max-h-[85vh] overflow-y-auto custom-scrollbar bg-[#111]">
|
||||||
<Clock className="w-3.5 h-3.5 text-[#E50914] animate-pulse" />
|
<PaymentCheckoutCard
|
||||||
<span>O QR Code expira em:</span>
|
itemId={currentItem.id}
|
||||||
<span className="text-white font-mono font-bold">{formatTime(timeLeft)}</span>
|
itemType={currentItem.type}
|
||||||
</div>
|
title={currentItem.title}
|
||||||
)}
|
price={currentItem.price}
|
||||||
</div>
|
cycle={currentItem.cycle}
|
||||||
</>
|
description={currentItem.description}
|
||||||
)}
|
user={user}
|
||||||
</div>
|
token={token}
|
||||||
)}
|
embedded={true}
|
||||||
|
onClose={onClose}
|
||||||
{paymentMethod === 'CREDIT_CARD' && (
|
onSuccess={onSuccess}
|
||||||
<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>
|
||||||
|
|
||||||
<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 já 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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: 1–2 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;
|
||||||
|
|
@ -1,14 +1,17 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Play, Calendar, DollarSign,
|
CreditCard, CheckCircle, RefreshCw, AlertTriangle, Calendar, ExternalLink,
|
||||||
ShieldCheck, ArrowRight, ExternalLink, QrCode
|
ShieldCheck, Layers, Sparkles, Clock3
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { SubscriptionStatus, SubscriptionPayment } from '../types';
|
import { SubscriptionStatus, SubscriptionPayment } from '../types';
|
||||||
|
import { Plan } from '../types';
|
||||||
|
import PaymentCheckoutCard from './PaymentCheckoutCard';
|
||||||
|
|
||||||
interface SubscriptionViewProps {
|
interface SubscriptionViewProps {
|
||||||
user: {
|
user: {
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
cpf?: string;
|
||||||
subscriptionStatus: SubscriptionStatus;
|
subscriptionStatus: SubscriptionStatus;
|
||||||
} | null;
|
} | null;
|
||||||
token: string | 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 [subStatus, setSubStatus] = useState<SubscriptionStatus | null>(user ? user.subscriptionStatus : null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
// Checkout flow state
|
// Plans
|
||||||
const [billingType, setBillingType] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
|
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
|
||||||
const [cardName, setCardName] = useState('');
|
const [loadingPlans, setLoadingPlans] = useState(true);
|
||||||
const [cardNumber, setCardNumber] = useState('');
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null);
|
||||||
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);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchSubscriptionDetails();
|
fetchSubscriptionDetails();
|
||||||
if (token) {
|
fetchPlans();
|
||||||
fetch('/api/settings', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
.then(res => res.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data && data.enableBoleto === false) {
|
|
||||||
setEnableBoleto(false);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchSubscriptionDetails = async () => {
|
const fetchSubscriptionDetails = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/subscription/status', {
|
const res = await fetch('/api/subscription/status', {
|
||||||
headers: {
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
@ -70,55 +52,20 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckout = async (e: React.FormEvent) => {
|
const fetchPlans = async () => {
|
||||||
e.preventDefault();
|
setLoadingPlans(true);
|
||||||
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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/subscription/subscribe', {
|
const res = await fetch('/api/plans', {
|
||||||
method: 'POST',
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
billingType,
|
|
||||||
creditCardInfo
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data: Plan[] = await res.json();
|
||||||
if (!res.ok) {
|
setAvailablePlans(data);
|
||||||
throw new Error(data.error || 'Erro ao processar assinatura');
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
setSuccessPayload(data);
|
console.error('Error fetching plans:', err);
|
||||||
// Wait a moment and fetch updated profile status
|
|
||||||
await fetchSubscriptionDetails();
|
|
||||||
} catch (err: any) {
|
|
||||||
setCheckoutError(err.message || 'Erro de rede. Tente novamente.');
|
|
||||||
} finally {
|
} 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) => {
|
const getPaymentStatusTag = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'CONFIRMED':
|
case 'CONFIRMED':
|
||||||
|
|
@ -165,27 +121,51 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasPlans = availablePlans.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8 max-w-4xl mx-auto">
|
<div className="space-y-8 max-w-4xl mx-auto">
|
||||||
{/* Top Title */}
|
{/* Title */}
|
||||||
<div className="border-b border-white/10 pb-4">
|
<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">
|
<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]" />
|
<CreditCard className="w-8 h-8 text-[#E50914]" />
|
||||||
<span>Gestão de Assinatura & Cobranças</span>
|
<span>Minha Assinatura & Cobranças</span>
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Main Grid: Status summary left, checkout right */}
|
{/* Checkout Modal (when a plan is selected) */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
{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">
|
<div className="space-y-6">
|
||||||
|
|
||||||
|
{/* Account Status Card */}
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-5">
|
<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>
|
<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="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]" />
|
<ShieldCheck className="w-6 h-6 text-[#E50914]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|
@ -194,7 +174,7 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Status Atual do Acesso</p>
|
||||||
{subStatus && (
|
{subStatus && (
|
||||||
<div className={`inline-block px-3 py-1.5 rounded-lg border text-sm font-extrabold ${getStatusColor(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' && (
|
{subStatus === 'TRIAL' && (
|
||||||
<p className="text-xs text-zinc-400 font-medium pt-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{subStatus === 'ACTIVE' && (
|
{subStatus === 'ACTIVE' && (
|
||||||
<p className="text-xs text-emerald-500/80 font-bold flex items-center space-x-1 pt-1">
|
<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" />
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{subStatus === 'OVERDUE' && (
|
{subStatus === 'OVERDUE' && (
|
||||||
<p className="text-xs text-red-500/80 font-semibold flex items-center space-x-1 pt-1">
|
<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" />
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Past Payments History */}
|
{/* Invoice History */}
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-4">
|
<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>
|
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">Faturas e Segunda Via</h3>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{payments.length === 0 ? (
|
{payments.length === 0 ? (
|
||||||
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
|
<p className="text-xs text-zinc-500 italic">Nenhum pagamento registrado nesta conta.</p>
|
||||||
) : (
|
) : (
|
||||||
payments.map(pay => (
|
payments.map(pay => (
|
||||||
<div
|
<div key={pay.id} className="p-3.5 glass-badge rounded-xl flex items-center justify-between">
|
||||||
key={pay.id}
|
|
||||||
className="p-3.5 glass-badge rounded-xl flex items-center justify-between"
|
|
||||||
>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<span className="text-white text-xs font-bold">R$ {pay.value.toFixed(2)}</span>
|
<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>
|
<span>Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href={pay.invoiceUrl}
|
href={pay.invoiceUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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"
|
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" />
|
<ExternalLink className="w-3 h-3" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -262,177 +237,70 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Hand: Checkout Form (Create Subscription) */}
|
{/* RIGHT: Plans (only shown if plans exist) */}
|
||||||
|
{hasPlans && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="glass-card p-6 rounded-2xl space-y-6">
|
<div className="glass-card p-6 rounded-2xl space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="flex items-center space-x-2">
|
||||||
<h3 className="font-display font-extrabold text-lg text-white tracking-tight">Assine o Plano Ilimitado</h3>
|
<Sparkles className="w-5 h-5 text-[#E50914]" />
|
||||||
<p className="text-xs text-zinc-500 font-semibold flex items-center space-x-1">
|
<h3 className="font-display font-bold text-sm text-zinc-400 uppercase tracking-wider">
|
||||||
<span>Trilha completa da Plataforma por apenas</span>
|
Planos & Combos Disponíveis
|
||||||
<span className="text-[#E50914] font-black text-sm">R$ 49,90/mês</span>
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Escolha o plano ideal e adquira acesso aos conteúdos da plataforma.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Billing Types Toggle */}
|
{loadingPlans ? (
|
||||||
<div className={`grid ${enableBoleto ? 'grid-cols-3' : 'grid-cols-2'} gap-2 p-1 glass-badge rounded-lg`}>
|
<div className="flex justify-center py-8">
|
||||||
<button
|
<RefreshCw className="w-6 h-6 animate-spin text-[#E50914]" />
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* ACTIVE CHECKOUT FORM */
|
<div className="space-y-3">
|
||||||
<form onSubmit={handleCheckout} className="space-y-4">
|
{availablePlans.map(plan => (
|
||||||
{billingType === 'CREDIT_CARD' ? (
|
<div
|
||||||
<div className="space-y-4">
|
key={plan.id}
|
||||||
<div className="space-y-1">
|
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"
|
||||||
<label className="text-[10px] text-zinc-500 font-bold uppercase">Nome impresso no Cartão</label>
|
onClick={() => setSelectedPlan(plan)}
|
||||||
<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 ? (
|
{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">
|
||||||
<RefreshCw className="w-4 h-4 animate-spin" />
|
Combo
|
||||||
<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 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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue