252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
'use client';
|
|
import { useState, useEffect } from 'react';
|
|
import { Plus, X, Edit, Trash2, Check, Users, AlertTriangle } from 'lucide-react';
|
|
|
|
interface Plan {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
priceMonthly: number;
|
|
priceYearly: number;
|
|
maxProfessionals: number;
|
|
maxServices: number;
|
|
features: string[];
|
|
active: boolean;
|
|
subscribers: number;
|
|
}
|
|
|
|
const emptyPlan: Partial<Plan> = {
|
|
name: '',
|
|
slug: '',
|
|
priceMonthly: 0,
|
|
priceYearly: 0,
|
|
maxProfessionals: 1,
|
|
maxServices: 10,
|
|
features: [],
|
|
active: true
|
|
};
|
|
|
|
export default function Plans() {
|
|
const [plans, setPlans] = useState<Plan[]>([]);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editing, setEditing] = useState<Plan | null>(null);
|
|
const [form, setForm] = useState<Partial<Plan>>(emptyPlan);
|
|
const [featureInput, setFeatureInput] = useState('');
|
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const fetchPlans = async () => {
|
|
try {
|
|
const res = await fetch('/api/plans');
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
if (Array.isArray(data)) {
|
|
setPlans(data);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchPlans();
|
|
}, []);
|
|
|
|
const openAdd = () => {
|
|
setEditing(null);
|
|
setForm({ ...emptyPlan, features: [] });
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openEdit = (p: Plan) => {
|
|
setEditing(p);
|
|
setForm({ ...p, features: p.features || [] });
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name?.trim()) return;
|
|
const method = editing ? 'PUT' : 'POST';
|
|
try {
|
|
const res = await fetch('/api/plans', {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(form)
|
|
});
|
|
if (res.ok) {
|
|
fetchPlans();
|
|
setShowModal(false);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await fetch(`/api/plans?id=${id}`, { method: 'DELETE' });
|
|
setPlans(prev => prev.filter(p => p.id !== id));
|
|
setDeleteConfirm(null);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
};
|
|
|
|
const addFeature = () => {
|
|
if (featureInput.trim()) {
|
|
setForm(p => ({ ...p, features: [...(p.features || []), featureInput.trim()] }));
|
|
setFeatureInput('');
|
|
}
|
|
};
|
|
|
|
const removeFeature = (idx: number) => {
|
|
setForm(p => ({ ...p, features: (p.features || []).filter((_, i) => i !== idx) }));
|
|
};
|
|
|
|
const colors = ['#3b82f6', '#6C63FF', '#a855f7', '#f59e0b'];
|
|
|
|
if (loading) return <div className="p-8 text-center" style={{color: 'var(--text-muted)'}}>Carregando planos...</div>;
|
|
|
|
return (
|
|
<div className="slide-up">
|
|
<div className="flex-between mb-6">
|
|
<p style={{ color: 'var(--text-secondary)', fontSize: '14px' }}>{plans.length} planos configurados</p>
|
|
<button className="btn btn-primary" onClick={openAdd}><Plus size={16} /> Novo Plano</button>
|
|
</div>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '20px' }}>
|
|
{plans.map((p, idx) => (
|
|
<div key={p.id} className="card" style={{ position: 'relative', borderTop: `3px solid ${colors[idx % colors.length]}` }}>
|
|
{deleteConfirm === p.id && (
|
|
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.9)', borderRadius: 'var(--radius-lg)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16, zIndex: 10 }}>
|
|
<AlertTriangle size={32} color="var(--warning)" />
|
|
<p style={{ fontWeight: 600 }}>Excluir plano {p.name}?</p>
|
|
<p style={{ fontSize: 12, color: 'var(--text-secondary)' }}>{p.subscribers} assinantes serão afetados</p>
|
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
<button className="btn btn-sm btn-danger" onClick={() => handleDelete(p.id)}>Excluir</button>
|
|
<button className="btn btn-sm btn-secondary" onClick={() => setDeleteConfirm(null)}>Cancelar</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="flex-between mb-4">
|
|
<h3 style={{ fontSize: '20px', fontWeight: 800 }}>{p.name}</h3>
|
|
<div style={{ display: 'flex', gap: 4 }}>
|
|
<button className="btn-ghost btn-icon" onClick={() => openEdit(p)}><Edit size={14} /></button>
|
|
<button className="btn-ghost btn-icon" style={{ color: 'var(--danger)' }} onClick={() => setDeleteConfirm(p.id)}><Trash2 size={14} /></button>
|
|
</div>
|
|
</div>
|
|
<div style={{ marginBottom: '16px' }}>
|
|
<span style={{ fontSize: '32px', fontWeight: 900 }}>R$ {Number(p.priceMonthly || 0).toFixed(2).replace('.', ',')}</span>
|
|
<span style={{ fontSize: '14px', color: 'var(--text-muted)' }}>/mês</span>
|
|
<div style={{ fontSize: '12px', color: 'var(--text-muted)', marginTop: 2 }}>ou R$ {Number(p.priceYearly || 0).toFixed(2).replace('.', ',')} /ano</div>
|
|
</div>
|
|
<div className="grid-2 mb-4">
|
|
<div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}>{p.maxProfessionals >= 999 ? '∞' : p.maxProfessionals}</div>
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Profissionais</div>
|
|
</div>
|
|
<div style={{ padding: '10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', textAlign: 'center' }}>
|
|
<div style={{ fontSize: '16px', fontWeight: 800, color: colors[idx % colors.length] }}>{p.maxServices >= 999 ? '∞' : p.maxServices}</div>
|
|
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Serviços</div>
|
|
</div>
|
|
</div>
|
|
<ul style={{ listStyle: 'none', fontSize: '13px' }}>
|
|
{(p.features || []).map((f, i) => (
|
|
<li key={i} style={{ padding: '4px 0', color: 'var(--text-secondary)', display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
<Check size={14} color="var(--success)" /> {f}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{plans.length === 0 && (
|
|
<div className="p-8 text-center" style={{color: 'var(--text-muted)', border: '1px dashed var(--border-color)', borderRadius: 'var(--radius-lg)'}}>
|
|
Nenhum plano cadastrado.
|
|
</div>
|
|
)}
|
|
|
|
{showModal && (
|
|
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
|
<div className="modal" onClick={e => e.stopPropagation()}>
|
|
<div className="modal-header">
|
|
<h2 className="modal-title">{editing ? 'Editar Plano' : 'Novo Plano'}</h2>
|
|
<button className="btn-ghost btn-icon" onClick={() => setShowModal(false)}><X size={20} /></button>
|
|
</div>
|
|
<div className="modal-body">
|
|
<div className="grid-2">
|
|
<div className="form-group">
|
|
<label className="form-label">Nome *</label>
|
|
<input className="form-input" value={form.name || ''} onChange={e => {
|
|
const val = e.target.value;
|
|
setForm(p => ({
|
|
...p,
|
|
name: val,
|
|
slug: editing ? p.slug : val.toLowerCase().replace(/[^a-z0-9]/g, '-')
|
|
}));
|
|
}} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Slug</label>
|
|
<input className="form-input" value={form.slug || ''} onChange={e => setForm(p => ({ ...p, slug: e.target.value }))} disabled={!!editing} />
|
|
</div>
|
|
</div>
|
|
<div className="grid-2">
|
|
<div className="form-group">
|
|
<label className="form-label">Preço Mensal (R$)</label>
|
|
<input className="form-input" type="number" step="0.01" value={form.priceMonthly || 0} onChange={e => {
|
|
const val = Number(e.target.value);
|
|
setForm(p => ({ ...p, priceMonthly: val, priceYearly: p.priceYearly || val * 10 }));
|
|
}} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Preço Anual (R$)</label>
|
|
<input className="form-input" type="number" step="0.01" value={form.priceYearly || 0} onChange={e => setForm(p => ({ ...p, priceYearly: Number(e.target.value) }))} />
|
|
</div>
|
|
</div>
|
|
<div className="grid-2">
|
|
<div className="form-group">
|
|
<label className="form-label">Max Profissionais</label>
|
|
<input className="form-input" type="number" value={form.maxProfessionals || 1} onChange={e => setForm(p => ({ ...p, maxProfessionals: Number(e.target.value) }))} />
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Max Serviços</label>
|
|
<input className="form-input" type="number" value={form.maxServices || 10} onChange={e => setForm(p => ({ ...p, maxServices: Number(e.target.value) }))} />
|
|
</div>
|
|
</div>
|
|
<div className="form-group">
|
|
<label className="form-label">Recursos</label>
|
|
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
|
|
<input className="form-input" placeholder="Adicionar recurso..." value={featureInput} onChange={e => setFeatureInput(e.target.value)} onKeyDown={e => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
addFeature();
|
|
}
|
|
}} />
|
|
<button type="button" className="btn btn-sm btn-secondary" onClick={addFeature}>+</button>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
{(form.features || []).map((f, i) => (
|
|
<div key={i} className="flex-between" style={{ padding: '6px 10px', background: 'var(--bg-primary)', borderRadius: 'var(--radius-sm)', fontSize: 13 }}>
|
|
<span>{f}</span>
|
|
<button type="button" className="btn-ghost btn-icon" onClick={() => removeFeature(i)} style={{ color: 'var(--danger)' }}><X size={12} /></button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="modal-footer">
|
|
<button className="btn btn-secondary" onClick={() => setShowModal(false)}>Cancelar</button>
|
|
<button className="btn btn-primary" onClick={handleSave}>{editing ? 'Salvar' : 'Criar Plano'}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|