feat: Add plan selection in CourseUnlockModal and CPF to ProfileView
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 33s Details

This commit is contained in:
Sidney Gomes 2026-07-22 19:28:13 -03:00
parent 9a5a977387
commit 90de5ebd1b
8 changed files with 820 additions and 117 deletions

184
server.ts
View File

@ -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') {

View File

@ -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<string | null>(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<string | null>(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) {
<Settings size={16} />
Configurações Globais
</button>
<button
onClick={() => setActiveTab('pricing')}
className={`flex items-center gap-2 px-4 py-2 border-b-2 font-medium text-sm transition-colors ${
activeTab === 'pricing' ? 'border-[#E50914] text-white' : 'border-transparent text-zinc-400 hover:text-white hover:border-white/20'
}`}
>
<DollarSign size={16} />
Precificação & Combos
</button>
</div>
{/* TAB 1: FINANCE / PAYMENTS */}
@ -494,6 +505,9 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
</div>
</div>
</div>
{/* TAB PRICING */}
{activeTab === 'pricing' && (
<AdminPricingManager courses={courses} fetchCourses={fetchCourses} />
)}
{/* TAB 2: STUDENTS MANAGEMENT */}

View File

@ -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<AdminPricingManagerProps> = ({ courses, fetchCourses }) => {
const [plans, setPlans] = useState<Plan[]>([]);
const [loading, setLoading] = useState(true);
const [saveLoading, setSaveLoading] = useState<string | null>(null);
// Modal state for Plans
const [showPlanModal, setShowPlanModal] = useState(false);
const [planForm, setPlanForm] = useState<Partial<Plan>>({ 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 (
<div className="space-y-8 animate-fade-in pb-12">
{/* SEÇÃO 1: Precificação Avulsa */}
<div className="bg-[#0a0a0a] rounded-xl border border-white/5 overflow-hidden shadow-2xl">
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-gradient-to-r from-red-900/20 to-transparent">
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<BookOpen className="w-5 h-5 text-red-500" />
Cursos Individuais (Venda Avulsa)
</h2>
<p className="text-zinc-400 text-sm mt-1">Configure quais cursos são pagos e defina o preço unitário vitalício.</p>
</div>
<button onClick={() => fetchCourses()} className="text-zinc-400 hover:text-white transition-colors">
<RefreshCw className="w-5 h-5" />
</button>
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{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 (
<div key={course.id} className="bg-zinc-900/50 border border-white/10 rounded-xl p-4 flex flex-col justify-between">
<div>
<h3 className="text-white font-bold mb-1 truncate" title={course.title}>{course.title}</h3>
<p className="text-xs text-zinc-500 mb-4">{course.category}</p>
<div className="flex items-center justify-between mb-4 bg-black/40 p-2 rounded-lg">
<span className="text-sm text-zinc-300 font-medium">Acesso</span>
<button
onClick={() => setLocalLocked(!localLocked)}
className={`px-3 py-1 rounded-md text-xs font-bold transition-all ${
localLocked ? 'bg-amber-500/20 text-amber-400 border border-amber-500/30' : 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
}`}
>
{localLocked ? '💰 Pago (Bloqueado)' : '🔓 Gratuito (Livre)'}
</button>
</div>
{localLocked && (
<div className="space-y-1 mb-4">
<label className="text-xs text-zinc-400 font-bold">Preço Avulso (R$)</label>
<div className="relative">
<DollarSign className="w-4 h-4 text-zinc-500 absolute left-3 top-3" />
<input
type="number"
step="0.01"
value={localPrice}
onChange={(e) => 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"
/>
</div>
</div>
)}
</div>
<button
disabled={!isChanged || saveLoading === `course_${course.id}`}
onClick={() => handleUpdateCoursePrice(course.id, localLocked, localPrice)}
className={`w-full py-2 rounded-lg text-sm font-bold flex items-center justify-center gap-2 transition-all ${
isChanged
? 'bg-red-600 hover:bg-red-700 text-white shadow-[0_0_15px_rgba(220,38,38,0.3)]'
: 'bg-zinc-800 text-zinc-500 cursor-not-allowed'
}`}
>
{saveLoading === `course_${course.id}` ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Salvar Alteração
</button>
</div>
);
})}
</div>
</div>
</div>
{/* SEÇÃO 2: Planos e Combos */}
<div className="bg-[#0a0a0a] rounded-xl border border-white/5 overflow-hidden shadow-2xl">
<div className="p-6 border-b border-white/5 flex justify-between items-center bg-gradient-to-r from-purple-900/20 to-transparent">
<div>
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-purple-500" />
Combos & Planos de Assinatura
</h2>
<p className="text-zinc-400 text-sm mt-1">Crie pacotes agrupando cursos ou assinaturas globais mensais/anuais.</p>
</div>
<button
onClick={() => { setPlanForm({ cycle: 'LIFETIME', active: true, includedCourses: [] }); setShowPlanModal(true); }}
className="bg-purple-600 hover:bg-purple-700 text-white text-sm font-bold py-2 px-4 rounded-lg flex items-center gap-2 transition-colors shadow-lg"
>
<Plus className="w-4 h-4" />
Criar Combo
</button>
</div>
<div className="p-6">
{loading ? (
<div className="flex justify-center py-12"><RefreshCw className="w-8 h-8 text-purple-500 animate-spin" /></div>
) : plans.length === 0 ? (
<div className="text-center py-12 border border-dashed border-white/10 rounded-xl bg-black/20">
<Layers className="w-12 h-12 text-zinc-700 mx-auto mb-3" />
<p className="text-zinc-400 font-medium">Nenhum plano ou combo criado.</p>
<p className="text-zinc-500 text-sm mt-1">Crie pacotes para oferecer descontos na compra de vários cursos juntos.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map(plan => (
<div key={plan.id} className={`border rounded-xl p-5 relative overflow-hidden flex flex-col ${
plan.active ? 'bg-zinc-900/40 border-purple-500/30' : 'bg-black/40 border-zinc-800 opacity-70'
}`}>
{!plan.active && (
<div className="absolute top-3 right-3 bg-red-500/20 text-red-400 text-xs font-bold px-2 py-1 rounded">Inativo</div>
)}
<div className="mb-4">
<div className="text-xs font-bold text-purple-400 mb-1 tracking-wider uppercase">
{plan.cycle === 'MONTHLY' ? 'Assinatura Mensal' : plan.cycle === 'YEARLY' ? 'Assinatura Anual' : 'Pagamento Único (Combo)'}
</div>
<h3 className="text-lg font-bold text-white mb-1">{plan.title}</h3>
<p className="text-sm text-zinc-400 line-clamp-2 min-h-[40px]">{plan.description}</p>
</div>
<div className="flex items-end gap-1 mb-6">
<span className="text-sm text-zinc-500 font-bold mb-1">R$</span>
<span className="text-3xl font-black text-white">{plan.price.toFixed(2).replace('.', ',')}</span>
{plan.cycle !== 'LIFETIME' && <span className="text-sm text-zinc-500 font-bold mb-1">/ {plan.cycle === 'MONTHLY' ? 'mês' : 'ano'}</span>}
</div>
<div className="bg-black/40 rounded-lg p-3 mb-6 flex-grow">
<span className="text-xs text-zinc-500 font-bold uppercase block mb-2">Cursos Inclusos:</span>
{plan.includedCourses.length === 0 ? (
<div className="flex items-center gap-2 text-emerald-400 text-sm font-medium">
<CheckCircle2 className="w-4 h-4" /> Acesso Global (Tudo)
</div>
) : (
<ul className="space-y-1">
{plan.includedCourses.slice(0, 3).map(cId => {
const c = courses.find(cx => cx.id === cId);
return c ? <li key={cId} className="text-sm text-zinc-300 truncate flex items-center gap-2"><div className="w-1.5 h-1.5 rounded-full bg-purple-500" />{c.title}</li> : null;
})}
{plan.includedCourses.length > 3 && (
<li className="text-xs text-zinc-500 italic mt-1">+ {plan.includedCourses.length - 3} outros cursos</li>
)}
</ul>
)}
</div>
<div className="flex gap-2 mt-auto">
<button
onClick={() => { setPlanForm(plan); setShowPlanModal(true); }}
className="flex-1 bg-zinc-800 hover:bg-zinc-700 text-white text-sm font-bold py-2 rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Edit className="w-4 h-4" /> Editar
</button>
<button
onClick={() => handleDeletePlan(plan.id)}
className="bg-red-950/30 hover:bg-red-900/50 text-red-500 text-sm font-bold p-2 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
{/* Plan Form Modal */}
{showPlanModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4 overflow-y-auto">
<div className="bg-[#141414] border border-white/10 rounded-2xl w-full max-w-2xl overflow-hidden shadow-2xl my-8 relative">
<div className="p-6 border-b border-white/10 bg-gradient-to-r from-purple-900/20 to-transparent flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-purple-500" />
{planForm.id ? 'Editar Plano / Combo' : 'Criar Novo Plano / Combo'}
</h2>
<button onClick={() => setShowPlanModal(false)} className="text-zinc-500 hover:text-white transition-colors">
</button>
</div>
<form onSubmit={handleSavePlan} className="p-6 space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold">Nome do Plano/Combo</label>
<input
required
type="text"
value={planForm.title || ''}
onChange={e => 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"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold">Descrição Curta</label>
<textarea
value={planForm.description || ''}
onChange={e => setPlanForm({...planForm, description: 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 resize-none h-20"
placeholder="Benefícios deste pacote..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold">Preço (R$)</label>
<input
required
type="number"
step="0.01"
value={planForm.price || ''}
onChange={e => setPlanForm({...planForm, price: parseFloat(e.target.value) || 0})}
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"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-zinc-400 font-bold">Ciclo Faturamento</label>
<select
value={planForm.cycle}
onChange={e => setPlanForm({...planForm, cycle: e.target.value as any})}
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 appearance-none"
>
<option value="LIFETIME">Pagamento Único (Vitalício)</option>
<option value="MONTHLY">Mensal (Assinatura)</option>
<option value="YEARLY">Anual (Assinatura)</option>
</select>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer p-3 bg-black/20 rounded-lg border border-white/5 hover:bg-black/40 transition-colors">
<input
type="checkbox"
checked={planForm.active}
onChange={e => setPlanForm({...planForm, active: e.target.checked})}
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 focus:ring-offset-zinc-900 bg-zinc-800"
/>
<div>
<span className="text-sm font-bold text-white block">Plano Ativo (Visível)</span>
<span className="text-xs text-zinc-500">Alunos poderão comprar este plano.</span>
</div>
</label>
</div>
{/* Course Selection */}
<div className="bg-black/30 border border-white/5 rounded-xl flex flex-col max-h-[400px]">
<div className="p-4 border-b border-white/10 bg-zinc-900/50">
<h3 className="text-sm font-bold text-white flex items-center justify-between">
Cursos Inclusos no Pacote
<span className="text-xs bg-purple-500/20 text-purple-400 px-2 py-0.5 rounded-full">
{planForm.includedCourses?.length === 0 ? 'TUDO' : planForm.includedCourses?.length}
</span>
</h3>
</div>
<div className="p-4 overflow-y-auto space-y-2 flex-grow custom-scrollbar">
<label className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
planForm.includedCourses?.length === 0
? 'bg-purple-900/20 border-purple-500/50'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}>
<input
type="checkbox"
checked={planForm.includedCourses?.length === 0}
onChange={() => setPlanForm({...planForm, includedCourses: []})}
className="w-5 h-5 rounded-full border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
/>
<div>
<span className="text-sm font-bold text-white block">Acesso Global (All-Access)</span>
<span className="text-xs text-zinc-400">Libera todos os cursos, presentes e futuros.</span>
</div>
</label>
<div className="my-4 border-t border-white/10 relative">
<span className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-black px-2 text-xs font-bold text-zinc-600">OU ESCOLHA ESPECÍFICOS</span>
</div>
{courses.map(course => (
<label key={course.id} className={`flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-all ${
planForm.includedCourses?.includes(course.id)
? 'bg-purple-900/20 border-purple-500/50'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}>
<input
type="checkbox"
checked={planForm.includedCourses?.includes(course.id)}
onChange={() => toggleCourseInPlan(course.id)}
className="w-5 h-5 rounded border-zinc-600 text-purple-600 focus:ring-purple-600 bg-zinc-800"
/>
<span className="text-sm font-medium text-zinc-300 truncate">{course.title}</span>
</label>
))}
</div>
</div>
</div>
<div className="pt-6 border-t border-white/10 flex justify-end gap-3">
<button
type="button"
onClick={() => setShowPlanModal(false)}
className="px-6 py-2.5 rounded-lg text-sm font-bold text-zinc-300 hover:text-white hover:bg-white/5 transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={saveLoading === 'plan_save'}
className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2.5 rounded-lg text-sm font-bold shadow-lg shadow-purple-600/20 transition-all flex items-center gap-2"
>
{saveLoading === 'plan_save' ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
Salvar Plano
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
};

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play } from 'lucide-react';
import { Course } from '../types';
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers } from 'lucide-react';
import { Course, Plan } from '../types';
interface CourseUnlockModalProps {
course: Course & { price?: number };
@ -13,7 +13,6 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
const [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
const [isPaying, setIsPaying] = useState(false);
const [pixCopied, setPixCopied] = useState(false);
const [boletoCopied, setBoletoCopied] = useState(false);
const [unlocked, setUnlocked] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -26,6 +25,11 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
const [ccCvv, setCcCvv] = useState('');
const [installments, setInstallments] = useState(1);
// Pricing & Plans Selection
const [availablePlans, setAvailablePlans] = useState<Plan[]>([]);
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null); // null means 'Avulso'
const [loadingPlans, setLoadingPlans] = useState(true);
const coursePrice = course.price || 49.90;
const handleCopyPix = () => {
@ -56,8 +60,22 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
}
})
.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));
}
}, [token]);
}, [token, course.id]);
const handleConfirmPayment = async (e: React.FormEvent) => {
e.preventDefault();
@ -83,17 +101,27 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
} : undefined;
try {
const res = await fetch(`/api/courses/${course.id}/unlock`, {
let endpoint = '';
let bodyData: any = {
billingType: paymentMethod,
creditCardInfo
};
if (selectedPlanId) {
endpoint = `/api/subscription/subscribe`;
bodyData.planId = selectedPlanId;
} else {
endpoint = `/api/courses/${course.id}/unlock`;
bodyData.installmentCount = paymentMethod === 'CREDIT_CARD' ? installments : 1;
}
const res = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
billingType: paymentMethod,
creditCardInfo,
installmentCount: paymentMethod === 'CREDIT_CARD' ? installments : 1
})
body: JSON.stringify(bodyData)
});
const contentType = res.headers.get('content-type');
@ -122,9 +150,12 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
}
};
const selectedPlanObj = selectedPlanId ? availablePlans.find(p => p.id === selectedPlanId) : null;
const currentPrice = selectedPlanObj ? selectedPlanObj.price : coursePrice;
return (
<div id="unlock-modal-overlay" className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-fade-in">
<div className="relative w-full max-w-2xl bg-[#141414]/95 border border-white/10 rounded-2xl shadow-2xl overflow-hidden md:flex">
<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
@ -136,45 +167,89 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
{!unlocked ? (
<>
{/* Left: Course Visual Details */}
<div className="w-full md:w-5/12 bg-zinc-950 p-6 flex flex-col justify-between border-r border-white/5">
<div className="space-y-4">
<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">
Adquirir Curso
</span>
<div className="relative aspect-video rounded-lg overflow-hidden border border-white/10">
<img src={course.thumbnail} alt={course.title} className="w-full h-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent" />
</div>
<div className="space-y-2">
<h3 className="font-display font-black text-lg text-white leading-tight">
{course.title}
</h3>
<p className="text-xs text-zinc-400 line-clamp-3">
{course.description}
</p>
{/* Left: Options & Selection */}
<div className="w-full md:w-1/2 bg-zinc-950 p-6 md:p-8 flex flex-col border-r border-white/5 overflow-y-auto custom-scrollbar max-h-[85vh]">
<div className="mb-6">
<div className="flex items-center gap-4 mb-4">
<img src={course.thumbnail} alt={course.title} className="w-20 h-20 object-cover rounded-lg border border-white/10" />
<div>
<span className="text-[10px] uppercase tracking-widest font-extrabold text-[#E50914] bg-[#E50914]/10 px-2.5 py-1 rounded-full border border-[#E50914]/20 inline-block mb-1.5">
Adquirir Acesso
</span>
<h3 className="font-display font-black text-lg text-white leading-tight line-clamp-2">
{course.title}
</h3>
</div>
</div>
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.</p>
</div>
<div className="mt-6 pt-4 border-t border-white/5">
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Acesso Vitalício</p>
<div className="flex items-baseline space-x-1.5 mt-1">
<span className="text-zinc-400 text-xs font-semibold">R$</span>
<span className="text-white text-3xl font-black font-mono tracking-tight">{coursePrice.toFixed(2)}</span>
<div className="space-y-4">
{/* Option 1: Avulso */}
<div
onClick={() => setSelectedPlanId(null)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all ${
selectedPlanId === null
? 'bg-red-950/20 border-[#E50914]'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}
>
{selectedPlanId === null && (
<div className="absolute top-3 right-3 text-[#E50914]"><Check className="w-5 h-5" /></div>
)}
<h4 className="font-bold text-white mb-1">Compra Avulsa (Apenas este curso)</h4>
<p className="text-xs text-zinc-400 mb-3">Pague uma vez e tenha acesso vitalício ao curso selecionado.</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{coursePrice.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">taxa única</span>
</div>
</div>
<p className="text-[10px] text-zinc-500 mt-1">Taxa única. Sem mensalidades adicionais.</p>
{/* Option 2+: Plans/Combos */}
{loadingPlans ? (
<div className="p-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-[#E50914]" /></div>
) : availablePlans.map(plan => (
<div
key={plan.id}
onClick={() => setSelectedPlanId(plan.id)}
className={`relative p-4 rounded-xl border-2 cursor-pointer transition-all overflow-hidden ${
selectedPlanId === plan.id
? 'bg-purple-900/20 border-purple-500 shadow-[0_0_20px_rgba(168,85,247,0.15)]'
: 'bg-zinc-900/50 border-white/5 hover:border-white/20'
}`}
>
<div className="absolute top-0 right-0 bg-purple-600 text-white text-[9px] font-bold px-3 py-1 rounded-bl-lg uppercase tracking-wider">
Recomendado
</div>
{selectedPlanId === plan.id && (
<div className="absolute top-3 right-3 mt-3 text-purple-400"><Check className="w-5 h-5" /></div>
)}
<h4 className="font-bold text-white mb-1 flex items-center gap-2 mt-2">
<Layers className="w-4 h-4 text-purple-400" />
{plan.title}
</h4>
<p className="text-xs text-zinc-400 mb-3">{plan.description || (plan.includedCourses.length === 0 ? 'Acesso Global a todos os cursos' : 'Combo promocional')}</p>
<div className="flex items-baseline gap-1">
<span className="text-sm text-zinc-500 font-bold">R$</span>
<span className="text-2xl font-black text-white">{plan.price.toFixed(2)}</span>
<span className="text-xs text-zinc-500 font-medium ml-1">
{plan.cycle === 'MONTHLY' ? '/ mês' : plan.cycle === 'YEARLY' ? '/ ano' : 'taxa única'}
</span>
</div>
</div>
))}
</div>
</div>
{/* Right: Payment Forms */}
<div className="w-full md:w-7/12 p-6 md:p-8 flex flex-col justify-between space-y-6">
<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]">
<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/60 rounded-xl border border-white/5 mb-6`}>
<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')}
@ -215,7 +290,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
<div className="space-y-4 text-center">
{!paymentData ? (
<div className="text-zinc-400 text-xs py-4 text-center">
Clique em Confirmar para gerar seu QR Code PIX
Clique em Confirmar para gerar seu QR Code PIX no valor de R$ {currentPrice.toFixed(2)}
</div>
) : (
<>
@ -273,7 +348,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
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"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
@ -285,7 +360,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
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"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
@ -298,7 +373,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
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"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
@ -310,29 +385,31 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
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"
className="w-full glass-input rounded-lg p-3 text-xs text-white focus:outline-none bg-black/40"
/>
</div>
</div>
{/* Parcelamento (Installments Dropdown) */}
<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"
>
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => {
const val = (coursePrice / 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>
{/* 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>
)}
@ -344,7 +421,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
<div className="space-y-2">
<p className="text-sm font-bold text-white">Boleto Bancário</p>
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
O boleto será gerado no valor de R$ {coursePrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis.
O boleto será gerado no valor de R$ {currentPrice.toFixed(2)}. O prazo de compensação é de 1 a 2 dias úteis.
</p>
<div className="pt-2">
{paymentData?.payment?.invoiceUrl ? (
@ -366,32 +443,33 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
{/* Submit button */}
{!paymentData && (
<div className="pt-4">
<div className="pt-4 mt-auto">
<button
type="submit"
disabled={isPaying}
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-2 cursor-pointer disabled:opacity-50"
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 Asaas...</span>
<span>Processando Pagamento Segurizado...</span>
</>
) : (
<>
<Sparkles className="w-4 h-4 text-yellow-400" />
<span>Gerar Cobrança (Asaas)</span>
<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 className="text-center text-[10px] text-zinc-500">
Transação processada de forma 100% segura pelo gateway **Asaas**.
</div>
</div>
</>
) : (
@ -406,13 +484,13 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
Pagamento Confirmado!
</h3>
<p className="text-zinc-400 text-xs leading-relaxed">
Parabéns! O curso **{course.title}** foi desbloqueado com sucesso na sua conta **MicrotecFlix**. O acesso a todas as aulas, módulos e anotações agora é vitalício para você!
Parabéns! O seu pagamento foi aprovado com sucesso na plataforma. Seu acesso está liberado!
</p>
</div>
<button
onClick={onSuccess}
className="glass-btn-primary text-white text-xs font-bold py-3 px-8 rounded-lg flex items-center justify-center space-x-2 shadow-lg transition-transform hover:scale-105 cursor-pointer"
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>

View File

@ -13,6 +13,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
const [name, setName] = useState(user.name);
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
const [birthDate, setBirthDate] = useState(user.birthDate || '');
const [cpf, setCpf] = useState(user.cpf || '');
const [isSaving, setIsSaving] = useState(false);
const [isUploading, setIsUploading] = useState(false);
@ -48,7 +49,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` })
},
body: JSON.stringify({ name, whatsapp, birthDate })
body: JSON.stringify({ name, whatsapp, birthDate, cpf })
});
const data = await res.json();
if (data.success && onUpdateUser) {
@ -121,6 +122,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
setName(user.name);
setWhatsapp(user.whatsapp || '');
setBirthDate(user.birthDate || '');
setCpf(user.cpf || '');
}}
className="flex items-center space-x-1 px-4 py-2 rounded-lg text-sm font-bold text-zinc-400 hover:text-white transition-colors"
>
@ -192,6 +194,27 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
<div className="flex items-center space-x-3 mb-1">
<Shield className="w-4 h-4 text-zinc-500" />
<p className="text-[10px] uppercase font-bold text-zinc-500">CPF</p>
</div>
{isEditing ? (
<div className="pl-7">
<input
type="text"
value={cpf}
onChange={(e) => setCpf(e.target.value.replace(/\D/g, '').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d)/, '$1.$2').replace(/(\d{3})(\d{1,2})$/, '$1-$2'))}
placeholder="000.000.000-00"
maxLength={14}
className="w-full bg-black/50 border border-white/10 rounded-md p-1.5 text-sm text-white focus:outline-none focus:border-[#E50914]"
/>
</div>
) : (
<p className="text-sm font-medium text-white pl-7">{user.cpf || 'Não informado'}</p>
)}
</div>
<div className="bg-black/30 p-4 rounded-xl border border-white/5 flex flex-col justify-center">
<div className="flex items-center space-x-3 mb-1">
<Mail className="w-4 h-4 text-zinc-500" />

View File

@ -1,7 +1,7 @@
import fs from 'fs';
import path from 'path';
import bcrypt from 'bcryptjs';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment } from '../types';
import { User, Course, Module, Lesson, Note, LessonProgress, WebhookLog, SubscriptionPayment, Plan } from '../types';
const DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/app/data') ? '/app/data' : process.cwd());
const DB_FILE = path.join(DATA_DIR, 'database.json');
@ -20,6 +20,7 @@ export interface DatabaseSchema {
certificates: any[];
notifications: any[];
settings: any; // SystemSettings
plans: Plan[];
}
const DEFAULT_DB: DatabaseSchema = {
@ -42,7 +43,8 @@ const DEFAULT_DB: DatabaseSchema = {
asaasWebhookUrl: '',
asaasWebhookSecret: '',
helpText: '# Central de Ajuda\n\nAqui você encontra todas as informações sobre os cursos e funcionamento da plataforma.'
}
},
plans: []
};
// Helper to load db
@ -54,7 +56,11 @@ export function loadDb(): DatabaseSchema {
return db;
}
const data = fs.readFileSync(DB_FILE, 'utf-8');
return JSON.parse(data);
const parsedData = JSON.parse(data);
if (!parsedData.plans) {
parsedData.plans = [];
}
return parsedData;
} catch (err) {
console.error('Error loading database:', err);
return DEFAULT_DB;
@ -443,6 +449,7 @@ function createInitialDb(): DatabaseSchema {
asaasWebhookUrl: '',
asaasWebhookSecret: '',
helpText: '# Central de Ajuda\n\nAqui você encontra todas as informações sobre os cursos e funcionamento da plataforma.'
}
},
plans: []
};
}

View File

@ -118,7 +118,9 @@ export class AsaasService {
customerId: string,
value: number,
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
creditCardInfo?: any
creditCardInfo?: any,
cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY',
description: string = 'Assinatura Mensal DevFlix'
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
const apiKey = this.getApiKey();
if (!apiKey) {
@ -131,8 +133,8 @@ export class AsaasService {
billingType,
value,
nextDueDate: new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
cycle: 'MONTHLY',
description: 'Assinatura Mensal DevFlix'
cycle,
description
};
if (billingType === 'CREDIT_CARD' && creditCardInfo) {

View File

@ -35,8 +35,19 @@ export interface Course {
thumbnail: string;
category: string;
certificateMode?: 'PER_MODULE' | 'FULL_COURSE';
isLocked?: boolean; // If true, requires payment/unlock
price?: number; // Cost to unlock if locked (e.g. 89.90)
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
price?: number; // Preço para compra avulsa (vitalícia)
createdAt: string;
}
export interface Plan {
id: string;
title: string;
description: string;
price: number;
cycle: 'MONTHLY' | 'YEARLY' | 'LIFETIME';
includedCourses: string[]; // Array vazio significa 'Global/Todos os cursos'
active: boolean;
createdAt: string;
}