From 0873ffd69a56c13ba444bae33fa8b6392e9032ec Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 10 Jun 2026 16:25:45 -0300 Subject: [PATCH 1/2] feat(evohub): add EvoHub channel option to instance creation (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona EvoHub como 4ª opção de canal no NewInstance (manager-v2), Fase 1 = vincular canal existente. Os 3 canais existentes ficam idênticos. - evolution.types.ts: shapes Hub (HubPlan, MetaAppOptions, HubChannel, EvoHubProvisionResponse) + EVOHUB_INTEGRATION + tipos de canal - i18n: chave integration.evohub + bloco instance.form.evohub.* nos 4 idiomas - NewInstance.tsx: EVOHUB no enum Zod + options + render condicional ; esconde o Save global no EVOHUB (evita double-create) - evohubService.ts: serviço control-plane via apiGlobal (apikey global); linkExisting (Fase 1) envia só { instanceName, hub_channel_id, channel_type }, token resolvido server-side — o front nunca vê o token - EvoHubConnect.tsx: painel de conexão; Fase 1 = modo existing (single-step, estado linked legítimo). Modo new (public_link) atrás de flag PHASE2_CREATE_NEW tsc --noEmit verde, eslint limpo. (build tsc -b acusa erro PRÉ-EXISTENTE em message-content.tsx:64 — fora do escopo desta feature.) --- src/lib/queries/evohub/evohubService.ts | 83 ++++++++ src/pages/Dashboard/EvoHubConnect.tsx | 254 ++++++++++++++++++++++++ src/pages/Dashboard/NewInstance.tsx | 27 ++- src/translate/languages/en-US.json | 44 +++- src/translate/languages/es-ES.json | 44 +++- src/translate/languages/fr-FR.json | 44 +++- src/translate/languages/pt-BR.json | 44 +++- src/types/evolution.types.ts | 62 ++++++ 8 files changed, 594 insertions(+), 8 deletions(-) create mode 100644 src/lib/queries/evohub/evohubService.ts create mode 100644 src/pages/Dashboard/EvoHubConnect.tsx diff --git a/src/lib/queries/evohub/evohubService.ts b/src/lib/queries/evohub/evohubService.ts new file mode 100644 index 0000000..c7282ff --- /dev/null +++ b/src/lib/queries/evohub/evohubService.ts @@ -0,0 +1,83 @@ +import { apiGlobal } from "../api"; + +import type { + EvoHubProvisionResponse, + HubChannel, + HubChannelType, + HubPlan, + MetaAppOptions, +} from "@/types/evolution.types"; + +// Extrai data tolerando { data: T } ou T direto (espelha extractData do CRM). +function extractData(response: { data: unknown }): T { + const d = response.data as { data?: T } | T; + if (d && typeof d === "object" && "data" in d && (d as { data?: T }).data !== undefined) { + return (d as { data: T }).data; + } + return d as T; +} + +// FASE 1 — vincular-existente. SEM token (resolvido server-side). +// channelType = tipo do HUB já mapeado (HubChannelType), não o valor da UI. +interface LinkExistingParams { + instanceName: string; + channelType: HubChannelType; // "whatsapp" | "facebook" | "instagram" + hubChannelId: string; +} + +// FASE 2 — criar-novo (public_link). SEM token (channel_token sai do provisionamento). +interface ProvisionNewParams { + instanceName: string; + channelType: HubChannelType; // "whatsapp" | "facebook" | "instagram" + metaAppMode: "shared" | string; // "shared" ou byo_credential_id +} + +export const evohubService = { + // GET /evohub/plan → HubPlan + async getPlan(): Promise { + const response = await apiGlobal.get("/evohub/plan"); + return extractData(response); + }, + + // GET /evohub/meta-app-options → MetaAppOptions + async getMetaAppOptions(): Promise { + const response = await apiGlobal.get("/evohub/meta-app-options"); + return extractData(response); + }, + + // GET /evohub/channels → HubChannel[] + async listChannels(): Promise { + const response = await apiGlobal.get("/evohub/channels"); + return extractData(response) ?? []; + }, + + // GET /evohub/available-channels?type=... → HubChannel[] (já-vinculados filtrados server-side) + async getAvailableChannels(type?: HubChannelType): Promise { + const query = type ? `?type=${encodeURIComponent(type)}` : ""; + const response = await apiGlobal.get(`/evohub/available-channels${query}`); + return extractData(response) ?? []; + }, + + // FASE 1 — POST /evohub/link-existing + // Envia SÓ { instanceName, hub_channel_id, channel_type }. SEM token: o back-end resolve + // o channel_token + phone_number_id via GET /api/v1/channels/:id (server-side) e cria a + // Instance local sincronamente. O front nunca vê o token (contrato §1, §4-A). + async linkExisting(params: LinkExistingParams): Promise { + const response = await apiGlobal.post("/evohub/link-existing", { + instanceName: params.instanceName, + hub_channel_id: params.hubChannelId, + channel_type: params.channelType, + }); + return extractData(response); + }, + + // FASE 2 — POST /evohub/provision (criar-novo, multi-step). + async provisionNew(params: ProvisionNewParams): Promise { + const response = await apiGlobal.post("/evohub/provision", { + instanceName: params.instanceName, + channel_type: params.channelType, + meta_app_mode: params.metaAppMode, + }); + return extractData(response); + }, +}; diff --git a/src/pages/Dashboard/EvoHubConnect.tsx b/src/pages/Dashboard/EvoHubConnect.tsx new file mode 100644 index 0000000..99ab25d --- /dev/null +++ b/src/pages/Dashboard/EvoHubConnect.tsx @@ -0,0 +1,254 @@ +import { Button } from "@evoapi/design-system/button"; +import { Label } from "@evoapi/design-system/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@evoapi/design-system/select"; +import { ExternalLink, Link2, Loader2 } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "react-toastify"; + +import { evohubService } from "@/lib/queries/evohub/evohubService"; +import { EvoHubUiChannelType, HubChannel, HubChannelType, MetaAppOptions } from "@/types/evolution.types"; + +type Mode = "new" | "existing"; +type ConnectState = "idle" | "creating" | "awaiting-meta-auth" | "connected" | "linking" | "linked"; + +// Mapping de tipo da UI -> tipo do hub (espelha HUB_TYPE_BY_CHANNEL do CRM) +const HUB_TYPE_BY_CHANNEL: Record = { + whatsapp_cloud: "whatsapp", + facebook_page: "facebook", + instagram: "instagram", +}; + +// FASE 2: trocar para true (ou ler de feature-flag) para habilitar o modo criar-novo. +const PHASE2_CREATE_NEW = false; + +// O painel NÃO recebe nem envia token (contrato §1, §5): o token é resolvido server-side +// pelo back-end no link-existing e persistido em Instance.token. +interface EvoHubConnectProps { + instanceName: string; + onConnected: () => void; +} + +export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) { + const { t } = useTranslation(); + + const [channelType, setChannelType] = useState("whatsapp_cloud"); + // FASE 1: inicia em "existing" (caminho funcional). "new" é Fase 2 (atrás da flag). + const [mode, setMode] = useState(PHASE2_CREATE_NEW ? "new" : "existing"); + + // shared vs BYO (modo new). Default: shared se permitido. + const [metaAppMode, setMetaAppMode] = useState<"shared" | string>("shared"); + const [metaOptions, setMetaOptions] = useState(null); + + // canais disponíveis (modo existing) + const [availableChannels, setAvailableChannels] = useState([]); + const [selectedHubChannelId, setSelectedHubChannelId] = useState(""); + + const [state, setState] = useState("idle"); + const [publicLink, setPublicLink] = useState(null); + + const submitting = state === "creating" || state === "linking"; + const hubType = HUB_TYPE_BY_CHANNEL[channelType]; + + // Preview de opções de Meta App (FASE 2 — modo new). Gateado pela flag para não + // disparar fetch/toast de uma UI invisível na Fase 1. + useEffect(() => { + if (!PHASE2_CREATE_NEW) return; + let cancelled = false; + evohubService + .getMetaAppOptions() + .then((opts) => { + if (cancelled) return; + setMetaOptions(opts); + if (opts.allowed_modes.includes("shared")) setMetaAppMode("shared"); + else if (opts.byo_credentials.length > 0) setMetaAppMode(opts.byo_credentials[0].id); + }) + .catch(() => { + if (!cancelled) toast.error(t("instance.form.evohub.error.load")); + }); + return () => { + cancelled = true; + }; + }, [t]); + + // Modo existing: buscar canais disponíveis (lazy, só quando mode === 'existing'). + useEffect(() => { + if (mode !== "existing") return; + let cancelled = false; + evohubService + .getAvailableChannels(hubType) + .then((chs) => { + if (!cancelled) setAvailableChannels(chs); + }) + .catch(() => { + if (!cancelled) toast.error(t("instance.form.evohub.error.load")); + }); + return () => { + cancelled = true; + }; + }, [mode, hubType, t]); + + // FASE 2 — criar-novo (multi-step, public_link). Só executa com PHASE2_CREATE_NEW=true. + const handleNew = async () => { + setState("creating"); + try { + const res = await evohubService.provisionNew({ + instanceName, + channelType: hubType, + metaAppMode, + }); + if (res.public_link) { + setPublicLink(res.public_link); + setState("awaiting-meta-auth"); + window.open(res.public_link, "_blank", "noopener,noreferrer"); + } else { + setState("connected"); + onConnected(); + } + } catch { + setState("idle"); + toast.error(t("instance.form.evohub.error.provision")); + } + }; + + // FASE 1 — vincular-existente (single-step). Envia só { instanceName, hub_channel_id, + // channel_type } — sem token (resolvido server-side). A Instance é criada sincronamente. + const handleExisting = async () => { + setState("linking"); + try { + await evohubService.linkExisting({ + instanceName, + channelType: hubType, + hubChannelId: selectedHubChannelId, + }); + setState("linked"); + onConnected(); + } catch { + setState("idle"); + toast.error(t("instance.form.evohub.error.provision")); + } + }; + + return ( +
+ {t("instance.form.evohub.title")} + + {/* Tipo de canal */} +
+ + +
+ + {/* Modo new/existing. FASE 1: só "existing"; o radio "new" só aparece na Fase 2. */} +
+ {t("instance.form.evohub.mode.label")} + {PHASE2_CREATE_NEW && ( + + )} + +
+ + {/* shared vs BYO (apenas modo new — FASE 2) */} + {PHASE2_CREATE_NEW && mode === "new" && metaOptions && ( +
+ + +
+ )} + + {/* canal existente (apenas modo existing) */} + {mode === "existing" && ( +
+ + + {availableChannels.length === 0 && ( + {t("instance.form.evohub.existingChannel.empty")} + )} +
+ )} + + {/* Ações + estado */} + {state === "awaiting-meta-auth" && publicLink ? ( +
+ {t("instance.form.evohub.state.awaitingAuth")} + + +
+ ) : state === "linked" ? ( + {t("instance.form.evohub.state.linked")} + ) : ( + + )} +
+ ); +} diff --git a/src/pages/Dashboard/NewInstance.tsx b/src/pages/Dashboard/NewInstance.tsx index 53b75ee..f809fda 100644 --- a/src/pages/Dashboard/NewInstance.tsx +++ b/src/pages/Dashboard/NewInstance.tsx @@ -15,6 +15,7 @@ import { useManageInstance } from "@/lib/queries/instance/manageInstance"; import { NewInstance as NewInstanceType } from "@/types/evolution.types"; +import { EvoHubConnect } from "./EvoHubConnect"; import { GoNewInstance } from "./GoNewInstance"; const stringOrUndefined = z @@ -27,7 +28,7 @@ const FormSchema = z.object({ token: stringOrUndefined, number: stringOrUndefined, businessId: stringOrUndefined, - integration: z.enum(["WHATSAPP-BUSINESS", "WHATSAPP-BAILEYS", "EVOLUTION"]), + integration: z.enum(["WHATSAPP-BUSINESS", "WHATSAPP-BAILEYS", "EVOLUTION", "EVOHUB"]), }); function NewInstance({ resetTable, open, onOpenChange }: { resetTable: () => void; open: boolean; onOpenChange: (open: boolean) => void }) { @@ -47,6 +48,10 @@ function NewInstance({ resetTable, open, onOpenChange }: { resetTable: () => voi value: "EVOLUTION", label: t("instance.form.integration.evolution"), }, + { + value: "EVOHUB", + label: t("instance.form.integration.evohub"), + }, ]; const form = useForm>({ @@ -95,6 +100,15 @@ function NewInstance({ resetTable, open, onOpenChange }: { resetTable: () => voi }); }; + // Conclusão do fluxo EvoHub (link-existing): a Instance foi criada server-side. + // Reaproveita o fechamento padrão (fechar diálogo, resetar form e tabela). + const onAfterEvoHubConnected = () => { + toast.success(t("toast.instance.created")); + setOpen(false); + onReset(); + resetTable(); + }; + if (getProvider() === "go") { return ; } @@ -122,9 +136,14 @@ function NewInstance({ resetTable, open, onOpenChange }: { resetTable: () => voi )} - - - + {integrationSelected === "EVOHUB" && ( + + )} + {integrationSelected !== "EVOHUB" && ( + + + + )} diff --git a/src/translate/languages/en-US.json b/src/translate/languages/en-US.json index a73aa30..6d71db2 100644 --- a/src/translate/languages/en-US.json +++ b/src/translate/languages/en-US.json @@ -275,7 +275,49 @@ "whatsapp": "WhatsApp Cloud API", "facebook": "Facebook", "instagram": "Instagram", - "evolution": "Evolution" + "evolution": "Evolution", + "evohub": "EvoHub" + }, + "evohub": { + "title": "Connect via EvoHub", + "channelType": { + "label": "Channel type", + "whatsapp": "WhatsApp Cloud API", + "facebook": "Facebook", + "instagram": "Instagram" + }, + "mode": { + "label": "How do you want to connect this channel on EvoHub?", + "new": "Create a new channel", + "existing": "Link an existing channel" + }, + "metaApp": { + "label": "Meta App", + "shared": "Evolution Meta App (Cloud)", + "sharedHint": "shared", + "byoHint": "own (BYO)" + }, + "existingChannel": { + "label": "Existing channel", + "placeholder": "Select a channel", + "empty": "No channels available to link" + }, + "button": { + "connect": "Connect via EvoHub", + "link": "Link existing channel", + "reopen": "Reopen authorization link" + }, + "state": { + "creating": "Provisioning channel...", + "awaitingAuth": "Waiting for Meta authorization on the Hub...", + "connected": "Channel connected", + "linking": "Linking...", + "linked": "Linked to existing EvoHub channel" + }, + "error": { + "load": "Failed to load EvoHub information", + "provision": "Failed to provision the EvoHub channel" + } }, "token": "Token", "number": "Number", diff --git a/src/translate/languages/es-ES.json b/src/translate/languages/es-ES.json index faf0d6f..3edf837 100644 --- a/src/translate/languages/es-ES.json +++ b/src/translate/languages/es-ES.json @@ -232,7 +232,49 @@ "whatsapp": "WhatsApp Cloud API", "facebook": "Facebook", "instagram": "Instagram", - "evolution": "Evolution" + "evolution": "Evolution", + "evohub": "EvoHub" + }, + "evohub": { + "title": "Conectar vía EvoHub", + "channelType": { + "label": "Tipo de canal", + "whatsapp": "WhatsApp Cloud API", + "facebook": "Facebook", + "instagram": "Instagram" + }, + "mode": { + "label": "¿Cómo conectar este canal en EvoHub?", + "new": "Crear un canal nuevo", + "existing": "Vincular un canal existente" + }, + "metaApp": { + "label": "Meta App", + "shared": "Meta App de Evolution (Cloud)", + "sharedHint": "compartida", + "byoHint": "propia (BYO)" + }, + "existingChannel": { + "label": "Canal existente", + "placeholder": "Seleccione un canal", + "empty": "No hay canales disponibles para vincular" + }, + "button": { + "connect": "Conectar vía EvoHub", + "link": "Vincular canal existente", + "reopen": "Reabrir enlace de autorización" + }, + "state": { + "creating": "Aprovisionando canal...", + "awaitingAuth": "Esperando autorización de Meta en el Hub...", + "connected": "Canal conectado", + "linking": "Vinculando...", + "linked": "Canal Evo Hub existente vinculado" + }, + "error": { + "load": "Error al cargar información de EvoHub", + "provision": "Error al aprovisionar el canal EvoHub" + } }, "token": "Token", "number": "Número", diff --git a/src/translate/languages/fr-FR.json b/src/translate/languages/fr-FR.json index 1bdfc54..c3e4029 100644 --- a/src/translate/languages/fr-FR.json +++ b/src/translate/languages/fr-FR.json @@ -231,7 +231,49 @@ "whatsapp": "WhatsApp Cloud API", "facebook": "Facebook", "instagram": "Instagram", - "evolution": "Evolution" + "evolution": "Evolution", + "evohub": "EvoHub" + }, + "evohub": { + "title": "Connecter via EvoHub", + "channelType": { + "label": "Type de canal", + "whatsapp": "WhatsApp Cloud API", + "facebook": "Facebook", + "instagram": "Instagram" + }, + "mode": { + "label": "Comment connecter ce canal sur EvoHub ?", + "new": "Créer un nouveau canal", + "existing": "Lier un canal existant" + }, + "metaApp": { + "label": "Meta App", + "shared": "Meta App d'Evolution (Cloud)", + "sharedHint": "partagée", + "byoHint": "propre (BYO)" + }, + "existingChannel": { + "label": "Canal existant", + "placeholder": "Sélectionnez un canal", + "empty": "Aucun canal disponible à lier" + }, + "button": { + "connect": "Connecter via EvoHub", + "link": "Lier un canal existant", + "reopen": "Rouvrir le lien d'autorisation" + }, + "state": { + "creating": "Provisionnement du canal...", + "awaitingAuth": "En attente de l'autorisation Meta sur le Hub...", + "connected": "Canal connecté", + "linking": "Liaison...", + "linked": "Canal Evo Hub existant lié" + }, + "error": { + "load": "Échec du chargement des informations EvoHub", + "provision": "Échec du provisionnement du canal EvoHub" + } }, "token": "Token", "number": "Numéro", diff --git a/src/translate/languages/pt-BR.json b/src/translate/languages/pt-BR.json index f8e9d45..466c1f6 100644 --- a/src/translate/languages/pt-BR.json +++ b/src/translate/languages/pt-BR.json @@ -232,7 +232,49 @@ "whatsapp": "WhatsApp Cloud API", "facebook": "Facebook", "instagram": "Instagram", - "evolution": "Evolution" + "evolution": "Evolution", + "evohub": "EvoHub" + }, + "evohub": { + "title": "Conectar via EvoHub", + "channelType": { + "label": "Tipo de canal", + "whatsapp": "WhatsApp Cloud API", + "facebook": "Facebook", + "instagram": "Instagram" + }, + "mode": { + "label": "Como conectar este canal no EvoHub?", + "new": "Criar um canal novo", + "existing": "Vincular um canal existente" + }, + "metaApp": { + "label": "Meta App", + "shared": "Meta App da Evolution (Cloud)", + "sharedHint": "compartilhada", + "byoHint": "própria (BYO)" + }, + "existingChannel": { + "label": "Canal existente", + "placeholder": "Selecione um canal", + "empty": "Nenhum canal disponível para vincular" + }, + "button": { + "connect": "Conectar via EvoHub", + "link": "Vincular canal existente", + "reopen": "Reabrir link de autorização" + }, + "state": { + "creating": "Provisionando canal...", + "awaitingAuth": "Aguardando autorização Meta no Hub...", + "connected": "Canal conectado", + "linking": "Vinculando...", + "linked": "Canal Evo Hub existente vinculado" + }, + "error": { + "load": "Falha ao carregar informações do EvoHub", + "provision": "Falha ao provisionar o canal EvoHub" + } }, "token": "Token", "number": "Número", diff --git a/src/types/evolution.types.ts b/src/types/evolution.types.ts index 8de75ff..d1f6d7b 100644 --- a/src/types/evolution.types.ts +++ b/src/types/evolution.types.ts @@ -43,6 +43,68 @@ export type Instance = { }; }; +// ── EvoHub (canal adicional) ────────────────────────────────────────────── +// integration value usado no payload de /instance/create e no enum do form +export const EVOHUB_INTEGRATION = "EVOHUB" as const; + +// Tipo de canal do hub (espelha HubChannel['type'] do CRM) +export type HubChannelType = "whatsapp" | "facebook" | "instagram"; + +// Tipo de canal selecionável na UI do manager-v2 (mapeia para HubChannelType) +export type EvoHubUiChannelType = "whatsapp_cloud" | "facebook_page" | "instagram"; + +export interface HubPlan { + id: string; + slug: string; + name: string; + description?: string; + allow_own_meta_app: boolean; + allow_shared_meta_app: boolean; + max_channels_total: number | null; + max_webhooks: number | null; + max_byo_credentials: number | null; +} + +export interface MetaAppOptionCred { + id: string; + name: string; + app_id: string; +} + +export interface MetaAppOptions { + allowed_modes: ("shared" | "byo")[]; + shared_configured: boolean; + shared_allowed_by_plan: boolean; + byo_allowed_by_plan: boolean; + max_byo_credentials?: number | null; + byo_credentials: MetaAppOptionCred[]; +} + +// HubChannel = item da LISTA (GET /evohub/channels e /evohub/available-channels). +// NÃO inclui token. GET /evohub/channels/:id (singular) adicionalmente carrega +// `token` + `meta_connection.phone_number_id`, mas isso é resolvido/consumido +// SERVER-SIDE pelo back-end no link-existing — o front nunca recebe esses campos. +export interface HubChannel { + id: string; + name: string; + type: HubChannelType; + status: string; + channel_credentials_id?: string | null; + created_at?: string; +} + +// Resposta do control-plane do evolution-api ao vincular/provisionar. +// FASE 1 (link-existing): a Instance é criada server-side; o retorno traz info da +// Instance (NUNCA o token — contrato §1, §4-A). Sem public_link. +// FASE 2 (provision/criar-novo): public_link presente (abrir em nova aba). +export interface EvoHubProvisionResponse { + instanceName: string; + integration: string; // "EVOHUB" + linked?: boolean; // FASE 1: true quando a Instance foi criada e vinculada + hub_channel_id?: string | null; + public_link?: string | null; // FASE 2 apenas (criar-novo); ausente na Fase 1 +} + export type Contact = { id: string; pushName: string; From 97d78e25a84d3c939a460b85de4d2383c9c13b79 Mon Sep 17 00:00:00 2001 From: Davidson Gomes Date: Wed, 10 Jun 2026 16:58:33 -0300 Subject: [PATCH 2/2] fix(evohub): finalizar criar-novo via link-existing + fix reaction senderName MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EvoHubConnect: o criar-novo agora FINALIZA a criação da Instance. O botão pós-OAuth ('Já autorizei, finalizar') chama linkExisting com o hub_channel_id do canal recém-provisionado, reusando o caminho testado (resolve token + phone_number_id server-side). Antes só fechava o diálogo sem criar nada. - Guarda 422: se o usuário clicar antes de concluir o OAuth (canal sem phone_number_id), toast pedindo para concluir e tentar de novo. disabled durante o finalize evita double-create. - Channel type fixado em WhatsApp (evolution-api é API de WhatsApp); removido o seletor de tipo. available-channels e provision sempre whatsapp. - i18n: chaves button.finalize + error.notAuthorizedYet nos 4 idiomas. - fix pré-existente: message-content.tsx reaction senderName ('You' || x → 'You', comportamento idêntico) para destravar o type-check. --- src/pages/Dashboard/EvoHubConnect.tsx | 111 ++++++++++-------- .../Messages/message-content.tsx | 2 +- src/translate/languages/en-US.json | 6 +- src/translate/languages/es-ES.json | 6 +- src/translate/languages/fr-FR.json | 6 +- src/translate/languages/pt-BR.json | 6 +- 6 files changed, 82 insertions(+), 55 deletions(-) diff --git a/src/pages/Dashboard/EvoHubConnect.tsx b/src/pages/Dashboard/EvoHubConnect.tsx index 99ab25d..dd5a98b 100644 --- a/src/pages/Dashboard/EvoHubConnect.tsx +++ b/src/pages/Dashboard/EvoHubConnect.tsx @@ -7,23 +7,20 @@ import { useTranslation } from "react-i18next"; import { toast } from "react-toastify"; import { evohubService } from "@/lib/queries/evohub/evohubService"; -import { EvoHubUiChannelType, HubChannel, HubChannelType, MetaAppOptions } from "@/types/evolution.types"; +import { HubChannel, HubChannelType, MetaAppOptions } from "@/types/evolution.types"; type Mode = "new" | "existing"; type ConnectState = "idle" | "creating" | "awaiting-meta-auth" | "connected" | "linking" | "linked"; -// Mapping de tipo da UI -> tipo do hub (espelha HUB_TYPE_BY_CHANNEL do CRM) -const HUB_TYPE_BY_CHANNEL: Record = { - whatsapp_cloud: "whatsapp", - facebook_page: "facebook", - instagram: "instagram", -}; +// evolution-api é uma API de WhatsApp — o canal é SEMPRE WhatsApp Cloud. +// (O EvoHub também suporta Facebook/Instagram, mas isso é escopo do CRM, não daqui.) +const HUB_TYPE: HubChannelType = "whatsapp"; -// FASE 2: trocar para true (ou ler de feature-flag) para habilitar o modo criar-novo. -const PHASE2_CREATE_NEW = false; +// Habilita os DOIS modos: criar canal novo (public_link + OAuth Meta) e vincular existente. +const CREATE_NEW_ENABLED = true; // O painel NÃO recebe nem envia token (contrato §1, §5): o token é resolvido server-side -// pelo back-end no link-existing e persistido em Instance.token. +// pelo back-end e persistido em Instance.token. interface EvoHubConnectProps { instanceName: string; onConnected: () => void; @@ -32,9 +29,8 @@ interface EvoHubConnectProps { export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) { const { t } = useTranslation(); - const [channelType, setChannelType] = useState("whatsapp_cloud"); - // FASE 1: inicia em "existing" (caminho funcional). "new" é Fase 2 (atrás da flag). - const [mode, setMode] = useState(PHASE2_CREATE_NEW ? "new" : "existing"); + // Default: criar-novo quando habilitado; senão, vincular existente. + const [mode, setMode] = useState(CREATE_NEW_ENABLED ? "new" : "existing"); // shared vs BYO (modo new). Default: shared se permitido. const [metaAppMode, setMetaAppMode] = useState<"shared" | string>("shared"); @@ -46,14 +42,16 @@ export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) const [state, setState] = useState("idle"); const [publicLink, setPublicLink] = useState(null); + // hub_channel_id do canal recém-provisionado (criar-novo) — usado para finalizar + // via link-existing depois que o usuário autoriza a Meta. + const [provisionedChannelId, setProvisionedChannelId] = useState(null); + const [finalizing, setFinalizing] = useState(false); const submitting = state === "creating" || state === "linking"; - const hubType = HUB_TYPE_BY_CHANNEL[channelType]; - // Preview de opções de Meta App (FASE 2 — modo new). Gateado pela flag para não - // disparar fetch/toast de uma UI invisível na Fase 1. + // Preview de opções de Meta App (modo new — shared vs BYO). useEffect(() => { - if (!PHASE2_CREATE_NEW) return; + if (!CREATE_NEW_ENABLED) return; let cancelled = false; evohubService .getMetaAppOptions() @@ -76,7 +74,7 @@ export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) if (mode !== "existing") return; let cancelled = false; evohubService - .getAvailableChannels(hubType) + .getAvailableChannels(HUB_TYPE) .then((chs) => { if (!cancelled) setAvailableChannels(chs); }) @@ -86,24 +84,26 @@ export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) return () => { cancelled = true; }; - }, [mode, hubType, t]); + }, [mode, t]); - // FASE 2 — criar-novo (multi-step, public_link). Só executa com PHASE2_CREATE_NEW=true. + // Criar-novo (multi-step, public_link → OAuth Meta). const handleNew = async () => { setState("creating"); try { const res = await evohubService.provisionNew({ instanceName, - channelType: hubType, + channelType: HUB_TYPE, metaAppMode, }); + // Guarda o hub_channel_id para finalizar via link-existing após o OAuth. + if (res.hub_channel_id) setProvisionedChannelId(res.hub_channel_id); if (res.public_link) { setPublicLink(res.public_link); setState("awaiting-meta-auth"); window.open(res.public_link, "_blank", "noopener,noreferrer"); } else { - setState("connected"); - onConnected(); + // Sem public_link (canal já ativo): finaliza direto. + await finalizeNew(res.hub_channel_id ?? null); } } catch { setState("idle"); @@ -111,14 +111,41 @@ export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) } }; - // FASE 1 — vincular-existente (single-step). Envia só { instanceName, hub_channel_id, + // Finaliza o criar-novo APÓS o usuário autorizar a Meta no public_link: reusa o + // link-existing (que JÁ resolve token + phone_number_id server-side e cria a Instance). + // Se o canal ainda não tiver phone_number_id (usuário clicou antes de concluir o OAuth), + // o back-end devolve 422 e avisamos para concluir e tentar de novo. + const finalizeNew = async (channelId: string | null) => { + const hubChannelId = channelId ?? provisionedChannelId; + if (!hubChannelId) { + toast.error(t("instance.form.evohub.error.provision")); + return; + } + setFinalizing(true); + try { + await evohubService.linkExisting({ + instanceName, + channelType: HUB_TYPE, + hubChannelId, + }); + setState("connected"); + onConnected(); + } catch { + // 422 = canal sem phone_number_id ainda (OAuth não concluído) ou outra falha. + toast.error(t("instance.form.evohub.error.notAuthorizedYet")); + } finally { + setFinalizing(false); + } + }; + + // Vincular-existente (single-step). Envia só { instanceName, hub_channel_id, // channel_type } — sem token (resolvido server-side). A Instance é criada sincronamente. const handleExisting = async () => { setState("linking"); try { await evohubService.linkExisting({ instanceName, - channelType: hubType, + channelType: HUB_TYPE, hubChannelId: selectedHubChannelId, }); setState("linked"); @@ -133,25 +160,10 @@ export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps)
{t("instance.form.evohub.title")} - {/* Tipo de canal */} -
- - -
- - {/* Modo new/existing. FASE 1: só "existing"; o radio "new" só aparece na Fase 2. */} + {/* Modo new/existing */}
{t("instance.form.evohub.mode.label")} - {PHASE2_CREATE_NEW && ( + {CREATE_NEW_ENABLED && (
- {/* shared vs BYO (apenas modo new — FASE 2) */} - {PHASE2_CREATE_NEW && mode === "new" && metaOptions && ( + {/* shared vs BYO (apenas modo new) */} + {CREATE_NEW_ENABLED && mode === "new" && metaOptions && (