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:
+24
-10
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+239
-19
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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
@@ -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ê já 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">Dê 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ó 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>
|
||||
);
|
||||
}
|
||||
@@ -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 já 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 lê 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) });
|
||||
},
|
||||
}));
|
||||
@@ -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) }))
|
||||
},
|
||||
}))
|
||||
@@ -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; }
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user