Files
DeskcommCRM/components/connections/CanalParceiroClient.tsx
T
Rafael César Rocha MonteiroandClaude Opus 5 72d2ed4c35 build(tailwind): migra para o Tailwind 4 com config em CSS
O `tailwind.config.ts` deixa de existir. O que era `theme.extend` virou um
bloco `@theme inline` em `app/globals.css` — o modo `inline` é o que preserva o
tema, porque o utilitário recebe o `var(--color-…)` literal em vez de uma cópia
do valor claro. Os três blocos de token (`:root`, `[data-theme="light"]`,
`[data-theme="dark"]`) ficam exatamente onde estavam: `extrairRegua`
(lib/branding/contraste.ts) acha o bloco claro por igualdade EXATA de seletor, e
`branding-tema-claro-escopavel` compara prop a prop os blocos claro e escuro.

O que a migração destapou, e é o item de maior impacto visual:

  No v3, modificador de opacidade sobre cor declarada como `var(--…)` sem
  `<alpha-value>` fazia o Tailwind NÃO EMITIR a regra — em silêncio. Todo token
  deste produto é `var(--color-*)`, então 62 classes distintas em 252 usos eram
  letra morta: `bg-destructive/10` não pintava fundo nenhum, `border-destructive/30`
  caía na cor neutra da regra global de borda, `text-muted-foreground/60` herdava
  a cor do pai. O v4 resolve opacidade por `color-mix()` e as 252 passaram a
  pintar. Medido contra um `tailwindcss@3.4.19` de descarte: alimentado com 7
  classes, ele emitiu 4 — nenhuma das 3 com barra.

Renomes mecânicos (58 `rounded` puro → `rounded-md`, 22 `outline-none` →
`outline-hidden`, 4 `flex-shrink-0` → `shrink-0`). O `rounded` puro não podia
ficar: o v3 remapeava o DEFAULT para `--radius-md` (8px) e o v4 tem um `rounded`
embutido de 0.25rem que nem `@utility` sobrescreve — o raio encolheria pela
metade em 33 arquivos, sem erro nenhum.

Guardas novas em `tests/unit/tailwind-tokens.test.ts`, porque a migração troca um
arquivo que o TypeScript conferia por um que ninguém confere: reprova config em
JS de volta, `:root` embrulhado em `@layer` (que ligaria a auto-referência que o
`@theme inline` emite), token referenciado que não existe, pasta de UI fora do
`@source`, e a volta dos três utilitários renomeados.

`branding-rampa` fatiava o CSS com `indexOf('[data-theme="dark"]')` e quebrou:
o `@custom-variant` pôs essa string no topo do arquivo. Passa a recortar o
`:root` por casamento de chaves.

Provado na tela, em instalação fresca (baseline.sql + bootstrap-owner + Supabase
local), medindo por `getComputedStyle` e nunca a olho — onboarding completo em
claro e escuro, 0 erro de console. Sonda em `tests/sonda-tailwind-4.ts`,
capturas e medições em `evidence/tailwind-4/`.

NÃO verificado: o build da imagem Docker. Os binários musl de `@tailwindcss/oxide`
e `lightningcss` estão no lockfile para linux x64 e arm64, mas docker.io está
inalcançável nesta máquina — quem prova é o `imagens-ok` no PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 01:48:00 -03:00

229 lines
8.6 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiClient } from "@/lib/api/client";
import { copyToClipboard } from "@/lib/clipboard";
/**
* Conectar um número por um PROVEDOR PARCEIRO.
*
* ─── Por que a tela não escreve o nome do provedor ──────────────────────────
*
* O rótulo vem do servidor (`label`), e não de uma string aqui, porque o
* `lint:channels` proíbe nomear provider fora de `lib/channels/` — e a razão é
* anterior ao lint: no dia em que houver um segundo parceiro, esta tela não
* muda. O que o usuário lê continua sendo a marca que ele contratou.
*
* ─── A ordem dos passos é a ordem em que dá errado ──────────────────────────
*
* Conectar são DUAS pontas: a chave, que deixa a gente FALAR com o provedor, e
* o webhook, que deixa o provedor falar com a gente. Quem faz só a primeira
* consegue enviar e nunca recebe — e o defeito aparece como "o cliente
* respondeu e não chegou", horas depois, sem nada na tela dizendo por quê.
*
* Por isso o segundo passo só aparece DEPOIS de conectar, com a URL e o segredo
* prontos para copiar, e um aviso explícito de que sem colá-los não entra
* mensagem.
*/
interface Estado {
label: string;
connected: boolean;
account_id: string | null;
phone_number: string | null;
display_name: string | null;
status: string | null;
has_api_key: boolean;
webhook_url: string | null;
}
interface Conectado {
webhook_url: string;
webhook_secret: string;
phone_number: string | null;
quality_rating: string | null;
}
/** Campo somente-leitura com botão de copiar — o que o operador cola do outro lado. */
function ParaColar({ rotulo, valor }: { rotulo: string; valor: string }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{rotulo}
</span>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-2 py-1.5 text-xs">{valor}</code>
<Button
size="sm"
variant="outline"
onClick={async () => {
await copyToClipboard(valor);
toast.success("Copiado.");
}}
>
Copiar
</Button>
</div>
</div>
);
}
export function CanalParceiroClient() {
const [estado, setEstado] = useState<Estado | null>(null);
const [accountId, setAccountId] = useState("");
const [apiKey, setApiKey] = useState("");
const [salvando, setSalvando] = useState(false);
const [recemConectado, setRecemConectado] = useState<Conectado | null>(null);
const carregar = async () => {
try {
const r = await apiClient.get<{ data: Estado }>("/api/v1/channels/partner");
setEstado(r.data);
if (r.data.account_id) setAccountId(r.data.account_id);
} catch {
// Falha de leitura não deve travar a tela: o formulário continua servindo.
setEstado(null);
}
};
useEffect(() => {
void carregar();
}, []);
const conectar = async () => {
setSalvando(true);
try {
const r = await apiClient.post<{ data: Conectado }>("/api/v1/channels/partner", {
account_id: accountId,
api_key: apiKey,
});
setRecemConectado(r.data);
// A chave sai da memória da tela assim que é gravada: ela não volta num
// GET, e deixá-la no input só cria uma cópia a mais de um segredo.
setApiKey("");
toast.success("Canal conectado.");
await carregar();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Não foi possível conectar.");
} finally {
setSalvando(false);
}
};
const rotulo = estado?.label ?? "provedor parceiro";
const conectado = estado?.connected ?? false;
return (
<div className="flex flex-col gap-4">
<Card className="flex flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">Conectar por {rotulo}</h3>
<p className="text-xs text-muted-foreground">
Um número oficial (WhatsApp Business) conectado através do seu provedor. As mensagens
entram e saem pelo CRM, e os modelos aprovados são os mesmos da sua conta.
</p>
</div>
{conectado ? (
<Badge variant="secondary">Conectado</Badge>
) : (
<Badge variant="outline">Não conectado</Badge>
)}
</div>
{conectado && (
<div className="rounded-md border border-border bg-muted/40 p-3 text-sm">
<p className="font-medium">{estado?.display_name ?? "Número conectado"}</p>
<p className="text-xs text-muted-foreground">
{estado?.phone_number ?? "sem número informado"} · {estado?.status ?? "—"}
</p>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor="parceiro-conta">Conta</Label>
<Input
id="parceiro-conta"
value={accountId}
onChange={(e) => setAccountId(e.target.value)}
placeholder="id da conta conectada no provedor"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
É o identificador do número no painel do provedor — não o da Meta.
</p>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="parceiro-chave">Chave de API</Label>
<Input
id="parceiro-chave"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={estado?.has_api_key ? "gravada — preencha para trocar" : "cole a chave"}
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Guardada cifrada. Depois de gravar ela não é mostrada de novo — para trocar, cole a
nova.
</p>
</div>
<div>
<Button onClick={conectar} disabled={salvando || !accountId || !apiKey}>
{salvando ? "Verificando…" : conectado ? "Reconectar" : "Conectar"}
</Button>
<p className="mt-1.5 text-xs text-muted-foreground">
A credencial é testada contra o provedor antes de ser gravada.
</p>
</div>
</div>
</Card>
{/* Só depois de conectar: antes disso não há URL nem segredo a mostrar, e
um passo 2 vazio faz parecer que falta algo que ainda não podia existir. */}
{recemConectado && (
<Card className="flex flex-col gap-4 border-warning/40 bg-warning-bg p-4">
<div>
<h3 className="text-sm font-semibold">Falta ligar a volta</h3>
<p className="text-xs text-muted-foreground">
Cole os dois valores abaixo no webhook do seu provedor. Sem isso o CRM{" "}
<strong>envia mas não recebe</strong>: a resposta do cliente não chega, e nada na tela
avisa. O segredo aparece <strong>uma única vez</strong> — se sair desta tela sem
copiá-lo, reconecte para gerar outro.
</p>
</div>
<ParaColar rotulo="URL do webhook" valor={recemConectado.webhook_url} />
<ParaColar rotulo="Segredo (assinatura)" valor={recemConectado.webhook_secret} />
{recemConectado.quality_rating && (
<p className="text-xs text-muted-foreground">
Qualidade do número segundo a plataforma: {recemConectado.quality_rating}
</p>
)}
</Card>
)}
{conectado && !recemConectado && estado?.webhook_url && (
<Card className="flex flex-col gap-3 p-4">
<div>
<h3 className="text-sm font-semibold">Webhook</h3>
<p className="text-xs text-muted-foreground">
O endereço que o provedor usa para entregar as mensagens. O segredo não é mostrado de
novo — para obter um novo, reconecte.
</p>
</div>
<ParaColar rotulo="URL do webhook" valor={estado.webhook_url} />
</Card>
)}
</div>
);
}