454 lines
21 KiB
TypeScript
454 lines
21 KiB
TypeScript
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;
|
|
}
|
|
|
|
function CoursePricingCard({ course, saveLoading, onSave }: { course: Course, saveLoading: string | null, onSave: (id: string, locked: boolean, price: number) => void }) {
|
|
const [localPrice, setLocalPrice] = useState(course.price || 0);
|
|
const [localLocked, setLocalLocked] = useState(course.isLocked || false);
|
|
|
|
useEffect(() => {
|
|
setLocalPrice(course.price || 0);
|
|
setLocalLocked(course.isLocked || false);
|
|
}, [course.price, course.isLocked]);
|
|
|
|
const isChanged = localPrice !== (course.price || 0) || localLocked !== (course.isLocked || false);
|
|
|
|
return (
|
|
<div 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={() => onSave(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>
|
|
);
|
|
}
|
|
|
|
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 => (
|
|
<CoursePricingCard
|
|
key={course.id}
|
|
course={course}
|
|
saveLoading={saveLoading}
|
|
onSave={handleUpdateCoursePrice}
|
|
/>
|
|
))}
|
|
</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>
|
|
);
|
|
};
|