From 90de5ebd1bc4c2ae463207475deabcc023fad565 Mon Sep 17 00:00:00 2001 From: Sidney Gomes Date: Wed, 22 Jul 2026 19:28:13 -0300 Subject: [PATCH] feat: Add plan selection in CourseUnlockModal and CPF to ProfileView --- server.ts | 184 +++++++++-- src/components/AdminDashboard.tsx | 26 +- src/components/AdminPricingManager.tsx | 440 +++++++++++++++++++++++++ src/components/CourseUnlockModal.tsx | 224 +++++++++---- src/components/ProfileView.tsx | 25 +- src/db/db.ts | 15 +- src/services/asaas.ts | 8 +- src/types.ts | 15 +- 8 files changed, 820 insertions(+), 117 deletions(-) create mode 100644 src/components/AdminPricingManager.tsx diff --git a/server.ts b/server.ts index e20348d..0633a34 100644 --- a/server.ts +++ b/server.ts @@ -265,7 +265,7 @@ app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Resp // 3.1. Profile: Update Data app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => { - const { name, whatsapp, birthDate } = req.body; + const { name, whatsapp, birthDate, cpf } = req.body; const userId = req.user!.id; const db = loadDb(); @@ -275,9 +275,10 @@ app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res return; } - if (name) user.name = name; + if (name !== undefined) user.name = name; if (whatsapp !== undefined) user.whatsapp = whatsapp; if (birthDate !== undefined) user.birthDate = birthDate; + if (cpf !== undefined) user.cpf = cpf; saveDb(db); @@ -627,7 +628,7 @@ app.get('/api/subscription/status', authenticateToken, (req: AuthenticatedReques // 9. Subscribe to plan app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => { - const { billingType, creditCardInfo } = req.body; + const { billingType, creditCardInfo, planId } = req.body; const userId = req.user!.id; if (!billingType) { @@ -653,43 +654,98 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic user.asaasCustomerId = customerId; } - // Assinatura de R$ 49.90/mês - const { subscriptionId, payment } = await AsaasService.createSubscription( - customerId, - 49.90, - billingType, - creditCardInfo - ); + let plan = planId ? db.plans?.find(p => p.id === planId) : null; + let price = plan ? plan.price : 49.90; + let cycle = plan ? plan.cycle : 'MONTHLY'; + let description = plan ? `Plano: ${plan.title}` : 'Assinatura Mensal DevFlix'; + + let asaasPaymentId = ''; + let invoiceUrlStr = ''; + let statusAsaas: 'PENDING' | 'CONFIRMED' = billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING'; + let asaasDueDate = new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0]; + let pixData = null; + + if (cycle === 'LIFETIME') { + // Pagamento Único + let processedCardInfo = creditCardInfo; + if (billingType === 'CREDIT_CARD' && creditCardInfo?.creditCard?.number) { + try { + const tokenResult = await AsaasService.tokenizeCreditCard( + customerId, + creditCardInfo.creditCard, + creditCardInfo.creditCardHolderInfo + ); + processedCardInfo = { + creditCardToken: tokenResult.creditCardToken, + creditCardNumberLast4: tokenResult.creditCardNumber + }; + } catch (tokErr) { + console.warn('Tokenization fallback to direct API transmission:', tokErr); + } + } + + const asaasPayment = await AsaasService.createPayment( + customerId, + price, + billingType, + `plan_${planId}`, + processedCardInfo, + 1 + ); + + asaasPaymentId = asaasPayment.id; + invoiceUrlStr = asaasPayment.invoiceUrl; + asaasDueDate = asaasPayment.dueDate || asaasDueDate; + + if (billingType === 'PIX') { + pixData = await AsaasService.getPixQrCode(asaasPayment.id); + } + } else { + // Assinatura (Mensal ou Anual) + const { subscriptionId, payment } = await AsaasService.createSubscription( + customerId, + price, + billingType, + creditCardInfo, + cycle as any, + description + ); + + asaasPaymentId = payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`; + invoiceUrlStr = payment.invoiceUrl || ''; + asaasDueDate = payment.dueDate || asaasDueDate; + } // Add subscription payment to database const newPayment: SubscriptionPayment = { - id: payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`, + id: asaasPaymentId, userId, - value: 49.90, - status: billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING', + value: price, + status: statusAsaas, billingType, - invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${subscriptionId}`, - dueDate: payment.dueDate || new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0], - paidAt: billingType === 'CREDIT_CARD' ? new Date().toISOString() : null, + invoiceUrl: invoiceUrlStr, + dueDate: asaasDueDate, + paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null, createdAt: new Date().toISOString() }; - db.payments.push(newPayment); - // Update user subscription status - if (billingType === 'CREDIT_CARD') { - user.subscriptionStatus = 'ACTIVE'; - } else { - user.subscriptionStatus = 'TRIAL'; // Give them trial until Pix/Boleto clears, or keep Trial + // If sandbox auto-approves credit card + if (statusAsaas === 'CONFIRMED') { + if (cycle === 'LIFETIME' && plan) { + if (!plan.includedCourses || plan.includedCourses.length === 0) { + user.subscriptionStatus = 'ACTIVE'; // Libera tudo + } else { + user.unlockedCourses = [...(user.unlockedCourses || []), ...plan.includedCourses]; + } + } else { + user.subscriptionStatus = 'ACTIVE'; + } } - + saveDb(db); - res.json({ - success: true, - subscriptionId, - payment: newPayment - }); + res.json({ success: true, payment: newPayment, pixData }); } catch (err: any) { console.error('Subscription error:', err); res.status(400).json({ error: err.message || 'Erro ao processar assinatura' }); @@ -1523,6 +1579,78 @@ app.put('/api/admin/modules/:moduleId/activity', authenticateToken, requireAdmin res.json(activity); }); +// --- PUBLIC/STUDENT PLANS API --- +app.get('/api/plans', authenticateToken, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const activePlans = (db.plans || []).filter(p => p.active); + res.json(activePlans); +}); + +// --- ADMIN PLANS API ENDPOINTS --- + +app.get('/api/admin/plans', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + res.json(db.plans || []); +}); + +app.post('/api/admin/plans', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const { title, description, price, cycle, includedCourses, active } = req.body; + + const newPlan = { + id: `plan_${Math.random().toString(36).substring(2, 9)}`, + title: title || 'Novo Plano', + description: description || '', + price: price || 0, + cycle: cycle || 'LIFETIME', + includedCourses: includedCourses || [], + active: active !== undefined ? active : true, + createdAt: new Date().toISOString() + }; + + if (!db.plans) db.plans = []; + db.plans.push(newPlan); + saveDb(db); + res.json({ success: true, plan: newPlan }); +}); + +app.put('/api/admin/plans/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const planIdx = db.plans?.findIndex(p => p.id === req.params.id); + + if (planIdx !== undefined && planIdx >= 0 && db.plans) { + db.plans[planIdx] = { ...db.plans[planIdx], ...req.body }; + saveDb(db); + res.json({ success: true, plan: db.plans[planIdx] }); + } else { + res.status(404).json({ error: 'Plano não encontrado' }); + } +}); + +app.delete('/api/admin/plans/:id', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + if (db.plans) { + db.plans = db.plans.filter(p => p.id !== req.params.id); + saveDb(db); + } + res.json({ success: true }); +}); + +// Update single course price and lock status directly +app.put('/api/admin/courses/:id/price', authenticateToken, requireAdmin, (req: AuthenticatedRequest, res: Response) => { + const db = loadDb(); + const courseIdx = db.courses.findIndex(c => c.id === req.params.id); + + if (courseIdx >= 0) { + db.courses[courseIdx].price = req.body.price; + db.courses[courseIdx].isLocked = req.body.isLocked; + saveDb(db); + res.json({ success: true, course: db.courses[courseIdx] }); + } else { + res.status(404).json({ error: 'Curso não encontrado' }); + } +}); + // --- VITE MIDDLEWARE / SPA FALLBACK --- async function startServer() { if (process.env.NODE_ENV !== 'production') { diff --git a/src/components/AdminDashboard.tsx b/src/components/AdminDashboard.tsx index 1bbe0b0..abc8d83 100644 --- a/src/components/AdminDashboard.tsx +++ b/src/components/AdminDashboard.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; import { TrendingUp, Users, AlertTriangle, XCircle, Search, - Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye + Settings, CheckCircle2, RefreshCw, Link as LinkIcon, ShieldCheck, HelpCircle, Eye, DollarSign } from 'lucide-react'; import { SubscriptionStatus } from '../types'; +import { AdminPricingManager } from './AdminPricingManager'; interface WebhookLog { id: string; @@ -64,7 +65,7 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { const [expandedStudentId, setExpandedStudentId] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'webhooks' | 'settings'>('finance'); + const [activeTab, setActiveTab] = useState<'finance' | 'students' | 'pricing' | 'webhooks' | 'settings'>('finance'); const [isLoading, setIsLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); @@ -256,11 +257,11 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { }, body: JSON.stringify({ type, targetId, courseName, moduleName }) }); - if (res.ok) { - await fetchStudents(); - alert('Certificado liberado manualmente!'); + if (token) { + fetchDashboardData(); + fetchCourses(); } else { - alert('Erro ao liberar certificado.'); + alert('Certificado liberado manualmente!'); } } catch (err) { console.error('Error unlocking certificate:', err); @@ -426,6 +427,16 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { Configurações Globais + + {/* TAB 1: FINANCE / PAYMENTS */} @@ -494,6 +505,9 @@ export default function AdminDashboard({ token }: AdminDashboardProps) { + {/* TAB PRICING */} + {activeTab === 'pricing' && ( + )} {/* TAB 2: STUDENTS MANAGEMENT */} diff --git a/src/components/AdminPricingManager.tsx b/src/components/AdminPricingManager.tsx new file mode 100644 index 0000000..0c56754 --- /dev/null +++ b/src/components/AdminPricingManager.tsx @@ -0,0 +1,440 @@ +import React, { useState, useEffect } from 'react'; +import { RefreshCw, Save, Plus, Trash2, Edit, CheckCircle2, AlertTriangle, BookOpen, Layers, DollarSign } from 'lucide-react'; +import { Course, Plan } from '../types'; + +interface AdminPricingManagerProps { + courses: Course[]; + fetchCourses: () => void; +} + +export const AdminPricingManager: React.FC = ({ courses, fetchCourses }) => { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [saveLoading, setSaveLoading] = useState(null); + + // Modal state for Plans + const [showPlanModal, setShowPlanModal] = useState(false); + const [planForm, setPlanForm] = useState>({ cycle: 'LIFETIME', active: true, includedCourses: [] }); + + const token = localStorage.getItem('devflix_token'); + + const fetchPlans = async () => { + setLoading(true); + try { + const res = await fetch('/api/admin/plans', { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setPlans(data); + } + } catch (err) { + console.error(err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchPlans(); + }, []); + + const handleUpdateCoursePrice = async (courseId: string, isLocked: boolean, price: number) => { + setSaveLoading(`course_${courseId}`); + try { + await fetch(`/api/admin/courses/${courseId}/price`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ isLocked, price }) + }); + fetchCourses(); + } catch (err) { + console.error(err); + } finally { + setSaveLoading(null); + } + }; + + const handleSavePlan = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveLoading('plan_save'); + try { + const url = planForm.id ? `/api/admin/plans/${planForm.id}` : '/api/admin/plans'; + const method = planForm.id ? 'PUT' : 'POST'; + + await fetch(url, { + method, + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify(planForm) + }); + + await fetchPlans(); + setShowPlanModal(false); + } catch (err) { + console.error(err); + } finally { + setSaveLoading(null); + } + }; + + const handleDeletePlan = async (planId: string) => { + if (!window.confirm('Tem certeza que deseja excluir este plano?')) return; + + try { + await fetch(`/api/admin/plans/${planId}`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${token}` } + }); + fetchPlans(); + } catch (err) { + console.error(err); + } + }; + + const toggleCourseInPlan = (courseId: string) => { + const current = planForm.includedCourses || []; + if (current.includes(courseId)) { + setPlanForm({ ...planForm, includedCourses: current.filter(id => id !== courseId) }); + } else { + setPlanForm({ ...planForm, includedCourses: [...current, courseId] }); + } + }; + + return ( +
+ + {/* SEÇÃO 1: Precificação Avulsa */} +
+
+
+

