feat(settings): painel central de configurações (API estável + versão)
O ícone de configurações deixa de abrir popover flutuante e passa a abrir um painel grande na área central (no lugar das mensagens). O painel mostra apenas: - "API estável" (= engine Baileys 7 oficial/baileys7, a que conecta), com ação de ativar na instância; - a versão do sistema (lida de /version.json). ThinSidebar simplificada (sem o seletor flutuante); as engines seguem selecionáveis no backend para teste. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { ShieldCheck, Check, Loader2, Tag } from 'lucide-react';
|
||||
|
||||
interface SettingsPanelProps {
|
||||
instanceId: string | null;
|
||||
}
|
||||
|
||||
// "API estável" aponta para a engine Baileys 7 oficial (a que conecta de fato).
|
||||
const STABLE_ENGINE = 'baileys7';
|
||||
|
||||
export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
|
||||
const [engine, setEngine] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [version, setVersion] = useState<any>(null);
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
||||
const authHeader = (): Record<string, string> => {
|
||||
const t = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return t ? { Authorization: `Bearer ${t}` } : {};
|
||||
};
|
||||
|
||||
// Versão do sistema (servida em /version.json)
|
||||
useEffect(() => {
|
||||
fetch('/version.json')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then(setVersion)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Engine atual da instância
|
||||
useEffect(() => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
fetch(`${API_URL}/api/instances`, { headers: authHeader() })
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((list) => {
|
||||
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
|
||||
setEngine(inst?.engine ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [instanceId]);
|
||||
|
||||
const ativarEstavel = useCallback(async () => {
|
||||
if (!instanceId || saving) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ engine: STABLE_ENGINE }),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? 'Aguarde o intervalo entre trocas.');
|
||||
}
|
||||
if (!res.ok) throw new Error('Falha ao ativar a API estável.');
|
||||
setEngine(STABLE_ENGINE);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Erro ao ativar.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [instanceId, saving]);
|
||||
|
||||
const ativa = engine === STABLE_ENGINE;
|
||||
|
||||
return (
|
||||
<div className="relative z-[1] flex-1 h-full overflow-y-auto bg-[#f0f2f5] p-6 md:p-10">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-3xl font-light text-[#41525d] mb-8">Configurações</h1>
|
||||
|
||||
{/* ── API estável ─────────────────────────────────────────── */}
|
||||
<div className={`rounded-2xl border-2 bg-white p-8 mb-6 transition-colors ${ativa ? 'border-emerald-400' : 'border-[#e9edef]'}`}>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${ativa ? 'bg-emerald-50' : 'bg-[#f0f2f5]'}`}>
|
||||
<ShieldCheck className={`w-8 h-8 ${ativa ? 'text-emerald-500' : 'text-[#54656f]'}`} />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold text-[#111b21]">API estável</h2>
|
||||
<p className="text-base text-[#667781]">Conexão recomendada do WhatsApp</p>
|
||||
</div>
|
||||
{ativa && (
|
||||
<span className="flex items-center gap-1 text-emerald-600 font-medium text-lg">
|
||||
<Check className="w-6 h-6" /> Ativa
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!instanceId ? (
|
||||
<p className="text-base text-[#667781] mt-2">Selecione uma instância primeiro.</p>
|
||||
) : ativa ? (
|
||||
<p className="text-base text-emerald-600 mt-2">A API estável está ativa nesta instância.</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={ativarEstavel}
|
||||
disabled={saving}
|
||||
className="mt-3 inline-flex items-center gap-2 rounded-xl bg-emerald-500 hover:bg-emerald-600 text-white text-lg font-medium px-6 py-3 disabled:opacity-60"
|
||||
>
|
||||
{saving ? <Loader2 className="w-5 h-5 animate-spin" /> : <ShieldCheck className="w-5 h-5" />}
|
||||
Ativar API estável
|
||||
</button>
|
||||
)}
|
||||
{loading && <p className="text-sm text-[#8696a0] mt-3">Carregando configuração…</p>}
|
||||
{error && <p className="text-sm text-red-500 mt-3">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* ── Versão ──────────────────────────────────────────────── */}
|
||||
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
|
||||
<Tag className="w-8 h-8 text-[#54656f]" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="text-2xl font-semibold text-[#111b21]">Versão</h2>
|
||||
<p className="text-3xl font-light text-[#41525d] mt-1">{version?.version ?? '—'}</p>
|
||||
{version?.buildCount != null && (
|
||||
<p className="text-base text-[#667781]">build {version.buildCount}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,47 +1,15 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import {
|
||||
MessageCircle,
|
||||
Megaphone,
|
||||
Users,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
LayoutGrid,
|
||||
Sparkles,
|
||||
X,
|
||||
Loader2,
|
||||
Check,
|
||||
} from 'lucide-react';
|
||||
import { getAvatarProxyUrl } from '../utils/inboxUtils';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
|
||||
|
||||
type Engine = 'official' | 'infinite' | 'baileys7';
|
||||
|
||||
// Seletor de API (engine de envio). Mapeamento dos rótulos para o usuário final:
|
||||
// official → Baileys oficial → "API estável" (sem botões)
|
||||
// infinite → InfiniteAPI → "API instável" (com botões)
|
||||
const ENGINES: {
|
||||
value: Engine; titulo: string; desc: string; hint: string;
|
||||
icon: any; iconCls: string; activeCls: string; checkCls: string;
|
||||
}[] = [
|
||||
{
|
||||
value: 'official', titulo: 'API estável', desc: 'sem botões', hint: 'Recomendada',
|
||||
icon: ShieldCheck, iconCls: 'text-emerald-500',
|
||||
activeCls: 'border-emerald-400 bg-emerald-50', checkCls: 'text-emerald-500',
|
||||
},
|
||||
{
|
||||
value: 'infinite', titulo: 'API instável', desc: 'com botões', hint: 'Experimental',
|
||||
icon: LayoutGrid, iconCls: 'text-amber-500',
|
||||
activeCls: 'border-amber-400 bg-amber-50', checkCls: 'text-amber-500',
|
||||
},
|
||||
{
|
||||
value: 'baileys7', titulo: 'Baileys v7 oficial', desc: 'LID/username', hint: 'Teste (rc13)',
|
||||
icon: Sparkles, iconCls: 'text-indigo-500',
|
||||
activeCls: 'border-indigo-400 bg-indigo-50', checkCls: 'text-indigo-500',
|
||||
},
|
||||
];
|
||||
|
||||
interface ThinSidebarProps {
|
||||
activeTab: TabType;
|
||||
setActiveTab: (tab: TabType) => void;
|
||||
@@ -51,64 +19,6 @@ interface ThinSidebarProps {
|
||||
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance }: ThinSidebarProps) {
|
||||
const router = useRouter();
|
||||
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
|
||||
const instanceId: string | null = activeInstance?.instance ?? null;
|
||||
|
||||
// ── Seletor de API (engine) ───────────────────────────────────────────────
|
||||
const [showEngine, setShowEngine] = useState(false);
|
||||
const [engine, setEngine] = useState<Engine | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState<Engine | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8008';
|
||||
const authHeader = (): Record<string, string> => {
|
||||
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
};
|
||||
|
||||
const toggleSelector = useCallback(async () => {
|
||||
const opening = !showEngine;
|
||||
setShowEngine(opening);
|
||||
setError(null);
|
||||
if (!opening || !instanceId) return;
|
||||
// Ao abrir, descobre a engine atual para destacá-la.
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/instances`, { headers: authHeader() });
|
||||
if (res.ok) {
|
||||
const list = await res.json();
|
||||
const inst = Array.isArray(list) ? list.find((i: any) => i.id === instanceId) : null;
|
||||
setEngine((inst?.engine as Engine) ?? null);
|
||||
}
|
||||
} catch {
|
||||
/* ignora — o seletor ainda funciona, só não destaca a atual */
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [showEngine, instanceId, API_URL]);
|
||||
|
||||
const selectEngine = useCallback(async (value: Engine) => {
|
||||
if (!instanceId || saving || value === engine) return;
|
||||
setSaving(value);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/instances/${instanceId}/engine`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeader() },
|
||||
body: JSON.stringify({ engine: value }),
|
||||
});
|
||||
if (res.status === 429) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error ?? 'Aguarde o intervalo entre trocas.');
|
||||
}
|
||||
if (!res.ok) throw new Error('Falha ao trocar a API.');
|
||||
setEngine(value);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? 'Erro ao trocar a API.');
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
}, [instanceId, saving, engine, API_URL]);
|
||||
|
||||
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
|
||||
const isActive = activeTab === id;
|
||||
@@ -136,7 +46,7 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance }:
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative w-[60px] h-full bg-[#f0f2f5] border-r border-[#d1d7db] flex flex-col items-center py-4 flex-shrink-0 z-30">
|
||||
<div className="w-[60px] h-full bg-[#f0f2f5] border-r border-[#d1d7db] flex flex-col items-center py-4 flex-shrink-0 z-30">
|
||||
{/* Top Icons */}
|
||||
<div className="flex-1 w-full flex flex-col items-center">
|
||||
{renderIcon('chats', MessageCircle, 'Conversas')}
|
||||
@@ -146,16 +56,8 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance }:
|
||||
|
||||
{/* Bottom Icons */}
|
||||
<div className="w-full flex flex-col items-center pb-2">
|
||||
{/* Configurações — abre o seletor de API (engine de envio) */}
|
||||
<button
|
||||
title="Configurações — API de envio"
|
||||
onClick={toggleSelector}
|
||||
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
|
||||
showEngine ? 'bg-[#d9dbdf]' : 'hover:bg-[#d9dbdf]/60'
|
||||
}`}
|
||||
>
|
||||
<Settings className={`w-6 h-6 ${showEngine ? 'text-[#111b21]' : 'text-[#54656f]'}`} />
|
||||
</button>
|
||||
{/* Configurações — abre o painel na área central (aba 'settings') */}
|
||||
{renderIcon('settings', Settings, 'Configurações')}
|
||||
|
||||
<div className="relative mt-2 p-1 cursor-pointer">
|
||||
<div className="w-8 h-8 rounded-full overflow-hidden bg-gray-200 border border-gray-300">
|
||||
@@ -192,68 +94,6 @@ export default function ThinSidebar({ activeTab, setActiveTab, activeInstance }:
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Popover: seletor de API (engine) ─────────────────────────────── */}
|
||||
{showEngine && (
|
||||
<>
|
||||
{/* backdrop para fechar ao clicar fora */}
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowEngine(false)} />
|
||||
<div className="absolute left-[64px] bottom-3 z-50 w-64 rounded-xl bg-white shadow-xl border border-[#e9edef] p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold text-[#111b21]">API de envio</span>
|
||||
<button
|
||||
onClick={() => setShowEngine(false)}
|
||||
className="text-[#54656f] hover:text-[#111b21]"
|
||||
title="Fechar"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!instanceId ? (
|
||||
<p className="text-xs text-[#667781] py-2">Selecione uma instância primeiro.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{ENGINES.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
const ativa = engine === opt.value;
|
||||
const salvando = saving === opt.value;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
disabled={!!saving}
|
||||
onClick={() => selectEngine(opt.value)}
|
||||
className={`flex items-center gap-3 rounded-lg border p-2.5 text-left transition-colors disabled:opacity-60 ${
|
||||
ativa ? opt.activeCls : 'border-[#e9edef] hover:bg-[#f5f6f6]'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-5 h-5 shrink-0 ${opt.iconCls}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[#111b21]">
|
||||
{opt.titulo}{' '}
|
||||
<span className="text-[#667781] font-normal">· {opt.desc}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[#8696a0]">{opt.hint}</div>
|
||||
</div>
|
||||
{salvando ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-[#54656f]" />
|
||||
) : ativa ? (
|
||||
<Check className={`w-4 h-4 ${opt.checkCls}`} />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-[11px] text-[#8696a0] mt-2">Carregando configuração atual…</p>}
|
||||
{error && <p className="text-[11px] text-red-500 mt-2">{error}</p>}
|
||||
<p className="text-[10px] text-[#8696a0] mt-2 leading-snug">
|
||||
A troca reinicia a conexão da instância. Há um intervalo mínimo entre trocas.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import ChatSidebar from '../../components/ChatSidebar';
|
||||
import ThinSidebar, { TabType } from '../../components/ThinSidebar';
|
||||
import InboxHeader from '../../components/InboxHeader';
|
||||
import MessageArea from '../../components/MessageArea';
|
||||
import SettingsPanel from '../../components/SettingsPanel';
|
||||
import InputBar from '../../components/InputBar';
|
||||
import NewConversationModal from '../../components/NewConversationModal';
|
||||
import ContactProfile from '../../components/ContactProfile';
|
||||
@@ -364,12 +365,14 @@ export default function WhatsAppInboxPage() {
|
||||
|
||||
{/* Área principal */}
|
||||
<div
|
||||
className={`${isMobile ? (selectedChat ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
|
||||
className={`${isMobile ? ((selectedChat || activeTab === 'settings') ? 'w-full' : 'hidden') : 'flex-1'} flex h-full relative overflow-hidden`}
|
||||
style={{ backgroundImage: "url('/chat-bg.png')", backgroundRepeat: 'repeat', backgroundColor: '#b5bdb5' }}
|
||||
>
|
||||
{/* Overlay branqueador sobre o padrão */}
|
||||
<div className="absolute inset-0 bg-white/75 pointer-events-none z-0" />
|
||||
{selectedChat ? (
|
||||
{activeTab === 'settings' ? (
|
||||
<SettingsPanel instanceId={activeInstance?.instance ?? null} />
|
||||
) : selectedChat ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col h-full relative z-[1] overflow-hidden border-r border-[#d1d7db]">
|
||||
<InboxHeader
|
||||
|
||||
Reference in New Issue
Block a user