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)}`;
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -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,229 +12,58 @@ 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}` }
|
||||
})
|
||||
.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));
|
||||
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);
|
||||
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 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 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 (
|
||||
<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]">
|
||||
{/* 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" />
|
||||
<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
|
||||
|
|
@ -243,11 +73,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
|||
</h3>
|
||||
</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 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 ${
|
||||
|
|
@ -257,10 +88,12 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
|||
}`}
|
||||
>
|
||||
{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>
|
||||
<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">
|
||||
<span className="text-sm text-zinc-500 font-bold">R$</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>
|
||||
|
||||
{/* Option 2+: Plans/Combos */}
|
||||
{/* 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 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)}
|
||||
|
|
@ -281,337 +117,48 @@ export default function CourseUnlockModal({ course, user, onClose, onSuccess, to
|
|||
: '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
|
||||
</div>
|
||||
{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">
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-xs text-zinc-500 font-medium ml-1">{getCycleLabel(plan.cycle)}</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"
|
||||
{/* 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 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
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) */}
|
||||
{/* RIGHT: Plans (only shown if plans exist) */}
|
||||
{hasPlans && (
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</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>
|
||||
{loadingPlans ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<RefreshCw className="w-6 h-6 animate-spin text-[#E50914]" />
|
||||
</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"
|
||||
<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)}
|
||||
>
|
||||
{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>
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue