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..dd5a98b --- /dev/null +++ b/src/pages/Dashboard/EvoHubConnect.tsx @@ -0,0 +1,273 @@ +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 { HubChannel, HubChannelType, MetaAppOptions } from "@/types/evolution.types"; + +type Mode = "new" | "existing"; +type ConnectState = "idle" | "creating" | "awaiting-meta-auth" | "connected" | "linking" | "linked"; + +// 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"; + +// 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 e persistido em Instance.token. +interface EvoHubConnectProps { + instanceName: string; + onConnected: () => void; +} + +export function EvoHubConnect({ instanceName, onConnected }: EvoHubConnectProps) { + const { t } = useTranslation(); + + // 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"); + 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); + // 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"; + + // Preview de opções de Meta App (modo new — shared vs BYO). + useEffect(() => { + if (!CREATE_NEW_ENABLED) 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(HUB_TYPE) + .then((chs) => { + if (!cancelled) setAvailableChannels(chs); + }) + .catch(() => { + if (!cancelled) toast.error(t("instance.form.evohub.error.load")); + }); + return () => { + cancelled = true; + }; + }, [mode, t]); + + // Criar-novo (multi-step, public_link → OAuth Meta). + const handleNew = async () => { + setState("creating"); + try { + const res = await evohubService.provisionNew({ + instanceName, + 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 { + // Sem public_link (canal já ativo): finaliza direto. + await finalizeNew(res.hub_channel_id ?? null); + } + } catch { + setState("idle"); + toast.error(t("instance.form.evohub.error.provision")); + } + }; + + // 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: HUB_TYPE, + hubChannelId: selectedHubChannelId, + }); + setState("linked"); + onConnected(); + } catch { + setState("idle"); + toast.error(t("instance.form.evohub.error.provision")); + } + }; + + return ( +
+ {t("instance.form.evohub.title")} + + {/* Modo new/existing */} +
+ {t("instance.form.evohub.mode.label")} + {CREATE_NEW_ENABLED && ( + + )} + +
+ + {/* shared vs BYO (apenas modo new) */} + {CREATE_NEW_ENABLED && 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")} + + {/* Após autorizar a Meta no public_link, finaliza criando a Instance via + link-existing (resolve token + phone_number_id server-side). */} + +
+ ) : 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/pages/instance/EmbedChatMessage/Messages/message-content.tsx b/src/pages/instance/EmbedChatMessage/Messages/message-content.tsx index ca47a94..faf7661 100644 --- a/src/pages/instance/EmbedChatMessage/Messages/message-content.tsx +++ b/src/pages/instance/EmbedChatMessage/Messages/message-content.tsx @@ -61,7 +61,7 @@ export function MessageContent({ message, quotedMessage, chat, fromMe, onQuoteCl }; // Tenta extrair o nome do remetente - const senderName = reaction.sender.includes("@g.us") ? "You" || reaction.sender.split("@")[0] : reaction.sender.split("@")[0]; + const senderName = reaction.sender.includes("@g.us") ? "You" : reaction.sender.split("@")[0]; // Adiciona o remetente à lista de senders deste emoji acc[reaction.emoji].senders.push(senderName); diff --git a/src/translate/languages/en-US.json b/src/translate/languages/en-US.json index a73aa30..5c83b82 100644 --- a/src/translate/languages/en-US.json +++ b/src/translate/languages/en-US.json @@ -275,7 +275,51 @@ "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", + "finalize": "I've authorized, finish" + }, + "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", + "notAuthorizedYet": "Finish the Meta authorization in the EvoHub tab and try again" + } }, "token": "Token", "number": "Number", diff --git a/src/translate/languages/es-ES.json b/src/translate/languages/es-ES.json index faf0d6f..9b9e905 100644 --- a/src/translate/languages/es-ES.json +++ b/src/translate/languages/es-ES.json @@ -232,7 +232,51 @@ "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", + "finalize": "Ya autoricé, finalizar" + }, + "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", + "notAuthorizedYet": "Completa la autorización de Meta en la pestaña de EvoHub e inténtalo de nuevo" + } }, "token": "Token", "number": "Número", diff --git a/src/translate/languages/fr-FR.json b/src/translate/languages/fr-FR.json index 1bdfc54..e381f2d 100644 --- a/src/translate/languages/fr-FR.json +++ b/src/translate/languages/fr-FR.json @@ -231,7 +231,51 @@ "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", + "finalize": "J'ai autorisé, finaliser" + }, + "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", + "notAuthorizedYet": "Terminez l'autorisation Meta dans l'onglet EvoHub et réessayez" + } }, "token": "Token", "number": "Numéro", diff --git a/src/translate/languages/pt-BR.json b/src/translate/languages/pt-BR.json index f8e9d45..378e1f5 100644 --- a/src/translate/languages/pt-BR.json +++ b/src/translate/languages/pt-BR.json @@ -232,7 +232,51 @@ "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", + "finalize": "Já autorizei, finalizar" + }, + "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", + "notAuthorizedYet": "Conclua a autorização da Meta na aba do EvoHub e tente novamente" + } }, "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;