feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
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:
1 parent
4671837c44
commit
c7d09d63d4
165 files changed
+7940
-3790
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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")}`;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
@@ -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". */
|
||||
|
||||
Reference in new issue
Block a user