feat: design flat de botoes, liberacao de acesso gratuito no admin e planos dinamicos
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 1m38s Details

This commit is contained in:
Sidney 2026-07-19 20:34:11 -03:00
parent b105fd2151
commit 1aa96c8907
6 changed files with 119 additions and 37 deletions

View File

@ -63,18 +63,20 @@ export async function POST(req: NextRequest) {
export async function PUT(req: NextRequest) {
try {
const { id, name, slug, email, phone, plan, status, password } = await req.json();
const { id, name, slug, email, phone, plan, status, password, grantAccessDays } = await req.json();
if (!id) return NextResponse.json({ error: 'Missing id' }, { status: 400 });
const planRes = await query('SELECT id FROM plans WHERE name = $1 LIMIT 1', [plan || 'Starter']);
const planId = planRes.rows.length > 0 ? planRes.rows[0].id : null;
const res = await query(
`UPDATE tenants
SET name = $1, slug = $2, email = $3, phone = $4, plan_id = $5, subscription_status = $6, updated_at = NOW()
WHERE id = $7 RETURNING *`,
[name, slug, email, phone, planId, status, id]
);
let updateQuery = `UPDATE tenants SET name = $1, slug = $2, email = $3, phone = $4, plan_id = $5, subscription_status = $6, updated_at = NOW() WHERE id = $7 RETURNING *`;
const params: any[] = [name, slug, email, phone, planId, status, id];
if (grantAccessDays) {
updateQuery = `UPDATE tenants SET name = $1, slug = $2, email = $3, phone = $4, plan_id = $5, subscription_status = 'active', updated_at = NOW(), subscription_ends_at = NOW() + INTERVAL '${Number(grantAccessDays)} days' WHERE id = $7 RETURNING *`;
}
const res = await query(updateQuery, params);
if (password) {
const salt = await bcrypt.genSalt(10);

View File

@ -104,18 +104,30 @@ body {
.stat-value { font-size: 28px; font-weight: 800; letter-spacing: -1px; line-height: 1.2; }
.stat-label { font-size: 13px; color: var(--text-secondary); margin-top: 2px; }
.btn {
display: inline-flex; align-items: center; gap: 8px; padding: 10px 20px; border-radius: var(--radius-sm);
font-size: 14px; font-weight: 600; cursor: pointer; border: none; transition: all 0.2s; font-family: inherit;
.btn, button:not(.tab):not(.toggle) {
display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 8px 16px; border-radius: 6px;
font-size: 13.5px; font-weight: 500; cursor: pointer; border: 1px solid transparent; background: transparent;
box-shadow: none !important; outline: none; transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.08s ease; font-family: inherit; user-select: none;
}
.btn-primary { background: var(--gradient-primary); color: white; box-shadow: var(--shadow-glow); }
.btn-primary:hover { transform: translateY(-1px); }
.btn-secondary { background: var(--bg-elevated); color: var(--text-primary); border: 1px solid var(--border-color); }
.btn-danger { background: var(--danger-bg); color: var(--danger); border: 1px solid rgba(239,68,68,0.2); }
.btn-ghost { background: transparent; color: var(--text-secondary); padding: 8px 12px; border: none; cursor: pointer; font-family: inherit; }
.btn-ghost:hover { color: var(--text-primary); background: var(--bg-card); }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn-icon { padding: 8px; border-radius: var(--radius-sm); }
.btn:active, button:not(.tab):not(.toggle):active {
transform: scale(0.95) !important;
}
.btn-primary { background: var(--accent); color: white; border: 1px solid transparent; }
.btn-primary:hover { background: var(--accent-hover); color: white; filter: brightness(1.15); }
.btn-secondary { background: rgba(255, 255, 255, 0.04); color: var(--text-primary); border: 1px solid var(--border-color); }
.btn-secondary:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.2); color: white; }
.btn-danger { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.25); }
.btn-danger:hover { background: rgba(239, 68, 68, 0.25); border-color: rgba(239, 68, 68, 0.5); color: #f87171; }
.btn-ghost { background: rgba(255, 255, 255, 0.03); color: var(--text-secondary); border: 1px solid rgba(255, 255, 255, 0.08); padding: 6px 10px; }
.btn-ghost:hover { color: var(--text-primary); background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.25); }
.btn-ghost[style*="danger"], .btn-ghost[style*="239, 68, 68"], .btn-ghost.danger {
background: rgba(239, 68, 68, 0.1) !important; border: 1px solid rgba(239, 68, 68, 0.2) !important; color: #ef4444 !important;
}
.btn-ghost[style*="danger"]:hover, .btn-ghost[style*="239, 68, 68"]:hover, .btn-ghost.danger:hover {
background: rgba(239, 68, 68, 0.25) !important; border-color: rgba(239, 68, 68, 0.5) !important; color: #f87171 !important;
}
.btn-sm { padding: 5px 12px; font-size: 12.5px; }
.btn-icon { padding: 6px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; }
.form-group { margin-bottom: 20px; }
.form-label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }

View File

@ -198,24 +198,24 @@ export default function Plans() {
<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 }));
<input className="form-input" type="number" step="0.01" value={form.priceMonthly ?? ''} onChange={e => {
const val = e.target.value === '' ? '' : Number(e.target.value);
setForm(p => ({ ...p, priceMonthly: val as any, priceYearly: p.priceYearly || (val as number) * 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) }))} />
<input className="form-input" type="number" step="0.01" value={form.priceYearly ?? ''} onChange={e => setForm(p => ({ ...p, priceYearly: e.target.value === '' ? '' as any : 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) }))} />
<input className="form-input" type="number" value={form.maxProfessionals ?? ''} onChange={e => setForm(p => ({ ...p, maxProfessionals: e.target.value === '' ? '' as any : 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) }))} />
<input className="form-input" type="number" value={form.maxServices ?? ''} onChange={e => setForm(p => ({ ...p, maxServices: e.target.value === '' ? '' as any : Number(e.target.value) }))} />
</div>
</div>
<div className="form-group">

