mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
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>
229 lines
8.6 KiB
TypeScript
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>
|
|
);
|
|
}
|