Ver Fonte

feat: whatsapp connection integration to frontend done

EduLascala há 1 semana atrás
pai
commit
7780d7e1d3

+ 20 - 0
src/lib/features/integrations/unipile/api.js

@@ -0,0 +1,20 @@
+import { api } from '$lib/core/api/client.js';
+
+export async function listWhatsappAccounts() {
+	const data = await api.get('/v1/integrations/unipile/whatsapp/accounts');
+	return data.items ?? [];
+}
+
+export async function createWhatsappHostedLink() {
+	const data = await api.post('/v1/integrations/unipile/whatsapp/hosted-link', {
+		body: { type: 'create' }
+	});
+	return String(data.url ?? '');
+}
+
+export async function reconnectWhatsappAccount(integrationId) {
+	const data = await api.post('/v1/integrations/unipile/whatsapp/hosted-link', {
+		body: { type: 'reconnect', integration_id: integrationId }
+	});
+	return String(data.url ?? '');
+}

+ 308 - 27
src/routes/(app)/dashboard/settings/+page.svelte

@@ -1,28 +1,15 @@
 <script>
-	import { Settings, Phone, Bell, HelpCircle, ShieldCheck } from 'lucide-svelte';
+	import { onMount } from 'svelte';
+	import { Settings, Phone, Bell, HelpCircle, ShieldCheck, LoaderCircle, RefreshCw, ExternalLink } from 'lucide-svelte';
 	import SlaConfigManager from '$lib/features/sla/SlaConfigManager.svelte';
+	import {
+		listWhatsappAccounts,
+		createWhatsappHostedLink,
+		reconnectWhatsappAccount
+	} from '$lib/features/integrations/unipile/api.js';
 
 	// Settings sections