View File

@ -187,6 +187,21 @@ export default function Tenants() {
</div>
</div>
</div>
<div className="card mt-4" style={{ padding: '16px', background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)' }}>
<h4 style={{ fontSize: '14px', marginBottom: '8px', color: 'var(--text-primary)' }}>Liberação de Acesso Gratuito (Sem Cobrança)</h4>
<p style={{ fontSize: '12px', color: 'var(--text-muted)', marginBottom: '12px' }}>Define a validade da assinatura sem exigir pagamento do cliente. O status mudará automaticamente para Ativo.</p>
<div className="grid-2">
<div className="form-group mb-0">
<label className="form-label" style={{ fontSize: '12px' }}>Período (Dias)</label>
<div style={{ display: 'flex', gap: 8 }}>
<input type="number" className="form-input" placeholder="Ex: 15, 30, 365" value={(form as any).grantAccessDays ?? ''} onChange={e => setForm(p => ({ ...p, grantAccessDays: e.target.value === '' ? '' : Number(e.target.value) }))} />
<button type="button" className="btn btn-secondary" onClick={() => setForm(p => ({ ...p, grantAccessDays: 30 }))}>30 Dias</button>
<button type="button" className="btn btn-secondary" onClick={() => setForm(p => ({ ...p, grantAccessDays: 365 }))}>1 Ano</button>
</div>
</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 Assinante'}</button>

View File

@ -299,60 +299,91 @@ body {
.stat-change.up { color: var(--success); }
.stat-change.down { color: var(--danger); }
.btn {
.btn, button:not(.tab):not(.toggle) {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
border-radius: 6px;
font-size: 13.5px;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s ease;
border: 1px solid transparent;
background: transparent;
box-shadow: none !important;
outline: none;
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.08s ease;
text-decoration: none;
font-family: inherit;
user-select: none;
}
/* Efeito básico de apertar quando clicar */
.btn:active, button:not(.tab):not(.toggle):active {
transform: scale(0.95) !important;
}
.btn-primary {
background: var(--accent);
color: white;
border: 1px solid transparent;
}
.btn-primary:hover {
background: var(--accent-hover);
color: white;
filter: brightness(1.15);
}
.btn-secondary {
background: transparent;
background: rgba(255, 255, 255, 0.04);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--bg-elevated);
border-color: var(--text-secondary);
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
.btn-danger {
background: transparent;
color: var(--danger);
border: 1px solid rgba(239,68,68,0.3);
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.btn-danger:hover {
background: var(--danger-bg);
background: rgba(239, 68, 68, 0.25);
border-color: rgba(239, 68, 68, 0.5);
color: #f87171;
}
.btn-ghost {
background: transparent;
background: rgba(255, 255, 255, 0.03);
color: var(--text-secondary);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 6px 10px;
}
.btn-ghost:hover {
color: var(--text-primary);
background: var(--bg-elevated);
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.25);
}
/* Ícone de excluir no modo ghost (vermelho plano que acende no hover) */
.btn-ghost[style*="danger"], .btn-ghost[style*="239, 68, 68"], .btn-ghost.danger {
background: rgba(239, 68, 68, 0.1) !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
color: #ef4444 !important;
}
.btn-ghost[style*="danger"]:hover, .btn-ghost[style*="239, 68, 68"]:hover, .btn-ghost.danger:hover {
background: rgba(239, 68, 68, 0.25) !important;
border-color: rgba(239, 68, 68, 0.5) !important;
color: #f87171 !important;
}
.btn-sm {
@ -363,6 +394,9 @@ body {
.btn-icon {
padding: 6px;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* === FORMS === */

View File

@ -106,6 +106,25 @@ export default function Subscription({ currentPlan, onPlanChange, isLocked = fal
else if (Array.isArray(p.features)) featuresList = p.features;
} catch (e) {}
const maxProf = Number(p.max_professionals ?? (p.name === 'Starter' ? 1 : p.name === 'Professional' ? 5 : p.name === 'Business' ? 15 : 999));
const maxSvc = Number(p.max_services ?? (p.name === 'Starter' ? 10 : p.name === 'Professional' ? 30 : p.name === 'Business' ? 100 : 999));
const maxAppt = p.max_appointments_month ? Number(p.max_appointments_month) : (p.name === 'Starter' ? 200 : 999);
const profText = maxProf >= 999 ? 'Profissionais ilimitados' : maxProf === 1 ? '1 profissional' : `${maxProf} profissionais`;
const svcText = maxSvc >= 999 ? 'Serviços ilimitados' : maxSvc === 1 ? '1 serviço' : `${maxSvc} serviços`;
const apptText = maxAppt >= 999 ? 'Agendamentos ilimitados' : `${maxAppt} agendamentos/mês`;
const isLimitLine = (f: string) => {
const lower = f.toLowerCase();
if (/(\d+|ilimitados)\s*profissional/i.test(lower)) return true;
if (/(\d+|ilimitados)\s*serviço/i.test(lower)) return true;
if (lower.includes('agendamentos/mês') || lower.includes('agendamentos ilimitados')) return true;
return false;
};
const customFeatures = featuresList.filter(f => !isLimitLine(f));
const dynamicFeatures = [profText, svcText, apptText, ...customFeatures];
return {
id: p.id,
name: p.name,
@ -113,7 +132,7 @@ export default function Subscription({ currentPlan, onPlanChange, isLocked = fal
icon: iconMap[p.name] || Crown,
color: colorMap[p.name] || '#6C63FF',
featured: p.name === 'Professional',
features: featuresList.length > 0 ? featuresList : ['Agendamento online']
features: dynamicFeatures
};
}) : staticPlans;