feat(newwhats): frontend do plugin (Inbox/Sessions/Secretária) em tela cheia

- /wa-inbox, /wa-sessions e /wa-secretaria rodam sem a sidebar do scoreodonto
  (isStandaloneView); cada tela tem seu "Voltar" (Secretária ganhou botão na ThinNav).
- SessionsView: banner "Você já possui acesso ao WhatsApp" quando há sessão ativa.
- avatares: usa a URL do CDN (contactAvatarUrl/instance.avatar) direto; Avatar
  exibe sem depender de version; self-heal no onError re-busca a foto atual.
- deps: framer-motion, immer. Dev override com HMR (docker-compose.dev.yml).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-07-01 20:54:24 +02:00
parent 6593993b3d
commit 3042ddca38
54 changed files with 12641 additions and 34 deletions
+24 -10
View File
@@ -1,11 +1,18 @@
# ─── Dev override — NO docker compose build required ─────────────────────────
# ─── Dev override — modo desenvolvimento com HMR ─────────────────────────────
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Uso:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
#
# What changes in dev:
# - Backend: Runs node server.js with direct hot mount of backend/
# - Frontend: Runs vite dev server on port 3013 with Hot Module Replacement (HMR)
# O que muda em relação à produção:
# - Backend: monta ./backend ao vivo (node server.js), preservando o
# node_modules da imagem (node:20-slim) — código reflete sem rebuild.
# - Frontend: builda o stage `builder` (node:20-alpine, que TEM vite +
# node_modules no arch musl correto) e roda o vite dev server na 3013 com
# Hot Module Replacement. O nginx (default.conf) já faz proxy de / → :3013
# com headers de Upgrade, então o WS de HMR passa.
#
# ⚠️ Este box serve APENAS dev.scoreodonto.com. A produção (scoreodonto.com)
# roda em outro servidor — flipar esta stack para dev NÃO afeta produção.
# ─────────────────────────────────────────────────────────────────────────────
services:
@@ -15,13 +22,20 @@ services:
- NODE_ENV=development
volumes:
- "/home/deploy/scoreodonto-sessions:/app/sessions"
- "./backend:/app"
- "/home/deploy/scoreodonto-media:/app/media"
- "./backend:/app" # source ao vivo (server.js + newwhats/ do túnel)
- "/app/node_modules" # preserva node_modules da imagem (arch correto)
- "./plugins:/app/plugins"
scoreodonto-frontend:
build:
context: "."
dockerfile: frontend/Dockerfile
target: builder # node:20-alpine com vite + node_modules (musl)
command: ["npx", "vite", "--port", "3013", "--host", "0.0.0.0"]
environment:
- PORT=3013
- NODE_ENV=development
- DEV_PUBLIC_HOST=dev.scoreodonto.com # libera host + roteia HMR por wss:443 (vite.config)
volumes:
- "./frontend:/app"
- /app/node_modules
- "./frontend:/app/frontend" # source ao vivo (HMR)
- "/app/frontend/node_modules" # preserva node_modules da imagem (musl)
+13 -4
View File
@@ -13,6 +13,9 @@ import { useAppVersion } from './buildInfo.ts';
import { Dashboard } from './views/Dashboard.tsx';
import { LeadsView } from './views/LeadsView.tsx';
import { WaInboxView } from './views/WaInboxView.tsx';
import { SessionsView } from './views/newwhats/SessionsView.tsx';
import { InboxView } from './views/newwhats/InboxView.tsx';
import { SecretariaView } from './views/newwhats/SecretariaView.tsx';
import { WaSecretariaView } from './views/WaSecretariaView.tsx';
import { PublicContactForm } from './views/PublicContactForm.tsx';
import { PatientsView } from './views/PatientsView.tsx';
@@ -89,6 +92,7 @@ export type ViewKey =
| 'plugins'
| 'rx-radiografias'
| 'wa-inbox'
| 'wa-sessions'
| 'wa-secretaria'
| 'salas'
| 'minhas-salas'
@@ -149,6 +153,7 @@ const ROUTE_MAP: Record<string, ViewKey> = {
'/plugins': 'plugins',
'/rx-radiografias': 'rx-radiografias',
'/wa-inbox': 'wa-inbox',
'/wa-sessions': 'wa-sessions',
'/wa-secretaria': 'wa-secretaria',
'/salas': 'salas',
'/minhas-salas': 'minhas-salas',
@@ -214,6 +219,7 @@ const VIEW_TO_ROUTE: Record<ViewKey, string> = {
plugins: '/plugins',
'rx-radiografias': '/rx-radiografias',
'wa-inbox': '/wa-inbox',
'wa-sessions': '/wa-sessions',
'wa-secretaria': '/wa-secretaria',
salas: '/salas',
'minhas-salas': '/minhas-salas',
@@ -325,7 +331,7 @@ const App: React.FC = () => {
if (view === 'salas' || view === 'minhas-salas' || view === 'alugar-salas') return isPluginActive('locacao-salas');
// NewWhats: Inbox e Secretária liberadas a qualquer usuário com o plugin ativo
// (a CONFIG do plugin é exclusiva do superadmin via view 'plugins').
if (view === 'wa-inbox' || view === 'wa-secretaria') return isPluginActive('newwhats');
if (view === 'wa-inbox' || view === 'wa-sessions' || view === 'wa-secretaria') return isPluginActive('newwhats');
// Contexto de SALA: isola da clínica — só painel da sala/salas/conta/notificações.
if ((HybridBackend.getActiveWorkspace() as any)?.tipo === 'sala') return ['sala-home', 'configuracoes', 'notificacoes', 'clinicas'].includes(view);
// Contexto de LABORATÓRIO: área do protético — painel do lab + bancada/conta/notificações.
@@ -517,8 +523,9 @@ const App: React.FC = () => {
case 'contratos': return <ContratosView />;
case 'plugins': return <PluginsView />;
case 'rx-radiografias': return <RXPlugin />;
case 'wa-inbox': return <WaInboxView />;
case 'wa-secretaria': return <WaSecretariaView />;
case 'wa-inbox': return <InboxView onNavigate={handleNavigate} />;
case 'wa-sessions': return <SessionsView onNavigate={handleNavigate} />;
case 'wa-secretaria': return <SecretariaView onNavigate={handleNavigate} />;
case 'salas': return <SalasPlugin />;
case 'minhas-salas': return <SalasPlugin view="minhas" />;
case 'alugar-salas': return <SalasPlugin view="alugar" />;
@@ -554,7 +561,9 @@ const App: React.FC = () => {
}
};
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite'].includes(currentView);
// Views do plugin NewWhats rodam em tela cheia (sem sidebar/barra do
// scoreodonto) — cada uma tem seu próprio "Voltar" (onNavigate) para sair.
const isStandaloneView = ['landing', 'login', 'public', 'update', 'cadastro-dentista', 'criar-clinica', 'aguardando-convite', 'wa-inbox', 'wa-sessions', 'wa-secretaria'].includes(currentView);
return (
<>
+2 -1
View File
@@ -2,7 +2,7 @@ import React from 'react';
import { useConfirm } from '../contexts/ConfirmContext.tsx';
import {
Users, Calendar as CalendarIcon, LayoutDashboard,
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot
DollarSign, RefreshCw, Activity, BookOpen, Award, Megaphone, LogOut, ClipboardList, Bell, Building2, User, BarChart3, Settings, FileText, Puzzle, ScanLine, Monitor, UsersRound, GraduationCap, UserSearch, Briefcase, TrendingUp, Eye, FlaskConical, DoorOpen, Sparkles, FileSignature, Percent, ShieldAlert, ChevronDown, MessageCircle, Bot, Smartphone
} from 'lucide-react';
import { ToothIcon } from './ToothIcon.tsx';
import { HybridBackend } from '../services/backend.ts';
@@ -41,6 +41,7 @@ export const Sidebar: React.FC<SidebarProps> = ({ activeTab, setActiveTab }) =>
// ativo (a CONFIG do plugin fica no catálogo PLUGINS, exclusivo do superadmin).
if (isPluginActive('newwhats')) {
pluginMenuItems.push({ id: 'wa-inbox', label: 'WHATSAPP', icon: MessageCircle, color: '#16a34a' });
pluginMenuItems.push({ id: 'wa-sessions', label: 'INSTÂNCIAS', icon: Smartphone, color: '#0ea5e9' });
pluginMenuItems.push({ id: 'wa-secretaria', label: 'SECRETÁRIA IA', icon: Bot, color: '#2563eb' });
}
// Locação de Salas: menus de LOCADOR (administra as próprias salas) e LOCATÁRIO (aluga de
+41
View File
@@ -1,5 +1,46 @@
@import "tailwindcss";
/* ── Tema do motor NewWhats (plugin newwhats) — tokens Tailwind v4 ──────────── */
/* Portados do tailwind.config.js do motor para as páginas inbox/sessions/ */
/* secretária renderizarem idênticas ao motor. */
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a8a;
--color-brand-950: #172554;
--color-surface: #070b14;
--color-surface-raised: #0c1220;
--color-surface-overlay: #111827;
--radius-4xl: 2rem;
--shadow-glow-brand: 0 0 24px rgba(59, 130, 246, 0.15);
--shadow-glow-sm: 0 0 12px rgba(59, 130, 246, 0.1);
--shadow-premium: 0 8px 32px rgba(0, 0, 0, 0.4);
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--animate-shimmer: shimmer 3s linear infinite;
--animate-pulse-slow: pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-float: float 6s ease-in-out infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
/* ScoreOdonto Base Styles */
body {
+239 -19
View File
@@ -15,6 +15,9 @@
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.90.21",
"date-fns": "^4.1.0",
"framer-motion": "^12.42.2",
"immer": "^11.1.8",
"jspdf": "^2.5.2",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -734,15 +737,6 @@
"node": ">=18"
}
},
"node_modules/@fullcalendar/core": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.20.tgz",
"integrity": "sha512-1cukXLlePFiJ8YKXn/4tMKsy0etxYLCkXk8nUCFi11nRONF2Ba2CD5b21/ovtOO2tL6afTJfwmc1ed3HG7eB1g==",
"peer": true,
"dependencies": {
"preact": "~10.12.1"
}
},
"node_modules/@fullcalendar/daygrid": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/daygrid/-/daygrid-6.1.20.tgz",
@@ -1522,11 +1516,17 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/raf": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
"optional": true
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"dev": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1565,6 +1565,17 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/atob": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
"bin": {
"atob": "bin/atob.js"
},
"engines": {
"node": ">= 4.5.0"
}
},
"node_modules/autoprefixer": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
@@ -1601,6 +1612,15 @@
"postcss": "^8.1.0"
}
},
"node_modules/base64-arraybuffer": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
"optional": true,
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -1646,6 +1666,17 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/btoa": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz",
"integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==",
"bin": {
"btoa": "bin/btoa.js"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001792",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
@@ -1666,12 +1697,42 @@
}
]
},
"node_modules/canvg": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
"optional": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@types/raf": "^3.4.0",
"core-js": "^3.8.3",
"raf": "^3.4.1",
"regenerator-runtime": "^0.13.7",
"rgbcolor": "^1.0.1",
"stackblur-canvas": "^2.0.0",
"svg-pathdata": "^6.0.3"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"optional": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
@@ -1680,11 +1741,20 @@
"tiny-invariant": "^1.0.6"
}
},
"node_modules/css-line-break": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true
"dev": true
},
"node_modules/date-fns": {
"version": "4.1.0",
@@ -1721,6 +1791,12 @@
"node": ">=8"
}
},
"node_modules/dompurify": {
"version": "2.5.9",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.9.tgz",
"integrity": "sha512-i6mvVmWN4xo9LrhCOZrDgSs9noW6nOahbrmzjRbPF36YPyj5Ue5lgok0MHDWkG7xzpWFO2OYttXdzM7rJxHvNA==",
"optional": true
},
"node_modules/electron-to-chromium": {
"version": "1.5.355",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.355.tgz",
@@ -1807,6 +1883,11 @@
}
}
},
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="
},
"node_modules/fraction.js": {
"version": "5.3.4",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
@@ -1820,6 +1901,32 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/framer-motion": {
"version": "12.42.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.42.2.tgz",
"integrity": "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==",
"dependencies": {
"motion-dom": "^12.42.2",
"motion-utils": "^12.39.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1849,6 +1956,28 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
"node_modules/html2canvas": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
"optional": true,
"dependencies": {
"css-line-break": "^2.1.0",
"text-segmentation": "^1.0.3"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/immer": {
"version": "11.1.8",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz",
"integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
@@ -1888,6 +2017,23 @@
"node": ">=6"
}
},
"node_modules/jspdf": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz",
"integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==",
"dependencies": {
"@babel/runtime": "^7.23.2",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"fflate": "^0.8.1"
},
"optionalDependencies": {
"canvg": "^3.0.6",
"core-js": "^3.6.0",
"dompurify": "^2.5.4",
"html2canvas": "^1.0.0-rc.5"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -2163,6 +2309,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/motion-dom": {
"version": "12.42.2",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.42.2.tgz",
"integrity": "sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==",
"dependencies": {
"motion-utils": "^12.39.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2193,6 +2352,12 @@
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"dev": true
},
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
"optional": true
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2245,14 +2410,13 @@
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true
},
"node_modules/preact": {
"version": "10.12.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.12.1.tgz",
"integrity": "sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"optional": true,
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/raf-schd": {
@@ -2315,6 +2479,21 @@
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"optional": true
},
"node_modules/rgbcolor": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
"optional": true,
"engines": {
"node": ">= 0.8.15"
}
},
"node_modules/rollup": {
"version": "4.60.3",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz",
@@ -2382,6 +2561,24 @@
"node": ">=0.10.0"
}
},
"node_modules/stackblur-canvas": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
"optional": true,
"engines": {
"node": ">=0.1.14"
}
},
"node_modules/svg-pathdata": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
"optional": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/tailwindcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
@@ -2401,6 +2598,15 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
"optional": true,
"dependencies": {
"utrie": "^1.0.2"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
@@ -2422,6 +2628,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@@ -2479,6 +2690,15 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/utrie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
"optional": true,
"dependencies": {
"base64-arraybuffer": "^1.0.2"
}
},
"node_modules/vite": {
"version": "6.4.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz",
+2
View File
@@ -16,6 +16,8 @@
"@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.90.21",
"date-fns": "^4.1.0",
"framer-motion": "^12.42.2",
"immer": "^11.1.8",
"jspdf": "^2.5.2",
"lucide-react": "^0.563.0",
"react": "^19.2.4",
+19
View File
@@ -110,6 +110,7 @@ export const DentistasView: React.FC = () => {
const [isHoursModalOpen, setIsHoursModalOpen] = useState(false);
const [selectedDentist, setSelectedDentist] = useState<Dentista | null>(null);
const [connectedAccounts, setConnectedAccounts] = useState<any[]>([]);
const [setores, setSetores] = useState<{ id: string; nome: string }[]>([]);
const toast = useToast();
const confirm = useConfirm();
@@ -125,6 +126,13 @@ export const DentistasView: React.FC = () => {
useEffect(() => {
fetchGoogleStatus();
// Setores da clínica (para lotar o dentista num setor → isolamento da agenda).
(async () => {
try {
const r = await HybridBackend.getSetores(activeWorkspace?.id || '');
setSetores(r?.setores || []);
} catch { /* clínica sem setores → seletor não aparece */ }
})();
}, []);
useEffect(() => {
@@ -261,6 +269,17 @@ export const DentistasView: React.FC = () => {
<form onSubmit={handleSave} className="space-y-4 max-w-2xl mx-auto">
<input id="dentista-nome" type="text" name="nome" placeholder="NOME COMPLETO" defaultValue={editingDentista?.nome} className="w-full border border-gray-300 rounded-lg p-2 uppercase-input" required />
<input id="dentista-email" type="email" name="email" placeholder="EMAIL" defaultValue={editingDentista?.email} className="w-full border border-gray-300 rounded-lg p-2" required />
{/* Setor do dentista → isola a agenda dele para a equipe do setor.
Só aparece se a clínica usa setores. Vazio = geral (visível a todos). */}
{setores.length > 0 && (
<div>
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Setor (opcional)</label>
<select id="dentista-setor" name="setor_id" defaultValue={(editingDentista as any)?.setor_id || ''} className="w-full border border-gray-300 rounded-lg p-2">
<option value="">SEM SETOR (geral visível a todos)</option>
{setores.map(s => <option key={s.id} value={s.id}>{s.nome}</option>)}
</select>
</div>
)}
{/* Especialidades que o dentista atende (várias) */}
<div>
<label className="text-xs font-bold text-gray-500 uppercase block mb-1.5">Especialidades que atende *</label>
+96
View File
@@ -20,9 +20,42 @@ async function nw(path: string, opts: RequestInit = {}) {
return res.json();
}
// URL do túnel WS (/api/nw/v1/stream → motor). Deriva o host do API (relativo ou
// absoluto) e troca http(s)→ws(s). O JWT vai no query porque o browser não envia
// Authorization no handshake de WebSocket.
function streamUrl(token: string) {
const base = API.startsWith('http') ? API : `${window.location.origin}${API}`;
const u = new URL(`${base}/nw/v1/stream`);
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
return u.toString();
}
interface Chat { id: string | number; remote_jid?: string; displayName?: string; phone?: string; last_message_text?: string | null; unread_count?: number }
interface Msg { id: string | number; text?: string; direction?: 'in' | 'out'; from_me?: number; timestamp?: number; type?: string }
// Normaliza a mensagem do stream (shape do motor) para o Msg do inbox.
function normalizeWsMsg(m: any): Msg {
return {
id: m?.id ?? `ws-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
text: m?.body ?? m?.text,
from_me: (m?.fromMe === true || m?.fromMe === 1) ? 1 : 0,
direction: m?.fromMe ? 'out' : 'in',
type: m?.type,
timestamp: m?.timestamp,
};
}
// Atualiza a lista de conversas com uma mensagem nova: preview, badge de unread
// (só quando não é a conversa aberta e não é enviada por nós) e sobe pro topo.
function bumpChat(list: Chat[], chatId: any, m: Msg, countUnread: boolean): Chat[] {
const i = list.findIndex((c) => String(c.id) === String(chatId));
if (i === -1) return list; // conversa ainda não carregada — aparece no próximo refresh
const updated: Chat = { ...list[i], last_message_text: m.text || `[${m.type || 'mídia'}]` };
if (countUnread && m.from_me !== 1) updated.unread_count = (updated.unread_count || 0) + 1;
return [updated, ...list.filter((_, idx) => idx !== i)];
}
export const WaInboxView: React.FC = () => {
const [sessions, setSessions] = useState<any[]>([]);
const [session, setSession] = useState<string>('');
@@ -32,7 +65,59 @@ export const WaInboxView: React.FC = () => {
const [text, setText] = useState('');
const [err, setErr] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [wsUp, setWsUp] = useState(false);
const [qr, setQr] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null);
// Ref com a conversa aberta para o handler do WS ler sempre o valor atual
// (evita closure velha dentro do onmessage).
const activeRef = useRef<Chat | null>(null);
useEffect(() => { activeRef.current = active; }, [active]);
// Tempo real: conecta ao túnel WS e reflete eventos do motor sem refetch.
useEffect(() => {
const token = localStorage.getItem('SCOREODONTO_AUTH_TOKEN');
if (!token) return;
let ws: WebSocket | null = null;
let retry: ReturnType<typeof setTimeout> | null = null;
let closed = false;
const connect = () => {
try { ws = new WebSocket(streamUrl(token)); } catch { return; }
ws.onopen = () => setWsUp(true);
ws.onerror = () => { try { ws?.close(); } catch { /* ignore */ } };
ws.onclose = () => {
setWsUp(false);
if (!closed) retry = setTimeout(connect, 3000); // reconexão simples
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (event === 'message.new') {
const norm = normalizeWsMsg(data?.message);
const isActive = !!activeRef.current && String(data?.chatId) === String(activeRef.current.id);
if (isActive) {
setMsgs((p) => p.some((x) => String(x.id) === String(norm.id)) ? p : [...p, norm]);
setTimeout(() => endRef.current?.scrollIntoView(), 50);
}
setChats((prev) => bumpChat(prev, data?.chatId, norm, !isActive));
} else if (event === 'session.status') {
setSessions((prev) => prev.map((s) =>
String(s.id || s.instance) === String(data?.instanceId) ? { ...s, status: data?.status } : s));
if (data?.status === 'connected') setQr(null);
} else if (event === 'session.qr') {
setQr(data?.qrBase64 || null);
}
};
};
connect();
return () => {
closed = true;
if (retry) clearTimeout(retry);
try { ws?.close(); } catch { /* ignore */ }
};
}, []);
useEffect(() => {
nw('/sessions').then((s) => {
@@ -54,6 +139,7 @@ export const WaInboxView: React.FC = () => {
const openChat = async (c: Chat) => {
setActive(c); setMsgs([]);
setChats((prev) => prev.map((x) => String(x.id) === String(c.id) ? { ...x, unread_count: 0 } : x));
try {
const m = await nw(`/inbox/${encodeURIComponent(String(c.id))}/messages?limit=50`);
setMsgs(Array.isArray(m) ? m : (m.messages ?? []));
@@ -79,10 +165,20 @@ export const WaInboxView: React.FC = () => {
{/* Lista de conversas */}
<div style={{ width: 320, borderRight: '1px solid #e5e7eb', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: 8, borderBottom: '1px solid #e5e7eb' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6, fontSize: 11, color: wsUp ? '#16a34a' : '#9ca3af' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: wsUp ? '#22c55e' : '#d1d5db', display: 'inline-block' }} />
{wsUp ? 'Tempo real ativo' : 'Conectando tempo real…'}
</div>
<select value={session} onChange={(e) => setSession(e.target.value)} style={{ width: '100%', padding: 6, fontSize: 13 }}>
<option value="">Selecione a sessão</option>
{sessions.map((s) => <option key={s.id || s.instance} value={s.id || s.instance}>{(s.nome || s.name || s.phone || s.id)} ({s.status})</option>)}
</select>
{qr && (
<div style={{ marginTop: 8, textAlign: 'center', background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8, padding: 8 }}>
<div style={{ fontSize: 12, color: '#6b7280', marginBottom: 6 }}>Escaneie para conectar</div>
<img src={qr.startsWith('data:') ? qr : `data:image/png;base64,${qr}`} alt="QR Code" style={{ width: '100%', maxWidth: 220 }} />
</div>
)}
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{loading && <div style={{ padding: 12, color: '#6b7280', fontSize: 13 }}>Carregando</div>}
+473
View File
@@ -0,0 +1,473 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
Search,
MoreVertical,
User,
MessageSquare,
ChevronDown,
RefreshCw,
MessageSquarePlus,
RotateCw,
ArrowLeft,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { NewWhatsChat } from './types/inboxTypes';
import { getAvatarProxyUrl, formatPhone } from './utils/inboxUtils';
import { useNewInboxBridge } from './hooks/inbox/useNewInboxBridge';
import { useInstanceSelector } from './hooks/inbox/useInstanceSelector';
import { useChatFilter } from './hooks/inbox/useChatFilter';
import { useChatWindow } from './hooks/inbox/useChatWindow';
import { useMessageInput } from './hooks/inbox/useMessageInput';
import { nw } from './services/nwClient';
import { useProtocolPanel } from './hooks/useProtocolPanel';
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';
import ProtocolPanel from './components/ProtocolPanel';
type Chat = NewWhatsChat;
export function InboxView({ onNavigate }: { onNavigate?: (view: string) => void }) {
// ── Seleção de instância (hook dedicado) ─────────────────────────────────
const {
instances,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
dropdownRef,
selectInstance,
refreshInstances,
handleConnect: handleConnectInstance,
} = useInstanceSelector()
// ── Inbox bridge (chats + mensagens) ──────────────────────────────────────
const {
chats: chatsStore,
selectedChat,
messages,
loadingChats: loading,
loadingMessages,
presenceByJid,
handleSelectChat: handleSelectChatBridge,
handleSendText,
handleLoadMoreHistory,
handleMenuAction,
} = useNewInboxBridge()
// ── Filtros de chat (hook dedicado) ───────────────────────────────────────
const {
searchTerm,
activeFilter,
chatScope,
chatsByScope,
isFavoriteChat,
setSearchTerm,
setActiveFilter,
setChatScope,
toggleFavorite,
} = useChatFilter(chatsStore)
// ── Chat window (hook dedicado) ───────────────────────────────────────────
const {
scrollRef,
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
handleTyping,
handleScroll,
handleSelectChat: onChatWindowSelect,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
} = useChatWindow({
selectedChat,
activeInstanceId: activeInstance?.instance ?? null,
onLoadMoreHistory: handleLoadMoreHistory,
})
// ── Mensagem de entrada (hook dedicado) ───────────────────────────────────
const { newMessage, setNewMessage, mentions, setMentions, handleSendMessage } = useMessageInput({
selectedChat,
replyingTo,
onClearReply: () => setReplyingTo(null),
onSendText: handleSendText,
})
// ── Painel de Protocolo ───────────────────────────────────────────────────
const [isProtocolOpen, setIsProtocolOpen] = useState(false)
const protocolPanel = useProtocolPanel(selectedChat ? String(selectedChat.id) : null)
// ── Estado local de UI ────────────────────────────────────────────────────
const [activeTab, setActiveTab] = useState<TabType>('chats')
const [isInstanceDropdownOpen, setIsInstanceDropdownOpen] = useState(false)
const [isOptionsMenuOpen, setIsOptionsMenuOpen] = useState(false)
const [drafts] = useState<Record<string, string>>({})
const [isMobile, setIsMobile] = useState(false)
const [isNewConvoModalOpen, setIsNewConvoModalOpen] = useState(false)
// Preloader: aparece APENAS em hard reload (F5 / URL direta) — nunca em
// navegação client-side (router.push). Detectado por:
// 1. performance.getEntriesByType('navigation')[0].type === 'reload'
// 2. ausência do flag sessionStorage 'nw:inbox-loaded' (primeira visita na sessão)
// sessionStorage sobrevive a navegações client-side mas é zerado em nova aba.
const [pageReady, setPageReady] = useState<boolean>(() => {
if (typeof window === 'undefined') return false
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming | undefined
const isHardReload = navEntry ? navEntry.type === 'reload' : (performance.navigation?.type === 1)
if (isHardReload) return false
return sessionStorage.getItem('nw:inbox-loaded') === '1'
})
const prevLoadingRef = useRef(false)
const readyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (prevLoadingRef.current && !loading) {
readyTimerRef.current = setTimeout(() => {
setPageReady(true)
sessionStorage.setItem('nw:inbox-loaded', '1')
}, 400)
}
prevLoadingRef.current = loading
return () => { if (readyTimerRef.current) clearTimeout(readyTimerRef.current) }
}, [loading])
const optionsRef = useRef<HTMLDivElement>(null)
const initialized = useRef(false)
// ── Setup mobile + click-outside ─────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
const resize = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', resize)
resize()
const click = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setIsInstanceDropdownOpen(false)
if (optionsRef.current && !optionsRef.current.contains(e.target as Node)) setIsOptionsMenuOpen(false)
}
document.addEventListener('mousedown', click)
return () => { window.removeEventListener('resize', resize); document.removeEventListener('mousedown', click) }
}, [])
// ── Handlers ─────────────────────────────────────────────────────────────
const handleSelectChat = useCallback((chat: Chat | null) => {
handleSelectChatBridge(chat)
onChatWindowSelect()
setIsProtocolOpen(false)
}, [handleSelectChatBridge, onChatWindowSelect])
const handleChatMenuAction = useCallback((action: string, chat: Chat) => {
handleMenuAction(action, chat, toggleFavorite)
}, [handleMenuAction, toggleFavorite])
const handleFetchChats = useCallback(async () => {
await refreshInstances()
setIsOptionsMenuOpen(false)
}, [refreshInstances])
// Voltar: se há chat aberto, fecha-o; senão, página anterior
const handleBack = useCallback(() => {
if (selectedChat) {
handleSelectChat(null)
} else {
onNavigate?.('dashboard')
}
}, [selectedChat, handleSelectChat, onNavigate])
// ESC fecha chat ou vai para página anterior
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') handleBack()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [handleBack])
// ── Render ────────────────────────────────────────────────────────────────
return (
<div className="flex h-full w-full bg-[#f0f2f5] overflow-hidden select-none font-sans text-[#111b21]">
{/* Preloader global visível até o primeiro carregamento de chats completar */}
<AnimatePresence>
{!pageReady && (
<motion.div
key="inbox-preloader"
exit={{ opacity: 0 }}
transition={{ duration: 0.4 }}
className="fixed inset-0 z-[9999] flex items-center justify-center bg-[#070b14]"
>
<div className="flex flex-col items-center gap-5">
<motion.div
animate={{ opacity: [1, 0.2, 1] }}
transition={{ duration: 1.6, repeat: Infinity, ease: 'easeInOut' }}
>
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg" className="w-20 h-20">
<defs>
<linearGradient id="wpp-grad" x1="50" y1="100" x2="50" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stopColor="#20b038"/>
<stop offset="1" stopColor="#60d66a"/>
</linearGradient>
</defs>
<circle cx="50" cy="50" r="50" fill="url(#wpp-grad)"/>
<path d="M50 15.5c-19.05 0-34.5 15.45-34.5 34.5 0 6.36 1.72 12.3 4.73 17.4L15.5 84.5l17.6-4.62a34.36 34.36 0 0 0 16.9 4.42c19.05 0 34.5-15.45 34.5-34.5S69.05 15.5 50 15.5z" fill="rgba(0,0,0,0.12)"/>
<path d="M68.5 60.3c-1.1-.57-6.53-3.22-7.54-3.59-.99-.37-1.72-.55-2.44.55-.73 1.1-2.82 3.59-3.46 4.32-.64.73-1.28.82-2.38.27-6.53-3.26-10.81-5.83-15.1-13.22-1.14-1.97.24-1.83.65-3.05.34-1 .17-1.84-.1-2.39-.27-.55-2.44-5.99-3.4-8.2-.9-2.16-1.81-1.86-2.49-1.89-.64-.03-1.38-.04-2.12-.04s-1.93.28-2.94 1.38c-1.01 1.1-3.87 3.78-3.87 9.22s3.96 10.69 4.51 11.43c.55.73 7.8 11.9 18.9 16.7 7.02 3.03 9.76 3.28 13.28 2.76 2.13-.32 6.53-2.67 7.45-5.25.92-2.58.92-4.79.64-5.25-.27-.46-1-.73-2.09-1.27z" fill="#fff"/>
</svg>
</motion.div>
<span className="text-slate-500 text-sm">Carregando conversas</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="flex flex-1 overflow-hidden h-full">
<ThinSidebar activeTab={activeTab} setActiveTab={setActiveTab} activeInstance={activeInstance} onNavigate={onNavigate} />
{activeTab === 'chats' && (
<div className={`${isMobile ? (selectedChat ? 'hidden' : 'w-full') : 'w-[400px]'} flex flex-col bg-white border-r border-[#d1d7db] shrink-0 z-20`}>
{/* Header da sidebar */}
<div className="h-[60px] px-4 flex items-center justify-between bg-[#f0f2f5] border-b border-[#d1d7db] shrink-0">
<div className="flex items-center gap-1">
<button
onClick={handleBack}
title={selectedChat ? 'Voltar aos chats' : 'Voltar'}
className="p-2 hover:bg-black/5 rounded-full text-[#54656f] shrink-0"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="relative" ref={dropdownRef}>
<div onClick={() => setIsInstanceDropdownOpen(!isInstanceDropdownOpen)} className="flex items-center gap-3 cursor-pointer p-1">
<div className="w-10 h-10 rounded-full bg-white border border-black/5 overflow-hidden flex items-center justify-center">
{activeInstance?.instance
? <img
src={getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
}) || activeInstance.avatar || undefined}
className="w-full h-full object-cover" alt=""
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
: <User className="text-gray-400" />}
</div>
<span className="font-bold text-sm truncate max-w-[120px]">{activeInstance?.nome || 'Instância'}</span>
<ChevronDown className={`w-4 h-4 transition-all ${isInstanceDropdownOpen ? 'rotate-180' : ''}`} />
</div>
<AnimatePresence>
{isInstanceDropdownOpen && (
<motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -10 }}
className="absolute left-0 top-full bg-white shadow-2xl rounded-xl border border-gray-100 w-72 z-50 py-2">
{instances.map(i => (
<button key={i.instance} onClick={() => { selectInstance(i); setIsInstanceDropdownOpen(false) }}
className="w-full p-3 hover:bg-gray-50 flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gray-100 overflow-hidden">
<img
src={getAvatarProxyUrl({
instance_name: i.instance,
remote_jid: i.phone ? `${i.phone}@s.whatsapp.net` : null,
}) || i.avatar || undefined}
alt="" className="w-full h-full object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none' }}
/>
</div>
<div className="text-left">
<span className="text-sm font-medium block">{i.nome}</span>
<span className={`text-xs ${i.status === 'connected' ? 'text-green-500' : 'text-gray-400'}`}>{i.status}</span>
</div>
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>{/* end left flex */}
<div className="flex items-center gap-1">
<div className="relative" ref={optionsRef}>
<button onClick={() => setIsOptionsMenuOpen(!isOptionsMenuOpen)} className="p-2 hover:bg-black/5 rounded-full">
<MoreVertical className="w-5 h-5 text-[#54656f]" />
</button>
<AnimatePresence>
{isOptionsMenuOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute right-0 top-full mt-1 bg-white shadow-2xl rounded-xl border border-gray-100 w-56 z-50 py-2"
>
<button
onClick={handleFetchChats}
disabled={loading}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
Atualizar chats
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Busca */}
<div className="px-4 py-2 flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input type="text" placeholder="Pesquisar..."
className="w-full bg-[#f0f2f5] h-[35px] pl-10 rounded-lg text-sm border-none focus:ring-0"
value={searchTerm} onChange={e => setSearchTerm(e.target.value)} />
</div>
<button onClick={() => setIsNewConvoModalOpen(true)}
className="w-9 h-9 flex items-center justify-center rounded-full text-gray-500 hover:bg-black/5">
<MessageSquarePlus className="w-4 h-4" />
</button>
</div>
{/* Lista de chats */}
<ChatSidebar
chats={chatsByScope}
loading={loading}
selectedChat={selectedChat}
searchTerm={searchTerm}
onSelectChat={handleSelectChat}
onMenuAction={handleChatMenuAction}
activeFilter={activeFilter}
selectedLabelFilter={null}
chatScope={chatScope}
activeCustomListName={null}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
/>
</div>
)}
{/* Área principal */}
<div
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" />
{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
selectedChat={selectedChat}
isMobile={isMobile}
isTyping={isTyping}
onAvatarClick={() => { setIsProfileOpen(!isProfileOpen); setIsProtocolOpen(false) }}
onBack={() => handleSelectChat(null)}
onSyncAvatar={handleSyncAvatar}
isChatMenuOpen={isChatMenuOpen}
isSyncingAvatar={isSyncingAvatar}
isProtocolOpen={isProtocolOpen}
hasActiveProtocol={protocolPanel.hasActive}
onSearchClick={() => {}}
onToggleMenu={() => setIsChatMenuOpen(!isChatMenuOpen)}
onProtocolClick={() => { setIsProtocolOpen(!isProtocolOpen); setIsProfileOpen(false) }}
/>
<MessageArea
messages={messages as any}
selectedChat={selectedChat}
scrollRef={scrollRef}
msgLoading={loadingMessages}
onScroll={handleScroll}
historyLoadingMore={loadingMore}
hasMoreHistory={hasMoreHistory}
reactionPickerFor={reactionPickerFor}
onReply={setReplyingTo}
onToggleReactionPicker={toggleReactionPicker}
onReact={handleReactToMessage}
onForward={() => {}}
onDelete={() => {}}
onEdit={() => {}}
quickReactions={['👍', '❤️', '😂', '😮', '😢', '🙏']}
getGroupSenderLabel={(msg: any) => {
if (msg.direction === 'out') return 'Você'
if (msg.participant) return msg.participant
if (msg.senderJid) {
if (msg.senderJid.endsWith('@lid')) return msg.participant || ''
const phone = msg.senderJid.split('@')[0].split(':')[0]
return formatPhone(phone, msg.senderJid)
}
return ''
}}
/>
<InputBar
newMessage={newMessage}
onNewMessageChange={setNewMessage}
onSend={handleSendMessage}
onPresence={handleTyping}
onCancelReply={() => setReplyingTo(null)}
replyingTo={replyingTo}
selectedChat={selectedChat}
isMobile={isMobile}
onSendMedia={handleSendMedia}
onSendAudio={handleSendAudio}
instanceId={activeInstance?.instance ?? null}
mentions={mentions}
onMentionsChange={setMentions}
/>
</div>
<ContactProfile
isOpen={isProfileOpen && !isProtocolOpen && !isMobile}
chat={selectedChat}
messages={messages as any}
onClose={() => setIsProfileOpen(false)}
/>
<ProtocolPanel
isOpen={isProtocolOpen && !isMobile}
chatId={selectedChat ? String(selectedChat.id) : null}
onClose={() => setIsProtocolOpen(false)}
{...protocolPanel}
/>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center p-10 bg-[#f0f2f5]">
<MessageSquare className="w-20 h-20 opacity-10 mb-8" />
<h1 className="text-2xl font-light text-[#41525d] mb-4">NewWhats</h1>
<p className="text-sm text-[#667781]">Selecione uma conversa para começar.</p>
</div>
)}
</div>
</div>
<NewConversationModal
isOpen={isNewConvoModalOpen}
onClose={() => setIsNewConvoModalOpen(false)}
activeInstance={mappedActiveInstance as any}
connectedInstances={connectedInstances as any}
sendMessage={async (instanceId: string, phone: string, text: string) => {
try {
// ext: inicia nova conversa por telefone (resolve/cria o chat).
const res = await nw.post('/conversations', { sessionId: instanceId, phone, text })
return { ok: true, messageId: res?.messageId }
} catch {
return { ok: false }
}
}}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
+658
View File
@@ -0,0 +1,658 @@
// SessionsView — página /sessions do motor NewWhats, portada para o satélite
// scoreodonto. Dados/realtime via shims (ext API + túnel WS). Adaptações: sem
// next/link (navegação via onNavigate), export nomeado, wrapper de fundo escuro.
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
Wifi, Plus, RefreshCw, Loader2, QrCode, X,
CheckCircle2, AlertCircle, Trash2, LogOut, Phone,
ArrowLeft, Zap, WifiOff, Clock, Terminal, Download,
ChevronDown, ChevronRight,
} from 'lucide-react';
import { Button } from './components/ui';
import { useSessionsLogic } from './hooks/useSessionsLogic';
import { socketService } from './services/socketService';
import type { Instance } from './store/instanceStore';
// ─── Status config ────────────────────────────────────────────────────────────
const STATUS_CONFIG = {
CONNECTED: {
label: 'Conectado',
dot: 'bg-emerald-400',
badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20',
icon: <Wifi className="w-4 h-4" />,
},
QR_PENDING: {
label: 'Aguardando QR',
dot: 'bg-amber-400 animate-pulse',
badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20',
icon: <QrCode className="w-4 h-4" />,
},
CONNECTING: {
label: 'Conectando...',
dot: 'bg-blue-400 animate-pulse',
badge: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
icon: <Loader2 className="w-4 h-4 animate-spin" />,
},
DISCONNECTED: {
label: 'Desconectado',
dot: 'bg-slate-500',
badge: 'bg-slate-500/10 text-slate-400 border-slate-500/20',
icon: <WifiOff className="w-4 h-4" />,
},
BANNED: {
label: 'Banido',
dot: 'bg-red-500',
badge: 'bg-red-500/10 text-red-400 border-red-500/20',
icon: <AlertCircle className="w-4 h-4" />,
},
};
// ─── Baileys Log Types ────────────────────────────────────────────────────────
interface BaileysLogEntry {
id: string;
ts: number;
event: string;
data: any;
}
const EVENT_COLOR: Record<string, string> = {
'connection.update': 'text-emerald-400 bg-emerald-500/15',
'messages.upsert': 'text-blue-400 bg-blue-500/15',
'contacts.upsert': 'text-purple-400 bg-purple-500/15',
'contacts.update': 'text-purple-300 bg-purple-500/10',
'chats.upsert': 'text-yellow-400 bg-yellow-500/15',
'chats.update': 'text-yellow-300 bg-yellow-500/10',
'groups.upsert': 'text-orange-400 bg-orange-500/15',
'groups.update': 'text-orange-300 bg-orange-500/10',
'messaging-history.set': 'text-cyan-400 bg-cyan-500/15',
'message': 'text-blue-300 bg-blue-500/10',
};
function eventColor(event: string): string {
return EVENT_COLOR[event] ?? 'text-slate-400 bg-slate-500/15';
}
function logSummary(entry: BaileysLogEntry): string {
const d = entry.data;
if (!d) return '—';
if (entry.event === 'connection.update') {
if (d.connection) return `${d.connection}`;
if (d.qr) return 'QR gerado';
}
if (entry.event === 'message' || entry.event === 'messages.upsert') {
const body = d.body ?? d.message?.conversation ?? d.message?.extendedTextMessage?.text;
if (body) return String(body).slice(0, 60);
const jid = d.remoteJid ?? d.key?.remoteJid;
if (jid) return jid;
}
if (typeof d === 'object') {
const count = Array.isArray(d) ? d.length : (d.count ?? d.chats ?? d.contacts ?? d.messages);
if (count !== undefined) return `${count} item(s)`;
const keys = Object.keys(d).slice(0, 3).join(', ');
return keys || '{}';
}
return String(d).slice(0, 60);
}
// ─── Log Row (expandable) ─────────────────────────────────────────────────────
function LogRow({ entry, index }: { entry: BaileysLogEntry; index: number }) {
const [open, setOpen] = useState(false);
const time = new Date(entry.ts).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return (
<div className="border-b border-white/[0.04] last:border-0">
<button
onClick={() => setOpen(o => !o)}
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-white/[0.03] text-left group transition-colors"
>
<span className="text-[9px] font-mono text-slate-700 w-5 shrink-0 text-right">{index + 1}</span>
<span className={`text-[9px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap ${eventColor(entry.event)}`}>
{entry.event}
</span>
<span className="text-[11px] text-slate-400 truncate flex-1 font-mono">{logSummary(entry)}</span>
<span className="text-[9px] text-slate-700 shrink-0 font-mono">{time}</span>
<span className="shrink-0 text-slate-700 group-hover:text-slate-500">
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
</span>
</button>
<AnimatePresence>
{open && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<pre className="mx-3 mb-2 p-2 bg-black/60 rounded-lg text-[10px] font-mono text-slate-300 overflow-x-auto whitespace-pre-wrap max-h-48 border border-white/5">
{JSON.stringify(entry.data, null, 2)}
</pre>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── QR Modal ─────────────────────────────────────────────────────────────────
function QrModal({
instanceId,
instanceName,
qrBase64,
onClose,
onNewQr,
}: {
instanceId: string;
instanceName: string;
qrBase64: string | null;
onClose: () => void;
onNewQr: (id: string) => Promise<void>;
}) {
const [expired, setExpired] = useState(false);
const [logs, setLogs] = useState<BaileysLogEntry[]>([]);
const logEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!qrBase64) return;
setExpired(false);
const t = setTimeout(() => setExpired(true), 60_000);
return () => clearTimeout(t);
}, [qrBase64]);
useEffect(() => {
const handler = (log: any) => {
if (log.sessionId !== instanceId) return;
setLogs(prev => {
const entry: BaileysLogEntry = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
ts: log.timestamp ? new Date(log.timestamp).getTime() : Date.now(),
event: log.event ?? 'unknown',
data: log.data,
};
return [...prev, entry].slice(-300);
});
};
socketService.on('baileys:log', handler);
return () => socketService.off('baileys:log', handler);
}, [instanceId]);
useEffect(() => {
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs.length]);
const saveJson = useCallback(() => {
const blob = new Blob([JSON.stringify(logs, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `baileys-${instanceId}-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}, [logs, instanceId]);
return (
<div className="fixed inset-0 z-[120] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-[#0d0f18] border border-white/10 rounded-[2rem] shadow-2xl w-full max-w-3xl max-h-[90vh] flex flex-col overflow-hidden"
onClick={e => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-white/[0.06] shrink-0">
<div>
<h2 className="text-lg font-black text-white">Conectar WhatsApp</h2>
<p className="text-slate-500 text-xs mt-0.5">{instanceName}</p>
</div>
<button onClick={onClose} className="p-2 text-slate-500 hover:text-white bg-white/5 rounded-xl transition-colors">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-72 shrink-0 flex flex-col items-center justify-center gap-5 p-6 border-r border-white/[0.06]">
<div className="bg-white/[0.02] border border-white/5 p-5 rounded-3xl w-full flex flex-col items-center justify-center min-h-[260px]">
{expired ? (
<div className="flex flex-col items-center gap-4">
<Clock className="w-10 h-10 text-slate-500" />
<p className="font-bold text-white text-sm">QR expirado</p>
<Button variant="primary" size="sm" onClick={async () => {
setExpired(false);
await onNewQr(instanceId);
}}>
Novo QR
</Button>
</div>
) : qrBase64 ? (
<div className="bg-white p-2.5 rounded-2xl shadow-xl">
<img src={qrBase64.startsWith('data:') ? qrBase64 : `data:image/png;base64,${qrBase64}`} alt="QR Code" className="w-48 h-48 rounded-xl" />
</div>
) : (
<div className="flex flex-col items-center gap-3">
<Loader2 className="w-10 h-10 animate-spin text-emerald-500" />
<p className="text-xs text-slate-500 font-semibold uppercase tracking-widest">Gerando QR...</p>
</div>
)}
</div>
{!expired && (
<div className="flex items-center gap-2 text-emerald-400 text-[10px] font-bold uppercase tracking-widest animate-pulse">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Aguardando pareamento...
</div>
)}
<button onClick={onClose} className="w-full py-2.5 text-slate-500 hover:text-white font-bold transition-colors text-sm">
Fechar
</button>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-white/[0.06] bg-black/20 shrink-0">
<Terminal className="w-3.5 h-3.5 text-slate-500" />
<span className="text-[10px] font-black uppercase tracking-widest text-slate-500">Console Baileys</span>
<span className="ml-1 text-[9px] font-mono text-slate-700 bg-white/5 px-1.5 py-0.5 rounded-full">
{logs.length} eventos
</span>
<div className="ml-auto flex items-center gap-2">
<button
onClick={() => setLogs([])}
className="text-[9px] font-bold uppercase tracking-wider text-slate-600 hover:text-slate-400 transition-colors px-2 py-0.5 rounded hover:bg-white/5"
>
Limpar
</button>
<button
onClick={saveJson}
disabled={logs.length === 0}
className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-emerald-500 hover:text-emerald-400 disabled:opacity-30 disabled:cursor-not-allowed transition-colors px-2 py-0.5 rounded hover:bg-emerald-500/10"
>
<Download size={10} /> Salvar JSON
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{logs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-slate-700 gap-2 py-16">
<Terminal size={28} className="opacity-30" />
<p className="text-xs">Aguardando eventos do Baileys...</p>
</div>
) : (
<div>
{logs.map((entry, i) => (
<LogRow key={entry.id} entry={entry} index={i} />
))}
<div ref={logEndRef} />
</div>
)}
</div>
<div className="px-4 py-2 border-t border-white/[0.06] bg-black/20 shrink-0">
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
<span className="text-[9px] font-mono text-slate-600">
{instanceId.slice(0, 8)}... · live
</span>
</div>
</div>
</div>
</div>
</motion.div>
</div>
);
}
// ─── Session Card ─────────────────────────────────────────────────────────────
const SessionCard = React.forwardRef<HTMLDivElement, {
inst: Instance;
onConnect: () => void;
onDisconnect: () => void;
onDelete: () => void;
onShowQr: () => void;
}>(function SessionCard({
inst,
onConnect,
onDisconnect,
onDelete,
onShowQr,
}, ref) {
const cfg = STATUS_CONFIG[inst.status] ?? STATUS_CONFIG.DISCONNECTED;
const isConnected = inst.status === 'CONNECTED';
const isQrPending = inst.status === 'QR_PENDING';
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 15 }} animate={{ opacity: 1, y: 0 }}
className="bg-white/[0.03] backdrop-blur-xl p-6 rounded-[2rem] shadow-2xl border border-white/5 flex flex-col gap-5 hover:border-white/10 transition-all group relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-brand-500/5 blur-[60px] rounded-full translate-x-1/2 -translate-y-1/2 pointer-events-none" />
<div className="flex items-start justify-between relative z-10">
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl overflow-hidden flex items-center justify-center border-2 shadow-lg ${isConnected ? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400' : 'bg-white/5 border-white/10 text-slate-500'}`}>
{inst.avatar
? <img src={inst.avatar} alt={inst.name} className="w-full h-full object-cover" onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; (e.currentTarget.nextSibling as HTMLElement)?.style.setProperty('display', 'flex') }} />
: null}
<span className={`w-full h-full items-center justify-center ${inst.avatar ? 'hidden' : 'flex'}`}>
<Phone className="w-6 h-6" />
</span>
</div>
<div>
<h3 className="font-black text-white text-lg tracking-tight">{inst.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
{inst.phone && (
<span className="text-xs font-mono text-slate-500 bg-white/5 px-2 py-0.5 rounded border border-white/5">{inst.phone}</span>
)}
<span className={`inline-flex items-center gap-1.5 text-[10px] font-black uppercase tracking-wider px-2 py-0.5 rounded-full border ${cfg.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
{cfg.label}
</span>
</div>
</div>
</div>
<button onClick={onDelete} title="Deletar instância"
className="p-2 text-slate-600 hover:text-red-400 hover:bg-red-500/10 rounded-xl transition-colors shrink-0"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{isQrPending && (
<button onClick={onShowQr}
className="flex items-center gap-3 p-3 bg-amber-500/10 border border-amber-500/20 rounded-2xl text-amber-300 text-sm font-semibold hover:bg-amber-500/15 transition-colors w-full"
>
<QrCode className="w-5 h-5 shrink-0" />
<span className="text-left">QR aguardando escaneamento clique para ver</span>
</button>
)}
<div className="flex items-center gap-2 mt-auto relative z-10 pt-2 border-t border-white/5">
{isConnected ? (
<>
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold uppercase tracking-widest flex-1">
<RefreshCw className="w-3.5 h-3.5 animate-spin" /> Sessão ativa
</div>
<button onClick={onDisconnect}
className="flex items-center gap-1.5 px-3 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-xl transition-colors border border-white/5"
>
<LogOut className="w-3.5 h-3.5" /> Desconectar
</button>
</>
) : (
<button onClick={onConnect}
className="flex-1 flex items-center justify-center gap-2 bg-white text-black font-black py-3 rounded-xl hover:bg-slate-200 transition-all text-[11px] uppercase tracking-widest"
>
<Zap className="w-3.5 h-3.5" fill="currentColor" /> Conectar
</button>
)}
</div>
</motion.div>
);
});
// ─── Confirm Delete Modal ─────────────────────────────────────────────────────
function ConfirmDeleteModal({
inst,
onClose,
onConfirm,
loading,
}: {
inst: Instance;
onClose: () => void;
onConfirm: () => void;
loading: boolean;
}) {
return (
<div className="fixed inset-0 z-[130] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={onClose} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.92, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9 }}
className="relative bg-black/40 backdrop-blur-2xl border border-red-500/20 border-t-[3px] border-t-red-500 rounded-[28px] p-7 w-full max-w-sm shadow-2xl"
>
<div className="flex items-center gap-3 mb-4 mt-2">
<div className="w-11 h-11 rounded-xl bg-red-500/15 border border-red-500/30 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-500" />
</div>
<div>
<h3 className="font-bold text-white">Deletar instância</h3>
<p className="text-xs text-slate-500">{inst.name}</p>
</div>
</div>
<p className="text-sm text-slate-400 mb-6 leading-relaxed">
Isso removerá permanentemente a instância, seus chats e sessão Baileys.<br />
<strong className="text-red-400">Esta ação é irreversível.</strong>
</p>
<div className="flex gap-3">
<Button variant="ghost" onClick={onClose} className="flex-1">Cancelar</Button>
<button onClick={onConfirm} disabled={loading}
className="flex-1 py-3 bg-red-500 hover:bg-red-600 text-white font-bold rounded-xl transition-colors disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
Deletar
</button>
</div>
</motion.div>
</div>
);
}
// ─── Main View ────────────────────────────────────────────────────────────────
export function SessionsView({ onNavigate }: { onNavigate?: (view: string) => void }) {
const {
instances,
loading,
toast,
addOpen,
newName,
creating,
setNewName,
openAddModal,
closeAddModal,
handleCreate,
qrModal,
closeQrModal,
handleNewQr,
deleteTarget,
deleting,
setDeleteTarget,
closeDeleteModal,
handleDelete,
handleConnect,
handleDisconnect,
handleShowQr,
refreshInstances,
} = useSessionsLogic();
// Já existe uma sessão ativa no motor para esta conta → mostra mensagem de
// sucesso em vez de sugerir novo pareamento.
const hasActiveSession = instances.some((i) => i.status === 'CONNECTED');
return (
<div className="bg-[#070b14] min-h-full text-slate-200">
<div className="max-w-5xl mx-auto p-6 lg:p-10 pb-20 font-sans space-y-10">
<AnimatePresence>
{toast && (
<motion.div
initial={{ opacity: 0, y: -20 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -20 }}
className={`fixed top-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-2.5 px-5 py-3 rounded-2xl shadow-2xl border text-sm font-semibold ${toast.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-300' : 'bg-red-500/10 border-red-500/20 text-red-300'}`}
>
{toast.ok ? <CheckCircle2 className="w-4 h-4" /> : <AlertCircle className="w-4 h-4" />}
{toast.msg}
</motion.div>
)}
</AnimatePresence>
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 relative">
<div className="absolute -top-16 -left-16 w-56 h-56 bg-brand-500/10 blur-[100px] rounded-full pointer-events-none" />
<div className="relative z-10 flex items-start gap-4">
<button onClick={() => onNavigate?.('dashboard')} className="p-2 hover:bg-white/5 rounded-xl transition-colors text-slate-400 hover:text-white mt-1">
<ArrowLeft className="w-5 h-5" />
</button>
<div className="flex-1">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-brand-500 to-brand-700 rounded-xl flex items-center justify-center text-white shadow-glow-brand shrink-0">
<Wifi className="w-6 h-6" />
</div>
<h1 className="text-3xl font-black text-white tracking-tight">Instâncias</h1>
</div>
<p className="text-sm text-slate-500 font-medium mt-1 ml-[60px]">WhatsApp Multi-Device · Baileys</p>
</div>
</div>
<div className="flex items-center gap-3 relative z-10">
<button onClick={refreshInstances} title="Recarregar"
className="p-3 bg-white/5 border border-white/10 rounded-xl text-slate-400 hover:text-white transition-all hover:bg-white/10"
>
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
<button onClick={openAddModal}
className="flex items-center gap-2 px-5 py-3 bg-brand-500 hover:bg-brand-600 text-white font-bold rounded-xl transition-all shadow-glow-brand text-sm"
>
<Plus className="w-5 h-5" /> Nova Instância
</button>
</div>
</header>
{hasActiveSession && (
<motion.div
initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-4 p-5 rounded-2xl bg-emerald-500/10 border border-emerald-500/20"
>
<div className="w-11 h-11 rounded-xl bg-emerald-500/15 flex items-center justify-center text-emerald-400 shrink-0">
<CheckCircle2 className="w-6 h-6" />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-base font-black text-emerald-300">Você possui acesso ao WhatsApp</h2>
<p className="text-sm text-emerald-400/70 font-medium">Sua sessão está ativa e conectada não é preciso parear novamente.</p>
</div>
{onNavigate && (
<button onClick={() => onNavigate('wa-inbox')}
className="shrink-0 flex items-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-xl transition-all text-sm"
>
Abrir conversas
</button>
)}
</motion.div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<AnimatePresence mode="popLayout">
{loading ? (
Array(3).fill(0).map((_, i) => (
<div key={i} className="h-56 rounded-[2rem] animate-pulse bg-white/5" />
))
) : instances.length === 0 ? (
<motion.div key="empty" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}
className="col-span-full py-24 flex flex-col items-center justify-center text-center gap-6"
>
<div className="w-20 h-20 bg-slate-800 rounded-full flex items-center justify-center text-slate-600">
<Phone className="w-10 h-10" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Nenhuma instância</h2>
<p className="text-slate-500 mt-1 text-sm">Crie uma para começar a usar o WhatsApp.</p>
</div>
<button onClick={openAddModal}
className="flex items-center gap-2 px-6 py-3 bg-white/5 hover:bg-white/10 text-white font-bold rounded-2xl border border-white/10 transition-all"
>
<Plus className="w-5 h-5" /> Criar instância
</button>
</motion.div>
) : (
instances.map((inst) => (
<SessionCard
key={inst.id}
inst={inst}
onConnect={() => handleConnect(inst)}
onDisconnect={() => handleDisconnect(inst)}
onDelete={() => setDeleteTarget(inst)}
onShowQr={() => handleShowQr(inst)}
/>
))
)}
</AnimatePresence>
</div>
<AnimatePresence>
{addOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center p-4">
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
onClick={closeAddModal} className="absolute inset-0 bg-black/80 backdrop-blur-sm cursor-pointer" />
<motion.div initial={{ opacity: 0, scale: 0.9, y: 20 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative bg-white/[0.03] backdrop-blur-2xl p-8 rounded-[3rem] border border-white/10 max-w-sm w-full space-y-6 shadow-2xl"
>
<div>
<h2 className="text-2xl font-black text-white">Nova Instância</h2>
<p className="text-slate-500 text-sm mt-1"> um nome para identificar esta conexão.</p>
</div>
<div className="space-y-1.5">
<label className="text-[10px] font-bold text-slate-600 uppercase tracking-widest">Nome</label>
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleCreate(); }}
placeholder="ex: principal, comercial, suporte..."
autoFocus
className="w-full bg-black/30 border border-white/10 rounded-2xl px-4 py-3 text-white placeholder:text-slate-600 focus:outline-none focus:ring-2 focus:ring-brand-500/40 text-sm"
/>
</div>
<div className="flex gap-3">
<Button variant="ghost" onClick={closeAddModal} className="flex-1">
Cancelar
</Button>
<button onClick={handleCreate} disabled={creating || !newName.trim()}
className="flex-1 py-3 bg-emerald-500 hover:bg-emerald-600 text-white font-bold rounded-2xl transition-all disabled:opacity-50 flex items-center justify-center gap-2 text-sm"
>
{creating ? <Loader2 className="w-4 h-4 animate-spin" /> : <Plus className="w-4 h-4" />}
Criar e conectar
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
<AnimatePresence>
{qrModal && (
<QrModal
instanceId={qrModal.instanceId}
instanceName={qrModal.instanceName}
qrBase64={qrModal.qrBase64}
onClose={closeQrModal}
onNewQr={handleNewQr}
/>
)}
</AnimatePresence>
<AnimatePresence>
{deleteTarget && (
<ConfirmDeleteModal
inst={deleteTarget}
onClose={closeDeleteModal}
onConfirm={handleDelete}
loading={deleting}
/>
)}
</AnimatePresence>
</div>
</div>
);
}
export default SessionsView;
@@ -0,0 +1,64 @@
'use client';
import React from 'react';
import { MessageCircle } from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import ChatListItem from './ChatListItem';
interface ChatListProps {
chats: NewWhatsChat[];
loading: boolean;
selectedChat: NewWhatsChat | null;
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
drafts?: Record<string, string>;
}
export default function ChatList({
chats,
loading,
selectedChat,
onSelectChat,
onMenuAction,
presenceByJid,
isFavoriteChat,
drafts = {},
}: ChatListProps) {
if (loading) {
return (
<div className="flex items-center justify-center h-40">
<div className="w-8 h-8 border-3 border-[#00a884] border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
if (chats.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-64 px-10 text-center">
<div className="w-16 h-16 bg-[#f0f2f5] rounded-full flex items-center justify-center mb-4">
<MessageCircle className="w-8 h-8 text-[#8696a0]" />
</div>
<p className="text-[#667781] text-sm font-normal">Nenhuma conversa encontrada</p>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto bg-white custom-scrollbar">
{chats.map((chat) => (
<ChatListItem
key={`${chat.instance_name}-${chat.remote_jid}`}
chat={chat}
isActive={selectedChat?.id === chat.id}
onSelect={onSelectChat}
onMenuAction={onMenuAction}
presenceByJid={presenceByJid}
isFavorite={isFavoriteChat(chat)}
draft={drafts[chat.remote_jid]}
/>
))}
</div>
);
}
@@ -0,0 +1,292 @@
'use client';
import React, { useMemo, useRef, useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
Pin, Check, CheckCheck, Clock, ChevronDown,
VolumeX, Star, StarOff, Archive, ArchiveRestore,
Trash2, MailOpen, Volume2, PinOff,
Mic, FileText, Video, Image as ImageIcon,
AlertCircle, Bot,
} from 'lucide-react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import {
normalizeJid,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface ChatListItemProps {
chat: NewWhatsChat;
isActive: boolean;
onSelect: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
presenceByJid: Record<string, PresenceState>;
isFavorite: boolean;
draft?: string;
}
const renderLastStatus = (chat: NewWhatsChat) => {
if (chat.last_message_from_me !== 1) return null;
switch (chat.last_message_status) {
case 'pending':
case 'sending':
return <Clock className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'sent':
return <Check className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'delivered':
return <CheckCheck className="w-3.5 h-3.5 text-[#8696a0] flex-shrink-0" />;
case 'read':
return <CheckCheck className="w-3.5 h-3.5 text-[#53bdeb] flex-shrink-0" />;
case 'failed':
return <AlertCircle className="w-3.5 h-3.5 text-red-500 flex-shrink-0" />;
default:
return null;
}
};
const getLastMessageSnippet = (chat: NewWhatsChat, draft?: string): React.ReactNode => {
if (draft) return <span className="text-[#00a884]">Rascunho: {draft}</span>;
const truncate = (value: string, limit = 35) => value.length > limit ? `${value.slice(0, limit - 1)}...` : value;
// Group Sender Prefix — WhatsApp exibe "Você:" ou primeiro nome do remetente
const isGroup = chat.remote_jid.endsWith('@g.us');
const senderPrefix = isGroup
? (chat.last_message_from_me === 1
? 'Você: '
: (chat.last_message_sender ? `${chat.last_message_sender.split(' ')[0]}: ` : ''))
: '';
const IconLabel = ({ icon: Icon, label }: { icon: any, label: string }) => (
<span className="flex items-center gap-1">
<Icon className="w-3.5 h-3.5" />
{senderPrefix}{label}
</span>
);
// Use last_message_type (pipeline do chatStore) para ícones de mídia
const msgType = (chat.last_message_type ?? '').toLowerCase();
if (msgType === 'audio' || msgType === 'ptt') return <IconLabel icon={Mic} label="Áudio" />;
if (msgType === 'sticker') return <IconLabel icon={Star} label="Figurinha" />;
if (msgType === 'image') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={ImageIcon} label={truncate(caption)} />
: <IconLabel icon={ImageIcon} label="Imagem" />;
}
if (msgType === 'video') {
const caption = chat.last_message_text?.trim();
return caption
? <IconLabel icon={Video} label={truncate(caption)} />
: <IconLabel icon={Video} label="Vídeo" />;
}
if (msgType === 'document') return <IconLabel icon={FileText} label={chat.last_message_text?.trim() || 'Documento'} />;
// Texto puro (text, extendedtext, etc.)
const text = chat.last_message_text ?? '';
if (text.trim()) return `${senderPrefix}${truncate(text.trim())}`;
return senderPrefix || '';
};
export default function ChatListItem({
chat,
isActive,
onSelect,
onMenuAction,
presenceByJid,
isFavorite,
draft
}: ChatListItemProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuBtnRef = useRef<HTMLButtonElement>(null);
const relativeTime = useMemo(() => {
if (!chat.last_message_time) return '';
const date = new Date(chat.last_message_time);
if (isNaN(date.getTime())) return '';
// Comparação por data de calendário (não por 24h corridas)
const today = new Date(); today.setHours(0, 0, 0, 0);
const msgDay = new Date(date); msgDay.setHours(0, 0, 0, 0);
const diffDays = Math.round((today.getTime() - msgDay.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
}
if (diffDays === 1) return 'Ontem';
if (diffDays < 7) {
// "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"
const short = date.toLocaleDateString('pt-BR', { weekday: 'short' });
return short.replace('.', '').replace(/^\w/, (c) => c.toUpperCase());
}
return date.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' });
}, [chat.last_message_time]);
const isMuted = useMemo(() => {
return Boolean(chat.muted_until && new Date(chat.muted_until).getTime() > Date.now());
}, [chat.muted_until]);
const menuItems = useMemo(() => {
const isPinned = Boolean(chat.is_pinned);
const isArchived = Boolean(chat.is_archived);
const isGroup = chat.remote_jid.endsWith('@g.us');
return [
{ key: 'favorite', label: isFavorite ? 'Remover favorito' : 'Favoritar', icon: isFavorite ? <StarOff className="w-4 h-4" /> : <Star className="w-4 h-4" /> },
{ key: 'archive', label: isArchived ? 'Desarquivar' : 'Arquivar', icon: isArchived ? <ArchiveRestore className="w-4 h-4" /> : <Archive className="w-4 h-4" /> },
{ key: 'mute', label: isMuted ? 'Desativar silêncio' : 'Silenciar', icon: isMuted ? <Volume2 className="w-4 h-4" /> : <VolumeX className="w-4 h-4" /> },
{ key: 'pin', label: isPinned ? 'Desafixar' : 'Fixar conversa', icon: isPinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" /> },
{ key: 'mark_read', label: 'Marcar como lida', icon: <MailOpen className="w-4 h-4" /> },
{ key: 'delete', label: 'Apagar conversa', icon: <Trash2 className="w-4 h-4" />, danger: true },
];
}, [chat, isFavorite, isMuted]);
const MENU_H = 252; // 6 itens × ~40px + 12px padding container
const MENU_W = 224; // w-56
const openMenu = (anchorX: number, anchorY: number, anchorBottom?: number) => {
const triggerBottom = anchorBottom ?? anchorY;
const spaceBelow = window.innerHeight - triggerBottom;
// Abre abaixo se couber; senão ancora o BOTTOM do menu no TOP do trigger
const top = spaceBelow >= MENU_H + 8
? triggerBottom + 4
: anchorY - MENU_H;
const left = Math.min(anchorX, window.innerWidth - MENU_W - 8);
setMenuPos({ top: Math.max(8, top), left: Math.max(8, left) });
setMenuOpen(true);
};
return (
<>
{/* Usamos div+role="button" em vez de <button> porque dentro há outro
<button> (o menu chevron) aninhar botões é inválido em HTML e
dispara validateDOMNesting warning no React. */}
<div
role="button"
tabIndex={0}
onClick={() => onSelect(chat)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect(chat);
}
}}
onContextMenu={(e) => {
e.preventDefault();
// Abre à direita do cursor se couber, senão à esquerda
const spaceRight = window.innerWidth - e.clientX;
const x = spaceRight >= MENU_W + 8 ? e.clientX : e.clientX - MENU_W;
// Passa anchorY = cursor Y, anchorBottom = cursor Y (sem item height)
openMenu(Math.max(8, x), e.clientY, e.clientY);
}}
className={`w-full flex items-center gap-3 px-3 py-2 transition-all text-[#111b21] group border-b border-[#f0f2f5] last:border-none cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-[#00a884]/40 ${isActive ? 'bg-[#f0f2f5]' : 'hover:bg-[#f5f6f6]'}`}
>
{/* Avatar Section */}
<Avatar chat={chat} className="flex-shrink-0" />
{/* Content Section */}
<div className="flex-1 min-w-0 flex flex-col justify-center py-0.5 pr-1 text-left">
<div className="flex items-center justify-between">
<h3 className="text-[16px] leading-[21px] font-normal text-[#111b21] truncate">
{chat.displayName || chat.subject || chat.phone}
</h3>
<span className={`text-[12px] leading-[14px] flex-shrink-0 ml-2 ${chat.unread_count > 0 ? 'text-[#00a884] font-medium' : 'text-[#667781]'}`}>
{relativeTime}
</span>
</div>
<div className="flex items-center justify-between mt-1 h-5">
<div className="flex items-center gap-1 flex-1 min-w-0">
{(() => {
const jid = normalizeJid(chat.remote_jid);
const p = presenceByJid[jid];
const isPresenceLive = p && (Date.now() - p.ts) <= 6000;
if (isPresenceLive && (p.state === 'composing' || p.state === 'recording')) {
return (
<p className="text-[13px] text-[#00a884] font-normal truncate">
{p.state === 'recording' ? 'gravando áudio...' : 'digitando...'}
</p>
);
}
return (
<div className="flex items-center gap-1 min-w-0">
{!draft && renderLastStatus(chat)}
<div className={`text-[13px] leading-[20px] truncate max-w-full ${draft ? 'text-[#00a884]' : 'text-[#667781]'}`}>
{getLastMessageSnippet(chat, draft)}
</div>
</div>
);
})()}
</div>
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
{chat.bot_paused && (
<span title="Bot pausado — aguardando atendente">
<Bot className="w-4 h-4 text-amber-500" />
</span>
)}
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
{chat.unread_count > 0 && (
<span className="bg-[#00a884] text-white text-[12px] font-bold px-1.5 py-0.5 rounded-full min-w-[20px] h-5 flex items-center justify-center">
{chat.unread_count > 99 ? '99+' : chat.unread_count}
</span>
)}
{/* Hover Chevron for Menu */}
<div className="relative group-hover:block hidden">
<button
ref={menuBtnRef}
className="p-1 rounded-full hover:bg-black/5"
onClick={(e) => {
e.stopPropagation();
const rect = menuBtnRef.current?.getBoundingClientRect();
if (rect) {
openMenu(rect.right - MENU_W, rect.top, rect.bottom);
}
}}
>
<ChevronDown className="w-4 h-4 text-[#8696a0]" />
</button>
</div>
</div>
</div>
</div>
</div>
{/* Context Menu Portal (fora do item pra não ficar dentro do role=button) */}
{menuOpen && menuPos && typeof window !== 'undefined' && createPortal(
<>
<div className="fixed inset-0 z-[1000]" onClick={(e) => { e.stopPropagation(); setMenuOpen(false); }} />
<div
className="fixed bg-white py-1.5 rounded-lg shadow-xl border border-gray-100 z-[1001] w-56 flex flex-col"
style={{ top: menuPos.top, left: menuPos.left }}
onClick={(e) => e.stopPropagation()}
>
{menuItems.map((item: any) => (
<button
key={item.key}
onClick={() => {
onMenuAction(item.key, chat);
setMenuOpen(false);
}}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors text-left ${
item.danger ? 'text-red-600 hover:bg-red-50' : 'text-[#3b4a54] hover:bg-[#f5f6f6]'
}`}
>
<span className={item.danger ? 'text-red-500' : 'text-[#8696a0]'}>{item.icon}</span>
{item.label}
</button>
))}
</div>
</>,
document.body
)}
</>
);
}
@@ -0,0 +1,107 @@
'use client';
import React, { useMemo, useState } from 'react';
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
import ChatList from './ChatList';
interface ChatSidebarProps {
chats: NewWhatsChat[];
loading: boolean;
selectedChat: NewWhatsChat | null;
searchTerm: string;
selectedLabelFilter: string | null;
activeFilter: string;
chatScope: 'inbox' | 'favorites' | 'archived' | 'custom_list';
activeCustomListName?: string | null;
presenceByJid: Record<string, PresenceState>;
isFavoriteChat: (chat: NewWhatsChat) => boolean;
onSelectChat: (chat: NewWhatsChat) => void;
onMenuAction: (action: any, chat: NewWhatsChat) => void;
drafts?: Record<string, string>;
}
export default function ChatSidebar({
chats,
loading,
selectedChat,
searchTerm,
selectedLabelFilter,
activeFilter,
chatScope,
activeCustomListName,
presenceByJid,
isFavoriteChat,
onSelectChat,
onMenuAction,
drafts = {},
}: ChatSidebarProps) {
// UI Pills (Simplified, for now we keep layout consistent)
const [pillFilter, setPillFilter] = useState<'all' | 'unread' | 'favorites' | 'groups'>('all');
const filteredChats = useMemo(() => chats.filter((chat) => {
const isGroup = chat.remote_jid.endsWith('@g.us');
// Filter by pill
if (pillFilter === 'unread' && chat.unread_count === 0) return false;
if (pillFilter === 'groups' && !isGroup) return false;
if (pillFilter === 'favorites' && !isFavoriteChat(chat)) return false;
const displayName = chat.displayName || chat.subject || chat.phone || chat.remote_jid;
const matchesSearch = displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
(chat.last_message_text || '').toLowerCase().includes(searchTerm.toLowerCase());
const matchesLabel = !selectedLabelFilter ||
(chat.labels && chat.labels.some((l) => l.label_id === selectedLabelFilter));
return matchesSearch && matchesLabel;
}), [chats, searchTerm, selectedLabelFilter, pillFilter, isFavoriteChat]);
const sortedFilteredChats = useMemo(
() => [...filteredChats].sort((a, b) => {
const aPinned = Boolean(a.is_pinned);
const bPinned = Boolean(b.is_pinned);
if (aPinned !== bPinned) return aPinned ? -1 : 1;
const tA = a.last_message_time ? new Date(a.last_message_time).getTime() : 0;
const tB = b.last_message_time ? new Date(b.last_message_time).getTime() : 0;
return tB - tA;
}),
[filteredChats]
);
return (
<div className="flex flex-col h-full bg-white border-r border-[#e9edef] overflow-hidden">
{/* Filter Pills */}
<div className="px-3 py-2 flex items-center gap-2 overflow-x-auto no-scrollbar flex-shrink-0 border-b border-[#f0f2f5]">
{(['all', 'unread', 'favorites', 'groups'] as const).map((filter) => (
<button
key={filter}
onClick={() => setPillFilter(filter)}
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap transition-colors ${
pillFilter === filter
? 'bg-[#00a884] text-white'
: 'bg-[#f0f2f5] text-[#54656f] hover:bg-[#e9edef]'
}`}
>
{filter === 'all' && 'Tudo'}
{filter === 'unread' && 'Não lidas'}
{filter === 'favorites' && 'Favoritos'}
{filter === 'groups' && 'Grupos'}
</button>
))}
</div>
{/* Scrollable Chat List */}
<ChatList
chats={sortedFilteredChats}
loading={loading}
selectedChat={selectedChat}
onSelectChat={onSelectChat}
onMenuAction={onMenuAction}
presenceByJid={presenceByJid}
isFavoriteChat={isFavoriteChat}
drafts={drafts}
/>
</div>
);
}
@@ -0,0 +1,173 @@
'use client';
import React, { useMemo } from 'react';
import {
X,
ChevronRight,
Image as ImageIcon,
Video as VideoIcon,
FileText,
Mic,
Bell,
Star,
Clock,
Ban,
ThumbsDown,
Trash2
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
getMediaUrl
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
import { Message } from '../types/inboxTypes';
interface ContactProfileProps {
isOpen: boolean;
chat: any;
messages: Message[];
onClose: () => void;
}
const ContactProfile: React.FC<ContactProfileProps> = ({
isOpen,
chat,
messages,
onClose,
}) => {
// Filter media messages
const mediaMessages = useMemo(() => {
return messages.filter(m => m.type === 'image' || m.type === 'video' || m.type === 'document' || m.type === 'audio');
}, [messages]);
const photoVideos = mediaMessages.filter(m => m.type === 'image' || m.type === 'video');
const documents = mediaMessages.filter(m => m.type === 'document');
const audios = mediaMessages.filter(m => m.type === 'audio');
if (!chat) return null;
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[400px] h-full bg-[#f0f2f5] border-l border-[#d1d7db] flex flex-col z-40 relative flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] px-4 bg-white flex items-center gap-6 border-b border-[#d1d7db] shrink-0">
<button
onClick={onClose}
className="p-2 hover:bg-black/5 rounded-full transition-colors"
>
<X className="w-6 h-6 text-[#54656f]" />
</button>
<h2 className="text-[16px] font-normal text-[#111b21]">Dados do contato</h2>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar pb-10">
{/* Profile Section */}
<div className="bg-white px-8 py-8 flex flex-col items-center mb-2 shadow-sm">
{/* Profile Photo */}
<Avatar chat={chat} size={200} className="mb-5 shadow-lg" />
<h1 className="text-[24px] font-normal text-[#111b21] mb-1 text-center">
{chat.lead_name || chat.subject || formatPhone(chat.phone, chat.remote_jid)}
</h1>
<p className="text-[16px] text-[#667781] font-normal mb-1">
{formatPhone(chat.phone, chat.remote_jid)}
</p>
{chat.bio && (
<p className="text-[14px] text-[#667781] font-normal mt-2 text-center max-w-xs">
{chat.bio}
</p>
)}
</div>
{/* Status (Bio) */}
<div className="bg-white px-8 py-4 mb-2 shadow-sm">
<h3 className="text-[14px] text-[#667781] font-normal mb-3">Recado</h3>
<p className="text-[16px] text-[#111b21] font-normal leading-relaxed">
{chat.bio || 'Sem recado disponível'}
</p>
</div>
{/* Media Section */}
<div className="bg-white mb-2 shadow-sm">
<button className="w-full px-8 py-4 flex items-center justify-between hover:bg-[#f5f6f6] transition-colors group">
<span className="text-[14px] text-[#667781] font-normal">Mídia, links e documentos</span>
<div className="flex items-center gap-1">
<span className="text-[14px] text-[#667781] font-normal">{mediaMessages.length}</span>
<ChevronRight className="w-5 h-5 text-[#8696a0] group-hover:translate-x-0.5 transition-transform" />
</div>
</button>
{mediaMessages.length > 0 ? (
<div className="px-8 pb-4 grid grid-cols-3 gap-1">
{photoVideos.slice(0, 3).map((m, i) => (
<div
key={m.id || i}
className="aspect-square bg-gray-100 rounded-sm overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => {
const url = getMediaUrl(m.media_url);
if (url) window.open(url, '_blank');
}}
>
<div className="w-full h-full flex items-center justify-center bg-black/10">
{m.type === 'video' ? <VideoIcon className="w-6 h-6 text-white" /> : <ImageIcon className="w-6 h-6 text-gray-400" />}
</div>
</div>
))}
{mediaMessages.length > 3 && (
<div className="aspect-square flex items-center justify-center bg-gray-50 border border-dashed border-gray-200 text-gray-400 text-xs rounded-sm">
+{mediaMessages.length - 3}
</div>
)}
</div>
) : (
<div className="px-8 pb-6 flex flex-col items-center justify-center text-center">
<p className="text-[14px] text-[#667781] italic">Nenhuma mídia compartilhada</p>
</div>
)}
</div>
{/* Actions Section */}
<div className="bg-white mb-2 shadow-sm py-2">
{[
{ icon: Bell, label: 'Silenciar notificações', color: '#111b21' },
{ icon: Star, label: 'Mensagens favoritas', color: '#111b21' },
{ icon: Clock, label: 'Mensagens temporárias', sub: 'Desativadas', color: '#111b21' },
].map((item, idx) => (
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
<item.icon className="w-5 h-5 text-[#8696a0]" />
<div className="flex flex-col">
<span className="text-[16px] text-[#111b21] font-normal leading-none">{item.label}</span>
{item.sub && <span className="text-[12px] text-[#667781] mt-1">{item.sub}</span>}
</div>
</button>
))}
</div>
<div className="bg-white mb-2 shadow-sm py-2">
{[
{ icon: Ban, label: `Bloquear ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
{ icon: ThumbsDown, label: `Denunciar ${chat.lead_name || 'Contato'}`, color: '#ea0038' },
{ icon: Trash2, label: 'Apagar conversa', color: '#ea0038' }
].map((item, idx) => (
<button key={idx} className="w-full px-8 py-3.5 flex items-center gap-6 hover:bg-[#f5f6f6] transition-colors text-left">
<item.icon className="w-5 h-5" style={{ color: item.color }} />
<span className="text-[16px] font-normal" style={{ color: item.color }}>{item.label}</span>
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
);
};
export default ContactProfile;
@@ -0,0 +1,244 @@
'use client';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Send, FileText, Film, Music, Archive, File, ZoomIn, ZoomOut } from 'lucide-react';
interface FilePreviewModalProps {
file: File | null;
onConfirm: (file: File, caption: string) => Promise<void>;
onCancel: () => void;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getFileIcon(type: string) {
if (type.startsWith('video/')) return <Film className="w-16 h-16 text-blue-400" />;
if (type.startsWith('audio/')) return <Music className="w-16 h-16 text-purple-400" />;
if (type.includes('pdf') || type.includes('document') || type.includes('word'))
return <FileText className="w-16 h-16 text-red-400" />;
if (type.includes('zip') || type.includes('compressed') || type.includes('tar'))
return <Archive className="w-16 h-16 text-yellow-400" />;
return <File className="w-16 h-16 text-[#8696a0]" />;
}
function getFileLabel(type: string): string {
if (type.startsWith('image/')) return 'Imagem';
if (type.startsWith('video/')) return 'Vídeo';
if (type.startsWith('audio/')) return 'Áudio';
if (type.includes('pdf')) return 'PDF';
return 'Arquivo';
}
function ModalContent({ file, onConfirm, onCancel }: FilePreviewModalProps & { file: File }) {
const [caption, setCaption] = useState('');
const [sending, setSending] = useState(false);
const [objectUrl, setObjectUrl] = useState<string | null>(null);
const [zoomed, setZoomed] = useState(false);
// Posição do mouse como % dentro da área da imagem (para o zoom seguir o cursor)
const [mouseOrigin, setMouseOrigin] = useState({ x: 50, y: 50 });
const captionRef = useRef<HTMLInputElement>(null);
const previewAreaRef = useRef<HTMLDivElement>(null);
const isImage = file.type.startsWith('image/');
const isVideo = file.type.startsWith('video/');
useEffect(() => {
const url = URL.createObjectURL(file);
setObjectUrl(url);
setCaption('');
setSending(false);
setZoomed(false);
setMouseOrigin({ x: 50, y: 50 });
setTimeout(() => captionRef.current?.focus(), 250);
return () => URL.revokeObjectURL(url);
}, [file]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onCancel]);
const handleSend = useCallback(async () => {
if (sending) return;
setSending(true);
try { await onConfirm(file, caption.trim()); }
finally { setSending(false); }
}, [file, caption, sending, onConfirm]);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { e.preventDefault(); handleSend(); }
};
// Atualiza a origem do zoom conforme o mouse se move na área de preview
const handleMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isImage) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
setMouseOrigin({ x, y });
}, [isImage]);
const toggleZoom = useCallback(() => setZoomed(z => !z), []);
return (
<motion.div
key="file-preview-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="fixed inset-0 flex flex-col bg-[#0b141a]"
style={{ zIndex: 99999 }}
>
{/* ── Top bar ── */}
<div className="flex items-center gap-3 px-4 py-3 bg-[#202c33] shrink-0 shadow-md">
{/* Info do arquivo — lado esquerdo */}
<div className="flex-1 min-w-0">
<p className="text-[#e9edef] text-sm font-medium truncate">{file.name}</p>
<p className="text-[#8696a0] text-xs">{getFileLabel(file.type)} · {formatFileSize(file.size)}</p>
</div>
{/* Botões — lado direito: zoom (se imagem) → fechar */}
<div className="flex items-center gap-1 shrink-0">
{isImage && (
<button
onClick={toggleZoom}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title={zoomed ? 'Zoom normal' : 'Ampliar'}
>
{zoomed ? <ZoomOut className="w-5 h-5" /> : <ZoomIn className="w-5 h-5" />}
</button>
)}
<button
onClick={onCancel}
className="p-2 rounded-full hover:bg-white/10 text-[#aebac1] transition-colors"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Preview area ── */}
<div
ref={previewAreaRef}
className="flex-1 flex items-center justify-center min-h-0 overflow-hidden p-4"
onMouseMove={handleMouseMove}
onClick={isImage ? toggleZoom : undefined}
style={{ cursor: isImage ? (zoomed ? 'zoom-out' : 'zoom-in') : 'default' }}
>
{isImage && objectUrl && (
<motion.img
src={objectUrl}
alt="preview"
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
draggable={false}
className="select-none rounded-lg shadow-2xl"
style={zoomed ? {
// Zoom 2.5× centrado no cursor
transform: `scale(2.5)`,
transformOrigin: `${mouseOrigin.x}% ${mouseOrigin.y}%`,
transition: 'transform-origin 0s', // origin segue o mouse instantaneamente
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-out',
} : {
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
objectFit: 'contain',
cursor: 'zoom-in',
transition: 'transform 0.2s ease',
transform: 'scale(1)',
transformOrigin: 'center center',
}}
/>
)}
{isVideo && objectUrl && (
<motion.video
src={objectUrl}
controls
initial={{ scale: 0.94, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
className="max-w-full max-h-full rounded-lg shadow-2xl"
style={{ maxHeight: 'calc(100vh - 160px)' }}
/>
)}
{!isImage && !isVideo && (
<motion.div
initial={{ scale: 0.92, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.22 }}
className="flex flex-col items-center gap-5 bg-[#202c33] rounded-2xl px-14 py-12 shadow-2xl max-w-sm w-full"
>
<div className="w-24 h-24 rounded-2xl bg-[#2a3942] flex items-center justify-center">
{getFileIcon(file.type)}
</div>
<div className="text-center space-y-1">
<p className="text-[#e9edef] text-sm font-medium break-all">{file.name}</p>
<p className="text-[#8696a0] text-xs">{formatFileSize(file.size)}</p>
</div>
</motion.div>
)}
</div>
{/* ── Caption + Send bar ── */}
<div className="shrink-0 bg-[#202c33] px-4 py-3 flex items-center gap-3 shadow-[0_-1px_0_rgba(255,255,255,0.06)]">
<input
ref={captionRef}
type="text"
placeholder="Adicionar legenda…"
value={caption}
onChange={(e) => setCaption(e.target.value)}
onKeyDown={handleKeyDown}
disabled={sending}
maxLength={1024}
className="flex-1 bg-[#2a3942] text-[#e9edef] placeholder:text-[#8696a0] text-sm rounded-full px-5 py-2.5 outline-none border-none focus:ring-0 disabled:opacity-50 transition-opacity"
/>
<button
onClick={handleSend}
disabled={sending}
className="w-12 h-12 bg-[#00a884] hover:bg-[#00c49a] disabled:opacity-60 rounded-full flex items-center justify-center transition-all shadow-lg active:scale-95 shrink-0"
>
{sending
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Send className="w-5 h-5 text-white translate-x-px" />
}
</button>
</div>
</motion.div>
);
}
export default function FilePreviewModal({ file, onConfirm, onCancel }: FilePreviewModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{file && (
<ModalContent
file={file}
onConfirm={onConfirm}
onCancel={onCancel}
/>
)}
</AnimatePresence>,
document.body
);
}
@@ -0,0 +1,159 @@
'use client';
import React from 'react';
import {
ArrowLeft,
Search,
MoreVertical,
RefreshCw,
Tag,
X,
ClipboardList,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import {
formatPhone,
} from '../utils/inboxUtils';
import Avatar from './ui/Avatar';
interface InboxHeaderProps {
selectedChat: any;
isMobile: boolean;
isTyping: boolean;
isChatMenuOpen: boolean;
isSyncingAvatar: boolean;
isProtocolOpen?: boolean;
hasActiveProtocol?: boolean;
onBack: () => void;
onSearchClick: () => void;
onToggleMenu: () => void;
onSyncAvatar: () => void;
onAvatarClick?: () => void;
onProtocolClick?: () => void;
}
const InboxHeader: React.FC<InboxHeaderProps> = ({
selectedChat,
isMobile,
isTyping,
isChatMenuOpen,
isSyncingAvatar,
isProtocolOpen,
hasActiveProtocol,
onBack,
onSearchClick,
onToggleMenu,
onSyncAvatar,
onAvatarClick,
onProtocolClick,
}) => {
return (
<header className="h-[60px] px-4 bg-[#f0f2f5] flex items-center justify-between border-b border-[#d1d7db] z-30 relative shrink-0">
<div className="flex items-center gap-3 flex-1 min-w-0">
{isMobile && (
<button
onClick={onBack}
className="p-1 -ml-1 mr-1 hover:bg-black/5 rounded-full transition-colors active:scale-90"
>
<ArrowLeft className="w-6 h-6 text-[#54656f]" />
</button>
)}
{/* Identity Area - Clickable */}
<div
onClick={onAvatarClick}
className="flex items-center gap-3 min-w-0 cursor-pointer group/header hover:bg-black/5 px-2 py-1 -ml-2 rounded-md transition-all active:scale-[0.98]"
>
{/* Avatar */}
<Avatar chat={selectedChat} size={40} className="flex-shrink-0" />
<div className="flex flex-col min-w-0">
<h3 className="text-[16px] leading-[21px] font-bold text-[#111b21] flex items-center gap-2 truncate group-hover/header:text-[#00a884] transition-colors">
{selectedChat.lead_name || selectedChat.subject || formatPhone(selectedChat.phone, selectedChat.remote_jid)}
</h3>
<div className="flex items-center gap-1 min-w-0">
{isTyping ? (
<span className="text-[13px] text-[#00a884] font-medium animate-pulse">
digitando...
</span>
) : (
<span className="text-[12.5px] text-[#667781] truncate max-w-[300px] font-normal">
{selectedChat.bio || (
selectedChat.remote_jid?.endsWith('@g.us')
? `Clique para dados do grupo`
: 'Clique para informações do contato'
)}
</span>
)}
</div>
</div>
</div>
</div>
<div className="flex items-center gap-1">
{onProtocolClick && (
<button
onClick={onProtocolClick}
className={`p-2 rounded-full transition-all relative ${isProtocolOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f] hover:text-[#111b21]'}`}
title="Protocolos de atendimento"
>
<ClipboardList className="w-5 h-5" />
{hasActiveProtocol && !isProtocolOpen && (
<span className="absolute top-1.5 right-1.5 w-2 h-2 rounded-full bg-brand-500 border border-white" />
)}
</button>
)}
<button
onClick={onSearchClick}
className="p-2 hover:bg-black/5 rounded-full transition-all text-[#54656f] hover:text-[#111b21]"
title="Procurar na conversa"
>
<Search className="w-5 h-5" />
</button>
<div className="relative">
<button
onClick={onToggleMenu}
className={`p-2 rounded-full transition-all ${isChatMenuOpen ? 'bg-black/10 text-[#111b21]' : 'hover:bg-black/5 text-[#54656f]'}`}
>
<MoreVertical className="w-5 h-5" />
</button>
<AnimatePresence>
{isChatMenuOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="absolute right-0 top-12 w-56 bg-white rounded-lg shadow-2xl border border-gray-100 py-2 z-50 overflow-hidden"
>
<button
onClick={() => {
onSyncAvatar();
onToggleMenu();
}}
disabled={isSyncingAvatar}
className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isSyncingAvatar ? 'animate-spin' : ''}`} />
Sincronizar Foto
</button>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] text-[#111b21] hover:bg-[#f5f6f6] transition-colors">
<Tag className="w-4 h-4" />
Gerenciar etiquetas
</button>
<div className="border-t border-gray-100 my-1"></div>
<button className="w-full flex items-center gap-3 px-4 py-2.5 text-[14px] font-bold text-red-600 hover:bg-red-50 transition-colors">
<X className="w-4 h-4" />
Limpar conversa
</button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</header>
);
};
export default InboxHeader;
@@ -0,0 +1,722 @@
'use client';
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import {
Smile,
Paperclip,
Mic,
Send,
X,
StopCircle,
Pause,
Play,
LayoutTemplate,
BookOpen,
Plus,
Sticker,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
// Satélite: emoji picker simples (evita a dep @emoji-mart, incompatível com react 19).
const EMOJI_SET = ['😀','😁','😂','🤣','😊','😍','😘','😎','🤔','😐','😴','😭','😡','👍','👎','🙏','👏','🙌','💪','🔥','🎉','❤️','💔','✅','❌','⚠️','📌','📎','📞','💬','⏰','🥳','😇','🤝','👀','💯'];
import { groupApi, type GroupParticipant, type MessageTemplate, type TemplateType } from '../services/chatApiService';
import RichMessageComposer from './RichMessageComposer';
import TemplateLibrary from './TemplateLibrary';
import ScheduleMessageModal from './ScheduleMessageModal';
import FilePreviewModal from './FilePreviewModal';
import StickerPanel from './StickerPanel';
import { mediaApi } from '../services/chatApiService';
interface Message {
id: number;
chat_id: number;
message_id: string;
text: string;
direction: 'in' | 'out';
}
interface InputBarProps {
newMessage: string;
onNewMessageChange: (val: string) => void;
onSend: (e?: React.FormEvent) => void;
onPresence: () => void;
onSendMedia: (file: File, caption?: string) => Promise<void>;
onSendAudio: (blob: Blob) => Promise<void>;
replyingTo: Message | null;
onCancelReply: () => void;
isMobile: boolean;
selectedChat: any;
instanceId?: string | null;
mentions?: string[];
onMentionsChange?: (mentions: string[]) => void;
}
const InputBar: React.FC<InputBarProps> = ({
newMessage,
onNewMessageChange,
onSend,
onPresence,
onSendMedia,
onSendAudio,
replyingTo,
onCancelReply,
isMobile,
selectedChat,
instanceId,
mentions = [],
onMentionsChange,
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [showStickerPanel, setShowStickerPanel] = useState(false);
const [showAttachMenu, setShowAttachMenu] = useState(false);
const stickerPanelRef = useRef<HTMLDivElement>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null);
const attachMenuRef = useRef<HTMLDivElement>(null);
const [showRichComposer, setShowRichComposer] = useState(false);
const [showTemplateLibrary, setShowTemplateLibrary] = useState(false);
const [richInitialPayload, setRichInitialPayload] = useState<Record<string, unknown> | undefined>();
const [richInitialType, setRichInitialType] = useState<TemplateType | undefined>();
const [showScheduleModal, setShowScheduleModal] = useState(false);
const [schedulingPayload, setSchedulingPayload] = useState<Record<string, unknown>>({});
const [schedulingType, setSchedulingType] = useState<TemplateType>('TEXT');
const emojiPickerRef = useRef<HTMLDivElement>(null);
// ── Mention autocomplete state ──────────────────────────────────────
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const [participants, setParticipants] = useState<GroupParticipant[]>([]);
const participantsCache = useRef<Record<string, GroupParticipant[]>>({});
const mentionDropdownRef = useRef<HTMLDivElement>(null);
const isGroupChat = selectedChat?.remote_jid?.endsWith('@g.us') || selectedChat?.jid?.endsWith('@g.us');
const groupJid = selectedChat?.remote_jid || selectedChat?.jid;
// Fetch group participants when group chat is selected
useEffect(() => {
if (!isGroupChat || !instanceId || !groupJid) {
setParticipants([]);
return;
}
if (participantsCache.current[groupJid]) {
setParticipants(participantsCache.current[groupJid]);
return;
}
groupApi.getParticipants(instanceId, groupJid)
.then((data) => {
participantsCache.current[groupJid] = data.participants;
setParticipants(data.participants);
})
.catch(() => setParticipants([]));
}, [isGroupChat, instanceId, groupJid]);
// Filter participants by mention query
const filteredParticipants = useMemo(() => {
if (mentionQuery === null || !isGroupChat) return [];
const q = mentionQuery.toLowerCase();
// Only show participants that have a displayable name or phone
return participants.filter((p) => {
const label = p.name || p.phone || '';
if (!label) return false;
return q === '' || label.toLowerCase().includes(q);
}).slice(0, 8);
}, [mentionQuery, participants, isGroupChat]);
// Detect @ trigger in text
const detectMentionQuery = useCallback((text: string, cursorPos: number) => {
if (!isGroupChat) { setMentionQuery(null); return; }
const before = text.slice(0, cursorPos);
const atMatch = before.match(/@([^\s@]*)$/);
if (atMatch) {
setMentionQuery(atMatch[1]);
setMentionIndex(0);
} else {
setMentionQuery(null);
}
}, [isGroupChat]);
const selectMention = useCallback((participant: GroupParticipant) => {
const textarea = textareaRef.current;
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const before = newMessage.slice(0, cursorPos);
const after = newMessage.slice(cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx === -1) return;
const displayName = participant.name || participant.phone;
const replacement = `@${displayName} `;
const newText = before.slice(0, atIdx) + replacement + after;
onNewMessageChange(newText);
setMentionQuery(null);
// Add JID to mentions list
if (onMentionsChange && !mentions.includes(participant.jid)) {
onMentionsChange([...mentions, participant.jid]);
}
// Restore cursor
requestAnimationFrame(() => {
if (textareaRef.current) {
const newCursorPos = atIdx + replacement.length;
textareaRef.current.selectionStart = newCursorPos;
textareaRef.current.selectionEnd = newCursorPos;
textareaRef.current.focus();
}
});
}, [newMessage, onNewMessageChange, mentions, onMentionsChange]);
// Audio recording states
const [isRecording, setIsRecording] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const [recordingDuration, setRecordingDuration] = useState(0);
const [micError, setMicError] = useState<string | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const timerRef = useRef<NodeJS.Timeout | null>(null);
// navigator.mediaDevices só existe em contextos seguros (HTTPS ou localhost)
const micAvailable = typeof window !== 'undefined' && !!window.isSecureContext && !!navigator.mediaDevices?.getUserMedia;
// Auto-expand textarea
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 150)}px`;
}
}, [newMessage]);
// Close emoji picker, sticker panel and attach menu on click outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
setShowEmojiPicker(false);
}
if (stickerPanelRef.current && !stickerPanelRef.current.contains(event.target as Node)) {
setShowStickerPanel(false);
}
if (attachMenuRef.current && !attachMenuRef.current.contains(event.target as Node)) {
setShowAttachMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSendSticker = useCallback(async (blob: Blob) => {
if (!instanceId || !selectedChat?.remote_jid) return;
setShowStickerPanel(false);
await mediaApi.sendSticker(instanceId, selectedChat.remote_jid, blob);
}, [instanceId, selectedChat]);
const handleKeyDown = (e: React.KeyboardEvent) => {
// Mention dropdown navigation
if (mentionQuery !== null && filteredParticipants.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setMentionIndex((prev) => (prev + 1) % filteredParticipants.length);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setMentionIndex((prev) => (prev - 1 + filteredParticipants.length) % filteredParticipants.length);
return;
}
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
selectMention(filteredParticipants[mentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setMentionQuery(null);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
onSend();
}
};
const onEmojiSelect = (emoji: any) => {
onNewMessageChange(newMessage + (emoji.native ?? ''));
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setPreviewFile(file);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handlePreviewConfirm = async (file: File, caption: string) => {
await onSendMedia(file, caption);
setPreviewFile(null);
};
const handlePreviewCancel = () => setPreviewFile(null);
const startRecording = async () => {
if (!micAvailable) {
setMicError('Áudio requer HTTPS. Acesse via https:// para gravar.');
setTimeout(() => setMicError(null), 4000);
return;
}
setMicError(null);
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const mimeType = mediaRecorder.mimeType || 'audio/webm'
const audioBlob = new Blob(audioChunksRef.current, { type: mimeType });
await onSendAudio(audioBlob);
stream.getTracks().forEach(track => track.stop());
};
mediaRecorder.start();
setIsRecording(true);
setIsPaused(false);
setRecordingDuration(0);
timerRef.current = setInterval(() => {
setRecordingDuration(prev => prev + 1);
}, 1000);
} catch (err: any) {
const denied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError';
const msg = denied
? 'Permissão de microfone negada.'
: 'Não foi possível acessar o microfone.';
setMicError(msg);
setTimeout(() => setMicError(null), 4000);
}
};
const togglePause = () => {
const mr = mediaRecorderRef.current
if (!mr) return
if (isPaused) {
mr.resume()
setIsPaused(false)
timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000)
} else {
mr.pause()
setIsPaused(true)
if (timerRef.current) clearInterval(timerRef.current)
}
}
const stopRecording = () => {
if (mediaRecorderRef.current && isRecording) {
// Se estava pausado, retoma antes de parar para capturar o último chunk
if (isPaused && mediaRecorderRef.current.state === 'paused') {
mediaRecorderRef.current.resume()
}
mediaRecorderRef.current.stop();
setIsRecording(false);
setIsPaused(false);
if (timerRef.current) clearInterval(timerRef.current);
}
};
const handleTemplateSelect = (template: MessageTemplate) => {
setRichInitialPayload(template.payload)
setRichInitialType(template.type)
setShowRichComposer(true)
}
const handleScheduleFromComposer = (type: TemplateType, payload: Record<string, unknown>) => {
setSchedulingType(type)
setSchedulingPayload(payload)
setShowRichComposer(false)
setShowScheduleModal(true)
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
return (
<>
<div className="relative z-20">
{/* Hidden File Input */}
<input
type="file"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
{/* Emoji Picker Popover */}
<AnimatePresence>
{showEmojiPicker && (
<motion.div
ref={emojiPickerRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-4 z-50 shadow-2xl rounded-2xl overflow-hidden border border-gray-100"
>
<div className="bg-white w-[280px] p-2 grid grid-cols-8 gap-1">
{EMOJI_SET.map((e) => (
<button
key={e}
type="button"
onClick={() => onEmojiSelect({ native: e })}
className="text-xl leading-none p-1 rounded-lg hover:bg-gray-100"
>
{e}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
{/* Sticker Panel Popover */}
<AnimatePresence>
{showStickerPanel && instanceId && (
<motion.div
ref={stickerPanelRef}
initial={{ opacity: 0, y: 10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.95 }}
className="absolute bottom-20 left-16 z-50"
>
<StickerPanel
instanceId={instanceId}
jid={selectedChat?.remote_jid ?? ''}
onSend={handleSendSticker}
/>
</motion.div>
)}
</AnimatePresence>
{/* Reply Preview */}
<AnimatePresence>
{replyingTo && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="mx-4 mb-2 bg-white/80 backdrop-blur-sm border-l-[4px] border-[#00a884] rounded-lg px-4 py-3 flex items-start justify-between gap-4 shadow-sm"
>
<div className="min-w-0">
<div className="text-[12px] font-medium text-[#00a884] mb-1">
{replyingTo.direction === 'out' ? 'Respondendo a você' : 'Respondendo ao contato'}
</div>
<div className="text-sm text-gray-500 font-medium truncate italic">
{replyingTo.text || 'Anexo/Mídia'}
</div>
</div>
<button
type="button"
className="bg-gray-100 hover:bg-gray-200 text-gray-500 p-1 rounded-full transition-colors"
onClick={onCancelReply}
>
<X className="w-3.5 h-3.5 stroke-[3px]" />
</button>
</motion.div>
)}
</AnimatePresence>
{/* Mic error banner */}
<AnimatePresence>
{micError && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.18 }}
className="mx-4 mb-1 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg flex items-center gap-2"
>
<Mic className="w-3.5 h-3.5 text-amber-500 flex-shrink-0" />
<span className="text-xs text-amber-700">{micError}</span>
</motion.div>
)}
</AnimatePresence>
{/* Main Input Bar */}
<div className="min-h-[62px] px-4 py-2 bg-transparent flex items-center gap-2 shrink-0">
{!isRecording ? (
<>
<div className="flex items-center gap-1">
{/* Grupo flutuante: + expande Anexar / Templates / Mensagem Rica */}
<div className="relative" ref={attachMenuRef}>
<button
onClick={() => setShowAttachMenu(v => !v)}
className={`p-2 rounded-full transition-all active:scale-95 ${showAttachMenu ? 'bg-[#00a884] text-white' : 'hover:bg-black/10 text-[#54656f]'}`}
title="Mais opções"
>
<motion.div
animate={{ rotate: showAttachMenu ? 45 : 0 }}
transition={{ duration: 0.18 }}
>
<Plus className="w-[26px] h-[26px]" />
</motion.div>
</button>
<AnimatePresence>
{showAttachMenu && (
<motion.div
initial={{ opacity: 0, y: 8, scale: 0.92 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 8, scale: 0.92 }}
transition={{ duration: 0.15 }}
className="absolute bottom-full left-0 mb-2 flex flex-col items-center gap-1 bg-white rounded-2xl shadow-2xl border border-gray-100 py-2 px-1.5"
>
{/* Anexar arquivo */}
<button
onClick={() => { fileInputRef.current?.click(); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Anexar arquivo"
>
<Paperclip className="w-[22px] h-[22px] rotate-45" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Anexar
</span>
</button>
{/* Biblioteca de templates */}
<button
onClick={() => { setShowTemplateLibrary(true); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Templates salvos"
>
<BookOpen className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Templates
</span>
</button>
{/* Mensagem rica */}
<button
onClick={() => { setRichInitialPayload(undefined); setRichInitialType(undefined); setShowRichComposer(true); setShowAttachMenu(false); }}
className="p-2.5 hover:bg-[#f0f2f5] rounded-xl transition-all text-[#54656f] active:scale-95 group relative"
title="Mensagem rica"
>
<LayoutTemplate className="w-[22px] h-[22px]" />
<span className="absolute left-full ml-2 whitespace-nowrap text-xs bg-[#111b21] text-white px-2 py-1 rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity">
Mensagem Rica
</span>
</button>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Emoji — mantém no lugar */}
<button
onClick={() => { setShowEmojiPicker(!showEmojiPicker); setShowStickerPanel(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showEmojiPicker ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Emojis"
>
<Smile className="w-[26px] h-[26px]" />
</button>
{/* Figurinhas */}
{instanceId && (
<button
onClick={() => { setShowStickerPanel(!showStickerPanel); setShowEmojiPicker(false); }}
className={`p-2 hover:bg-black/10 rounded-full transition-all active:scale-95 ${showStickerPanel ? 'text-[#00a884]' : 'text-[#54656f]'}`}
title="Figurinhas"
>
<Sticker className="w-[24px] h-[24px]" />
</button>
)}
</div>
<div className="flex-1 relative">
{/* Mention Autocomplete Dropdown */}
<AnimatePresence>
{mentionQuery !== null && filteredParticipants.length > 0 && (
<motion.div
ref={mentionDropdownRef}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.12 }}
className="absolute bottom-full left-0 right-0 mb-1 bg-white rounded-lg shadow-xl border border-gray-200 max-h-[240px] overflow-y-auto z-50"
>
{filteredParticipants.map((p, i) => (
<button
key={p.jid}
type="button"
className={`w-full px-3 py-2 flex items-center gap-2 text-left text-sm hover:bg-[#f0f2f5] transition-colors ${
i === mentionIndex ? 'bg-[#e9edef]' : ''
}`}
onMouseDown={(e) => {
e.preventDefault();
selectMention(p);
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="w-7 h-7 rounded-full bg-[#dfe5e7] flex items-center justify-center text-xs font-bold text-[#54656f] shrink-0">
{(p.name || p.phone || '?')[0].toUpperCase()}
</div>
<div className="min-w-0">
<div className="font-medium text-[#111b21] truncate">{p.name || p.phone}</div>
{p.name && <div className="text-[11px] text-[#667781] truncate">+{p.phone}</div>}
</div>
{p.admin && (
<span className="ml-auto text-[10px] bg-[#e7f8f2] text-[#00a884] px-1.5 py-0.5 rounded font-medium shrink-0">
{p.admin === 'superadmin' ? 'Admin' : 'Admin'}
</span>
)}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<label htmlFor="message-textarea" className="sr-only">Digite sua mensagem</label>
<textarea
id="message-textarea"
name="message-textarea"
ref={textareaRef}
rows={1}
placeholder="Digite uma mensagem"
className="w-full px-3 py-2.5 bg-white rounded-lg text-[15px] text-[#111b21] border-none focus:ring-0 focus:outline-none placeholder:text-[#667781] resize-none shadow-sm transition-all overflow-hidden leading-[1.4]"
value={newMessage}
onChange={(e) => {
onNewMessageChange(e.target.value);
onPresence();
detectMentionQuery(e.target.value, e.target.selectionStart);
}}
onKeyDown={handleKeyDown}
/>
</div>
<div className="transition-all flex items-center">
{newMessage.trim() ? (
<motion.button
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
onClick={() => onSend()}
className="p-2 text-[#54656f] hover:bg-black/10 rounded-full transition-all active:scale-90"
>
<Send className="w-[26px] h-[26px]" />
</motion.button>
) : (
<button
onClick={startRecording}
title={!micAvailable ? 'Requer HTTPS para gravar áudio' : 'Gravar áudio'}
className={`p-2 rounded-full transition-all active:scale-90 ${
!micAvailable
? 'text-[#aab7bf] cursor-not-allowed opacity-50'
: 'hover:bg-black/10 text-[#54656f]'
}`}
>
<Mic className="w-[26px] h-[26px]" />
</button>
)}
</div>
</>
) : (
<div className={`flex-1 flex items-center justify-between rounded-2xl px-4 py-2 border shadow-inner ${isPaused ? 'bg-amber-50/70 border-amber-200' : 'bg-red-50/50 border-red-100'}`}>
{/* Indicador + tempo */}
<div className="flex items-center gap-3">
{isPaused ? (
<div className="w-3 h-3 bg-amber-400 rounded-full" />
) : (
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping" />
)}
<span className={`text-sm font-bold tracking-wider ${isPaused ? 'text-amber-600' : 'text-red-600'}`}>
{isPaused ? 'PAUSADO' : 'GRAVANDO'} {formatDuration(recordingDuration)}
</span>
</div>
{/* Controles */}
<div className="flex items-center gap-1.5">
{/* Cancelar */}
<button
onClick={() => {
const mr = mediaRecorderRef.current
if (mr) {
mr.onstop = null;
mr.stream?.getTracks().forEach(t => t.stop());
if (mr.state !== 'inactive') mr.stop();
setIsRecording(false);
setIsPaused(false);
if (timerRef.current) clearInterval(timerRef.current);
}
}}
className="p-2 hover:bg-red-100 rounded-full text-red-400 transition-colors"
title="Cancelar"
>
<X className="w-5 h-5" />
</button>
{/* Pause / Resume */}
<button
onClick={togglePause}
className="p-2 hover:bg-black/10 rounded-full text-[#54656f] transition-colors"
title={isPaused ? 'Continuar gravação' : 'Pausar gravação'}
>
{isPaused
? <Play className="w-5 h-5 fill-current text-amber-500" />
: <Pause className="w-5 h-5 fill-current" />
}
</button>
{/* Parar e Enviar */}
<button
onClick={stopRecording}
className="p-2 bg-red-500 hover:bg-red-600 text-white rounded-full transition-all shadow-md active:scale-90"
title="Parar e Enviar"
>
<StopCircle className="w-5 h-5 fill-current" />
</button>
</div>
</div>
)}
</div>
</div>
{/* ── Modal de preview de arquivo ─────────────────────── */}
<FilePreviewModal
file={previewFile}
onConfirm={handlePreviewConfirm}
onCancel={handlePreviewCancel}
/>
{/* ── Modais de mensagem rica ────────────────────────────── */}
<RichMessageComposer
isOpen={showRichComposer}
onClose={() => setShowRichComposer(false)}
instanceId={instanceId ?? null}
jid={selectedChat?.remote_jid ?? selectedChat?.jid ?? ''}
onSent={() => setShowRichComposer(false)}
onSchedule={handleScheduleFromComposer}
initialPayload={richInitialPayload}
initialType={richInitialType}
/>
<TemplateLibrary
isOpen={showTemplateLibrary}
onClose={() => setShowTemplateLibrary(false)}
onSelect={handleTemplateSelect}
/>
<ScheduleMessageModal
isOpen={showScheduleModal}
onClose={() => setShowScheduleModal(false)}
payload={schedulingPayload}
messageType={schedulingType}
onSaved={() => setShowScheduleModal(false)}
/>
</>
);
};
export default InputBar;
@@ -0,0 +1,143 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Globe } from 'lucide-react';
// Satélite: a ext API não expõe /link-preview — stub que rejeita (o componente
// cai para "sem preview" no catch, sem quebrar a renderização).
const api = { get: (_url: string): Promise<any> => Promise.reject(new Error('no link-preview')) };
interface PreviewData {
title: string;
description: string;
image: string;
url: string;
domain: string;
}
// Simple in-memory cache so the same URL is only fetched once per page load
const cache = new Map<string, PreviewData | null>();
interface LinkPreviewProps {
url: string;
isOut: boolean;
}
const LinkPreview: React.FC<LinkPreviewProps> = ({ url, isOut }) => {
const [data, setData] = useState<PreviewData | null>(cache.has(url) ? cache.get(url)! : undefined as any);
const [loading, setLoading] = useState(!cache.has(url));
useEffect(() => {
if (cache.has(url)) {
setData(cache.get(url) ?? null);
setLoading(false);
return;
}
let cancelled = false;
setLoading(true);
api.get(`/link-preview?url=${encodeURIComponent(url)}`)
.then((d: PreviewData) => {
if (cancelled) return;
const value = d?.title ? d : null;
cache.set(url, value);
setData(value);
})
.catch(() => {
if (!cancelled) {
cache.set(url, null);
setData(null);
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, [url]);
if (loading) {
return (
<div className={`mt-2 rounded-xl overflow-hidden border animate-pulse ${isOut ? 'border-green-200' : 'border-gray-100'}`}>
<div className={`h-24 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
<div className="p-2.5 space-y-1.5">
<div className={`h-2 rounded w-1/3 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
<div className={`h-3 rounded w-4/5 ${isOut ? 'bg-green-200' : 'bg-gray-200'}`} />
<div className={`h-2 rounded w-3/5 ${isOut ? 'bg-green-100' : 'bg-gray-100'}`} />
</div>
</div>
);
}
if (!data) return null;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className={`mt-2 block rounded-xl overflow-hidden border transition-all hover:shadow-md active:scale-[0.98] no-underline ${isOut
? 'border-green-200 bg-white/70 hover:bg-white/90'
: 'border-gray-200 bg-gray-50 hover:bg-white'
}`}
>
{data.image && (
<img
src={data.image}
alt={data.title}
className="w-full max-h-[160px] object-cover"
onError={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
)}
<div className="px-3 py-2.5">
<div className="flex items-center gap-1 text-[10px] text-gray-400 font-bold uppercase tracking-wider mb-1">
<Globe className="w-3 h-3 shrink-0" />
<span className="truncate">{data.domain}</span>
</div>
{data.title && (
<p className="text-[13px] font-bold text-gray-800 leading-tight line-clamp-2">
{data.title}
</p>
)}
{data.description && (
<p className="text-[11px] text-gray-500 mt-0.5 leading-snug line-clamp-2">
{data.description}
</p>
)}
</div>
</a>
);
};
// URL extraction helper (exported for use in MessageBubble)
const URL_RE = /https?:\/\/[^\s<>"{}|\\^`[\]]{4,}/;
export const extractFirstUrl = (text: string | null | undefined): string | null => {
if (!text) return null;
const m = text.match(URL_RE);
return m ? m[0] : null;
};
// Renders plain text with URLs converted to clickable <a> tags
export const renderTextWithLinks = (text: string): React.ReactNode[] => {
const parts = text.split(/(https?:\/\/[^\s<>"{}|\\^`[\]]{4,})/g);
return parts.map((part, i) => {
if (/^https?:\/\//.test(part)) {
return (
<a
key={i}
href={part}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-blue-600 underline break-all hover:text-blue-700"
>
{part}
</a>
);
}
return <span key={i}>{part}</span>;
});
};
export default LinkPreview;
@@ -0,0 +1,260 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import {
X,
Download,
Reply,
Star,
ZoomIn,
ZoomOut,
ChevronLeft,
ChevronRight,
Video,
} from 'lucide-react';
import { Message } from '../types/inboxTypes';
import { getMediaUrl } from '../utils/inboxUtils';
import { format } from 'date-fns';
import { parseSafeDate } from '../utils/inboxUtils';
interface MediaViewerProps {
msgs: Message[]
currentMsgId: string
onClose: () => void
onReply?: (msg: Message) => void
onStar?: (msg: Message) => void
}
const MediaViewer: React.FC<MediaViewerProps> = ({ msgs, currentMsgId, onClose, onReply, onStar }) => {
const [currentIdx, setCurrentIdx] = useState(() => {
const idx = msgs.findIndex(m => m.message_id === currentMsgId)
return idx >= 0 ? idx : 0
})
const [zoom, setZoom] = useState(1)
const filmstripRef = useRef<HTMLDivElement>(null)
const currentMsg = msgs[currentIdx]
const currentUrl = getMediaUrl(currentMsg?.media_url)
const goNext = useCallback(() => {
setCurrentIdx(i => Math.min(i + 1, msgs.length - 1))
setZoom(1)
}, [msgs.length])
const goPrev = useCallback(() => {
setCurrentIdx(i => Math.max(i - 1, 0))
setZoom(1)
}, [])
// Keyboard: ← → Esc
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowRight') goNext()
if (e.key === 'ArrowLeft') goPrev()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [onClose, goNext, goPrev])
// Scroll filmstrip to keep current thumbnail visible
useEffect(() => {
const el = filmstripRef.current
if (!el) return
const thumb = el.children[currentIdx] as HTMLElement
if (thumb) thumb.scrollIntoView({ inline: 'center', block: 'nearest', behavior: 'smooth' })
}, [currentIdx])
if (!currentMsg) return null
const isVideo = currentMsg.type === 'video'
const isImage = currentMsg.type === 'image'
const caption = currentMsg.text?.trim() || null
const timeStr = currentMsg.timestamp ? format(parseSafeDate(currentMsg.timestamp), 'dd/MM/yyyy HH:mm') : ''
const senderLabel = currentMsg.direction === 'out' ? 'Você' : 'Contato'
const viewer = (
<div className="fixed inset-0 z-[9999] flex flex-col bg-[#111b21]">
{/* ── Top bar ─────────────────────────────────────────── */}
<div className="flex items-center justify-between px-2 py-1.5 bg-[#202c33] shrink-0">
{/* Left: back + sender info */}
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Fechar"
>
<ChevronLeft className="w-5 h-5" />
</button>
<div className="leading-tight">
<div className="text-white text-[13.5px] font-medium">{senderLabel}</div>
<div className="text-[#aebac1] text-[11px]">{timeStr}</div>
</div>
</div>
{/* Right: actions */}
<div className="flex items-center">
{isImage && (
<>
<button
onClick={() => setZoom(z => Math.min(z + 0.3, 4))}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Aumentar zoom"
>
<ZoomIn className="w-5 h-5" />
</button>
<button
onClick={() => setZoom(z => Math.max(z - 0.3, 0.4))}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Diminuir zoom"
>
<ZoomOut className="w-5 h-5" />
</button>
</>
)}
<button
onClick={() => { onReply?.(currentMsg); onClose(); }}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Responder"
>
<Reply className="w-5 h-5" />
</button>
<button
onClick={() => onStar?.(currentMsg)}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Favoritar"
>
<Star className="w-5 h-5" />
</button>
{currentUrl && (
<a
href={currentUrl}
download
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Baixar"
>
<Download className="w-5 h-5" />
</a>
)}
<button
onClick={onClose}
className="p-2 text-[#aebac1] hover:text-white rounded-full hover:bg-white/10 transition-colors"
title="Fechar"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* ── Main content ─────────────────────────────────────── */}
<div
className="flex-1 flex items-center justify-center relative overflow-hidden"
onClick={onClose}
>
{/* Left arrow */}
{currentIdx > 0 && (
<button
onClick={e => { e.stopPropagation(); goPrev() }}
className="absolute left-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
{/* Media */}
<div className="max-w-full max-h-full flex items-center justify-center" onClick={e => e.stopPropagation()}>
{!currentUrl ? (
<div className="text-[#aebac1] text-sm">Mídia não disponível</div>
) : isVideo ? (
<video
src={currentUrl}
controls
autoPlay
className="max-w-full rounded"
style={{ maxHeight: 'calc(100vh - 160px)' }}
/>
) : (
<img
src={currentUrl}
alt={caption || 'Imagem'}
draggable={false}
className="object-contain select-none rounded"
style={{
maxWidth: '100%',
maxHeight: 'calc(100vh - 160px)',
transform: `scale(${zoom})`,
transition: 'transform 0.18s ease',
cursor: zoom > 1 ? 'zoom-out' : 'default',
}}
onClick={e => { e.stopPropagation(); if (zoom > 1) setZoom(1) }}
/>
)}
</div>
{/* Right arrow */}
{currentIdx < msgs.length - 1 && (
<button
onClick={e => { e.stopPropagation(); goNext() }}
className="absolute right-4 z-10 w-11 h-11 bg-black/50 hover:bg-black/80 text-white rounded-full flex items-center justify-center transition-colors shadow-lg"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
{/* Caption */}
{caption && (
<div className="absolute bottom-2 inset-x-0 flex justify-center pointer-events-none">
<span className="bg-black/60 text-white text-[13px] px-4 py-1.5 rounded-lg max-w-[70%] text-center leading-snug">
{caption}
</span>
</div>
)}
</div>
{/* ── Bottom filmstrip ─────────────────────────────────── */}
{msgs.length > 1 && (
<div className="shrink-0 bg-[#202c33] py-2 px-3">
<div
ref={filmstripRef}
className="flex gap-1.5 overflow-x-auto items-center"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{msgs.map((m, i) => {
const url = getMediaUrl(m.media_url)
const isVid = m.type === 'video'
const isActive = i === currentIdx
return (
<button
key={m.message_id}
onClick={() => { setCurrentIdx(i); setZoom(1) }}
className={`relative shrink-0 w-[58px] h-[58px] rounded overflow-hidden border-2 transition-all ${
isActive
? 'border-white opacity-100'
: 'border-transparent opacity-50 hover:opacity-80'
}`}
>
{url ? (
isVid ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<Video className="w-5 h-5 text-white fill-white" />
</div>
) : (
<img src={url} alt="" className="w-full h-full object-cover" />
)
) : (
<div className="w-full h-full bg-[#2a3942]" />
)}
</button>
)
})}
</div>
</div>
)}
</div>
)
return typeof document !== 'undefined' ? createPortal(viewer, document.body) : null
}
export default MediaViewer
@@ -0,0 +1,268 @@
'use client';
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { ChevronDown, ArrowDown } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import MessageBubble from './MessageBubble';
import MediaViewer from './MediaViewer';
import { isSameDay, format, isToday, isYesterday } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { normalizeJid, parseSafeDate } from '../utils/inboxUtils';
import { Message } from '../types/inboxTypes';
import { mediaApi } from '../services/chatApiService';
// Message interface removed and moved to types.ts
interface MessageAreaProps {
messages: Message[];
selectedChat: any;
scrollRef: React.RefObject<HTMLDivElement>;
onScroll: () => void;
msgLoading: boolean;
historyLoadingMore: boolean;
hasMoreHistory: boolean;
reactionPickerFor: string | null;
onReply: (msg: Message) => void;
onToggleReactionPicker: (msgId: string) => void;
onReact: (msg: Message, emoji: string) => void;
onForward?: (msg: Message) => void;
onDelete?: (msg: Message) => void;
onStar?: (msg: Message) => void;
onEdit?: (msg: Message) => void;
quickReactions: string[];
getGroupSenderLabel: (msg: Message) => string;
}
const MessageArea: React.FC<MessageAreaProps> = ({
messages,
selectedChat,
scrollRef,
onScroll,
msgLoading,
historyLoadingMore,
hasMoreHistory,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
quickReactions,
getGroupSenderLabel,
}) => {
const [showScrollBottom, setShowScrollBottom] = useState(false);
const lastScrollTop = useRef(0);
const needsInitialScroll = useRef(false);
// ── Media viewer state ────────────────────────────────────────────────────
const [viewerMsgId, setViewerMsgId] = useState<string | null>(null)
// ── Re-download de mídia não baixada ─────────────────────────────────────
const handleRedownloadMedia = useCallback(async (msg: Message) => {
// selectedChat no formato legado usa instance_name (não instanceId)
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
if (!instanceId || !msg.id) return
await mediaApi.redownload(instanceId, String(msg.id))
}, [selectedChat?.instance_name, selectedChat?.instanceId])
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
const handleInternalScroll = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const isAtBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 100;
setShowScrollBottom(!isAtBottom);
// Trigger parent onScroll (for infinite loading etc)
onScroll();
lastScrollTop.current = el.scrollTop;
}, [onScroll, scrollRef]);
const scrollToBottom = (smooth = true) => {
const el = scrollRef.current;
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'instant' });
}
};
const formatDateSeparator = (date: Date) => {
try {
if (isToday(date)) return 'Hoje';
if (isYesterday(date)) return 'Ontem';
return format(date, "d 'de' MMMM 'de' yyyy", { locale: ptBR });
} catch (e) {
return 'Histórico';
}
};
const isGroupChat = Boolean(selectedChat?.remote_jid?.endsWith('@g.us'));
const chatId = selectedChat?.remote_jid ?? null;
// Marca que precisa scroll ao fundo quando trocar de chat
useEffect(() => {
needsInitialScroll.current = true;
}, [chatId]);
// Auto-scroll: roda sempre que messages muda
useEffect(() => {
const el = scrollRef.current;
if (!el || messages.length === 0) return;
if (needsInitialScroll.current) {
// Carga inicial após selectChat → scroll instantâneo ao fundo
needsInitialScroll.current = false;
// Triplo rAF: aguarda React render + layout + paint
requestAnimationFrame(() => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
});
});
} else {
// Nova mensagem em tempo real → só rola se já estiver perto do fundo
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 250;
if (isNearBottom) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
});
}
}
}, [messages, scrollRef]);
return (
<>
<div className="flex-1 relative flex flex-col min-h-0 bg-transparent overflow-hidden">
<div
className="flex-1 overflow-y-auto px-4 md:px-14 py-6 flex flex-col custom-scrollbar relative z-10"
ref={scrollRef}
onScroll={handleInternalScroll}
>
{/* Loader for history */}
{historyLoadingMore && (
<div className="flex justify-center py-4">
<div className="w-5 h-5 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
</div>
)}
{(() => {
const seen = new Set<string>();
return messages
.filter(m => {
// Reactions não são mensagens visuais — são metadados
if (m.type === 'reaction') return false;
const key = m.message_id || m.id?.toString();
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
})
.map((msg, idx) => {
const msgDate = parseSafeDate(msg.timestamp);
const prevMsg = messages[idx - 1];
const prevDate = prevMsg ? parseSafeDate(prevMsg.timestamp) : null;
const showDateSeparator = !prevDate || !isSameDay(prevDate, msgDate);
// Authentic WA Tail Logic: Show tail on the first message of a sequence from the same sender
const isSameSenderAsPrev = prevMsg && (prevMsg.direction === msg.direction);
const showTail = showDateSeparator || !isSameSenderAsPrev;
const senderLabel = isGroupChat ? getGroupSenderLabel(msg) : '';
// Resolve Quoted Sender
let quotedSenderLabel = 'Mensagem';
if (msg.quoted_participant) {
const qp = normalizeJid(msg.quoted_participant);
const chatJid = normalizeJid(selectedChat?.remote_jid || '');
if (qp === chatJid) quotedSenderLabel = selectedChat?.subject || 'Contato';
else quotedSenderLabel = 'Você';
}
return (
<React.Fragment key={msg.message_id || msg.id}>
{showDateSeparator && (
<div className="flex justify-center my-6">
<span className="px-3 py-1 bg-white text-[#54656f] text-[12.5px] font-normal uppercase tracking-tight rounded-lg shadow-sm border border-[#d1d7db]">
{formatDateSeparator(msgDate)}
</span>
</div>
)}
<MessageBubble
msg={msg}
isGroupChat={isGroupChat}
senderLabel={senderLabel}
showTail={showTail}
quotedSenderLabel={quotedSenderLabel}
quotedPreviewText={msg.quoted_message_text || 'Mídia'}
reactionPickerFor={reactionPickerFor}
onReply={onReply}
onToggleReactionPicker={onToggleReactionPicker}
onReact={onReact}
onForward={onForward}
onDelete={onDelete}
onStar={onStar}
onEdit={onEdit}
quickReactions={quickReactions}
selectedChat={selectedChat}
onOpenMedia={(m) => setViewerMsgId(m.message_id)}
onRedownloadMedia={handleRedownloadMedia}
/>
</React.Fragment>
);
});
})()}
{/* Bottom Anchor */}
<div className="h-2 w-full shrink-0" />
</div>
{/* Scroll to Bottom Button */}
<AnimatePresence>
{showScrollBottom && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 10 }}
onClick={() => scrollToBottom()}
className="absolute bottom-6 right-6 w-11 h-11 bg-white text-[#54656f] rounded-full shadow-lg flex items-center justify-center hover:bg-[#f0f2f5] transition-all z-30 border border-[#d1d7db] active:scale-90"
>
<ArrowDown className="w-5 h-5" />
{selectedChat?.unread_count > 0 && (
<span className="absolute -top-1 -right-1 bg-[#00a884] text-white text-[10px] min-w-[20px] h-5 px-1 flex items-center justify-center rounded-full font-bold border-2 border-white shadow-sm">
{selectedChat.unread_count}
</span>
)}
</motion.button>
)}
</AnimatePresence>
{/* Initial Loading Overlay */}
{msgLoading && messages.length === 0 && (
<div className="absolute inset-0 bg-white/50 backdrop-blur-sm z-50 flex items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-[3px] border-[#00a884]/20 border-t-[#00a884] rounded-full animate-spin" />
<span className="text-[12px] font-medium text-[#667781]">Carregando mensagens...</span>
</div>
</div>
)}
</div>
{/* ── Media viewer portal ───────────────────────────────────────────── */}
{viewerMsgId && mediaMessages.length > 0 && (
<MediaViewer
msgs={mediaMessages}
currentMsgId={viewerMsgId}
onClose={() => setViewerMsgId(null)}
onReply={onReply}
onStar={onStar}
/>
)}
</>
);
};
export default MessageArea;
@@ -0,0 +1,996 @@
'use client';
import React, { useMemo, useState, useRef, useCallback } from 'react';
import {
Check,
CheckCheck,
Clock,
AlertCircle,
ImageIcon,
Video,
FileText,
Mic,
Reply,
MoreHorizontal,
ChevronDown,
Forward,
Star,
Trash2,
Link,
Copy,
Phone,
List,
ChevronRight,
BarChart2,
X,
Download,
} from 'lucide-react';
import { createPortal } from 'react-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { Message, RichPayload, NativeBtn } from '../types/inboxTypes';
import { normalizeJid, formatPhone, getMediaUrl, parseSafeDate } from '../utils/inboxUtils';
import LinkPreview, { extractFirstUrl, renderTextWithLinks } from './LinkPreview';
import Avatar from './ui/Avatar';
// ── AudioBubble ───────────────────────────────────────────────────────────────
interface AudioBubbleProps {
msg: Message
fullMediaUrl: string | null
isOut: boolean
TimestampRow: React.ReactNode
}
const WAVEFORM_BARS = 30
const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, TimestampRow }) => {
const audioRef = useRef<HTMLAudioElement>(null)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [speed, setSpeed] = useState<1 | 1.5 | 2>(1)
const [played, setPlayed] = useState(false)
const toggle = useCallback(() => {
const el = audioRef.current
if (!el) return
if (playing) {
el.pause()
setPlaying(false)
} else {
el.play().catch(() => {})
setPlaying(true)
setPlayed(true)
}
}, [playing])
const cycleSpeed = useCallback(() => {
const el = audioRef.current
const next: 1 | 1.5 | 2 = speed === 1 ? 1.5 : speed === 1.5 ? 2 : 1
setSpeed(next)
if (el) el.playbackRate = next
}, [speed])
const formatTime = (s: number) => {
if (!isFinite(s) || s <= 0) return '0:00'
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m}:${sec.toString().padStart(2, '0')}`
}
const progress = duration > 0 ? currentTime / duration : 0
// Incoming unread PTT = green; played or outgoing = blue
const isPtt = msg.type === 'ptt'
const accent = isPtt && !isOut && !played ? '#00a884' : '#53bdeb'
// Deterministic waveform heights seeded from message_id
const waveHeights = useMemo(() => {
const seed = msg.message_id.split('').reduce((a, c) => (a + c.charCodeAt(0)) % 97, 0)
return Array.from({ length: WAVEFORM_BARS }, (_, i) => {
const v = Math.abs(Math.sin((i + seed) * 0.85)) * 0.55 + Math.abs(Math.sin((i * 2.3 + seed) * 0.4)) * 0.45
return Math.max(0.15, Math.min(1, v))
})
}, [msg.message_id])
return (
<div>
<audio
ref={audioRef}
src={fullMediaUrl || undefined}
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
onDurationChange={() => setDuration(audioRef.current?.duration ?? 0)}
onEnded={() => { setPlaying(false); setCurrentTime(0) }}
preload="metadata"
/>
<div className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px]">
{/* Play/pause */}
<button
onClick={toggle}
className="w-10 h-10 rounded-full flex items-center justify-center shrink-0 shadow-sm transition-transform active:scale-95"
style={{ backgroundColor: accent }}
>
{playing ? (
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<rect x="5" y="3" width="4" height="18" rx="1" />
<rect x="15" y="3" width="4" height="18" rx="1" />
</svg>
) : (
<svg className="w-4 h-4 fill-white" viewBox="0 0 24 24">
<polygon points="6,3 20,12 6,21" />
</svg>
)}
</button>
{/* Speed — antes do waveform, alinhado ao centro vertical */}
<button
onClick={cycleSpeed}
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none shrink-0 w-7 text-center"
>
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
</button>
{/* Waveform + tempo */}
<div className="flex-1 flex flex-col gap-1 min-w-0">
<div className="flex items-end gap-[2px] h-7">
{waveHeights.map((h, i) => (
<div
key={i}
className="flex-1 rounded-full transition-colors duration-100"
style={{
height: `${h * 100}%`,
backgroundColor: i / WAVEFORM_BARS <= progress ? accent : '#cdd4da',
}}
/>
))}
</div>
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
</span>
</div>
</div>
{msg.transcription && (
<p className="mt-1.5 text-[12.5px] italic text-[#54656f] whitespace-pre-wrap leading-snug max-w-[260px]">
🎤 {msg.transcription}
</p>
)}
{TimestampRow}
</div>
)
}
// ── DocumentBubble ────────────────────────────────────────────────────────────
interface DocumentBubbleProps {
msg: Message
fullMediaUrl: string | null
TimestampRow: React.ReactNode
onRedownload?: () => void
isRedownloading?: boolean
mediaUnavailable?: boolean
}
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
pdf: { bg: '#e53e3e', label: 'PDF' },
doc: { bg: '#2b6cb0', label: 'W' },
docx: { bg: '#2b6cb0', label: 'W' },
xls: { bg: '#276749', label: 'X' },
xlsx: { bg: '#276749', label: 'X' },
ppt: { bg: '#c05621', label: 'P' },
pptx: { bg: '#c05621', label: 'P' },
txt: { bg: '#718096', label: 'TXT' },
csv: { bg: '#276749', label: 'CSV' },
}
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading, mediaUnavailable }) => {
const [previewOpen, setPreviewOpen] = useState(false)
// Derive extension from media_type or media_url
const ext = (() => {
const fromMime = msg.media_type?.split('/').pop()?.replace(/[^a-z0-9]/gi, '') ?? ''
const fromUrl = fullMediaUrl?.split('?')[0].split('.').pop()?.toLowerCase() ?? ''
return (fromMime || fromUrl || 'file').toLowerCase().slice(0, 5)
})()
const docType = DOC_TYPES[ext] ?? { bg: '#00a884', label: ext.toUpperCase().slice(0, 3) || 'FILE' }
// Filename: prefer msg.text; fallback to last segment of URL
const filename = msg.text?.trim() ||
fullMediaUrl?.split('?')[0].split('/').pop()?.replace(/^\d+-/, '') ||
'Documento'
const sizeStr = msg.media_size && msg.media_size > 0
? msg.media_size >= 1024 * 1024
? `${(msg.media_size / 1024 / 1024).toFixed(1)} MB`
: `${Math.ceil(msg.media_size / 1024)} KB`
: null
const isPdf = ext === 'pdf'
const handleClick = () => {
if (mediaUnavailable) return
if (!fullMediaUrl) {
onRedownload?.()
return
}
if (isPdf) {
setPreviewOpen(true)
} else {
window.open(fullMediaUrl, '_blank')
}
}
return (
<>
<div>
<div
onClick={handleClick}
className={`flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 transition-all min-w-[220px] max-w-[280px] ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed] active:scale-[0.98]'}`}
>
{/* Type icon */}
<div
className="w-10 h-10 rounded-lg flex items-center justify-center shrink-0 shadow-sm"
style={{ backgroundColor: docType.bg }}
>
{isRedownloading
? <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <span className="text-white text-[10px] font-extrabold tracking-tight leading-none">{docType.label}</span>
}
</div>
{/* Filename + meta */}
<div className="flex flex-col min-w-0 flex-1">
<span className="text-[13.5px] font-normal truncate text-[#111b21] leading-snug">
{filename}
</span>
<span className="text-[11px] text-[#667781] mt-[2px]">
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Arquivo não disponível' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
</span>
</div>
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
</div>
{TimestampRow}
</div>
{/* PDF inline preview modal — rendered via portal to escape framer-motion stacking context */}
{previewOpen && fullMediaUrl && typeof document !== 'undefined' && createPortal(
<div
className="fixed inset-0 z-[9999] flex flex-col bg-black/80 backdrop-blur-sm"
onClick={(e) => { if (e.target === e.currentTarget) setPreviewOpen(false) }}
>
<div className="flex items-center justify-between px-4 py-2 bg-[#111b21] shrink-0">
<span className="text-white text-[13px] font-medium truncate max-w-[70%]">{filename}</span>
<div className="flex items-center gap-3">
<a
href={fullMediaUrl}
download={filename}
className="text-[#8696a0] hover:text-white transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Download className="w-5 h-5" />
</a>
<button
onClick={() => setPreviewOpen(false)}
className="text-[#8696a0] hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<iframe
src={fullMediaUrl}
className="flex-1 w-full border-0"
title={filename}
/>
</div>,
document.body
)}
</>
)
}
// Message interface removed and moved to types.ts
interface MessageBubbleProps {
msg: Message;
isGroupChat: boolean;
senderLabel: string;
showTail?: boolean;
quotedSenderLabel?: string;
quotedPreviewText?: string;
reactionPickerFor: string | null;
onReply: (msg: Message) => void;
onToggleReactionPicker: (msgId: string) => void;
onReact: (msg: Message, emoji: string) => void;
onForward?: (msg: Message) => void;
onDelete?: (msg: Message) => void;
onStar?: (msg: Message) => void;
onEdit?: (msg: Message) => void;
onOpenMedia?: (msg: Message) => void;
onRedownloadMedia?: (msg: Message) => Promise<void>;
quickReactions: string[];
selectedChat?: any;
}
const MessageBubble: React.FC<MessageBubbleProps> = ({
msg,
isGroupChat,
senderLabel,
quotedSenderLabel,
quotedPreviewText,
reactionPickerFor,
onReply,
onToggleReactionPicker,
onReact,
onForward,
onDelete,
onStar,
onEdit,
onOpenMedia,
onRedownloadMedia,
quickReactions,
selectedChat,
showTail = false,
}) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const [listOpen, setListOpen] = useState(false);
const [isRedownloading, setIsRedownloading] = useState(false);
const [mediaUnavailable, setMediaUnavailable] = useState(false);
const handleRedownload = useCallback(async () => {
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
setIsRedownloading(true)
try {
await onRedownloadMedia(msg)
} catch (err: any) {
const status = err?.response?.status ?? err?.status
if (status === 400 || status === 404) setMediaUnavailable(true)
} finally {
setIsRedownloading(false)
}
}, [onRedownloadMedia, msg, isRedownloading, mediaUnavailable])
const isOut = msg.direction === 'out';
const showActionsLeft = isOut;
const showActionsRight = !isOut;
// variant: 'dark' = texto escuro (balão claro), 'white' = overlay sobre imagem
const renderMessageStatus = (status: string, variant: 'dark' | 'white' = 'dark') => {
const c = variant === 'white'
? { pending: 'text-white/50', sent: 'text-white/70', check2: 'text-white/70', read: 'text-[#53bdeb]', fail: 'text-red-300' }
: { pending: 'text-[#8696a0]/60', sent: 'text-[#8696a0]', check2: 'text-[#8696a0]', read: 'text-[#53bdeb]', fail: 'text-[#ef4444]' }
switch (status) {
case 'pending':
// Mensagem enfileirada — ainda não chegou ao servidor
return <Clock className={`w-3 h-3 ${c.pending}`} />
case 'sent':
// Chegou ao servidor — não sabemos se foi entregue
return <Check className={`w-3.5 h-3.5 ${c.sent}`} />
case 'delivered':
// Entregue no dispositivo do destinatário
return <CheckCheck className={`w-3.5 h-3.5 ${c.check2}`} />
case 'read':
// Lido pelo destinatário
return <CheckCheck className={`w-3.5 h-3.5 ${c.read}`} />
case 'failed':
return <span title="Falha no envio"><AlertCircle className={`w-3.5 h-3.5 ${c.fail}`} /></span>
default:
return null
}
};
// ── Timestamp helpers ─────────────────────────────────────────────────────
const timeStr = format(parseSafeDate(msg.timestamp), 'HH:mm');
// Espaçador invisível — empurra o texto da última linha para criar espaço
// para o timestamp sem sobrepor o conteúdo (técnica usada pelo WhatsApp oficial)
// outgoing: tempo (~38px) + gap + check (~14px) = ~62px
// incoming: tempo somente (~42px)
const spacerW = isOut ? 62 : 42;
const TimespacerEl = (
<span
className="inline-block pointer-events-none select-none opacity-0 align-bottom"
style={{ width: spacerW, height: 14 }}
aria-hidden
/>
);
// Timestamp padrão (sobre fundo claro)
const TimestampEl = (
<span className="inline-flex items-center gap-[3px] text-[11px] text-[#667781] font-normal leading-none select-none flex-shrink-0">
{timeStr}
{isOut && renderMessageStatus(msg.status)}
</span>
);
// Timestamp para overlay em fundo escuro (imagem/vídeo sem legenda)
const TimestampWhiteEl = (
<span className="inline-flex items-center gap-[3px] text-[11px] text-white/90 font-normal leading-none select-none flex-shrink-0 drop-shadow">
{timeStr}
{isOut && renderMessageStatus(msg.status, 'white')}
</span>
);
// Linha de timestamp abaixo do conteúdo (áudio, documento, sticker, etc.)
const TimestampRow = (
<div className="flex items-center justify-end gap-[3px] mt-1 -mb-0.5 pr-0.5">
{TimestampEl}
</div>
);
// ── Rich message renderers ────────────────────────────────────────────────
const renderRichButtons = (p: RichPayload) => {
const btns = p.nativeButtons ?? []
const btnIcon = (b: NativeBtn) => {
if (b.type === 'url') return <Link className="w-3.5 h-3.5 shrink-0" />
if (b.type === 'copy') return <Copy className="w-3.5 h-3.5 shrink-0" />
if (b.type === 'call') return <Phone className="w-3.5 h-3.5 shrink-0" />
return null
}
return (
<div>
{(p.text || p.footer) && (
<div className="px-1 pt-1 pb-1">
{p.text && (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(p.text)}
</p>
)}
{p.footer && <p className="text-[12px] text-[#667781] mt-0.5">{p.footer}</p>}
</div>
)}
<div className="border-t border-black/10 mt-1">
{btns.map((b, i) => (
<div
key={i}
className={`flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium text-[#00a884] ${i > 0 ? 'border-t border-black/10' : ''}`}
>
{btnIcon(b)}
<span>{b.text}</span>
</div>
))}
</div>
{TimestampRow}
</div>
)
}
const renderRichList = (p: RichPayload) => {
const open = listOpen
const setOpen = setListOpen
const list = p.nativeList!
return (
<div>
{(p.text || p.footer) && (
<div className="px-1 pt-1 pb-1">
{p.text && (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(p.text)}
</p>
)}
{p.footer && <p className="text-[12px] text-[#667781] mt-0.5">{p.footer}</p>}
</div>
)}
<div className="border-t border-black/10 mt-1">
<button
onClick={() => setOpen(!open)}
className="w-full flex items-center justify-center gap-1.5 py-2 px-3 text-[13px] font-medium text-[#00a884]"
>
<List className="w-3.5 h-3.5 shrink-0" />
<span>{list.buttonText}</span>
<ChevronRight className={`w-3.5 h-3.5 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
</button>
{open && (
<div className="border-t border-black/10 bg-black/[0.02] rounded-b-lg">
{list.sections.map((s, si) => (
<div key={si}>
{s.title && <div className="px-3 py-1.5 text-[11px] font-bold text-[#667781] uppercase tracking-wide">{s.title}</div>}
{s.rows.map((r, ri) => (
<div key={ri} className="px-3 py-2 border-t border-black/5">
<div className="text-[13px] font-medium text-[#111b21]">{r.title}</div>
{r.description && <div className="text-[12px] text-[#667781] mt-0.5">{r.description}</div>}
</div>
))}
</div>
))}
</div>
)}
</div>
{TimestampRow}
</div>
)
}
const renderRichCarousel = (p: RichPayload) => {
const cards = p.nativeCarousel?.cards ?? []
return (
<div className="max-w-[320px]">
{p.text && (
<div className="px-1 pt-1 pb-1">
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(p.text)}
</p>
</div>
)}
<div className="flex gap-2 overflow-x-auto pb-1 snap-x snap-mandatory px-1 scrollbar-hide">
{cards.map((card, ci) => (
<div key={ci} className="snap-start shrink-0 w-[200px] bg-white border border-black/10 rounded-xl overflow-hidden shadow-sm">
{card.image?.url && (
<img
src={card.image.url}
alt={card.title ?? ''}
className="w-full h-28 object-cover"
/>
)}
<div className="p-2">
{card.title && <div className="text-[13px] font-semibold text-[#111b21] truncate">{card.title}</div>}
{card.body && <div className="text-[12px] text-[#667781] mt-0.5 line-clamp-2">{card.body}</div>}
{card.footer && <div className="text-[11px] text-[#8696a0] mt-0.5">{card.footer}</div>}
{(card.buttons ?? []).length > 0 && (
<div className="mt-2 border-t border-black/10 pt-1.5 flex flex-col gap-1">
{card.buttons.map((b, bi) => (
<div key={bi} className="text-center text-[12px] font-medium text-[#00a884] py-0.5">{b.text}</div>
))}
</div>
)}
</div>
</div>
))}
</div>
{TimestampRow}
</div>
)
}
const renderRichPoll = (p: RichPayload) => {
const poll = p.poll!
return (
<div className="min-w-[220px]">
<div className="px-1 pt-1 pb-1">
<div className="flex items-center gap-1.5 text-[12px] text-[#667781] font-medium mb-1.5">
<BarChart2 className="w-3.5 h-3.5" />
<span>ENQUETE</span>
</div>
<p className="text-[14.2px] font-semibold text-[#111b21] mb-2">{poll.name}</p>
<div className="flex flex-col gap-1.5">
{poll.values.map((v, i) => (
<div key={i} className="flex items-center gap-2 py-1.5 px-2 border border-black/10 rounded-lg text-[13px] text-[#111b21] hover:bg-black/5 cursor-pointer transition-colors">
<div className="w-4 h-4 rounded-full border-2 border-[#8696a0] shrink-0" />
{v}
</div>
))}
</div>
{poll.selectableCount && poll.selectableCount > 1 && (
<div className="text-[11px] text-[#8696a0] mt-1.5">Selecione até {poll.selectableCount} opções</div>
)}
</div>
{TimestampRow}
</div>
)
}
const renderMessageContent = () => {
const fullMediaUrl = getMediaUrl(msg.media_url);
// ── Rich message types ──────────────────────────────────────────────
const rp = msg.rich_payload
// Mensagem outgoing com payload rico salvo
if (rp) {
if (msg.type === 'buttons') return renderRichButtons(rp)
if (msg.type === 'list') return renderRichList(rp)
if (msg.type === 'carousel') return renderRichCarousel(rp)
if (msg.type === 'poll' && rp.poll) return renderRichPoll(rp)
// INTERACTIVE (url/copy/call) também usa renderRichButtons
if (msg.type === 'interactive' && rp.nativeButtons) return renderRichButtons(rp)
}
// Resposta de clique de botão / seleção de lista recebida (incoming)
if (msg.type === 'interactive' && !rp && msg.text) {
return (
<div>
<div className="flex items-center gap-1.5 text-[12px] text-[#667781] mb-1">
<ChevronRight className="w-3.5 h-3.5 text-[#00a884]" />
<span className="font-medium text-[#00a884]">Selecionou:</span>
</div>
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{msg.text}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)
}
// Mensagem interativa incoming (botões/lista enviados por outro BA)
if (msg.type === 'buttons' && !rp && msg.text) {
return (
<div>
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(msg.text)}
{TimespacerEl}
</p>
<div className="flex items-center gap-1 text-[11px] text-[#8696a0] mt-1">
<span>📋</span><span>Mensagem com botões</span>
</div>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)
}
switch (msg.type) {
case 'image': {
const hasCaption = Boolean(msg.text?.trim());
return (
<div className="rounded-lg overflow-hidden w-[268px]">
<div
className={`relative group/media w-[268px] h-[200px] bg-black/5 overflow-hidden ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
>
{fullMediaUrl ? (
<img
src={fullMediaUrl}
alt="Media"
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
/>
) : (
<div className={`flex flex-col items-center justify-center w-full h-full gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">{mediaUnavailable ? 'Imagem não disponível' : 'Toque para baixar'}</span></>
}
</div>
)}
{!hasCaption && (
<div className="absolute bottom-1.5 right-2 bg-black/40 backdrop-blur-[2px] rounded-full px-1.5 py-[3px]">
{TimestampWhiteEl}
</div>
)}
</div>
{hasCaption && (
<div className="relative px-1 pt-1.5 pb-0.5">
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{msg.text}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)}
{msg.transcription && (
<div className="px-1 pb-1 pt-0.5">
<p className="text-[12.5px] italic text-[#54656f] whitespace-pre-wrap leading-snug">
👁 {msg.transcription}
</p>
</div>
)}
</div>
);
}
case 'video': {
const hasCaption = Boolean(msg.text?.trim());
return (
<div className="rounded-lg overflow-hidden w-[268px]">
<div
className={`relative w-[268px] h-[200px] bg-black overflow-hidden group/vid ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
>
{fullMediaUrl ? (
<div className="absolute inset-0 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors">
<div className="w-12 h-12 bg-white/20 backdrop-blur-md rounded-full flex items-center justify-center text-white shadow-xl border border-white/30 group-hover/vid:scale-110 transition-transform">
<Video className="w-6 h-6 fill-current" />
</div>
</div>
) : (
<div className={`absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
{isRedownloading
? <div className="w-8 h-8 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">{mediaUnavailable ? 'Vídeo não disponível' : 'Toque para baixar'}</span></>
}
</div>
)}
<div className="absolute bottom-0 inset-x-0 h-10 bg-gradient-to-t from-black/50 to-transparent pointer-events-none" />
{!hasCaption && (
<div className="absolute bottom-1.5 right-2">
{TimestampWhiteEl}
</div>
)}
</div>
{hasCaption && (
<div className="relative px-1 pt-1.5 pb-0.5">
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{msg.text}
{TimespacerEl}
</p>
<div className="absolute bottom-0.5 right-1">{TimestampEl}</div>
</div>
)}
</div>
);
}
case 'audio':
case 'ptt':
if (!fullMediaUrl) {
return (
<div>
<div
onClick={mediaUnavailable ? undefined : handleRedownload}
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
>
{isRedownloading
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
}
<span className="text-[12px] text-[#667781]">
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
</span>
</div>
{TimestampRow}
</div>
)
}
return <AudioBubble msg={msg} fullMediaUrl={fullMediaUrl} isOut={isOut} TimestampRow={TimestampRow} />;
case 'document':
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} mediaUnavailable={mediaUnavailable} />;
case 'sticker':
return (
<div>
{fullMediaUrl
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
: (
<div
onClick={mediaUnavailable ? undefined : handleRedownload}
className={`flex items-center gap-2 text-[13px] text-[#667781] italic transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:text-[#111b21]'}`}
>
{isRedownloading
? <div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
: <Star className="w-4 h-4" />
}
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Figurinha não disponível' : 'Toque para baixar figurinha'}
</div>
)
}
{TimestampRow}
</div>
);
case 'location':
return (
<div>
<div className="flex items-center gap-2 text-[13px] text-[#667781] italic">
<span className="text-lg">📍</span> Localização compartilhada
</div>
{TimestampRow}
</div>
);
case 'contact':
return (
<div>
<div className="flex items-center gap-2 text-[13px] text-[#667781] italic">
<span className="text-lg">👤</span> {msg.text || 'Contato compartilhado'}
</div>
{TimestampRow}
</div>
);
case 'reaction':
return null;
case 'unsupported':
return (
<div>
<div className="flex items-center gap-2 text-[12px] text-[#8696a0] italic">
<AlertCircle className="w-3.5 h-3.5" /> Mensagem não suportada
</div>
{TimestampRow}
</div>
);
default: {
// text, extendedtext, etc.
if (!msg.text) {
return (
<div>
<div className="flex items-center gap-2 text-[12px] text-[#8696a0] italic">
<AlertCircle className="w-3.5 h-3.5" /> Mensagem não disponível
</div>
{TimestampRow}
</div>
);
}
return (
<p className="text-[14.2px] break-words whitespace-pre-wrap leading-[1.6] text-[#111b21]">
{renderTextWithLinks(msg.text)}
<span style={{ float: 'right', marginLeft: 6, position: 'relative', bottom: -3 }}>
{TimestampEl}
</span>
</p>
);
}
}
};
const bubbleClasses = `px-2 py-1.5 rounded-lg shadow-[0_1px_0.5px_rgba(11,20,26,0.13)] relative group overflow-visible transition-all ${isOut
? `bg-[#d9fdd3] ${showTail ? 'rounded-tr-none' : ''}`
: `bg-white ${showTail ? 'rounded-tl-none' : ''}`
}`;
const tailStyles = showTail ? (
<div
className="absolute top-0 w-2.5 h-3.5 pointer-events-none"
style={{
[isOut ? 'right' : 'left']: '-8px',
backgroundColor: isOut ? '#d9fdd3' : '#ffffff',
clipPath: isOut ? 'polygon(0 0, 100% 0, 0 100%)' : 'polygon(100% 0, 0 0, 100% 100%)',
zIndex: 1
}}
/>
) : null;
return (
<motion.div
id={`msg-${msg.message_id}`}
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
className={`flex w-full mb-3 group/row ${isOut ? 'justify-end pr-4' : 'justify-start'}`}
>
{showActionsLeft && (
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity flex flex-col items-center justify-center mr-2 gap-2">
<button onClick={() => onReply(msg)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Responder">
<Reply className="w-4 h-4" />
</button>
<button onClick={() => onToggleReactionPicker(msg.message_id)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Reagir">
<span className="text-xs">🙂</span>
</button>
</div>
)}
{!isOut && isGroupChat && senderLabel && (
<div className="flex-shrink-0 mr-2 mt-1 self-start">
<Avatar
chat={{
remote_jid: msg.sender_jid || null,
displayName: senderLabel,
phone: null,
instance_name: (selectedChat as any)?.instance_name
}}
size={28}
/>
</div>
)}
<div className="flex flex-col max-w-[75%] relative">
<div className={bubbleClasses}>
{tailStyles}
{/* Authentic WhatsApp Message Menu Button */}
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className={`absolute top-1 right-1 p-1 text-slate-500 opacity-0 group-hover:opacity-100 hover:text-slate-300 transition-opacity z-30`}
>
<ChevronDown className="w-5 h-5" />
</button>
{isGroupChat && senderLabel && !isOut && (
<div className="mb-1.5 text-[11px] font-black tracking-wider text-brand-400 uppercase">
{senderLabel}
</div>
)}
{/* Quoted Message */}
{(msg.quoted_message_id || msg.quoted_id) && (
<div className={`mb-2 p-2 rounded-lg border-l-[4px] border-[#00a884] text-[13px] cursor-pointer ${isOut ? 'bg-black/5' : 'bg-[#f0f2f5]'} hover:bg-black/10 transition-colors`}
onClick={() => {
const targetId = msg.quoted_message_id || msg.quoted_id;
const el = targetId ? document.getElementById(`msg-${targetId}`) : null;
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-[#00a884]/20');
setTimeout(() => el.classList.remove('bg-[#00a884]/20'), 1500);
}
}}
>
<div className="font-bold text-[#00a884] mb-0.5 truncate">
{quotedSenderLabel}
</div>
<div className="text-[#667781] truncate">
{quotedPreviewText}
</div>
</div>
)}
{renderMessageContent()}
{/* Context Menu Dropdown */}
<AnimatePresence>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsMenuOpen(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -10 }}
className={`absolute top-8 ${isOut ? 'right-2' : 'left-2'} w-48 bg-white rounded-lg shadow-2xl border border-gray-100 py-1.5 z-[100] origin-top-right`}
>
{[
{ label: 'Responder', icon: <Reply className="w-4 h-4" />, onClick: () => { onReply(msg); setIsMenuOpen(false); } },
...(isOut && msg.type === 'text' && onEdit ? [{ label: 'Editar', icon: <span className="w-4 h-4 font-bold text-xs"></span>, onClick: () => { onEdit(msg); setIsMenuOpen(false); } }] : []),
{ label: 'Encaminhar', icon: <Forward className="w-4 h-4" />, onClick: () => { onForward?.(msg); setIsMenuOpen(false); } },
{ label: 'Favoritar', icon: <Star className="w-4 h-4" />, onClick: () => { onStar?.(msg); setIsMenuOpen(false); } },
{ label: 'Apagar', icon: <Trash2 className="w-4 h-4" />, onClick: () => { onDelete?.(msg); setIsMenuOpen(false); }, danger: true },
].map((item, i) => (
<button
key={i}
onClick={item.onClick}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold transition-colors ${item.danger ? 'text-red-400 hover:bg-red-500/10' : 'text-slate-300 hover:bg-white/5'}`}
>
{item.icon}
{item.label}
</button>
))}
</motion.div>
</>
)}
</AnimatePresence>
{/* Reaction Picker Popover */}
<AnimatePresence>
{reactionPickerFor === msg.message_id && (
<>
<div className="fixed inset-0 z-40" onClick={() => onToggleReactionPicker('')} />
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.9 }}
>
<div className={`absolute top-[calc(100%+8px)] ${isOut ? 'right-0 origin-top-right' : 'left-0 origin-top'} z-[100] flex animate-in fade-in zoom-in duration-200`}>
<div className="flex items-center gap-1.5 p-2 bg-[#111b21] rounded-full shadow-2xl border border-white/10 backdrop-blur-xl">
{quickReactions.map((emoji) => (
<button
key={emoji}
onClick={(e) => {
e.stopPropagation();
onReact(msg, emoji);
onToggleReactionPicker('');
}}
className="w-10 h-10 flex items-center justify-center hover:bg-white/5 rounded-full transition-all hover:scale-125 active:scale-95 text-2xl"
>
{emoji}
</button>
))}
</div>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
{/* Visible Reactions Badge */}
{msg.reactions && Object.keys(msg.reactions).length > 0 && (
<motion.div
initial={{ scale: 0.5, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
className={`mt-1 flex ${isOut ? 'justify-end mr-2' : 'justify-start ml-2'}`}
>
<div className="flex items-center bg-[#111b21] rounded-full px-2.5 py-1 shadow-2xl border border-white/5 gap-1.5 z-10 hover:scale-105 transition-transform cursor-pointer ring-1 ring-white/10 backdrop-blur-xl">
{Object.entries(msg.reactions).slice(0, 3).map(([sender, emoji], idx) => (
<span key={idx} title={sender} className="text-[13px]">{emoji}</span>
))}
{Object.keys(msg.reactions).length > 3 && (
<span className="text-[10px] text-slate-500 font-black">+{Object.keys(msg.reactions).length - 3}</span>
)}
</div>
</motion.div>
)}
</div>
{showActionsRight && (
<div className="opacity-0 group-hover/row:opacity-100 transition-opacity flex flex-col items-center justify-center ml-2 gap-2">
<button onClick={() => onReply(msg)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Responder">
<Reply className="w-4 h-4" />
</button>
<button onClick={() => onToggleReactionPicker(msg.message_id)} className="p-1.5 hover:bg-white/5 rounded-full text-slate-500 transition-all hover:scale-110" title="Reagir">
<span className="text-xs">🙃</span>
</button>
</div>
)}
</motion.div>
);
};
export default MessageBubble;
@@ -0,0 +1,281 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { X, Phone, Send, ChevronDown, Check } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
interface Instance {
id: string;
nome: string;
avatar: string | null;
phone: string | null;
}
interface NewConversationModalProps {
isOpen: boolean;
onClose: () => void;
onSent?: (phone: string, instanceId: string) => void;
activeInstance: Instance | null;
connectedInstances: Instance[];
sendMessage: (instanceId: string, phone: string, text: string) => Promise<{ ok: boolean; messageId?: string }>;
}
function normalizePhoneForSend(input: string): string {
const digits = input.replace(/\D/g, '');
// Already has Brazil country code: 55 + DDD (2) + number (8-9) = 12-13 digits
if (digits.startsWith('55') && (digits.length === 12 || digits.length === 13)) return digits;
// Local Brazilian number: DDD (2) + number (8-9) = 10-11 digits
if (digits.length === 10 || digits.length === 11) return '55' + digits;
// Return as-is (international or unusual)
return digits;
}
export default function NewConversationModal({
isOpen,
onClose,
onSent,
activeInstance,
connectedInstances,
sendMessage,
}: NewConversationModalProps) {
const [phone, setPhone] = useState('');
const [message, setMessage] = useState('');
const [selectedInstanceId, setSelectedInstanceId] = useState('');
const [sending, setSending] = useState(false);
const [sent, setSent] = useState(false);
const [error, setError] = useState('');
const phoneInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Reset state when modal opens
useEffect(() => {
if (isOpen) {
setPhone('');
setMessage('');
setError('');
setSent(false);
setSending(false);
setSelectedInstanceId(activeInstance?.id || connectedInstances[0]?.id || '');
setTimeout(() => phoneInputRef.current?.focus(), 120);
}
}, [isOpen, activeInstance?.id, connectedInstances]);
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setPhone(e.target.value.replace(/[^\d\s\-()+]/g, ''));
setError('');
};
const handleSubmit = async () => {
const normalized = normalizePhoneForSend(phone);
const digits = normalized.replace(/\D/g, '');
if (digits.length < 10) {
setError('Número inválido. Informe DDD + número (ex: 67 99922-2377)');
phoneInputRef.current?.focus();
return;
}
if (!message.trim()) {
setError('Escreva uma mensagem para iniciar a conversa');
textareaRef.current?.focus();
return;
}
if (!selectedInstanceId) {
setError('Nenhuma instância conectada');
return;
}
setSending(true);
setError('');
try {
const result = await sendMessage(selectedInstanceId, normalized, message.trim());
if (!result.ok) throw new Error('Envio falhou');
setSent(true);
// Brief success state, then close and notify parent
setTimeout(() => {
onSent?.(normalized, selectedInstanceId);
onClose();
}, 1300);
} catch (err: any) {
setError(err?.message || 'Erro ao enviar. Verifique o número e tente novamente.');
setSending(false);
}
};
const handleTextareaKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
handleSubmit();
}
};
const handlePhoneKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') textareaRef.current?.focus();
};
const canSend = !!phone.trim() && !!message.trim() && !!selectedInstanceId && !sending && !sent;
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={!sending ? onClose : undefined}
/>
{/* Modal */}
<motion.div
initial={{ scale: 0.92, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.92, opacity: 0, y: 16 }}
transition={{ type: 'spring', damping: 26, stiffness: 320 }}
className="relative w-full max-w-md bg-white rounded-2xl shadow-2xl flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-[#008069] text-white">
<div className="flex items-center gap-3">
<div className="w-9 h-9 bg-white/20 rounded-full flex items-center justify-center">
<Phone className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-semibold leading-tight">Nova Conversa</h2>
<p className="text-[11px] text-white/70 leading-tight">Enviar primeira mensagem</p>
</div>
</div>
<button
onClick={onClose}
disabled={sending}
className="p-1.5 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-5">
{/* Instance selector — only if multiple instances connected */}
{connectedInstances.length > 1 && (
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Enviar como
</label>
<div className="relative">
<select
value={selectedInstanceId}
onChange={(e) => setSelectedInstanceId(e.target.value)}
disabled={sending || sent}
className="w-full appearance-none bg-[#f0f2f5] rounded-xl px-4 py-2.5 pr-10 text-sm text-[#111b21] font-medium focus:outline-none focus:ring-2 focus:ring-[#008069]/40 disabled:opacity-60"
>
{connectedInstances.map(inst => (
<option key={inst.id} value={inst.id}>
{inst.nome}{inst.phone ? ` (+${inst.phone})` : ''}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
</div>
</div>
)}
{/* Phone input */}
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Número do WhatsApp
</label>
<div className="flex items-center gap-2 bg-[#f0f2f5] rounded-xl px-4 py-2.5 focus-within:ring-2 focus-within:ring-[#008069]/40 transition-all">
<span className="text-gray-400 text-sm select-none shrink-0">🇧🇷 +55</span>
<div className="w-px h-5 bg-gray-300 shrink-0" />
<input
ref={phoneInputRef}
type="tel"
placeholder="(67) 99922-2377"
value={phone}
onChange={handlePhoneChange}
onKeyDown={handlePhoneKeyDown}
disabled={sending || sent}
className="flex-1 bg-transparent text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none disabled:opacity-60 min-w-0"
/>
</div>
</div>
{/* Message textarea */}
<div>
<label className="block text-[11px] font-semibold text-gray-400 uppercase tracking-wide mb-1.5">
Mensagem
</label>
<textarea
ref={textareaRef}
rows={3}
placeholder="Olá! Tudo bem?"
value={message}
onChange={(e) => { setMessage(e.target.value); setError(''); }}
onKeyDown={handleTextareaKeyDown}
disabled={sending || sent}
className="w-full bg-[#f0f2f5] rounded-xl px-4 py-3 text-sm text-[#111b21] placeholder:text-[#8696a0] focus:outline-none focus:ring-2 focus:ring-[#008069]/40 resize-none transition-all disabled:opacity-60"
/>
<p className="text-[11px] text-gray-400 mt-1 text-right">Ctrl+Enter para enviar</p>
</div>
{/* Error */}
<AnimatePresence>
{error && (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="bg-red-50 border border-red-200 text-red-600 text-sm rounded-xl px-4 py-2.5"
>
{error}
</motion.p>
)}
</AnimatePresence>
</div>
{/* Footer */}
<div className="px-6 pb-6 flex items-center justify-between">
<button
onClick={onClose}
disabled={sending}
className="px-5 py-2.5 text-sm font-semibold text-gray-500 hover:bg-gray-100 rounded-xl transition-colors disabled:opacity-50"
>
Cancelar
</button>
<button
onClick={handleSubmit}
disabled={!canSend}
className={`flex items-center gap-2 px-6 py-2.5 rounded-xl text-sm font-semibold text-white transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed ${
sent
? 'bg-green-500'
: sending
? 'bg-[#008069]/80'
: 'bg-[#008069] hover:bg-[#017259]'
}`}
>
{sent ? (
<>
<Check className="w-4 h-4" />
Enviado!
</>
) : sending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Enviando...
</>
) : (
<>
<Send className="w-4 h-4" />
Enviar
</>
)}
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,380 @@
'use client';
import React from 'react';
import {
X, Plus, ClipboardList, ChevronDown, Loader2,
CheckCircle2, Clock, AlertCircle, Ban, Users, Layers,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import type { Protocol, ProtocolStatus } from '../hooks/useProtocolPanel';
import Toast from './ui/Toast';
// ─── Status config ────────────────────────────────────────────────────────────
type StatusConfig = {
label: string
color: string
bg: string
icon: React.ReactNode
}
const STATUS: Record<ProtocolStatus, StatusConfig> = {
OPEN: { label: 'Aberto', color: 'text-blue-400', bg: 'bg-blue-500/10 border-blue-500/20', icon: <Clock className="w-3 h-3" /> },
IN_PROGRESS: { label: 'Em Andamento', color: 'text-brand-400', bg: 'bg-brand-500/10 border-brand-500/20', icon: <AlertCircle className="w-3 h-3" /> },
WAITING_CLIENT: { label: 'Aguardando Cliente', color: 'text-amber-400', bg: 'bg-amber-500/10 border-amber-500/20', icon: <Clock className="w-3 h-3" /> },
WAITING_AGENT: { label: 'Aguardando Agente', color: 'text-orange-400', bg: 'bg-orange-500/10 border-orange-500/20', icon: <Clock className="w-3 h-3" /> },
RESOLVED: { label: 'Resolvido', color: 'text-emerald-400', bg: 'bg-emerald-500/10 border-emerald-500/20', icon: <CheckCircle2 className="w-3 h-3" /> },
CANCELLED: { label: 'Cancelado', color: 'text-slate-400', bg: 'bg-slate-500/10 border-slate-500/20', icon: <Ban className="w-3 h-3" /> },
}
const TRANSITIONS: Record<ProtocolStatus, ProtocolStatus[]> = {
OPEN: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED'],
WAITING_CLIENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
WAITING_AGENT: ['IN_PROGRESS', 'RESOLVED', 'CANCELLED'],
RESOLVED: [],
CANCELLED: [],
}
function StatusBadge({ status }: { status: ProtocolStatus }) {
const cfg = STATUS[status]
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-bold border ${cfg.color} ${cfg.bg}`}>
{cfg.icon}
{cfg.label}
</span>
)
}
// ─── Protocol card ────────────────────────────────────────────────────────────
interface ProtocolCardProps {
protocol: Protocol
isActive: boolean
updatingId: string | null
onStatusChange: (protocol: Protocol, status: ProtocolStatus) => void
}
function ProtocolCard({ protocol, isActive, updatingId, onStatusChange }: ProtocolCardProps) {
const [showActions, setShowActions] = React.useState(false)
const transitions = TRANSITIONS[protocol.status]
const isUpdating = updatingId === protocol.id
return (
<div className={`rounded-xl border p-4 space-y-3 ${isActive ? 'border-brand-500/30 bg-brand-500/5' : 'border-white/5 bg-white/[0.02]'}`}>
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div>
<div className="flex items-center gap-2">
<span className="text-white font-bold text-sm">{protocol.number}</span>
{isActive && (
<span className="text-[10px] font-bold text-brand-400 bg-brand-500/10 px-1.5 py-0.5 rounded-full border border-brand-500/20">
ATIVO
</span>
)}
</div>
<p className="text-xs text-slate-500 mt-0.5">
{format(new Date(protocol.createdAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
</div>
<StatusBadge status={protocol.status} />
</div>
{/* Sector / Team */}
{(protocol.sector || protocol.team) && (
<div className="flex items-center gap-3 text-xs text-slate-400">
{protocol.sector && (
<span className="flex items-center gap-1">
<Layers className="w-3 h-3" />
{protocol.sector.name}
</span>
)}
{protocol.team && (
<span className="flex items-center gap-1">
<Users className="w-3 h-3" />
{protocol.team.name}
</span>
)}
</div>
)}
{/* Summary */}
{protocol.summary && (
<p className="text-xs text-slate-400 italic border-l-2 border-white/10 pl-3">{protocol.summary}</p>
)}
{/* Status actions */}
{transitions.length > 0 && (
<div className="relative">
<button
onClick={() => setShowActions(!showActions)}
disabled={isUpdating}
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-white transition-colors disabled:opacity-50"
>
{isUpdating
? <Loader2 className="w-3 h-3 animate-spin" />
: <ChevronDown className={`w-3 h-3 transition-transform ${showActions ? 'rotate-180' : ''}`} />}
Alterar status
</button>
<AnimatePresence>
{showActions && (
<motion.div
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
className="absolute left-0 top-full mt-1 bg-[#1a1f2e] border border-white/10 rounded-xl shadow-2xl z-10 py-1 min-w-[180px]"
>
{transitions.map((s) => {
const cfg = STATUS[s]
return (
<button
key={s}
onClick={() => { onStatusChange(protocol, s); setShowActions(false) }}
className={`w-full text-left px-3 py-2 text-xs flex items-center gap-2 hover:bg-white/5 transition-colors ${cfg.color}`}
>
{cfg.icon}
{cfg.label}
</button>
)
})}
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Resolved at */}
{protocol.resolvedAt && (
<p className="text-xs text-emerald-400 flex items-center gap-1">
<CheckCircle2 className="w-3 h-3" />
Resolvido em {format(new Date(protocol.resolvedAt), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}
</p>
)}
</div>
)
}
// ─── Panel ────────────────────────────────────────────────────────────────────
interface ProtocolPanelProps {
isOpen: boolean
chatId: string | null
onClose: () => void
// injected from useProtocolPanel
protocols: Protocol[]
loading: boolean
activeProtocol: Protocol | null
hasActive: boolean
showOpenForm: boolean
openForm: () => void
setShowOpenForm: (v: boolean) => void
sectors: any[]
teams: any[]
selectedSectorId: string
setSelectedSectorId: (v: string) => void
selectedTeamId: string
setSelectedTeamId: (v: string) => void
loadingSectors: boolean
loadingTeams: boolean
isOpening: boolean
openProtocol: () => void
updatingId: string | null
updateStatus: (protocol: Protocol, status: ProtocolStatus) => void
toast: { message: string; type: 'success' | 'error' } | null
}
export default function ProtocolPanel({
isOpen, onClose,
protocols, loading,
activeProtocol, hasActive,
showOpenForm, openForm, setShowOpenForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
updatingId, updateStatus,
toast,
}: ProtocolPanelProps) {
const selectClass = "w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-brand-500/50 transition-colors appearance-none"
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[360px] h-full bg-[#111b21] border-l border-[#d1d7db] flex flex-col z-40 flex-shrink-0"
>
{/* Header */}
<div className="h-[60px] bg-[#f0f2f5] px-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2">
<ClipboardList className="w-5 h-5 text-[#54656f]" />
<span className="font-bold text-[#111b21] text-sm">Protocolos de Atendimento</span>
</div>
<button onClick={onClose} className="p-1.5 hover:bg-black/5 rounded-full transition-colors">
<X className="w-5 h-5 text-[#54656f]" />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{/* Open protocol form */}
<AnimatePresence>
{showOpenForm ? (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden"
>
<div className="bg-white/[0.03] border border-white/10 rounded-xl p-4 space-y-3">
<p className="text-white text-sm font-semibold">Novo Protocolo</p>
{/* Sector */}
<div>
<label className="block text-xs text-slate-400 mb-1">Setor (opcional)</label>
<div className="relative">
<select
value={selectedSectorId}
onChange={(e) => setSelectedSectorId(e.target.value)}
className={selectClass}
disabled={loadingSectors}
>
<option value="">Sem setor</option>
{sectors.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
{loadingSectors && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
{/* Team */}
{selectedSectorId && (
<div>
<label className="block text-xs text-slate-400 mb-1">Equipe (opcional)</label>
<div className="relative">
<select
value={selectedTeamId}
onChange={(e) => setSelectedTeamId(e.target.value)}
className={selectClass}
disabled={loadingTeams}
>
<option value="">Sem equipe</option>
{teams.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
{loadingTeams && (
<Loader2 className="absolute right-3 top-2.5 w-3.5 h-3.5 text-slate-400 animate-spin" />
)}
</div>
</div>
)}
<div className="flex gap-2">
<button
onClick={() => setShowOpenForm(false)}
className="flex-1 py-2 text-xs font-semibold text-slate-400 hover:text-white bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
>
Cancelar
</button>
<button
onClick={openProtocol}
disabled={isOpening}
className="flex-1 py-2 text-xs font-bold text-white bg-brand-500 hover:bg-brand-600 rounded-lg transition-colors disabled:opacity-50 flex items-center justify-center gap-1.5"
>
{isOpening
? <><Loader2 className="w-3 h-3 animate-spin" /> Abrindo</>
: 'Abrir Protocolo'
}
</button>
</div>
</div>
</motion.div>
) : (
!hasActive && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-3 border-2 border-dashed border-white/10 rounded-xl text-sm text-slate-400 hover:text-brand-400 hover:border-brand-500/40 transition-colors"
>
<Plus className="w-4 h-4" />
Abrir Protocolo
</button>
)
)}
</AnimatePresence>
{/* Active protocol */}
{activeProtocol && !showOpenForm && (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Protocolo Ativo</p>
<ProtocolCard
protocol={activeProtocol}
isActive
updatingId={updatingId}
onStatusChange={updateStatus}
/>
{!hasActive && null}
</div>
)}
{/* Open new when active exists */}
{hasActive && !showOpenForm && (
<button
onClick={openForm}
className="w-full flex items-center justify-center gap-2 py-2.5 border border-dashed border-white/10 rounded-xl text-xs text-slate-500 hover:text-brand-400 hover:border-brand-500/30 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Abrir novo protocolo
</button>
)}
{/* History */}
{loading ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 text-brand-400 animate-spin" />
</div>
) : protocols.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 gap-2">
<ClipboardList className="w-8 h-8 text-slate-700" />
<p className="text-slate-500 text-xs text-center">Nenhum protocolo para este chat.</p>
</div>
) : (
(() => {
const history = protocols.filter(
(p) => p.status === 'RESOLVED' || p.status === 'CANCELLED'
)
return history.length > 0 ? (
<div>
<p className="text-xs text-slate-500 uppercase tracking-widest font-bold mb-2">Histórico</p>
<div className="space-y-2">
{history.map((p) => (
<ProtocolCard
key={p.id}
protocol={p}
isActive={false}
updatingId={updatingId}
onStatusChange={updateStatus}
/>
))}
</div>
</div>
) : null
})()
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}
@@ -0,0 +1,719 @@
'use client';
/**
* RichMessageComposer modal completo para compor e enviar mensagens ricas.
*
* Suporta 6 tipos de mensagem WhatsApp via InfiniteAPI/Baileys:
* TEXT texto simples (padrão atual)
* BUTTONS até 3 botões de resposta rápida (nativeButtons reply)
* INTERACTIVE botões CTA: URL / Copiar / Ligar (nativeButtons url/copy/call)
* LIST lista dropdown com seções e itens (nativeList)
* POLL enquete nativa do WhatsApp (poll)
* CAROUSEL cards deslizáveis com imagem e botões (nativeCarousel)
*
* Props:
* isOpen controla visibilidade
* onClose callback ao fechar
* instanceId instância WhatsApp ativa
* jid JID destino (pré-preenchido quando aberto do chat)
* onSent callback após envio bem-sucedido
* initialPayload pré-preenche o form (ao usar um template)
* initialType tipo inicial selecionado
*/
import React, { useState, useCallback, useEffect, useId } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Send, Plus, Trash2, ChevronDown, ChevronUp,
Type, MousePointerClick, List, BarChart2, LayoutGrid, Link,
BookmarkPlus, Calendar,
} from 'lucide-react'
import { richMessageApi, templateApi, type TemplateType, type RichPayload } from '../services/chatApiService'
// ─── Tipos locais ────────────────────────────────────────────────────────────
type MessageType = TemplateType
interface BtnItem { id: string; text: string }
interface CtaItem { type: 'url' | 'copy' | 'call'; text: string; extra: string }
interface ListRow { id: string; title: string; description: string }
interface ListSection { title: string; rows: ListRow[] }
interface CarouselBtn { id: string; text: string }
interface CarouselCard { title: string; body: string; footer: string; imageUrl: string; buttons: CarouselBtn[] }
interface FormState {
// TEXT
text: string
// BUTTONS
btnText: string
btnFooter: string
buttons: BtnItem[]
// INTERACTIVE (CTA)
ctaText: string
ctaFooter: string
ctaButtons: CtaItem[]
// LIST
listText: string
listButtonText: string
listFooter: string
listSections: ListSection[]
// POLL
pollName: string
pollSelectable: number
pollOptions: string[]
// CAROUSEL
carouselText: string
carouselFooter: string
cards: CarouselCard[]
}
const defaultForm: FormState = {
text: '',
btnText: '', btnFooter: '', buttons: [{ id: 'btn1', text: '' }],
ctaText: '', ctaFooter: '', ctaButtons: [{ type: 'url', text: '', extra: '' }],
listText: '', listButtonText: 'Ver opções', listFooter: '',
listSections: [{ title: 'Seção 1', rows: [{ id: 'r1', title: '', description: '' }] }],
pollName: '', pollSelectable: 1, pollOptions: ['', ''],
carouselText: '', carouselFooter: '',
cards: [{ title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: 'cb1', text: '' }] }],
}
// ─── Tipo selector ────────────────────────────────────────────────────────────
const TYPE_TABS: Array<{ type: MessageType; label: string; icon: React.ReactNode; color: string }> = [
{ type: 'TEXT', label: 'Texto', icon: <Type className="w-4 h-4" />, color: '#667781' },
{ type: 'BUTTONS', label: 'Botões', icon: <MousePointerClick className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'INTERACTIVE', label: 'CTA', icon: <Link className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'LIST', label: 'Lista', icon: <List className="w-4 h-4" />, color: '#f59e0b' },
{ type: 'POLL', label: 'Enquete', icon: <BarChart2 className="w-4 h-4" />, color: '#10b981' },
{ type: 'CAROUSEL', label: 'Carrossel', icon: <LayoutGrid className="w-4 h-4" />, color: '#ef4444' },
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
function uid() { return `id_${Date.now()}_${Math.random().toString(36).slice(2, 7)}` }
function buildPayload(type: MessageType, form: FormState): RichPayload {
switch (type) {
case 'TEXT':
return { text: form.text }
case 'BUTTONS':
return {
text: form.btnText || undefined,
footer: form.btnFooter || undefined,
nativeButtons: form.buttons.filter(b => b.id && b.text).map(b => ({
type: 'reply' as const, id: b.id, text: b.text,
})),
}
case 'INTERACTIVE':
return {
text: form.ctaText || undefined,
nativeButtons: form.ctaButtons.filter(b => b.text && b.extra).map(b => {
if (b.type === 'url') return { type: 'url' as const, text: b.text, url: b.extra }
if (b.type === 'copy') return { type: 'copy' as const, text: b.text, copyText: b.extra }
return { type: 'call' as const, text: b.text, phoneNumber: b.extra }
}),
}
case 'LIST': {
const sections = form.listSections
.map(s => ({ title: s.title, rows: s.rows.filter(r => r.id && r.title) }))
.filter(s => s.rows.length > 0)
return {
text: form.listText || undefined,
nativeList: {
buttonText: form.listButtonText || 'Ver opções',
sections,
},
}
}
case 'POLL':
return {
poll: {
name: form.pollName || 'Enquete',
values: form.pollOptions.filter(Boolean),
selectableCount: form.pollSelectable,
},
}
case 'CAROUSEL': {
const cards = form.cards.map(c => ({
title: c.title || undefined,
body: c.body || undefined,
footer: c.footer || undefined,
image: c.imageUrl ? { url: c.imageUrl } : undefined,
buttons: c.buttons.filter(b => b.id && b.text).map(b => ({ type: 'reply' as const, id: b.id, text: b.text })),
}))
return {
text: form.carouselText || undefined,
nativeCarousel: { cards },
}
}
}
}
// ─── Sub-forms ────────────────────────────────────────────────────────────────
function TextForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
return (
<div className="space-y-3">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Mensagem</label>
<textarea
rows={5}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30 resize-none"
placeholder="Digite o texto da mensagem..."
value={form.text}
onChange={e => set({ text: e.target.value })}
/>
</div>
)
}
function ButtonsForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const add = () => {
if (form.buttons.length >= 3) return
set({ buttons: [...form.buttons, { id: uid(), text: '' }] })
}
const remove = (i: number) => set({ buttons: form.buttons.filter((_, idx) => idx !== i) })
const update = (i: number, field: keyof BtnItem, val: string) => {
const btns = [...form.buttons]; btns[i] = { ...btns[i], [field]: val }; set({ buttons: btns })
}
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30 resize-none"
placeholder="Corpo da mensagem" value={form.btnText} onChange={e => set({ btnText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto de rodapé (opcional)" value={form.btnFooter} onChange={e => set({ btnFooter: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões (máx. 3)</label>
{form.buttons.map((btn, i) => (
<div key={btn.id} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="ID do botão" value={btn.id} onChange={e => update(i, 'id', e.target.value)} />
<input className="flex-[2] rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
placeholder="Texto visível" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => remove(i)} disabled={form.buttons.length <= 1}
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.buttons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#0ea5e9] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão
</button>
)}
</div>
</div>
)
}
function InteractiveForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const add = () => {
if (form.ctaButtons.length >= 3) return
set({ ctaButtons: [...form.ctaButtons, { type: 'url', text: '', extra: '' }] })
}
const remove = (i: number) => set({ ctaButtons: form.ctaButtons.filter((_, idx) => idx !== i) })
const update = (i: number, field: keyof CtaItem, val: string) => {
const btns = [...form.ctaButtons]; btns[i] = { ...btns[i], [field]: val as any }; set({ ctaButtons: btns })
}
const placeholders: Record<string, string> = { url: 'https://...', copy: 'Código do cupom', call: '5511999999999' }
const labels: Record<string, string> = { url: 'URL', copy: 'Código a copiar', call: 'Número de telefone' }
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30 resize-none"
placeholder="Corpo da mensagem" value={form.ctaText} onChange={e => set({ ctaText: e.target.value })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Botões CTA (máx. 3)</label>
{form.ctaButtons.map((btn, i) => (
<div key={i} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<select value={btn.type} onChange={e => update(i, 'type', e.target.value)}
className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30">
<option value="url">🌐 URL</option>
<option value="copy">📋 Copiar</option>
<option value="call">📞 Ligar</option>
</select>
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="Texto do botão" value={btn.text} onChange={e => update(i, 'text', e.target.value)} />
<button onClick={() => remove(i)} disabled={form.ctaButtons.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder={placeholders[btn.type]} value={btn.extra} onChange={e => update(i, 'extra', e.target.value)} />
<span className="text-[10px] text-gray-400">{labels[btn.type]}</span>
</div>
))}
{form.ctaButtons.length < 3 && (
<button onClick={add} className="flex items-center gap-1 text-xs text-[#8b5cf6] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar botão CTA
</button>
)}
</div>
</div>
)
}
function ListForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addSection = () => {
set({ listSections: [...form.listSections, { title: `Seção ${form.listSections.length + 1}`, rows: [{ id: uid(), title: '', description: '' }] }] })
}
const removeSection = (si: number) => set({ listSections: form.listSections.filter((_, i) => i !== si) })
const updateSection = (si: number, title: string) => {
const ss = [...form.listSections]; ss[si] = { ...ss[si], title }; set({ listSections: ss })
}
const addRow = (si: number) => {
const ss = [...form.listSections]
ss[si] = { ...ss[si], rows: [...ss[si].rows, { id: uid(), title: '', description: '' }] }
set({ listSections: ss })
}
const removeRow = (si: number, ri: number) => {
const ss = [...form.listSections]
ss[si] = { ...ss[si], rows: ss[si].rows.filter((_, i) => i !== ri) }
set({ listSections: ss })
}
const updateRow = (si: number, ri: number, field: keyof ListRow, val: string) => {
const ss = [...form.listSections]
const rows = [...ss[si].rows]; rows[ri] = { ...rows[ri], [field]: val }
ss[si] = { ...ss[si], rows }; set({ listSections: ss })
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto</label>
<textarea rows={2} className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none resize-none"
placeholder="Corpo da mensagem" value={form.listText} onChange={e => set({ listText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto do Botão</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ver opções" value={form.listButtonText} onChange={e => set({ listButtonText: e.target.value })} />
</div>
</div>
<div className="space-y-3">
{form.listSections.map((sec, si) => (
<div key={si} className="rounded-xl border border-amber-100 bg-amber-50/50 p-3 space-y-2">
<div className="flex items-center gap-2">
<input className="flex-1 rounded-lg border border-amber-200 bg-white px-2 py-1.5 text-xs font-medium focus:outline-none"
placeholder="Título da seção" value={sec.title} onChange={e => updateSection(si, e.target.value)} />
<button onClick={() => removeSection(si)} disabled={form.listSections.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{sec.rows.map((row, ri) => (
<div key={ri} className="flex gap-2">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="ID" value={row.id} onChange={e => updateRow(si, ri, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={row.title} onChange={e => updateRow(si, ri, 'title', e.target.value)} />
<input className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Descrição" value={row.description} onChange={e => updateRow(si, ri, 'description', e.target.value)} />
<button onClick={() => removeRow(si, ri)} disabled={sec.rows.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addRow(si)} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3 h-3" /> Adicionar item
</button>
</div>
))}
<button onClick={addSection} className="flex items-center gap-1 text-xs text-amber-600 hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar seção
</button>
</div>
</div>
)
}
function PollForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addOption = () => set({ pollOptions: [...form.pollOptions, ''] })
const removeOption = (i: number) => {
if (form.pollOptions.length <= 2) return
set({ pollOptions: form.pollOptions.filter((_, idx) => idx !== i) })
}
const updateOption = (i: number, val: string) => {
const opts = [...form.pollOptions]; opts[i] = val; set({ pollOptions: opts })
}
return (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Pergunta</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder="Qual a sua preferência?" value={form.pollName} onChange={e => set({ pollName: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Máx. seleções</label>
<input type="number" min={1} max={form.pollOptions.filter(Boolean).length || 1}
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
value={form.pollSelectable} onChange={e => set({ pollSelectable: parseInt(e.target.value) || 1 })} />
</div>
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Opções (mín. 2)</label>
{form.pollOptions.map((opt, i) => (
<div key={i} className="flex gap-2">
<input className="flex-1 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#10b981]/30"
placeholder={`Opção ${i + 1}`} value={opt} onChange={e => updateOption(i, e.target.value)} />
<button onClick={() => removeOption(i)} disabled={form.pollOptions.length <= 2}
className="p-2 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
{form.pollOptions.length < 12 && (
<button onClick={addOption} className="flex items-center gap-1 text-xs text-[#10b981] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar opção
</button>
)}
</div>
</div>
)
}
function CarouselForm({ form, set }: { form: FormState; set: (p: Partial<FormState>) => void }) {
const addCard = () => {
if (form.cards.length >= 10) return
set({ cards: [...form.cards, { title: '', body: '', footer: '', imageUrl: '', buttons: [{ id: uid(), text: '' }] }] })
}
const removeCard = (ci: number) => set({ cards: form.cards.filter((_, i) => i !== ci) })
const updateCard = (ci: number, field: keyof CarouselCard, val: any) => {
const cards = [...form.cards]; cards[ci] = { ...cards[ci], [field]: val }; set({ cards })
}
const addCardBtn = (ci: number) => {
const cards = [...form.cards]; cards[ci].buttons.push({ id: uid(), text: '' }); set({ cards })
}
const removeCardBtn = (ci: number, bi: number) => {
const cards = [...form.cards]; cards[ci].buttons = cards[ci].buttons.filter((_, i) => i !== bi); set({ cards })
}
const updateCardBtn = (ci: number, bi: number, field: keyof CarouselBtn, val: string) => {
const cards = [...form.cards]; cards[ci].buttons[bi] = { ...cards[ci].buttons[bi], [field]: val }; set({ cards })
}
const [expanded, setExpanded] = useState<number[]>([0])
const toggle = (i: number) => setExpanded(prev => prev.includes(i) ? prev.filter(x => x !== i) : [...prev, i])
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Texto acima</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Ofertas especiais" value={form.carouselText} onChange={e => set({ carouselText: e.target.value })} />
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Rodapé</label>
<input className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none"
placeholder="Rodapé geral" value={form.carouselFooter} onChange={e => set({ carouselFooter: e.target.value })} />
</div>
</div>
<div className="space-y-2 max-h-[320px] overflow-y-auto pr-1">
{form.cards.map((card, ci) => (
<div key={ci} className="rounded-xl border border-gray-200 bg-[#f8f9fa] overflow-hidden">
<button onClick={() => toggle(ci)}
className="w-full flex items-center justify-between px-3 py-2.5 text-sm font-medium text-[#111b21] hover:bg-gray-100 transition-colors">
<span>Card {ci + 1}{card.title ? `${card.title}` : ''}</span>
<div className="flex items-center gap-2">
{form.cards.length > 1 && (
<button onClick={(e) => { e.stopPropagation(); removeCard(ci) }}
className="p-1 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{expanded.includes(ci) ? <ChevronUp className="w-4 h-4 text-gray-400" /> : <ChevronDown className="w-4 h-4 text-gray-400" />}
</div>
</button>
{expanded.includes(ci) && (
<div className="px-3 pb-3 space-y-2 border-t border-gray-100 pt-2">
<div className="grid grid-cols-2 gap-2">
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Título" value={card.title} onChange={e => updateCard(ci, 'title', e.target.value)} />
<input className="rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="Rodapé" value={card.footer} onChange={e => updateCard(ci, 'footer', e.target.value)} />
</div>
<textarea rows={2} className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none resize-none"
placeholder="Descrição/corpo" value={card.body} onChange={e => updateCard(ci, 'body', e.target.value)} />
<input className="w-full rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none"
placeholder="URL da imagem (opcional)" value={card.imageUrl} onChange={e => updateCard(ci, 'imageUrl', e.target.value)} />
<div className="space-y-1.5">
<span className="text-[10px] font-semibold text-gray-400 uppercase">Botões do card</span>
{card.buttons.map((btn, bi) => (
<div key={bi} className="flex gap-1.5">
<input className="w-20 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="ID" value={btn.id} onChange={e => updateCardBtn(ci, bi, 'id', e.target.value)} />
<input className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Texto" value={btn.text} onChange={e => updateCardBtn(ci, bi, 'text', e.target.value)} />
<button onClick={() => removeCardBtn(ci, bi)} disabled={card.buttons.length <= 1}
className="p-1 rounded text-gray-400 hover:text-red-500 disabled:opacity-30">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
<button onClick={() => addCardBtn(ci)} className="flex items-center gap-1 text-[11px] text-red-500 hover:underline font-medium">
<Plus className="w-3 h-3" /> Botão no card
</button>
</div>
</div>
)}
</div>
))}
</div>
{form.cards.length < 10 && (
<button onClick={addCard} className="flex items-center gap-1 text-xs text-[#ef4444] hover:underline font-medium">
<Plus className="w-3.5 h-3.5" /> Adicionar card
</button>
)}
</div>
)
}
// ─── Modal principal ──────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
instanceId: string | null
jid: string
onSent?: () => void
onSaveTemplate?: (type: TemplateType, payload: Record<string, unknown>, name?: string) => void
onSchedule?: (type: TemplateType, payload: Record<string, unknown>) => void
initialPayload?: Record<string, unknown>
initialType?: TemplateType
}
export default function RichMessageComposer({
isOpen, onClose, instanceId, jid, onSent, onSaveTemplate, onSchedule,
initialPayload, initialType = 'TEXT',
}: Props) {
const [type, setType] = useState<MessageType>(initialType)
const [form, setFormState] = useState<FormState>(defaultForm)
const [sending, setSending] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [savedName, setSavedName] = useState('')
const [showSaveName, setShowSaveName] = useState(false)
const set = useCallback((partial: Partial<FormState>) => {
setFormState(prev => ({ ...prev, ...partial }))
}, [])
const handleSend = async () => {
if (!instanceId || !jid) return
// Validações de frontend antes de enviar
if (type === 'CAROUSEL' && form.cards.length < 2) {
setError('Carrossel requer no mínimo 2 cards')
return
}
if (type === 'CAROUSEL' && form.cards.some(c => c.buttons.filter(b => b.id && b.text).length === 0)) {
setError('Cada card do carrossel precisa ter pelo menos 1 botão')
return
}
setError(null)
setSending(true)
try {
const payload = buildPayload(type, form)
await richMessageApi.send(instanceId, jid, payload)
onSent?.()
onClose()
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao enviar')
} finally {
setSending(false)
}
}
const handleSave = async () => {
if (!savedName.trim()) { setShowSaveName(true); return }
setSaving(true)
try {
const payload = buildPayload(type, form) as Record<string, unknown>
await templateApi.create({ name: savedName.trim(), type, payload, tags: [] })
setShowSaveName(false)
setSavedName('')
} finally {
setSaving(false)
}
}
const handleSchedule = () => {
const payload = buildPayload(type, form) as Record<string, unknown>
onSchedule?.(type, payload)
}
const activeTab = TYPE_TABS.find(t => t.type === type)!
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-end sm:items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 40, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-xl max-h-[92vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div>
<h2 className="text-base font-semibold text-[#111b21]">Mensagem Rica</h2>
<p className="text-xs text-[#667781] mt-0.5">Compose um tipo avançado de mensagem WhatsApp</p>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Type tabs */}
<div className="px-5 pt-3 pb-2 shrink-0">
<div className="flex gap-1 bg-[#f0f2f5] rounded-xl p-1">
{TYPE_TABS.map(tab => (
<button
key={tab.type}
onClick={() => setType(tab.type)}
className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-[11px] font-semibold transition-all ${
type === tab.type
? 'bg-white shadow-sm text-[#111b21]'
: 'text-[#667781] hover:text-[#111b21]'
}`}
style={type === tab.type ? { color: tab.color } : {}}
>
{tab.icon}
<span className="hidden sm:block">{tab.label}</span>
</button>
))}
</div>
</div>
{/* Form area */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{type === 'TEXT' && <TextForm form={form} set={set} />}
{type === 'BUTTONS' && <ButtonsForm form={form} set={set} />}
{type === 'INTERACTIVE' && <InteractiveForm form={form} set={set} />}
{type === 'LIST' && <ListForm form={form} set={set} />}
{type === 'POLL' && <PollForm form={form} set={set} />}
{type === 'CAROUSEL' && <CarouselForm form={form} set={set} />}
</div>
{/* Save name input */}
<AnimatePresence>
{showSaveName && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="px-5 py-2 border-t border-gray-100 bg-[#f8f9fa] overflow-hidden shrink-0"
>
<div className="flex gap-2 items-center">
<input
autoFocus
className="flex-1 rounded-xl border border-gray-200 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Nome do template..."
value={savedName}
onChange={e => setSavedName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') setShowSaveName(false) }}
/>
<button onClick={handleSave} disabled={!savedName.trim() || saving}
className="px-3 py-2 bg-[#00a884] text-white rounded-xl text-sm font-medium disabled:opacity-50 hover:bg-[#008f72] transition-colors">
{saving ? '...' : 'Salvar'}
</button>
<button onClick={() => setShowSaveName(false)} className="p-2 rounded-full hover:bg-gray-200 text-gray-400">
<X className="w-4 h-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Error */}
{error && (
<div className="mx-5 mb-2 px-3 py-2 bg-red-50 border border-red-200 rounded-xl text-xs text-red-600">
{error}
</div>
)}
{/* Footer actions */}
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<div className="flex items-center gap-1">
<button
onClick={() => setShowSaveName(!showSaveName)}
title="Salvar como template"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
<BookmarkPlus className="w-4 h-4" />
<span className="hidden sm:block">Salvar template</span>
</button>
{onSchedule && (
<button
onClick={handleSchedule}
title="Agendar envio"
className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
<Calendar className="w-4 h-4" />
<span className="hidden sm:block">Agendar</span>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button onClick={onClose}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors">
Cancelar
</button>
<button
onClick={handleSend}
disabled={sending || !instanceId || !jid}
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-semibold text-white disabled:opacity-50 transition-all active:scale-95"
style={{ backgroundColor: activeTab.color }}
>
{sending ? (
<span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />
) : (
<Send className="w-4 h-4" />
)}
Enviar
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,506 @@
'use client';
/**
* ScheduleMessageModal modal completo de agendamentos de mensagem.
*
* Modos:
* ONCE data/hora específica (datetime-local)
* RECURRING expressão cron com presets visuais
* EVENT BIRTHDAY / ANNIVERSARY / SIGNUP_DAYS
* vinculado a dados de recipients (birthday, anniversary, signupDate)
*
* Suporte a variáveis de personalização: {{name}}, {{birthday}}, etc.
* Destinatários: lista manual de JIDs com nome e dados de evento.
* Variáveis substituídas em runtime pelo SchedulerService.
*/
import React, { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Calendar, RotateCw, Gift, Plus, Trash2, Clock,
CheckCircle2, AlertTriangle, ChevronRight, Users,
} from 'lucide-react'
import {
scheduleApi, instanceApi,
type ScheduleType, type EventType, type ScheduleRecipient, type TemplateType,
} from '../services/chatApiService'
// ─── CRON presets ─────────────────────────────────────────────────────────────
const CRON_PRESETS = [
{ label: 'Diariamente às 8h', expr: '0 8 * * *' },
{ label: 'Seg a Sex às 9h', expr: '0 9 * * 1-5' },
{ label: 'Toda segunda às 10h', expr: '0 10 * * 1' },
{ label: 'Quinzenal (dia 1/15)', expr: '0 9 1,15 * *' },
{ label: 'Mensal (dia 1)', expr: '0 9 1 * *' },
{ label: 'Semanal domingo 11h', expr: '0 11 * * 0' },
]
// ─── Tipos locais ─────────────────────────────────────────────────────────────
interface RecipientRow extends ScheduleRecipient {
_key: string
}
interface Props {
isOpen: boolean
onClose: () => void
payload: Record<string, unknown>
messageType: TemplateType
onSaved?: () => void
}
function uid() { return `k_${Date.now()}_${Math.random().toString(36).slice(2, 6)}` }
// ─── Component ────────────────────────────────────────────────────────────────
export default function ScheduleMessageModal({ isOpen, onClose, payload, messageType, onSaved }: Props) {
const [step, setStep] = useState<1 | 2 | 3>(1) // 1=tipo, 2=destinatários, 3=confirmação
// Step 1 — configuração de schedule
const [scheduleType, setScheduleType] = useState<ScheduleType>('ONCE')
const [scheduledAt, setScheduledAt] = useState('')
const [cronExpr, setCronExpr] = useState('0 9 * * 1-5')
const [eventType, setEventType] = useState<EventType>('BIRTHDAY')
const [daysAfter, setDaysAfter] = useState('30')
const [name, setName] = useState('')
const [timezone] = useState('America/Sao_Paulo')
// Step 2 — destinatários
const [recipients, setRecipients] = useState<RecipientRow[]>([
{ _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' },
])
// Step 3 — resultado
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [instanceId, setInstanceId] = useState('')
const [instances, setInstances] = useState<Array<{ id: string; name: string; status: string }>>([])
useEffect(() => {
if (isOpen) {
instanceApi.list().then((list: any[]) => setInstances(list))
}
}, [isOpen])
const addRecipient = () => {
setRecipients(prev => [...prev, { _key: uid(), jid: '', name: '', birthday: '', anniversary: '', signupDate: '' }])
}
const removeRecipient = (key: string) => {
setRecipients(prev => prev.filter(r => r._key !== key))
}
const updateRecipient = (key: string, field: keyof ScheduleRecipient, val: string) => {
setRecipients(prev => prev.map(r => r._key === key ? { ...r, [field]: val } : r))
}
const isStep1Valid = () => {
if (!name.trim() || !instanceId) return false
if (scheduleType === 'ONCE' && !scheduledAt) return false
if (scheduleType === 'RECURRING' && !cronExpr.trim()) return false
return true
}
const validRecipients = recipients.filter(r => r.jid.trim())
const buildScheduleData = () => ({
instanceId,
name: name.trim(),
payload,
recipients: validRecipients.map(({ _key, ...r }) => ({
...r,
jid: r.jid.includes('@') ? r.jid : `${r.jid.replace(/\D/g, '')}@s.whatsapp.net`,
})),
scheduleType,
scheduledAt: scheduleType === 'ONCE' ? scheduledAt : undefined,
cronExpr: scheduleType === 'RECURRING' ? cronExpr
: scheduleType === 'EVENT' && eventType === 'SIGNUP_DAYS' ? daysAfter
: undefined,
eventType: scheduleType === 'EVENT' ? eventType : undefined,
timezone,
})
const handleSave = async () => {
setSaving(true)
setError(null)
try {
await scheduleApi.create(buildScheduleData())
setSaved(true)
onSaved?.()
setTimeout(() => { setSaved(false); onClose(); setStep(1) }, 2000)
} catch (e: any) {
setError(e?.response?.data?.error ?? e?.message ?? 'Erro ao salvar')
} finally {
setSaving(false)
}
}
// Rótulo do tipo de mensagem
const typeLabels: Record<TemplateType, string> = {
TEXT: 'Texto', BUTTONS: 'Botões', INTERACTIVE: 'CTA', LIST: 'Lista', POLL: 'Enquete', CAROUSEL: 'Carrossel',
}
const minDateTime = new Date(Date.now() + 60_000).toISOString().slice(0, 16)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60] flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-lg max-h-[90vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#fef3c7] flex items-center justify-center">
<Calendar className="w-5 h-5 text-[#f59e0b]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Agendar Mensagem</h2>
<p className="text-xs text-[#667781]">Tipo: {typeLabels[messageType]}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Step indicator */}
<div className="px-5 py-3 flex items-center gap-2 shrink-0 border-b border-gray-100">
{[1, 2, 3].map(s => (
<React.Fragment key={s}>
<button
onClick={() => s < step ? setStep(s as any) : undefined}
className={`w-7 h-7 rounded-full text-xs font-bold flex items-center justify-center transition-all ${
step === s ? 'bg-[#f59e0b] text-white shadow-md scale-105' :
step > s ? 'bg-[#10b981] text-white' : 'bg-gray-100 text-gray-400'
}`}
>
{step > s ? <CheckCircle2 className="w-4 h-4" /> : s}
</button>
{s < 3 && <ChevronRight className="w-4 h-4 text-gray-300" />}
</React.Fragment>
))}
<span className="ml-2 text-xs text-[#667781]">
{step === 1 ? 'Configurar disparo' : step === 2 ? 'Destinatários' : 'Confirmar'}
</span>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{/* ── STEP 1: schedule config ─────────────────────────────── */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Nome do agendamento</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
placeholder="Ex: Aniversários de Abril"
value={name}
onChange={e => setName(e.target.value)}
/>
</div>
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Instância WhatsApp</label>
<select
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#f59e0b]/30"
value={instanceId}
onChange={e => setInstanceId(e.target.value)}
>
<option value="">Selecionar instância...</option>
{instances.map((inst: any) => (
<option key={inst.id} value={inst.id} disabled={inst.status !== 'CONNECTED'}>
{inst.name} {inst.status !== 'CONNECTED' ? '(desconectada)' : ''}
</option>
))}
</select>
</div>
{/* Schedule type selector */}
<div className="space-y-2">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de agendamento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'ONCE' as ScheduleType, label: 'Uma vez', icon: <Clock className="w-4 h-4" />, color: '#0ea5e9' },
{ type: 'RECURRING' as ScheduleType, label: 'Recorrente', icon: <RotateCw className="w-4 h-4" />, color: '#8b5cf6' },
{ type: 'EVENT' as ScheduleType, label: 'Evento', icon: <Gift className="w-4 h-4" />, color: '#ef4444' },
].map(opt => (
<button
key={opt.type}
onClick={() => setScheduleType(opt.type)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 transition-all text-sm font-semibold ${
scheduleType === opt.type
? 'border-current shadow-sm'
: 'border-gray-200 text-gray-400 hover:border-gray-300'
}`}
style={scheduleType === opt.type ? { borderColor: opt.color, color: opt.color, backgroundColor: opt.color + '10' } : {}}
>
{opt.icon}
{opt.label}
</button>
))}
</div>
</div>
{/* ONCE — datetime */}
{scheduleType === 'ONCE' && (
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Data e hora</label>
<input
type="datetime-local"
min={minDateTime}
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#0ea5e9]/30"
value={scheduledAt}
onChange={e => setScheduledAt(e.target.value)}
/>
</div>
)}
{/* RECURRING — cron */}
{scheduleType === 'RECURRING' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Expressão Cron</label>
<input
className="w-full rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2.5 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-[#8b5cf6]/30"
placeholder="0 9 * * 1-5"
value={cronExpr}
onChange={e => setCronExpr(e.target.value)}
/>
</div>
<div className="space-y-1">
<p className="text-xs text-[#667781] font-medium">Presets:</p>
<div className="flex flex-wrap gap-1.5">
{CRON_PRESETS.map(p => (
<button
key={p.expr}
onClick={() => setCronExpr(p.expr)}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
cronExpr === p.expr
? 'bg-[#8b5cf6] text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
)}
{/* EVENT — tipo */}
{scheduleType === 'EVENT' && (
<div className="space-y-3">
<div className="space-y-1">
<label className="block text-xs font-semibold text-[#667781] uppercase tracking-wider">Tipo de evento</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: 'BIRTHDAY' as EventType, label: '🎂 Aniversário' },
{ type: 'ANNIVERSARY' as EventType, label: '💍 Aniversariante' },
{ type: 'SIGNUP_DAYS' as EventType, label: '📅 X dias após cadastro' },
].map(opt => (
<button
key={opt.type}
onClick={() => setEventType(opt.type)}
className={`py-2.5 px-2 rounded-xl border-2 text-xs font-semibold transition-all ${
eventType === opt.type
? 'border-[#ef4444] bg-[#fee2e2] text-[#ef4444]'
: 'border-gray-200 text-gray-500 hover:border-gray-300'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
{eventType === 'SIGNUP_DAYS' && (
<div className="flex items-center gap-3">
<label className="text-xs font-semibold text-[#667781] uppercase tracking-wider whitespace-nowrap">Dias após cadastro</label>
<input
type="number" min="1" max="365"
className="w-24 rounded-xl border border-gray-200 bg-[#f8f9fa] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#ef4444]/30"
value={daysAfter}
onChange={e => setDaysAfter(e.target.value)}
/>
</div>
)}
<div className="rounded-xl bg-amber-50 border border-amber-200 px-3 py-2.5 text-xs text-amber-700">
<strong>Variáveis disponíveis:</strong> {'{{name}}, {{birthday}}, {{anniversary}}'}<br />
São substituídas automaticamente por contato no envio.
</div>
</div>
)}
</div>
)}
{/* ── STEP 2: recipients ─────────────────────────────────── */}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-[#667781]" />
<span className="text-sm font-semibold text-[#111b21]">Destinatários</span>
<span className="text-xs text-[#667781]">({validRecipients.length} válido{validRecipients.length !== 1 ? 's' : ''})</span>
</div>
<button onClick={addRecipient} className="flex items-center gap-1 text-xs text-[#00a884] font-medium hover:underline">
<Plus className="w-3.5 h-3.5" /> Adicionar
</button>
</div>
<div className="space-y-3 max-h-[380px] overflow-y-auto pr-1">
{recipients.map((r) => (
<div key={r._key} className="rounded-xl border border-gray-200 bg-[#f8f9fa] p-3 space-y-2">
<div className="flex gap-2 items-start">
<input
className="flex-[2] rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Número ou JID ex: 5511999999999"
value={r.jid}
onChange={e => updateRecipient(r._key, 'jid', e.target.value)}
/>
<input
className="flex-1 rounded-lg border border-gray-200 bg-white px-2 py-1.5 text-xs focus:outline-none focus:ring-1 focus:ring-[#00a884]"
placeholder="Nome ({{name}})"
value={r.name ?? ''}
onChange={e => updateRecipient(r._key, 'name', e.target.value)}
/>
<button onClick={() => removeRecipient(r._key)} disabled={recipients.length <= 1}
className="p-1.5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-500 disabled:opacity-30 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{scheduleType === 'EVENT' && (
<div className="grid grid-cols-3 gap-1.5">
{(eventType === 'BIRTHDAY' || eventType === 'ANNIVERSARY') && (
<>
<input
type="date"
className="rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Aniversário"
value={eventType === 'BIRTHDAY' ? (r.birthday ?? '') : (r.anniversary ?? '')}
onChange={e => updateRecipient(r._key, eventType === 'BIRTHDAY' ? 'birthday' : 'anniversary', e.target.value)}
/>
</>
)}
{eventType === 'SIGNUP_DAYS' && (
<input
type="date"
className="col-span-2 rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] focus:outline-none"
placeholder="Data de cadastro"
value={r.signupDate ?? ''}
onChange={e => updateRecipient(r._key, 'signupDate', e.target.value)}
/>
)}
</div>
)}
</div>
))}
</div>
{validRecipients.length === 0 && (
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
Adicione ao menos um destinatário com número válido.
</div>
)}
</div>
)}
{/* ── STEP 3: confirm ─────────────────────────────────────── */}
{step === 3 && (
<div className="space-y-4">
{saved ? (
<div className="flex flex-col items-center justify-center py-10 gap-3">
<CheckCircle2 className="w-14 h-14 text-[#10b981]" />
<p className="text-base font-semibold text-[#111b21]">Agendamento criado!</p>
<p className="text-xs text-[#667781] text-center">O disparo será executado conforme configurado.</p>
</div>
) : (
<>
<div className="rounded-xl bg-[#f8f9fa] border border-gray-200 p-4 space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-[#667781]">Nome</span>
<span className="font-medium text-[#111b21]">{name}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Tipo de mensagem</span>
<span className="font-medium text-[#111b21]">{typeLabels[messageType]}</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Disparo</span>
<span className="font-medium text-[#111b21]">
{scheduleType === 'ONCE' && `Uma vez — ${scheduledAt.replace('T', ' ')}`}
{scheduleType === 'RECURRING' && `Recorrente — ${cronExpr}`}
{scheduleType === 'EVENT' && `Evento: ${eventType}`}
</span>
</div>
<div className="flex justify-between">
<span className="text-[#667781]">Destinatários</span>
<span className="font-medium text-[#111b21]">{validRecipients.length} contato{validRecipients.length !== 1 ? 's' : ''}</span>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-xl px-3 py-2">
<AlertTriangle className="w-4 h-4 shrink-0" />
{error}
</div>
)}
</>
)}
</div>
)}
</div>
{/* Footer */}
{!saved && (
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-between shrink-0">
<button
onClick={() => step > 1 ? setStep(prev => (prev - 1) as any) : onClose()}
className="px-4 py-2 rounded-xl text-sm font-medium text-[#667781] hover:bg-gray-100 transition-colors"
>
{step > 1 ? 'Voltar' : 'Cancelar'}
</button>
<button
onClick={() => {
if (step === 1) setStep(2)
else if (step === 2) setStep(3)
else handleSave()
}}
disabled={
(step === 1 && !isStep1Valid()) ||
(step === 2 && validRecipients.length === 0) ||
saving
}
className="flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-[#f59e0b] hover:bg-[#d97706] disabled:opacity-50 transition-all active:scale-95"
>
{saving && <span className="w-4 h-4 border-2 border-white/40 border-t-white rounded-full animate-spin" />}
{step < 3 ? 'Continuar' : 'Confirmar e Salvar'}
</button>
</div>
)}
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,198 @@
import React, { useEffect, useState, useCallback } from 'react';
import { ShieldCheck, LayoutGrid, Check, Loader2, Tag, AlertTriangle, Briefcase } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
interface SettingsPanelProps {
instanceId: string | null;
}
// Engines oferecidas no painel. A 'official' (Baileys 6, que dá 401) fica de fora.
// baileys7 → Baileys 7 oficial (a que conecta) → rótulo "API estável"
// infinite → InfinityAPI (com botões)
// titulo = rótulo genérico (todos os usuários). tituloTec = rótulo técnico
// (Baileys/Infinity), exibido apenas para admin/ruibto@gmail.com.
const ENGINES: {
value: string; titulo: string; tituloTec?: string; desc: string; icon: any;
iconCls: string; activeBorder: string; activeBg: string; checkCls: string; btnCls: string;
}[] = [
{
value: 'baileys7', titulo: 'API estável', tituloTec: 'API estável (Baileys 7)',
desc: 'Conexão recomendada do WhatsApp',
icon: ShieldCheck, iconCls: 'text-emerald-500',
activeBorder: 'border-emerald-400', activeBg: 'bg-emerald-50', checkCls: 'text-emerald-600',
btnCls: 'bg-emerald-500 hover:bg-emerald-600',
},
{
value: 'infinite', titulo: 'API com botões', tituloTec: 'InfinityAPI',
desc: 'Com botões interativos (experimental)',
icon: LayoutGrid, iconCls: 'text-amber-500',
activeBorder: 'border-amber-400', activeBg: 'bg-amber-50', checkCls: 'text-amber-600',
btnCls: 'bg-amber-500 hover:bg-amber-600',
},
];
export default function SettingsPanel({ instanceId }: SettingsPanelProps) {
const user = useAuthStore((s) => s.user);
// Termos técnicos (Baileys/Infinity) só para admin ou ruibto@gmail.com.
const canSeeTech = user?.role === 'ADMIN' || user?.email === 'ruibto@gmail.com';
const [engine, setEngine] = useState<string | null>(null);
const [isBusiness, setIsBusiness] = useState<boolean | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState<string | null>(null);
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);
setIsBusiness(inst ? !!inst.isBusiness : null);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [instanceId]);
const selectEngine = useCallback(async (value: string) => {
if (!instanceId || saving || value === engine) return;
// Trocar de engine invalida a sessão (credenciais não migram entre APIs):
// confirma porque exige escanear o QR Code novamente.
if (typeof window !== 'undefined' && !window.confirm(
'Trocar de API vai DESCONECTAR a sessão atual.\n\n' +
'Você precisará escanear o QR Code novamente para reconectar. Continuar?'
)) 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]);
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-2">Configurações</h1>
<p className="text-base text-[#667781] mb-4">Escolha a API de conexão do WhatsApp para esta instância.</p>
<div className="flex items-start gap-3 rounded-xl bg-amber-50 border border-amber-200 p-4 mb-6">
<AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />
<p className="text-sm text-[#5b4b1f]">
Trocar de API <strong>desconecta a sessão</strong> será necessário <strong>escanear o QR Code novamente</strong> para reconectar.
</p>
</div>
{!instanceId && (
<p className="text-base text-[#667781] mb-6">Selecione uma instância primeiro.</p>
)}
{/* ── Engines ─────────────────────────────────────────────── */}
<div className="flex flex-col gap-4 mb-6">
{ENGINES.map((opt) => {
const Icon = opt.icon;
const ativa = engine === opt.value;
const salvando = saving === opt.value;
return (
<div
key={opt.value}
className={`rounded-2xl border-2 bg-white p-8 transition-colors ${ativa ? `${opt.activeBorder} ${opt.activeBg}` : 'border-[#e9edef]'}`}
>
<div className="flex items-center gap-4">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${ativa ? 'bg-white' : 'bg-[#f0f2f5]'}`}>
<Icon className={`w-8 h-8 ${opt.iconCls}`} />
</div>
<div className="flex-1 min-w-0">
<h2 className="text-2xl font-semibold text-[#111b21]">{canSeeTech && opt.tituloTec ? opt.tituloTec : opt.titulo}</h2>
<p className="text-base text-[#667781]">{opt.desc}</p>
</div>
{ativa ? (
<span className={`flex items-center gap-1 font-medium text-lg ${opt.checkCls}`}>
<Check className="w-6 h-6" /> Ativa
</span>
) : (
<button
onClick={() => selectEngine(opt.value)}
disabled={!instanceId || !!saving}
className={`inline-flex items-center gap-2 rounded-xl text-white text-lg font-medium px-6 py-3 disabled:opacity-60 ${opt.btnCls}`}
>
{salvando ? <Loader2 className="w-5 h-5 animate-spin" /> : <Icon className="w-5 h-5" />}
Ativar
</button>
)}
</div>
</div>
);
})}
</div>
{loading && <p className="text-sm text-[#8696a0] mb-4">Carregando configuração atual</p>}
{error && <p className="text-sm text-red-500 mb-4">{error}</p>}
{/* ── Conta Business ──────────────────────────────────────── */}
<div className="rounded-2xl border-2 border-[#e9edef] bg-white p-8 mb-6">
<div className="flex items-center gap-4">
<div className="w-14 h-14 rounded-2xl bg-[#f0f2f5] flex items-center justify-center">
<Briefcase className="w-8 h-8 text-[#54656f]" />
</div>
<div className="flex-1">
<h2 className="text-2xl font-semibold text-[#111b21]">Conta Business</h2>
<p className="text-xl font-medium text-[#41525d] mt-1">
{isBusiness === null ? '—' : isBusiness ? 'Sim (WhatsApp Business)' : 'Não (conta pessoal)'}
</p>
<p className="text-xs text-[#8696a0] mt-1">
Business App Business API botões são garantidos na Business API oficial.
</p>
</div>
</div>
</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>
);
}
@@ -0,0 +1,228 @@
'use client';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Heart, Clock, Loader2 } from 'lucide-react';
import { stickerApi, type StickerRecord } from '../services/chatApiService';
import { getStickerFromCache, setStickerInCache } from '../hooks/useStickerCache';
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003';
const LIST_VERSION_KEY = 'nw:sticker-list-version';
interface StickerPanelProps {
instanceId: string;
jid: string;
onSend: (blob: Blob) => void;
}
// Converte URL Wasabi → URL do proxy do backend
function stickerSrcUrl(wasabiPath: string): string {
const clean = wasabiPath.startsWith('/') ? wasabiPath.slice(1) : wasabiPath;
return `${API}/api/storage/view/${clean}`;
}
// Carrega sticker: tenta IndexedDB, depois proxy, depois persiste no cache
function useStickerBlob(sticker: StickerRecord) {
const [dataUrl, setDataUrl] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
getStickerFromCache(sticker.sha256).then((cached) => {
if (cancelled) return;
if (cached) { setDataUrl(cached); return; }
// Carrega do proxy e converte para dataURL via canvas
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || 96;
canvas.height = img.naturalHeight || 96;
const ctx = canvas.getContext('2d');
if (!ctx) { setDataUrl(img.src); return; }
ctx.drawImage(img, 0, 0);
const url = canvas.toDataURL('image/webp', 0.9);
setDataUrl(url);
setStickerInCache(sticker.sha256, url);
} catch {
setDataUrl(img.src);
}
};
img.onerror = () => { if (!cancelled) setDataUrl(null); };
img.src = stickerSrcUrl(sticker.wasabiPath);
});
return () => { cancelled = true; };
}, [sticker.sha256, sticker.wasabiPath]);
return dataUrl;
}
// Item individual de sticker
function StickerItem({
sticker,
onSend,
onToggleFavorite,
}: {
sticker: StickerRecord;
onSend: (s: StickerRecord) => void;
onToggleFavorite: (s: StickerRecord) => void;
}) {
const dataUrl = useStickerBlob(sticker);
const [hovering, setHovering] = useState(false);
return (
<div
className="relative group cursor-pointer"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onClick={() => dataUrl && onSend(sticker)}
>
<div className="w-16 h-16 flex items-center justify-center rounded-xl hover:bg-black/5 transition-all active:scale-90 p-1">
{dataUrl ? (
<img
src={dataUrl}
alt=""
className="w-full h-full object-contain"
loading="lazy"
/>
) : (
<div className="w-10 h-10 bg-gray-100 rounded-lg animate-pulse" />
)}
</div>
{/* Botão de favoritar — aparece no hover */}
{hovering && (
<button
className={`absolute top-0.5 right-0.5 p-0.5 rounded-full transition-all z-10 ${
sticker.isFavorite
? 'text-red-400 bg-white/90'
: 'text-gray-300 bg-white/80 hover:text-red-400'
}`}
onClick={(e) => { e.stopPropagation(); onToggleFavorite(sticker); }}
title={sticker.isFavorite ? 'Remover dos favoritos' : 'Favoritar'}
>
<Heart className="w-3 h-3" fill={sticker.isFavorite ? 'currentColor' : 'none'} />
</button>
)}
</div>
);
}
export default function StickerPanel({ instanceId, jid, onSend }: StickerPanelProps) {
const [tab, setTab] = useState<'recent' | 'favorites'>('recent');
const [stickers, setStickers] = useState<StickerRecord[]>([]);
const [loading, setLoading] = useState(true);
const fetchedRef = useRef(false);
const load = useCallback(async (force = false) => {
setLoading(true);
try {
const { listVersion, stickers: list } = await stickerApi.list();
if (!force) {
const cached = localStorage.getItem(LIST_VERSION_KEY);
if (cached === listVersion) {
// versão igual — sem novidades, mas ainda mostra o que temos
}
localStorage.setItem(LIST_VERSION_KEY, listVersion);
}
setStickers(list);
} catch {
// silencioso — sem stickers ainda
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
load();
}, [load]);
const handleSend = useCallback(async (sticker: StickerRecord) => {
const dataUrl = await getStickerFromCache(sticker.sha256);
if (!dataUrl) return;
// Converte dataURL → Blob → envia
const res = await fetch(dataUrl);
const blob = await res.blob();
onSend(blob);
}, [onSend]);
const handleToggleFavorite = useCallback(async (sticker: StickerRecord) => {
const result = await stickerApi.toggleFavorite(sticker.id);
setStickers((prev) =>
prev.map((s) => s.id === sticker.id ? { ...s, isFavorite: result.isFavorite } : s)
);
}, []);
const displayed =
tab === 'favorites'
? stickers.filter((s) => s.isFavorite)
: stickers; // recent = all sorted by lastSeenAt (backend already sorts)
return (
<div className="w-72 bg-white rounded-2xl shadow-2xl border border-gray-100 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-gray-100">
<button
onClick={() => setTab('recent')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'recent' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Clock className="w-3.5 h-3.5" />
Recentes
</button>
<button
onClick={() => setTab('favorites')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors ${
tab === 'favorites' ? 'text-[#00a884] border-b-2 border-[#00a884]' : 'text-[#54656f] hover:bg-gray-50'
}`}
>
<Heart className="w-3.5 h-3.5" />
Favoritos
</button>
</div>
{/* Content */}
<div className="h-[220px] overflow-y-auto p-2">
{loading ? (
<div className="flex items-center justify-center h-full text-gray-300">
<Loader2 className="w-6 h-6 animate-spin" />
</div>
) : displayed.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-2 text-gray-300">
{tab === 'favorites' ? (
<>
<Heart className="w-8 h-8" />
<span className="text-xs">Nenhum favorito ainda</span>
</>
) : (
<>
<span className="text-3xl">🙂</span>
<span className="text-xs text-center">
As figurinhas que você receber<br />aparecem aqui automaticamente
</span>
</>
)}
</div>
) : (
<div className="grid grid-cols-4 gap-0.5">
{displayed.map((s) => (
<StickerItem
key={s.id}
sticker={s}
onSend={handleSend}
onToggleFavorite={handleToggleFavorite}
/>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,271 @@
'use client';
/**
* TemplateLibrary modal de biblioteca de templates de mensagens.
*
* Permite:
* - Navegar templates por tipo
* - Pesquisar por nome
* - Selecionar um template para usar (retorna o payload ao pai)
* - Deletar templates
* - Ver contagem de uso
*/
import React, { useEffect, useState, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { motion, AnimatePresence } from 'framer-motion'
import {
X, Search, Trash2, Clock, Type, MousePointerClick,
Link, List, BarChart2, LayoutGrid, BookOpen, CheckCircle2,
} from 'lucide-react'
import { templateApi, type MessageTemplate, type TemplateType } from '../services/chatApiService'
// ─── Helpers ─────────────────────────────────────────────────────────────────
const TYPE_META: Record<TemplateType, { label: string; icon: React.ReactNode; color: string; bg: string }> = {
TEXT: { label: 'Texto', icon: <Type className="w-3.5 h-3.5" />, color: '#667781', bg: '#f0f2f5' },
BUTTONS: { label: 'Botões', icon: <MousePointerClick className="w-3.5 h-3.5" />, color: '#0ea5e9', bg: '#e0f2fe' },
INTERACTIVE: { label: 'CTA', icon: <Link className="w-3.5 h-3.5" />, color: '#8b5cf6', bg: '#ede9fe' },
LIST: { label: 'Lista', icon: <List className="w-3.5 h-3.5" />, color: '#f59e0b', bg: '#fef3c7' },
POLL: { label: 'Enquete', icon: <BarChart2 className="w-3.5 h-3.5" />, color: '#10b981', bg: '#d1fae5' },
CAROUSEL: { label: 'Carrossel', icon: <LayoutGrid className="w-3.5 h-3.5" />, color: '#ef4444', bg: '#fee2e2' },
}
function TypeBadge({ type }: { type: TemplateType }) {
const meta = TYPE_META[type]
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-semibold"
style={{ color: meta.color, backgroundColor: meta.bg }}>
{meta.icon}
{meta.label}
</span>
)
}
function PayloadPreview({ template }: { template: MessageTemplate }) {
const p = template.payload
if (template.type === 'TEXT') return <span className="line-clamp-2">{(p as any).text || '—'}</span>
if (template.type === 'BUTTONS') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} botões]</span>
if (template.type === 'INTERACTIVE') return <span className="line-clamp-1">{(p as any).text || ''} [{((p as any).nativeButtons ?? []).length} CTAs]</span>
if (template.type === 'LIST') return <span className="line-clamp-1">{(p as any).text || ''} {(p as any).nativeList?.buttonText}</span>
if (template.type === 'POLL') return <span className="line-clamp-1">📊 {(p as any).poll?.name} ({((p as any).poll?.values ?? []).length} opções)</span>
if (template.type === 'CAROUSEL') return <span className="line-clamp-1">🎠 {((p as any).nativeCarousel?.cards ?? []).length} cards</span>
return null
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface Props {
isOpen: boolean
onClose: () => void
onSelect: (template: MessageTemplate) => void
}
// ─── Component ────────────────────────────────────────────────────────────────
const ALL = 'ALL'
type FilterType = TemplateType | typeof ALL
export default function TemplateLibrary({ isOpen, onClose, onSelect }: Props) {
const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [loading, setLoading] = useState(false)
const [search, setSearch] = useState('')
const [filter, setFilter] = useState<FilterType>(ALL)
const [deleting, setDeleting] = useState<string | null>(null)
const [selected, setSelected] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => { setMounted(true) }, [])
const load = useCallback(async () => {
setLoading(true)
try {
const data = await templateApi.list({
...(filter !== ALL ? { type: filter } : {}),
...(search ? { search } : {}),
})
setTemplates(data)
} finally {
setLoading(false)
}
}, [filter, search])
useEffect(() => {
if (isOpen) load()
}, [isOpen, load])
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (!confirm('Remover este template?')) return
setDeleting(id)
try {
await templateApi.delete(id)
setTemplates(prev => prev.filter(t => t.id !== id))
} finally {
setDeleting(null)
}
}
const handleSelect = (template: MessageTemplate) => {
setSelected(template.id)
setTimeout(() => {
onSelect(template)
setSelected(null)
onClose()
}, 200)
}
const filterTabs: Array<{ key: FilterType; label: string }> = [
{ key: ALL, label: 'Todos' },
...Object.entries(TYPE_META).map(([k, v]) => ({ key: k as TemplateType, label: v.label })),
]
if (!mounted) return null
return createPortal(
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-50 flex items-center justify-center p-4"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[85vh] flex flex-col overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-100 shrink-0">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-[#e0f2fe] flex items-center justify-center">
<BookOpen className="w-5 h-5 text-[#0ea5e9]" />
</div>
<div>
<h2 className="text-base font-semibold text-[#111b21]">Biblioteca de Templates</h2>
<p className="text-xs text-[#667781]">{templates.length} template{templates.length !== 1 ? 's' : ''} salvos</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-full hover:bg-gray-100 text-gray-400 transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Search */}
<div className="px-5 py-3 border-b border-gray-100 shrink-0">
<div className="relative">
<Search className="absolute left-3 top-2.5 w-4 h-4 text-gray-400" />
<input
className="w-full pl-9 pr-4 py-2 rounded-xl bg-[#f0f2f5] border-none text-sm focus:outline-none focus:ring-2 focus:ring-[#00a884]/30"
placeholder="Pesquisar templates..."
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
</div>
{/* Filter tabs */}
<div className="px-5 py-2 flex gap-1.5 overflow-x-auto shrink-0 border-b border-gray-100 pb-2">
{filterTabs.map(tab => (
<button
key={tab.key}
onClick={() => setFilter(tab.key)}
className={`px-3 py-1.5 rounded-full text-xs font-semibold whitespace-nowrap transition-all ${
filter === tab.key
? 'bg-[#111b21] text-white'
: 'bg-[#f0f2f5] text-[#667781] hover:bg-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Template list */}
<div className="flex-1 overflow-y-auto p-5">
{loading ? (
<div className="flex items-center justify-center py-16">
<span className="w-6 h-6 border-2 border-gray-200 border-t-[#00a884] rounded-full animate-spin" />
</div>
) : templates.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-center">
<BookOpen className="w-12 h-12 text-gray-200 mb-3" />
<p className="text-sm font-medium text-[#667781]">Nenhum template encontrado</p>
<p className="text-xs text-gray-400 mt-1">Crie um template no compositor de mensagens</p>
</div>
) : (
<div className="space-y-2">
{templates.map(tmpl => (
<motion.button
key={tmpl.id}
layout
onClick={() => handleSelect(tmpl)}
className={`w-full text-left rounded-xl border p-4 transition-all hover:shadow-md active:scale-[0.99] group ${
selected === tmpl.id
? 'border-[#00a884] bg-[#e7f8f2]'
: 'border-gray-200 hover:border-gray-300 bg-white'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<TypeBadge type={tmpl.type} />
{tmpl.usageCount > 0 && (
<span className="flex items-center gap-1 text-[10px] text-gray-400">
<Clock className="w-3 h-3" />
{tmpl.usageCount}x usado
</span>
)}
</div>
<p className="text-sm font-semibold text-[#111b21] truncate">{tmpl.name}</p>
{tmpl.description && (
<p className="text-xs text-[#667781] mt-0.5 line-clamp-1">{tmpl.description}</p>
)}
<p className="text-xs text-gray-400 mt-1.5 line-clamp-2">
<PayloadPreview template={tmpl} />
</p>
{tmpl.tags.length > 0 && (
<div className="flex gap-1 mt-2 flex-wrap">
{tmpl.tags.map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-gray-100 text-[10px] text-gray-500 rounded">
#{tag}
</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
{selected === tmpl.id ? (
<CheckCircle2 className="w-5 h-5 text-[#00a884]" />
) : (
<>
<button
onClick={(e) => handleDelete(e, tmpl.id)}
disabled={deleting === tmpl.id}
className="p-1.5 rounded-full opacity-0 group-hover:opacity-100 hover:bg-red-50 text-gray-400 hover:text-red-500 transition-all"
>
{deleting === tmpl.id
? <span className="w-3.5 h-3.5 border border-gray-300 border-t-gray-500 rounded-full animate-spin block" />
: <Trash2 className="w-3.5 h-3.5" />
}
</button>
</>
)}
</div>
</div>
</motion.button>
))}
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body
)
}
@@ -0,0 +1,107 @@
import React from 'react';
import {
MessageCircle,
Megaphone,
Users,
Settings,
BrainCircuit,
} from 'lucide-react';
import { getAvatarProxyUrl } from '../utils/inboxUtils';
export type TabType = 'chats' | 'broadcasts' | 'groups' | 'settings';
interface ThinSidebarProps {
activeTab: TabType;
setActiveTab: (tab: TabType) => void;
activeInstance: any; // We'll pass the active instance to show the avatar
onNavigate?: (view: string) => void; // satélite: navegação entre as views do plugin
}
export default function ThinSidebar({ activeTab, setActiveTab, activeInstance, onNavigate }: ThinSidebarProps) {
const isConnected = activeInstance?.status === 'connected' || activeInstance?.status === 'open';
const renderIcon = (id: TabType, IconComponent: any, title: string) => {
const isActive = activeTab === id;
return (
<button
key={id}
title={title}
onClick={() => {
if (id === 'groups') {
onNavigate?.('wa-sessions');
} else {
setActiveTab(id);
}
}}
className={`w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors ${
isActive ? 'bg-[#d9dbdf]' : 'hover:bg-[#d9dbdf]/60'
}`}
>
<IconComponent
className={`w-6 h-6 ${isActive ? 'text-[#111b21]' : 'text-[#54656f]'}`}
fill={isActive ? 'currentColor' : 'none'}
/>
</button>
);
};
return (
<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')}
{renderIcon('broadcasts', Megaphone, 'Transmissões')}
{renderIcon('groups', Users, 'Sessões')}
{/* Secretária IA — abre a tela de configuração da secretária virtual */}
<button
title="Secretária IA"
onClick={() => onNavigate?.('wa-secretaria')}
className="w-10 h-10 mb-2 rounded-full flex items-center justify-center transition-colors hover:bg-[#d9dbdf]/60"
>
<BrainCircuit className="w-6 h-6 text-[#54656f]" />
</button>
</div>
{/* Bottom Icons */}
<div className="w-full flex flex-col items-center pb-2">
{/* 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">
{(() => {
// Avatar da instância SEMPRE via proxy do backend. Nunca usar a URL
// crua do CDN do WhatsApp (pps.whatsapp.net) como fallback: ela expira
// e o navegador recebe 403 (hotlink). Sem proxy resolvível → ícone.
const avatarSrc = activeInstance?.instance
? getAvatarProxyUrl({
instance_name: activeInstance.instance,
remote_jid: activeInstance.phone ? `${activeInstance.phone}@s.whatsapp.net` : null,
})
: null;
return avatarSrc ? (
<img
src={avatarSrc}
alt="Status"
className="w-full h-full object-cover"
onError={(e) => { e.currentTarget.style.display = 'none'; }}
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-[#dfe5e7] text-[#54656f]">
<Users className="w-5 h-5" />
</div>
);
})()}
</div>
{/* Status indicator badge */}
<div
className={`absolute bottom-0.5 right-0.5 w-3 h-3 rounded-full border-2 border-[#f0f2f5] ${
isConnected ? 'bg-emerald-500' : 'bg-red-500'
}`}
title={isConnected ? 'Conectado' : 'Desconectado'}
></div>
</div>
</div>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
// Button do motor NewWhats (de @/components/ui) — portado para o satélite.
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'premium';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
loading?: boolean;
icon?: React.ReactNode;
children?: React.ReactNode;
}
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-brand-500 text-white font-bold shadow-lg shadow-brand-500/20 hover:bg-brand-600 active:scale-[0.98]',
secondary: 'bg-white/5 border border-white/10 text-white font-semibold hover:bg-white/10 backdrop-blur-sm',
ghost: 'text-slate-400 hover:text-white hover:bg-white/5',
danger: 'bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20',
premium: 'bg-gradient-to-r from-brand-500 to-blue-500 text-white font-bold shadow-glow-brand hover:shadow-lg active:scale-[0.98] relative overflow-hidden',
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-xs rounded-lg gap-1.5',
md: 'px-4 py-2.5 text-sm rounded-xl gap-2',
lg: 'px-6 py-3.5 text-sm rounded-xl gap-2',
};
export function Button({
variant = 'primary',
size = 'md',
loading,
icon,
children,
className = '',
disabled,
...props
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={`
inline-flex items-center justify-center transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed
${variantClasses[variant]} ${sizeClasses[size]} ${className}
`}
{...props}
>
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : icon ? icon : null}
{children}
{variant === 'premium' && (
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-white/10 to-transparent pointer-events-none" />
)}
</button>
);
}
export default Button;
@@ -0,0 +1,147 @@
'use client';
import React, { useState, useEffect, useMemo, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { getAvatarInitials, getAvatarColor } from '../../utils/inboxUtils';
import { getAvatarFromCache, setAvatarInCache } from '../../hooks/useAvatarCache';
import { avatarApi } from '../../services/chatApiService';
interface AvatarProps {
chat: any;
size?: number;
className?: string;
}
export default function Avatar({ chat, size = 46, className = '' }: AvatarProps) {
const [displayUrl, setDisplayUrl] = useState<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [showInitials, setShowInitials] = useState(false);
const cacheAttempted = useRef(false);
const refreshTried = useRef(false);
const imageUrl = chat?.avatar_url || null;
const version = chat?.avatar_version || null;
const remoteJid = chat?.remote_jid || '';
const instanceName = chat?.instance_name || '';
// Quando jid ou version muda, tenta servir do cache IndexedDB antes de fazer request HTTP.
// Se não houver cache (primeira vez), usa a URL do proxy normalmente.
useEffect(() => {
cacheAttempted.current = false;
refreshTried.current = false;
setIsLoaded(false);
if (!imageUrl) {
setShowInitials(true);
setDisplayUrl(null);
return;
}
setShowInitials(false);
// Sem versão (ex.: satélite, cujo ext /inbox não envia avatar_version) →
// usa a URL do CDN direto, sem cache IndexedDB (o cache depende da versão
// para invalidar). A imagem ainda carrega normalmente.
if (!version) {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
return;
}
// Tenta cache IndexedDB — resultado síncrono do ponto de vista do usuário
getAvatarFromCache(remoteJid, version).then((cached) => {
if (cached) {
setDisplayUrl(cached);
setIsLoaded(true); // já temos os bytes — sem skeleton
} else {
setDisplayUrl(imageUrl); // busca pelo proxy normalmente
}
cacheAttempted.current = true;
}).catch(() => {
setDisplayUrl(imageUrl);
cacheAttempted.current = true;
});
}, [remoteJid, version, imageUrl]);
const handleLoad = (e: React.SyntheticEvent<HTMLImageElement>) => {
setIsLoaded(true);
// Salva no IndexedDB após primeira carga bem-sucedida do proxy.
// Só salva se a versão é conhecida e o displayUrl não é já um dataUrl (evita re-cache).
if (!version || !remoteJid || !displayUrl || displayUrl.startsWith('data:')) return;
try {
const img = e.currentTarget;
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || size;
canvas.height = img.naturalHeight || size;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setAvatarInCache(remoteJid, version, dataUrl);
} catch { /* CORS ou canvas tainted — ignora, cache não é crítico */ }
};
const handleError = async () => {
// Self-heal: a URL do CDN do WhatsApp pode ter expirado (404). Tenta UMA
// vez re-buscar a foto atual pela conexão viva do motor antes de desistir.
if (!refreshTried.current && instanceName && remoteJid) {
refreshTried.current = true;
const fresh = await avatarApi.refresh(instanceName, remoteJid);
if (fresh && fresh !== displayUrl) {
setIsLoaded(false);
setDisplayUrl(fresh);
return;
}
}
setShowInitials(true);
setDisplayUrl(null);
};
const initials = useMemo(() => getAvatarInitials(chat), [chat]);
const bgColor = useMemo(
() => getAvatarColor(chat?.remote_jid || chat?.phone || chat?.displayName || 'anon'),
[chat]
);
return (
<div
className={`relative flex-shrink-0 rounded-full overflow-hidden bg-gray-100 flex items-center justify-center border border-black/5 ${className}`}
style={{ width: size, height: size }}
>
<AnimatePresence mode="wait">
{!showInitials && displayUrl ? (
<motion.img
key={displayUrl}
src={displayUrl}
alt=""
loading="lazy"
decoding="async"
crossOrigin="anonymous"
initial={{ opacity: 0 }}
animate={{ opacity: isLoaded ? 1 : 0 }}
transition={{ duration: 0.3 }}
onLoad={handleLoad}
onError={handleError}
className="w-full h-full object-cover"
/>
) : (
<motion.div
key="initials"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="w-full h-full flex items-center justify-center text-white font-semibold"
style={{ backgroundColor: bgColor, fontSize: size * 0.4 }}
>
{initials}
</motion.div>
)}
</AnimatePresence>
{/* Skeleton apenas quando buscando pelo proxy (não quando servindo do cache) */}
{!isLoaded && !showInitials && displayUrl && !displayUrl.startsWith('data:') && (
<div className="absolute inset-0 bg-gray-200 animate-pulse" />
)}
</div>
);
}
@@ -0,0 +1,35 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Check, AlertCircle } from 'lucide-react';
interface ToastProps {
message: string;
type: 'success' | 'error';
visible: boolean;
}
export default function Toast({ message, type, visible }: ToastProps) {
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, x: 40 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 40 }}
className={`
fixed top-6 right-6 z-[300] flex items-center gap-3 px-5 py-3.5 rounded-2xl
glass-elevated shadow-premium text-sm font-semibold text-white
${type === 'success' ? 'border-l-4 border-l-emerald-500' : 'border-l-4 border-l-red-500'}
`}
>
{type === 'success' ? (
<Check className="w-4 h-4 text-emerald-400" />
) : (
<AlertCircle className="w-4 h-4 text-red-400" />
)}
{message}
</motion.div>
)}
</AnimatePresence>
);
}
@@ -0,0 +1,87 @@
/**
* useChatFilter gerencia toda a lógica de filtro/escopo de chats no inbox.
*
* Extrai de inbox/index.tsx:
* - searchTerm, activeFilter, chatScope, favoriteJids
* - derivados: favoriteSet, filterCounts, chatsByScope, isFavoriteChat
*/
import { useState, useMemo, useCallback } from 'react'
import { normalizeJid } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
export type ChatScope = 'inbox' | 'favorites' | 'archived'
export interface CustomChatList { id: string; name: string; jids: string[] }
export function useChatFilter(chatsStore: NewWhatsChat[]) {
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState('all')
const [chatScope, setChatScope] = useState<ChatScope>('inbox')
const [favoriteJids, setFavoriteJids] = useState<string[]>([])
const [customLists, setCustomLists] = useState<CustomChatList[]>([])
const [activeCustomListId, setActiveCustomListId] = useState<string | null>(null)
// ── Derivados ──────────────────────────────────────────────────────────────
const favoriteSet = useMemo(() => new Set(favoriteJids), [favoriteJids])
const filterCounts = useMemo(() => ({
all: chatsStore.length,
unread: chatsStore.filter(c => c.unread_count > 0).length,
groups: chatsStore.filter(c => c.remote_jid.endsWith('@g.us')).length,
pinned: chatsStore.filter(c => c.is_pinned).length,
archived: chatsStore.filter(c => c.is_archived).length,
}), [chatsStore])
const chatsByScope = useMemo(() => {
let base = chatsStore
if (chatScope === 'favorites') {
base = base.filter(c => favoriteSet.has(normalizeJid(c.remote_jid) || c.remote_jid))
} else if (chatScope === 'archived') {
base = base.filter(c => Boolean(c.is_archived))
} else {
base = base.filter(c => !Boolean(c.is_archived))
}
if (activeFilter === 'unread') return base.filter(c => c.unread_count > 0)
if (activeFilter === 'groups') return base.filter(c => c.remote_jid.endsWith('@g.us'))
if (activeFilter === 'pinned') return base.filter(c => c.is_pinned)
return base
}, [chatScope, chatsStore, favoriteSet, activeFilter])
const isFavoriteChat = useCallback(
(chat: NewWhatsChat) => favoriteSet.has(normalizeJid(chat.remote_jid) || chat.remote_jid),
[favoriteSet]
)
const toggleFavorite = useCallback((jid: string) => {
const normalized = normalizeJid(jid) || jid
setFavoriteJids(prev =>
prev.includes(normalized) ? prev.filter(j => j !== normalized) : [...prev, normalized]
)
}, [])
return {
// Estado
searchTerm,
activeFilter,
chatScope,
favoriteJids,
customLists,
activeCustomListId,
// Setters
setSearchTerm,
setActiveFilter,
setChatScope,
setCustomLists,
setActiveCustomListId,
// Derivados
favoriteSet,
filterCounts,
chatsByScope,
isFavoriteChat,
toggleFavorite,
}
}
@@ -0,0 +1,144 @@
/**
* useChatWindow gerencia o estado e handlers da área de chat ativa.
*
* Extrai de inbox/index.tsx:
* - scroll + carregamento de histórico
* - indicador de digitação
* - reply / reaction
* - envio de mídia e áudio
* - sync de avatar
* - abertura de perfil / menu de chat
*/
import { useState, useRef, useCallback } from 'react'
import { mediaApi } from '../../services/chatApiService'
import type { NewWhatsChat } from '../../types/inboxTypes'
import type { Message } from '../../types/inboxTypes'
interface UseChatWindowOptions {
selectedChat: NewWhatsChat | null
activeInstanceId: string | null
onLoadMoreHistory: () => Promise<{ hasMore: boolean }>
}
export function useChatWindow({
selectedChat,
activeInstanceId,
onLoadMoreHistory,
}: UseChatWindowOptions) {
// ── Refs ────────────────────────────────────────────────────────────────────
const scrollRef = useRef<HTMLDivElement>(null)
const isTypingRef = useRef(false)
const typingTimeout = useRef<NodeJS.Timeout | null>(null)
// ── Estado ──────────────────────────────────────────────────────────────────
const [isTyping, setIsTyping] = useState(false)
const [replyingTo, setReplyingTo] = useState<Message | null>(null)
const [reactionPickerFor, setReactionPickerFor] = useState<string | null>(null)
const [hasMoreHistory, setHasMoreHistory] = useState(true)
const [loadingMore, setLoadingMore] = useState(false)
const [isProfileOpen, setIsProfileOpen] = useState(false)
const [isSyncingAvatar, setIsSyncingAvatar] = useState(false)
const [isChatMenuOpen, setIsChatMenuOpen] = useState(false)
// ── Handlers ────────────────────────────────────────────────────────────────
const handleTyping = useCallback(() => {
if (!selectedChat) return
if (!isTypingRef.current) {
isTypingRef.current = true
setIsTyping(true)
}
if (typingTimeout.current) clearTimeout(typingTimeout.current)
typingTimeout.current = setTimeout(() => {
isTypingRef.current = false
setIsTyping(false)
}, 3000)
}, [selectedChat])
const handleScroll = useCallback(async () => {
const el = scrollRef.current
if (!el || !hasMoreHistory || loadingMore) return
if (el.scrollTop < 100) {
setLoadingMore(true)
const { hasMore } = await onLoadMoreHistory()
setHasMoreHistory(hasMore)
setLoadingMore(false)
}
}, [hasMoreHistory, loadingMore, onLoadMoreHistory])
const handleSelectChat = useCallback(() => {
setHasMoreHistory(true)
}, [])
const handleReactToMessage = useCallback((_msg: Message, _emoji: string) => {
setReactionPickerFor(null)
// Será implementado via endpoint /api/instances/:id/react
}, [])
const handleSendMedia = useCallback(async (file: File, caption?: string) => {
if (!selectedChat || !activeInstanceId) return
try {
await mediaApi.sendMedia(activeInstanceId, selectedChat.remote_jid, file, caption || undefined, undefined)
} catch (err) {
console.error('Erro ao enviar mídia:', err)
}
}, [selectedChat, activeInstanceId])
const handleSendAudio = useCallback(async (blob: Blob) => {
if (!selectedChat || !activeInstanceId) return
try {
await mediaApi.sendAudio(activeInstanceId, selectedChat.remote_jid, blob)
} catch (err) {
console.error('Erro ao enviar áudio:', err)
}
}, [selectedChat, activeInstanceId])
const handleSyncAvatar = useCallback(async () => {
if (!selectedChat || !activeInstanceId) return
setIsSyncingAvatar(true)
try {
const API = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3003'
const token = localStorage.getItem('token')
await fetch(
`${API}/api/inbox/avatar/${encodeURIComponent(selectedChat.remote_jid)}?instance=${activeInstanceId}`,
{ headers: { Authorization: `Bearer ${token}` } }
)
} finally {
setIsSyncingAvatar(false)
}
}, [selectedChat, activeInstanceId])
const toggleReactionPicker = useCallback((id: string) => {
setReactionPickerFor(prev => prev === id ? null : id)
}, [])
return {
// Refs
scrollRef,
// Estado
isTyping,
replyingTo,
reactionPickerFor,
hasMoreHistory,
loadingMore,
isProfileOpen,
isSyncingAvatar,
isChatMenuOpen,
// Setters simples
setReplyingTo,
setIsProfileOpen,
setIsChatMenuOpen,
// Handlers
handleTyping,
handleScroll,
handleSelectChat,
handleReactToMessage,
handleSendMedia,
handleSendAudio,
handleSyncAvatar,
toggleReactionPicker,
}
}
@@ -0,0 +1,129 @@
/**
* useInstanceSelector gerencia seleção e troca de instâncias WhatsApp.
*
* Extrai toda a lógica de instâncias do inbox/index.tsx.
* O componente visual consome apenas as variáveis retornadas.
*/
import { useRef, useCallback, useMemo, useEffect } from 'react'
import { useInstanceStore } from '../../store/instanceStore'
import { useChatStore } from '../../store/chatStore'
import { socketService } from '../../services/socketService'
// ─── Tipos ────────────────────────────────────────────────────────────────────
export interface LegacyInstance {
instance: string
nome: string
status: string
phone: string | null
avatar: string | null
profilePictureUrl: string | null
}
export interface MappedInstance {
id: string
nome: string
avatar: string | null
phone: string | null
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useInstanceSelector() {
const instances = useInstanceStore((s) => s.instances)
const activeId = useInstanceStore((s) => s.activeInstanceId)
const setActiveId = useInstanceStore((s) => s.setActiveInstance)
const loadInstances = useInstanceStore((s) => s.loadInstances)
const connectInstance = useInstanceStore((s) => s.connectInstance)
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
const resetChats = useChatStore((s) => s.setChats)
const dropdownRef = useRef<HTMLDivElement>(null)
// ── Formato legado esperado pelo JSX do inbox ──────────────────────────────
const instancesLegacy = useMemo<LegacyInstance[]>(() =>
instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: i.avatar ?? null,
profilePictureUrl: i.avatar ?? null,
})),
[instances]
)
const activeInstance = useMemo<LegacyInstance | null>(
() => instancesLegacy.find((i) => i.instance === activeId) ?? null,
[instancesLegacy, activeId]
)
// Versão com `id` usada por alguns componentes internos
const mappedActiveInstance = useMemo<MappedInstance | null>(
() => activeInstance
? { id: activeInstance.instance, nome: activeInstance.nome, avatar: activeInstance.avatar ?? null, phone: activeInstance.phone }
: null,
[activeInstance]
)
// Apenas instâncias conectadas (para o dropdown de troca rápida)
const connectedInstances = useMemo<MappedInstance[]>(() =>
instances
.filter((i) => i.status === 'CONNECTED')
.map((i) => ({ id: i.id, nome: i.name, avatar: i.avatar ?? null, phone: i.phone })),
[instances]
)
const isConnected = activeInstance?.status === 'connected'
// ── Auto-select primeira instância disponível ──────────────────────────────
useEffect(() => {
if (!activeId && instances.length > 0) {
const first = instances[0]
setActiveId(first.id)
socketService.joinInstance(first.id)
}
}, [instances, activeId, setActiveId])
// ── Troca de instância ────────────────────────────────────────────────────
const selectInstance = useCallback((legacyInstance: LegacyInstance | { instance: string }) => {
const id = legacyInstance.instance
if (id === activeId) return
resetChats([])
setActiveId(id)
socketService.joinInstance(id)
}, [activeId, setActiveId, resetChats])
// ── Refresh ────────────────────────────────────────────────────────────────
const refreshInstances = useCallback(async () => {
await loadInstances()
}, [loadInstances])
const handleConnect = useCallback(async (instanceId: string) => {
await connectInstance(instanceId)
}, [connectInstance])
return {
// Estado
instances: instancesLegacy,
activeInstance,
mappedActiveInstance,
connectedInstances,
isConnected,
qrByInstance,
activeInstanceId: activeId,
// Refs (para click-outside no dropdown)
dropdownRef,
// Handlers
selectInstance,
refreshInstances,
handleConnect,
}
}
@@ -0,0 +1,41 @@
/**
* useMessageInput gerencia o estado e envio da barra de mensagem.
*
* Extrai de inbox/index.tsx:
* - newMessage + setNewMessage
* - handleSendMessage
*/
import { useState, useCallback } from 'react'
import type { NewWhatsChat } from '../../types/inboxTypes'
interface UseMessageInputOptions {
selectedChat: NewWhatsChat | null
replyingTo?: { message_id: string } | null
onClearReply?: () => void
onSendText: (text: string, mentions?: string[], replyToMessageId?: string) => Promise<void>
}
export function useMessageInput({ selectedChat, replyingTo, onClearReply, onSendText }: UseMessageInputOptions) {
const [newMessage, setNewMessage] = useState('')
const [mentions, setMentions] = useState<string[]>([])
const handleSendMessage = useCallback(async (e?: React.FormEvent) => {
e?.preventDefault()
if (!selectedChat || !newMessage.trim()) return
const text = newMessage
const mentionJids = mentions.length > 0 ? [...mentions] : undefined
const replyToMessageId = replyingTo?.message_id
setNewMessage('')
setMentions([])
onClearReply?.()
await onSendText(text, mentionJids, replyToMessageId)
}, [selectedChat, newMessage, mentions, replyingTo, onSendText, onClearReply])
return {
newMessage,
setNewMessage,
mentions,
setMentions,
handleSendMessage,
}
}
@@ -0,0 +1,268 @@
/**
* useNewInboxBridge adapta chatStore + instanceStore para o formato
* que os componentes de display legados consomem.
* Nenhum componente visual precisa ser alterado.
*/
import { useMemo, useCallback, useEffect, useRef } from 'react'
import { useChatStore } from '../../store/chatStore'
import { useInstanceStore } from '../../store/instanceStore'
import { chatApi, messageApi, reconcileApi } from '../../services/chatApiService'
import { socketService } from '../../services/socketService'
import { formatPhone } from '../../utils/inboxUtils'
import type { NewWhatsChat } from '../../types/inboxTypes'
// ─── Mapeamentos legado ───────────────────────────────────────────────────────
function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]): NewWhatsChat {
const isGroup = c.jid.endsWith('@g.us')
const jidUser = c.jid.split('@')[0]
// Telefone formatado — sempre passa por formatPhone para normalizar E.164 → exibição
// c.contactPhone é o número cru do DB (ex: "5511999887766"); formatPhone aplica máscara
const formattedPhone = isGroup ? jidUser : formatPhone(c.contactPhone ?? jidUser, c.jid)
// 1. Nome de Exibição
// Regra estrita: grupos usam SEMPRE o subject (c.name) — nunca o
// pushName/contactName do último participante que enviou mensagem.
let displayName = isGroup
? (c.name || 'Grupo')
: (c.contactName || c.name || formattedPhone || jidUser)
// Limpeza final: se por algum motivo ainda vier o JID completo, removemos o sufixo
if (displayName.includes('@')) {
displayName = displayName.split('@')[0]
}
// 2. Avatar — o motor (ext /inbox) já devolve a URL pública do CDN do
// WhatsApp (pps.whatsapp.net) em contactAvatarUrl; usamos direto (o <img>
// carrega cross-origin sem CORS). Quando é null, Avatar.tsx mostra iniciais.
const avatar_url: string | null = c.contactAvatarUrl ?? null
const avatar_version: string | null = c.contactAvatarVersion ?? null
return {
id: c.id as any,
instance_name: c.instanceId,
remote_jid: c.jid,
phone: formattedPhone,
lead_name: c.contactName,
avatar_url,
avatar_version,
bio: isGroup ? 'Detalhes do Grupo' : (formattedPhone || 'Clique para informações'),
displayName,
subject: c.protocolNumber ? `#${c.protocolNumber}` : (isGroup ? null : formattedPhone),
last_message_text: c.lastMessageBody || null,
last_message_time: c.lastMessageAt || c.createdAt,
last_message_from_me: c.lastMessageFromMe ? 1 : 0,
last_message_status: c.lastMessageStatus?.toLowerCase() ?? null,
last_message_type: c.lastMessageType?.toLowerCase() ?? null,
last_message_sender: c.lastMessageSender ?? null,
unread_count: c.unreadCount,
is_pinned: c.isPinned,
is_archived: c.isArchived,
bot_paused: c.botPaused ?? null,
labels: [],
}
}
function messagesToLegacy(msgs: ReturnType<typeof useChatStore.getState>['messages'][string]) {
return (msgs ?? []).map((m) => ({
id: m.id as any,
chat_id: m.chatId as any,
message_id: m.messageId,
text: m.body ?? '',
direction: (m.fromMe ? 'out' : 'in') as 'in' | 'out',
timestamp: new Date(m.timestamp).getTime(),
status: m.status.toLowerCase(),
type: m.type.toLowerCase(),
media_url: m.mediaUrl,
transcription: (m as any).transcription ?? null,
mime_type: m.mimeType,
reply_to_id: m.replyToId,
// Quoted message — mapeia replyTo do Prisma para o formato do MessageBubble
quoted_id: m.replyTo?.id ?? null,
quoted_message_id: m.replyTo?.messageId ?? null,
quoted_message_text: m.replyTo?.body ?? null,
quoted_participant: m.replyTo ? (m.replyTo.fromMe ? 'me' : null) : null,
participant: m.pushName ?? null,
senderJid: m.senderJid ?? null,
sender_jid: m.senderJid ?? null,
remote_jid: m.chatId,
from_me: m.fromMe ? 1 : 0,
rich_payload: (m as any).richPayload ?? null,
}))
}
// ─── Hook principal ───────────────────────────────────────────────────────────
export function useNewInboxBridge() {
const initialized = useRef(false)
const chatStore = useChatStore()
const instanceStore = useInstanceStore()
// ── Boot: socket + instâncias ─────────────────────────────────────────────
useEffect(() => {
if (initialized.current) return
initialized.current = true
// Satélite: o socketService lê o token do scoreodonto internamente.
socketService.connect()
chatStore.initSocketListeners()
instanceStore.initSocketListeners()
instanceStore.loadInstances()
}, [])
// ── Carregar chats quando instância ativa muda ─────────────────────────────
useEffect(() => {
const { activeInstanceId } = instanceStore
if (!activeInstanceId) return
chatStore.loadChats(activeInstanceId)
socketService.joinInstance(activeInstanceId)
// Reconcilia nomes em background (cache 30min — leve em escala)
reconcileApi.reconcileNames(activeInstanceId).catch(() => {})
}, [instanceStore.activeInstanceId])
// ── Adaptação: instâncias → legado ────────────────────────────────────────
const instancesLegacy = useMemo(
() =>
instanceStore.instances.map((i) => ({
instance: i.id,
nome: i.name,
status: i.status === 'CONNECTED' ? 'connected' : i.status.toLowerCase(),
phone: i.phone,
avatar: null,
profilePictureUrl: null,
})),
[instanceStore.instances]
)
const activeInstanceLegacy = useMemo(
() => instancesLegacy.find((i) => i.instance === instanceStore.activeInstanceId) ?? null,
[instancesLegacy, instanceStore.activeInstanceId]
)
// ── Adaptação: chats → legado ─────────────────────────────────────────────
const chatsLegacy = useMemo(() => chatStore.chats.map(chatToLegacy), [chatStore.chats])
const selectedChatLegacy = useMemo(() => {
if (!chatStore.activeChatId) return null
const c = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
return c ? chatToLegacy(c) : null
}, [chatStore.activeChatId, chatStore.chats])
const messagesLegacy = useMemo(() => {
if (!chatStore.activeChatId) return []
const raw = chatStore.messages[chatStore.activeChatId] ?? []
const mapped = messagesToLegacy(raw)
console.log('[bridge] messagesLegacy recomputed:', { activeChatId: chatStore.activeChatId, rawCount: raw.length, mappedCount: mapped.length })
return mapped
}, [chatStore.activeChatId, chatStore.messages])
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSelectChat = useCallback(async (chat: NewWhatsChat | null) => {
if (!chat) { useChatStore.getState().setActiveChat(null); return }
await chatStore.selectChat(String(chat.id))
}, [chatStore])
const handleSendText = useCallback(async (text: string, mentions?: string[], replyToMessageId?: string) => {
if (!chatStore.activeChatId || !instanceStore.activeInstanceId) return
const chat = chatStore.chats.find((c) => c.id === chatStore.activeChatId)
if (!chat) return
await chatStore.sendMessage(instanceStore.activeInstanceId, chat.jid, text, replyToMessageId, mentions)
}, [chatStore, instanceStore.activeInstanceId])
const handleLoadMoreHistory = useCallback(async () => {
if (!chatStore.activeChatId) return { hasMore: false }
const oldest = chatStore.messages[chatStore.activeChatId]?.[0]
if (!oldest) return { hasMore: false }
return chatStore.loadMessages(chatStore.activeChatId, { before: oldest.timestamp })
}, [chatStore])
const handleSetActiveInstance = useCallback((legacyInstance: any) => {
instanceStore.setActiveInstance(legacyInstance?.instance ?? null)
}, [instanceStore])
const handleRefreshInstances = useCallback(async () => {
await instanceStore.loadInstances()
}, [instanceStore])
const handleConnectInstance = useCallback(async (instanceId: string) => {
await instanceStore.connectInstance(instanceId)
}, [instanceStore])
const handleArchiveChat = useCallback(async (chatId: string, archived: boolean) => {
await chatApi.archive(chatId, archived)
useChatStore.getState().patchChat(chatId, { isArchived: archived })
}, [])
const handlePinChat = useCallback(async (chatId: string, pinned: boolean) => {
await chatApi.pin(chatId, pinned)
useChatStore.getState().patchChat(chatId, { isPinned: pinned })
}, [])
const handleDeleteChat = useCallback(async (chatId: string) => {
await chatApi.delete(chatId)
useChatStore.getState().setChats(
useChatStore.getState().chats.filter((c) => c.id !== chatId)
)
if (useChatStore.getState().activeChatId === chatId) {
useChatStore.getState().setActiveChat(null)
}
}, [])
const handleMarkRead = useCallback(async (chatId: string) => {
await chatApi.markRead(chatId)
useChatStore.getState().patchChat(chatId, { unreadCount: 0 })
}, [])
const handleSearchMessages = useCallback(async (q: string) => {
if (!instanceStore.activeInstanceId || q.length < 2) return []
return messageApi.search(instanceStore.activeInstanceId, q)
}, [instanceStore.activeInstanceId])
// action é a string-key emitida por ChatListItem (ex: 'archive', 'pin', ...)
// onFavoriteToggle é injetado pelo componente pai (inbox page) pois favoritos são estado local
const handleMenuAction = useCallback((
action: string,
chat: NewWhatsChat,
onFavoriteToggle?: (jid: string) => void
) => {
const chatId = String(chat.id)
switch (action) {
case 'archive': handleArchiveChat(chatId, !chat.is_archived); break
case 'pin': handlePinChat(chatId, !chat.is_pinned); break
case 'mark_read': handleMarkRead(chatId); break
case 'delete': handleDeleteChat(chatId); break
case 'favorite': onFavoriteToggle?.(chat.remote_jid); break
}
}, [handleArchiveChat, handlePinChat, handleMarkRead, handleDeleteChat])
return {
instances: instancesLegacy,
activeInstance: activeInstanceLegacy,
chats: chatsLegacy,
selectedChat: selectedChatLegacy,
messages: messagesLegacy,
loadingChats: chatStore.chatsLoading,
loadingMessages: chatStore.messagesLoading,
activeInstanceId: instanceStore.activeInstanceId,
qrByInstance: instanceStore.qrByInstance,
setActiveInstance: handleSetActiveInstance,
refreshInstances: handleRefreshInstances,
handleSelectChat,
handleSendText,
handleLoadMoreHistory,
handleConnectInstance,
handleArchiveChat,
handlePinChat,
handleDeleteChat,
handleMarkRead,
handleSearchMessages,
handleMenuAction,
presenceByJid: {} as Record<string, any>,
isFavoriteChat: (_: NewWhatsChat) => false,
}
}
@@ -0,0 +1,89 @@
'use client'
/**
* useAvatarCache cache de avatares em IndexedDB.
*
* Chave de cache: `${jid}:${version}` (version = hash curto do avatarUrl no banco).
* Quando a versão muda (novo avatar no banco), a chave muda e o cache invalida
* automaticamente sem TTL fixo, sem stale data visível ao usuário.
*
* Armazenamento: dataURL base64 do JPEG/WebP (compatível com <img src>).
* Limpeza: entradas com mais de 30 dias são removidas na abertura do DB.
*/
const DB_NAME = 'nw-avatars'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000 // 30 dias
interface CacheEntry {
key: string // `${jid}:${version}`
dataUrl: string
savedAt: number
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'key' })
}
}
req.onsuccess = () => {
const db = req.result
pruneOldEntries(db)
resolve(db)
}
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
function pruneOldEntries(db: IDBDatabase): void {
const tx = db.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const cutoff = Date.now() - MAX_AGE_MS
const req = store.openCursor()
req.onsuccess = () => {
const cursor = req.result
if (!cursor) return
if ((cursor.value as CacheEntry).savedAt < cutoff) cursor.delete()
cursor.continue()
}
}
export async function getAvatarFromCache(jid: string, version: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
const key = `${jid}:${version}`
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(key)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setAvatarInCache(jid: string, version: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
const key = `${jid}:${version}`
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { key, dataUrl, savedAt: Date.now() }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded ou IDB indisponível — ignora silenciosamente */ }
}
@@ -0,0 +1,173 @@
import { useState, useCallback, useEffect } from 'react'
import { protocolApi, sectorApi, type Sector, type Team } from '../services/chatApiService'
// ─── Types ────────────────────────────────────────────────────────────────────
export type ProtocolStatus =
| 'OPEN'
| 'IN_PROGRESS'
| 'WAITING_CLIENT'
| 'WAITING_AGENT'
| 'RESOLVED'
| 'CANCELLED'
export interface Protocol {
id: string
number: string
chatId: string
contactId: string
sectorId: string | null
teamId: string | null
agentId: string | null
status: ProtocolStatus
summary: string | null
deadline: string | null
resolvedAt: string | null
createdAt: string
updatedAt: string
sector?: { name: string } | null
team?: { name: string } | null
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function useProtocolPanel(chatId: string | null) {
const [protocols, setProtocols] = useState<Protocol[]>([])
const [loading, setLoading] = useState(false)
// Sectors & teams for the "open protocol" form
const [sectors, setSectors] = useState<Sector[]>([])
const [teams, setTeams] = useState<Team[]>([])
const [selectedSectorId, setSelectedSectorId] = useState<string>('')
const [selectedTeamId, setSelectedTeamId] = useState<string>('')
const [loadingSectors, setLoadingSectors] = useState(false)
const [loadingTeams, setLoadingTeams] = useState(false)
// Opening a new protocol
const [isOpening, setIsOpening] = useState(false)
const [showOpenForm, setShowOpenForm] = useState(false)
// Updating status
const [updatingId, setUpdatingId] = useState<string | null>(null)
// Toast
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null)
const showToast = useCallback((message: string, type: 'success' | 'error') => {
setToast({ message, type })
setTimeout(() => setToast(null), 3000)
}, [])
// ── Load protocols ────────────────────────────────────────────────────────
const load = useCallback(async () => {
if (!chatId) { setProtocols([]); return }
setLoading(true)
try {
const data = await protocolApi.list(chatId)
setProtocols(data as Protocol[])
} catch {
// silently ignore
} finally {
setLoading(false)
}
}, [chatId])
useEffect(() => { load() }, [chatId])
// ── Load sectors (lazy, once) ─────────────────────────────────────────────
const loadSectors = useCallback(async () => {
if (sectors.length > 0) return
setLoadingSectors(true)
try {
const data = await sectorApi.list()
setSectors(data.filter((s) => s.isActive))
} catch {
// ignore
} finally {
setLoadingSectors(false)
}
}, [sectors.length])
// ── Load teams when sector changes ────────────────────────────────────────
useEffect(() => {
if (!selectedSectorId) { setTeams([]); setSelectedTeamId(''); return }
setLoadingTeams(true)
sectorApi.listTeams(selectedSectorId)
.then((data) => { setTeams(data.filter((t) => t.isActive)); setSelectedTeamId('') })
.catch(() => {})
.finally(() => setLoadingTeams(false))
}, [selectedSectorId])
const openForm = useCallback(() => {
setShowOpenForm(true)
setSelectedSectorId('')
setSelectedTeamId('')
loadSectors()
}, [loadSectors])
// ── Open new protocol ─────────────────────────────────────────────────────
const openProtocol = useCallback(async () => {
if (!chatId) return
setIsOpening(true)
try {
const protocol = await protocolApi.open(chatId, {
sectorId: selectedSectorId || undefined,
teamId: selectedTeamId || undefined,
})
setProtocols((prev) => [protocol as Protocol, ...prev])
setShowOpenForm(false)
showToast('Protocolo aberto.', 'success')
} catch (err: any) {
showToast(err?.response?.data?.error ?? 'Erro ao abrir protocolo.', 'error')
} finally {
setIsOpening(false)
}
}, [chatId, selectedSectorId, selectedTeamId, showToast])
// ── Update status ─────────────────────────────────────────────────────────
const updateStatus = useCallback(async (protocol: Protocol, status: ProtocolStatus) => {
if (!chatId) return
setUpdatingId(protocol.id)
try {
await protocolApi.update(chatId, protocol.id, { status })
setProtocols((prev) =>
prev.map((p) =>
p.id === protocol.id
? { ...p, status, resolvedAt: status === 'RESOLVED' ? new Date().toISOString() : p.resolvedAt }
: p
)
)
showToast('Status atualizado.', 'success')
} catch {
showToast('Erro ao atualizar status.', 'error')
} finally {
setUpdatingId(null)
}
}, [chatId, showToast])
// ── Derived ───────────────────────────────────────────────────────────────
const activeProtocol = protocols.find(
(p) => p.status !== 'RESOLVED' && p.status !== 'CANCELLED'
) ?? null
const hasActive = activeProtocol !== null
return {
protocols, loading, load,
activeProtocol, hasActive,
// open form
showOpenForm, setShowOpenForm,
openForm,
sectors, teams,
selectedSectorId, setSelectedSectorId,
selectedTeamId, setSelectedTeamId,
loadingSectors, loadingTeams,
isOpening, openProtocol,
// update
updatingId, updateStatus,
toast,
}
}
@@ -0,0 +1,185 @@
/**
* useSessionsLogic toda a lógica da página /sessions. Portado do motor.
* Adaptações no satélite: imports apontam p/ os shims; o bootstrap chama
* socketService.connect() sem depender de localStorage['token'] (o shim o
* token do scoreodonto internamente).
*/
import { useState, useEffect, useCallback } from 'react'
import { useInstanceStore, type Instance } from '../store/instanceStore'
import { socketService } from '../services/socketService'
interface QrModalState {
instanceId: string
instanceName: string
qrBase64: string | null
}
interface Toast {
msg: string
ok: boolean
}
export function useSessionsLogic() {
const storeInstances = useInstanceStore((s) => s.instances)
const storeLoading = useInstanceStore((s) => s.loading)
const loadInstances = useInstanceStore((s) => s.loadInstances)
const createInstance = useInstanceStore((s) => s.createInstance)
const connectInst = useInstanceStore((s) => s.connectInstance)
const disconnectInst = useInstanceStore((s) => s.disconnectInstance)
const deleteInst = useInstanceStore((s) => s.deleteInstance)
const patchInstance = useInstanceStore((s) => s.patchInstance)
const setQr = useInstanceStore((s) => s.setQr)
const clearQr = useInstanceStore((s) => s.clearQr)
const qrByInstance = useInstanceStore((s) => s.qrByInstance)
const initListeners = useInstanceStore((s) => s.initSocketListeners)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [qrModal, setQrModal] = useState<QrModalState | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Instance | null>(null)
const [deleting, setDeleting] = useState(false)
const [toast, setToast] = useState<Toast | null>(null)
useEffect(() => {
socketService.connect() // shim lê o token do scoreodonto internamente
initListeners()
loadInstances()
}, [])
useEffect(() => {
const onStatus = ({ instanceId, status, qrBase64 }: { instanceId: string; status: Instance['status']; qrBase64?: string }) => {
patchInstance(instanceId, { status })
if (qrBase64) {
setQr(instanceId, qrBase64)
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
}
if (status === 'CONNECTED') {
clearQr(instanceId)
setQrModal((prev) => (prev?.instanceId === instanceId ? null : prev))
showToast('WhatsApp conectado!')
}
}
const onQr = ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
setQr(instanceId, qrBase64)
patchInstance(instanceId, { status: 'QR_PENDING' })
setQrModal((prev) => (prev?.instanceId === instanceId ? { ...prev, qrBase64 } : prev))
}
socketService.on('instance:status', onStatus)
socketService.on('qr', onQr)
return () => {
socketService.off('instance:status', onStatus)
socketService.off('qr', onQr)
}
}, [])
const showToast = (msg: string, ok = true) => {
setToast({ msg, ok })
setTimeout(() => setToast(null), 3000)
}
const openAddModal = useCallback(() => setAddOpen(true), [])
const closeAddModal = useCallback(() => { setAddOpen(false); setNewName('') }, [])
const handleCreate = useCallback(async () => {
const name = newName.trim()
if (!name) return
setCreating(true)
try {
const created = await createInstance(name)
closeAddModal()
if (created?.id) {
socketService.joinInstance(created.id)
await connectInst(created.id)
setQrModal({ instanceId: created.id, instanceName: name, qrBase64: null })
}
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao criar instância', false)
} finally {
setCreating(false)
}
}, [newName, createInstance, connectInst, closeAddModal])
const handleConnect = useCallback(async (inst: Instance) => {
setActionLoading(`connect-${inst.id}`)
try {
socketService.joinInstance(inst.id)
await connectInst(inst.id)
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao conectar', false)
} finally {
setActionLoading(null)
}
}, [connectInst])
const handleDisconnect = useCallback(async (inst: Instance) => {
setActionLoading(`disconnect-${inst.id}`)
try {
await disconnectInst(inst.id)
showToast('Instância desconectada')
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao desconectar', false)
} finally {
setActionLoading(null)
}
}, [disconnectInst])
const handleShowQr = useCallback(async (inst: Instance) => {
socketService.joinInstance(inst.id)
const cached = qrByInstance[inst.id]
if (cached) {
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: cached })
return
}
setQrModal({ instanceId: inst.id, instanceName: inst.name, qrBase64: null })
}, [qrByInstance])
const handleDelete = useCallback(async () => {
if (!deleteTarget) return
setDeleting(true)
try {
await deleteInst(deleteTarget.id)
setDeleteTarget(null)
showToast('Instância removida')
} catch (err: any) {
showToast(err.response?.data?.error ?? 'Erro ao deletar', false)
} finally {
setDeleting(false)
}
}, [deleteTarget, deleteInst])
const handleNewQr = useCallback(async (instanceId: string) => {
socketService.joinInstance(instanceId)
await connectInst(instanceId)
}, [connectInst])
return {
instances: storeInstances,
loading: storeLoading,
actionLoading,
toast,
addOpen,
newName,
creating,
setNewName,
openAddModal,
closeAddModal,
handleCreate,
qrModal,
closeQrModal: () => setQrModal(null),
handleNewQr,
deleteTarget,
deleting,
setDeleteTarget,
closeDeleteModal: () => setDeleteTarget(null),
handleDelete,
handleConnect,
handleDisconnect,
handleShowQr,
refreshInstances: loadInstances,
}
}
@@ -0,0 +1,82 @@
'use client'
/**
* useStickerCache cache de figurinhas em IndexedDB.
*
* Chave de cache: sha256 hex do arquivo (conteúdo permanente nunca muda).
* Sem TTL: stickers são imutáveis, então o cache é eterno.
* O único motivo para uma entrada sumir é o usuário limpar dados do navegador.
*
* Armazenamento: dataURL base64 do WebP (compatível com <img src>).
*/
const DB_NAME = 'nw-stickers'
const STORE_NAME = 'blobs'
const DB_VERSION = 1
interface CacheEntry {
sha256: string
dataUrl: string
}
let dbPromise: Promise<IDBDatabase> | null = null
function openDB(): Promise<IDBDatabase> {
if (dbPromise) return dbPromise
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'sha256' })
}
}
req.onsuccess = () => resolve(req.result)
req.onerror = () => { dbPromise = null; reject(req.error) }
})
return dbPromise
}
export async function getStickerFromCache(sha256: string): Promise<string | null> {
if (typeof window === 'undefined') return null
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).get(sha256)
req.onsuccess = () => resolve((req.result as CacheEntry | undefined)?.dataUrl ?? null)
req.onerror = () => resolve(null)
})
} catch {
return null
}
}
export async function setStickerInCache(sha256: string, dataUrl: string): Promise<void> {
if (typeof window === 'undefined') return
try {
const db = await openDB()
await new Promise<void>((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite')
const entry: CacheEntry = { sha256, dataUrl }
const req = tx.objectStore(STORE_NAME).put(entry)
req.onsuccess = () => resolve()
req.onerror = () => reject(req.error)
})
} catch { /* quota exceeded — ignora */ }
}
export async function hasStickerInCache(sha256: string): Promise<boolean> {
if (typeof window === 'undefined') return false
try {
const db = await openDB()
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly')
const req = tx.objectStore(STORE_NAME).count(sha256)
req.onsuccess = () => resolve(req.result > 0)
req.onerror = () => resolve(false)
})
} catch {
return false
}
}
@@ -0,0 +1,250 @@
// Shims dos services do motor sobre a ext API do satélite (/api/nw/v1/*).
// Mantém a MESMA interface que o chatApiService.ts do motor expõe, normalizando
// os shapes da ext API para o formato que os stores/hooks portados esperam.
import { nw } from './nwClient';
// ─── Instâncias ── (ext /sessions) ──────────────────────────────────────────
export const instanceApi = {
list: () => nw.get('/sessions'),
create: (name: string) => nw.post('/sessions', { name }),
connect: (id: string) => nw.post(`/sessions/${id}/connect`),
disconnect: (id: string) => nw.post(`/sessions/${id}/disconnect`),
getQr: (id: string) => nw.get(`/sessions/${id}/qr`),
delete: (id: string) => nw.delete(`/sessions/${id}`),
};
// ─── Avatar self-heal ── (ext /contacts/:jid/avatar/refresh) ─────────────────
// A URL do CDN do WhatsApp expira e passa a 404; aqui pedimos ao motor para
// re-buscar a foto atual pela conexão viva. Retorna a URL nova ou null.
export const avatarApi = {
refresh: async (instanceId: string, jid: string): Promise<string | null> => {
try {
const r = await nw.get(`/contacts/${encodeURIComponent(jid)}/avatar/refresh?session=${encodeURIComponent(instanceId)}`);
return r?.avatar ?? null;
} catch { return null; }
},
};
// ─── Chats ── (ext /inbox → shape achatado; normaliza p/ mapBackendChat) ────
export const chatApi = {
list: async (instanceId: string, opts: { search?: string; archived?: boolean; limit?: number } = {}) => {
const q = new URLSearchParams({ session: instanceId });
if (opts.search) q.set('search', opts.search);
if (opts.limit) q.set('limit', String(opts.limit));
const raw = await nw.get(`/inbox?${q.toString()}`);
const arr = Array.isArray(raw) ? raw : [];
// ext: { id, jid, displayName, avatar, phone, unreadCount, lastMessageAt,
// isPinned, isArchived, lastMessage } → shape esperado pelo chatStore.
return arr.map((c: any) => ({
id: c.id,
jid: c.jid,
instanceId,
name: c.displayName ?? null,
contactName: c.displayName ?? null,
contactPhone: c.phone ?? null,
contactAvatarUrl: c.avatar ?? null,
unreadCount: c.unreadCount ?? 0,
lastMessageAt: c.lastMessageAt ?? null,
createdAt: c.lastMessageAt ?? null,
isPinned: c.isPinned ?? false,
isArchived: c.isArchived ?? false,
lastMessage: c.lastMessage ?? null,
}));
},
markRead: (chatId: string) => nw.post(`/inbox/${chatId}/read`),
archive: (chatId: string, archived: boolean) => nw.patch(`/inbox/${chatId}/archive`, { archived }),
pin: (chatId: string, pinned: boolean) => nw.patch(`/inbox/${chatId}/pin`, { pinned }),
delete: (chatId: string) => nw.delete(`/inbox/${chatId}`),
};
// ─── Mensagens ── (ext devolve array; envolve em {messages, hasMore}) ───────
export const messageApi = {
list: async (chatId: string, opts: { before?: string; after?: string; limit?: number } = {}) => {
const q = new URLSearchParams();
if (opts.before) q.set('before', opts.before);
if (opts.limit) q.set('limit', String(opts.limit));
const qs = q.toString();
const raw = await nw.get(`/inbox/${chatId}/messages${qs ? `?${qs}` : ''}`);
const arr = Array.isArray(raw) ? raw : (raw?.messages ?? []);
const limit = opts.limit ?? 50;
return {
messages: arr.map((m: any) => ({ ...m, chatId, replyToId: m.replyToId ?? null, replyTo: m.replyTo ?? null })),
hasMore: arr.length >= limit,
};
},
search: (instanceId: string, q: string, limit?: number) => {
const p = new URLSearchParams({ session: instanceId, q });
if (limit) p.set('limit', String(limit));
return nw.get(`/inbox/messages/search?${p.toString()}`);
},
// Envio por chatId (a ext resolve jid/instância). O motor chamava por
// (instanceId, jid, ...); o chatStore foi adaptado p/ passar (chatId, text, replyTo).
send: (chatId: string, text: string, replyToMessageId?: string) =>
nw.post(`/inbox/${chatId}/send`, { text, ...(replyToMessageId ? { replyToMessageId } : {}) }),
};
// ─── Mídia ── (ext /inbox/:chatId/send-media | send-audio) ──────────────────
export const mediaApi = {
sendMedia: (chatId: string, file: File, caption?: string, replyToMessageId?: string) => {
const fd = new FormData();
fd.append('file', file);
if (caption) fd.append('caption', caption);
if (replyToMessageId) fd.append('replyToMessageId', replyToMessageId);
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
sendAudio: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('audio', blob, 'audio.ogg');
return nw.post(`/inbox/${chatId}/send-audio`, fd);
},
// Sticker: reaproveita o endpoint de mídia (a ext detecta image/webp → sticker).
sendSticker: (chatId: string, blob: Blob) => {
const fd = new FormData();
fd.append('file', new File([blob], 'sticker.webp', { type: 'image/webp' }));
return nw.post(`/inbox/${chatId}/send-media`, fd);
},
// Re-download não existe na ext (mídia já vem resolvida). No-op gracioso.
redownload: (_instanceId: string, _messageDbId: string): Promise<{ mediaUrl: string }> =>
Promise.resolve({ mediaUrl: '' }),
};
// ─── Grupos (ext não expõe participantes — stub gracioso) ───────────────────
export const groupApi = {
getParticipants: (_instanceId: string, groupJid: string) =>
Promise.resolve({ groupJid, subject: '', participants: [] as any[] }),
};
// ─── Protocolo (ext ainda não expõe CRUD por chat — stub; task follow-up) ───
export const protocolApi = {
list: (_chatId: string) => Promise.resolve([] as any[]),
open: (_chatId: string, _data: { sectorId?: string; teamId?: string }) => Promise.resolve(null as any),
update: (_chatId: string, _protocolId: string, _data: { status?: string; summary?: string }) => Promise.resolve(null as any),
};
// ─── Reconciliação (ext não expõe — no-op) ──────────────────────────────────
export const reconcileApi = {
reconcileNames: (_instanceId: string) => Promise.resolve({ updated: 0 }),
};
// ─── Tipos (compat com o motor; usados como type-only pelos componentes) ────
export interface GroupParticipant { jid: string; admin: string | null; name: string | null; phone: string }
export interface Sector { id: string; tenantId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { teams: number; protocols: number } }
export interface Team { id: string; tenantId: string; sectorId: string; name: string; isActive: boolean; createdAt: string; updatedAt: string; _count?: { protocols: number } }
export type TemplateType = 'TEXT' | 'BUTTONS' | 'INTERACTIVE' | 'LIST' | 'POLL' | 'CAROUSEL'
export interface MessageTemplate { id: string; tenantId: string; name: string; description: string | null; type: TemplateType; payload: Record<string, unknown>; tags: string[]; usageCount: number; createdAt: string; updatedAt: string }
export interface StickerRecord { id: string; sha256: string; wasabiPath: string; isAnimated: boolean; isFavorite: boolean; useCount?: number; lastSeenAt?: string }
export interface RichPayload { text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any; nativeCarousel?: any; poll?: any }
// ─── Setores/Equipes (ext não expõe protocolo/setores — stubs) ──────────────
export const sectorApi = {
list: () => Promise.resolve([] as Sector[]),
create: (_name: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
listTeams: (_sectorId: string) => Promise.resolve([] as Team[]),
createTeam: (_sectorId: string, _name: string) => Promise.resolve(null as any),
updateTeam: (_sectorId: string, _teamId: string, _d: any) => Promise.resolve(null as any),
deleteTeam: (_sectorId: string, _teamId: string) => Promise.resolve({} as any),
};
// ─── Templates (stub simples) ───────────────────────────────────────────────
export const templateApi = {
list: (_opts: any = {}) => Promise.resolve([] as MessageTemplate[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
markUsed: (_id: string) => Promise.resolve({} as any),
};
// ─── Stickers (ext não expõe catálogo — stub) ───────────────────────────────
export const stickerApi = {
list: () => Promise.resolve({ listVersion: '', stickers: [] as StickerRecord[] }),
recent: () => Promise.resolve({ stickers: [] as StickerRecord[] }),
toggleFavorite: (id: string) => Promise.resolve({ id, isFavorite: false }),
};
// ─── Rich message (composer passa instanceId/jid; sem chatId não mapeia na
// ext → stub que rejeita graciosamente) ─────────────────────────────────────
export const richMessageApi = {
send: (_instanceId: string, _jid: string, _payload: RichPayload) =>
Promise.reject(new Error('Envio rico indisponível no satélite')),
};
// ─── Demais exports do chatApiService do motor (stubs — a ext não os expõe;
// mantidos para os named imports da árvore não quebrarem em runtime) ─────────
export interface ApiKeyRecord { id: string; name: string; keyPreview: string; isActive: boolean; createdAt: string; expiresAt: string | null }
export interface BackendContact { id: string; instanceId: string; jid: string; name: string | null; verifiedName: string | null; notify: string | null; phone: string | null; avatarUrl: string | null; scoreReputacao: number; flagRestricao: boolean; isBlocked: boolean; createdAt: string; updatedAt: string }
export type EventType = 'BIRTHDAY' | 'ANNIVERSARY' | 'SIGNUP_DAYS'
export type RecipientMode = 'MANUAL' | 'ALL_CONTACTS' | 'TAG_FILTER'
export type ScheduleType = 'ONCE' | 'RECURRING' | 'EVENT'
export type ScheduleStatus = 'ACTIVE' | 'PAUSED' | 'DONE' | 'CANCELLED'
export interface ScheduleRecipient { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export interface ScheduledMessage { id: string; tenantId: string; instanceId: string; name: string; templateId: string | null; payload: Record<string, unknown>; recipientMode: RecipientMode; recipients: ScheduleRecipient[]; scheduleType: ScheduleType; cronExpr: string | null; scheduledAt: string | null; eventType: EventType | null; timezone: string; status: ScheduleStatus; lastRunAt: string | null; nextRunAt: string | null; runCount: number; createdAt: string; updatedAt: string; template?: any; _count?: { logs: number } }
export interface ScheduleLog { id: string; scheduleId: string; status: string; sentCount: number; failedCount: number; error: string | null; runAt: string }
// Cliente axios-like genérico sobre o nw (alguns arquivos importam `api` direto).
export const api = {
get: (u: string) => nw.get(u).then((data: any) => ({ data })),
post: (u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data })),
put: (u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data })),
patch: (u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data })),
delete: (u: string) => nw.delete(u).then((data: any) => ({ data })),
};
export const authApi = {
register: (_d: any) => Promise.resolve({} as any),
login: (_d: any) => Promise.resolve({} as any),
logout: () => Promise.resolve({} as any),
me: () => Promise.resolve({} as any),
changePassword: (_d: any) => Promise.resolve({} as any),
};
export const apiKeyApi = {
list: () => Promise.resolve([] as ApiKeyRecord[]),
create: (_name: string, _e?: string) => Promise.resolve({} as any),
toggle: (_id: string, _a: boolean) => Promise.resolve({} as any),
delete: (_id: string) => Promise.resolve({} as any),
};
export const contactApi = {
list: (_opts: any = {}) => Promise.resolve({ total: 0, contacts: [] as BackendContact[] }),
get: (_id: string) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
stats: (_instanceId?: string) => Promise.resolve({ total: 0, blocked: 0, flagged: 0, active: 0 }),
};
export const broadcastApi = {
create: (_instanceId: string, _d: any) => Promise.resolve({ jobId: '', total: 0 }),
list: (_instanceId: string, _limit?: number) => Promise.resolve([] as any[]),
get: (_instanceId: string, _broadcastId: string) => Promise.resolve(null as any),
};
export const planApi = {
status: () => Promise.resolve({ daysRemaining: null as number | null, planName: '' }),
};
export const chatbotApi = {
getCredentials: (_instanceId: string) => Promise.resolve([] as any[]),
createCredential: (_instanceId: string, _d: any) => Promise.resolve({} as any),
deleteCredential: (_instanceId: string, _credId: string) => Promise.resolve({} as any),
getBots: (_instanceId: string) => Promise.resolve([] as any[]),
createBot: (_instanceId: string, _d: any) => Promise.resolve({} as any),
updateBot: (_instanceId: string, _botId: string, _d: any) => Promise.resolve({} as any),
deleteBot: (_instanceId: string, _botId: string) => Promise.resolve({} as any),
pauseChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
resumeChat: (_instanceId: string, _chatId: string) => Promise.resolve({} as any),
};
export const scheduleApi = {
list: (_opts: any = {}) => Promise.resolve([] as ScheduledMessage[]),
get: (_id: string) => Promise.resolve(null as any),
create: (_d: any) => Promise.resolve(null as any),
update: (_id: string, _d: any) => Promise.resolve(null as any),
delete: (_id: string) => Promise.resolve({} as any),
pause: (_id: string) => Promise.resolve({} as any),
resume: (_id: string) => Promise.resolve({} as any),
runNow: (_id: string) => Promise.resolve({} as any),
logs: (_id: string) => Promise.resolve([] as ScheduleLog[]),
};
@@ -0,0 +1,43 @@
// Cliente base do satélite NewWhats → /api/nw/v1/* (proxy do backend → motor
// /api/ext/v1/*). Auth: Bearer do scoreodonto (localStorage SCOREODONTO_AUTH_TOKEN).
// Retorna JSON já parseado; em erro, lança Error com `.response = { status, data }`
// (mesmo shape do axios, que o código portado do motor espera).
const API: string = (import.meta as any).env?.VITE_API_URL || '/api';
const TOKEN_KEY = 'SCOREODONTO_AUTH_TOKEN';
export function nwToken(): string | null {
try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
}
function authHeaders(extra?: Record<string, string>): Record<string, string> {
const t = nwToken();
return { ...(t ? { Authorization: `Bearer ${t}` } : {}), ...(extra || {}) };
}
async function req(method: string, path: string, body?: any): Promise<any> {
const isForm = typeof FormData !== 'undefined' && body instanceof FormData;
const res = await fetch(`${API}/nw/v1${path}`, {
method,
headers: authHeaders(isForm ? undefined : (body != null ? { 'Content-Type': 'application/json' } : undefined)),
body: body == null ? undefined : (isForm ? body : JSON.stringify(body)),
});
const text = await res.text();
let data: any = null;
try { data = text ? JSON.parse(text) : null; } catch { data = text; }
if (!res.ok) {
const err: any = new Error((data && data.error) || `${res.status}`);
err.response = { status: res.status, data };
throw err;
}
return data;
}
export const nw = {
get: (p: string) => req('GET', p),
post: (p: string, body?: any) => req('POST', p, body),
put: (p: string, body?: any) => req('PUT', p, body),
patch: (p: string, body?: any) => req('PATCH', p, body),
delete: (p: string) => req('DELETE', p),
// URL base ('/api' relativo ou absoluta) — usada pelo socketService p/ o WS.
base: () => API,
};
@@ -0,0 +1,161 @@
// Satélite: `api` axios-like sobre o nwClient. As rotas internas da secretária
// foram montadas sob a ext em /api/ext/v1/secretaria (proxy /api/nw/v1/secretaria).
import { nw } from './nwClient'
const _qs = (params?: Record<string, any>) => {
if (!params) return ''
const p = new URLSearchParams()
Object.entries(params).forEach(([k, v]) => { if (v != null) p.set(k, String(v)) })
const s = p.toString()
return s ? `?${s}` : ''
}
const api = {
get: <T = any>(u: string, cfg?: { params?: Record<string, any> }) => nw.get(u + _qs(cfg?.params)).then((data: any) => ({ data: data as T })),
post: <T = any>(u: string, body?: any) => nw.post(u, body).then((data: any) => ({ data: data as T })),
put: <T = any>(u: string, body?: any) => nw.put(u, body).then((data: any) => ({ data: data as T })),
patch: <T = any>(u: string, body?: any) => nw.patch(u, body).then((data: any) => ({ data: data as T })),
delete:<T = any>(u: string) => nw.delete(u).then((data: any) => ({ data: data as T })),
}
const BASE = '/secretaria'
// ─── Types ────────────────────────────────────────────────────────────────────
export interface SecAgent {
id: string
name: string
description: string | null
model: string
provider: string
temperature: number
context_window: number
active: boolean
created_at: string
updated_at: string
}
export type BrainNodeType = 'persona' | 'knowledge' | 'rules' | 'calendar' | 'escalation'
export interface BrainNode {
id: string
agent_id: string
type: BrainNodeType
title: string
content: string
active: boolean
sort_order: number
node_model: string | null // modelo específico para este nó (opcional)
created_at: string
updated_at: string
}
export interface SecConversation {
id: string
agent_id: string
contact_name: string
protocol_number: string
status: 'active' | 'closed' | 'escalated'
summary: string | null
created_at: string
updated_at: string
}
export interface SecMessage {
id: string
conversation_id: string
role: 'user' | 'assistant' | 'system'
content: string
created_at: string
}
export interface CalendarSlot {
id: string
title: string
date: string
time_start: string
time_end: string
attendee_name: string | null
attendee_phone: string | null
status: 'available' | 'booked' | 'cancelled'
notes: string | null
created_at: string
}
// ─── Agents ───────────────────────────────────────────────────────────────────
export const secAgentApi = {
list: () => api.get<SecAgent[]>(`${BASE}/agents`).then((r) => r.data),
create: (d: Partial<SecAgent>) => api.post<SecAgent>(`${BASE}/agents`, d).then((r) => r.data),
update: (id: string, d: Partial<SecAgent>) => api.put<SecAgent>(`${BASE}/agents/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/agents/${id}`).then((r) => r.data),
}
// ─── Brain Nodes ──────────────────────────────────────────────────────────────
export const secNodeApi = {
list: (agentId: string) => api.get<BrainNode[]>(`${BASE}/agents/${agentId}/nodes`).then((r) => r.data),
create: (agentId: string, d: Partial<BrainNode>) =>
api.post<BrainNode>(`${BASE}/agents/${agentId}/nodes`, d).then((r) => r.data),
update: (id: string, d: Partial<BrainNode>) => api.put<BrainNode>(`${BASE}/nodes/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/nodes/${id}`).then((r) => r.data),
}
// ─── Conversations ────────────────────────────────────────────────────────────
export const secConvApi = {
list: (agentId?: string) =>
api.get<SecConversation[]>(`${BASE}/conversations`, { params: agentId ? { agent_id: agentId } : {} }).then((r) => r.data),
create: (agentId: string, contactName: string) =>
api.post<SecConversation>(`${BASE}/conversations`, { agent_id: agentId, contact_name: contactName }).then((r) => r.data),
patch: (id: string, d: Partial<SecConversation>) =>
api.patch<SecConversation>(`${BASE}/conversations/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/conversations/${id}`).then((r) => r.data),
messages: (id: string) => api.get<SecMessage[]>(`${BASE}/conversations/${id}/messages`).then((r) => r.data),
chat: (id: string, message: string) =>
api.post<{ reply: string }>(`${BASE}/conversations/${id}/chat`, { message }).then((r) => r.data),
finalize: (id: string) =>
api.post<{ summary: string; protocol_number: string }>(`${BASE}/conversations/${id}/finalize`).then((r) => r.data),
}
// ─── Numbers ──────────────────────────────────────────────────────────────────
export type NumberRole =
| 'secretary_virtual'
| 'clinic'
| 'doctor'
| 'specialist'
| 'manager'
| 'reserve'
| 'human_secretary'
export interface SecNumber {
id: string
instance_id: string
label: string
role: NumberRole
area: string | null
priority: number
active: boolean
notes: string | null
created_at: string
updated_at: string
}
export const secNumberApi = {
list: () => api.get<SecNumber[]>(`${BASE}/numbers`).then((r) => r.data),
// upsert por instance_id — cria ou atualiza o papel da instância
assign: (d: Partial<SecNumber>) => api.post<SecNumber>(`${BASE}/numbers`, d).then((r) => r.data),
update: (id: string, d: Partial<SecNumber>) => api.put<SecNumber>(`${BASE}/numbers/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/numbers/${id}`).then((r) => r.data),
}
// ─── Calendar ─────────────────────────────────────────────────────────────────
export const secCalApi = {
list: (params?: { from?: string; to?: string; status?: string }) =>
api.get<CalendarSlot[]>(`${BASE}/calendar`, { params }).then((r) => r.data),
create: (d: Partial<CalendarSlot>) => api.post<CalendarSlot>(`${BASE}/calendar`, d).then((r) => r.data),
update: (id: string, d: Partial<CalendarSlot>) => api.put<CalendarSlot>(`${BASE}/calendar/${id}`, d).then((r) => r.data),
delete: (id: string) => api.delete(`${BASE}/calendar/${id}`).then((r) => r.data),
}
@@ -0,0 +1,111 @@
// Shim do socketService do motor sobre o túnel WS do satélite
// (/api/nw/v1/stream?token=). Traduz os eventos do ext stream para os nomes que
// as páginas do motor escutam. joinInstance/joinChat/leaveChat são no-ops: o
// stream já é do tenant inteiro; as páginas filtram por instanceId/chatId no
// cliente. Mantém a MESMA interface do services/socketService.ts do motor.
import { nw, nwToken } from './nwClient';
type Listener = (data: any) => void;
// ext status (minúsculo) → enum do motor (maiúsculo).
function normStatus(s: string): string {
const m: Record<string, string> = {
connected: 'CONNECTED', disconnected: 'DISCONNECTED',
connecting: 'CONNECTING', qr: 'QR_PENDING', banned: 'BANNED',
};
return m[String(s).toLowerCase()] ?? String(s).toUpperCase();
}
class SocketShim {
private ws: WebSocket | null = null;
private listeners = new Map<string, Set<Listener>>();
private retry: ReturnType<typeof setTimeout> | null = null;
private closedByUser = false;
connect(_token?: string) {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) return;
this.closedByUser = false;
const token = nwToken();
if (!token) return;
const base = nw.base();
const abs = base.startsWith('http') ? base : `${window.location.origin}${base}`;
let u: URL;
try { u = new URL(`${abs}/nw/v1/stream`); } catch { return; }
u.protocol = u.protocol === 'https:' ? 'wss:' : 'ws:';
u.searchParams.set('token', token);
let ws: WebSocket;
try { ws = new WebSocket(u.toString()); } catch { return; }
this.ws = ws;
ws.onopen = () => this._emit('connected', { id: 'nw' });
ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } };
ws.onclose = () => {
this._emit('disconnected', {});
if (!this.closedByUser) this.retry = setTimeout(() => this.connect(), 3000);
};
ws.onmessage = (ev) => {
let env: any;
try { env = JSON.parse(ev.data); } catch { return; }
const { event, data } = env || {};
if (!event) return;
switch (event) {
case 'session.status':
this._emit('instance:status', { instanceId: data?.instanceId, status: normStatus(data?.status) });
break;
case 'session.qr':
this._emit('qr', { instanceId: data?.instanceId, qrBase64: data?.qrBase64 });
break;
case 'message.new': {
// ext envia { instanceId, chatId, message, timestamp }; o chatStore
// espera o objeto de mensagem direto (com chatId).
// OBS: o motor também emite ext:message.new numa variante SEM `message`
// (só { jid, text }) para acionar a Secretária IA — ignoramos aqui,
// senão o chatStore sobrescreve o preview do chat com undefined.
if (!data?.message) break;
const m = { ...data.message, chatId: data.message.chatId ?? data.chatId };
this._emit('message:new', m);
break;
}
case 'message.update': {
const m = data?.message ?? data;
if (m?.messageId && m?.status) this._emit('message:status', { messageId: m.messageId, status: m.status });
this._emit('message:update', m);
break;
}
case 'conversation.handoff': this._emit('handoff', data); break;
case 'conversation.escalated': this._emit('escalated', data); break;
default: break;
}
this._emit('*', { event, data });
};
}
disconnect() {
this.closedByUser = true;
if (this.retry) clearTimeout(this.retry);
try { this.ws?.close(); } catch { /* ignore */ }
this.ws = null;
}
// Satélite não envia comandos pelo stream (é read-only); mantidos p/ compat.
emit(_event: string, ..._args: any[]) { /* no-op */ }
joinInstance(_instanceId: string) { /* no-op — stream é tenant-wide */ }
joinChat(_chatId: string) { /* no-op */ }
leaveChat(_chatId: string) { /* no-op */ }
on(event: string, cb: Listener) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set());
this.listeners.get(event)!.add(cb);
}
off(event: string, cb: Listener) { this.listeners.get(event)?.delete(cb); }
get connected() { return this.ws?.readyState === WebSocket.OPEN; }
getSocket() { return this.ws; }
private _emit(event: string, data: any) {
this.listeners.get(event)?.forEach((cb) => { try { cb(data); } catch { /* ignore */ } });
}
}
export const socketService = new SocketShim();
@@ -0,0 +1,60 @@
// Stub do authStore do motor para o satélite. Os componentes portados leem
// `user`/`token` daqui. No satélite a auth é do scoreodonto (SCOREODONTO_AUTH_TOKEN);
// decodificamos o mínimo do JWT para exibição. As ações de auth são no-op (o
// scoreodonto cuida do login/logout).
import { create } from 'zustand';
export interface AuthUser {
id: string;
email: string;
name: string | null;
role: 'USER' | 'ADMIN';
planId: string | null;
daysRemaining: number | null;
}
interface AuthState {
user: AuthUser | null;
token: string | null;
refreshToken: string | null;
isHydrated: boolean;
setAuth: (token: string, refreshToken: string, user: AuthUser) => void;
clearAuth: () => void;
refreshSession: () => Promise<boolean>;
fetchMe: () => Promise<void>;
}
function readScoreToken(): string | null {
try { return localStorage.getItem('SCOREODONTO_AUTH_TOKEN'); } catch { return null; }
}
function decodeUser(token: string | null): AuthUser | null {
if (!token) return null;
try {
const payload = JSON.parse(atob(token.split('.')[1] || ''));
return {
id: payload.userId || payload.id || 'me',
email: payload.email || '',
name: payload.name || payload.nome || null,
role: 'USER',
planId: null,
daysRemaining: null,
};
} catch { return null; }
}
const token = readScoreToken();
export const useAuthStore = create<AuthState>()((set) => ({
user: decodeUser(token),
token,
refreshToken: null,
isHydrated: true,
setAuth: () => {},
clearAuth: () => {},
refreshSession: async () => false,
fetchMe: async () => {
const t = readScoreToken();
set({ token: t, user: decodeUser(t) });
},
}));
+458
View File
@@ -0,0 +1,458 @@
/**
* chatStore.ts Store de Inbox: chats e mensagens.
* Instâncias foram extraídas para instanceStore.ts.
* Usa Zustand + Immer para mutações diretas e legíveis.
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { socketService } from '../services/socketService'
import { chatApi, messageApi } from '../services/chatApiService'
// ─── Mapper: resposta aninhada do backend → Chat plano do store ────────────────
function mapBackendChat(raw: any): Chat {
return {
id: raw.id,
tenantId: raw.tenantId ?? '',
instanceId: raw.instanceId,
jid: raw.jid,
name: raw.name ?? null,
unreadCount: raw.unreadCount ?? 0,
lastMessageAt: raw.lastMessageAt ?? null,
createdAt: raw.createdAt ?? null,
isPinned: raw.isPinned ?? false,
isArchived: raw.isArchived ?? false,
// Prioridade do nome: agenda (contact.name) → nome do WhatsApp
// (verifiedName de Business → notify/pushName) → pushName da última msg.
contactName: raw.contact?.name
?? raw.contact?.verifiedName
?? raw.contact?.notify
?? raw.contactName
?? raw.lastMessage?.pushName
?? null,
contactAvatarUrl: raw.contact?.avatarUrl ?? raw.contactAvatarUrl ?? null,
contactAvatarVersion: raw.contact?.avatarVersion ?? raw.contactAvatarVersion ?? null,
contactPhone: raw.contact?.phone ?? raw.contactPhone ?? null,
scoreReputacao: raw.contact?.scoreReputacao ?? raw.scoreReputacao ?? 0,
flagRestricao: raw.contact?.flagRestricao ?? raw.flagRestricao ?? false,
lastMessageBody: raw.lastMessage?.body ?? raw.lastMessageBody ?? null,
lastMessageFromMe: raw.lastMessage?.fromMe ?? raw.lastMessageFromMe ?? false,
lastMessageStatus: raw.lastMessage?.status ?? raw.lastMessageStatus ?? null,
lastMessageType: raw.lastMessage?.type ?? raw.lastMessageType ?? null,
lastMessageSender: raw.lastMessage?.pushName ?? raw.lastMessageSender ?? null,
protocolNumber: raw.protocol?.number ?? raw.protocolNumber ?? null,
botPaused: raw.botPaused ?? false,
}
}
// ─── Tipos ────────────────────────────────────────────────────────────────────
export interface Chat {
id: string
tenantId: string
instanceId: string
jid: string
name: string | null // subject do grupo; null para individuais
unreadCount: number
lastMessageAt: string | null
createdAt: string | null // quando o chat foi registrado (fallback de ordenação)
isPinned: boolean
isArchived: boolean
contactName: string | null
contactAvatarUrl: string | null
contactAvatarVersion: string | null
contactPhone: string | null
scoreReputacao: number
flagRestricao: boolean
lastMessageBody: string | null
lastMessageFromMe: boolean
lastMessageStatus: string | null
lastMessageType: string | null
lastMessageSender: string | null // pushName do remetente (grupos: quem enviou a última msg)
protocolNumber: string | null
botPaused: boolean
}
export interface Message {
id: string
chatId: string
messageId: string
fromMe: boolean
type: string
body: string | null
mediaUrl: string | null
transcription?: string | null
mimeType: string | null
fileName: string | null
replyToId: string | null
replyTo?: {
id: string
messageId: string
body: string | null
fromMe: boolean
type: string
mediaUrl: string | null
} | null
status: 'PENDING' | 'SENT' | 'DELIVERED' | 'READ' | 'FAILED'
timestamp: string
pushName?: string | null
senderJid?: string | null
}
// ─── Estado ───────────────────────────────────────────────────────────────────
interface ChatState {
chats: Chat[]
chatsLoading: boolean
activeChatId: string | null
messages: Record<string, Message[]>
messagesLoading: boolean
// Sync
setChats: (chats: Chat[]) => void
setChatsLoading: (v: boolean) => void
upsertChat: (chat: Chat) => void
patchChat: (chatId: string, patch: Partial<Chat>) => void
setActiveChat: (chatId: string | null) => void
clearUnread: (chatId: string) => void
setMessages: (chatId: string, msgs: Message[]) => void
setMessagesLoading: (v: boolean) => void
appendMessage: (msg: Message) => void
patchMessage: (msgId: string, patch: Partial<Message>) => void
updateMediaReady: (msgId: string, mediaUrl: string) => void
// Async
loadChats: (instanceId: string, opts?: { search?: string; archived?: boolean }) => Promise<void>
loadMessages: (chatId: string, opts?: { before?: string }) => Promise<{ hasMore: boolean }>
selectChat: (chatId: string) => Promise<void>
sendMessage: (instanceId: string, jid: string, text: string, replyToMessageId?: string, mentions?: string[]) => Promise<void>
// Socket
initSocketListeners: () => void
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function chatSortKey(c: Chat): number {
// WhatsApp ordena por: timestamp da última msg; se não houver msg, usa hora de criação do chat
if (c.lastMessageAt) return new Date(c.lastMessageAt).getTime()
if (c.createdAt) return new Date(c.createdAt).getTime()
return 0
}
function sortChats(chats: Chat[]): Chat[] {
return [...chats].sort((a, b) => {
// Fixados sempre primeiro (mesma lógica do WhatsApp oficial)
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1
return chatSortKey(b) - chatSortKey(a)
})
}
// ─── Store ────────────────────────────────────────────────────────────────────
// ─── Cache local (localStorage) — UX instantâneo no reload ─────────────────────
// Persiste os primeiros N chats por instância para hidratar a UI antes do fetch.
// Chave: `inboxCache:v1:${instanceId}:${archived ? '1' : '0'}`
// TTL implícito: validamos pela data salva e descartamos se > 7 dias.
const INBOX_CACHE_PREFIX = 'inboxCache:v1'
const INBOX_CACHE_LIMIT = 10
const INBOX_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000
interface InboxCacheEntry {
savedAt: number
chats: Chat[]
}
function inboxCacheKey(instanceId: string, archived: boolean): string {
return `${INBOX_CACHE_PREFIX}:${instanceId}:${archived ? '1' : '0'}`
}
function readInboxCache(instanceId: string, archived: boolean): Chat[] | null {
if (typeof window === 'undefined') return null
try {
const raw = localStorage.getItem(inboxCacheKey(instanceId, archived))
if (!raw) return null
const parsed = JSON.parse(raw) as InboxCacheEntry
if (!parsed || !Array.isArray(parsed.chats)) return null
if (Date.now() - parsed.savedAt > INBOX_CACHE_MAX_AGE_MS) return null
return parsed.chats
} catch { return null }
}
function writeInboxCache(instanceId: string, archived: boolean, chats: Chat[]): void {
if (typeof window === 'undefined') return
try {
const slim = chats.slice(0, INBOX_CACHE_LIMIT)
const entry: InboxCacheEntry = { savedAt: Date.now(), chats: slim }
localStorage.setItem(inboxCacheKey(instanceId, archived), JSON.stringify(entry))
} catch { /* quota exceeded — ignora */ }
}
export const useChatStore = create<ChatState>()(
immer((set, get) => ({
chats: [],
chatsLoading: false,
activeChatId: null,
messages: {},
messagesLoading: false,
// ── Chats ─────────────────────────────────────────────────────────────────
setChats: (chats) =>
set((s) => { s.chats = sortChats(chats) }),
setChatsLoading: (v) =>
set((s) => { s.chatsLoading = v }),
upsertChat: (chat) =>
set((s) => {
const idx = s.chats.findIndex((c) => c.id === chat.id)
if (idx !== -1) s.chats[idx] = chat
else s.chats.unshift(chat)
s.chats = sortChats(s.chats as Chat[])
}),
patchChat: (chatId, patch) =>
set((s) => {
const idx = s.chats.findIndex((c) => c.id === chatId)
if (idx !== -1) {
Object.assign(s.chats[idx], patch)
s.chats = sortChats(s.chats as Chat[])
}
}),
setActiveChat: (chatId) =>
set((s) => { s.activeChatId = chatId }),
clearUnread: (chatId) =>
set((s) => {
const c = s.chats.find((c) => c.id === chatId)
if (c) c.unreadCount = 0
}),
// ── Mensagens ─────────────────────────────────────────────────────────────
setMessages: (chatId, msgs) =>
set((s) => { s.messages[chatId] = msgs }),
setMessagesLoading: (v) =>
set((s) => { s.messagesLoading = v }),
appendMessage: (msg) =>
set((s) => {
const list = s.messages[msg.chatId]
if (!list) { s.messages[msg.chatId] = [msg]; return }
const exists = list.some((m) => m.id === msg.id || m.messageId === msg.messageId)
if (!exists) list.push(msg)
}),
patchMessage: (msgId, patch) =>
set((s) => {
for (const list of Object.values(s.messages)) {
const idx = list.findIndex((m) => m.id === msgId || m.messageId === msgId)
if (idx !== -1) { Object.assign(list[idx], patch); break }
}
}),
updateMediaReady: (msgId, mediaUrl) =>
set((s) => {
for (const list of Object.values(s.messages)) {
const msg = list.find((m) => m.id === msgId)
if (msg) { msg.mediaUrl = mediaUrl; break }
}
}),
// ── Async ─────────────────────────────────────────────────────────────────
loadChats: async (instanceId, opts = {}) => {
// Hidratação instantânea: se temos cache local válido para esta instância,
// mostra os 10 chats salvos imediatamente enquanto a request real roda.
// Evita tela em branco ou skeleton de 15s+ em redes lentas.
const archivedFlag = opts.archived ?? false
const hasSearch = !!opts.search
const current = get().chats
if (!hasSearch) {
const sameInstance = current.length > 0 && current[0]?.instanceId === instanceId
if (!sameInstance) {
const cached = readInboxCache(instanceId, archivedFlag)
if (cached && cached.length > 0) {
set((s) => { s.chats = sortChats(cached); s.chatsLoading = true })
} else {
set((s) => { s.chatsLoading = true })
}
} else {
set((s) => { s.chatsLoading = true })
}
} else {
set((s) => { s.chatsLoading = true })
}
try {
const raw = await chatApi.list(instanceId, opts)
const data: Chat[] = (Array.isArray(raw) ? raw : []).map(mapBackendChat)
const sorted = sortChats(data)
set((s) => { s.chats = sorted; s.chatsLoading = false })
if (!hasSearch) writeInboxCache(instanceId, archivedFlag, sorted)
} catch {
set((s) => { s.chatsLoading = false })
}
},
loadMessages: async (chatId, opts = {}) => {
console.log('[chatStore] loadMessages chamado:', chatId, opts)
set((s) => { s.messagesLoading = true })
try {
const res = await messageApi.list(chatId, opts)
console.log('[chatStore] API retornou:', { hasMore: res.hasMore, count: res.messages?.length, raw: typeof res })
const { messages, hasMore } = res
if (!Array.isArray(messages)) {
console.error('[chatStore] messages NÃO é array!', messages)
set((s) => { s.messagesLoading = false })
return { hasMore: false }
}
set((s) => {
if (opts.before) {
const existing = s.messages[chatId] ?? []
const ids = new Set(existing.map((m) => m.id))
s.messages[chatId] = [...messages.filter((m: Message) => !ids.has(m.id)), ...existing]
} else {
s.messages[chatId] = messages
}
s.messagesLoading = false
})
console.log('[chatStore] messages armazenadas:', get().messages[chatId]?.length)
return { hasMore }
} catch (err) {
console.error('[chatStore] loadMessages ERRO:', err)
set((s) => { s.messagesLoading = false })
return { hasMore: false }
}
},
selectChat: async (chatId) => {
console.log('[chatStore] selectChat:', chatId)
const prev = get().activeChatId
if (prev && prev !== chatId) socketService.leaveChat(prev)
set((s) => { s.activeChatId = chatId })
socketService.joinChat(chatId)
get().clearUnread(chatId)
chatApi.markRead(chatId).catch(() => {})
await get().loadMessages(chatId)
console.log('[chatStore] selectChat completo. Messages:', get().messages[chatId]?.length)
},
sendMessage: async (instanceId, jid, text, replyToMessageId, mentions) => {
const chatId = get().chats.find((c) => c.jid === jid && c.instanceId === instanceId)?.id
if (!chatId) return
// Resolve replyTo a partir das mensagens já no store
const replyToMsg = replyToMessageId
? get().messages[chatId]?.find((m) => m.messageId === replyToMessageId) ?? null
: null
const optimistic: Message = {
id: `opt-${Date.now()}`,
chatId,
messageId: `opt-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: text,
mediaUrl: null,
mimeType: null,
fileName: null,
replyToId: replyToMsg?.id ?? null,
replyTo: replyToMsg
? { id: replyToMsg.id, messageId: replyToMsg.messageId, body: replyToMsg.body, fromMe: replyToMsg.fromMe, type: replyToMsg.type, mediaUrl: replyToMsg.mediaUrl }
: null,
status: 'PENDING',
timestamp: new Date().toISOString(),
}
get().appendMessage(optimistic)
try {
// Satélite: envia por chatId (a ext resolve jid/instância). A ext devolve
// { ok, messageId } — mantemos o id otimista (key React estável) e só
// fixamos o messageId real (dedup com o echo do WS message.new).
const saved = await messageApi.send(chatId, text, replyToMessageId)
get().patchMessage(optimistic.id, { ...(saved?.id ? { id: saved.id } : {}), messageId: saved?.messageId ?? optimistic.messageId, status: 'SENT' })
get().patchChat(chatId, {
lastMessageBody: text,
lastMessageFromMe: true,
lastMessageAt: saved.timestamp,
lastMessageStatus: 'SENT',
lastMessageType: 'TEXT',
})
} catch {
get().patchMessage(optimistic.id, { status: 'FAILED' })
}
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
// IMPORTANTE: sempre usar get() dentro dos callbacks para ler estado atual.
// Capturar `const store = get()` fora criaria uma closure stale.
socketService.on('message:new', (msg: Message) => {
console.log('[socket] message:new recebida:', { chatId: msg.chatId, body: msg.body?.slice(0, 40), fromMe: msg.fromMe })
get().appendMessage(msg)
const current = get()
const isActive = current.activeChatId === msg.chatId
current.patchChat(msg.chatId, {
lastMessageBody: msg.body,
lastMessageFromMe: msg.fromMe,
lastMessageAt: msg.timestamp,
lastMessageStatus: msg.status,
lastMessageType: msg.type,
lastMessageSender: msg.fromMe ? null : (msg.pushName ?? null),
unreadCount: msg.fromMe || isActive
? 0
: (current.chats.find((c) => c.id === msg.chatId)?.unreadCount ?? 0) + 1,
})
})
socketService.on('message:media_ready', ({ messageId, mediaUrl }: { messageId: string; mediaUrl: string }) => {
get().updateMediaReady(messageId, mediaUrl)
})
socketService.on('message:transcription', ({ messageId, transcription }: { messageId: string; transcription: string }) => {
get().patchMessage(messageId, { transcription })
})
socketService.on('message:status', ({ messageId, status }: { messageId: string; status: Message['status'] }) => {
get().patchMessage(messageId, { status })
})
socketService.on('chat:upsert', (raw: any) => {
get().upsertChat(mapBackendChat(raw))
})
socketService.on('bot:escalated', ({ chatId }: { chatId: string }) => {
get().patchChat(chatId, { botPaused: true })
})
socketService.on('contact:avatar', ({ instanceId, jid, avatarUrl }: { instanceId: string; jid: string; avatarUrl: string }) => {
set((s) => {
const chat = s.chats.find((c) => c.instanceId === instanceId && c.jid === jid)
if (chat) chat.contactAvatarUrl = avatarUrl
})
})
// Reconciliação de nomes concluída — re-busca lista de chats
socketService.on('chats:refresh', ({ instanceId }: { instanceId: string }) => {
const chats = get().chats
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
get().loadChats(instanceId)
}
})
// Reconciliação via ContactHandler (pós-sync)
socketService.on('contacts:reconciled', ({ instanceId }: { instanceId: string }) => {
const chats = get().chats
if (chats.length > 0 && chats[0]?.instanceId === instanceId) {
get().loadChats(instanceId)
}
})
},
}))
)
@@ -0,0 +1,186 @@
/**
* instanceStore.ts Gerencia instâncias WhatsApp e QR Codes.
* Separado do chatStore para responsabilidade única.
*/
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { instanceApi } from '../services/chatApiService'
import { socketService } from '../services/socketService'
import { notify } from './notificationStore'
export type InstanceStatus = 'DISCONNECTED' | 'CONNECTING' | 'QR_PENDING' | 'CONNECTED' | 'BANNED'
export interface Instance {
id: string
name: string
status: InstanceStatus
phone: string | null
avatar: string | null
}
interface InstanceState {
instances: Instance[]
activeInstanceId: string | null
qrByInstance: Record<string, string>
loading: boolean
// Sync actions
setInstances: (instances: Instance[]) => void
setActiveInstance: (id: string | null) => void
patchInstance: (id: string, patch: Partial<Instance>) => void
setQr: (instanceId: string, qrBase64: string) => void
clearQr: (instanceId: string) => void
removeInstance: (id: string) => void
// Async actions
loadInstances: () => Promise<void>
connectInstance: (id: string) => Promise<void>
disconnectInstance: (id: string) => Promise<void>
createInstance: (name: string) => Promise<Instance>
deleteInstance: (id: string) => Promise<void>
// Socket
initSocketListeners: () => void
}
export const useInstanceStore = create<InstanceState>()(
immer((set, get) => ({
instances: [],
// Persiste a seleção ativa no localStorage para sobreviver a reloads
activeInstanceId: typeof window !== 'undefined' ? localStorage.getItem('activeInstanceId') : null,
qrByInstance: {},
loading: false,
// ── Sync ─────────────────────────────────────────────────────────────────
setInstances: (instances) =>
set((s) => { s.instances = instances }),
setActiveInstance: (id) => {
if (typeof window !== 'undefined') {
if (id) localStorage.setItem('activeInstanceId', id)
else localStorage.removeItem('activeInstanceId')
}
set((s) => { s.activeInstanceId = id })
},
patchInstance: (id, patch) =>
set((s) => {
const idx = s.instances.findIndex((i) => i.id === id)
if (idx !== -1) Object.assign(s.instances[idx], patch)
else s.instances.push({ id, name: '', phone: null, status: 'CONNECTING', ...patch } as Instance)
}),
setQr: (instanceId, qrBase64) =>
set((s) => { s.qrByInstance[instanceId] = qrBase64 }),
clearQr: (instanceId) =>
set((s) => { delete s.qrByInstance[instanceId] }),
removeInstance: (id) =>
set((s) => { s.instances = s.instances.filter((i) => i.id !== id) }),
// ── Async ─────────────────────────────────────────────────────────────────
loadInstances: async () => {
set((s) => { s.loading = true })
try {
const data = await instanceApi.list()
const list: Instance[] = (Array.isArray(data) ? data : (data.instances ?? [])).map((i: any) => ({
...i,
avatar: i.avatar ?? null,
}))
set((s) => {
s.loading = false
// Mescla em vez de substituir: preserva status CONNECTED que veio via socket
// (socket events são mais recentes que leituras REST — o banco pode estar
// desatualizado se a resposta da API chegou antes do DB ser gravado pelo backend)
const memMap = new Map(s.instances.map((i) => [i.id, i]))
s.instances = list.map((fromDb) => {
const inMem = memMap.get(fromDb.id)
if (inMem?.status === 'CONNECTED' && fromDb.status !== 'CONNECTED') {
// Socket já confirmou CONNECTED — mantém, não deixa REST stale sobrescrever
return { ...fromDb, status: 'CONNECTED' as InstanceStatus }
}
return fromDb
})
})
} catch {
set((s) => { s.loading = false })
}
},
connectInstance: async (id) => {
get().patchInstance(id, { status: 'CONNECTING' })
socketService.joinInstance(id)
await instanceApi.connect(id)
},
disconnectInstance: async (id) => {
await instanceApi.disconnect(id)
get().patchInstance(id, { status: 'DISCONNECTED' })
get().clearQr(id)
},
createInstance: async (name) => {
const created: Instance = await instanceApi.create(name)
set((s) => { s.instances.push(created) })
return created
},
deleteInstance: async (id) => {
await instanceApi.delete(id)
get().removeInstance(id)
if (get().activeInstanceId === id) {
set((s) => { s.activeInstanceId = null })
}
},
// ── Socket ────────────────────────────────────────────────────────────────
initSocketListeners: () => {
socketService.on('instance:status', ({
instanceId,
status,
qrBase64,
}: {
instanceId: string
status: InstanceStatus
qrBase64?: string
}) => {
get().patchInstance(instanceId, { status })
if (qrBase64) get().setQr(instanceId, qrBase64)
if (status === 'CONNECTED') get().clearQr(instanceId)
})
socketService.on('qr', ({ instanceId, qrBase64 }: { instanceId: string; qrBase64: string }) => {
get().setQr(instanceId, qrBase64)
get().patchInstance(instanceId, { status: 'QR_PENDING' })
})
socketService.on('instance:avatar', ({ instanceId, avatar }: { instanceId: string; avatar: string }) => {
get().patchInstance(instanceId, { avatar })
})
socketService.on('instance:syncing', ({ instanceId, gapMinutes }: { instanceId: string; gapMinutes: number }) => {
const inst = get().instances.find((i) => i.id === instanceId)
const label = inst?.name ?? instanceId.slice(0, 8)
if (gapMinutes >= 60) {
notify(
`📥 ${label}: sincronizando mensagens dos últimos ${Math.round(gapMinutes / 60)}h`,
'warning',
8000,
'syncing',
)
} else if (gapMinutes > 0) {
notify(
`📥 ${label}: buscando mensagens perdidas (${gapMinutes} min offline)`,
'info',
6000,
'syncing',
)
}
})
},
}))
)
@@ -0,0 +1,5 @@
// Stub do notificationStore do motor. As notificações "syncing" que o
// instanceStore dispara não são essenciais no satélite — no-op evita portar todo
// o sistema de toasts do motor. (As páginas têm toasts locais próprios.)
export type NotificationType = 'info' | 'success' | 'warning' | 'error';
export const notify = (_msg: string, _type?: NotificationType, _durationMs?: number, _tag?: string) => {};
@@ -0,0 +1,279 @@
import { create } from 'zustand'
import {
secAgentApi, secNodeApi, secConvApi, secCalApi, secNumberApi,
type SecAgent, type BrainNode, type SecConversation, type SecMessage, type CalendarSlot, type SecNumber,
} from '../services/secretariaApi'
interface SecretariaState {
// Data
agents: SecAgent[]
selectedAgent: SecAgent | null
nodes: BrainNode[]
conversations: SecConversation[]
selectedConversation: SecConversation | null
messages: SecMessage[]
calendarSlots: CalendarSlot[]
// Loading flags
loadingAgents: boolean
loadingNodes: boolean
loadingConversations: boolean
loadingMessages: boolean
sendingMessage: boolean
loadingCalendar: boolean
// ── Agents ────────────────────────────────────────────────────────────────
loadAgents: () => Promise<void>
selectAgent: (a: SecAgent | null) => void
createAgent: (d: Partial<SecAgent>) => Promise<SecAgent>
updateAgent: (id: string, d: Partial<SecAgent>) => Promise<void>
deleteAgent: (id: string) => Promise<void>
// ── Brain Nodes ───────────────────────────────────────────────────────────
loadNodes: (agentId: string) => Promise<void>
createNode: (agentId: string, d: Partial<BrainNode>) => Promise<void>
updateNode: (id: string, d: Partial<BrainNode>) => Promise<void>
deleteNode: (id: string) => Promise<void>
// ── Conversations ─────────────────────────────────────────────────────────
loadConversations: (agentId?: string) => Promise<void>
selectConversation: (c: SecConversation | null) => void
createConversation: (agentId: string, contactName: string) => Promise<SecConversation>
deleteConversation: (id: string) => Promise<void>
updateConversationStatus: (id: string, status: SecConversation['status']) => Promise<void>
// ── Messages ──────────────────────────────────────────────────────────────
loadMessages: (convId: string) => Promise<void>
sendMessage: (convId: string, text: string) => Promise<void>
// ── Calendar ──────────────────────────────────────────────────────────────
loadCalendar: (params?: { from?: string; to?: string; status?: string }) => Promise<void>
createCalendarSlot: (d: Partial<CalendarSlot>) => Promise<void>
updateCalendarSlot: (id: string, d: Partial<CalendarSlot>) => Promise<void>
deleteCalendarSlot: (id: string) => Promise<void>
// ── Numbers ───────────────────────────────────────────────────────────────
numbers: SecNumber[]
loadingNumbers: boolean
loadNumbers: () => Promise<void>
createNumber: (d: Partial<SecNumber>) => Promise<void>
updateNumber: (id: string, d: Partial<SecNumber>) => Promise<void>
deleteNumber: (id: string) => Promise<void>
}
export const useSecretariaStore = create<SecretariaState>((set, get) => ({
agents: [], selectedAgent: null, nodes: [],
conversations: [], selectedConversation: null, messages: [],
calendarSlots: [], numbers: [],
loadingAgents: false, loadingNodes: false, loadingConversations: false,
loadingMessages: false, sendingMessage: false, loadingCalendar: false, loadingNumbers: false,
// ── Agents ────────────────────────────────────────────────────────────────
loadAgents: async () => {
set({ loadingAgents: true })
try {
const agents = await secAgentApi.list()
set({ agents })
// Auto-seleciona o primeiro agente se nenhum selecionado
if (!get().selectedAgent && agents.length > 0) {
get().selectAgent(agents[0])
}
} finally {
set({ loadingAgents: false })
}
},
selectAgent: (a) => {
set({ selectedAgent: a, nodes: [], conversations: [], selectedConversation: null, messages: [] })
if (a) {
get().loadNodes(a.id)
get().loadConversations(a.id)
}
},
createAgent: async (d) => {
const agent = await secAgentApi.create(d)
set((s) => ({ agents: [...s.agents, agent] }))
return agent
},
updateAgent: async (id, d) => {
const updated = await secAgentApi.update(id, d)
set((s) => ({
agents: s.agents.map((a) => (a.id === id ? updated : a)),
selectedAgent: s.selectedAgent?.id === id ? updated : s.selectedAgent,
}))
},
deleteAgent: async (id) => {
await secAgentApi.delete(id)
set((s) => ({
agents: s.agents.filter((a) => a.id !== id),
selectedAgent: s.selectedAgent?.id === id ? null : s.selectedAgent,
}))
},
// ── Brain Nodes ───────────────────────────────────────────────────────────
loadNodes: async (agentId) => {
set({ loadingNodes: true })
try {
const nodes = await secNodeApi.list(agentId)
set({ nodes })
} finally {
set({ loadingNodes: false })
}
},
createNode: async (agentId, d) => {
const node = await secNodeApi.create(agentId, d)
set((s) => ({ nodes: [...s.nodes, node] }))
},
updateNode: async (id, d) => {
const updated = await secNodeApi.update(id, d)
set((s) => ({ nodes: s.nodes.map((n) => (n.id === id ? updated : n)) }))
},
deleteNode: async (id) => {
await secNodeApi.delete(id)
set((s) => ({ nodes: s.nodes.filter((n) => n.id !== id) }))
},
// ── Conversations ─────────────────────────────────────────────────────────
loadConversations: async (agentId) => {
set({ loadingConversations: true })
try {
const conversations = await secConvApi.list(agentId)
set({ conversations })
} finally {
set({ loadingConversations: false })
}
},
selectConversation: (c) => {
set({ selectedConversation: c, messages: [] })
if (c) get().loadMessages(c.id)
},
createConversation: async (agentId, contactName) => {
const conv = await secConvApi.create(agentId, contactName)
set((s) => ({ conversations: [conv, ...s.conversations] }))
return conv
},
deleteConversation: async (id) => {
await secConvApi.delete(id)
set((s) => ({
conversations: s.conversations.filter((c) => c.id !== id),
selectedConversation: s.selectedConversation?.id === id ? null : s.selectedConversation,
}))
},
updateConversationStatus: async (id, status) => {
const updated = await secConvApi.patch(id, { status })
set((s) => ({
conversations: s.conversations.map((c) => (c.id === id ? updated : c)),
selectedConversation: s.selectedConversation?.id === id ? updated : s.selectedConversation,
}))
},
// ── Messages ──────────────────────────────────────────────────────────────
loadMessages: async (convId) => {
set({ loadingMessages: true })
try {
const messages = await secConvApi.messages(convId)
set({ messages })
} finally {
set({ loadingMessages: false })
}
},
sendMessage: async (convId, text) => {
// Adiciona mensagem do usuário imediatamente (optimistic)
const tempId = `temp-${Date.now()}`
const userMsg: SecMessage = {
id: tempId, conversation_id: convId, role: 'user', content: text, created_at: new Date().toISOString(),
}
set((s) => ({ messages: [...s.messages, userMsg], sendingMessage: true }))
try {
const { reply } = await secConvApi.chat(convId, text)
const aiMsg: SecMessage = {
id: `ai-${Date.now()}`, conversation_id: convId, role: 'assistant', content: reply,
created_at: new Date().toISOString(),
}
set((s) => ({ messages: [...s.messages, aiMsg] }))
// Atualiza updated_at da conversa na lista
set((s) => ({
conversations: s.conversations.map((c) =>
c.id === convId ? { ...c, updated_at: new Date().toISOString() } : c,
),
}))
} finally {
set({ sendingMessage: false })
}
},
// ── Calendar ──────────────────────────────────────────────────────────────
loadCalendar: async (params) => {
set({ loadingCalendar: true })
try {
const calendarSlots = await secCalApi.list(params)
set({ calendarSlots })
} finally {
set({ loadingCalendar: false })
}
},
createCalendarSlot: async (d) => {
const slot = await secCalApi.create(d)
set((s) => ({ calendarSlots: [...s.calendarSlots, slot].sort((a, b) => a.date.localeCompare(b.date)) }))
},
updateCalendarSlot: async (id, d) => {
const updated = await secCalApi.update(id, d)
set((s) => ({ calendarSlots: s.calendarSlots.map((sl) => (sl.id === id ? updated : sl)) }))
},
deleteCalendarSlot: async (id) => {
await secCalApi.delete(id)
set((s) => ({ calendarSlots: s.calendarSlots.filter((sl) => sl.id !== id) }))
},
// ── Numbers ───────────────────────────────────────────────────────────────
loadNumbers: async () => {
set({ loadingNumbers: true })
try {
const numbers = await secNumberApi.list()
set({ numbers })
} finally {
set({ loadingNumbers: false })
}
},
createNumber: async (d) => {
const num = await secNumberApi.assign(d)
set((s) => ({
// upsert: substitui se já existia o mesmo instance_id
numbers: [...s.numbers.filter((n) => n.instance_id !== num.instance_id), num]
.sort((a, b) => a.priority - b.priority),
}))
},
updateNumber: async (id, d) => {
const updated = await secNumberApi.update(id, d)
set((s) => ({ numbers: s.numbers.map((n) => (n.id === id ? updated : n)).sort((a, b) => a.priority - b.priority) }))
},
deleteNumber: async (id) => {
await secNumberApi.delete(id)
set((s) => ({ numbers: s.numbers.filter((n) => n.id !== id) }))
},
}))
+117
View File
@@ -0,0 +1,117 @@
// ─── Rich payload types ───────────────────────────────────────────────────────
export type NativeBtn =
| { type: 'reply'; id: string; text: string }
| { type: 'url'; text: string; url: string }
| { type: 'copy'; text: string; copyText: string }
| { type: 'call'; text: string; phoneNumber: string }
export interface RichPayload {
text?: string
footer?: string
nativeButtons?: NativeBtn[]
nativeList?: {
buttonText: string
sections: Array<{ title: string; rows: Array<{ id: string; title: string; description?: string }> }>
}
nativeCarousel?: {
cards: Array<{
title?: string; body?: string; footer?: string
image?: { url: string }
buttons: Array<{ type?: string; id?: string; text: string }>
}>
}
poll?: { name: string; values: string[]; selectableCount?: number }
}
export interface Message {
id: number;
chat_id: number;
message_id: string;
text: string;
type: string;
direction: 'in' | 'out';
status: 'sending' | 'pending' | 'sent' | 'delivered' | 'read' | 'deleted' | 'failed';
timestamp: string;
media_url?: string;
media_type?: string;
media_size?: number;
quoted_id?: string | null;
quoted_message_id?: string | null;
quoted_message_text?: string | null;
quoted_participant?: string | null;
reactions?: Record<string, string> | null;
participant?: string | null;
instance_name?: string;
sender_jid?: string;
rich_payload?: RichPayload | null;
transcription?: string | null;
}
export interface WhatsAppInstance {
instance: string;
/** New Whats API status: open, connected, disconnected, connecting, etc. */
status: 'connecting' | 'qr' | 'connected' | 'disconnected' | 'open' | 'close';
phone?: string;
hasQr: boolean;
createdAt: string;
profilePictureUrl?: string;
photoUpdatesRemaining?: number;
partner_id?: number | null;
partner_name?: string | null;
/** For UI display primarily from the Console/Admin View */
id?: string;
nome?: string;
avatar?: string | null;
webhook_url?: string;
metrics?: {
sent: number;
failed: number;
};
}
// ─── Inbox / Chat UI types ────────────────────────────────────────────────────
// Formato legado consumido por ChatList, ChatListItem, ChatSidebar e hooks de inbox.
// Produzido pelo mapper chatToLegacy() em useNewInboxBridge.
export interface NewWhatsChat {
id: any
instance_name: string
remote_jid: string
phone: string
lead_name: string | null
avatar_url: string | null
avatar_version: string | null
bio: string | null
displayName: string | null
subject: string | null
last_message_text: string | null
last_message_time: string | null
last_message_from_me: 0 | 1
last_message_status: string | null
last_message_type?: string | null
last_message_sender?: string | null
unread_count: number
is_pinned: boolean | null
is_archived: boolean | null
muted_until?: string | null
bot_paused?: boolean | null
labels?: Array<{ label_id: string; name?: string }>
}
export interface PresenceState {
state: 'composing' | 'recording' | 'paused' | 'available' | 'unavailable'
ts: number
}
export type PurgeMode = 'all' | 'data_only';
export type StepStatus = 'waiting' | 'running' | 'ok' | 'error' | 'skip';
export interface PurgeStep {
id: string;
label: string;
detail: string;
icon?: any;
status: StepStatus;
}
export default function TypesPage() { return null; }
@@ -0,0 +1,99 @@
const getAccessToken = () => {
if (typeof window !== 'undefined') return localStorage.getItem('token') || '';
return '';
};
export const normalizeJid = (jid: string) => {
if (!jid) return '';
const [user] = jid.split('@');
const [cleanUser] = user.split(':');
return cleanUser;
};
export const formatPhone = (phone: string, remoteJid?: string): string => {
const raw = String(phone || '').trim();
const jid = String(remoteJid || '');
const cleaned = raw.replace(/\D/g, '');
// ── @lid — identificador interno do WhatsApp: exibe como +número ──────────
if (jid.endsWith('@lid')) {
const id = (normalizeJid(jid) || cleaned).replace(/\D/g, '');
return id ? `+${id}` : raw;
}
// ── Brasil (55) — 12 ou 13 dígitos ───────────────────────────────────────
if ((cleaned.length === 12 || cleaned.length === 13) && cleaned.startsWith('55')) {
return cleaned.replace(/^(\d{2})(\d{2})(\d{4,5})(\d{4})$/, '+$1 $2 $3-$4');
}
// ── EUA / Canadá (1) — 11 dígitos ────────────────────────────────────────
if (cleaned.length === 11 && cleaned.startsWith('1')) {
return cleaned.replace(/^(\d{1})(\d{3})(\d{3})(\d{4})$/, '+$1 ($2) $3-$4');
}
// ── Outros países — formato E.164 genérico ────────────────────────────────
if (cleaned.length >= 7 && cleaned.length <= 15) {
return `+${cleaned}`;
}
return raw || cleaned;
};
export const getAvatarInitials = (chat: any) => {
const name = chat.displayName || chat.subject || chat.phone || '';
return name.slice(0, 2).toUpperCase();
};
export const getAvatarColor = (id: string): string => {
let hash = 0;
const cleanId = String(id || 'anon');
for (let i = 0; i < cleanId.length; i++) {
hash = cleanId.charCodeAt(i) + ((hash << 5) - hash);
}
const h = Math.abs(hash % 360);
// HSL: S=65%, L=45% for a vibrant, accessible look with white text
return `hsl(${h}, 65%, 45%)`;
};
export const getLabelColor = (index: number) => {
const colors = [
'bg-gray-500', 'bg-red-500', 'bg-orange-500', 'bg-yellow-500',
'bg-green-500', 'bg-teal-500', 'bg-blue-500', 'bg-indigo-500',
'bg-purple-500', 'bg-pink-500'
];
return colors[index % colors.length] || 'bg-gray-500';
};
export const getMediaUrl = (url: string | null | undefined): string | null => {
if (!url) return null;
if (url.startsWith('http')) return url;
const apiBase = ''; // satélite: same-origin (URLs http/Wasabi já retornam acima)
// Path local (/media/...) — servido pelo Express static do backend
if (url.startsWith('/media/')) return `${apiBase}${url}`;
// Path Wasabi — proxiado pela rota /api/storage/view/ do backend
const clean = url.startsWith('/') ? url.slice(1) : url;
return `${apiBase}/api/storage/view/${clean}`;
};
export const getAvatarProxyUrl = (chat: any, _type: 'instance' | 'contact' = 'contact'): string | null => {
// Satélite: não há proxy de avatar exposto na ext API — retorna null e o
// componente Avatar cai para as iniciais (fallback limpo, sem 404s).
return null;
};
export const parseSafeDate = (timestamp: any): Date => {
if (!timestamp) return new Date();
let d = new Date(timestamp);
// If invalid and a number (or numeric string)
if (isNaN(d.getTime())) {
const num = Number(timestamp);
if (!isNaN(num)) {
// Unix seconds (Whats APIs often use this) vs ms
d = new Date(num > 10000000000 ? num : num * 1000);
}
}
return isNaN(d.getTime()) ? new Date() : d;
};
export default function UtilsPage() { return null; }
+8
View File
@@ -5,10 +5,18 @@ import tailwindcss from '@tailwindcss/vite';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
// Servido atrás de um proxy TLS (ex.: dev.scoreodonto.com via nginx/traefik):
// libera o host e roteia o websocket de HMR por wss:443. Só ativa quando
// DEV_PUBLIC_HOST está definido (container dev) — `npm run dev` local não muda.
const proxiedHost = process.env.DEV_PUBLIC_HOST;
return {
server: {
port: 3000,
host: 'localhost',
...(proxiedHost ? {
allowedHosts: [proxiedHost],
hmr: { protocol: 'wss', host: proxiedHost, clientPort: 443 },
} : {}),
},
plugins: [
react(),