-	const settingsSections = [
-		{
-			title: 'Integrações',
-			icon: Phone,
-			items: [
-				{
-					title: 'WhatsApp Business',
-					description: 'Configure sua conta do WhatsApp Business para comunicação',
-					status: 'connected',
-					action: 'gerenciar'
-				},
-				{
-					title: 'WhatsApp dos Vendedores',
-					description: 'Adicione os números de WhatsApp da equipe de vendas',
-					status: 'partial',
-					action: 'configurar',
-					count: '3 de 5 configurados'
-				}
-			]
-		},
+	const staticSettingsSections = [
 		{
 			title: 'Notificações',
 			icon: Bell,
@@ -61,22 +48,279 @@
 		}
 	];
 
+	const MAX_WHATSAPP_POLL_ATTEMPTS = 12;
+
+	let whatsappAccounts = $state([]);
+	let whatsappLoading = $state(true);
+	let whatsappLoadError = $state('');
+	let whatsappActionError = $state('');
+	let whatsappOpening = $state(false);
+	let whatsappRefreshing = $state(false);
+	let whatsappPolling = $state(false);
+	let lastHostedAuthUrl = $state('');
+	let whatsappPollInterval = $state(null);
+	let whatsappPollTimeout = $state(null);
+	let whatsappPollAttempts = $state(0);
+
+	const primaryWhatsappAccount = $derived(
+		whatsappAccounts.find((account) => account?.isConnected) ?? whatsappAccounts[0] ?? null
+	);
+	const settingsSections = $derived([
+		{
+			title: 'Integrações',
+			icon: Phone,
+			items: [buildWhatsappItem(), {
+				key: 'whatsapp-sellers',
+				title: 'WhatsApp dos Vendedores',
+				description: 'Adicione os números de WhatsApp da equipe de vendas',
+				status: 'partial',
+				action: 'configurar',
+				count: '3 de 5 configurados'
+			}]
+		},
+		...staticSettingsSections
+	]);
+
 	function handleAction(item) {
 		// Handle different actions based on item
-		console.log(`Action: ${item.action} for ${item.title}`);
+		if (item.key === 'whatsapp-business') {
+			void handleWhatsappPrimaryAction();
+			return;
+		}
 		
 		// For WhatsApp integration, we could open a modal or navigate to a specific page
-		if (item.title === 'WhatsApp Business') {
+		if (item.key === 'whatsapp-business') {
 			// TODO: Open WhatsApp configuration modal
-		} else if (item.title === 'WhatsApp dos Vendedores') {
+		} else if (item.key === 'whatsapp-sellers') {
 			// TODO: Open sellers WhatsApp configuration
 		}
 	}
 
+	function handleSecondaryAction(item) {
+		if (item.key === 'whatsapp-business') {
+			void refreshWhatsappAccounts();
+		}
+	}
+
+	function getWhatsappStatus() {
+		if (whatsappLoading) return 'loading';
+		if (whatsappOpening || whatsappPolling) return 'connecting';
+		if (whatsappLoadError || whatsappActionError) return 'error';
+		if (primaryWhatsappAccount?.isConnected) return 'connected';
+		if (primaryWhatsappAccount) return 'partial';
+		return 'disabled';
+	}
+
+	function buildWhatsappItem() {
+		const connectedAccounts = whatsappAccounts.filter((account) => account?.isConnected);
+		const status = getWhatsappStatus();
+		const hasAccount = primaryWhatsappAccount !== null;
+		const action = hasAccount ? 'reconectar' : 'conectar';
+		const description = whatsappLoading
+			? 'Consultando o status da integração WhatsApp.'
+			: primaryWhatsappAccount?.isConnected
+				? 'Conta conectada e pronta para receber e enviar mensagens pelo backend.'
+				: hasAccount
+					? 'A conta existe, mas ainda não está conectada. Gere um novo link para concluir ou repetir o Hosted Auth.'
+					: 'Nenhuma conta WhatsApp conectada ainda. Gere um link, conclua o Hosted Auth e volte para atualizar o status.';
+		const count = whatsappLoading
+			? ''
+			: connectedAccounts.length > 0
+				? `${connectedAccounts.length} conta${connectedAccounts.length > 1 ? 's' : ''} conectada${connectedAccounts.length > 1 ? 's' : ''}`
+				: whatsappAccounts.length > 0
+					? `${whatsappAccounts.length} conta${whatsappAccounts.length > 1 ? 's' : ''} cadastrada${whatsappAccounts.length > 1 ? 's' : ''}`
+					: '';
+		const meta = primaryWhatsappAccount
+			? [
+					primaryWhatsappAccount.accountName ? `Conta: ${primaryWhatsappAccount.accountName}` : '',
+					primaryWhatsappAccount.accountId ? `Account ID: ${primaryWhatsappAccount.accountId}` : '',
+					primaryWhatsappAccount.status ? `Status: ${primaryWhatsappAccount.status}` : '',
+					primaryWhatsappAccount.lastSyncAt ? `Última sincronização: ${formatDateTime(primaryWhatsappAccount.lastSyncAt)}` : ''
+				]
+					.filter(Boolean)
+					.join(' • ')
+			: '';
+		const helper = whatsappOpening || whatsappPolling
+			? 'Complete a autenticação na janela do Unipile. Esta tela está verificando automaticamente se a conta entrou no backend.'
+			: lastHostedAuthUrl !== ''
+				? 'Se você fechou a janela do Unipile, use “abrir link” para retomar o Hosted Auth ou “atualizar” para consultar o backend.'
+				: 'Depois de concluir o Hosted Auth, use “atualizar” para confirmar se o callback registrou a integração.';
+		return {
+			key: 'whatsapp-business',
+			title: 'WhatsApp Business',
+			description,
+			status,
+			action: whatsappOpening ? 'abrindo...' : action,
+			secondaryAction: whatsappRefreshing ? 'atualizando...' : 'atualizar',
+			tertiaryAction: lastHostedAuthUrl !== '' ? 'abrir link' : '',
+			count,
+			meta,
+			error: whatsappLoadError || whatsappActionError || primaryWhatsappAccount?.lastError || '',
+			helper,
+			disablePrimary: whatsappLoading || whatsappOpening,
+			disableSecondary: whatsappLoading || whatsappRefreshing,
+			disableTertiary: whatsappOpening
+		};
+	}
+
+	async function loadWhatsappAccounts({ silent = false } = {}) {
+		if (!silent) {
+			whatsappLoading = true;
+			whatsappLoadError = '';
+		}
+
+		try {
+			const accounts = await listWhatsappAccounts();
+			whatsappAccounts = accounts;
+			if (accounts.some((account) => account?.isConnected)) {
+				stopWhatsappPolling();
+			}
+			return accounts;
+		} catch (err) {
+			const message = err?.message ?? 'Falha ao carregar as integrações do WhatsApp.';
+			if (silent) {
+				whatsappActionError = message;
+				return whatsappAccounts;
+			}
+			whatsappLoadError = message;
+			whatsappAccounts = [];
+			return [];
+		} finally {
+			if (!silent) {
+				whatsappLoading = false;
+			}
+		}
+	}
+
+	async function refreshWhatsappAccounts() {
+		if (whatsappRefreshing) {
+			return whatsappAccounts;
+		}
+
+		whatsappRefreshing = true;
+		whatsappActionError = '';
+		try {
+			return await loadWhatsappAccounts({ silent: true });
+		} finally {
+			whatsappRefreshing = false;
+		}
+	}
+
+	function stopWhatsappPolling() {
+		if (whatsappPollTimeout !== null) {
+			window.clearTimeout(whatsappPollTimeout);
+			whatsappPollTimeout = null;
+		}
+		if (whatsappPollInterval !== null) {
+			window.clearInterval(whatsappPollInterval);
+			whatsappPollInterval = null;
+		}
+		whatsappPolling = false;
+		whatsappPollAttempts = 0;
+	}
+
+	function startWhatsappPolling() {
+		stopWhatsappPolling();
+		whatsappPolling = true;
+		whatsappPollAttempts = 0;
+
+		const poll = async () => {
+			whatsappPollAttempts += 1;
+			const accounts = await refreshWhatsappAccounts();
+			if (accounts.some((account) => account?.isConnected) || whatsappPollAttempts >= MAX_WHATSAPP_POLL_ATTEMPTS) {
+				stopWhatsappPolling();
+			}
+		};
+
+		whatsappPollTimeout = window.setTimeout(() => {
+			void poll();
+		}, 2000);
+		whatsappPollInterval = window.setInterval(() => {
+			void poll();
+		}, 5000);
+	}
+
+	async function handleWhatsappPrimaryAction() {
+		if (whatsappOpening) return;
+
+		whatsappActionError = '';
+		whatsappOpening = true;
+		let popup = null;
+
+		try {
+			popup = window.open('', '_blank');
+			const url = primaryWhatsappAccount?.id
+				? await reconnectWhatsappAccount(primaryWhatsappAccount.id)
+				: await createWhatsappHostedLink();
+
+			if (!url) {
+				throw new Error('A API não retornou a URL de conexão do WhatsApp.');
+			}
+
+			lastHostedAuthUrl = url;
+
+			if (popup && !popup.closed) {
+				popup.location.replace(url);
+			} else {
+				const opened = window.open(url, '_blank');
+				if (!opened) {
+					throw new Error('Não foi possível abrir a janela do Unipile. Verifique o bloqueador de pop-up.');
+				}
+			}
+
+			startWhatsappPolling();
+			await refreshWhatsappAccounts();
+		} catch (err) {
+			if (popup && !popup.closed) {
+				popup.close();
+			}
+			whatsappActionError = err?.message ?? 'Falha ao iniciar a conexão do WhatsApp.';
+		} finally {
+			whatsappOpening = false;
+		}
+	}
+
+	function openLastHostedAuthLink() {
+		if (!lastHostedAuthUrl) return;
+		window.open(lastHostedAuthUrl, '_blank');
+	}
+
+	function formatDateTime(value) {
+		if (!value) return '';
+		const parsed = new Date(value);
+		if (Number.isNaN(parsed.getTime())) {
+			return value;
+		}
+		return new Intl.DateTimeFormat('pt-BR', {
+			dateStyle: 'short',
+			timeStyle: 'short'
+		}).format(parsed);
+	}
+
+	onMount(() => {
+		void loadWhatsappAccounts();
+
+		const onFocus = () => {
+			if (lastHostedAuthUrl !== '' || whatsappPolling) {
+				void refreshWhatsappAccounts();
+			}
+		};
+
+		window.addEventListener('focus', onFocus);
+
+		return () => {
+			window.removeEventListener('focus', onFocus);
+			stopWhatsappPolling();
+		};
+	});
+
 	function getStatusBadge(status) {
 		const statusConfig = {
+			loading: { text: 'Carregando', class: 'bg-slate-100 text-slate-800 dark:bg-slate-900/30 dark:text-slate-400' },
+			connecting: { text: 'Conectando', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
 			connected: { text: 'Conectado', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
 			partial: { text: 'Parcial', class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
+			error: { text: 'Erro', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
 			enabled: { text: 'Ativo', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
 			disabled: { text: 'Inativo', class: 'bg-slate-100 text-slate-800 dark:bg-slate-900/30 dark:text-slate-400' },
 			configured: { text: 'Configurado', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
@@ -121,7 +365,7 @@
 				<div class="divide-y divide-slate-200 dark:divide-slate-800">
 					{#each section.items as item}
 						<div class="p-5 hover:bg-slate-50 transition-colors duration-150 dark:hover:bg-slate-800/50">
-							<div class="flex items-center justify-between">
+							<div class="flex items-start justify-between gap-4">
 								<div class="flex-1 min-w-0">
 									<div class="flex items-center gap-3">
 										<h3 class="text-sm font-medium text-slate-900 dark:text-white">{item.title}</h3>
@@ -133,14 +377,51 @@
 									{#if item.count}
 										<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">{item.count}</p>
 									{/if}
+									{#if item.meta}
+										<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.meta}</p>
+									{/if}
+									{#if item.error}
+										<p class="mt-2 text-xs text-red-600 dark:text-red-400">{item.error}</p>
+									{/if}
+									{#if item.helper}
+										<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.helper}</p>
+									{/if}
 								</div>
-								<div class="ml-4">
+								<div class="ml-4 flex shrink-0 flex-col items-end gap-2">
+									{#if item.secondaryAction}
+										<button
+											onclick={() => handleSecondaryAction(item)}
+											disabled={item.disableSecondary}
+											class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-slate-300 text-slate-700 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 transition-colors duration-150 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
+										>
+											{#if item.disableSecondary}
+												<LoaderCircle size={14} class="animate-spin" />
+											{:else}
+												<RefreshCw size={14} />
+											{/if}
+											{item.secondaryAction}
+										</button>
+									{/if}
 									<button
 										onclick={() => handleAction(item)}
-										class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md bg-indigo-600 text-white hover:bg-indigo-700 transition-colors duration-150"
+										disabled={item.disablePrimary}
+										class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-indigo-600 text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60 transition-colors duration-150"
 									>
+										{#if item.disablePrimary}
+											<LoaderCircle size={14} class="animate-spin" />
+										{/if}
 										{item.action}
 									</button>
+									{#if item.tertiaryAction}
+										<button
+											onclick={openLastHostedAuthLink}
+											disabled={item.disableTertiary}
+											class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md text-indigo-700 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60 transition-colors duration-150 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
+										>
+											<ExternalLink size={14} />
+											{item.tertiaryAction}
+										</button>
+									{/if}
 								</div>
 							</div>
 						</div>