mirror of
https://github.com/melgarafael/DeskcommCRM.git
synced 2026-10-02 01:28:34 +08:00
Os SVGs desenhados pelo dono do produto entram em docs/brand/ como fonte. O app NÃO os serve: a geometria vive em lib/branding/desenho.ts e é desenhada inline por components/branding/MarcaDoProduto.tsx (barra lateral aberta e recolhida, fachada de entrada, cabeçalhos do admin e do onboarding) e por app/icon.tsx (satori aceita <svg> inline — medido). Cores seguem o tema pela classe `dark:`, com os graus da régua do produto. A condição é marcaEhADoProduto (lib/branding.ts): só sem logo E com o nome padrão. Quem trocou o nome ou subiu logo não vê nada disto — provado pela tela com platform_branding.app_name gravado (evidence/marca/*-revendedor.png). Nada em public/: um .svg fixo ali seria a nossa marca na instalação de todo revendedor, o vazamento que tests/unit/branding.test.ts vigia (segue em zero ocorrências novas — o alt vem do nome resolvido, nunca de string fixa). Também: og-card.html e og-social-preview.png regenerados com o logotipo real; READMEs pt/en/es com <picture> claro/escuro; .changes/ com o fragmento (nada_mudou / alterado). Gates: typecheck limpo; lint 0 erros; pnpm test:unit 7967 passed, 6 failed — 5 em lib/ai/dispatcher/rate-limit.test.ts (UPSTASH no .env.local sem Redis, o falso vermelho documentado no CLAUDE.md) e 1 em evidencia-citada, que era os SVGs ainda untracked e passa depois do add. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011W9v8akzZKhpwQw265hxCr
142 lines
6.8 KiB
TypeScript
142 lines
6.8 KiB
TypeScript
import { ImageResponse } from "next/og";
|
|
|
|
import { marcaEhADoProduto } from "@/lib/branding";
|
|
import { CORES_DA_MARCA, SIMBOLO } from "@/lib/branding/desenho";
|
|
import { letraDoIcone } from "@/lib/branding/icone";
|
|
import { marcaDaSaida, NEUTROS_DE_SAIDA } from "@/lib/branding/saida";
|
|
|
|
/**
|
|
* O ícone da aba, DESENHADO em runtime com a marca da instalação.
|
|
*
|
|
* ─── O que existia antes: nada ──────────────────────────────────────────────
|
|
*
|
|
* Zero `app/icon.*`, zero `app/favicon.ico`, zero `public/favicon*` (medido:
|
|
* `public/` tem dois arquivos, `.gitkeep` e `llms.txt`). O navegador pedia
|
|
* `/favicon.ico` por conta própria e recebia 404 — em produção, 19.435 bytes,
|
|
* porque o 404 é a `app/not-found.tsx` INTEIRA servida para um pedido de
|
|
* ícone. Na prática: aba sem marca nenhuma, para nós e para todo revendedor.
|
|
*
|
|
* ─── Por que GERADO, e não um arquivo em `public/` ──────────────────────────
|
|
*
|
|
* `Dockerfile:75-79` copia `public/` para a imagem final, e a imagem é UMA SÓ
|
|
* para todas as marcas — a mesma tag do GHCR que cada clone puxa. Um
|
|
* `favicon.ico` estático resolveria o 404 e entregaria a NOSSA marca na aba de
|
|
* todo revendedor, que é o mesmo modo de falha que `lib/branding.ts:12-16`
|
|
* documenta para `NEXT_PUBLIC_*`: verde em dev, verde no CI, verde na Vercel, e
|
|
* errado exatamente na VPS de quem a feature existe para servir.
|
|
*
|
|
* ─── Cor + inicial, NUNCA o `logo_url` ──────────────────────────────────────
|
|
*
|
|
* `platform_branding.logo_url` é `text` livre, sem CHECK de host
|
|
* (`supabase/baseline.sql:11832-11848`). Buscá-la aqui seria uma requisição de
|
|
* saída disparada pelo `<head>` de TODA página, com a URL vinda de um campo que
|
|
* o operador digita — SSRF com gatilho em cada page load. Derivar o ícone de
|
|
* cor + inicial não toca a rede: o accent vem do mesmo resolvedor que pinta os
|
|
* e-mails (`marcaDaSaida`) e a fonte (`Geist-Regular.ttf`) vem embutida no
|
|
* `@vercel/og` que o Next já traz — nenhuma dependência nova, nenhum download.
|
|
*
|
|
* ─── O símbolo do produto, quando a marca é a do produto ────────────────────
|
|
*
|
|
* Sem nome nem logo configurados (`marcaEhADoProduto`), o ladrilho é o símbolo
|
|
* de `lib/branding/desenho.ts` sobre o creme da régua — o mesmo desenho que a
|
|
* barra lateral e a fachada mostram, para a aba e a tela contarem a mesma
|
|
* marca. O satori aceita `<svg>` inline (medido: 1.135 bytes de PNG válido com
|
|
* o símbolo, em 2026-09-08), então continua sem rede e sem arquivo em `public/`.
|
|
* Quem configurou um nome próprio segue com cor + inicial: o símbolo soletra
|
|
* "D", e um "D" na aba de quem se chama "Acme" seria a nossa marca vazando.
|
|
*
|
|
* ─── `force-dynamic` não é zelo ─────────────────────────────────────────────
|
|
*
|
|
* O loader de metadata NÃO injeta `force-static` na variante gerada por código
|
|
* (`next-metadata-route-loader.js`, `getSingleImageRouteCode`), mas também não
|
|
* a torna dinâmica sozinha — sem esta linha o `next build` congelaria o ícone
|
|
* dentro da imagem pré-buildada, com a marca de quem buildou. E o defeito seria
|
|
* invisível em dev, em teste e na Vercel: só apareceria na VPS do revendedor,
|
|
* que é o único lugar onde a marca é outra. O loader re-exporta todo named
|
|
* export do arquivo do usuário (`:43`), então declarar aqui basta.
|
|
*
|
|
* ─── Custo ──────────────────────────────────────────────────────────────────
|
|
*
|
|
* Um `ImageResponse` por requisição a `/icon`. O `Cache-Control` abaixo é o que
|
|
* mantém isso em uma renderização por minuto por navegador em vez de uma por
|
|
* navegação. A leitura da marca é a MESMA que o `generateMetadata` do layout já
|
|
* faz, memoizada por 30s (`lib/branding/instalacao.ts:209`) — nenhuma consulta
|
|
* a mais no banco.
|
|
*
|
|
* ⚠️ `/icon` precisa estar em `PUBLIC_PATHS` (`lib/auth/public-paths.ts`): o
|
|
* matcher do `proxy.ts:128` só dispensa caminho COM extensão, e `/icon` não tem
|
|
* — sem a entrada, o ícone responde 307 para `/login` a quem ainda não entrou,
|
|
* que é exatamente a primeira tela que um comprador vê.
|
|
*/
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/** 64 e não 32: a aba pede 16-32 CSS px, e em tela retina isso são 32-64 reais. */
|
|
export const size = { width: 64, height: 64 };
|
|
export const contentType = "image/png";
|
|
|
|
export default async function Icon() {
|
|
const marca = await marcaDaSaida(null);
|
|
|
|
if (marcaEhADoProduto({ name: marca.nome, logoUrl: marca.logoUrl })) {
|
|
// 78% da aresta: o D ocupa ~75% do próprio viewBox, então sobra o mesmo
|
|
// respiro que a letra tem no ramo de baixo.
|
|
const lado = Math.round(size.width * 0.78);
|
|
return new ImageResponse(
|
|
(
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: NEUTROS_DE_SAIDA.fundo,
|
|
}}
|
|
>
|
|
<svg viewBox={SIMBOLO.viewBox} width={lado} height={lado}>
|
|
<g fill={CORES_DA_MARCA.claro.simbolo} transform={SIMBOLO.transform}>
|
|
<path d={SIMBOLO.d} />
|
|
<rect {...SIMBOLO.modulo} />
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
),
|
|
{ ...size, headers: CACHE },
|
|
);
|
|
}
|
|
|
|
const letra = letraDoIcone(marca.nome);
|
|
|
|
return new ImageResponse(
|
|
(
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
background: marca.accent,
|
|
color: marca.accentFg,
|
|
// 62% da altura: a caixa maiúscula do Geist ocupa ~72% do em, então
|
|
// a letra fica com respiro sem virar um selo minúsculo no meio.
|
|
fontSize: Math.round(size.height * 0.62),
|
|
// O ladrilho é quadrado e cheio: o navegador já arredonda o favicon
|
|
// no chrome dele, e arredondar aqui também produz canto duplo.
|
|
borderRadius: 0,
|
|
}}
|
|
>
|
|
{letra ?? ""}
|
|
</div>
|
|
),
|
|
{ ...size, headers: CACHE },
|
|
);
|
|
}
|
|
|
|
// 60s é deliberado, e o par com o TTL da marca: o operador que troca a cor em
|
|
// `/admin/marca` vê a aba acompanhar dentro de um minuto. Um `immutable` de um
|
|
// ano tornaria a tela de marca uma promessa que o ícone não cumpre; `no-store`
|
|
// faria o satori rodar a cada navegação.
|
|
const CACHE = { "cache-control": "public, max-age=60, stale-while-revalidate=600" };
|