+ + Cursos Individuais (Venda Avulsa) +

+

Configure quais cursos são pagos e defina o preço unitário vitalício.

+
+ +
+ +
+
+ {courses.map(course => { + // Local state to handle fast typing before saving + const [localPrice, setLocalPrice] = useState(course.price || 0); + const [localLocked, setLocalLocked] = useState(course.isLocked || false); + + const isChanged = localPrice !== (course.price || 0) || localLocked !== (course.isLocked || false); + + return ( +
+
+

{course.title}

+

{course.category}

+ +
+ Acesso + +
+ + {localLocked && ( +
+ +
+ + setLocalPrice(parseFloat(e.target.value) || 0)} + className="w-full bg-black/40 border border-white/10 rounded-lg pl-9 pr-3 py-2 text-sm text-white focus:outline-none focus:border-red-500 transition-colors" + /> +
+
+ )} +
+ + +
+ ); + })} +
+
+
+ + {/* SEÇÃO 2: Planos e Combos */} +
+
+
+

+ + Combos & Planos de Assinatura +

+

Crie pacotes agrupando cursos ou assinaturas globais mensais/anuais.

+
+ +
+ +
+ {loading ? ( +
+ ) : plans.length === 0 ? ( +
+ +

Nenhum plano ou combo criado.

+

Crie pacotes para oferecer descontos na compra de vários cursos juntos.

+
+ ) : ( +
+ {plans.map(plan => ( +
+ {!plan.active && ( +
Inativo
+ )} + +
+
+ {plan.cycle === 'MONTHLY' ? 'Assinatura Mensal' : plan.cycle === 'YEARLY' ? 'Assinatura Anual' : 'Pagamento Único (Combo)'} +
+

{plan.title}

+

{plan.description}

+
+ +
+ R$ + {plan.price.toFixed(2).replace('.', ',')} + {plan.cycle !== 'LIFETIME' && / {plan.cycle === 'MONTHLY' ? 'mês' : 'ano'}} +
+ +
+ Cursos Inclusos: + {plan.includedCourses.length === 0 ? ( +
+ Acesso Global (Tudo) +
+ ) : ( +
    + {plan.includedCourses.slice(0, 3).map(cId => { + const c = courses.find(cx => cx.id === cId); + return c ?
  • {c.title}
  • : null; + })} + {plan.includedCourses.length > 3 && ( +
  • + {plan.includedCourses.length - 3} outros cursos
  • + )} +
+ )} +
+ +
+ + +
+
+ ))} +
+ )} +
+
+ + {/* Plan Form Modal */} + {showPlanModal && ( +
+
+
+

+ + {planForm.id ? 'Editar Plano / Combo' : 'Criar Novo Plano / Combo'} +

+ +
+ +
+
+
+
+ + setPlanForm({...planForm, title: e.target.value})} + className="w-full bg-black/40 border border-white/10 rounded-lg p-3 text-sm text-white focus:outline-none focus:border-purple-500 transition-colors" + placeholder="Ex: Combo Master Office" + /> +
+ +
+ +