From 0c83b67025f3900a49df438dbeb7937854e5091a Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Fri, 24 Jul 2026 20:48:08 -0300 Subject: [PATCH] feat: checkout unificado com PaymentCheckoutCard planos dinamicos e PIX para assinaturas --- server.ts | 10 + src/components/CourseUnlockModal.tsx | 697 +++++-------------------- src/components/PaymentCheckoutCard.tsx | 590 +++++++++++++++++++++ src/components/SubscriptionView.tsx | 402 +++++--------- 4 files changed, 857 insertions(+), 842 deletions(-) create mode 100644 src/components/PaymentCheckoutCard.tsx diff --git a/server.ts b/server.ts index 98f521c..0ca365b 100644 --- a/server.ts +++ b/server.ts @@ -986,8 +986,18 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`; invoiceUrlStr = payment.invoiceUrl || ''; asaasDueDate = payment.dueDate || asaasDueDate; + + // Also fetch PIX QR code for the first installment of recurring plans + if (billingType === 'PIX' && asaasPaymentId && !asaasPaymentId.startsWith('pay_')) { + try { + pixData = await AsaasService.getPixQrCode(asaasPaymentId); + } catch (pixErr) { + console.warn('Could not fetch PIX QR Code for subscription payment:', pixErr); + } + } } + const newPayment = await prisma.payment.create({ data: { id: asaasPaymentId, diff --git a/src/components/CourseUnlockModal.tsx b/src/components/CourseUnlockModal.tsx index 77f4d5b..3372a39 100644 --- a/src/components/CourseUnlockModal.tsx +++ b/src/components/CourseUnlockModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers, Clock } from 'lucide-react'; +import { Check, Layers, Loader2 } from 'lucide-react'; import { Course, Plan } from '../types'; +import PaymentCheckoutCard from './PaymentCheckoutCard'; interface CourseUnlockModalProps { course: Course & { price?: number }; @@ -11,607 +12,153 @@ interface CourseUnlockModalProps { } export default function CourseUnlockModal({ course, user, onClose, onSuccess, token }: CourseUnlockModalProps) { - const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX'); - const [isPaying, setIsPaying] = useState(false); - const [pixCopied, setPixCopied] = useState(false); - const [unlocked, setUnlocked] = useState(false); - const [error, setError] = useState(null); - - const [paymentData, setPaymentData] = useState(null); - const [timeLeft, setTimeLeft] = useState(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([]); - const [selectedPlanId, setSelectedPlanId] = useState(null); // null means 'Avulso' const [loadingPlans, setLoadingPlans] = useState(true); + const [selectedPlanId, setSelectedPlanId] = useState(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(true); - const [enableInstallmentBoleto, setEnableInstallmentBoleto] = useState(false); - const [maxBoletoInstallments, setMaxBoletoInstallments] = useState(3); - useEffect(() => { - if (token) { - fetch('/api/settings', { - headers: { 'Authorization': `Bearer ${token}` } + if (!token) return; + fetch('/api/plans', { headers: { Authorization: `Bearer ${token}` } }) + .then(r => r.json()) + .then((data: Plan[]) => { + // Show plans that include this course or are global (no specific courses) + const relevant = data.filter( + p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id) + ); + setAvailablePlans(relevant); }) - .then(res => res.json()) - .then(data => { - if (data) { - if (data.enableBoleto === false) { - setEnableBoleto(false); - } - if (data.enableInstallmentBoleto === true) { - setEnableInstallmentBoleto(true); - } - if (data.maxBoletoInstallments) { - setMaxBoletoInstallments(data.maxBoletoInstallments); - } - } - }) - .catch(() => {}); - - // Fetch available plans for this course - fetch('/api/plans', { - headers: { 'Authorization': `Bearer ${token}` } - }) - .then(res => res.json()) - .then(data => { - const plans = data as Plan[]; - // Filter plans that include this course or are global (empty includedCourses) - const relevant = plans.filter(p => !p.includedCourses || p.includedCourses.length === 0 || p.includedCourses.includes(course.id)); - setAvailablePlans(relevant); - setLoadingPlans(false); - }) - .catch(() => setLoadingPlans(false)); - } + .catch(() => {}) + .finally(() => setLoadingPlans(false)); }, [token, course.id]); - const handleConfirmPayment = async (e: React.FormEvent) => { - e.preventDefault(); - setIsPaying(true); - setError(null); + const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null; + const currentItem = selectedPlanObj + ? { id: selectedPlanObj.id, type: 'plan' as const, title: selectedPlanObj.title, price: selectedPlanObj.price, cycle: selectedPlanObj.cycle as any, description: selectedPlanObj.description || undefined } + : { id: course.id, type: 'course' as const, title: course.title, price: coursePrice, cycle: 'LIFETIME' as const, description: undefined }; - const creditCardInfo = paymentMethod === 'CREDIT_CARD' ? { - creditCard: { - holderName: ccName, - number: ccNumber.replace(/\s+/g, ''), - expiryMonth: ccExpiry.split('/')[0]?.trim(), - expiryYear: '20' + ccExpiry.split('/')[1]?.trim(), - ccv: ccCvv - }, - creditCardHolderInfo: { - name: ccName, - email: 'aluno@microtecflix.com', // Will be fetched by server - cpfCnpj: '000.000.000-00', - postalCode: '01001-000', - addressNumber: '123', - phone: '11999999999' - } - } : undefined; - - if (!checkoutCpf || checkoutCpf.replace(/\D/g, '').length < 11) { - setError('Por favor, informe um CPF ou CNPJ válido.'); - setIsPaying(false); - return; - } - - try { - let endpoint = ''; - let bodyData: any = { - billingType: paymentMethod, - creditCardInfo, - cpf: checkoutCpf - }; - - if (selectedPlanId) { - endpoint = `/api/subscription/subscribe`; - bodyData.planId = selectedPlanId; - } else { - endpoint = `/api/courses/${course.id}/unlock`; - bodyData.installmentCount = (paymentMethod === 'CREDIT_CARD' || (paymentMethod === 'BOLETO' && enableInstallmentBoleto)) ? installments : 1; - } - - const res = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}` - }, - body: JSON.stringify(bodyData) - }); - - const contentType = res.headers.get('content-type'); - let data: any = {}; - if (contentType && contentType.includes('application/json')) { - data = await res.json(); - } else { - const text = await res.text(); - console.error('Non-JSON response received:', text); - throw new Error(`Ocorreu um erro no servidor de pagamentos (${res.status}).`); - } - - if (!res.ok) { - throw new Error(data.error || data.message || 'Erro ao processar pagamento com o Asaas'); - } - - if (paymentMethod === 'PIX') { - setPaymentData(data); - setTimeLeft(300); // 5 minutes countdown - } else if (paymentMethod === 'BOLETO') { - setPaymentData(data); - setTimeLeft(null); - } else { - setUnlocked(true); // Credit Card assumes instant in sandbox - } - } catch (err: any) { - setError(err.message || 'Erro de conexão.'); - } finally { - setIsPaying(false); + const getCycleLabel = (cycle: string) => { + switch (cycle) { + case 'MONTHLY': return '/ mês'; + case 'YEARLY': return '/ ano'; + case 'LIFETIME': return 'taxa única'; + default: return ''; } }; - const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null; - const currentPrice = selectedPlanObj ? selectedPlanObj.price : coursePrice; - return ( -
+
- - {/* Close Button */} - - {!unlocked ? ( - <> - {/* Left: Options & Selection */} -
-
-
- {course.title} -
- - Adquirir Acesso - -

- {course.title} -

-
-
-

Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.

+ {/* LEFT: Course info + Plan selection */} +
+ {/* Course header */} +
+
+ {course.title} +
+ + Adquirir Acesso + +

+ {course.title} +

+
+

Escolha a melhor oferta para você.

+
-
- {/* Option 1: Avulso */} -
setSelectedPlanId(null)} - className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${ - selectedPlanId === null - ? 'bg-red-950/20 border-[#E50914]' + {/* Option list */} +
+ {/* Avulso */} +
setSelectedPlanId(null)} + className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${ + selectedPlanId === null + ? 'bg-red-950/20 border-[#E50914]' + : 'bg-zinc-900/50 border-white/5 hover:border-white/20' + }`} + > + {selectedPlanId === null && ( +
+ +
+ )} +

Compra Avulsa (Apenas este curso)

+

Pague uma vez e tenha acesso vitalício ao curso.

+
+ R$ + {coursePrice.toFixed(2)} + taxa única +
+
+ + {/* Plans/Combos */} + {loadingPlans ? ( +
+ +
+ ) : ( + availablePlans.map(plan => ( +
setSelectedPlanId(plan.id)} + className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${ + selectedPlanId === plan.id + ? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]' : 'bg-zinc-900/50 border-white/5 hover:border-white/20' }`} > - {selectedPlanId === null && ( -
+
+ Recomendado +
+ {selectedPlanId === plan.id && ( +
+ +
)} -

Compra Avulsa (Apenas este curso)

-

Pague uma vez e tenha acesso vitalício ao curso selecionado.

+

+ + {plan.title} +

+

+ {plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')} +

R$ - {coursePrice.toFixed(2)} - taxa única + {plan.price.toFixed(2)} + {getCycleLabel(plan.cycle)}
- - {/* Option 2+: Plans/Combos */} - {loadingPlans ? ( -
- ) : availablePlans.map(plan => ( -
setSelectedPlanId(plan.id)} - className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${ - selectedPlanId === plan.id - ? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]' - : 'bg-zinc-900/50 border-white/5 hover:border-white/20' - }`} - > -
- Recomendado -
- {selectedPlanId === plan.id && ( -
- )} - -

- - {plan.title} -

-

{plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')}

-
- R$ - {plan.price.toFixed(2)} - - {plan.cycle === 'MONTHLY' ? '/ mês' : plan.cycle === 'YEARLY' ? '/ ano' : 'taxa única'} - -
-
- ))} -
-
- - {/* Right: Payment Forms */} -
-
-

Escolha a forma de pagamento

- - {/* Tabs */} -
- - - {enableBoleto && ( - - )} -
- - {error && ( -
- {error} -
- )} - - {/* Tab Contents */} -
- {paymentMethod === 'PIX' && ( -
- {!paymentData ? ( -
- Clique em Confirmar para gerar seu QR Code PIX no valor de R$ - {currentPrice.toFixed(2)} -
- ) : ( - <> -
- {paymentData.pixData?.encodedImage && paymentData.pixData.encodedImage.length > 20 ? ( - QR Code PIX - ) : paymentData.pixData?.payload ? ( - QR Code PIX - ) : ( -
Gerando QR Code...
- )} -
-
-

- Escaneie o QR Code acima com o app do seu banco ou copie a chave Pix Copia e Cola abaixo. -

- -
- - -
- - {timeLeft !== null && ( -
- - O QR Code expira em: - {formatTime(timeLeft)} -
- )} -
- - )} -
- )} - - {paymentMethod === 'CREDIT_CARD' && ( -
-
- - 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" - /> -
- -
- - 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" - /> -
- -
-
- - 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" - /> -
- -
- - 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" - /> -
-
- - {/* Parcelamento (apenas para Vitalício/Pagamento Único) */} - {(!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && ( -
- - -
- )} -
- )} - - {paymentMethod === 'BOLETO' && ( -
-
- -
-
-

Boleto Bancário

- - {!paymentData ? ( -

- O boleto será gerado no valor de R$ {currentPrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis. -

- ) : ( -

- Boleto gerado com sucesso no valor de R$ {currentPrice.toFixed(2)} {paymentData.payment?.installmentCount > 1 ? `parcelado em ${paymentData.payment.installmentCount}x` : ''}. -

- )} - - {!paymentData && enableInstallmentBoleto && (!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && ( -
- - -
- )} - -
- {paymentData?.payment?.invoiceUrl ? ( - - ) : ( -
Clique abaixo para gerar o boleto
- )} -
-
-
- )} - - {/* CPF/CNPJ Input (Required for all Asaas payments) */} - {!paymentData && ( -
- - 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 - /> -

Necessário para emissão do Pix, boleto ou cobrança de cartão via Asaas.

-
- )} - - {/* Submit button */} - {!paymentData && ( -
- -
- Pagamento via API Oficial Sandbox Asaas. -
-
- )} -
-
-
- - ) : ( - /* Success Step */ -
-
- -
- -
-

- Pagamento Confirmado! -

-

- Parabéns! O seu pagamento foi aprovado com sucesso na plataforma. Seu acesso já está liberado! -

-
- - + )) + )}
- )} +
+ + {/* RIGHT: Unified checkout form via PaymentCheckoutCard */} +
+ +
); diff --git a/src/components/PaymentCheckoutCard.tsx b/src/components/PaymentCheckoutCard.tsx new file mode 100644 index 0000000..7e34264 --- /dev/null +++ b/src/components/PaymentCheckoutCard.tsx @@ -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 = ({ + 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('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(null); + const [paymentData, setPaymentData] = useState(null); + const [pixCopied, setPixCopied] = useState(false); + const [timeLeft, setTimeLeft] = useState(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 = { 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 ( +
+
+ +
+
+

Pagamento Confirmado!

+

+ Parabéns! Seu pagamento foi aprovado. O acesso está liberado! +

+
+ {onSuccess && ( + + )} + {onClose && ( + + )} +
+ ); + } + + // ── MAIN CARD ───────────────────────────────────────────────────────────── + const inner = ( +
+ {/* Header */} +
+
+ + {itemType === 'plan' + ? cycle === 'LIFETIME' + ? 'Combo — Pagamento Único' + : cycle === 'YEARLY' + ? 'Plano Anual' + : 'Assinatura Mensal' + : 'Compra Avulsa do Curso'} + +

{title}

+ {description &&

{description}

} +
+ R$ + {price.toFixed(2)} + + {cycle === 'MONTHLY' ? '/ mês' : cycle === 'YEARLY' ? '/ ano' : 'taxa única'} + +
+
+ {onClose && ( + + )} +
+ + {/* Payment Method Tabs */} +
+ {(['PIX', 'CREDIT_CARD', ...(enableBoleto ? ['BOLETO'] : [])] as BillingMethod[]).map(method => { + const icons: Record = { + PIX: , + CREDIT_CARD: , + BOLETO: , + }; + const labels: Record = { PIX: 'PIX', CREDIT_CARD: 'Cartão', BOLETO: 'Boleto' }; + return ( + + ); + })} +
+ + {/* Error banner */} + {error && ( +
+ + {error} +
+ )} + + {/* Form */} +
+ + {/* ── PIX ────────────────────────────────────────────────────────── */} + {billingType === 'PIX' && ( +
+ {!paymentData ? ( +
+ Clique em Confirmar para gerar seu QR Code PIX de{' '} + R$ {price.toFixed(2)}. +
+ ) : ( + <> +
+ {paymentData.pixData?.encodedImage && paymentData.pixData.encodedImage.length > 20 ? ( + QR Code PIX + ) : paymentData.pixData?.payload ? ( + QR Code PIX + ) : ( +
+ Gerando QR Code... +
+ )} +
+ +

+ Escaneie o QR Code com o app do seu banco ou copie a chave Pix Copia e Cola abaixo. +

+ +
+ + +
+ + {timeLeft !== null && ( +
+ + Expira em: + {formatTime(timeLeft)} +
+ )} + + )} +
+ )} + + {/* ── CREDIT CARD ──────────────────────────────────────────────── */} + {billingType === 'CREDIT_CARD' && ( +
+
+ + + setCcNumber( + e.target.value + .replace(/\D/g, '') + .replace(/(\d{4})/g, '$1 ') + .trim() + .slice(0, 19) + ) + } + placeholder="0000 0000 0000 0000" + className={inputClass} + /> +
+
+ + setCcName(e.target.value.toUpperCase())} + placeholder="NOME IGUAL DO CARTÃO" + className={inputClass} + /> +
+
+
+ + + setCcExpiry( + e.target.value + .replace(/\D/g, '') + .replace(/(\d{2})(\d)/, '$1/$2') + .slice(0, 5) + ) + } + placeholder="MM/AA" + className={inputClass} + /> +
+
+ + setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))} + placeholder="123" + className={inputClass} + /> +
+
+ + {/* Installments only for single-payment items */} + {isSinglePayment && ( +
+ + +
+ )} +
+ )} + + {/* ── BOLETO ───────────────────────────────────────────────────── */} + {billingType === 'BOLETO' && ( +
+
+ +
+
+

Boleto Bancário

+ {!paymentData ? ( +

+ O boleto será gerado no valor de R$ {price.toFixed(2)}. Prazo de compensação: 1–2 dias úteis. +

+ ) : ( +

+ Boleto gerado no valor de R$ {price.toFixed(2)}. +

+ )} + + {!paymentData && enableInstallmentBoleto && isSinglePayment && ( +
+ + +
+ )} + + {paymentData?.payment?.invoiceUrl && ( + + )} +
+
+ )} + + {/* CPF field */} + {!paymentData && ( +
+ + 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 + /> +

Necessário para emissão do Pix, boleto ou cartão via Asaas.

+
+ )} + + {/* Submit button */} + {!paymentData && ( +
+ +

+ Pagamento via API Oficial Asaas. Seus dados são criptografados e protegidos. +

+
+ )} +
+
+ ); + + // ── Embedded vs Modal wrapper ────────────────────────────────────────────── + if (embedded) { + return
{inner}
; + } + + return ( +
+
+ {inner} +
+
+ ); +}; + +export default PaymentCheckoutCard; diff --git a/src/components/SubscriptionView.tsx b/src/components/SubscriptionView.tsx index fc30e6b..01e3bff 100644 --- a/src/components/SubscriptionView.tsx +++ b/src/components/SubscriptionView.tsx @@ -1,14 +1,17 @@ import React, { useState, useEffect } from 'react'; -import { - CreditCard, CheckCircle, RefreshCw, AlertTriangle, Play, Calendar, DollarSign, - ShieldCheck, ArrowRight, ExternalLink, QrCode +import { + 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; @@ -19,43 +22,22 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr const [payments, setPayments] = useState([]); const [subStatus, setSubStatus] = useState(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(null); - const [checkoutError, setCheckoutError] = useState(null); - const [enableBoleto, setEnableBoleto] = useState(true); + // Plans + const [availablePlans, setAvailablePlans] = useState([]); + const [loadingPlans, setLoadingPlans] = useState(true); + const [selectedPlan, setSelectedPlan] = useState(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 (
- {/* Top Title */} + {/* Title */}

- Gestão de Assinatura & Cobranças + Minha Assinatura & Cobranças

-

Veja seus comprovantes, históricos de faturamento e mude seu plano de estudos.

+

+ Veja seus comprovantes, histórico de faturamento e adquira planos ou cursos. +

- {/* Main Grid: Status summary left, checkout right */} -
- - {/* Left Hand: Current Plan details & invoice history */} + {/* Checkout Modal (when a plan is selected) */} + {selectedPlan && ( + setSelectedPlan(null)} + onSuccess={() => { + setSelectedPlan(null); + fetchSubscriptionDetails(); + }} + /> + )} + +
+ + {/* LEFT: Account status + Invoice history */}
+ + {/* Account Status Card */}

Sua Conta

- +
-
+
@@ -194,47 +174,43 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
-
+

Status Atual do Acesso

{subStatus && (
{getStatusLabel(subStatus)}
)} - + {subStatus === 'TRIAL' && (

- 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.

)} {subStatus === 'ACTIVE' && (

- Acesso integral liberado a todos os cursos de informática, hardware e servidores! + Acesso integral liberado a todos os cursos!

)} {subStatus === 'OVERDUE' && (

- Seu acesso foi bloqueado por falta de pagamento. Regularize via Pix ou Cartão para liberar. + Seu acesso foi bloqueado por falta de pagamento. Regularize via um dos planos abaixo.

)}
- {/* Past Payments History */} + {/* Invoice History */}

Faturas e Segunda Via

-
{payments.length === 0 ? (

Nenhum pagamento registrado nesta conta.

) : ( payments.map(pay => ( -
+
R$ {pay.value.toFixed(2)} @@ -245,14 +221,13 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr Vence em: {new Date(pay.dueDate).toLocaleDateString('pt-BR')}

- - - Boleto / Pix / Detalhes + Detalhes
@@ -262,177 +237,70 @@ export default function SubscriptionView({ user, token, onStatusUpdate }: Subscr
- {/* Right Hand: Checkout Form (Create Subscription) */} -
-
-
-

Assine o Plano Ilimitado

-

- Trilha completa da Plataforma por apenas - R$ 49,90/mês + {/* RIGHT: Plans (only shown if plans exist) */} + {hasPlans && ( +

+
+
+ +

+ Planos & Combos Disponíveis +

+
+

+ Escolha o plano ideal e adquira acesso aos conteúdos da plataforma.

-
- {/* Billing Types Toggle */} -
- - - {enableBoleto && ( - + {loadingPlans ? ( +
+ +
+ ) : ( +
+ {availablePlans.map(plan => ( +
setSelectedPlan(plan)} + > + {plan.cycle === 'LIFETIME' && ( +
+ Combo +
+ )} + +
+
+ +
+
+

{plan.title}

+ {plan.description && ( +

{plan.description}

+ )} + {plan.includedCourses && plan.includedCourses.length === 0 && ( +

✓ Acesso global a todos os cursos

+ )} +
+ R$ + {plan.price.toFixed(2)} + {getCycleLabel(plan.cycle)} +
+
+
+
+ + Contratar +
+
+
+
+ ))} +
)}
- - {/* Error alerts */} - {checkoutError && ( -
- {checkoutError} -
- )} - - {/* SUCCESS PAYLOAD (PIX QR CODE OR CREDIT CARD CLEAR) */} - {successPayload ? ( -
-
- -
- -
-

Inscrição criada com sucesso!

-

- Sua assinatura está sendo faturada e integrada com o Asaas. -

-
- - {billingType === 'PIX' && ( -
-

- 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. -

-
- )} - - -
- ) : ( - /* ACTIVE CHECKOUT FORM */ -
- {billingType === 'CREDIT_CARD' ? ( -
-
- - 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" - /> -
- -
- - 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" - /> -
- -
-
- - setCardExpiry(e.target.value)} - className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none" - /> -
-
- - setCardCvv(e.target.value)} - className="w-full glass-input rounded-lg p-2.5 text-xs text-white focus:outline-none" - /> -
-
-
- ) : ( - /* PIX or BOLETO Info */ -
-

Informações da Assinatura:

-

- - Faturamento recorrente mensal (R$ 49,90) via {billingType}. -

-

- - 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. -

-
- )} - - -
- )}
-
- + )}
);