794 lines
38 KiB
TypeScript
794 lines
38 KiB
TypeScript
import React, { useEffect, useState, useRef, useCallback } from 'react'
|
|
import { useRouter } from 'next/router'
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import {
|
|
Settings, Paintbrush, Globe, Bell, Mail, Users, Wrench,
|
|
Upload, Save, Check, AlertTriangle, Eye, EyeOff, Send,
|
|
Image as ImageIcon, Smile, RefreshCw, Webhook,
|
|
} from 'lucide-react'
|
|
import { useAuth } from '@/context/AuthContext'
|
|
import { api } from '@/services/chatApiService'
|
|
import { PageHeader, Card, Badge } from '@/components/ui'
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
interface SystemSettings {
|
|
systemName: string
|
|
timezone: string
|
|
logoUrl: string | null
|
|
faviconUrl: string | null
|
|
accentColor: string
|
|
sounds: { newMessage: boolean; notification: boolean; connectionStatus: boolean }
|
|
smtp: { host: string; port: number; user: string; password: string; from: string; secure: boolean }
|
|
emailTemplates: { welcome: string; passwordReset: string; trialExpiring: string }
|
|
registration: { mode: 'open' | 'closed' | 'invite'; defaultPlanId: string | null }
|
|
maintenance: { enabled: boolean; message: string }
|
|
webhook: { url: string; secret: string }
|
|
}
|
|
|
|
const TABS = [
|
|
{ id: 'branding', label: 'Branding', icon: Paintbrush },
|
|
{ id: 'regional', label: 'Regional', icon: Globe },
|
|
{ id: 'sounds', label: 'Sons', icon: Bell },
|
|
{ id: 'email', label: 'E-mail', icon: Mail },
|
|
{ id: 'registration', label: 'Registro', icon: Users },
|
|
{ id: 'system', label: 'Sistema', icon: Wrench },
|
|
{ id: 'deploys', label: 'Versões', icon: RefreshCw },
|
|
] as const
|
|
|
|
type Tab = typeof TABS[number]['id']
|
|
|
|
const TIMEZONES = [
|
|
'America/Sao_Paulo','America/Manaus','America/Belem','America/Fortaleza',
|
|
'America/Recife','America/Maceio','America/Bahia','America/Cuiaba',
|
|
'America/Porto_Velho','America/Boa_Vista','America/Rio_Branco','America/Noronha',
|
|
'America/New_York','America/Chicago','America/Denver','America/Los_Angeles',
|
|
'Europe/London','Europe/Paris','Europe/Berlin','Europe/Lisbon',
|
|
'Asia/Tokyo','Asia/Shanghai','Asia/Kolkata','Australia/Sydney','UTC',
|
|
]
|
|
|
|
// ─── Sub-components ───────────────────────────────────────────────────────────
|
|
|
|
function SectionTitle({ children }: { children: React.ReactNode }) {
|
|
return <h3 className="text-sm font-black text-white uppercase tracking-widest mb-4">{children}</h3>
|
|
}
|
|
|
|
function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-400 mb-1.5">{label}</label>
|
|
{children}
|
|
{hint && <p className="text-xs text-slate-600 mt-1">{hint}</p>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function TextInput({ value, onChange, placeholder, type = 'text', disabled }: {
|
|
value: string; onChange: (v: string) => void; placeholder?: string; type?: string; disabled?: boolean
|
|
}) {
|
|
return (
|
|
<input
|
|
type={type}
|
|
value={value}
|
|
onChange={e => onChange(e.target.value)}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-slate-200 placeholder-slate-600 focus:outline-none focus:border-brand-500/40 focus:bg-white/[0.05] transition-all disabled:opacity-40"
|
|
/>
|
|
)
|
|
}
|
|
|
|
function Toggle({ checked, onChange, label, hint }: { checked: boolean; onChange: (v: boolean) => void; label: string; hint?: string }) {
|
|
return (
|
|
<div className="flex items-center justify-between p-4 rounded-2xl bg-white/[0.02] border border-white/5">
|
|
<div>
|
|
<p className="text-sm font-semibold text-slate-200">{label}</p>
|
|
{hint && <p className="text-xs text-slate-500 mt-0.5">{hint}</p>}
|
|
</div>
|
|
<button
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative w-11 h-6 rounded-full transition-colors duration-200 ${checked ? 'bg-brand-500' : 'bg-white/10'}`}
|
|
>
|
|
<span className={`absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow transition-transform duration-200 ${checked ? 'translate-x-5' : 'translate-x-0'}`} />
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AssetUpload({ label, current, onUpload, accept = 'image/*' }: {
|
|
label: string; current: string | null; onUpload: (f: File) => Promise<void>; accept?: string
|
|
}) {
|
|
const [dragging, setDragging] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const inputRef = useRef<HTMLInputElement>(null)
|
|
const API = process.env.NEXT_PUBLIC_API_URL ?? ''
|
|
|
|
const handle = async (file: File) => {
|
|
setLoading(true)
|
|
try { await onUpload(file) } finally { setLoading(false) }
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<label className="block text-xs font-bold text-slate-400 mb-1.5">{label}</label>
|
|
<div
|
|
onDragOver={e => { e.preventDefault(); setDragging(true) }}
|
|
onDragLeave={() => setDragging(false)}
|
|
onDrop={e => { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files[0]; if (f) handle(f) }}
|
|
onClick={() => inputRef.current?.click()}
|
|
className={`relative h-28 rounded-2xl border-2 border-dashed flex flex-col items-center justify-center gap-2 cursor-pointer transition-all
|
|
${dragging ? 'border-brand-500 bg-brand-500/5' : 'border-white/10 hover:border-white/20 bg-white/[0.02]'}`}
|
|
>
|
|
{current ? (
|
|
<img src={`${API}${current}`} alt={label} className="h-16 object-contain" />
|
|
) : (
|
|
<>
|
|
<Upload size={20} className="text-slate-500" />
|
|
<span className="text-xs text-slate-500">Arraste ou clique para enviar</span>
|
|
</>
|
|
)}
|
|
{loading && (
|
|
<div className="absolute inset-0 bg-black/50 rounded-2xl flex items-center justify-center">
|
|
<RefreshCw size={20} className="text-brand-400 animate-spin" />
|
|
</div>
|
|
)}
|
|
<input ref={inputRef} type="file" accept={accept} className="hidden" onChange={e => { const f = e.target.files?.[0]; if (f) handle(f) }} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SaveBar({ saving, saved, onSave }: { saving: boolean; saved: boolean; onSave: () => void }) {
|
|
return (
|
|
<div className="flex items-center justify-end mt-8 pt-6 border-t border-white/5">
|
|
<button
|
|
onClick={onSave}
|
|
disabled={saving}
|
|
className="flex items-center gap-2 px-6 py-2.5 rounded-xl bg-brand-600 hover:bg-brand-500 text-white text-sm font-bold transition-all disabled:opacity-50"
|
|
>
|
|
{saving ? <RefreshCw size={15} className="animate-spin" /> : saved ? <Check size={15} /> : <Save size={15} />}
|
|
{saving ? 'Salvando...' : saved ? 'Salvo!' : 'Salvar Alterações'}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
export default function AdminSettings() {
|
|
const { user, loading } = useAuth()
|
|
const router = useRouter()
|
|
const [tab, setTab] = useState<Tab>('branding')
|
|
const [settings, setSettings] = useState<SystemSettings | null>(null)
|
|
const [saving, setSaving] = useState(false)
|
|
const [saved, setSaved] = useState(false)
|
|
const [smtpPassVisible, setSmtpPassVisible] = useState(false)
|
|
const [sendingTest, setSendingTest] = useState(false)
|
|
const [plans, setPlans] = useState<{ id: string; name: string }[]>([])
|
|
const [engine, setEngine] = useState<'infinite' | 'official'>('infinite')
|
|
const [switchingEngine, setSwitchingEngine] = useState(false)
|
|
|
|
// ── Estados de Controle de Versões & Deploys ──
|
|
const [versionData, setVersionData] = useState<{
|
|
version: string;
|
|
buildCount: number;
|
|
lastDeploy: string;
|
|
lastCommit?: { hash: string; message: string; author: string };
|
|
} | null>(null)
|
|
const [deploysHistory, setDeploysHistory] = useState<any[]>([])
|
|
const [triggeringDeploy, setTriggeringDeploy] = useState(false)
|
|
|
|
const fetchDeployData = useCallback(async () => {
|
|
try {
|
|
const vRes = await fetch('/version.json?t=' + Date.now())
|
|
if (vRes.ok) {
|
|
const vData = await vRes.json()
|
|
setVersionData(vData)
|
|
}
|
|
} catch (e) {}
|
|
|
|
try {
|
|
const dRes = await fetch('/deploys.json?t=' + Date.now())
|
|
if (dRes.ok) {
|
|
const dData = await dRes.json()
|
|
setDeploysHistory(dData)
|
|
}
|
|
} catch (e) {}
|
|
}, [])
|
|
|
|
const triggerDeploy = async () => {
|
|
if (!window.confirm('Deseja realmente iniciar uma nova compilação e deploy em produção agora?\n\nIsso irá atualizar o repositório, reinstalar dependências, compilar o frontend e backend, e reiniciar os serviços.')) return
|
|
setTriggeringDeploy(true)
|
|
try {
|
|
await api.post('/api/admin/deploys/trigger')
|
|
alert('Deploy automático iniciado em segundo plano! A VPS 4 está compilando os arquivos. O painel monitorará o progresso automaticamente.')
|
|
|
|
let attempts = 0
|
|
const interval = setInterval(async () => {
|
|
attempts++
|
|
try {
|
|
const vRes = await fetch('/version.json?t=' + Date.now())
|
|
if (vRes.ok) {
|
|
const vData = await vRes.json()
|
|
if (!versionData || vData.buildCount !== versionData.buildCount) {
|
|
setVersionData(vData)
|
|
const dRes = await fetch('/deploys.json?t=' + Date.now())
|
|
if (dRes.ok) {
|
|
setDeploysHistory(await dRes.json())
|
|
}
|
|
setTriggeringDeploy(false)
|
|
clearInterval(interval)
|
|
alert(`Sucesso! Deploy finalizado. Versão atualizada para ${vData.version}.`)
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
|
|
if (attempts >= 36) { // 3 minutos
|
|
clearInterval(interval)
|
|
setTriggeringDeploy(false)
|
|
alert('O tempo limite de monitoramento esgotou, mas o deploy pode ainda estar rodando. Por favor, recarregue a página em instantes.')
|
|
}
|
|
}, 5000)
|
|
} catch (err) {
|
|
alert('Erro ao tentar acionar o deploy automático.')
|
|
setTriggeringDeploy(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (user?.role === 'admin') {
|
|
fetchDeployData()
|
|
}
|
|
}, [user, fetchDeployData])
|
|
|
|
|
|
useEffect(() => {
|
|
if (!loading && user?.role !== 'admin') router.replace('/inbox')
|
|
}, [loading, user, router])
|
|
|
|
useEffect(() => {
|
|
if (user?.role !== 'admin') return
|
|
api.get('/api/admin/settings').then(r => setSettings(r.data)).catch(() => {})
|
|
api.get('/api/admin/plans').then(r => setPlans(r.data ?? [])).catch(() => {})
|
|
api.get('/api/admin/engine').then(r => setEngine(r.data.engine)).catch(() => {})
|
|
}, [user])
|
|
|
|
const handleEngineSwitch = async (newEngine: 'infinite' | 'official') => {
|
|
if (newEngine === engine) return
|
|
const confirmSwitch = window.confirm(
|
|
`Deseja realmente alternar a Engine para "${newEngine === 'infinite' ? 'InfiniteAPI' : 'Baileys Oficial'}"?\n\nEsta alteração irá reiniciar o backend (~10s) para aplicar as novas configurações.`
|
|
)
|
|
if (!confirmSwitch) return
|
|
|
|
setSwitchingEngine(true)
|
|
try {
|
|
await api.post('/api/admin/engine', { engine: newEngine })
|
|
setEngine(newEngine)
|
|
alert('Engine configurada com sucesso! O servidor está reiniciando. Por favor, aguarde cerca de 10 segundos antes de recarregar a página.')
|
|
} catch {
|
|
alert('Erro ao tentar alternar a engine.')
|
|
} finally {
|
|
setSwitchingEngine(false)
|
|
}
|
|
}
|
|
|
|
const set = useCallback(<K extends keyof SystemSettings>(key: K, value: SystemSettings[K]) => {
|
|
setSettings(prev => prev ? { ...prev, [key]: value } : prev)
|
|
}, [])
|
|
|
|
const setNested = useCallback(<K extends keyof SystemSettings>(key: K, patch: Partial<SystemSettings[K]>) => {
|
|
setSettings(prev => prev ? { ...prev, [key]: { ...(prev[key] as object), ...patch } } : prev)
|
|
}, [])
|
|
|
|
const save = async () => {
|
|
if (!settings) return
|
|
setSaving(true)
|
|
try {
|
|
await api.patch('/api/admin/settings', settings)
|
|
setSaved(true)
|
|
setTimeout(() => setSaved(false), 3000)
|
|
} catch {
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
const uploadAsset = async (type: 'logo' | 'favicon', file: File) => {
|
|
const form = new FormData()
|
|
form.append('file', file)
|
|
const { data } = await api.post(`/api/admin/settings/${type}`, form, { headers: { 'Content-Type': 'multipart/form-data' } })
|
|
setSettings(prev => prev ? { ...prev, [`${type}Url`]: data.url } : prev)
|
|
}
|
|
|
|
const sendTestEmail = async () => {
|
|
setSendingTest(true)
|
|
try {
|
|
await api.post('/api/admin/settings/smtp-test')
|
|
alert('E-mail de teste enviado!')
|
|
} catch {
|
|
alert('Falha ao enviar e-mail de teste. Verifique as configurações SMTP.')
|
|
} finally {
|
|
setSendingTest(false)
|
|
}
|
|
}
|
|
|
|
if (loading || user?.role !== 'admin' || !settings) return (
|
|
<div className="min-h-screen bg-surface flex items-center justify-center">
|
|
<RefreshCw size={24} className="text-brand-400 animate-spin" />
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-surface p-6 lg:p-10 font-sans">
|
|
<PageHeader icon={Settings} title="Configurações do Sistema" subtitle="Personalização global da plataforma" />
|
|
|
|
{/* Tabs */}
|
|
<div className="flex items-center gap-1 mb-8 bg-white/[0.02] border border-white/5 rounded-2xl p-1.5 w-fit flex-wrap">
|
|
{TABS.map(t => {
|
|
const Icon = t.icon
|
|
const active = tab === t.id
|
|
return (
|
|
<button
|
|
key={t.id}
|
|
onClick={() => setTab(t.id)}
|
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold transition-all ${
|
|
active ? 'bg-brand-600 text-white shadow-lg shadow-brand-900/40' : 'text-slate-400 hover:text-white hover:bg-white/5'
|
|
}`}
|
|
>
|
|
<Icon size={15} />
|
|
{t.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
<AnimatePresence mode="wait">
|
|
<motion.div key={tab} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={{ duration: 0.18 }}>
|
|
<Card variant="glass" padding="p-8" className={`rounded-3xl transition-all duration-300 ${tab === 'deploys' ? 'max-w-5xl w-full' : 'max-w-3xl'}`}>
|
|
|
|
{/* ── BRANDING ── */}
|
|
{tab === 'branding' && (
|
|
<div className="space-y-6">
|
|
<SectionTitle>Identidade Visual</SectionTitle>
|
|
|
|
<Field label="Nome do Sistema">
|
|
<TextInput value={settings.systemName} onChange={v => set('systemName', v)} placeholder="NewWhats" />
|
|
</Field>
|
|
|
|
<Field label="Cor de Destaque" hint="Aplica-se a botões, links e elementos ativos em toda a interface">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="color"
|
|
value={settings.accentColor}
|
|
onChange={e => set('accentColor', e.target.value)}
|
|
className="w-12 h-10 rounded-xl border border-white/10 bg-transparent cursor-pointer"
|
|
/>
|
|
<TextInput value={settings.accentColor} onChange={v => set('accentColor', v)} placeholder="#3b82f6" />
|
|
</div>
|
|
</Field>
|
|
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<AssetUpload
|
|
label="Logo"
|
|
current={settings.logoUrl}
|
|
onUpload={f => uploadAsset('logo', f)}
|
|
/>
|
|
<AssetUpload
|
|
label="Favicon"
|
|
current={settings.faviconUrl}
|
|
onUpload={f => uploadAsset('favicon', f)}
|
|
accept=".ico,.png,.svg"
|
|
/>
|
|
</div>
|
|
|
|
<SaveBar saving={saving} saved={saved} onSave={save} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── REGIONAL ── */}
|
|
{tab === 'regional' && (
|
|
<div className="space-y-6">
|
|
<SectionTitle>Configurações Regionais</SectionTitle>
|
|
|
|
<Field label="Fuso Horário" hint="Usado para exibição de datas, relatórios e agendamentos">
|
|
<select
|
|
value={settings.timezone}
|
|
onChange={e => set('timezone', e.target.value)}
|
|
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-brand-500/40 transition-all"
|
|
>
|
|
{TIMEZONES.map(tz => (
|
|
<option key={tz} value={tz} className="bg-slate-900">{tz}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
<div className="p-4 rounded-2xl bg-brand-500/5 border border-brand-500/20 text-xs text-slate-400">
|
|
Hora atual no fuso selecionado:{' '}
|
|
<span className="text-brand-400 font-bold">
|
|
{new Date().toLocaleString('pt-BR', { timeZone: settings.timezone })}
|
|
</span>
|
|
</div>
|
|
|
|
<SaveBar saving={saving} saved={saved} onSave={save} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── SONS ── */}
|
|
{tab === 'sounds' && (
|
|
<div className="space-y-4">
|
|
<SectionTitle>Sons do Sistema</SectionTitle>
|
|
<Toggle
|
|
checked={settings.sounds.newMessage}
|
|
onChange={v => setNested('sounds', { newMessage: v })}
|
|
label="Nova Mensagem"
|
|
hint="Som ao receber uma nova mensagem no inbox"
|
|
/>
|
|
<Toggle
|
|
checked={settings.sounds.notification}
|
|
onChange={v => setNested('sounds', { notification: v })}
|
|
label="Notificações"
|
|
hint="Som para alertas e notificações do sistema"
|
|
/>
|
|
<Toggle
|
|
checked={settings.sounds.connectionStatus}
|
|
onChange={v => setNested('sounds', { connectionStatus: v })}
|
|
label="Status de Conexão"
|
|
hint="Som ao conectar ou desconectar uma instância WhatsApp"
|
|
/>
|
|
<SaveBar saving={saving} saved={saved} onSave={save} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── EMAIL ── */}
|
|
{tab === 'email' && (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<SectionTitle>Servidor SMTP</SectionTitle>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Field label="Host">
|
|
<TextInput value={settings.smtp.host} onChange={v => setNested('smtp', { host: v })} placeholder="smtp.gmail.com" />
|
|
</Field>
|
|
<Field label="Porta">
|
|
<TextInput value={String(settings.smtp.port)} onChange={v => setNested('smtp', { port: Number(v) || 587 })} placeholder="587" />
|
|
</Field>
|
|
<Field label="Usuário">
|
|
<TextInput value={settings.smtp.user} onChange={v => setNested('smtp', { user: v })} placeholder="noreply@empresa.com" />
|
|
</Field>
|
|
<Field label="Senha">
|
|
<div className="relative">
|
|
<TextInput
|
|
type={smtpPassVisible ? 'text' : 'password'}
|
|
value={settings.smtp.password}
|
|
onChange={v => setNested('smtp', { password: v })}
|
|
placeholder="••••••••"
|
|
/>
|
|
<button
|
|
onClick={() => setSmtpPassVisible(p => !p)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300"
|
|
>
|
|
{smtpPassVisible ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
</Field>
|
|
<Field label="E-mail Remetente">
|
|
<TextInput value={settings.smtp.from} onChange={v => setNested('smtp', { from: v })} placeholder="NewWhats <noreply@empresa.com>" />
|
|
</Field>
|
|
<Field label="Segurança">
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<Toggle
|
|
checked={settings.smtp.secure}
|
|
onChange={v => setNested('smtp', { secure: v })}
|
|
label="TLS/SSL"
|
|
hint="Porta 465"
|
|
/>
|
|
</div>
|
|
</Field>
|
|
</div>
|
|
|
|
<button
|
|
onClick={sendTestEmail}
|
|
disabled={sendingTest || !settings.smtp.host}
|
|
className="mt-4 flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-sm text-slate-300 hover:border-brand-500/40 hover:text-brand-400 transition-all disabled:opacity-40"
|
|
>
|
|
{sendingTest ? <RefreshCw size={14} className="animate-spin" /> : <Send size={14} />}
|
|
Enviar E-mail de Teste
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<SectionTitle>Modelos de E-mail</SectionTitle>
|
|
<div className="space-y-4">
|
|
{[
|
|
{ key: 'welcome' as const, label: 'Boas-vindas', hint: 'Enviado ao novo usuário após cadastro' },
|
|
{ key: 'passwordReset' as const, label: 'Redefinição de Senha', hint: 'Link de reset enviado ao usuário' },
|
|
{ key: 'trialExpiring' as const, label: 'Trial Expirando', hint: 'Aviso enviado 3 dias antes do fim do trial' },
|
|
].map(({ key, label, hint }) => (
|
|
<Field key={key} label={label} hint={hint}>
|
|
<textarea
|
|
value={settings.emailTemplates[key]}
|
|
onChange={e => setNested('emailTemplates', { [key]: e.target.value })}
|
|
rows={5}
|
|
placeholder={`Conteúdo HTML do e-mail de ${label.toLowerCase()}...`}
|
|
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-4 py-3 text-sm text-slate-200 placeholder-slate-600 focus:outline-none focus:border-brand-500/40 font-mono resize-y transition-all"
|
|
/>
|
|
</Field>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<SaveBar saving={saving} saved={saved} onSave={save} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── REGISTRO ── */}
|
|
{tab === 'registration' && (
|
|
<div className="space-y-6">
|
|
<SectionTitle>Controle de Acesso</SectionTitle>
|
|
|
|
<Field label="Modo de Registro" hint="Define quem pode criar uma conta na plataforma">
|
|
<div className="grid grid-cols-3 gap-3 mt-1">
|
|
{([
|
|
{ val: 'open', label: 'Aberto', hint: 'Qualquer pessoa pode se cadastrar' },
|
|
{ val: 'invite', label: 'Por Convite', hint: 'Somente via link de convite' },
|
|
{ val: 'closed', label: 'Fechado', hint: 'Apenas o admin cria usuários' },
|
|
] as const).map(opt => (
|
|
<button
|
|
key={opt.val}
|
|
onClick={() => setNested('registration', { mode: opt.val })}
|
|
className={`p-3 rounded-xl border text-left transition-all ${
|
|
settings.registration.mode === opt.val
|
|
? 'border-brand-500/40 bg-brand-500/10 text-white'
|
|
: 'border-white/5 bg-white/[0.02] text-slate-400 hover:border-white/10'
|
|
}`}
|
|
>
|
|
<p className="text-sm font-bold">{opt.label}</p>
|
|
<p className="text-xs text-slate-500 mt-0.5">{opt.hint}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</Field>
|
|
|
|
<Field label="Plano Padrão" hint="Plano atribuído automaticamente a novos usuários">
|
|
<select
|
|
value={settings.registration.defaultPlanId ?? ''}
|
|
onChange={e => setNested('registration', { defaultPlanId: e.target.value || null })}
|
|
className="w-full bg-white/[0.03] border border-white/5 rounded-xl px-4 py-2.5 text-sm text-slate-200 focus:outline-none focus:border-brand-500/40 transition-all"
|
|
>
|
|
<option value="" className="bg-slate-900">Nenhum (sem plano)</option>
|
|
{plans.map(p => (
|
|
<option key={p.id} value={p.id} className="bg-slate-900">{p.name}</option>
|
|
))}
|
|
</select>
|
|
</Field>
|
|
|
|
<SaveBar saving={saving} saved={saved} onSave={save} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── SISTEMA ── */}
|
|
{tab === 'system' && (
|
|
<div className="space-y-6">
|
|
<SectionTitle>Manutenção</SectionTitle>
|
|
|
|
{settings.maintenance.enabled && (
|
|
<div className="flex items-center gap-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-xs text-amber-400 font-bold">
|
|
<AlertTriangle size={14} /> Modo manutenção ATIVO — usuários não-admin não conseguem fazer login
|
|
</div>
|
|
)}
|
|
|
|
<Toggle
|
|
checked={settings.maintenance.enabled}
|
|
onChange={v => setNested('maintenance', { enabled: v })}
|
|
label="Modo Manutenção"
|
|
hint="Bloqueia o login de todos os usuários não-admin"
|
|
/>
|
|
|
|
<Field label="Mensagem de Manutenção" hint="Exibida na tela de login durante a manutenção">
|
|
<TextInput
|
|
value={settings.maintenance.message}
|
|
onChange={v => setNested('maintenance', { message: v })}
|
|
placeholder="Sistema em manutenção. Voltamos em breve."
|
|
/>
|
|
</Field>
|
|
|
|
<div className="border-t border-white/5 pt-6">
|
|
<SectionTitle>Webhook Global</SectionTitle>
|
|
<div className="space-y-4">
|
|
<Field label="URL do Webhook" hint="Recebe eventos: novo usuário, instância conectada/desconectada, broadcast finalizado">
|
|
<TextInput value={settings.webhook.url} onChange={v => setNested('webhook', { url: v })} placeholder="https://n8n.empresa.com/webhook/newwhats" />
|
|
</Field>
|
|
<Field label="Secret de Assinatura" hint="Enviado no header X-Webhook-Secret para validar autenticidade">
|
|
<TextInput value={settings.webhook.secret} onChange={v => setNested('webhook', { secret: v })} placeholder="um-segredo-aleatorio-forte" type="password" />
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-white/5 pt-6">
|
|
<SectionTitle>Engine de WhatsApp</SectionTitle>
|
|
<p className="text-xs text-slate-500 mb-4">
|
|
Selecione o motor de conexão do WhatsApp. O InfiniteAPI permite o envio nativo de botões, listas e carrosséis que funcionam perfeitamente no celular e no WhatsApp Web. O Baileys Oficial é o motor padrão de mercado (estável, mas sem suporte nativo a botões no Web).
|
|
</p>
|
|
<div className="grid grid-cols-2 gap-3 mt-1">
|
|
{([
|
|
{ val: 'infinite', label: 'InfiniteAPI', hint: 'Suporta botões, listas e carrosséis ricos (Celular & Web)' },
|
|
{ val: 'official', label: 'Baileys Oficial', hint: 'Estável padrão de mercado (sem botões no Web)' },
|
|
] as const).map(opt => (
|
|
<button
|
|
key={opt.val}
|
|
disabled={switchingEngine}
|
|
onClick={() => handleEngineSwitch(opt.val)}
|
|
type="button"
|
|
className={`p-4 rounded-xl border text-left transition-all relative ${
|
|
engine === opt.val
|
|
? 'border-brand-500/40 bg-brand-500/10 text-white'
|
|
: 'border-white/5 bg-white/[0.02] text-slate-400 hover:border-white/10'
|
|
} disabled:opacity-40`}
|
|
>
|
|
<p className="text-sm font-bold flex items-center gap-2">
|
|
{opt.label}
|
|
{engine === opt.val && (
|
|
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
)}
|
|
</p>
|
|
<p className="text-xs text-slate-500 mt-0.5">{opt.hint}</p>
|
|
</button>
|
|
))}
|
|
</div>
|
|
<p className="text-[11px] text-amber-500/70 mt-3 flex items-center gap-1.5 font-semibold">
|
|
<AlertTriangle size={12} /> Nota: Alterar a engine causará a reinicialização automática do backend (~10 segundos).
|
|
</p>
|
|
</div>
|
|
|
|
<SaveBar saving={saving} saved={saved} onSave={save} />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── DEPLOYS ── */}
|
|
{tab === 'deploys' && (
|
|
<div className="space-y-8">
|
|
<div>
|
|
<SectionTitle>Ciclo de Deploy (GitOps) e Versões</SectionTitle>
|
|
<p className="text-xs text-slate-500 mt-1">
|
|
Visualize o histórico de compilação da plataforma e gerencie os deploys automáticos em produção diretamente do painel de administração.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Grid das Versões */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
{/* Card Versão Atual */}
|
|
<div className="p-6 rounded-2xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
|
|
<div>
|
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Versão Ativa</span>
|
|
<div className="flex items-baseline gap-2 mt-2">
|
|
<span className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-brand-400 to-brand-500">
|
|
{versionData?.version || 'v1.0.0'}
|
|
</span>
|
|
<span className="text-xs text-slate-400 font-semibold bg-white/5 px-2 py-0.5 rounded-lg border border-white/5">
|
|
build #{versionData?.buildCount || 0}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-4 text-xs text-slate-400">
|
|
<span className="relative flex h-2 w-2">
|
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
|
|
</span>
|
|
Online em Produção (VPS 1)
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 pt-4 border-t border-white/5 text-xs text-slate-400">
|
|
Último deploy:{' '}
|
|
<span className="text-slate-300 font-semibold">
|
|
{versionData?.lastDeploy
|
|
? new Date(versionData.lastDeploy).toLocaleString('pt-BR')
|
|
: 'Pendente ou Manual'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card Último Commit */}
|
|
<div className="p-6 rounded-2xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
|
|
<div>
|
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-widest">Última Alteração Integrada</span>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className="text-sm font-bold text-slate-200 truncate">
|
|
{versionData?.lastCommit?.message || 'Sem dados de commit'}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-slate-500 mt-1">
|
|
Por: <span className="text-slate-400 font-medium">{versionData?.lastCommit?.author || 'N/A'}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 pt-4 border-t border-white/5 flex items-center justify-between text-xs">
|
|
<span className="text-slate-500">Hash do commit:</span>
|
|
<span className="font-mono text-brand-400 bg-brand-500/10 px-2 py-0.5 rounded-lg border border-brand-500/20">
|
|
{versionData?.lastCommit?.hash || 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bloco de Ação Manual */}
|
|
<div className="p-6 rounded-2xl bg-brand-500/5 border border-brand-500/10 flex flex-col md:flex-row items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<h4 className="text-sm font-bold text-slate-200">Compilação e Deploy Forçado</h4>
|
|
<p className="text-xs text-slate-500 max-w-xl">
|
|
Deseja puxar a última versão do repositório Git, reinstalar pacotes, rodar o build do Next.js e reiniciar os containers de produção agora?
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={triggerDeploy}
|
|
disabled={triggeringDeploy}
|
|
className="flex items-center justify-center gap-2 px-6 py-3 rounded-xl bg-brand-600 hover:bg-brand-500 text-white text-sm font-bold shadow-lg shadow-brand-900/20 transition-all disabled:opacity-50 whitespace-nowrap min-w-[200px]"
|
|
>
|
|
{triggeringDeploy ? (
|
|
<>
|
|
<RefreshCw size={16} className="animate-spin" />
|
|
Compilando & Sincronizando...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send size={15} />
|
|
Compilar e Despachar
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Histórico Completo de Deploys */}
|
|
<div className="space-y-4">
|
|
<SectionTitle>Histórico de Push & Compilações</SectionTitle>
|
|
|
|
{deploysHistory.length === 0 ? (
|
|
<div className="text-center py-10 rounded-2xl border border-dashed border-white/5 text-xs text-slate-500">
|
|
Nenhum histórico de deploy registrado ainda.
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto rounded-2xl border border-white/5 bg-white/[0.01]">
|
|
<table className="w-full text-left text-xs text-slate-400">
|
|
<thead>
|
|
<tr className="bg-white/[0.02] border-b border-white/5 font-semibold text-slate-300">
|
|
<th className="px-5 py-3.5">Versão</th>
|
|
<th className="px-5 py-3.5">Autor</th>
|
|
<th className="px-5 py-3.5">Mensagem do Commit</th>
|
|
<th className="px-5 py-3.5">Data & Hora</th>
|
|
<th className="px-5 py-3.5 text-right">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-white/5">
|
|
{deploysHistory.map((deploy, index) => (
|
|
<tr key={index} className="hover:bg-white/[0.01] transition-colors">
|
|
<td className="px-5 py-4 font-bold text-transparent bg-clip-text bg-gradient-to-r from-brand-300 to-brand-400 font-mono">
|
|
{deploy.version}
|
|
</td>
|
|
<td className="px-5 py-4 text-slate-200 font-semibold">{deploy.author}</td>
|
|
<td className="px-5 py-4 max-w-xs truncate text-slate-300">{deploy.commitMessage}</td>
|
|
<td className="px-5 py-4">
|
|
{new Date(deploy.timestamp).toLocaleString('pt-BR')}
|
|
</td>
|
|
<td className="px-5 py-4 text-right">
|
|
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />
|
|
{deploy.status || 'Sucesso'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
</Card>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|