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>
166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
"use client";
|
|
import { toast } from "sonner";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import {
|
|
useSyncTemplates,
|
|
useTemplates,
|
|
type TemplatePreview,
|
|
} from "@/hooks/channels/useTemplates";
|
|
|
|
/** Só APPROVED pode ser disparado — o resto é informação, não opção. */
|
|
function statusTone(status: string): "default" | "secondary" | "destructive" | "outline" {
|
|
if (status === "APPROVED") return "default";
|
|
if (status === "REJECTED" || status === "DISABLED") return "destructive";
|
|
if (status === "PENDING") return "secondary";
|
|
return "outline";
|
|
}
|
|
|
|
/**
|
|
* O preview é a peça central desta tela. A Meta **não devolve** valores de exemplo
|
|
* (`example` vem null nos templates reais), então o único jeito de o operador saber
|
|
* o que preencher é ver o texto ao redor do parâmetro.
|
|
*
|
|
* O texto aparece INTEIRO e uma vez só, com todos os `{{n}}` marcados. A versão
|
|
* anterior repetia o corpo a cada slot — cada linha destacava o seu e deixava o
|
|
* vizinho cru, o que é tecnicamente correto e ilegível.
|
|
*/
|
|
function Preview({ preview }: { preview: TemplatePreview }) {
|
|
const partes = preview.text.split(/(\{\{\w+\}\})/g);
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
{preview.onde}
|
|
</span>
|
|
<p className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
{partes.map((parte, i) =>
|
|
/^\{\{\w+\}\}$/.test(parte) ? (
|
|
<span
|
|
key={i}
|
|
className="rounded-md bg-primary/10 px-1.5 py-0.5 font-mono text-xs font-medium text-primary ring-1 ring-primary/20"
|
|
>
|
|
{parte.slice(2, -2)}
|
|
</span>
|
|
) : (
|
|
<span key={i} className="text-muted-foreground">
|
|
{parte}
|
|
</span>
|
|
),
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function TemplatesClient() {
|
|
const { data, isPending } = useTemplates();
|
|
const sync = useSyncTemplates();
|
|
|
|
const waba = data?.data.waba ?? null;
|
|
const templates = data?.data.templates ?? null;
|
|
|
|
async function sincronizar() {
|
|
const res = await sync.mutateAsync();
|
|
const { inserted, updated, disabled } = res.data;
|
|
toast.success(
|
|
`Sincronizado: ${inserted} novo(s), ${updated} atualizado(s), ${disabled} desativado(s).`,
|
|
);
|
|
}
|
|
|
|
if (isPending || templates === null) {
|
|
return <p className="text-sm text-muted-foreground">Carregando…</p>;
|
|
}
|
|
|
|
// Estado vazio que ENSINA — distinguir "canal não conectado" de "conectado sem
|
|
// template" é a diferença entre o operador saber o próximo passo ou não.
|
|
if (!waba) {
|
|
return (
|
|
<Card className="p-6">
|
|
<h2 className="font-medium">Canal oficial não conectado</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Os templates vivem na sua conta do WhatsApp Business (Meta) — esta tela é um espelho
|
|
deles. Conecte o canal oficial em <strong>Conexões WhatsApp</strong> para começar a
|
|
sincronizar.
|
|
</p>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4" data-testid="templates-root">
|
|
<div className="flex items-center justify-between gap-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Espelho da conta <span className="font-mono text-xs">{waba}</span> ·{" "}
|
|
{templates.length} template(s)
|
|
</p>
|
|
<Button onClick={sincronizar} disabled={sync.isPending} data-testid="btn-sync">
|
|
{sync.isPending ? "Sincronizando…" : "Sincronizar com a Meta"}
|
|
</Button>
|
|
</div>
|
|
|
|
{templates.length === 0 ? (
|
|
<Card className="p-6">
|
|
<h2 className="font-medium">Nenhum template ainda</h2>
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
|
Crie templates no Gerenciador do WhatsApp e clique em{" "}
|
|
<strong>Sincronizar com a Meta</strong>. Só templates aprovados podem ser enviados
|
|
fora da janela de 24 horas.
|
|
</p>
|
|
</Card>
|
|
) : (
|
|
<div className="flex flex-col gap-3">
|
|
{templates.map((t) => (
|
|
<Card key={`${t.name}:${t.language}`} className="p-4" data-testid="template-card">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-medium">{t.name}</span>
|
|
<Badge variant="outline" className="font-mono text-xs">
|
|
{t.language}
|
|
</Badge>
|
|
<Badge variant={statusTone(t.status)}>{t.status}</Badge>
|
|
{t.category ? (
|
|
<Badge variant="outline" className="text-xs">
|
|
{t.category}
|
|
</Badge>
|
|
) : null}
|
|
<span className="ml-auto text-xs text-muted-foreground">
|
|
{t.slots.length === 0
|
|
? "sem parâmetros"
|
|
: `${t.slots.length} parâmetro(s)`}
|
|
</span>
|
|
</div>
|
|
|
|
{t.rejectedReason ? (
|
|
<p className="mt-2 text-sm text-destructive">Recusado: {t.rejectedReason}</p>
|
|
) : null}
|
|
|
|
{t.previews.length > 0 || t.slots.length > 0 ? (
|
|
<div className="mt-3 flex flex-col gap-3 border-l-2 border-muted pl-3">
|
|
{t.previews.map((p, i) => (
|
|
<Preview key={`${p.onde}:${i}`} preview={p} />
|
|
))}
|
|
{/* Slots SEM texto ao redor: header de mídia. É justamente o
|
|
parâmetro que contar `{{n}}` não enxerga. */}
|
|
{t.slots
|
|
.filter((s) => s.expects !== "text")
|
|
.map((s, i) => (
|
|
<div key={`m:${s.onde}:${i}`} className="flex flex-col gap-0.5">
|
|
<span className="text-xs uppercase tracking-wide text-muted-foreground">
|
|
{s.onde} · {s.expects}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
arquivo de {s.expects} enviado no disparo
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|