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
Build and Deploy (Gitea Actions) / build-and-deploy (push) Failing after 1m38s
Details
This commit is contained in:
parent
b105fd2151
commit
1aa96c8907
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 === */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue