feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s

Hero e movimento
- cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py):
  marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao
  cursor e, parada no topo, passa sozinha pelos estados com legenda
- três níveis de aparelho (full, lite, static); sem WebGL fica o pôster
- título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha."
- faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções
- rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa
  compartilhada entre a lista de cases e o case

Ofertas
- atendimento no WhatsApp com IA e nota fiscal para a reforma tributária
  (páginas de serviço, cartões da home, formulário e aviso com data de validade)
- página /ia atualizada, com fontes
- peças 3D em loop nos cartões e topos de página (3d/pecas.py)

Design
- hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta
- um destino por rótulo: "Falar sobre meu projeto" leva ao formulário,
  "Chamar no WhatsApp" abre o WhatsApp
- logo montado em blocos, 404 redesenhado, grades sem célula vazia
- ícones do PWA e manifesto novos

Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
This commit is contained in:
evandro committed 2026-11-10 03:48:25 -03:00
1 parent 4671837c44
commit c7d09d63d4
165 files changed
+7940 -3790

No files matched your search

+435
View File
@@ -0,0 +1,435 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import {
caixaDosEstados,
cicloAt,
createFrameBudget,
ESTADOS,
type Estado,
framePiece,
legendaDe,
papeis,
parseBlocos,
seguir,
stateAt,
} from "./blocos";
/** O arquivo que o Blender exporta (`3d/blocos.py`) e a cena baixa. */
const real = () =>
JSON.parse(
readFileSync(join(process.cwd(), "public/3d/blocos.json"), "utf8"),
) as { n: number; passo: number; estados: string[]; dados: number[] };
describe("stateAt (which block state the dive progress shows)", () => {
it("parks on marca at the top of the dive", () => {
expect(stateAt(0)).toEqual({ from: 0, to: 0, t: 0 });
expect(stateAt(0.1)).toEqual({ from: 0, to: 0, t: 0 });
expect(stateAt(0.16)).toEqual({ from: 0, to: 0, t: 0 });
});
it("is halfway from marca to tela in the middle of the first stretch", () => {
const { from, to, t } = stateAt(0.23);
expect([from, to]).toEqual([0, 1]);
expect(t).toBeCloseTo(0.5, 6);
});
it.each([
[0.35, 1],
[0.63, 2],
[0.93, 3],
[1, 3],
])("parks at %p on state %p", (p, state) => {
expect(stateAt(p)).toEqual({ from: state, to: state, t: 0 });
});
it("clamps the progress to 0..1", () => {
expect(stateAt(-3)).toEqual({ from: 0, to: 0, t: 0 });
expect(stateAt(7)).toEqual({ from: 3, to: 3, t: 0 });
expect(stateAt(Number.NaN)).toEqual({ from: 0, to: 0, t: 0 });
});
it("eases in and out inside a stretch (smoothstep)", () => {
expect(stateAt(0.44 + 0.12 * 0.25).t).toBeCloseTo(0.15625, 6);
expect(stateAt(0.7 + 0.12 * 0.75).t).toBeCloseTo(0.84375, 6);
});
it("never goes back as the progress grows", () => {
let previous = -1;
for (let i = 0; i <= 1000; i++) {
const { from, to, t } = stateAt(i / 1000);
const position = from + (to - from) * t;
expect(position).toBeGreaterThanOrEqual(previous);
previous = position;
}
expect(previous).toBe(3);
});
});
describe("cicloAt (the resting cycle: time parked -> state)", () => {
it("rests on marca first, then goes marca, tela, integracoes, dados and back, 3 s on each", () => {
expect(cicloAt(0, 3, 1.2)).toEqual({ from: 0, to: 0, t: 0, wait: 3 });
expect(cicloAt(2, 3, 1.2)).toEqual({ from: 0, to: 0, t: 0, wait: 1 });
const indo = cicloAt(3.6, 3, 1.2);
expect([indo.from, indo.to, indo.wait]).toEqual([0, 1, 0]);
expect(indo.t).toBeCloseTo(0.5, 6);
const parados = [4.3, 8.5, 12.7, 16.9].map((clock) =>
cicloAt(clock, 3, 1.2),
);
expect(parados.map(({ from, to, t }) => [from, to, t])).toEqual([
[1, 1, 0],
[2, 2, 0],
[3, 3, 0],
[0, 0, 0],
]);
// de dados volta direto à marca
const volta = cicloAt(4.2 * 3 + 3.6, 3, 1.2);
expect([volta.from, volta.to]).toEqual([3, 0]);
});
it("says how long until the next change while resting", () => {
expect(cicloAt(4.2 + 1, 3, 1.2).wait).toBeCloseTo(2, 6);
expect(cicloAt(5, 4, 1.2).wait).toBe(0); // 4 s no lite, já na troca
});
});
describe("seguir (the scroll taking the piece back from the cycle, and the cycle from the scroll)", () => {
const parado = (e: number): Estado => ({ from: e, to: e, t: 0 });
/** Passos de `passo` até alcançar o alvo; devolve os estados do caminho. */
const caminho = (atual: Estado, alvo: Estado, passo = 0.1) => {
const passos = [atual];
for (let i = 0; i < 100; i++) {
const proximo = seguir(passos[passos.length - 1], alvo, passo);
passos.push(proximo);
if (
proximo.from === alvo.from &&
proximo.to === alvo.to &&
proximo.t === alvo.t
)
return passos;
}
throw new Error("não alcançou o alvo");
};
it("stays put when it already shows the target", () => {
expect(seguir(parado(2), parado(2), 0.1)).toEqual(parado(2));
});
it("goes straight from a resting state to another one, one step at a time", () => {
expect(seguir(parado(1), parado(0), 0.1)).toEqual({
from: 1,
to: 0,
t: 0.1,
});
const passos = caminho(parado(3), parado(0), 0.25);
expect(passos.map(({ t }) => t)).toEqual([0, 0.25, 0.5, 0.75, 0]);
expect(passos[passos.length - 1]).toEqual(parado(0));
});
it("enters a scroll transition by the end it shares with it, without a jump", () => {
const alvo = { from: 0, to: 1, t: 0.35 };
expect(seguir(parado(0), alvo, 0.1)).toEqual({ from: 0, to: 1, t: 0.1 });
expect(seguir(parado(1), alvo, 0.1)).toEqual({ from: 0, to: 1, t: 0.9 });
expect(seguir({ from: 0, to: 1, t: 0.3 }, alvo, 0.1)).toEqual(alvo);
});
it("turns a transition running the other way around instead of restarting it", () => {
expect(
seguir({ from: 1, to: 0, t: 0.25 }, { from: 0, to: 1, t: 0.3 }, 0.25),
).toEqual({ from: 0, to: 1, t: 0.5 });
});
it("finishes a transition that leads elsewhere at its nearest end, then heads for the target", () => {
// o ciclo indo de integrações para dados, e a rolagem pede a marca
const passos = caminho({ from: 2, to: 3, t: 0.8 }, parado(0));
expect(passos[1]).toEqual({ from: 2, to: 3, t: 0.9 });
expect(passos).toContainEqual(parado(3));
expect(passos[passos.length - 1]).toEqual(parado(0));
// a troca em curso já vai para o alvo (ou sai dele): só anda nela
expect(seguir({ from: 3, to: 0, t: 0.5 }, parado(0), 0.1)).toEqual({
from: 3,
to: 0,
t: 0.6,
});
expect(seguir({ from: 0, to: 1, t: 0.5 }, parado(0), 0.1)).toEqual({
from: 0,
to: 1,
t: 0.4,
});
});
it("moves t by at most one step per call", () => {
const passos = caminho({ from: 1, to: 2, t: 0.45 }, parado(3), 0.05);
for (let i = 1; i < passos.length; i++) {
const [a, b] = [passos[i - 1], passos[i]];
if (a.from === b.from && a.to === b.to)
expect(Math.abs(b.t - a.t)).toBeLessThanOrEqual(0.05 + 1e-9);
// ou parou numa ponta, ou começou uma troca nova a partir dela
else if (b.t !== 0) expect([b.from, b.t]).toEqual([a.to, 0.05]);
}
});
it("keeps heading for the scroll when its request moves on mid-way (no back and forth)", () => {
// o ciclo parado nas integrações; a rolagem pede a marca e, logo depois, a troca da marca para a tela
let atual = seguir(parado(2), parado(0), 0.1);
expect(atual).toEqual({ from: 2, to: 0, t: 0.1 });
const alvo = { from: 0, to: 1, t: 0.9 };
const vistos = [atual];
for (let i = 0; i < 40; i++) {
atual = seguir(atual, alvo, 0.1);
vistos.push(atual);
}
expect(atual).toEqual(alvo);
// nunca volta para as integrações
expect(vistos.slice(1)).not.toContainEqual(parado(2));
});
});
describe("caixaDosEstados (where tela, integracoes and dados sit in the resting cycle)", () => {
const host = { width: 1440, height: 900 };
it("drops the left 22% of the piece box, keeps the 17 x 12 proportion and the height center of the piece", () => {
const caixa = caixaDosEstados(
{ x: 700, y: 100, width: 600, height: 600 },
host,
);
expect(caixa.x).toBeCloseTo(832, 6);
expect(caixa.width).toBeCloseTo(468, 6);
expect(caixa.height).toBeCloseTo((468 * 12) / 17, 6);
expect(caixa.y + caixa.height / 2).toBeCloseTo(400, 6);
});
it("stops 24 px short of the right edge when the piece box runs past it", () => {
const caixa = caixaDosEstados(
{ x: 694, y: -4, width: 795, height: 795 },
host,
);
expect(caixa.x + caixa.width).toBeCloseTo(1416, 6);
});
});
describe("legendaDe (the caption under the piece)", () => {
it.each([
[0, null],
[1, "Sites e sistemas"],
[2, "Integrações"],
[3, "Painéis e dados"],
[null, null],
[7, null],
])("state %p -> %p", (estado, texto) => {
expect(legendaDe(estado)).toBe(texto);
});
});
describe("parseBlocos (the block states exported from Blender)", () => {
it("reads the real file: 238 blocks, one vector of n * 7 per state", () => {
const blocos = parseBlocos(real());
expect(blocos.n).toBe(238);
expect(blocos.estados).toHaveLength(ESTADOS.length);
for (const estado of blocos.estados) {
expect(estado).toBeInstanceOf(Float32Array);
expect(estado).toHaveLength(238 * 7);
}
// o primeiro bloco da marca: x, y, z, escala e tom, na ordem do arquivo
const json = real();
expect(Array.from(blocos.estados[0].slice(0, 7))).toEqual(
Array.from(new Float32Array(json.dados.slice(0, 7))),
);
// o segundo estado começa logo depois dos n blocos do primeiro
expect(blocos.estados[1][0]).toBe(Math.fround(json.dados[238 * 7]));
});
it.each([
["not an object", null, /objeto/],
[
"states out of order",
{ ...real(), estados: ["tela", "marca", "integracoes", "dados"] },
/estados/,
],
[
"a short data array",
{ ...real(), dados: real().dados.slice(7) },
/dados/,
],
["a different step", { ...real(), passo: 8 }, /passo/],
["no block count", { ...real(), n: 0 }, /n/],
])("throws a clear error for %s", (_label, json, message) => {
expect(() => parseBlocos(json)).toThrow(message);
});
});
describe("papeis (which blocks move in each resting state, from the real file)", () => {
const blocos = parseBlocos(real());
const { vida, botao, chao } = papeis(blocos);
const papel = (estado: number) =>
Array.from({ length: blocos.n }, (_, i) => vida[i * 4 + estado]);
/** Os índices dos blocos visíveis do estado com o papel que `filtro` aceita. */
const com = (estado: number, filtro: (valor: number) => boolean) =>
papel(estado).flatMap((valor, i) =>
blocos.estados[estado][i * 7 + 3] > 0 && filtro(valor) ? [i] : [],
);
const tom = (estado: number, i: number) => blocos.estados[estado][i * 7 + 6];
it("splits the lime blocks of marca into its four squares, left to right", () => {
const marca = blocos.estados[0];
const quadrados = [0, 1, 2, 3].map((q) => com(0, (v) => v === q));
expect(quadrados.map((q) => q.length)).toEqual([40, 32, 18, 18]);
expect(com(0, (v) => v > 3)).toEqual([]);
// todo bloco lima visível está num quadrado, e só eles
expect(com(0, (v) => v >= 0).sort((a, b) => a - b)).toEqual(
com(0, () => true).filter((i) => tom(0, i) === 1),
);
const xs = quadrados.map(
(q) => q.reduce((soma, i) => soma + marca[i * 7], 0) / q.length,
);
expect(xs).toEqual([...xs].sort((a, b) => a - b));
});
it("marks the lime button of tela and its center", () => {
const tela = blocos.estados[1];
const botoes = com(1, (v) => v === 1);
expect(botoes).toHaveLength(12);
for (const i of botoes) expect(tom(1, i)).toBe(1);
const x = botoes.reduce((soma, i) => soma + tela[i * 7], 0) / 12;
expect(botao[0]).toBeCloseTo(x, 5);
// o botão fica na metade de baixo e à esquerda da tela
expect(botao[0]).toBeLessThan(0);
expect(botao[1]).toBeLessThan(0);
});
it("lays the porcelain line blocks of integracoes from the core (0) to the nodes (1)", () => {
const integracoes = blocos.estados[2];
const linhas = com(2, (v) => v >= 0);
expect(linhas.length).toBeGreaterThan(30);
for (const i of linhas) expect(tom(2, i)).toBe(0);
expect(Math.min(...linhas.map((i) => vida[i * 4 + 2]))).toBe(0);
expect(Math.max(...linhas.map((i) => vida[i * 4 + 2]))).toBe(1);
// mais longe do centro, mais adiante na linha (o núcleo fica na origem)
const raio = (i: number) =>
Math.hypot(integracoes[i * 7], integracoes[i * 7 + 1]);
const perto = linhas.filter((i) => vida[i * 4 + 2] < 0.2);
const longe = linhas.filter((i) => vida[i * 4 + 2] > 0.8);
expect(Math.max(...perto.map(raio))).toBeLessThan(
Math.min(...longe.map(raio)),
);
// seis linhas, uma por nó: cada uma começa no núcleo
expect(perto.filter((i) => vida[i * 4 + 2] === 0)).toHaveLength(6);
});
it("gives each bar of dados its own phase and the floor it grows from", () => {
const dados = blocos.estados[3];
const barras = com(3, (v) => v >= 0);
const fases = [...new Set(barras.map((i) => vida[i * 4 + 3]))].sort();
expect(fases).toEqual([0, 0.2, 0.4, 0.6, 0.8].map(Math.fround));
// cinco barras de duas colunas, com 3, 5, 2, 6 e 4 camadas
expect(barras).toHaveLength(40);
for (const i of com(3, () => true))
expect(dados[i * 7 + 2] - dados[i * 7 + 5] / 2).toBeGreaterThanOrEqual(
chao - 1e-6,
);
// só as barras saem da camada de base
const alto = (i: number) =>
dados[i * 7 + 2] - dados[i * 7 + 5] / 2 > chao + 1e-3;
expect(com(3, (v) => v < 0).filter(alto)).toEqual([]);
});
});
describe("createFrameBudget (does this device keep up with the scene?)", () => {
const feed = (budget: (delta: number) => string, deltas: number[]) =>
deltas.map((delta) => budget(delta));
it("is pending until 60 frames, then ok for 16 ms frames, and keeps that verdict", () => {
const budget = createFrameBudget();
const verdicts = feed(budget, Array(60).fill(16));
expect(verdicts.slice(0, 59).every((v) => v === "pending")).toBe(true);
expect(verdicts[59]).toBe("ok");
expect(budget(200)).toBe("ok");
});
it("is slow when 60 frames average more than 28 ms", () => {
const budget = createFrameBudget();
const verdicts = feed(budget, Array(60).fill(40));
expect(verdicts[58]).toBe("pending");
expect(verdicts[59]).toBe("slow");
expect(budget(16)).toBe("slow");
});
it("ignores a stall above 250 ms (a tab switch, a GC pause) instead of counting it", () => {
const budget = createFrameBudget();
const verdicts = feed(budget, [
...Array(30).fill(16),
900,
...Array(29).fill(16),
]);
expect(verdicts.at(-1)).toBe("pending");
expect(budget(16)).toBe("ok");
});
it("takes a custom limit and frame count", () => {
const budget = createFrameBudget(10, 2);
expect(budget(12)).toBe("pending");
expect(budget(12)).toBe("slow");
});
});
describe("framePiece (where the piece sits in the host)", () => {
const host = { width: 1700, height: 900 };
const placement = { x: 1000, y: 100, width: 520, height: 650 };
it("fits the 17 x 12 box in the whole host, centered, away from marca (w = 0)", () => {
const expected = { x: 850, y: 450, scale: 75 }; // min(1700 / 17, 900 / 12)
expect(framePiece(host, null, 0)).toEqual(expected);
expect(framePiece(host, placement, 0)).toEqual(expected);
});
it("fits the poster box (15.25 x 15.25) in the whole host when there is no placement (w = 1)", () => {
const { x, y, scale } = framePiece(host, null, 1);
expect([x, y]).toEqual([850, 450]);
expect(scale).toBeCloseTo(900 / 15.25, 9);
});
it("fits the poster box inside the placement, centered on it (w = 1)", () => {
const { x, y, scale } = framePiece(host, placement, 1);
expect([x, y]).toEqual([1260, 425]);
expect(scale).toBeCloseTo(520 / 15.25, 9);
});
it("interpolates center and size in between, and clamps w", () => {
const half = framePiece(host, placement, 0.5);
expect(half.x).toBeCloseTo((850 + 1260) / 2, 9);
expect(half.y).toBeCloseTo((450 + 425) / 2, 9);
expect(half.scale).toBeCloseTo((75 + 520 / 15.25) / 2, 9);
expect(framePiece(host, placement, 2)).toEqual(
framePiece(host, placement, 1),
);
expect(framePiece(host, placement, -1)).toEqual(
framePiece(host, placement, 0),
);
});
it("fits the 17 x 12 box in the useful stage area, centered on it, when one is given (w = 0)", () => {
const stage = { x: 86, y: 84, width: 1267, height: 546 };
const { x, y, scale } = framePiece(host, placement, 0, stage);
expect([x, y]).toEqual([86 + 1267 / 2, 84 + 546 / 2]);
expect(scale).toBeCloseTo(546 / 12, 9); // min(1267 / 17, 546 / 12)
// a marca continua no retângulo da peça
expect(framePiece(host, placement, 1, stage)).toEqual(
framePiece(host, placement, 1),
);
});
it("works in a portrait host (the width limits the box)", () => {
expect(framePiece({ width: 390, height: 844 }, null, 0)).toEqual({
x: 195,
y: 422,
scale: 390 / 17,
});
});
});
+391
View File
@@ -0,0 +1,391 @@
/**
* A parte da cena de blocos que não depende do Three.js: os estados exportados do Blender (`public/3d/blocos.json`,
* gerado por `3d/blocos.py`), qual estado o mergulho mostra e o orçamento de quadro que decide se o aparelho segura a
* cena.
*/
export const ESTADOS = ["marca", "tela", "integracoes", "dados"] as const;
/** Números por bloco em cada estado: x, y, z, escala x, y, z e tom (0 porcelana, 1 lima). Escala 0 = bloco oculto. */
const PASSO = 7;
/** Um `Float32Array` de `n * 7` por estado, na ordem de `ESTADOS`. */
export type Blocos = { n: number; estados: Float32Array[] };
/** Valida o JSON exportado e o separa por estado. Lança se o arquivo não for o que a cena espera. */
export function parseBlocos(json: unknown): Blocos {
if (typeof json !== "object" || json === null)
throw new Error("blocos.json: esperava um objeto");
const { n, passo, estados, dados } = json as Record<string, unknown>;
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0)
throw new Error(`blocos.json: n inválido (${String(n)})`);
if (passo !== PASSO)
throw new Error(`blocos.json: passo ${String(passo)}, esperava ${PASSO}`);
if (
!Array.isArray(estados) ||
estados.length !== ESTADOS.length ||
estados.some((nome, i) => nome !== ESTADOS[i])
)
throw new Error(`blocos.json: estados devem ser ${ESTADOS.join(", ")}`);
const tamanho = n * PASSO;
if (
!Array.isArray(dados) ||
dados.length !== ESTADOS.length * tamanho ||
dados.some((v) => typeof v !== "number")
)
throw new Error(
`blocos.json: dados devem ter ${ESTADOS.length * tamanho} números`,
);
return {
n,
estados: ESTADOS.map(
(_, i) => new Float32Array(dados.slice(i * tamanho, (i + 1) * tamanho)),
),
};
}
/** Grupos de blocos encostados (centros a até 1,2 vez o maior lado do bloco), entre os índices `membros`. */
function grupos(d: Float32Array, membros: number[]) {
const livres = new Set(membros);
const saida: number[][] = [];
for (const inicio of membros) {
if (!livres.delete(inicio)) continue;
const grupo = [inicio];
for (let g = 0; g < grupo.length; g++) {
const a = grupo[g] * PASSO;
const alcance = 1.2 * Math.max(d[a + 3], d[a + 4], d[a + 5]);
for (const j of livres) {
const b = j * PASSO;
const dist = Math.hypot(
d[a] - d[b],
d[a + 1] - d[b + 1],
d[a + 2] - d[b + 2],
);
if (dist <= alcance) {
livres.delete(j);
grupo.push(j);
}
}
}
saida.push(grupo);
}
return saida;
}
/** Centro (média dos centros) de um grupo de blocos. */
function centro(d: Float32Array, grupo: number[]): [number, number, number] {
const c: [number, number, number] = [0, 0, 0];
for (const i of grupo)
for (let k = 0; k < 3; k++) c[k] += d[i * PASSO + k] / grupo.length;
return c;
}
/** Papel de cada bloco na vida em repouso de cada estado. */
export type Papeis = {
/**
* Quatro números por bloco: o quadrado lima da marca (0 a 3, da esquerda para a direita; -1 fora deles), 1 se é do
* botão da tela (senão 0), a posição na linha do núcleo até um nó nas integrações (0 no núcleo, 1 no nó; -1 fora
* das linhas) e a fase da barra dos dados (0 a 1; -1 fora das barras).
*/
vida: Float32Array;
/** Centro do botão da tela, em volta do qual ele pulsa. */
botao: [number, number, number];
/** Fundo da peça dos dados (z da face de trás da camada de base): a barra cresce a partir dele. */
chao: number;
};
/**
* Quem faz o quê na vida em repouso, tirado do tom e da posição de cada estado (sem mexer no JSON): na marca, os
* grupos lima são os quadrados; na tela, o grupo lima é o botão; nas integrações, o maior grupo lima é o núcleo, os
* outros marcam os nós, e as linhas são os blocos de porcelana de uma camada só no caminho do núcleo até um nó; nos
* dados, as barras são as colunas que saem em +Z da camada de base.
*/
export function papeis({ n, estados }: Blocos): Papeis {
const vida = new Float32Array(n * 4).fill(-1);
const visiveis = (d: Float32Array) =>
Array.from({ length: n }, (_, i) => i).filter((i) => d[i * PASSO + 3] > 0);
const lima = (d: Float32Array) =>
visiveis(d).filter((i) => d[i * PASSO + 6] > 0.5);
const [marca, tela, integracoes, dados] = estados;
const quadrados = grupos(marca, lima(marca)).sort(
(a, b) => centro(marca, a)[0] - centro(marca, b)[0],
);
quadrados.forEach((grupo, q) => {
for (const i of grupo) vida[i * 4] = q;
});
for (let i = 0; i < n; i++) vida[i * 4 + 1] = 0;
const botao = lima(tela);
for (const i of botao) vida[i * 4 + 1] = 1;
const [nucleo, ...nos] = grupos(integracoes, lima(integracoes))
.sort((a, b) => b.length - a.length)
.map((grupo) => centro(integracoes, grupo));
const vistos = visiveis(integracoes);
const raios = nos.map(() => [] as [number, number][]);
for (const i of vistos) {
const a = i * PASSO;
if (integracoes[a + 6] > 0.5) continue;
// os nós e o núcleo têm duas camadas; a linha, uma
const empilhado = vistos.some(
(j) =>
j !== i &&
Math.abs(integracoes[a] - integracoes[j * PASSO]) < 1e-3 &&
Math.abs(integracoes[a + 1] - integracoes[j * PASSO + 1]) < 1e-3,
);
if (empilhado) continue;
// o nó cuja reta desde o núcleo passa por este bloco (a menos de 3/4 de bloco), entre o núcleo e o nó
const x = integracoes[a] - nucleo[0];
const y = integracoes[a + 1] - nucleo[1];
nos.forEach(([nx, ny], k) => {
const ux = nx - nucleo[0];
const uy = ny - nucleo[1];
const comprimento = Math.hypot(ux, uy);
const aoLongo = (x * ux + y * uy) / comprimento;
const fora = Math.abs(x * uy - y * ux) / comprimento;
if (
aoLongo > 0 &&
aoLongo < comprimento &&
fora < 0.75 * integracoes[a + 3]
)
raios[k].push([i, aoLongo]);
});
}
for (const raio of raios) {
const ds = raio.map(([, d]) => d);
const min = Math.min(...ds);
const faixa = Math.max(...ds) - min || 1;
for (const [i, d] of raio) vida[i * 4 + 2] = (d - min) / faixa;
}
const base = visiveis(dados);
const fundo = (i: number) => dados[i * PASSO + 2] - dados[i * PASSO + 5] / 2;
const chao = Math.min(...base.map(fundo));
const altos = base.filter((i) => fundo(i) > chao + 1e-3);
const barras = grupos(
dados,
base.filter((i) =>
altos.some(
(j) =>
Math.abs(dados[i * PASSO] - dados[j * PASSO]) < 1e-3 &&
Math.abs(dados[i * PASSO + 1] - dados[j * PASSO + 1]) < 1e-3,
),
),
).sort((a, b) => centro(dados, b)[1] - centro(dados, a)[1]);
barras.forEach((grupo, k) => {
for (const i of grupo) vida[i * 4 + 3] = k / barras.length;
});
return {
vida,
botao: botao.length ? centro(tela, botao) : [0, 0, 0],
chao,
};
}
/**
* Trechos do mergulho em que a peça troca de estado (início, fim, de, para); entre eles ela fica parada. Batem com as
* legendas de `hero-captions.ts` (tela, integrações, dados).
*/
const TRECHOS = [
// a peça só se desmonta quando o título do hero já quase se apagou
[0.16, 0.3, 0, 1],
[0.44, 0.56, 1, 2],
[0.7, 0.82, 2, 3],
] as const;
/** Um estado da peça: parado em `from` (`from === to` e `t === 0`) ou em trânsito de `from` para `to` (`t` de 0 a 1). */
export type Estado = { from: number; to: number; t: number };
/**
* O estado que o progresso do mergulho (0 a 1) mostra. Parado: `from === to` e `t === 0`; em trânsito, `t` vai de 0 a
* 1 com smoothstep dentro do trecho.
*/
export function stateAt(p: number): Estado {
const x = Math.min(1, Math.max(0, p || 0));
let parado = 0;
for (const [inicio, fim, from, to] of TRECHOS) {
if (x <= inicio) break;
if (x < fim) {
const k = (x - inicio) / (fim - inicio);
return { from, to, t: k * k * (3 - 2 * k) };
}
parado = to;
}
return { from: parado, to: parado, t: 0 };
}
/**
* O ciclo em repouso, depois de `clock` segundos parado: `fica` s em cada estado e `troca` s para o seguinte (com
* smoothstep), na ordem marca, tela, integrações, dados e de volta à marca. Começa parado na marca. `wait` diz em
* quantos segundos vem a próxima troca (0 durante uma).
*/
export function cicloAt(
clock: number,
fica: number,
troca: number,
): Estado & { wait: number } {
const ciclo = fica + troca;
const atual = Math.floor(clock / ciclo) % ESTADOS.length;
const dentro = (clock % ciclo) - fica;
if (dentro < 0) return { from: atual, to: atual, t: 0, wait: -dentro };
const k = Math.min(1, dentro / troca);
return {
from: atual,
to: (atual + 1) % ESTADOS.length,
t: k * k * (3 - 2 * k),
wait: 0,
};
}
/** O estado em que a peça está parada, ou `null` em trânsito. */
function paradoEm({ from, to, t }: Estado) {
if (t <= 0) return from;
if (t >= 1) return to;
return null;
}
/** Leva `t` até `alvo`, andando no máximo `passo`; numa ponta, o estado fica parado nela. */
function mover({ from, to, t }: Estado, alvo: number, passo: number): Estado {
const k =
Math.abs(alvo - t) <= passo ? alvo : t + Math.sign(alvo - t) * passo;
if (k <= 0) return { from, to: from, t: 0 };
if (k >= 1) return { from: to, to, t: 0 };
return { from, to, t: k };
}
/**
* Um passo do estado mostrado (`atual`) em direção ao pedido (`alvo`), sem salto: `t` anda no máximo `passo` por
* chamada. Na mesma troca, `t` vai ao do alvo; parado num estado da troca pedida, entra nela por essa ponta; fora
* dela, troca direto para a ponta do pedido mais perto dele; no meio de outra troca, anda para a ponta que é um
* estado do pedido (ou, sem nenhuma, para a mais perto) e segue de lá. É como a rolagem retoma a peça do ponto em
* que o ciclo em repouso a deixou (e o ciclo, da rolagem). Quando chega, devolve exatamente o alvo.
*/
export function seguir(atual: Estado, alvo: Estado, passo: number): Estado {
const a = paradoEm(atual);
const b = paradoEm(alvo);
if (a !== null) {
if (b === a) return { from: a, to: a, t: 0 };
if (b !== null) return mover({ from: a, to: b, t: 0 }, 1, passo);
if (a === alvo.from)
return mover({ from: a, to: alvo.to, t: 0 }, alvo.t, passo);
if (a === alvo.to)
return mover({ from: alvo.from, to: a, t: 1 }, alvo.t, passo);
// fora da troca pedida: direto para a ponta dela onde o alvo está mais perto
const ponta = alvo.t < 0.5 ? alvo.from : alvo.to;
return mover({ from: a, to: ponta, t: 0 }, 1, passo);
}
if (b === null && atual.from === alvo.from && atual.to === alvo.to)
return mover(atual, alvo.t, passo);
// a mesma troca, no sentido contrário: vira para o sentido do alvo
if (b === null && atual.from === alvo.to && atual.to === alvo.from)
return mover(
{ from: alvo.from, to: alvo.to, t: 1 - atual.t },
alvo.t,
passo,
);
// outra troca: vai à ponta que já é um estado do pedido (sem voltar atrás quando o pedido muda), ou à mais perto
const pedidos = b === null ? [alvo.from, alvo.to] : [b];
if (pedidos.includes(atual.to)) return mover(atual, 1, passo);
if (pedidos.includes(atual.from)) return mover(atual, 0, passo);
return mover(atual, atual.t < 0.5 ? 0 : 1, passo);
}
/** O que a peça está montando em cada estado, na legenda abaixo dela; a marca não tem legenda. */
const LEGENDAS = [
null,
"Sites e sistemas",
"Integrações",
"Painéis e dados",
] as const;
/** A legenda do estado (índice de `ESTADOS`), ou `null` sem legenda (a marca, nenhum estado ou um índice inválido). */
export function legendaDe(estado: number | null): string | null {
return estado === null ? null : (LEGENDAS[estado] ?? null);
}
export type FrameVerdict = "pending" | "ok" | "slow";
/**
* Mede se o aparelho segura a cena: recebe o tempo de cada quadro (ms) e devolve `"pending"` até juntar `frames`
* quadros, depois `"slow"` se a média passou de `limitMs` (senão `"ok"`), e repete esse veredito dali em diante.
* Quadros acima de 250 ms (aba trocada, pausa do coletor de lixo) não contam.
*/
export function createFrameBudget(limitMs = 28, frames = 60) {
let soma = 0;
let contados = 0;
let veredito: FrameVerdict = "pending";
return (deltaMs: number): FrameVerdict => {
if (veredito !== "pending" || !(deltaMs <= 250)) return veredito;
soma += deltaMs;
contados += 1;
if (contados >= frames)
veredito = soma / contados > limitMs ? "slow" : "ok";
return veredito;
};
}
/** Retângulo em pixels CSS, relativo ao elemento hospedeiro do canvas. */
export type Rect = { x: number; y: number; width: number; height: number };
/**
* Caixas (unidades da cena) em que a peça é enquadrada. Na `marca`, o quadro do próprio pôster (`3d/blocos.py`: câmera a
* 36 unidades, lente de 85 mm em filme de 36 mm, 36 × 36 / 85 ≈ 15,25 de lado), para o canvas cair sobre ele; nos
* outros estados, 17 x 12.
*/
const CAIXA_MARCA = { width: 15.25, height: 15.25 };
const CAIXA_ESTADOS = { width: 17, height: 12 };
/** A caixa ajustada dentro do retângulo, centrada nele: centro (px) e pixels por unidade. */
function fit(rect: Rect, caixa: { width: number; height: number }) {
return {
x: rect.x + rect.width / 2,
y: rect.y + rect.height / 2,
scale: Math.min(rect.width / caixa.width, rect.height / caixa.height),
};
}
/**
* Onde a peça aparece no hospedeiro: centro (`x`, `y`, px a partir do canto de cima à esquerda) e `scale` (px por
* unidade da cena). `w` é o peso do estado `marca` (1 parado nela, 0 nos outros, entre os dois em trânsito): a marca
* vai no retângulo `placement` (ou no hospedeiro inteiro sem ele), os outros estados na área útil `stage` (ou no
* hospedeiro inteiro sem ela).
*/
export function framePiece(
host: { width: number; height: number },
placement: Rect | null,
w: number,
stage?: Rect | null,
) {
const inteiro = { x: 0, y: 0, width: host.width, height: host.height };
const marca = fit(placement ?? inteiro, CAIXA_MARCA);
const resto = fit(stage ?? inteiro, CAIXA_ESTADOS);
const k = Math.min(1, Math.max(0, w || 0));
return {
x: resto.x + (marca.x - resto.x) * k,
y: resto.y + (marca.y - resto.y) * k,
scale: resto.scale + (marca.scale - resto.scale) * k,
};
}
/** Margem (px) entre a caixa dos estados no topo e a borda direita do hospedeiro. */
const MARGEM_ESTADOS = 24;
/**
* Onde tela, integrações e dados ficam no ciclo em repouso do topo (e onde vai a legenda, logo abaixo): dentro da
* caixa da peça, sem a faixa da esquerda (22%), que a marca deixa vazia e que fica perto do texto do hero, e sem
* passar da borda direita do hospedeiro; na proporção da caixa dos estados (17 x 12), centrada na altura da peça.
*/
export function caixaDosEstados(
piece: Rect,
host: { width: number; height: number },
): Rect {
const x = piece.x + piece.width * 0.22;
const width = Math.max(
0,
Math.min(piece.x + piece.width, host.width - MARGEM_ESTADOS) - x,
);
const height = (width * CAIXA_ESTADOS.height) / CAIXA_ESTADOS.width;
return { x, y: piece.y + (piece.height - height) / 2, width, height };
}
+15 -15
View File
@@ -3,15 +3,15 @@ import { join } from "node:path";
import ts from "typescript";
import {
isCoarsePointer,
isLowPowerDevice,
isMobileViewport,
isSaveData,
prefersReducedMotion,
sceneTier,
} from "./device";
/**
* Testes de caminhos-base de lib/device.ts (complemento de device.test.ts): matches() sem matchMedia,
* cada condição de isLowPowerDevice isolada (inclusive os limites), isSaveData e o ambiente sem window/navigator
* cada condição de sceneTier isolada (inclusive os limites), isSaveData e o ambiente sem window/navigator
* (servidor). O servidor é simulado transpilando o fonte e executando-o com `window` e `navigator` indefinidos,
* porque o jsdom não deixa esconder esses globais.
*/
@@ -70,7 +70,7 @@ describe("matches() (through the media helpers)", () => {
expect(isCoarsePointer()).toBe(false);
expect(prefersReducedMotion()).toBe(false);
expect(isMobileViewport()).toBe(false);
expect(isLowPowerDevice()).toBe(false);
expect(sceneTier()).toBe("full");
});
it("path: matchMedia answers false for the query -> false", () => {
@@ -105,7 +105,7 @@ describe("isSaveData()", () => {
});
});
describe("isLowPowerDevice(): limites de cada condição, uma de cada vez", () => {
describe("sceneTier(): limites de cada condição, uma de cada vez", () => {
it.each([
["5 cores", { hardwareConcurrency: 5 }],
["8 GB of memory", { hardwareConcurrency: 8, deviceMemory: 8 }],
@@ -118,7 +118,7 @@ describe("isLowPowerDevice(): limites de cada condição, uma de cada vez", () =
(_label, hints) => {
mockNavigator(hints);
expect(isLowPowerDevice()).toBe(false);
expect(sceneTier()).toBe("full");
},
);
@@ -127,20 +127,20 @@ describe("isLowPowerDevice(): limites de cada condição, uma de cada vez", () =
["exactly 4 GB", { deviceMemory: 4 }],
["0.5 GB", { deviceMemory: 0.5 }],
["1 core", { hardwareConcurrency: 1 }],
])("path: %s -> low power", (_label, hints) => {
])("path: %s -> static", (_label, hints) => {
mockNavigator(hints);
expect(isLowPowerDevice()).toBe(true);
expect(sceneTier()).toBe("static");
});
it("path: only the first condition is true (touch) -> low power without reading the rest", () => {
it("path: touch on a capable phone -> lite, never full", () => {
mockMedia(["(pointer: coarse)"]);
mockNavigator({ hardwareConcurrency: 16, deviceMemory: 16 });
expect(isLowPowerDevice()).toBe(true);
expect(sceneTier()).toBe("lite");
});
it("path: several conditions true at once -> still just true", () => {
it("path: several conditions true at once -> still just static", () => {
mockMedia(["(pointer: coarse)", "(prefers-reduced-motion: reduce)"]);
mockNavigator({
hardwareConcurrency: 2,
@@ -148,7 +148,7 @@ describe("isLowPowerDevice(): limites de cada condição, uma de cada vez", () =
connection: { saveData: true },
});
expect(isLowPowerDevice()).toBe(true);
expect(sceneTier()).toBe("static");
});
});
@@ -161,11 +161,11 @@ describe("without window and navigator (server)", () => {
expect(device.isMobileViewport()).toBe(false);
});
it("path: no navigator -> save data is off and the device counts as low power", () => {
it("path: no navigator -> save data is off and the scene is static", () => {
const device = loadOnServer({ window: undefined, navigator: undefined });
expect(device.isSaveData()).toBe(false);
expect(device.isLowPowerDevice()).toBe(true);
expect(device.sceneTier()).toBe("static");
});
it("path: a navigator without window (runtime that exposes only navigator) -> hints still read", () => {
@@ -175,11 +175,11 @@ describe("without window and navigator (server)", () => {
});
expect(device.isSaveData()).toBe(true);
expect(device.isLowPowerDevice()).toBe(true);
expect(device.sceneTier()).toBe("static");
const capable = loadOnServer({
window: undefined,
navigator: { hardwareConcurrency: 16 },
});
expect(capable.isLowPowerDevice()).toBe(false);
expect(capable.sceneTier()).toBe("full");
});
});
+100 -7
View File
@@ -1,14 +1,16 @@
import {
isCoarsePointer,
isKnownSlowScene,
isKnownSoftwareWebGL,
isLowPowerDevice,
isMobileViewport,
isSaveData,
isSoftwareRenderer,
isSoftwareWebGL,
isWebKit,
MOBILE_QUERY,
rememberSlowScene,
rememberSoftwareWebGL,
sceneTier,
} from "./device";
const originalMatchMedia = window.matchMedia;
@@ -40,9 +42,11 @@ afterEach(() => {
window.matchMedia = originalMatchMedia;
});
const TOUCH = "(pointer: coarse)";
describe("device hints", () => {
it("treats a capable desktop as full power", () => {
expect(isLowPowerDevice()).toBe(false);
expect(sceneTier()).toBe("full");
expect(isCoarsePointer()).toBe(false);
expect(isSaveData()).toBe(false);
expect(isMobileViewport()).toBe(false);
@@ -55,26 +59,80 @@ describe("device hints", () => {
});
it.each([
["a touch pointer", () => mockMedia(["(pointer: coarse)"])],
["reduced motion", () => mockMedia(["(prefers-reduced-motion: reduce)"])],
["the data saver", () => mockNavigator({ connection: { saveData: true } })],
["four cores", () => mockNavigator({ hardwareConcurrency: 4 })],
["two cores", () => mockNavigator({ hardwareConcurrency: 2 })],
["4 GB of memory", () => mockNavigator({ deviceMemory: 4 })],
["2 GB of memory", () => mockNavigator({ deviceMemory: 2 })],
])("is low power with %s", (_label, arrange) => {
])("shows only the poster with %s", (_label, arrange) => {
arrange();
expect(isLowPowerDevice()).toBe(true);
expect(sceneTier()).toBe("static");
});
it("does not penalize a browser that hides the hardware hints", () => {
mockNavigator({ hardwareConcurrency: undefined, deviceMemory: undefined });
expect(isLowPowerDevice()).toBe(false);
expect(sceneTier()).toBe("full");
});
it("keeps 8 GB and 6 cores on the full path", () => {
mockNavigator({ hardwareConcurrency: 6, deviceMemory: 8 });
expect(isLowPowerDevice()).toBe(false);
expect(sceneTier()).toBe("full");
});
});
describe("sceneTier(): one case per row of the decision table", () => {
it("row 1: no navigator (server) -> static", () => {
const original = Object.getOwnPropertyDescriptor(globalThis, "navigator");
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: undefined,
});
try {
expect(sceneTier()).toBe("static");
} finally {
if (original) Object.defineProperty(globalThis, "navigator", original);
else Reflect.deleteProperty(globalThis, "navigator");
}
});
it.each([
["reduced motion", [TOUCH, "(prefers-reduced-motion: reduce)"], {}],
["the data saver", [TOUCH], { connection: { saveData: true } }],
])("row 2: a capable phone with %s -> static", (_label, media, hints) => {
mockMedia(media);
mockNavigator({ hardwareConcurrency: 8, deviceMemory: 8, ...hints });
expect(sceneTier()).toBe("static");
});
it("row 3: 4 cores with plenty of memory -> static even with a mouse", () => {
mockNavigator({ hardwareConcurrency: 4, deviceMemory: 8 });
expect(sceneTier()).toBe("static");
});
it.each([
["8 cores and 4 GB", { hardwareConcurrency: 8, deviceMemory: 4 }, "lite"],
["8 cores and 2 GB", { hardwareConcurrency: 8, deviceMemory: 2 }, "static"],
["5 cores", { hardwareConcurrency: 5 }, "static"],
["6 cores and 4 GB", { hardwareConcurrency: 6, deviceMemory: 4 }, "lite"],
[
"no hints reported",
{ hardwareConcurrency: undefined, deviceMemory: undefined },
"lite",
],
])("row 4: touch with %s -> %s", (_label, hints, tier) => {
mockMedia([TOUCH]);
mockNavigator(hints);
expect(sceneTier()).toBe(tier);
});
it.each([
["8 cores and 4 GB", { hardwareConcurrency: 8, deviceMemory: 4 }, "static"],
["8 cores and 8 GB", { hardwareConcurrency: 8, deviceMemory: 8 }, "full"],
["5 cores and no memory hint", { hardwareConcurrency: 5 }, "full"],
])("row 5: mouse with %s -> %s", (_label, hints, tier) => {
mockNavigator(hints);
expect(sceneTier()).toBe(tier);
});
});
@@ -162,6 +220,41 @@ describe("the software WebGL verdict kept for the session", () => {
});
});
describe("the slow scene verdict kept for the session", () => {
beforeEach(() => sessionStorage.clear());
afterEach(() => {
jest.restoreAllMocks();
sessionStorage.clear();
});
it("is unknown until someone remembers it, then known for the rest of the session", () => {
expect(isKnownSlowScene()).toBe(false);
rememberSlowScene();
expect(isKnownSlowScene()).toBe(true);
expect(sessionStorage.getItem("scene-slow")).toBe("1");
});
it("ignores any other value in the key (only the verdict this module writes counts)", () => {
sessionStorage.setItem("scene-slow", "0");
expect(isKnownSlowScene()).toBe(false);
});
it("counts a blocked sessionStorage as unknown, and remembering then does not throw", () => {
jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("blocked", "SecurityError");
});
expect(isKnownSlowScene()).toBe(false);
expect(() => rememberSlowScene()).not.toThrow();
});
});
describe("isWebKit (the only engine that needs the showcase video to play once)", () => {
const iphone =
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1";
+43 -13
View File
@@ -35,21 +35,27 @@ export function isSaveData() {
return (navigator as NavigatorHints).connection?.saveData === true;
}
export type SceneTier = "full" | "lite" | "static";
/**
* Aparelho que não deve montar WebGL: toque, 4 núcleos ou menos, 4 GB de memória ou menos, economia de dados
* ou movimento reduzido. O que não é informado pelo navegador (Safari, Firefox) não conta contra o aparelho.
* Quanto de cena 3D o aparelho recebe: `full` a cena completa com o mergulho; `lite` a cena leve, em celular capaz
* (toque com 6 núcleos ou mais e 4 GB ou mais); `static` só o pôster (servidor, movimento reduzido, economia de dados,
* 4 núcleos ou menos, celular fraco, ou computador com 4 GB ou menos). O que não é informado pelo navegador (Safari,
* Firefox) não conta contra o aparelho.
*/
export function isLowPowerDevice() {
if (typeof navigator === "undefined") return true;
const hints = navigator as NavigatorHints;
return (
isCoarsePointer() ||
prefersReducedMotion() ||
isSaveData() ||
(typeof hints.hardwareConcurrency === "number" &&
hints.hardwareConcurrency <= 4) ||
(typeof hints.deviceMemory === "number" && hints.deviceMemory <= 4)
);
export function sceneTier(): SceneTier {
if (typeof navigator === "undefined") return "static";
if (prefersReducedMotion() || isSaveData()) return "static";
const { hardwareConcurrency: cores, deviceMemory: memory } =
navigator as NavigatorHints;
const hasCores = typeof cores === "number";
const hasMemory = typeof memory === "number";
if (hasCores && cores <= 4) return "static";
if (isCoarsePointer())
return (!hasCores || cores >= 6) && (!hasMemory || memory >= 4)
? "lite"
: "static";
return hasMemory && memory <= 4 ? "static" : "full";
}
/** Nomes que os navegadores dão a um renderizador de WebGL que roda na CPU: SwiftShader (Chrome), llvmpipe (Mesa) e o Microsoft Basic Render Driver. */
@@ -97,6 +103,30 @@ export function rememberSoftwareWebGL() {
}
}
const SLOW_SCENE_KEY = "scene-slow";
/**
* A cena 3D já estourou o orçamento de quadro nesta sessão (aba)? Mesmo com GPU, um aparelho pode não dar conta da
* cena; quem mediu anota o veredito aqui e as páginas seguintes da sessão já nascem estáticas. `sessionStorage`
* bloqueado vale como "não sei".
*/
export function isKnownSlowScene() {
try {
return sessionStorage.getItem(SLOW_SCENE_KEY) === "1";
} catch {
return false;
}
}
/** Anota na sessão que a cena 3D ficou lenta demais neste aparelho (ver `isKnownSlowScene`). */
export function rememberSlowScene() {
try {
sessionStorage.setItem(SLOW_SCENE_KEY, "1");
} catch {
// armazenamento bloqueado (modo privado, política do navegador): sem anotação, a próxima página testa de novo
}
}
/**
* O navegador é WebKit (Safari no Mac, no iPhone e no iPad; no iOS todo navegador é WebKit)? É o único que deixa em
* branco um vídeo que ainda não tocou uma vez, mesmo com o quadro já decodificado: só nele vale tocar e pausar o
+9 -1
View File
@@ -1,5 +1,5 @@
/**
* Nome de cada uma das três ofertas e a frase que a mensagem de WhatsApp usa como assunto ("Quero conversar sobre {assunto}.").
* Nome de cada uma das cinco ofertas e a frase que a mensagem de WhatsApp usa como assunto ("Quero conversar sobre {assunto}.").
* É a única fonte desses textos: a home, a página da oferta, os dados estruturados, o formulário de contato e as mensagens de
* WhatsApp importam daqui. Fica à parte de lib/site-content.ts, que reúne o texto de todas as páginas e não deve ir inteiro para
* o navegador: este módulo é pequeno, não tem dependência e não usa nada do Node, então componentes de cliente podem importá-lo.
@@ -13,8 +13,16 @@ export const offerLabels = {
name: "Sistema para tirar a operação da planilha",
whatsappSubject: "um sistema para tirar a operação da planilha",
},
whatsapp: {
name: "Atendimento no WhatsApp com IA sob controle",
whatsappSubject: "um atendimento no WhatsApp com IA sob controle",
},
revisao: {
name: "Revisão de sistema que já existe",
whatsappSubject: "a revisão de um sistema que já existe",
},
fiscal: {
name: "Nota fiscal pronta para a reforma tributária",
whatsappSubject: "a adaptação da nota fiscal à reforma tributária",
},
} as const;
+13
View File
@@ -217,3 +217,16 @@ describe("showcase assets", () => {
},
);
});
describe("project kicker (the label above each project in the showcase)", () => {
it("writes category and status as a sentence, keeping acronyms as they are", () => {
const { projectKicker } = jest.requireActual("./project-showcase");
expect(projectKicker("loja virtual", "Em Produção")).toBe(
"Loja virtual · em produção",
);
expect(projectKicker("IoT", "Protótipo")).toBe("IoT · protótipo");
expect(projectKicker("integração com API", "Concluído")).toBe(
"Integração com API · concluído",
);
});
});
+9
View File
@@ -254,3 +254,12 @@ export function chapterFraction(
? chapter.desktop / config.durations.desktop
: chapter.mobile / config.durations.mobile;
}
/**
* Rótulo do projeto na vitrine: categoria e situação como frase ("Loja virtual · em produção"). Os rótulos não são mais
* em caixa alta, então a grafia dos dados ("loja virtual", "Em Produção") passou a aparecer como está.
*/
export function projectKicker(category: string, status: string) {
const first = category.charAt(0).toLocaleUpperCase("pt-BR");
return `${first}${category.slice(1)} · ${status.toLocaleLowerCase("pt-BR")}`;
}
+15
View File
@@ -0,0 +1,15 @@
import { CLIENT_SECTORS, clientNames } from "./proof";
it("gives every client in the hero strip its sector, defined in one place next to the list", () => {
expect(clientNames.map(({ id, sector }) => [id, sector])).toEqual([
["sev-modaintima", "loja virtual"],
["lavacao-047", "lavação automotiva"],
["sc-plus", "aquicultura"],
["as-servicos", "engenharia e software"],
["julia-beauty", "estúdio de cílios"],
]);
// nenhum ramo sobra para um cliente que não está na lista
expect(Object.keys(CLIENT_SECTORS).sort()).toEqual(
clientNames.map(({ id }) => id).sort(),
);
});
+21 -2
View File
@@ -22,7 +22,26 @@ export function isClientProject(project: { kind?: string }) {
return project.kind === "cliente";
}
/** Clientes na ordem dos projetos, para a faixa "Já entregue para". */
/**
* Ramo de cada cliente, pelo id do projeto: aparece sob o nome na faixa "Já entregue para" (de 640 px em diante), para
* quem lê ver que o trabalho não é de um setor só. Cliente sem ramo aqui mostra só o nome.
*/
export const CLIENT_SECTORS: Readonly<Record<string, string>> = {
"sev-modaintima": "loja virtual",
"lavacao-047": "lavação automotiva",
"sc-plus": "aquicultura",
"as-servicos": "engenharia e software",
"julia-beauty": "estúdio de cílios",
};
/** Clientes na ordem dos projetos, para a faixa "Já entregue para", cada um com o seu ramo quando há. */
export const clientNames = portfolio.projects
.filter(isClientProject)
.map((project) => ({ id: project.id, name: project.title }));
.map((project) => {
const sector = CLIENT_SECTORS[project.id];
return {
id: project.id,
name: project.title,
...(sector ? { sector } : {}),
};
});
+11 -5
View File
@@ -106,20 +106,24 @@ it("builds a canonical service whose service area is the visible one: Garuva, Jo
}
});
it("describes the three offers as Service nodes tied to the person, with the service area and the page of each offer", () => {
it("describes the five offers as Service nodes tied to the person, with the service area and the page of each offer", () => {
const services = buildOfferServicesJsonLd();
expect(services).toHaveLength(3);
// as mesmas três ofertas da home, com o mesmo nome e a página de cada uma (a revisão aponta para /ia)
expect(services).toHaveLength(5);
// as mesmas cinco ofertas, com o mesmo nome e a página de cada uma (a revisão aponta para /ia)
expect(services.map(({ name }) => name)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
expect(services.map(({ url }) => url)).toEqual([
`${siteUrl}/servicos/criacao-de-sites`,
`${siteUrl}/servicos/engenharia-de-software`,
`${siteUrl}/servicos/atendimento-whatsapp-ia`,
`${siteUrl}/ia`,
`${siteUrl}/servicos/nota-fiscal-reforma-tributaria`,
]);
expect(services.map(({ url }) => url)).toEqual(
serviceOffers.map(({ href }) => `${siteUrl}${href}`),
@@ -137,11 +141,13 @@ it("describes the three offers as Service nodes tied to the person, with the ser
}
// ids próprios e estáveis, que não repetem o `#service` que a página de cada serviço emite
const ids = services.map((service) => service["@id"]);
expect(new Set(ids).size).toBe(3);
expect(new Set(ids).size).toBe(5);
expect(ids).toEqual([
`${siteUrl}/#service-site`,
`${siteUrl}/#service-sistema`,
`${siteUrl}/#service-whatsapp`,
`${siteUrl}/#service-revisao`,
`${siteUrl}/#service-fiscal`,
]);
// política de docs/seo-geo-operations.md: nenhum tipo de negócio local e nada que o site não publique
expect(JSON.stringify(services)).not.toMatch(
@@ -246,7 +252,7 @@ it("keeps profile data timeless and sitemap output deterministic", () => {
expect(first.map(({ url }) => url)).toEqual(expectedUrls);
expect(second).toEqual(first);
expect(first).toHaveLength(21);
expect(first).toHaveLength(23);
expect(
first.filter(({ url }) => url === `${siteUrl}/privacidade`),
).toHaveLength(1);
+1 -1
View File
@@ -78,7 +78,7 @@ const serviceAreaServed = [
] as const;
/**
* As três ofertas da home como nós `Service`, ligados à pessoa (`provider`) e com a área de atendimento (`serviceAreaServed`).
* As cinco ofertas como nós `Service`, ligados à pessoa (`provider`) e com a área de atendimento (`serviceAreaServed`).
* Nada de `ProfessionalService`, `LocalBusiness` ou catálogo de ofertas: a política
* de docs/seo-geo-operations.md os exclui sem entidade ou local comprovado. Sem endereço, preço, prazo, telefone ou e-mail: o que
* não está publicado no site não entra nos dados estruturados. Só o construtor: quem o coloca na página é a home.
+66 -9
View File
@@ -205,11 +205,20 @@ it("picks the cover by subject: sites -> Lavação 047, Next.js -> SC Plus, syst
}
});
it("gives each service without a client cover a flow diagram instead, and never both", () => {
it("gives each service without a client cover a flow diagram instead, and never both; the two offers without a case have neither", () => {
expect(getService("tauri-rust").diagram).toBe("desktop");
expect(getService("integracao-hardware-software-iot").diagram).toBe("iot");
expect(getService("arquitetura-de-software").diagram).toBe("arquitetura");
for (const { heroCase, diagram } of servicePages) {
// nenhum esquema existente mostra um atendimento no WhatsApp ou a emissão de nota: o topo fica numa coluna só
const withoutSide = [
"atendimento-whatsapp-ia",
"nota-fiscal-reforma-tributaria",
];
for (const { slug, heroCase, diagram } of servicePages) {
if (withoutSide.includes(slug)) {
expect(heroCase ?? diagram).toBeUndefined();
continue;
}
expect(Boolean(heroCase) !== Boolean(diagram)).toBe(true);
}
});
@@ -225,34 +234,53 @@ it("lists the client cases for the mosaic and /cases in the order of the home sh
expect(clientCasesForListing).toHaveLength(clientCases.length);
});
it("keeps one list of button labels: 'Falar sobre meu projeto' for contact and 'Ver case' for a case", () => {
it("keeps one list of button labels: 'Falar sobre meu projeto' for the form, 'Chamar no WhatsApp' for WhatsApp and 'Ver case' for a case", () => {
expect(buttonLabels.contact).toBe("Falar sobre meu projeto");
expect(buttonLabels.whatsapp).toBe("Chamar no WhatsApp");
expect(buttonLabels.case).toBe("Ver case");
expect(Object.values(buttonLabels).join("|")).not.toMatch(
/Ver o case|Ler case|Conversar|oportunidade/,
);
});
it("gives a block loop to the two offer pages that have no cover nor diagram at the top", () => {
const loops = Object.fromEntries(
servicePages
.filter((service) => service.heroLoop)
.map((service) => [service.slug, service.heroLoop]),
);
expect(loops).toEqual({
"atendimento-whatsapp-ia": "whatsapp",
"nota-fiscal-reforma-tributaria": "fiscal",
});
});
it("reduces 'direto' and 'diretamente' in the case and service text", () => {
const text = JSON.stringify({ caseStudies, servicePages });
expect(text.match(/diret[oa]s?|diretamente/gi) ?? []).toEqual([]);
});
it("explains the three offers of the home with the same names, 3 or 4 deliverables and a place to see them", () => {
it("explains the five offers with the same names, 3 or 4 deliverables and a place to see them", () => {
expect(serviceOffers.map(({ name }) => name)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Revisão de sistema que já existe",
"Nota fiscal pronta para a reforma tributária",
]);
expect(serviceOffers.map(({ whatsappSubject }) => whatsappSubject)).toEqual([
"um site que traz pedidos pelo WhatsApp",
"um sistema para tirar a operação da planilha",
"um atendimento no WhatsApp com IA sob controle",
"a revisão de um sistema que já existe",
"a adaptação da nota fiscal à reforma tributária",
]);
expect(serviceOffers.map(({ href }) => href)).toEqual([
"/servicos/criacao-de-sites",
"/servicos/engenharia-de-software",
"/servicos/atendimento-whatsapp-ia",
"/ia",
"/servicos/nota-fiscal-reforma-tributaria",
]);
for (const offer of serviceOffers) {
expect(offer.deliverables.length).toBeGreaterThanOrEqual(3);
@@ -277,14 +305,41 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl
].join(" ");
expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/);
}
expect(serviceOffers[0].proof.caseIds).toEqual([
const offer = (id: string) => serviceOffers.find((item) => item.id === id);
expect(offer("site")?.proof.caseIds).toEqual([
"lavacao-047",
"sev-modaintima",
]);
expect(serviceOffers[1].proof.caseIds).toEqual(["sev-modaintima"]);
expect(serviceOffers[1].proof.note).toMatch(/Apolo Energia/);
expect(serviceOffers[1].proof.note).toMatch(/uso interno/);
expect(serviceOffers[2].proof.caseIds).toEqual([]);
expect(offer("sistema")?.proof.caseIds).toEqual(["sev-modaintima"]);
expect(offer("sistema")?.proof.note).toMatch(/Apolo Energia/);
expect(offer("sistema")?.proof.note).toMatch(/uso interno/);
expect(offer("revisao")?.proof.caseIds).toEqual([]);
expect(offer("revisao")?.proof.note).toMatch(/cinco episódios públicos/);
// as duas ofertas novas não têm case: a nota diz isso em vez de mostrar prova que não existe
for (const id of ["whatsapp", "fiscal"]) {
expect(offer(id)?.proof.caseIds).toEqual([]);
expect(offer(id)?.proof.note).toMatch(/^Ainda não há case público/);
}
});
it("backs the WhatsApp and the invoice pages with the official sources, and says nothing the sources do not", () => {
const whatsapp = getService("atendimento-whatsapp-ia");
expect(whatsapp.official?.links.map(({ href }) => href)).toEqual([
"https://whatsappbusiness.com/policy/",
]);
const fiscal = getService("nota-fiscal-reforma-tributaria");
expect(fiscal.official?.links.map(({ href }) => href)).toEqual([
"https://www.cgibs.gov.br/upload/arquivos/202607/31091735-20260730-16h30-ato-conjunto-rfb-cgibs-na-c2-ba-4-260731-090909.pdf",
"https://www.gov.br/receitafederal/pt-br/assuntos/noticias/2026/julho/receita-federal-e-comite-gestor-do-ibs-publicam-o-cronograma-de-implementacao-dos-documentos-fiscais-eletronicos-da-reforma-tributaria-do-consumo",
]);
// as quatro datas do art. 1º do Ato Conjunto RFB/CGIBS nº 4/2026
expect(fiscal.official?.items.join(" ")).toMatch(
/3 de agosto de 2026.*1º de outubro de 2026.*1º de dezembro de 2026.*1º de janeiro de 2027/,
);
// sem fonte oficial lida para rejeição de nota, multa ou tolerância: nada disso entra
expect(JSON.stringify(fiscal)).not.toMatch(/rejei|multa|tolerânc/i);
// o contador continua dono da classificação fiscal e das alíquotas
expect(JSON.stringify(fiscal)).toMatch(/não substitui o contador/);
});
it("uses one name per offer: the service page, the breadcrumb label and the WhatsApp subject match the offer", () => {
@@ -307,6 +362,8 @@ it("gives every service a short name and a lowercase WhatsApp subject with its a
expect(servicePages.map(({ navLabel }) => navLabel)).toEqual([
"Site que traz pedidos pelo WhatsApp",
"Sistema para tirar a operação da planilha",
"Atendimento no WhatsApp com IA sob controle",
"Nota fiscal pronta para a reforma tributária",
"Consultoria em Next.js",
"Aplicativos desktop",
"Integração de equipamentos",
+199 -6
View File
@@ -13,10 +13,12 @@ export type ServiceDiagramKind = "desktop" | "iot" | "arquitetura";
/**
* Rótulos dos botões e links de ação, numa lista só: o contato diz sempre a mesma coisa, e o case também.
* "Falar sobre meu projeto" leva sempre ao formulário; o controle que abre o WhatsApp diz "Chamar no WhatsApp".
* O verde-limão fica só no contato; os demais são contorno ou link de texto.
*/
export const buttonLabels = {
contact: "Falar sobre meu projeto",
whatsapp: "Chamar no WhatsApp",
case: "Ver case",
site: "Abrir o site",
service: "Ver o serviço",
@@ -54,6 +56,8 @@ export interface ServicePageContent {
heroCase?: { id: string; device: HeroDevice };
/** Esquema de fluxo do topo, para o serviço sem case com captura: nunca leva dado real nem logo de cliente. */
diagram?: ServiceDiagramKind;
/** Peça de blocos em loop (`public/3d/peca-<nome>`) ao lado do título, de 1024 px em diante, quando não há capa nem esquema. */
heroLoop?: string;
/** Data (AAAA-MM-DD) da última mudança no conteúdo da página: vira o `lastmod` do mapa do site. Atualize ao mudar o texto. */
updatedAt: string;
/** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */
@@ -65,6 +69,12 @@ export interface ServicePageContent {
typicalTimeline?: string;
guarantee?: string;
faq?: { question: string; answer: string }[];
/** Regras ou datas tiradas de fonte oficial, cada uma conferível pelos links: o bloco só aparece quando existe. */
official?: {
title: string;
items: string[];
links: { label: string; href: string }[];
};
}
export interface CaseStudyContent {
@@ -251,6 +261,150 @@ export const servicePages: ServicePageContent[] = [
},
],
},
{
slug: "atendimento-whatsapp-ia",
navLabel: offerLabels.whatsapp.name,
whatsappSubject: offerLabels.whatsapp.whatsappSubject,
heroLoop: "whatsapp",
eyebrow: "Atendimento com IA",
metaTitle: "Atendimento no WhatsApp com IA em Joinville",
metaDescription:
"Atendente automático no WhatsApp que responde só com o seu catálogo e chama uma pessoa quando não sabe. Garuva e Joinville (SC), remoto em todo o Brasil.",
headline: offerLabels.whatsapp.name,
answer:
"Monto um atendente automático para o WhatsApp da sua empresa. Ele responde a partir do catálogo, dos preços e dos horários que você fornece. O que não está lá ele não responde: passa a conversa para uma pessoa da sua equipe.",
intro:
"Responda o cliente no WhatsApp na hora, com o que a sua empresa já informa, e chame uma pessoa quando a pergunta sair do catálogo.",
audience: [
"Empresas que atendem e vendem pelo WhatsApp e respondem muitas vezes as mesmas perguntas sobre produtos, preços e horários.",
"Equipes que querem responder na hora sem deixar a IA inventar preço ou prazo.",
],
outcomes: [
"Um atendente que responde só com o catálogo, os preços e os horários da empresa.",
"Passagem para uma pessoa da equipe quando a pergunta sai do que foi fornecido.",
],
process: [
"Entendemos o atendimento de hoje: quem responde, o que o cliente pergunta e onde estão o catálogo, os preços e os horários.",
"Definimos o que o atendente pode responder e em que momento ele passa a conversa para uma pessoa.",
"Ligamos o atendente pela API oficial do WhatsApp para empresas e testamos as conversas antes de abrir para os clientes.",
"Você acompanha o registro das conversas e atualiza o catálogo quando algo muda.",
],
examples: [
"O cliente pergunta o preço de um item da tabela: o atendente responde com o valor que está na tabela.",
"O cliente pergunta o horário de sábado: o atendente responde com o horário que você informou.",
"O cliente pede um desconto ou pergunta algo que não está no catálogo: o atendente não responde por conta própria e chama uma pessoa da equipe na mesma conversa.",
],
technicalDetails: [
"A ligação é feita pela API oficial do WhatsApp para empresas.",
"As respostas saem só do catálogo, dos preços e dos horários fornecidos pela empresa. Pergunta sem resposta nessas fontes vai para uma pessoa.",
"Cada conversa fica registrada, com o que foi informado ao cliente.",
],
stack: ["API oficial do WhatsApp", "IA", "Banco de dados"],
relatedCaseIds: [],
updatedAt: "2026-11-09",
caseRelevance: {},
official: {
title: "Regras do WhatsApp para empresas",
items: [
"A política do WhatsApp para empresas permite responder com automação, desde que o cliente tenha um caminho para falar com uma pessoa.",
"A janela de 24 horas abre e recomeça a cada mensagem do cliente. Fora dela, a empresa só pode enviar mensagem de modelo aprovado.",
"O que o atendente informa ao cliente é informado em nome da empresa.",
],
links: [
{
label: "Política do WhatsApp para empresas (em inglês)",
href: "https://whatsappbusiness.com/policy/",
},
],
},
faq: [
{
question: "A IA responde sobre qualquer assunto?",
answer:
"Não. Ela responde só com o que está no catálogo, nos preços e nos horários que você fornece. O resto vai para uma pessoa da sua equipe.",
},
{
question: "Quem responde pelo que o atendente diz?",
answer:
"A empresa. O que o atendente informa ao cliente é informado em nome dela. Por isso ele fica limitado ao que você fornece e cada conversa fica registrada.",
},
],
},
{
slug: "nota-fiscal-reforma-tributaria",
navLabel: offerLabels.fiscal.name,
whatsappSubject: offerLabels.fiscal.whatsappSubject,
heroLoop: "fiscal",
eyebrow: "Reforma tributária",
metaTitle: "Nota fiscal com IBS e CBS em Joinville e SC",
metaDescription:
"Ajuste dos campos de IBS e CBS na nota que o seu sistema emite, com teste em homologação. Garuva e Joinville (SC), remoto em todo o Brasil.",
headline: offerLabels.fiscal.name,
answer:
"Se o seu sistema ou a sua integração emite nota fiscal, vejo por onde a nota sai, ajusto os campos de IBS e CBS no leiaute novo e testo a emissão em homologação antes de mudar a produção. Classificação fiscal e alíquotas continuam com o seu contador.",
intro:
"Ajuste os campos de IBS e CBS da nota que o seu sistema emite e teste a emissão antes de o prazo chegar.",
audience: [
"Empresas cujo sistema próprio ou integração emite NF-e, NFC-e ou NFS-e.",
"Optantes do Simples Nacional com sistema que emite nota: a data deles é 1º de janeiro de 2027.",
],
outcomes: [
"Campos de IBS e CBS no leiaute novo da nota.",
"Emissão testada em homologação antes de mudar a produção.",
],
process: [
"Levantamos o que o seu sistema emite hoje e por onde a nota sai.",
"Ajustamos os campos de IBS e CBS no leiaute novo da nota.",
"Testamos a emissão em ambiente de homologação antes de mudar a produção.",
"Entregamos a lista do que depende do seu contador, como classificação fiscal e alíquotas.",
],
examples: [
"Um sistema de vendas que emite NF-e ou NFC-e e precisa dos campos novos.",
"Uma integração que emite NFS-e e precisa seguir o leiaute novo.",
"Uma empresa do Simples Nacional que quer testar a emissão antes de 1º de janeiro de 2027.",
],
technicalDetails: [
"Os campos de IBS e CBS entram no leiaute da NF-e, da NFC-e ou da NFS-e, conforme o documento que o sistema emite.",
"O teste é feito em ambiente de homologação, antes de qualquer mudança na produção.",
"O trabalho é técnico e não substitui o contador: classificação fiscal e alíquotas são definidas por ele.",
],
stack: ["NF-e", "NFC-e", "NFS-e", "Integração de sistemas"],
relatedCaseIds: [],
updatedAt: "2026-11-09",
caseRelevance: {},
official: {
title: "As datas do Ato Conjunto RFB/CGIBS nº 4/2026",
items: [
"NF-e e NFC-e: desde 3 de agosto de 2026.",
"NFS-e da maior parte dos serviços: desde 1º de outubro de 2026.",
"NFS-e de plataformas digitais, de processamento e hospedagem de dados (subitem 1.03), de licenciamento de software (1.05), de conteúdo de áudio e vídeo pela internet (1.09), de locações, de condomínios e dos demais casos listados no ato: a partir de 1º de dezembro de 2026.",
"Optantes do Simples Nacional: a partir de 1º de janeiro de 2027.",
],
links: [
{
label:
"Texto oficial do Ato Conjunto RFB/CGIBS nº 4, de 30 de julho de 2026 (PDF)",
href: "https://www.cgibs.gov.br/upload/arquivos/202607/31091735-20260730-16h30-ato-conjunto-rfb-cgibs-na-c2-ba-4-260731-090909.pdf",
},
{
label: "Nota da Receita Federal sobre o cronograma",
href: "https://www.gov.br/receitafederal/pt-br/assuntos/noticias/2026/julho/receita-federal-e-comite-gestor-do-ibs-publicam-o-cronograma-de-implementacao-dos-documentos-fiscais-eletronicos-da-reforma-tributaria-do-consumo",
},
],
},
faq: [
{
question: "O trabalho substitui o contador?",
answer:
"Não. O trabalho é técnico: ajustar e testar a emissão. Classificação fiscal e alíquotas são do seu contador.",
},
{
question: "Qual é a data do meu caso?",
answer:
"Depende do documento que você emite e do regime da empresa. As datas do Ato Conjunto RFB/CGIBS nº 4/2026 estão nesta página, com o link para o texto oficial.",
},
],
},
{
slug: "consultoria-nextjs",
navLabel: "Consultoria em Next.js",
@@ -909,7 +1063,7 @@ export const pageUpdatedAt = {
} as const;
export interface ServiceOffer {
id: "site" | "sistema" | "revisao";
id: keyof typeof offerLabels;
/** Nome da oferta, o mesmo da home, da página da oferta, dos dados estruturados e do WhatsApp. */
name: string;
/** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */
@@ -920,7 +1074,7 @@ export interface ServiceOffer {
href: string;
/** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. A home e a página da oferta mostram esta lista. */
deliverables: string[];
/** Primeiro passo: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). A página da oferta mostra esta frase; a home, que serve às três, mostra `generalFirstStep`. */
/** Primeiro passo: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). A página da oferta mostra esta frase; a home, que serve a todas, mostra `generalFirstStep`. */
firstStep: string;
/** Onde ver a oferta funcionando: o texto que abre a linha, os cases (links) e uma observação opcional. */
proof: { label: string; caseIds: string[]; note?: string };
@@ -929,12 +1083,13 @@ export interface ServiceOffer {
/** O que vem depois da primeira conversa: o mesmo em cada oferta e na frase geral da home. */
const FIRST_STEP_NEXT = "Depois, escopo e proposta por escrito.";
/** O primeiro passo da home, que vale para as três ofertas: igual ao de cada uma, só sem dizer o que se entende na conversa. */
/** O primeiro passo da home, que vale para todas as ofertas: igual ao de cada uma, só sem dizer o que se entende na conversa. */
export const generalFirstStep = `Primeira conversa para entender o seu caso. ${FIRST_STEP_NEXT}`;
/**
* As três ofertas da home, na ordem do H1, com o mesmo nome. Cada texto só diz o que os cases citados, a experiência
* profissional ou a página /ia já sustentam; sem prazo nem preço.
* As cinco ofertas, com o mesmo nome em todo o site; a home mostra as quatro primeiras em cartões e a da nota fiscal numa
* faixa datada. Cada texto só diz o que os cases citados, a experiência profissional, a página /ia ou a página da oferta
* (com as fontes oficiais) já sustentam; sem prazo nem preço.
*/
export const serviceOffers: ServiceOffer[] = [
{
@@ -974,6 +1129,25 @@ export const serviceOffers: ServiceOffer[] = [
note: "Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile para rotinas internas, APIs e painéis de operação. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
},
},
{
id: "whatsapp",
...offerLabels.whatsapp,
summary:
"Um atendente automático que responde só com o que está no seu catálogo e na sua tabela de preços, chama uma pessoa quando não sabe e guarda o registro de cada conversa.",
href: "/servicos/atendimento-whatsapp-ia",
deliverables: [
"Respostas limitadas ao seu catálogo, aos seus preços e aos seus horários.",
"Passagem para uma pessoa da sua equipe na mesma conversa, quando o atendente não sabe responder.",
"Registro de cada conversa e do que foi informado ao cliente.",
"Ligação pela API oficial do WhatsApp para empresas.",
],
firstStep: `Primeira conversa para entender o atendimento de hoje. ${FIRST_STEP_NEXT}`,
proof: {
label: "Veja como funciona",
caseIds: [],
note: "Ainda não há case público deste serviço. A página do serviço mostra o que o atendente pode e não pode responder e as regras do WhatsApp para empresas.",
},
},
{
id: "revisao",
...offerLabels.revisao,
@@ -990,7 +1164,26 @@ export const serviceOffers: ServiceOffer[] = [
proof: {
label: "Veja como funciona",
caseIds: [],
note: "A página da revisão mostra o que você recebe, três episódios públicos com plataformas de apps feitos com IA e os cuidados com dados pessoais (LGPD).",
note: "A página da revisão mostra o que você recebe, cinco episódios públicos com plataformas de apps feitos com IA e os cuidados com dados pessoais (LGPD).",
},
},
{
id: "fiscal",
...offerLabels.fiscal,
summary:
"Se o seu sistema ou a sua integração emite nota, ajusto os campos de IBS e CBS e testo a emissão antes de o prazo chegar.",
href: "/servicos/nota-fiscal-reforma-tributaria",
deliverables: [
"Levantamento do que o seu sistema emite hoje e por onde a nota sai.",
"Campos de IBS e CBS no leiaute novo da nota.",
"Teste de emissão em ambiente de homologação antes de mudar a produção.",
"Lista do que depende do seu contador, como classificação fiscal e alíquotas.",
],
firstStep: `Primeira conversa para entender como a nota é emitida hoje. ${FIRST_STEP_NEXT}`,
proof: {
label: "Veja as datas",
caseIds: [],
note: "Ainda não há case público deste serviço. A página do serviço traz as datas do Ato Conjunto RFB/CGIBS nº 4/2026, com o link para o texto oficial.",
},
},
];
+4 -1
View File
@@ -1,13 +1,16 @@
export const CV_URL = "/Profile.pdf";
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
/** Rótulo da página /ia no menu, na trilha, na própria página e no rodapé. A oferta, nos cartões e no formulário, continua "Revisão de sistema que já existe". */
export const IA_PAGE_LABEL = "Revisão de app com IA";
/** "Projetos" saiu: duplicava "Cases". A ordem é a da conversa de venda: prova, oferta, método, quem sou, IA. */
export const NAVIGATION_ITEMS = [
{ href: "/cases", label: "Cases" },
{ href: "/servicos", label: "Serviços" },
{ href: "/#process", label: "Como trabalho" },
{ href: "/#about", label: "Sobre" },
{ href: "/ia", label: "IA" },
{ href: "/ia", label: IA_PAGE_LABEL },
] as const;
export const CONTACT_EMAIL = "[email protected]";
+3 -1
View File
@@ -45,14 +45,16 @@ export const SERVICES_SUBJECT = "sites, sistemas e aplicativos";
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
/**
* Assunto de cada oferta da home, para o contato direto de WhatsApp do formulário quando o tipo de projeto já está
* Assunto de cada oferta, para o contato direto de WhatsApp do formulário quando o tipo de projeto já está
* escolhido ("Quero conversar sobre um site que traz pedidos pelo WhatsApp."). Os nomes das ofertas, em minúsculas e com artigo,
* que vêm de `offerLabels` (lib/offer-labels.ts), a mesma fonte do nome da oferta e da página dela.
*/
export const OFFER_SUBJECTS = {
site: offerLabels.site.whatsappSubject,
sistema: offerLabels.sistema.whatsappSubject,
whatsapp: offerLabels.whatsapp.whatsappSubject,
revisao: offerLabels.revisao.whatsappSubject,
fiscal: offerLabels.fiscal.whatsappSubject,
} as const;
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). "O case" concorda com qualquer título: sem ele, saía "o Lavação 047" e "o SEV Moda Íntima". */