feat: Add plan selection in CourseUnlockModal and CPF to ProfileView
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 33s
Details
Build and Deploy (Gitea) / build-and-deploy (push) Failing after 33s
Details
This commit is contained in:
parent
9a5a977387
commit
90de5ebd1b
172
server.ts
172
server.ts
|
|
@ -265,7 +265,7 @@ app.get('/api/auth/me', authenticateToken, (req: AuthenticatedRequest, res: Resp
|
||||||
|
|
||||||
// 3.1. Profile: Update Data
|
// 3.1. Profile: Update Data
|
||||||
app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res: Response) => {
|
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 userId = req.user!.id;
|
||||||
const db = loadDb();
|
const db = loadDb();
|
||||||
|
|
||||||
|
|
@ -275,9 +275,10 @@ app.put('/api/users/profile', authenticateToken, (req: AuthenticatedRequest, res
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (name) user.name = name;
|
if (name !== undefined) user.name = name;
|
||||||
if (whatsapp !== undefined) user.whatsapp = whatsapp;
|
if (whatsapp !== undefined) user.whatsapp = whatsapp;
|
||||||
if (birthDate !== undefined) user.birthDate = birthDate;
|
if (birthDate !== undefined) user.birthDate = birthDate;
|
||||||
|
if (cpf !== undefined) user.cpf = cpf;
|
||||||
|
|
||||||
saveDb(db);
|
saveDb(db);
|
||||||
|
|
||||||
|
|
@ -627,7 +628,7 @@ app.get('/api/subscription/status', authenticateToken, (req: AuthenticatedReques
|
||||||
|
|
||||||
// 9. Subscribe to plan
|
// 9. Subscribe to plan
|
||||||
app.post('/api/subscription/subscribe', authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
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;
|
const userId = req.user!.id;
|
||||||
|
|
||||||
if (!billingType) {
|
if (!billingType) {
|
||||||
|
|
@ -653,43 +654,98 @@ app.post('/api/subscription/subscribe', authenticateToken, async (req: Authentic
|
||||||
user.asaasCustomerId = customerId;
|
user.asaasCustomerId = customerId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Assinatura de R$ 49.90/mês
|
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(
|
const { subscriptionId, payment } = await AsaasService.createSubscription(
|
||||||
customerId,
|
customerId,
|
||||||
49.90,
|
price,
|
||||||
billingType,
|
billingType,
|
||||||
creditCardInfo
|
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
|
// Add subscription payment to database
|
||||||
const newPayment: SubscriptionPayment = {
|
const newPayment: SubscriptionPayment = {
|
||||||
id: payment.id || `pay_${Math.random().toString(36).substring(2, 9)}`,
|
id: asaasPaymentId,
|
||||||
userId,
|
userId,
|
||||||
value: 49.90,
|
value: price,
|
||||||
status: billingType === 'CREDIT_CARD' ? 'CONFIRMED' : 'PENDING',
|
status: statusAsaas,
|
||||||
billingType,
|
billingType,
|
||||||
invoiceUrl: payment.invoiceUrl || `https://sandbox.asaas.com/i/${subscriptionId}`,
|
invoiceUrl: invoiceUrlStr,
|
||||||
dueDate: payment.dueDate || new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
|
dueDate: asaasDueDate,
|
||||||
paidAt: billingType === 'CREDIT_CARD' ? new Date().toISOString() : null,
|
paidAt: statusAsaas === 'CONFIRMED' ? new Date().toISOString() : null,
|
||||||
createdAt: new Date().toISOString()
|
createdAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
db.payments.push(newPayment);
|
db.payments.push(newPayment);
|
||||||
|
|
||||||
// Update user subscription status
|
// If sandbox auto-approves credit card
|
||||||
if (billingType === 'CREDIT_CARD') {
|
if (statusAsaas === 'CONFIRMED') {
|
||||||
user.subscriptionStatus = 'ACTIVE';
|
if (cycle === 'LIFETIME' && plan) {
|
||||||
|
if (!plan.includedCourses || plan.includedCourses.length === 0) {
|
||||||
|
user.subscriptionStatus = 'ACTIVE'; // Libera tudo
|
||||||
} else {
|
} else {
|
||||||
user.subscriptionStatus = 'TRIAL'; // Give them trial until Pix/Boleto clears, or keep Trial
|
user.unlockedCourses = [...(user.unlockedCourses || []), ...plan.includedCourses];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
user.subscriptionStatus = 'ACTIVE';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
saveDb(db);
|
saveDb(db);
|
||||||
|
|
||||||
res.json({
|
res.json({ success: true, payment: newPayment, pixData });
|
||||||
success: true,
|
|
||||||
subscriptionId,
|
|
||||||
payment: newPayment
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Subscription error:', err);
|
console.error('Subscription error:', err);
|
||||||
res.status(400).json({ error: err.message || 'Erro ao processar assinatura' });
|
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);
|
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 ---
|
// --- VITE MIDDLEWARE / SPA FALLBACK ---
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
TrendingUp, Users, AlertTriangle, XCircle, Search,
|
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';
|
} from 'lucide-react';
|
||||||
import { SubscriptionStatus } from '../types';
|
import { SubscriptionStatus } from '../types';
|
||||||
|
import { AdminPricingManager } from './AdminPricingManager';
|
||||||
|
|
||||||
interface WebhookLog {
|
interface WebhookLog {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -64,7 +65,7 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
const [expandedStudentId, setExpandedStudentId] = useState<string | null>(null);
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
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 [isLoading, setIsLoading] = useState(true);
|
||||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -256,11 +257,11 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ type, targetId, courseName, moduleName })
|
body: JSON.stringify({ type, targetId, courseName, moduleName })
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (token) {
|
||||||
await fetchStudents();
|
fetchDashboardData();
|
||||||
alert('Certificado liberado manualmente!');
|
fetchCourses();
|
||||||
} else {
|
} else {
|
||||||
alert('Erro ao liberar certificado.');
|
alert('Certificado liberado manualmente!');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error unlocking certificate:', err);
|
console.error('Error unlocking certificate:', err);
|
||||||
|
|
@ -426,6 +427,16 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
<Settings size={16} />
|
<Settings size={16} />
|
||||||
Configurações Globais
|
Configurações Globais
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* TAB 1: FINANCE / PAYMENTS */}
|
{/* TAB 1: FINANCE / PAYMENTS */}
|
||||||
|
|
@ -494,6 +505,9 @@ export default function AdminDashboard({ token }: AdminDashboardProps) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* TAB PRICING */}
|
||||||
|
{activeTab === 'pricing' && (
|
||||||
|
<AdminPricingManager courses={courses} fetchCourses={fetchCourses} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TAB 2: STUDENTS MANAGEMENT */}
|
{/* TAB 2: STUDENTS MANAGEMENT */}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play } from 'lucide-react';
|
import { X, Check, CreditCard, QrCode, FileText, Sparkles, Loader2, Play, Layers } from 'lucide-react';
|
||||||
import { Course } from '../types';
|
import { Course, Plan } from '../types';
|
||||||
|
|
||||||
interface CourseUnlockModalProps {
|
interface CourseUnlockModalProps {
|
||||||
course: Course & { price?: number };
|
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 [paymentMethod, setPaymentMethod] = useState<'PIX' | 'CREDIT_CARD' | 'BOLETO'>('PIX');
|
||||||
const [isPaying, setIsPaying] = useState(false);
|
const [isPaying, setIsPaying] = useState(false);
|
||||||
const [pixCopied, setPixCopied] = useState(false);
|
const [pixCopied, setPixCopied] = useState(false);
|
||||||
const [boletoCopied, setBoletoCopied] = useState(false);
|
|
||||||
const [unlocked, setUnlocked] = useState(false);
|
const [unlocked, setUnlocked] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|
@ -26,6 +25,11 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
const [ccCvv, setCcCvv] = useState('');
|
const [ccCvv, setCcCvv] = useState('');
|
||||||
const [installments, setInstallments] = useState(1);
|
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 coursePrice = course.price || 49.90;
|
||||||
|
|
||||||
const handleCopyPix = () => {
|
const handleCopyPix = () => {
|
||||||
|
|
@ -56,8 +60,22 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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) => {
|
const handleConfirmPayment = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -83,17 +101,27 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(bodyData)
|
||||||
billingType: paymentMethod,
|
|
||||||
creditCardInfo,
|
|
||||||
installmentCount: paymentMethod === 'CREDIT_CARD' ? installments : 1
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const contentType = res.headers.get('content-type');
|
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 (
|
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 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-2xl bg-[#141414]/95 border border-white/10 rounded-2xl shadow-2xl overflow-hidden md:flex">
|
<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 */}
|
{/* Close Button */}
|
||||||
<button
|
<button
|
||||||
|
|
@ -136,45 +167,89 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
|
|
||||||
{!unlocked ? (
|
{!unlocked ? (
|
||||||
<>
|
<>
|
||||||
{/* Left: Course Visual Details */}
|
{/* Left: Options & Selection */}
|
||||||
<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="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="space-y-4">
|
<div className="mb-6">
|
||||||
<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">
|
<div className="flex items-center gap-4 mb-4">
|
||||||
Adquirir Curso
|
<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>
|
</span>
|
||||||
|
<h3 className="font-display font-black text-lg text-white leading-tight line-clamp-2">
|
||||||
<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}
|
{course.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-zinc-400 line-clamp-3">
|
</div>
|
||||||
{course.description}
|
</div>
|
||||||
</p>
|
<p className="text-sm text-zinc-400">Escolha a melhor oferta para você acessar este curso e expandir seus conhecimentos.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-4 border-t border-white/5">
|
{/* Option 2+: Plans/Combos */}
|
||||||
<p className="text-[10px] text-zinc-500 font-bold uppercase tracking-wider">Acesso Vitalício</p>
|
{loadingPlans ? (
|
||||||
<div className="flex items-baseline space-x-1.5 mt-1">
|
<div className="p-8 flex justify-center"><Loader2 className="w-6 h-6 animate-spin text-[#E50914]" /></div>
|
||||||
<span className="text-zinc-400 text-xs font-semibold">R$</span>
|
) : availablePlans.map(plan => (
|
||||||
<span className="text-white text-3xl font-black font-mono tracking-tight">{coursePrice.toFixed(2)}</span>
|
<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>
|
</div>
|
||||||
<p className="text-[10px] text-zinc-500 mt-1">Taxa única. Sem mensalidades adicionais.</p>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Payment Forms */}
|
{/* 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>
|
<div>
|
||||||
<h4 className="font-display font-bold text-sm text-white uppercase tracking-wider mb-4">Escolha a forma de pagamento</h4>
|
<h4 className="font-display font-bold text-sm text-white uppercase tracking-wider mb-4">Escolha a forma de pagamento</h4>
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* 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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPaymentMethod('PIX')}
|
onClick={() => setPaymentMethod('PIX')}
|
||||||
|
|
@ -215,7 +290,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
{!paymentData ? (
|
{!paymentData ? (
|
||||||
<div className="text-zinc-400 text-xs py-4 text-center">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|
@ -273,7 +348,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
value={ccNumber}
|
value={ccNumber}
|
||||||
onChange={(e) => setCcNumber(e.target.value.replace(/\D/g, '').replace(/(\d{4})/g, '$1 ').trim().slice(0, 19))}
|
onChange={(e) => setCcNumber(e.target.value.replace(/\D/g, '').replace(/(\d{4})/g, '$1 ').trim().slice(0, 19))}
|
||||||
placeholder="0000 0000 0000 0000"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -285,7 +360,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
value={ccName}
|
value={ccName}
|
||||||
onChange={(e) => setCcName(e.target.value.toUpperCase())}
|
onChange={(e) => setCcName(e.target.value.toUpperCase())}
|
||||||
placeholder="NOME IGUAL DO CARTÃO"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -298,7 +373,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
value={ccExpiry}
|
value={ccExpiry}
|
||||||
onChange={(e) => setCcExpiry(e.target.value.replace(/\D/g, '').replace(/(\d{2})/g, '$1/').slice(0, 5))}
|
onChange={(e) => setCcExpiry(e.target.value.replace(/\D/g, '').replace(/(\d{2})/g, '$1/').slice(0, 5))}
|
||||||
placeholder="MM/AA"
|
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>
|
</div>
|
||||||
|
|
||||||
|
|
@ -310,21 +385,22 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
value={ccCvv}
|
value={ccCvv}
|
||||||
onChange={(e) => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
onChange={(e) => setCcCvv(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||||
placeholder="123"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Parcelamento (Installments Dropdown) */}
|
{/* Parcelamento (apenas para Vitalício/Pagamento Único) */}
|
||||||
|
{(!selectedPlanObj || selectedPlanObj.cycle === 'LIFETIME') && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Opções de Parcelamento</label>
|
<label className="text-[10px] text-zinc-400 font-bold uppercase tracking-wider">Opções de Parcelamento</label>
|
||||||
<select
|
<select
|
||||||
value={installments}
|
value={installments}
|
||||||
onChange={(e) => setInstallments(Number(e.target.value))}
|
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"
|
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 => {
|
{[1, 2, 3, 4, 5, 6, 10, 12].map(n => {
|
||||||
const val = (coursePrice / n).toFixed(2);
|
const val = (currentPrice / n).toFixed(2);
|
||||||
return (
|
return (
|
||||||
<option key={n} value={n} className="bg-zinc-900 text-white">
|
<option key={n} value={n} className="bg-zinc-900 text-white">
|
||||||
{n}x de R$ {val} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
|
{n}x de R$ {val} {n === 1 ? '(À vista sem juros)' : '(sem juros)'}
|
||||||
|
|
@ -333,6 +409,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
})}
|
})}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -344,7 +421,7 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-sm font-bold text-white">Boleto Bancário</p>
|
<p className="text-sm font-bold text-white">Boleto Bancário</p>
|
||||||
<p className="text-xs text-zinc-400 max-w-sm mx-auto">
|
<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>
|
</p>
|
||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
{paymentData?.payment?.invoiceUrl ? (
|
{paymentData?.payment?.invoiceUrl ? (
|
||||||
|
|
@ -366,32 +443,33 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
|
|
||||||
{/* Submit button */}
|
{/* Submit button */}
|
||||||
{!paymentData && (
|
{!paymentData && (
|
||||||
<div className="pt-4">
|
<div className="pt-4 mt-auto">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isPaying}
|
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 ? (
|
{isPaying ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin text-white" />
|
<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" />
|
<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>
|
</button>
|
||||||
|
<div className="text-center text-[10px] text-zinc-500 mt-4">
|
||||||
|
Pagamento via API Oficial Sandbox Asaas.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-center text-[10px] text-zinc-500">
|
|
||||||
Transação processada de forma 100% segura pelo gateway **Asaas**.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -406,13 +484,13 @@ export default function CourseUnlockModal({ course, onClose, onSuccess, token }:
|
||||||
Pagamento Confirmado!
|
Pagamento Confirmado!
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-zinc-400 text-xs leading-relaxed">
|
<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 já está liberado!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onSuccess}
|
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" />
|
<Play className="w-4 h-4 fill-white" />
|
||||||
<span>Começar a Estudar Agora</span>
|
<span>Começar a Estudar Agora</span>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
const [name, setName] = useState(user.name);
|
const [name, setName] = useState(user.name);
|
||||||
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
|
const [whatsapp, setWhatsapp] = useState(user.whatsapp || '');
|
||||||
const [birthDate, setBirthDate] = useState(user.birthDate || '');
|
const [birthDate, setBirthDate] = useState(user.birthDate || '');
|
||||||
|
const [cpf, setCpf] = useState(user.cpf || '');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
|
@ -48,7 +49,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token && { Authorization: `Bearer ${token}` })
|
...(token && { Authorization: `Bearer ${token}` })
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ name, whatsapp, birthDate })
|
body: JSON.stringify({ name, whatsapp, birthDate, cpf })
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success && onUpdateUser) {
|
if (data.success && onUpdateUser) {
|
||||||
|
|
@ -121,6 +122,7 @@ export default function ProfileView({ user, onBack, onUpdateUser }: ProfileViewP
|
||||||
setName(user.name);
|
setName(user.name);
|
||||||
setWhatsapp(user.whatsapp || '');
|
setWhatsapp(user.whatsapp || '');
|
||||||
setBirthDate(user.birthDate || '');
|
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"
|
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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<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="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">
|
<div className="flex items-center space-x-3 mb-1">
|
||||||
<Mail className="w-4 h-4 text-zinc-500" />
|
<Mail className="w-4 h-4 text-zinc-500" />
|
||||||
|
|
|
||||||
15
src/db/db.ts
15
src/db/db.ts
|
|
@ -1,7 +1,7 @@
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import bcrypt from 'bcryptjs';
|
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 DATA_DIR = process.env.DATA_DIR || (fs.existsSync('/app/data') ? '/app/data' : process.cwd());
|
||||||
const DB_FILE = path.join(DATA_DIR, 'database.json');
|
const DB_FILE = path.join(DATA_DIR, 'database.json');
|
||||||
|
|
@ -20,6 +20,7 @@ export interface DatabaseSchema {
|
||||||
certificates: any[];
|
certificates: any[];
|
||||||
notifications: any[];
|
notifications: any[];
|
||||||
settings: any; // SystemSettings
|
settings: any; // SystemSettings
|
||||||
|
plans: Plan[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_DB: DatabaseSchema = {
|
const DEFAULT_DB: DatabaseSchema = {
|
||||||
|
|
@ -42,7 +43,8 @@ const DEFAULT_DB: DatabaseSchema = {
|
||||||
asaasWebhookUrl: '',
|
asaasWebhookUrl: '',
|
||||||
asaasWebhookSecret: '',
|
asaasWebhookSecret: '',
|
||||||
helpText: '# Central de Ajuda\n\nAqui você encontra todas as informações sobre os cursos e funcionamento da plataforma.'
|
helpText: '# Central de Ajuda\n\nAqui você encontra todas as informações sobre os cursos e funcionamento da plataforma.'
|
||||||
}
|
},
|
||||||
|
plans: []
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to load db
|
// Helper to load db
|
||||||
|
|
@ -54,7 +56,11 @@ export function loadDb(): DatabaseSchema {
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
const data = fs.readFileSync(DB_FILE, 'utf-8');
|
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) {
|
} catch (err) {
|
||||||
console.error('Error loading database:', err);
|
console.error('Error loading database:', err);
|
||||||
return DEFAULT_DB;
|
return DEFAULT_DB;
|
||||||
|
|
@ -443,6 +449,7 @@ function createInitialDb(): DatabaseSchema {
|
||||||
asaasWebhookUrl: '',
|
asaasWebhookUrl: '',
|
||||||
asaasWebhookSecret: '',
|
asaasWebhookSecret: '',
|
||||||
helpText: '# Central de Ajuda\n\nAqui você encontra todas as informações sobre os cursos e funcionamento da plataforma.'
|
helpText: '# Central de Ajuda\n\nAqui você encontra todas as informações sobre os cursos e funcionamento da plataforma.'
|
||||||
}
|
},
|
||||||
|
plans: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,9 @@ export class AsaasService {
|
||||||
customerId: string,
|
customerId: string,
|
||||||
value: number,
|
value: number,
|
||||||
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
|
billingType: 'PIX' | 'CREDIT_CARD' | 'BOLETO',
|
||||||
creditCardInfo?: any
|
creditCardInfo?: any,
|
||||||
|
cycle: 'MONTHLY' | 'YEARLY' = 'MONTHLY',
|
||||||
|
description: string = 'Assinatura Mensal DevFlix'
|
||||||
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
|
): Promise<{ subscriptionId: string; payment: Partial<SubscriptionPayment> }> {
|
||||||
const apiKey = this.getApiKey();
|
const apiKey = this.getApiKey();
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
|
|
@ -131,8 +133,8 @@ export class AsaasService {
|
||||||
billingType,
|
billingType,
|
||||||
value,
|
value,
|
||||||
nextDueDate: new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
|
nextDueDate: new Date(Date.now() + 3 * 24 * 3600 * 1000).toISOString().split('T')[0],
|
||||||
cycle: 'MONTHLY',
|
cycle,
|
||||||
description: 'Assinatura Mensal DevFlix'
|
description
|
||||||
};
|
};
|
||||||
|
|
||||||
if (billingType === 'CREDIT_CARD' && creditCardInfo) {
|
if (billingType === 'CREDIT_CARD' && creditCardInfo) {
|
||||||
|
|
|
||||||
15
src/types.ts
15
src/types.ts
|
|
@ -35,8 +35,19 @@ export interface Course {
|
||||||
thumbnail: string;
|
thumbnail: string;
|
||||||
category: string;
|
category: string;
|
||||||
certificateMode?: 'PER_MODULE' | 'FULL_COURSE';
|
certificateMode?: 'PER_MODULE' | 'FULL_COURSE';
|
||||||
isLocked?: boolean; // If true, requires payment/unlock
|
isLocked?: boolean; // Se true, o aluno precisa comprar ou assinar para acessar
|
||||||
price?: number; // Cost to unlock if locked (e.g. 89.90)
|
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;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue