From 9e2d65a9262d9ce41861c8a4d71454692d5000b4 Mon Sep 17 00:00:00 2001
From: Evandro Fonseca Junior
Date: Wed, 14 Oct 2026 19:14:47 -0300
Subject: [PATCH] =?UTF-8?q?fix(home):=20oferta=20leva=20ao=20formul=C3=A1r?=
=?UTF-8?q?io,=20ofertas=20logo=20depois=20da=20vitrine?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
O botão das ofertas rola até o contato com o tipo marcado e não quebra mais as âncoras; ofertas com o que você recebe e primeiro passo, logo depois da vitrine; bloco de IA fora da home; cartão largo da SEV no hero do celular; separador da faixa de clientes só entre itens; favicon de 13 para 1,8 KB; pré-carregamento só no hover e nunca de /#... fora da home; X do menu e cabeçalho opaco; WhatsApp com "o case"; /ia com 3 passos e modelo de relatório ilustrativo; Sobre alinhado ao currículo; CSP Report-Only com os domínios do Ads.
---
README.md | 2 +-
app/components/About.paths.test.tsx | 13 +-
app/components/About.tsx | 4 +-
app/components/ClientStrip.paths.test.tsx | 8 +-
app/components/ClientStrip.test.tsx | 38 +++-
app/components/ClientStrip.tsx | 89 +++++----
.../ContactOfferLink.paths.test.tsx | 189 ++++++++++++++++++
app/components/ContactOfferLink.test.tsx | 132 ++++++++++++
app/components/ContactOfferLink.tsx | 63 +++++-
app/components/EngineeringWithAI.test.tsx | 38 ----
app/components/EngineeringWithAI.tsx | 20 --
.../FloatingWhatsApp.route.test.tsx | 2 +-
app/components/Footer.test.tsx | 34 +++-
app/components/Footer.tsx | 32 +--
app/components/Header.menu.test.tsx | 32 ++-
app/components/Header.paths.test.tsx | 39 +++-
app/components/Header.test.tsx | 2 +-
app/components/Header.tsx | 12 +-
app/components/Hero.paths.test.tsx | 2 +-
app/components/Hero.test.tsx | 89 +++++++--
app/components/Hero.tsx | 35 ++--
app/components/HeroWork.test.tsx | 67 ++++---
app/components/HeroWork.tsx | 68 +++++--
app/components/HomeFaq.tsx | 61 +-----
.../HoverPrefetchLink.home.paths.test.tsx | 127 ++++++++++++
...overPrefetchLink.nopathname.paths.test.tsx | 63 ++++++
app/components/HoverPrefetchLink.test.tsx | 99 +++++++++
app/components/HoverPrefetchLink.tsx | 26 ++-
app/components/Projects.test.tsx | 30 ++-
app/components/Projects.tsx | 8 +-
app/components/Testimonials.paths.test.tsx | 2 +-
app/components/Testimonials.test.tsx | 4 +-
app/components/Testimonials.tsx | 8 +-
app/components/WorkModalities.paths.test.tsx | 110 +++++++---
app/components/WorkModalities.tsx | 155 +++++++++-----
.../cinematic/CinematicHome.jsonld.test.tsx | 53 +++++
.../cinematic/CinematicHome.paths.test.tsx | 9 +-
app/components/cinematic/CinematicHome.tsx | 38 ++--
app/components/contact-subject.ts | 2 +-
app/components/hero-captions.test.ts | 44 ++++
app/components/hero-captions.ts | 31 +++
app/favicon.ico | Bin 13166 -> 1838 bytes
app/ia/page.test.tsx | 100 +++++++--
app/ia/page.tsx | 119 ++++++++++-
app/layout.tsx | 3 +-
lib/next-config.paths.test.ts | 22 ++
lib/next-config.test.ts | 2 +-
lib/proof.ts | 4 +-
lib/whatsapp.test.ts | 4 +-
lib/whatsapp.ts | 4 +-
next.config.ts | 12 +-
51 files changed, 1730 insertions(+), 420 deletions(-)
create mode 100644 app/components/ContactOfferLink.paths.test.tsx
delete mode 100644 app/components/EngineeringWithAI.test.tsx
delete mode 100644 app/components/EngineeringWithAI.tsx
create mode 100644 app/components/HoverPrefetchLink.home.paths.test.tsx
create mode 100644 app/components/HoverPrefetchLink.nopathname.paths.test.tsx
create mode 100644 app/components/cinematic/CinematicHome.jsonld.test.tsx
create mode 100644 app/components/hero-captions.test.ts
create mode 100644 app/components/hero-captions.ts
diff --git a/README.md b/README.md
index e830a44..c6ceec7 100644
--- a/README.md
+++ b/README.md
@@ -112,7 +112,7 @@ A comunicação apresenta Evandro como responsável direto pelo projeto, sem sug
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
-`app/components/EngineeringWithAI.tsx` explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 e links junto de cada afirmação:
+`app/ia/page.tsx` (rota `/ia`) explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 (na seção recolhida "O que casos documentados ensinam") e links junto de cada afirmação:
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
diff --git a/app/components/About.paths.test.tsx b/app/components/About.paths.test.tsx
index 7bc4903..52ee1cd 100644
--- a/app/components/About.paths.test.tsx
+++ b/app/components/About.paths.test.tsx
@@ -117,12 +117,23 @@ describe("About", () => {
"Evandro Fonseca Junior",
);
expect(screen.getByTestId("about-description").textContent).toMatch(
- /^Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia\./,
+ /^Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia\./,
+ );
+ // só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira
+ expect(container.textContent).not.toMatch(
+ /os sistemas internos e a IoT da Apolo/,
);
// a frase da Apolo aparece uma só vez
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
});
+ it("path: the first story card does not reuse the name 'Como eu trabalho' of the process list in the offers section right before", () => {
+ const { container } = render();
+
+ expect(container.textContent).not.toMatch(/Como eu trabalho/);
+ expect(screen.getByText("Acompanhamento")).toBeInTheDocument();
+ });
+
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
const { container } = render();
diff --git a/app/components/About.tsx b/app/components/About.tsx
index 39ff2e9..2b523ce 100644
--- a/app/components/About.tsx
+++ b/app/components/About.tsx
@@ -9,7 +9,7 @@ import { Section } from "./Section";
const STORY_DATA = [
{
- label: "Como eu trabalho",
+ label: "Acompanhamento",
title: "Sem intermediários entre o pedido e o sistema",
content:
"Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.",
@@ -55,7 +55,7 @@ export default function About() {
id="about"
eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior"
- description="Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia. Você acompanha as decisões técnicas ao longo da entrega."
+ description="Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia. Você acompanha as decisões técnicas ao longo da entrega."
disableChildrenAnimation
aside={
diff --git a/app/components/ClientStrip.paths.test.tsx b/app/components/ClientStrip.paths.test.tsx
index f0071c7..dd3086c 100644
--- a/app/components/ClientStrip.paths.test.tsx
+++ b/app/components/ClientStrip.paths.test.tsx
@@ -3,7 +3,7 @@ import ClientStrip from "./ClientStrip";
/**
* Testes de caminhos-base de ClientStrip (complemento de ClientStrip.test.tsx): o separador entre clientes
- * (um cliente, vários) e o className opcional.
+ * (um cliente, vários; só entre itens, nunca no fim de uma linha) e o className opcional.
*/
const clients = [
@@ -20,15 +20,15 @@ describe("ClientStrip", () => {
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
});
- it("path: several clients -> a separator after every client except the last", () => {
+ it("path: several clients -> a separator before every client except the first", () => {
render();
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
- ).toEqual([true, true, false]);
- expect(items[0].querySelector("[aria-hidden='true']")).toHaveTextContent(
+ ).toEqual([false, true, true]);
+ expect(items[1].querySelector("[aria-hidden='true']")).toHaveTextContent(
"·",
);
});
diff --git a/app/components/ClientStrip.test.tsx b/app/components/ClientStrip.test.tsx
index 8f587d1..695f7e7 100644
--- a/app/components/ClientStrip.test.tsx
+++ b/app/components/ClientStrip.test.tsx
@@ -63,12 +63,19 @@ it("gives every client link a touch target of at least 44 px (min-h-11)", () =>
}
});
-it("keeps the separator after the last client when the years follow, and none when they do not", () => {
+it("puts the separator only BETWEEN items: none before the first, one before each of the others (years included)", () => {
const withYears = render();
- const separators = withYears.container.querySelectorAll(
- "li [aria-hidden='true']",
- );
- expect(separators).toHaveLength(clients.length);
+ const items = withYears.container.querySelectorAll("li");
+ expect(items).toHaveLength(clients.length + 1);
+ expect(
+ Array.from(items).map(
+ (item) => item.querySelector("[aria-hidden='true']") !== null,
+ ),
+ ).toEqual([false, true, true]);
+ // nenhum item termina com o ponto: ele sempre abre o item, para a margem negativa escondê-lo no começo de uma linha
+ for (const item of items) {
+ expect(item.lastElementChild).not.toHaveAttribute("aria-hidden");
+ }
withYears.unmount();
// zero anos não escreve um "0" solto nem um ponto a mais
@@ -79,3 +86,24 @@ it("keeps the separator after the last client when the years follow, and none wh
expect(zero.container).not.toHaveTextContent(/anos de experiência/);
expect(screen.queryByText("0")).toBeNull();
});
+
+it("hides the separator of an item that wraps to the next line: negative margin on every item but the first, and the list clips the overflow on the x axis", () => {
+ const { container } = render();
+
+ const list = container.querySelector("ul") as HTMLElement;
+ expect(list).toHaveClass("flex-wrap", "overflow-x-clip");
+ const items = Array.from(list.querySelectorAll("li"));
+ expect(items[0]).not.toHaveClass("-ml-4");
+ for (const item of items.slice(1)) {
+ expect(item).toHaveClass("-ml-4");
+ // o ponto ocupa os mesmos 16 px que a margem negativa
+ expect(item.querySelector("[aria-hidden='true']")).toHaveClass("w-4");
+ }
+ for (const item of items) expect(item).toHaveClass("pr-4");
+});
+
+it("keeps the years number in the display font (the mono font is not downloaded above the fold)", () => {
+ render();
+
+ expect(screen.getByText("5").className).not.toContain("font-mono");
+});
diff --git a/app/components/ClientStrip.tsx b/app/components/ClientStrip.tsx
index fcde784..426ce95 100644
--- a/app/components/ClientStrip.tsx
+++ b/app/components/ClientStrip.tsx
@@ -6,6 +6,13 @@ export type ClientLink = { id: string; name: string };
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
+ *
+ * O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
+ * (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
+ * anterior, e é nesses 16 px que o ponto (`w-4`) fica. Quando a lista quebra e um item abre a linha seguinte, o
+ * ponto dele cai na margem à esquerda da lista e o `overflow-x: clip` o esconde: nenhuma linha termina nem começa
+ * com "·". O recuo de 4 px da lista (`-ml-1 pl-1`) só dá espaço ao contorno de foco do primeiro link (2 px de
+ * contorno e 2 px de afastamento); o ponto, centrado na margem, fica 6 px além dele.
*/
export default function ClientStrip({
clients = [],
@@ -19,44 +26,58 @@ export default function ClientStrip({
}) {
if (clients.length === 0 && !years) return null;
+ const items = [
+ ...clients.map((client) => ({
+ key: client.id,
+ node: (
+
+ {client.name}
+
+ ),
+ })),
+ ...(years
+ ? [
+ {
+ key: "years",
+ node: (
+
+
+ {years}
+ {" "}
+ anos de experiência
+
+ ),
+ },
+ ]
+ : []),
+ ];
+
return (
- {clients.length > 0 && (
- <>
-
Já entregue para:
-
- {clients.map((client, index) => (
-
-
- {client.name}
-
- {/* o ponto fica no fim do item (e do último cliente, quando vêm os anos depois): numa quebra de linha ele nunca abre a linha seguinte */}
- {(index < clients.length - 1 || Boolean(years)) && (
-
- ·
-
- )}
-
);
}
diff --git a/app/components/ContactOfferLink.paths.test.tsx b/app/components/ContactOfferLink.paths.test.tsx
new file mode 100644
index 0000000..d3a26f0
--- /dev/null
+++ b/app/components/ContactOfferLink.paths.test.tsx
@@ -0,0 +1,189 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import ContactOfferLink from "./ContactOfferLink";
+import { focusFragment, onFragmentClick } from "./cinematic/fragment-nav";
+import { CONTACT_KIND_EVENT } from "./contact-subject";
+
+/**
+ * Testes de caminhos-base de ContactOfferLink (complemento de ContactOfferLink.test.tsx).
+ *
+ * onClick (CC 6): fora da home | sem #contact | clique já tratado pelo gerenciador de âncoras | tecla modificadora ou
+ * botão que não é o principal | rolagem suave | rolagem em salto (movimento reduzido). Os seis caminhos já estão
+ * em ContactOfferLink.test.tsx; aqui entram o que faltava:
+ * - o gerenciador de âncoras REAL (fragment-nav.onFragmentClick) tratando o clique, em vez de um ouvinte avulso;
+ * - Alt como tecla modificadora;
+ * - showContactInAddress (CC 2): o endereço já é /#contact, então não há replaceState; e o `history.state` do roteador
+ * do Next sobrevive ao replaceState.
+ */
+
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
+const scrollIntoView = jest.fn();
+const scrollTo = jest.fn();
+let form: HTMLElement;
+let replaceState: jest.SpyInstance;
+const originalMatchMedia = window.matchMedia;
+const originalScrollTo = window.scrollTo;
+
+beforeEach(() => {
+ scrollIntoView.mockReset();
+ scrollTo.mockReset();
+ HTMLElement.prototype.scrollIntoView = scrollIntoView;
+ window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ window.history.replaceState(null, "", "/");
+ replaceState = jest.spyOn(window.history, "replaceState");
+ form = document.createElement("section");
+ form.id = "contact";
+ document.body.append(form);
+});
+
+afterEach(() => {
+ replaceState.mockRestore();
+ form.remove();
+ window.matchMedia = originalMatchMedia;
+ window.scrollTo = originalScrollTo;
+ window.history.replaceState(null, "", "/");
+});
+
+/** O que useCinematicScroll faz com um clique numa âncora: cancela, empurra o hash, rola e dá o foco. */
+function installAnchorManager() {
+ return onFragmentClick((hit, event) => {
+ event.preventDefault();
+ if (window.location.hash !== hit.hash)
+ window.history.pushState(null, "", hit.hash);
+ window.scrollTo({ top: 1234, behavior: "smooth" });
+ focusFragment(hit.target);
+ });
+}
+
+describe("ContactOfferLink: gerenciador de âncoras real", () => {
+ it("path: the manager takes the click -> its scroll is the only one, the offer cleans the old ?tipo= and still tells the form", () => {
+ window.history.replaceState(null, "", "/?tipo=site");
+ replaceState.mockClear();
+ const heard: unknown[] = [];
+ const listener = (event: Event) =>
+ heard.push((event as CustomEvent).detail);
+ window.addEventListener(CONTACT_KIND_EVENT, listener);
+ const stop = installAnchorManager();
+ try {
+ render(Começar);
+ const entries = window.history.length;
+
+ expect(
+ fireEvent.click(screen.getByRole("link", { name: "Começar" })),
+ ).toBe(false);
+
+ expect(scrollTo).toHaveBeenCalledTimes(1);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ expect(form).toHaveFocus();
+ expect(heard).toEqual(["sistema"]);
+ // o gerenciador abriu uma entrada de histórico (/?tipo=site#contact); a oferta só a reescreve
+ expect(window.history.length).toBe(entries + 1);
+ expect(window.location.search).toBe("");
+ expect(window.location.hash).toBe("#contact");
+ expect(replaceState).toHaveBeenCalledTimes(1);
+ } finally {
+ stop();
+ window.removeEventListener(CONTACT_KIND_EVENT, listener);
+ }
+ });
+
+ it("path: the manager takes the click and the address is already /#contact -> no replaceState at all", () => {
+ window.history.replaceState(null, "", "/#contact");
+ replaceState.mockClear();
+ const stop = installAnchorManager();
+ try {
+ render(Começar);
+
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }));
+
+ expect(scrollTo).toHaveBeenCalledTimes(1);
+ expect(replaceState).not.toHaveBeenCalled();
+ expect(window.location.pathname + window.location.hash).toBe("/#contact");
+ } finally {
+ stop();
+ }
+ });
+
+ it("path: no manager mounted -> the offer scrolls and focuses by itself, and a second click scrolls again", () => {
+ render(Começar);
+ const link = screen.getByRole("link", { name: "Começar" });
+
+ expect(fireEvent.click(link)).toBe(false);
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(form).toHaveFocus();
+ expect(window.location.hash).toBe("#contact");
+
+ // o endereço já é /#contact (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço
+ (document.activeElement as HTMLElement).blur();
+ replaceState.mockClear();
+ expect(fireEvent.click(link)).toBe(false);
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
+ expect(form).toHaveFocus();
+ expect(replaceState).not.toHaveBeenCalled();
+ });
+});
+
+describe("ContactOfferLink: teclas e histórico", () => {
+ it("path: Alt held down -> not taken over (the browser keeps its own behavior)", () => {
+ render(Começar);
+
+ expect(
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
+ altKey: true,
+ }),
+ ).toBe(true);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ expect(replaceState).not.toHaveBeenCalled();
+ expect(form).not.toHaveFocus();
+ });
+
+ it("path: a modified click still tells the form which type was chosen (the form may be in the same tab)", () => {
+ const heard: unknown[] = [];
+ const listener = (event: Event) =>
+ heard.push((event as CustomEvent).detail);
+ window.addEventListener(CONTACT_KIND_EVENT, listener);
+ try {
+ render(Começar);
+
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
+ ctrlKey: true,
+ });
+
+ expect(heard).toEqual(["revisao"]);
+ } finally {
+ window.removeEventListener(CONTACT_KIND_EVENT, listener);
+ }
+ });
+
+ it("path: replaceState keeps the history.state of the router (the Next router stores its tree there)", () => {
+ const routerState = { __NA: true, tree: ["", {}] };
+ window.history.replaceState(routerState, "", "/?tipo=site#contact");
+ replaceState.mockClear();
+ render(Começar);
+
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }));
+
+ expect(replaceState).toHaveBeenCalledWith(routerState, "", "/#contact");
+ expect(window.history.state).toEqual(routerState);
+ });
+});
diff --git a/app/components/ContactOfferLink.test.tsx b/app/components/ContactOfferLink.test.tsx
index c1bf65f..0a64754 100644
--- a/app/components/ContactOfferLink.test.tsx
+++ b/app/components/ContactOfferLink.test.tsx
@@ -67,3 +67,135 @@ describe("ContactOfferLink", () => {
}
});
});
+
+describe("ContactOfferLink: rolagem até o formulário", () => {
+ const scrollIntoView = jest.fn();
+ const original = window.matchMedia;
+ let form: HTMLElement;
+
+ const setReducedMotion = (reduced: boolean) => {
+ window.matchMedia = jest.fn().mockImplementation((query: string) => ({
+ matches: reduced && query === "(prefers-reduced-motion: reduce)",
+ media: query,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ }));
+ };
+
+ beforeEach(() => {
+ scrollIntoView.mockReset();
+ HTMLElement.prototype.scrollIntoView = scrollIntoView;
+ window.history.replaceState(null, "", "/");
+ setReducedMotion(false);
+ form = document.createElement("section");
+ form.id = "contact";
+ document.body.append(form);
+ });
+
+ afterEach(() => {
+ form.remove();
+ window.matchMedia = original;
+ window.history.replaceState(null, "", "/");
+ });
+
+ it("cancels the navigation on the home, rolls to #contact, focuses the form and leaves the address as /#contact", () => {
+ const heard: unknown[] = [];
+ const listener = (event: Event) =>
+ heard.push((event as CustomEvent).detail);
+ window.addEventListener(CONTACT_KIND_EVENT, listener);
+ const pushState = jest.spyOn(window.history, "pushState");
+ try {
+ render(
+ Começar pelo site,
+ );
+ const link = screen.getByRole("link", { name: "Começar pelo site" });
+
+ // fireEvent devolve false quando alguém chamou preventDefault: o Link do Next não navega
+ expect(fireEvent.click(link)).toBe(false);
+ expect(heard).toEqual(["site"]);
+ expect(scrollIntoView).toHaveBeenCalledTimes(1);
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ behavior: "smooth",
+ block: "start",
+ });
+ expect(form).toHaveFocus();
+ expect(window.location.pathname + window.location.search).toBe("/");
+ expect(window.location.hash).toBe("#contact");
+ expect(pushState).not.toHaveBeenCalled();
+ } finally {
+ pushState.mockRestore();
+ window.removeEventListener(CONTACT_KIND_EVENT, listener);
+ }
+ });
+
+ it("jumps without animation when the visitor asks for reduced motion", () => {
+ setReducedMotion(true);
+ render(Começar);
+
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }));
+ expect(scrollIntoView).toHaveBeenCalledWith({
+ behavior: "instant",
+ block: "start",
+ });
+ });
+
+ it("drops the ?tipo= left by an earlier offer or by the arrival from another page, and keeps the other parameters", () => {
+ window.history.replaceState(null, "", "/?utm_source=ads&tipo=site#contact");
+ render(Começar);
+
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }));
+ expect(window.location.search).toBe("?utm_source=ads");
+ expect(window.location.hash).toBe("#contact");
+ });
+
+ it("leaves the click alone when the scroll manager already took it (no second scroll), still cleaning the address", () => {
+ window.history.replaceState(null, "", "/?tipo=site#contact");
+ const cancel = (event: Event) => event.preventDefault();
+ document.addEventListener("click", cancel, true);
+ try {
+ render(Começar);
+
+ fireEvent.click(screen.getByRole("link", { name: "Começar" }));
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ expect(window.location.search).toBe("");
+ expect(window.location.hash).toBe("#contact");
+ } finally {
+ document.removeEventListener("click", cancel, true);
+ }
+ });
+
+ it("does not take over a click with a modifier key (new tab) or the middle button", () => {
+ render(Começar);
+ const link = screen.getByRole("link", { name: "Começar" });
+
+ expect(fireEvent.click(link, { ctrlKey: true })).toBe(true);
+ expect(fireEvent.click(link, { metaKey: true })).toBe(true);
+ expect(fireEvent.click(link, { shiftKey: true })).toBe(true);
+ expect(fireEvent.click(link, { button: 1 })).toBe(true);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ expect(window.location.hash).toBe("");
+ });
+
+ it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact", () => {
+ window.history.replaceState(null, "", "/privacidade");
+ render(Começar);
+ const link = screen.getByRole("link", { name: "Começar" });
+
+ expect(link).toHaveAttribute("href", "/?tipo=revisao#contact");
+ expect(fireEvent.click(link)).toBe(true);
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ expect(window.location.pathname).toBe("/privacidade");
+ expect(window.location.hash).toBe("");
+ });
+
+ it("falls back to the Link navigation when the page has no #contact", () => {
+ form.remove();
+ render(Começar);
+
+ expect(fireEvent.click(screen.getByRole("link", { name: "Começar" }))).toBe(
+ true,
+ );
+ expect(scrollIntoView).not.toHaveBeenCalled();
+ document.body.append(form);
+ });
+});
diff --git a/app/components/ContactOfferLink.tsx b/app/components/ContactOfferLink.tsx
index 0a8aecd..f925fea 100644
--- a/app/components/ContactOfferLink.tsx
+++ b/app/components/ContactOfferLink.tsx
@@ -1,17 +1,46 @@
"use client";
-import type { ReactNode } from "react";
+import type { MouseEvent, ReactNode } from "react";
+import { prefersReducedMotion } from "@/lib/device";
+import {
+ elementForHash,
+ focusFragment,
+ isPlainPrimaryClick,
+} from "./cinematic/fragment-nav";
import {
CONTACT_KIND_EVENT,
+ CONTACT_KIND_PARAM,
type ContactKind,
contactKindHref,
} from "./contact-subject";
import HoverPrefetchLink from "./HoverPrefetchLink";
+const CONTACT_HASH = "#contact";
+
+/**
+ * Deixa o endereço da home como o do link do cabeçalho (`/#contact`): com o fragmento do formulário e sem o `?tipo=`
+ * que uma oferta anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não
+ * uma navegação do Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
+ */
+function showContactInAddress() {
+ const url = new URL(window.location.href);
+ url.searchParams.delete(CONTACT_KIND_PARAM);
+ url.hash = CONTACT_HASH;
+ const next = `${url.pathname}${url.search}${url.hash}`;
+ const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+ if (next !== current)
+ window.history.replaceState(window.history.state, "", next);
+}
+
/**
* Botão de uma oferta da home: leva ao formulário de contato com o tipo de projeto já escolhido.
- * Na própria home o clique rola até o formulário (useCinematicScroll) sem trocar a busca do endereço, então o
- * tipo também segue por evento; o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
+ * Na própria home o tipo segue por evento e a página rola até `#contact` (sem navegar pelo Next); o `href` com
+ * `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
+ *
+ * O normal é o gerenciador de âncoras (fragment-nav + useCinematicScroll) tratar o clique antes deste código, do
+ * mesmo jeito que trata o link do cabeçalho: rola (com movimento reduzido, em salto), dá o foco ao formulário e
+ * cancela o clique. Quando ele não o trata (ainda não montou, por exemplo), este botão cancela a navegação, rola e dá
+ * o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact`.
*/
export default function ContactOfferLink({
kind,
@@ -22,15 +51,33 @@ export default function ContactOfferLink({
className?: string;
children: ReactNode;
}) {
+ const onClick = (event: MouseEvent) => {
+ window.dispatchEvent(
+ new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }),
+ );
+ if (window.location.pathname !== "/") return;
+ const form = elementForHash(CONTACT_HASH);
+ if (!form) return;
+ if (event.defaultPrevented) {
+ showContactInAddress();
+ return;
+ }
+ // aba nova, clique com tecla modificadora ou botão do meio: o navegador cuida, com o `?tipo=` do href
+ if (!isPlainPrimaryClick(event.nativeEvent)) return;
+ event.preventDefault();
+ showContactInAddress();
+ form.scrollIntoView({
+ behavior: prefersReducedMotion() ? "instant" : "smooth",
+ block: "start",
+ });
+ focusFragment(form);
+ };
+
return (
- window.dispatchEvent(
- new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }),
- )
- }
+ onClick={onClick}
>
{children}
diff --git a/app/components/EngineeringWithAI.test.tsx b/app/components/EngineeringWithAI.test.tsx
deleted file mode 100644
index 0d88e95..0000000
--- a/app/components/EngineeringWithAI.test.tsx
+++ /dev/null
@@ -1,38 +0,0 @@
-import { render, screen } from "@testing-library/react";
-import type { ReactNode } from "react";
-import EngineeringWithAI from "./EngineeringWithAI";
-
-jest.mock("./Section", () => ({
- Section: ({
- children,
- title,
- description,
- }: {
- children: ReactNode;
- title: string;
- description?: string;
- }) => (
-
-
{title}
-
{description}
- {children}
-
- ),
-}));
-
-it("is the short home version and points to /ia for the full content", () => {
- render();
-
- expect(
- screen.getByRole("heading", {
- name: "Fez um app com IA? Eu reviso.",
- }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("link", { name: "Ver a revisão de app feito com IA" }),
- ).toHaveAttribute("href", "/ia");
- // a oferta não promete prazo nem preço
- expect(screen.queryByText(/R\$|prazo de|dias/)).toBeNull();
- // os incidentes e a LGPD ficam só em /ia
- expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull();
-});
diff --git a/app/components/EngineeringWithAI.tsx b/app/components/EngineeringWithAI.tsx
deleted file mode 100644
index 143b375..0000000
--- a/app/components/EngineeringWithAI.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import Link from "next/link";
-import { secondaryButtonLight } from "./button-styles";
-import { Section } from "./Section";
-
-/** Versão curta da home, perto do fim da página: a oferta de revisão de app feito com IA. O conteúdo completo e os incidentes citados ficam em /ia. */
-export default function EngineeringWithAI() {
- return (
-
-
- Ver a revisão de app feito com IA
-
-
- );
-}
diff --git a/app/components/FloatingWhatsApp.route.test.tsx b/app/components/FloatingWhatsApp.route.test.tsx
index d34d40b..a16aa3e 100644
--- a/app/components/FloatingWhatsApp.route.test.tsx
+++ b/app/components/FloatingWhatsApp.route.test.tsx
@@ -61,7 +61,7 @@ describe("message by route", () => {
],
[
"/cases/sev-modaintima",
- "Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
+ "Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
],
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
// a mesma frase do botão do hero e do cabeçalho de cada página
diff --git a/app/components/Footer.test.tsx b/app/components/Footer.test.tsx
index afe98eb..9bc908a 100644
--- a/app/components/Footer.test.tsx
+++ b/app/components/Footer.test.tsx
@@ -3,13 +3,30 @@ import { OPEN_COOKIE_PREFERENCES_EVENT } from "@/lib/analytics";
import { serviceHubLinks } from "@/lib/site-content";
import Footer from "./Footer";
+// o Link do Next, trocado por um que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
describe("Footer contact and navigation", () => {
it("offers a direct mailto link without repeating the contact call to action", () => {
render();
// o convite principal fica na secao Contato, logo acima do rodape
expect(
- screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
+ screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
const emailLink = screen.getByRole("link", {
@@ -125,4 +142,19 @@ describe("Footer social links", () => {
expect(instagram).toHaveAttribute("target", "_blank");
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
+
+ it("pre-fetches the internal links only when the visitor reaches them, never by viewport (services, cases, /ia, privacy, résumé, home)", () => {
+ render();
+
+ const internal = screen
+ .getAllByRole("link")
+ .filter((link) => (link.getAttribute("href") ?? "").startsWith("/"));
+ // serviços do hub + Cases, /ia, Privacidade, Currículo + o logo para /#home
+ expect(internal.length).toBeGreaterThanOrEqual(serviceHubLinks.length + 5);
+ for (const link of internal) {
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link);
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ }
+ });
});
diff --git a/app/components/Footer.tsx b/app/components/Footer.tsx
index 6296585..5336fa3 100644
--- a/app/components/Footer.tsx
+++ b/app/components/Footer.tsx
@@ -1,6 +1,5 @@
"use client";
-import Link from "next/link";
import {
openCookiePreferences,
trackEmailClick,
@@ -13,6 +12,7 @@ import {
type ServiceLink,
} from "@/lib/site-navigation";
import "./conversion.css";
+import HoverPrefetchLink from "./HoverPrefetchLink";
import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
@@ -92,9 +92,12 @@ export default function Footer({
{serviceLinks.map((service) => (
-
+
{service.label}
-
+
))}
@@ -106,19 +109,22 @@ export default function Footer({
+ Usamos métricas de navegação do Google só se você permitir.{" "}
+ Política de privacidade
+
@@ -338,31 +342,43 @@ describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
);
}
- it("cycles through Aceitar and Recusar before the header, and back from the contact button", () => {
+ it("cycles through the policy link, Aceitar and Recusar before the header, and back from the contact button", () => {
render();
openMenu();
+ const policy = screen.getByRole("link", {
+ name: "Política de privacidade",
+ });
const accept = screen.getByRole("button", { name: "Aceitar" });
const decline = screen.getByRole("button", { name: "Recusar" });
const order = trapOrder();
const last = order[order.length - 1];
+ expect(policy).not.toHaveAttribute("inert");
expect(accept).not.toHaveAttribute("inert");
- // do botão de contato (o último do cabeçalho) o Tab vai para Aceitar, em vez de dar a volta no cabeçalho
+ // do botão de contato (o último do cabeçalho) o Tab vai para o link da política, em vez de dar a volta no cabeçalho
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
- expect(accept).toHaveFocus();
+ expect(policy).toHaveFocus();
- // entre Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
+ // entre o link, Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
+ expect(fireEvent.keyDown(policy, { key: "Tab" })).toBe(true);
+ act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
act(() => decline.focus());
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
- // Shift+Tab de Aceitar (o primeiro do ciclo) volta ao botão de contato
- act(() => accept.focus());
- expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
+ // Shift+Tab do link da política (o primeiro do ciclo) volta ao botão de contato
+ act(() => policy.focus());
+ expect(fireEvent.keyDown(policy, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
+
+ // e Shift+Tab de Aceitar ou de Recusar não é preso: o navegador anda um controle para trás
+ act(() => accept.focus());
+ expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
+ true,
+ );
});
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
diff --git a/app/components/Header.paths.test.tsx b/app/components/Header.paths.test.tsx
index e996ba3..ce4750d 100644
--- a/app/components/Header.paths.test.tsx
+++ b/app/components/Header.paths.test.tsx
@@ -33,7 +33,7 @@ function setScrollY(value: number) {
}
const header = () => screen.getByRole("banner");
-/** Rolada: borda visível e fundo a 95% (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
+/** Rolada: borda visível e fundo opaco (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
const isDark = () => header().className.includes("border-white/12");
beforeEach(() => {
@@ -79,6 +79,20 @@ describe("Header: rolagem", () => {
expect(isDark()).toBe(false);
});
+ it("path: the background is fully opaque at the top and scrolled (no 95% fill: the titles showed through)", () => {
+ render();
+
+ expect(header().className).toContain("bg-[var(--ink)]");
+ expect(header().className).not.toContain("bg-[var(--ink)]/");
+
+ setScrollY(120);
+ act(() => {
+ window.dispatchEvent(new Event("scroll"));
+ });
+ expect(header().className).toContain("bg-[var(--ink)]");
+ expect(header().className).not.toContain("bg-[var(--ink)]/");
+ });
+
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
setScrollY(500);
render();
@@ -96,6 +110,29 @@ describe("Header: rolagem", () => {
});
});
+describe("Header: ícone do menu", () => {
+ it("path: open -> the outer bars move 10px (2px bar + 8px gap) to the middle and turn into a straight X; closed -> nothing moves", () => {
+ render();
+ const button = screen.getByRole("button", { name: "Abrir menu" });
+ const bars = () =>
+ Array.from(button.querySelectorAll("span")) as HTMLElement[];
+
+ // 2 px de altura + 8 px de espaço entre as linhas = 10 px de centro a centro
+ expect(bars()[0]).toHaveClass("h-0.5");
+ expect(button).toHaveClass("gap-sp-2");
+ for (const bar of bars()) expect(bar.className).not.toMatch(/translate-y/);
+
+ fireEvent.click(button);
+ const open = bars();
+ expect(open).toHaveLength(3);
+ expect(open[0]).toHaveClass("translate-y-[10px]", "rotate-45");
+ expect(open[1]).toHaveClass("opacity-0");
+ expect(open[2]).toHaveClass("-translate-y-[10px]", "-rotate-45");
+ expect(open[0].className).not.toContain("6px");
+ expect(open[2].className).not.toContain("6px");
+ });
+});
+
describe("Header: teclado no menu do celular", () => {
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
render();
diff --git a/app/components/Header.test.tsx b/app/components/Header.test.tsx
index e4988eb..4939588 100644
--- a/app/components/Header.test.tsx
+++ b/app/components/Header.test.tsx
@@ -278,7 +278,7 @@ describe("Header", () => {
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(whatsAppHref(button)).toContain(
- "Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
+ "Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
);
}
diff --git a/app/components/Header.tsx b/app/components/Header.tsx
index 535bca1..8c82c8d 100644
--- a/app/components/Header.tsx
+++ b/app/components/Header.tsx
@@ -21,8 +21,9 @@ const INERT_IDS = ["main-content", "site-footer"];
const DESKTOP_QUERY = "(min-width: 1280px)";
/**
- * Controles que o Tab percorre com o menu aberto: Aceitar e Recusar da barra de cookies, quando ela está aberta (ela
- * vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do menu, links e botão de contato do painel.
+ * Controles que o Tab percorre com o menu aberto: todos os da barra de cookies, quando ela está aberta (o link "Política
+ * de privacidade", Aceitar e Recusar; ela vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do
+ * menu, links e botão de contato do painel.
* A navegação e o botão da barra de desktop ficam de fora (data-menu-trap-skip): estão escondidos no celular.
*/
function menuFocusables(header: HTMLElement) {
@@ -193,7 +194,7 @@ export function Header({
ref={headerRef}
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
scrolled
- ? "border-white/12 bg-[var(--ink)]/95"
+ ? "border-white/12 bg-[var(--ink)]"
: "border-transparent bg-[var(--ink)]"
}`}
>
@@ -239,19 +240,20 @@ export function Header({
ref={menuButtonRef}
type="button"
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
+ // as linhas têm 2 px (h-0.5) e 8 px de espaço (gap-sp-2): de uma a outra são 10 px, e as de cima e de baixo andam 10 px até o meio, para o X fechar sem torcer
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
>
diff --git a/app/components/Hero.paths.test.tsx b/app/components/Hero.paths.test.tsx
index f2f779a..f6d810e 100644
--- a/app/components/Hero.paths.test.tsx
+++ b/app/components/Hero.paths.test.tsx
@@ -125,7 +125,7 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
render();
expect(
- screen.getByRole("link", { name: /Conversar sobre meu projeto/ }),
+ screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
).toHaveAttribute("id", "hero-contact-cta");
});
});
diff --git a/app/components/Hero.test.tsx b/app/components/Hero.test.tsx
index ab826c4..7ca8e26 100644
--- a/app/components/Hero.test.tsx
+++ b/app/components/Hero.test.tsx
@@ -1,6 +1,24 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import Hero from "./Hero";
+import { HERO_CAPTIONS } from "./hero-captions";
+
+// o Link do Next, trocado por um que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
@@ -22,9 +40,9 @@ describe("Hero", () => {
render();
const contactLink = screen.getByRole("link", {
- name: /conversar/i,
+ name: /falar sobre meu projeto/i,
});
- expect(contactLink).toHaveAccessibleName("Conversar sobre meu projeto");
+ expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(contactLink.getAttribute("href")).toMatch(
/^https:\/\/wa\.me\/\d+\?/,
);
@@ -88,13 +106,12 @@ describe("Hero", () => {
expect(
screen.getByText(
- "Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala direto com quem desenvolve.",
+ "Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
),
).toBeInTheDocument();
- expect(screen.getByRole("link", { name: /conversar/i })).toHaveAttribute(
- "id",
- "hero-contact-cta",
- );
+ expect(
+ screen.getByRole("link", { name: /falar sobre meu projeto/i }),
+ ).toHaveAttribute("id", "hero-contact-cta");
});
it("shows only the years of experience, with no repository numbers", () => {
@@ -142,18 +159,64 @@ describe("Hero", () => {
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
});
- it("shows a small phone frame with the mobile poster of SEV below the buttons on small screens", () => {
+ it("shows a wide card to the SEV case below the buttons on small screens", () => {
render();
const small = screen.getByTestId("hero-work-mobile");
expect(small).toHaveClass("lg:hidden");
- const image = small.querySelector("img") as HTMLImageElement;
- expect(image).toHaveAccessibleName(
- "Site da SEV Moda Íntima aberto no celular",
- );
+ expect(small).toHaveAttribute("href", "/cases/sev-modaintima");
+ expect(small).toHaveAccessibleName("Site da SEV Moda Íntima Ver case");
const buttons = screen.getByRole("link", { name: /ver projetos/i });
expect(
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
+
+ it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
+ render();
+
+ const label = screen.getByText(
+ /Evandro Fonseca Junior · Engenharia de software/,
+ );
+ expect(label.className).toContain("font-display");
+ expect(label.className).not.toContain("font-mono");
+ });
+
+ it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelector(".font-mono")).toBeNull();
+ });
+
+ it("pre-fetches 'Ver projetos entregues', the clients and the SEV card only when reached (hover, focus or touch), never by viewport", () => {
+ render();
+
+ const links = [
+ screen.getByRole("link", { name: /ver projetos/i }),
+ screen.getByRole("link", { name: "SEV Moda Íntima" }),
+ screen.getByTestId("hero-work-mobile"),
+ ];
+ for (const link of links) {
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link);
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ }
+ });
+
+ it("writes the range of each dive caption (--from and --to, from hero-captions.ts) on its own item, in order", () => {
+ const { container } = render();
+
+ const items = Array.from(
+ container.querySelectorAll(".hero-captions li"),
+ );
+ expect(items).toHaveLength(HERO_CAPTIONS.length);
+ items.forEach((item, index) => {
+ const { title, text, from, to } = HERO_CAPTIONS[index];
+ expect(item).toHaveTextContent(`${title}${text}`);
+ expect(item.style.getPropertyValue("--from")).toBe(String(from));
+ expect(item.style.getPropertyValue("--to")).toBe(String(to));
+ });
+ });
});
diff --git a/app/components/Hero.tsx b/app/components/Hero.tsx
index e50e985..9b19673 100644
--- a/app/components/Hero.tsx
+++ b/app/components/Hero.tsx
@@ -1,6 +1,5 @@
"use client";
-import Link from "next/link";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
@@ -9,6 +8,8 @@ import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
+import HoverPrefetchLink from "./HoverPrefetchLink";
+import { HERO_CAPTIONS } from "./hero-captions";
import WhatsAppLink from "./WhatsAppLink";
const SOCIAL_LINKS = [
@@ -45,16 +46,6 @@ const TITLE =
accent: ACCENT.has(index),
}));
-/** O que cada camada da cena significa, em linguagem simples. */
-const CAPTIONS = [
- ["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
- [
- "As integrações",
- "Os programas que você já usa conversando entre si, sem retrabalho.",
- ],
- ["Os dados", "Suas informações guardadas, organizadas e seguras."],
-] as const;
-
export default function Hero({
stats,
clients,
@@ -76,8 +67,13 @@ export default function Hero({
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
- {CAPTIONS.map(([title, text], index) => (
-
@@ -86,7 +82,8 @@ export default function Hero({
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
-
+ {/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
+
Evandro Fonseca Junior · Engenharia de software
@@ -106,7 +103,7 @@ export default function Hero({
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
- fala direto com quem desenvolve.
+ fala com quem desenvolve.
@@ -117,7 +114,7 @@ export default function Hero({
placement="hero"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
- Conversar sobre meu projeto
+ Falar sobre meu projeto
-
Ver projetos entregues
-
+
Role para mergulhar
diff --git a/app/components/HeroWork.test.tsx b/app/components/HeroWork.test.tsx
index 6ca5360..3a3e45e 100644
--- a/app/components/HeroWork.test.tsx
+++ b/app/components/HeroWork.test.tsx
@@ -3,7 +3,7 @@ import HeroWork from "./HeroWork";
/**
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
- * (o título é o LCP), e a moldura de celular pequena para as telas abaixo de 1024 px.
+ * (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
*/
const original = (image: Element) =>
@@ -29,11 +29,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
"(min-width: 1400px) 136px, 10vw",
);
- const small = within(screen.getByTestId("hero-work-mobile")).getByRole(
- "img",
- );
- expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
- expect(small).toHaveAttribute("sizes", "84px");
+ // o cartão do celular usa o pôster largo da SEV, na largura da coluna (cerca de 350 px num celular de 390 px)
+ const small = screen
+ .getByTestId("hero-work-mobile")
+ .querySelector("img") as HTMLImageElement;
+ expect(original(small)).toBe("/videos/sev-modaintima/desktop.webp");
+ expect(small).toHaveAttribute("sizes", "(min-width: 1024px) 1px, 100vw");
});
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
@@ -62,34 +63,52 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
});
});
-describe("HeroWork: moldura de celular pequena", () => {
- it("shows a 180px phone with the SEV mobile poster only below 1024px, while the column stays desktop-only", () => {
+describe("HeroWork: cartão do celular", () => {
+ it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
render();
const column = screen.getByTestId("hero-work");
expect(column).toHaveClass("hidden", "lg:block");
- const small = screen.getByTestId("hero-work-mobile");
- expect(small).toHaveClass("lg:hidden");
- const phone = small.querySelector(".ds-phone") as HTMLElement;
- expect(phone.style.height).toBe("180px");
- expect(phone).toContainElement(within(small).getByRole("img"));
- expect(within(small).getByRole("img")).toHaveAccessibleName(
- "Site da SEV Moda Íntima aberto no celular",
- );
- expect(
- within(small).getByText(/Site da SEV Moda Íntima, como aparece/),
- ).toBeInTheDocument();
+ const card = screen.getByTestId("hero-work-mobile");
+ expect(card).toHaveClass("lg:hidden", "w-full");
+ // sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
+ expect(card.querySelector(".ds-phone")).toBeNull();
+ const imageBox = card.querySelector("img")?.parentElement as HTMLElement;
+ expect(imageBox).toHaveClass("w-full");
+ expect(imageBox.style.height).toBe("72px");
+ expect(card).toHaveTextContent("Site da SEV Moda Íntima");
+ expect(card).toHaveTextContent("Ver case");
});
- it("is not interactive: the frames only show the work", () => {
+ it("is a link to the SEV case, with a touch target of at least 48px and its own focus ring", () => {
+ render();
+
+ const card = screen.getByRole("link", {
+ name: "Site da SEV Moda Íntima Ver case",
+ });
+ expect(card).toBe(screen.getByTestId("hero-work-mobile"));
+ expect(card).toHaveAttribute("href", "/cases/sev-modaintima");
+ expect(card).not.toHaveClass("pointer-events-none");
+ expect(card.className).toContain("focus-visible:outline-2");
+ expect(card.querySelector("span.min-h-12")).not.toBeNull();
+ // a imagem é decorativa: o texto do link já diz o que ela mostra
+ expect(card.querySelector("img")).toHaveAttribute("alt", "");
+ });
+
+ it("draws no other link: the card is the only one in HeroWork", () => {
+ render();
+
+ expect(screen.getAllByRole("link")).toHaveLength(1);
+ });
+
+ it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
render();
- expect(screen.queryByRole("link")).toBeNull();
- expect(screen.getByTestId("hero-work-mobile")).toHaveClass(
- "pointer-events-none",
- );
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
+ expect(
+ within(screen.getByTestId("hero-work")).queryByRole("link"),
+ ).toBeNull();
});
it("passes the address to the browser bar of the desktop frame", () => {
diff --git a/app/components/HeroWork.tsx b/app/components/HeroWork.tsx
index 52c0f39..92504e0 100644
--- a/app/components/HeroWork.tsx
+++ b/app/components/HeroWork.tsx
@@ -1,5 +1,6 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
+import HoverPrefetchLink from "./HoverPrefetchLink";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
@@ -8,24 +9,32 @@ export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "sev-modaintima";
const MOBILE_PROJECT = "as-servicos";
-/** No celular a moldura mostra o site da SEV, o mesmo projeto do navegador ao lado do título no computador. */
+/** No celular o cartão mostra o site da SEV, o mesmo projeto do navegador ao lado do título no computador, e leva ao case. */
const SMALL_SCREEN_PROJECT = "sev-modaintima";
-/** Altura da moldura de celular abaixo dos botões (px); a largura sai da proporção 390x844 do aparelho. */
-const SMALL_PHONE_HEIGHT = 180;
+const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
+const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
+/**
+ * Altura da imagem do cartão de celular (px): a largura é a da coluna (cerca de 350 px num celular de 390 px). Com 72 px
+ * a área da imagem (cerca de 25 mil px²) fica abaixo da do H1 (cerca de 29 mil px² a 390 px): com 96 px ela era maior, e
+ * numa rede lenta a imagem, que chega depois, passava a ser o LCP no lugar do título (medido com rede e CPU limitadas).
+ */
+const SMALL_CARD_IMAGE_HEIGHT = 72;
/**
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da SEV
* Moda Íntima no navegador (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo
- * da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, uma moldura de celular
- * pequena (180 px de altura) com o pôster mobile da SEV, abaixo dos botões.
+ * da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
+ * (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
+ * case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
- * era banda desperdiçada), em `loading="lazy"` e prioridade baixa: o título segue como o maior elemento da dobra (LCP)
- * e a imagem que fica escondida pelo breakpoint (display: none) nem é baixada.
+ * era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
+ * dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
+ * baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
*/
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
- const smallPhone = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
+ const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "desktop");
return (
<>
@@ -64,26 +73,43 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
-
-
+
-
-
- Site da SEV Moda Íntima, como aparece no celular.
-
-
+
+
+
+ Site da {SMALL_SCREEN_TITLE}{" "}
+ Ver case
+
+
+
+
>
);
}
diff --git a/app/components/HomeFaq.tsx b/app/components/HomeFaq.tsx
index 99ea131..16b5d20 100644
--- a/app/components/HomeFaq.tsx
+++ b/app/components/HomeFaq.tsx
@@ -1,12 +1,6 @@
-import Link from "next/link";
-import { textLink } from "./button-styles";
+import { Faq, type FaqEntry } from "./Faq";
-export interface FaqItem {
- question: string;
- /** Resposta vazia = pergunta preparada, que ainda não é exibida. */
- answer: string;
- link?: { href: string; label: string };
-}
+export type FaqItem = FaqEntry;
/**
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
@@ -47,51 +41,10 @@ export const FAQ_ITEMS: FaqItem[] = [
{ question: "Como funciona o pagamento?", answer: "" },
];
-/** Perguntas frequentes da home, antes do contato. Só aparecem as que têm resposta; sem nenhuma, a seção não existe. */
+/**
+ * Perguntas frequentes da home, antes do contato (`#faq`). O desenho é o de `Faq`, o mesmo das páginas de serviço:
+ * só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
+ */
export default function HomeFaq({ items = FAQ_ITEMS }: { items?: FaqItem[] }) {
- const answered = items.filter((item) => item.answer.trim() !== "");
- if (answered.length === 0) return null;
-
- return (
-
-
-
- );
+ return ;
}
diff --git a/app/components/HoverPrefetchLink.home.paths.test.tsx b/app/components/HoverPrefetchLink.home.paths.test.tsx
new file mode 100644
index 0000000..457f88d
--- /dev/null
+++ b/app/components/HoverPrefetchLink.home.paths.test.tsx
@@ -0,0 +1,127 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { usePathname } from "next/navigation";
+import HoverPrefetchLink from "./HoverPrefetchLink";
+
+/**
+ * Testes de caminhos-base do pré-carregamento de âncoras da home em HoverPrefetchLink (complemento de
+ * HoverPrefetchLink.test.tsx).
+ *
+ * isHomeAnchor (CC 3): href em texto (é âncora da home | não é) | href em objeto (caminho "/" e hash | caminho "/" sem
+ * hash | outro caminho). O que faltava: o objeto com caminho "/" e SEM hash, que não é âncora e pré-carrega.
+ * HoverPrefetchLink (CC 5): caminho desconhecido | na home | fora da home com âncora da home | fora da home com outro
+ * link, cada um ativado ou não. Já cobertos; aqui entram as trocas de página com o link já ativado.
+ * O dublê de next/navigation sem `usePathname` está em HoverPrefetchLink.nopathname.paths.test.tsx.
+ */
+
+jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
+const link = () => screen.getByRole("link", { name: "Ir" });
+
+beforeEach(() => {
+ jest.mocked(usePathname).mockReturnValue("/privacidade");
+});
+
+describe("isHomeAnchor, vista de fora da home", () => {
+ it.each([
+ ["objeto com caminho / e sem hash", { pathname: "/" }],
+ ["objeto com caminho / e hash vazio", { pathname: "/", hash: "" }],
+ ["objeto com outro caminho e hash", { pathname: "/cases", hash: "#x" }],
+ ["objeto só com hash (a página aberta)", { hash: "#x" }],
+ ["objeto com caminho / e só a busca", { pathname: "/", query: { a: "b" } }],
+ ])(
+ "path: %s -> não é âncora da home, pré-carrega ao chegar",
+ (_name, href) => {
+ render(Ir);
+
+ fireEvent.mouseEnter(link());
+
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+ },
+ );
+
+ it.each([
+ [
+ "objeto com caminho /, hash e busca",
+ { pathname: "/", hash: "#contact", query: { tipo: "site" } },
+ ],
+ ["objeto com caminho / e hash sem #", { pathname: "/", hash: "contact" }],
+ ["texto com busca e hash", "/?tipo=site#contact"],
+ ["texto com busca de vários parâmetros e hash", "/?a=b&c=d#contact"],
+ ])("path: %s -> é âncora da home, nunca pré-carrega", (_name, href) => {
+ render(Ir);
+
+ fireEvent.mouseEnter(link());
+ fireEvent.focus(link());
+ fireEvent.touchStart(link());
+
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+ });
+
+ it.each([
+ ["texto com busca e sem hash", "/?tipo=site"],
+ ["texto de outra página com hash", "/cases#x"],
+ ["texto de outra página com busca e hash", "/cases?a=b#x"],
+ ["texto com dois caminhos e hash (//host)", "//cdn.example.com/#x"],
+ ])(
+ "path: %s -> não é âncora da home, pré-carrega ao chegar",
+ (_name, href) => {
+ render(Ir);
+
+ fireEvent.mouseEnter(link());
+
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+ },
+ );
+});
+
+describe("HoverPrefetchLink: quando a página aberta muda", () => {
+ it("path: leaves the home anchor, comes back to the home and goes away again -> prefetching follows the page, never the link's earlier state", () => {
+ jest.mocked(usePathname).mockReturnValue("/");
+ const { rerender } = render(
+ Ir,
+ );
+ fireEvent.mouseEnter(link());
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+
+ jest.mocked(usePathname).mockReturnValue("/servicos");
+ rerender(Ir);
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+
+ jest.mocked(usePathname).mockReturnValue("/");
+ rerender(Ir);
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+
+ jest.mocked(usePathname).mockReturnValue("/cases/lavacao-047");
+ rerender(Ir);
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+ });
+
+ it("path: a home anchor never reached from another page, then the visitor reaches the home -> prefetching only starts with the first touch", () => {
+ const { rerender } = render(
+ Ir,
+ );
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+
+ jest.mocked(usePathname).mockReturnValue("/");
+ rerender(Ir);
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+
+ fireEvent.touchStart(link());
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+ });
+});
diff --git a/app/components/HoverPrefetchLink.nopathname.paths.test.tsx b/app/components/HoverPrefetchLink.nopathname.paths.test.tsx
new file mode 100644
index 0000000..5aca7f6
--- /dev/null
+++ b/app/components/HoverPrefetchLink.nopathname.paths.test.tsx
@@ -0,0 +1,63 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import type HoverPrefetchLinkType from "./HoverPrefetchLink";
+
+/**
+ * Caminho-base de HoverPrefetchLink que só existe com next/navigation sem `usePathname` (os testes que trocam o
+ * módulo por um dublê só com `notFound`): o `navigation.usePathname ?? (() => null)` do módulo cai no `null` e
+ * nada muda no pré-carregamento, nem para uma âncora da home vista de outra página.
+ * Arquivo à parte: o módulo é carregado isolado, depois de registrar o dublê, e não pode ter sido importado antes.
+ */
+
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
+function loadWithoutUsePathname() {
+ const React = jest.requireActual("react");
+ let loaded: typeof HoverPrefetchLinkType | undefined;
+ jest.isolateModules(() => {
+ // o mesmo React do teste, para os hooks do componente isolado
+ jest.doMock("react", () => React);
+ jest.doMock("next/navigation", () => ({ notFound: jest.fn() }));
+ loaded = require("./HoverPrefetchLink").default;
+ });
+ jest.dontMock("react");
+ jest.dontMock("next/navigation");
+ if (!loaded) throw new Error("o módulo isolado não carregou");
+ return loaded;
+}
+
+describe("HoverPrefetchLink sem usePathname em next/navigation", () => {
+ it("path: unknown page -> a home anchor waits for the first touch and then prefetches as usual", () => {
+ const Isolated = loadWithoutUsePathname();
+ render(Ir);
+ const link = screen.getByRole("link", { name: "Ir" });
+ expect(link).toHaveAttribute("data-prefetch", "false");
+
+ fireEvent.mouseEnter(link);
+
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ });
+
+ it("path: unknown page -> a link to another page behaves the same", () => {
+ const Isolated = loadWithoutUsePathname();
+ render(Ir);
+ const link = screen.getByRole("link", { name: "Ir" });
+
+ fireEvent.focus(link);
+
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ });
+});
diff --git a/app/components/HoverPrefetchLink.test.tsx b/app/components/HoverPrefetchLink.test.tsx
index 331951e..4a06274 100644
--- a/app/components/HoverPrefetchLink.test.tsx
+++ b/app/components/HoverPrefetchLink.test.tsx
@@ -1,6 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
+import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink";
+jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
+
jest.mock("next/link", () => ({
__esModule: true,
default: ({
@@ -19,6 +22,10 @@ jest.mock("next/link", () => ({
const link = () => screen.getByRole("link", { name: "Cases" });
+beforeEach(() => {
+ jest.mocked(usePathname).mockReturnValue("/");
+});
+
it("does not prefetch when the page opens", () => {
render(Cases);
@@ -79,3 +86,95 @@ it("only prefetches the link that was reached, not its siblings", () => {
"false",
);
});
+
+describe("links para um ponto da home", () => {
+ it.each([["/#contact"], ["/#process"], ["/?tipo=site#contact"]])(
+ "never prefetches %s from another page, even on hover, focus or touch (it would pull the whole home)",
+ (href) => {
+ jest.mocked(usePathname).mockReturnValue("/privacidade");
+ render(Cases);
+
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link());
+ fireEvent.focus(link());
+ fireEvent.touchStart(link());
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+ },
+ );
+
+ it("never prefetches an object href with the home path and a hash from another page", () => {
+ jest.mocked(usePathname).mockReturnValue("/cases");
+ render(
+
+ Cases
+ ,
+ );
+
+ fireEvent.mouseEnter(link());
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+ });
+
+ it("stops prefetching a home anchor that was reached on the home once the visitor moves to another page (the header does not remount)", () => {
+ jest.mocked(usePathname).mockReturnValue("/");
+ const { rerender } = render(
+ Cases,
+ );
+ fireEvent.mouseEnter(link());
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+
+ jest.mocked(usePathname).mockReturnValue("/cases");
+ rerender(Cases);
+ expect(link()).toHaveAttribute("data-prefetch", "false");
+ });
+
+ it("keeps the hover prefetch for a home anchor while on the home itself (it is the same page)", () => {
+ jest.mocked(usePathname).mockReturnValue("/");
+ render(Cases);
+
+ fireEvent.mouseEnter(link());
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+ });
+
+ it("keeps the hover prefetch for a link to another page, and for the home without a hash, from anywhere", () => {
+ jest.mocked(usePathname).mockReturnValue("/privacidade");
+ render(
+ <>
+ Cases
+ Início
+
+ Vídeo
+
+
+ Serviços
+
+ >,
+ );
+
+ for (const name of ["Cases", "Início", "Vídeo", "Serviços"]) {
+ const item = screen.getByRole("link", { name });
+ fireEvent.mouseEnter(item);
+ expect(item).toHaveAttribute("data-prefetch", "null");
+ }
+ });
+
+ it("still runs the caller's handlers on a home anchor seen from another page", () => {
+ jest.mocked(usePathname).mockReturnValue("/privacidade");
+ const onMouseEnter = jest.fn();
+ render(
+
+ Cases
+ ,
+ );
+
+ fireEvent.mouseEnter(link());
+ expect(onMouseEnter).toHaveBeenCalledTimes(1);
+ });
+
+ it("prefetches as usual when the router is unknown (no pathname)", () => {
+ jest.mocked(usePathname).mockReturnValue(null as unknown as string);
+ render(Cases);
+
+ fireEvent.mouseEnter(link());
+ expect(link()).toHaveAttribute("data-prefetch", "null");
+ });
+});
diff --git a/app/components/HoverPrefetchLink.tsx b/app/components/HoverPrefetchLink.tsx
index 5b4649f..fbc5c62 100644
--- a/app/components/HoverPrefetchLink.tsx
+++ b/app/components/HoverPrefetchLink.tsx
@@ -1,13 +1,34 @@
"use client";
import Link from "next/link";
+import * as navigation from "next/navigation";
import { type ComponentProps, useState } from "react";
+type LinkHref = ComponentProps["href"];
+
+/**
+ * O caminho da página aberta. Sem `usePathname` (testes que trocam next/navigation por um dublê só com
+ * `notFound`, por exemplo) vale `null`: sem caminho conhecido, nada muda no pré-carregamento.
+ */
+const usePathname: () => string | null = navigation.usePathname ?? (() => null);
+
+/** Link para um ponto da home: `/#contact` ou `/?tipo=site#contact`. */
+function isHomeAnchor(href: LinkHref) {
+ if (typeof href === "string") return /^\/(\?[^#]*)?#/.test(href);
+ return href.pathname === "/" && Boolean(href.hash);
+}
+
/**
* Link interno que só pré-carrega a rota quando a pessoa chega nele (mouse, teclado ou toque).
* O padrão do Link pré-carrega todo link visível ao abrir a página, e o cabeçalho e a faixa de clientes
* somavam 15 pedidos disputando a rede na abertura. Com `prefetch={false}` o App Router não pré-carrega nem no hover.
* Padrão da documentação do Next: node_modules/next/dist/docs/01-app/02-guides/prefetching.md.
+ *
+ * Fora da home, um link para um ponto dela (`/#contact`) não pré-carrega nem no hover: o pré-carregamento puxaria
+ * a home inteira, com o CSS da vitrine, para quem só passou o mouse (e o navegador avisava "preloaded but not
+ * used"). Na própria home o link leva à página em que a pessoa já está, e o pré-carregamento segue o padrão. O
+ * caminho vem de `usePathname`, e não de uma leitura única no hover: o cabeçalho e o rodapé não remontam entre as
+ * páginas, e um link ativado na home não pode continuar pré-carregando depois que a pessoa sair dela.
*/
export default function HoverPrefetchLink({
onMouseEnter,
@@ -15,12 +36,15 @@ export default function HoverPrefetchLink({
onTouchStart,
...props
}: Omit, "prefetch">) {
+ const pathname = usePathname();
const [active, setActive] = useState(false);
+ const homeAnchorFromElsewhere =
+ pathname !== null && pathname !== "/" && isHomeAnchor(props.href);
return (
{
setActive(true);
onMouseEnter?.(event);
diff --git a/app/components/Projects.test.tsx b/app/components/Projects.test.tsx
index 547ff0b..2a4a72c 100644
--- a/app/components/Projects.test.tsx
+++ b/app/components/Projects.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from "@testing-library/react";
+import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import {
getFeaturedProjects,
@@ -7,6 +7,23 @@ import {
import { isClientProject } from "@/lib/proof";
import Projects from "./Projects";
+// o Link do Next, trocado por um que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
+jest.mock("next/link", () => ({
+ __esModule: true,
+ default: ({
+ children,
+ prefetch,
+ ...props
+ }: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
+ string,
+ unknown
+ >) => (
+
+ {children}
+
+ ),
+}));
+
jest.mock("./Section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
@@ -74,11 +91,20 @@ describe("Projects", () => {
it("ends with a secondary link to all the cases", () => {
render();
- const link = screen.getByRole("link", { name: "Ver todos os cases" });
+ const link = screen.getByRole("link", { name: "Ver os cases" });
expect(link).toHaveAttribute("href", "/cases");
// secundário: contorno, sem o fundo verde-limão do contato
expect(link.className).toContain("border");
expect(link.className).not.toContain("bg-[var(--slash)]");
expect(screen.getAllByRole("link")).toEqual([link]);
});
+
+ it("pre-fetches /cases only when the visitor reaches the link, not when it comes into view", () => {
+ render();
+
+ const link = screen.getByRole("link", { name: "Ver os cases" });
+ expect(link).toHaveAttribute("data-prefetch", "false");
+ fireEvent.mouseEnter(link);
+ expect(link).toHaveAttribute("data-prefetch", "null");
+ });
});
diff --git a/app/components/Projects.tsx b/app/components/Projects.tsx
index 6061ddd..9fab626 100644
--- a/app/components/Projects.tsx
+++ b/app/components/Projects.tsx
@@ -1,8 +1,8 @@
"use client";
-import Link from "next/link";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { secondaryButtonDark } from "./button-styles";
+import HoverPrefetchLink from "./HoverPrefetchLink";
import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel";
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
@@ -27,9 +27,9 @@ export default function Projects({
-
- Ver todos os cases
-
+
+ Ver os cases
+
);
diff --git a/app/components/Testimonials.paths.test.tsx b/app/components/Testimonials.paths.test.tsx
index 4082711..c9d44af 100644
--- a/app/components/Testimonials.paths.test.tsx
+++ b/app/components/Testimonials.paths.test.tsx
@@ -60,7 +60,7 @@ describe("Testimonials", () => {
it("path: a testimonial with a project -> the case link points to that project", () => {
render();
- expect(screen.getByRole("link", { name: "Ver o case" })).toHaveAttribute(
+ expect(screen.getByRole("link", { name: "Ver case" })).toHaveAttribute(
"href",
"/cases/sc-plus",
);
diff --git a/app/components/Testimonials.test.tsx b/app/components/Testimonials.test.tsx
index 4efe4f2..c14aecb 100644
--- a/app/components/Testimonials.test.tsx
+++ b/app/components/Testimonials.test.tsx
@@ -34,7 +34,7 @@ it("shows the quote, who said it and a link to the case", () => {
).toBeInTheDocument();
expect(screen.getByText("Maria Souza")).toBeInTheDocument();
expect(screen.getByText("Sócia, Empresa de Teste")).toBeInTheDocument();
- expect(screen.getByRole("link", { name: "Ver o case" })).toHaveAttribute(
+ expect(screen.getByRole("link", { name: "Ver case" })).toHaveAttribute(
"href",
"/cases/lavacao-047",
);
@@ -44,5 +44,5 @@ it("falls back to initials without a photo and has no case link without a projec
render();
expect(screen.getByText("MS")).toBeInTheDocument();
- expect(screen.queryByRole("link", { name: "Ver o case" })).toBeNull();
+ expect(screen.queryByRole("link", { name: "Ver case" })).toBeNull();
});
diff --git a/app/components/Testimonials.tsx b/app/components/Testimonials.tsx
index 1d3eef1..7932133 100644
--- a/app/components/Testimonials.tsx
+++ b/app/components/Testimonials.tsx
@@ -1,7 +1,7 @@
import Image from "next/image";
-import Link from "next/link";
import type { Testimonial } from "@/lib/proof";
import { textLink } from "./button-styles";
+import HoverPrefetchLink from "./HoverPrefetchLink";
function initials(name: string) {
return name
@@ -67,12 +67,12 @@ export default function Testimonials({
{item.role}, {item.company}
{item.projectId && (
-
- Ver o case
-
+ Ver case
+
)}
diff --git a/app/components/WorkModalities.paths.test.tsx b/app/components/WorkModalities.paths.test.tsx
index fddcebc..ee74e33 100644
--- a/app/components/WorkModalities.paths.test.tsx
+++ b/app/components/WorkModalities.paths.test.tsx
@@ -109,20 +109,29 @@ describe("WorkModalities: as três ofertas", () => {
]);
});
- it("path: the site offer cites the Lavação 047 (catalog or price table, ready message, panel to edit alone)", () => {
+ it("path: the site offer cites the Lavação 047 and delivers a catalog or price table, a ready WhatsApp message, a panel to edit prices and photos and the publication", () => {
const { container } = render();
const [site] = Array.from(container.querySelectorAll("article"));
- expect(site.textContent).toMatch(/Catálogo ou tabela de preços/);
- expect(site.textContent).toMatch(/mensagem pronta/);
- expect(site.textContent).toMatch(/painel para você editar/);
expect(site.textContent).toMatch(/Lavação 047/);
+ expect(
+ within(site)
+ .getAllByRole("listitem")
+ .map((item) => item.textContent),
+ ).toEqual([
+ "Catálogo ou tabela de preços",
+ "Mensagem pronta no WhatsApp",
+ "Painel para editar preços e fotos",
+ "Publicação do site",
+ ]);
});
- it("path: the first two offers show the poster of their case, sized for a third of the row", () => {
+ it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row)", () => {
const { container } = render();
- const [site, system] = Array.from(container.querySelectorAll("article"));
+ const [site, system, review] = Array.from(
+ container.querySelectorAll("article"),
+ );
const siteImage = within(site).getByRole("img");
expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp");
expect(siteImage).toHaveAccessibleName(
@@ -133,27 +142,72 @@ describe("WorkModalities: as três ofertas", () => {
"(min-width: 1024px) 33vw, 100vw",
);
expect(siteImage).toHaveAttribute("loading", "lazy");
- expect(original(within(system).getByRole("img"))).toBe(
- "/videos/sev-modaintima/desktop.webp",
- );
+ // a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
+ expect(within(system).queryByRole("img")).toBeNull();
+ expect(system.querySelector("img")).toBeNull();
+ expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
+ expect(within(review).queryByRole("img")).toBeNull();
});
- it("path: the review offer has a checklist card instead of a poster, with what the review delivers", () => {
+ it("path: the system and the review offers have a card with no photo, only an icon (decorative), and the deliverables below", () => {
const { container } = render();
- const review = Array.from(container.querySelectorAll("article"))[2];
- expect(within(review).queryByRole("img")).toBeNull();
- expect(within(review).getByText("O que você recebe")).toBeInTheDocument();
- for (const item of [
+ const [, system, review] = Array.from(
+ container.querySelectorAll("article"),
+ );
+ for (const card of [system, review]) {
+ const icon = within(card).getByTestId("offer-icon");
+ expect(icon.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
+ expect(icon.textContent).toBe("");
+ }
+ expect(
+ within(system)
+ .getAllByRole("listitem")
+ .map((item) => item.textContent),
+ ).toEqual([
+ "Cadastro de clientes e produtos",
+ "Pedidos e acompanhamento",
+ "Relatórios",
+ "Acesso por perfil",
+ ]);
+ expect(
+ within(review)
+ .getAllByRole("listitem")
+ .map((item) => item.textContent),
+ ).toEqual([
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
- ]) {
- expect(within(review).getByText(item)).toBeInTheDocument();
+ ]);
+ });
+
+ it("path: every card says what you receive, with three or four items, once", () => {
+ const { container } = render();
+
+ for (const card of container.querySelectorAll("article")) {
+ expect(
+ within(card as HTMLElement).getAllByText("O que você recebe"),
+ ).toHaveLength(1);
+ const items = within(card as HTMLElement).getAllByRole("listitem");
+ expect(items.length).toBeGreaterThanOrEqual(3);
+ expect(items.length).toBeLessThanOrEqual(4);
}
});
+ it("path: every card has its first step: a first conversation and, after it, the scope and the proposal in writing", () => {
+ const { container } = render();
+
+ const cards = Array.from(container.querySelectorAll("article"));
+ for (const card of cards) {
+ expect(within(card).getAllByText("Primeiro passo")).toHaveLength(1);
+ expect(card.textContent).toMatch(
+ /Primeira conversa para entender .+\. Depois, escopo e proposta por escrito\./,
+ );
+ }
+ expect(screen.getAllByText("Primeiro passo")).toHaveLength(3);
+ });
+
it("path: each button opens the contact form with the project type already chosen, all three with the same filled style", () => {
const { container } = render();
@@ -175,11 +229,11 @@ describe("WorkModalities: as três ofertas", () => {
const articles = Array.from(container.querySelectorAll("article"));
expect(
- within(articles[0]).getByRole("link", { name: "Ver o case Lavação 047" }),
+ within(articles[0]).getByRole("link", { name: "Ver case Lavação 047" }),
).toHaveAttribute("href", "/cases/lavacao-047");
expect(
within(articles[1]).getByRole("link", {
- name: "Ver o case SEV Moda Íntima",
+ name: "Ver case SEV Moda Íntima",
}),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
@@ -189,18 +243,23 @@ describe("WorkModalities: as três ofertas", () => {
).toHaveAttribute("href", "/ia");
});
- it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel", () => {
+ it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
const { container } = render();
expect(screen.queryByText(/Reconhece algum destes cenários/)).toBeNull();
expect(screen.queryByText(/Planilhas demais/)).toBeNull();
- expect(
- screen.queryByRole("link", { name: "Ver todos os serviços" }),
- ).toBeNull();
- for (const link of screen.getAllByRole("link")) {
- expect(link.getAttribute("href")).not.toMatch(/^\/servicos/);
- }
+ const services = screen.getByRole("link", {
+ name: "Ver todos os serviços",
+ });
+ expect(services).toHaveAttribute("href", "/servicos");
expect(container.querySelectorAll("article")).toHaveLength(3);
+ // o link fica fora dos cartões, depois da grade
+ expect(services.closest("article")).toBeNull();
+ expect(
+ screen
+ .getAllByRole("link")
+ .filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
+ ).toEqual([services]);
});
});
@@ -278,6 +337,5 @@ describe("WorkModalities: sem prometer o que o dono não disse", () => {
expect(container.textContent).not.toMatch(
/R\$|em contrato|prazo de \d|\d+ dias|grátis|gratuit/i,
);
- expect(screen.queryByText("Primeiro passo")).toBeNull();
});
});
diff --git a/app/components/WorkModalities.tsx b/app/components/WorkModalities.tsx
index e9a67f0..96aead7 100644
--- a/app/components/WorkModalities.tsx
+++ b/app/components/WorkModalities.tsx
@@ -1,3 +1,4 @@
+import { LayoutDashboard, type LucideIcon, ShieldCheck } from "lucide-react";
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { primaryButton, textLink } from "./button-styles";
@@ -12,11 +13,17 @@ interface Offer {
id: string;
title: string;
description: string;
+ /** O que a pessoa recebe: de três a quatro itens, só o que o case ou a página /ia citados já sustentam. */
+ deliverables: string[];
+ /** Primeira etapa: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). */
+ firstStep: string;
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
kind: ContactKind;
ctaLabel: string;
- /** Case cujo pôster ilustra a oferta; sem ele, a miniatura é o cartão de checklist. */
- project?: { id: string; title: string };
+ /** Case cujo pôster ilustra a oferta; sem ele, o cartão não tem foto e mostra só o ícone. */
+ poster?: { id: string; title: string };
+ /** Ícone do cartão sem foto. */
+ icon?: LucideIcon;
moreLabel: string;
moreHref: string;
}
@@ -24,17 +31,28 @@ interface Offer {
/**
* As três ofertas da home, na ordem do H1: site que traz pedidos, sistema que tira a operação da planilha e revisão
* de sistema que já existe. Cada texto só diz o que o case ou a página de /ia citados já sustentam; sem prazo nem preço.
+ * Só o site tem foto: a Lavação 047 é um site de catálogo e tabela de preços, o que o texto descreve. O sistema e a
+ * revisão não têm um case que mostre o que descrevem (a vitrine da SEV é uma loja, não um painel), então o cartão
+ * traz só um ícone, e os entregáveis dizem o que a pessoa recebe.
*/
const OFFERS: Offer[] = [
{
id: "site",
title: "Site que traz pedidos pelo WhatsApp",
description:
- "Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho, como na Lavação 047.",
+ "O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
+ deliverables: [
+ "Catálogo ou tabela de preços",
+ "Mensagem pronta no WhatsApp",
+ "Painel para editar preços e fotos",
+ "Publicação do site",
+ ],
+ firstStep:
+ "Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
kind: "site",
ctaLabel: "Começar pelo site",
- project: { id: "lavacao-047", title: "Lavação 047" },
- moreLabel: "Ver o case Lavação 047",
+ poster: { id: "lavacao-047", title: "Lavação 047" },
+ moreLabel: "Ver case Lavação 047",
moreHref: "/cases/lavacao-047",
},
{
@@ -42,32 +60,41 @@ const OFFERS: Offer[] = [
title: "Sistema para tirar a operação da planilha",
description:
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe. Um exemplo é a SEV Moda Íntima, com cadastro de peças, pedidos e estoque por tamanho.",
+ deliverables: [
+ "Cadastro de clientes e produtos",
+ "Pedidos e acompanhamento",
+ "Relatórios",
+ "Acesso por perfil",
+ ],
+ firstStep:
+ "Primeira conversa para entender a operação. Depois, escopo e proposta por escrito.",
kind: "sistema",
ctaLabel: "Começar pelo sistema",
- project: { id: "sev-modaintima", title: "SEV Moda Íntima" },
- moreLabel: "Ver o case SEV Moda Íntima",
+ icon: LayoutDashboard,
+ moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
{
id: "revisao",
title: "Revisão de sistema que já existe, inclusive feito com IA",
description:
- "Avalio o que já existe, testo as falhas de acesso a dados e separo o que dá para aproveitar do que é melhor refazer, antes de o sistema receber mais clientes.",
+ "Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
+ deliverables: [
+ "As falhas de acesso a dados",
+ "O que dá para aproveitar",
+ "O que é melhor refazer",
+ "Um orçamento para pôr em produção",
+ ],
+ firstStep:
+ "Primeira conversa para entender o app e o que ele precisa fazer. Depois, escopo e proposta por escrito.",
kind: "revisao",
ctaLabel: "Começar pela revisão",
+ icon: ShieldCheck,
moreLabel: "Ver como funciona a revisão",
moreHref: "/ia",
},
];
-/** O que a revisão entrega: os mesmos itens da página /ia, em versão curta para a miniatura da oferta. */
-const REVIEW_CHECKLIST = [
- "As falhas de acesso a dados",
- "O que dá para aproveitar",
- "O que é melhor refazer",
- "Um orçamento para pôr em produção",
-];
-
const PROCESS = [
{
title: "Entender a operação",
@@ -91,59 +118,40 @@ const PROCESS = [
},
];
-/** Miniatura da revisão, sem case para mostrar: o que o relatório traz, em um cartão escuro do mesmo tamanho dos pôsteres. */
-function ReviewChecklist() {
+/** Miniatura sem foto: um painel escuro do mesmo tamanho dos pôsteres, só com o ícone da oferta. */
+function OfferIcon({ icon: Icon }: Readonly<{ icon: LucideIcon }>) {
return (
-
-
- O que você recebe
-
-
- {REVIEW_CHECKLIST.map((item) => (
-
-
- {item}
-
- ))}
-
+
+
);
}
-/** Um único estilo de cartão para as três ofertas: miniatura, título com altura mínima, texto, botão e link do case. */
+/** Um único estilo de cartão para as três ofertas: foto ou ícone, título com altura mínima, texto, o que você recebe, primeiro passo, botão e link do case. */
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
- const poster = offer.project
- ? getShowcaseAssets(offer.project.id, "desktop")
+ const poster = offer.poster
+ ? getShowcaseAssets(offer.poster.id, "desktop")
: null;
return (
{/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */}
@@ -152,15 +160,51 @@ function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
{offer.description}
+
+
+ O que você recebe
+
+
+ {offer.deliverables.map((item) => (
+
+
+ {item}
+
+ ))}
+
+
+
+ {/* mt-auto: com cartões de alturas diferentes, o primeiro passo e o botão descem juntos até o pé do cartão */}
+
+
+ Ver todos os serviços
+
+
{/* Lista compacta: o título e a descrição de cada etapa numa linha só, sem a grade de quatro cartões. */}
({ __esModule: true, default: () => null }));
+jest.mock("../Projects", () => ({ __esModule: true, default: () => null }));
+jest.mock("../About", () => ({ __esModule: true, default: () => null }));
+jest.mock("../WorkModalities", () => ({ WorkModalities: () => null }));
+jest.mock("../HomeFaq", () => ({ __esModule: true, default: () => null }));
+jest.mock("../Testimonials", () => ({ __esModule: true, default: () => null }));
+jest.mock("../Contact", () => ({ __esModule: true, default: () => null }));
+jest.mock("./CinematicMain", () => ({
+ __esModule: true,
+ default: ({ children }: { children: React.ReactNode }) => (
+ {children}
+ ),
+}));
+
+function structuredData() {
+ const { container } = render();
+ return Array.from(
+ container.querySelectorAll('script[type="application/ld+json"]'),
+ ).map((script) => JSON.parse(script.textContent ?? ""));
+}
+
+describe("CinematicHome: dados estruturados", () => {
+ it("emits exactly one ld+json script, before the scenes", () => {
+ const { container } = render();
+
+ const scripts = container.querySelectorAll(
+ 'script[type="application/ld+json"]',
+ );
+ expect(scripts).toHaveLength(1);
+ expect(container.firstElementChild).toBe(scripts[0]);
+ });
+
+ it("describes only the business as a ProfessionalService, without repeating what the layout emits", () => {
+ const [data] = structuredData();
+
+ expect(data["@context"]).toBe("https://schema.org");
+ expect(data["@graph"]).toHaveLength(1);
+ const [business] = data["@graph"];
+ expect(business["@type"]).toBe("ProfessionalService");
+ expect(business["@id"]).toBe(SEO_IDS.professionalService);
+ expect(business.founder).toEqual({ "@id": SEO_IDS.person });
+ expect(JSON.stringify(data)).not.toMatch(/"@type":"(WebSite|Person)"/);
+ });
+});
diff --git a/app/components/cinematic/CinematicHome.paths.test.tsx b/app/components/cinematic/CinematicHome.paths.test.tsx
index fb90619..634c196 100644
--- a/app/components/cinematic/CinematicHome.paths.test.tsx
+++ b/app/components/cinematic/CinematicHome.paths.test.tsx
@@ -41,10 +41,6 @@ jest.mock("../WorkModalities", () => ({
return ;
},
}));
-jest.mock("../EngineeringWithAI", () => ({
- __esModule: true,
- default: () => ,
-}));
jest.mock("../HomeFaq", () => ({
__esModule: true,
default: () => ,
@@ -167,7 +163,7 @@ describe("CinematicHome: props e estrutura", () => {
expect(mockModalitiesProps).toHaveBeenCalledWith({});
});
- it("path: scenes sit in order inside (FAQ right before the contact); the home has no footer of its own (it comes from the layout)", () => {
+ it("path: scenes sit in order inside (hero, projects, offers, about, FAQ, contact; no AI block); the home has no footer of its own (it comes from the layout)", () => {
render();
const main = screen.getByRole("main");
@@ -178,9 +174,8 @@ describe("CinematicHome: props e estrutura", () => {
).toEqual([
"scene-hero",
"scene-projects",
- "scene-about",
"scene-modalities",
- "scene-ai",
+ "scene-about",
"scene-testimonials",
"scene-faq",
"scene-contact",
diff --git a/app/components/cinematic/CinematicHome.tsx b/app/components/cinematic/CinematicHome.tsx
index b98e63b..523ff65 100644
--- a/app/components/cinematic/CinematicHome.tsx
+++ b/app/components/cinematic/CinematicHome.tsx
@@ -1,9 +1,10 @@
+import { safeJsonLd } from "@/lib/jsonld";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import type { Testimonial } from "@/lib/proof";
+import { buildProfessionalServiceJsonLd } from "@/lib/seo";
import About from "../About";
import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact";
-import EngineeringWithAI from "../EngineeringWithAI";
import Hero, { type HeroStats } from "../Hero";
import HomeFaq from "../HomeFaq";
import Projects from "../Projects";
@@ -15,9 +16,21 @@ import CinematicMain from "./CinematicMain";
/** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */
const HERO_WORK_PROJECT = "sev-modaintima";
+/**
+ * Dados estruturados da home: o negócio como ProfessionalService (área de atendimento e as três ofertas). Só ele: o
+ * WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. O script fica aqui, e não em
+ * app/page.tsx, para a página continuar devolvendo só a home.
+ */
+const jsonLd = {
+ "@context": "https://schema.org",
+ "@graph": [buildProfessionalServiceJsonLd()],
+};
+
/**
* Cenas da home, em componente de servidor: sem hooks aqui. Os hooks de rolagem e de chegada por fragmento ficam
* na ilha de cliente CinematicMain, que recebe as cenas como filhos.
+ * A ordem é a da conversa de venda: hero, projetos (a prova), ofertas ("Escolha por onde começar"), quem conduz,
+ * perguntas frequentes e contato. A revisão de app feito com IA não tem cena própria: a oferta e a FAQ levam a /ia.
*/
export default function CinematicHome({
projects,
@@ -40,22 +53,23 @@ export default function CinematicHome({
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
return (
-
-
+ <>
+
+
+
-
+
-
+
-
+
-
+
-
+
-
-
-
-
+
+
+ >
);
}
diff --git a/app/components/contact-subject.ts b/app/components/contact-subject.ts
index 60361d8..f1369a7 100644
--- a/app/components/contact-subject.ts
+++ b/app/components/contact-subject.ts
@@ -2,7 +2,7 @@
* Tipos de projeto do formulário de contato (Contact.tsx) e o jeito de as ofertas da home escolherem um deles.
* Uma oferta abre o formulário com o tipo já marcado de dois jeitos: o endereço `/?tipo=#contact` (vale ao
* chegar de outra página ou em um link copiado) e o evento CONTACT_KIND_EVENT (vale na própria home, onde o
- * gerenciador de âncoras rola até o formulário sem recarregar a página nem mudar a busca do endereço).
+ * gerenciador de âncoras rola até o formulário sem recarregar a página, e ContactOfferLink deixa o endereço como `/#contact`).
* O tipo vai na busca, antes do `#`: um `/#contact?tipo=...` faria o fragmento virar "contact?tipo=...",
* que não é o id de nenhum elemento, e a página não rolaria até o formulário.
*/
diff --git a/app/components/hero-captions.test.ts b/app/components/hero-captions.test.ts
new file mode 100644
index 0000000..43d2fce
--- /dev/null
+++ b/app/components/hero-captions.test.ts
@@ -0,0 +1,44 @@
+import { HERO_CAPTIONS } from "./hero-captions";
+
+/*
+ * Sem janela de rolagem sem texto no mergulho do hero: o texto do hero sai de cena e as legendas entram uma depois da
+ * outra (globals.css lê --from e --to de cada item). Aqui ficam as garantias sobre os trechos; o e2e de rolagem
+ * (e2e/scroll.spec.ts) confere, no navegador, que nenhuma posição amostrada fica sem texto.
+ */
+
+/** O texto do hero some em `1 / 4,2` do progresso: `opacity: calc(1 - var(--hero-p) * 4.2)` em globals.css. */
+const HERO_TEXT_GONE = 1 / 4.2;
+/** Maior intervalo sem legenda entre duas: 0,1 de progresso são cerca de 100 px de rolagem. */
+const MAX_GAP = 0.1;
+
+describe("hero dive captions", () => {
+ it("has three captions, each with a valid range, in the order of the scene layers", () => {
+ expect(HERO_CAPTIONS).toHaveLength(3);
+ for (const { from, to } of HERO_CAPTIONS) {
+ expect(from).toBeGreaterThanOrEqual(0);
+ expect(to).toBeGreaterThan(from);
+ }
+ for (let i = 1; i < HERO_CAPTIONS.length; i++) {
+ expect(HERO_CAPTIONS[i].from).toBeGreaterThanOrEqual(
+ HERO_CAPTIONS[i - 1].from,
+ );
+ expect(HERO_CAPTIONS[i].to).toBeGreaterThan(HERO_CAPTIONS[i - 1].to);
+ }
+ });
+
+ it("starts the first caption before the text of the hero is gone", () => {
+ expect(HERO_CAPTIONS[0].from).toBeLessThanOrEqual(HERO_TEXT_GONE);
+ });
+
+ it("leaves a short gap, at most 0.1 of the dive, between one caption and the next", () => {
+ for (let i = 1; i < HERO_CAPTIONS.length; i++) {
+ expect(
+ HERO_CAPTIONS[i].from - HERO_CAPTIONS[i - 1].to,
+ ).toBeLessThanOrEqual(MAX_GAP);
+ }
+ });
+
+ it("keeps the last caption on stage past the end of the dive, until Projects covers it", () => {
+ expect(HERO_CAPTIONS[HERO_CAPTIONS.length - 1].to).toBeGreaterThan(1);
+ });
+});
diff --git a/app/components/hero-captions.ts b/app/components/hero-captions.ts
new file mode 100644
index 0000000..9f17084
--- /dev/null
+++ b/app/components/hero-captions.ts
@@ -0,0 +1,31 @@
+/**
+ * Legendas do mergulho do hero (as camadas da cena 3D, em linguagem simples) e o trecho do progresso do mergulho
+ * (`--hero-p`, de 0 a 1) em que cada uma fica em cena. O Hero as escreve com `--from` e `--to` em cada item, e o CSS
+ * (globals.css, `.hero-captions li`) as liga e desliga por esses dois valores: uma rampa de 1/14 de progresso para
+ * entrar a partir de `from` e para sair até `to`.
+ *
+ * Os trechos são os da câmera (cameraFrames em cinematic/HeroFieldScene.tsx: a câmera enquadra a camada 1 entre
+ * 0,28 e 0,44, a 2 entre 0,55 e 0,69 e a 3 entre 0,78 e 0,89), com folga: cada legenda entra um pouco antes de a
+ * câmera chegar e sai um pouco depois de ela partir. Assim o intervalo sem texto entre uma e outra é de poucos pixels
+ * de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
+ */
+export const HERO_CAPTIONS = [
+ {
+ title: "A tela",
+ text: "O que sua equipe e seus clientes usam no dia a dia.",
+ from: 0.18,
+ to: 0.49,
+ },
+ {
+ title: "As integrações",
+ text: "Os programas que você já usa conversando entre si, sem retrabalho.",
+ from: 0.51,
+ to: 0.75,
+ },
+ {
+ title: "Os dados",
+ text: "Suas informações guardadas, organizadas e seguras.",
+ from: 0.79,
+ to: 1.07,
+ },
+] as const;
diff --git a/app/favicon.ico b/app/favicon.ico
index 4eb8f91da87b9bc349bbd27a8f4930ecdb802805..ebfc386d5ca307ededf61cb5f44d01d3b305e48a 100644
GIT binary patch
delta 45
scmaEtwvJDQfq{{Mi9tYs0SFWra+w$y)Icl+2!A#U1H)vdjVWC00E-j_#{d8T
delta 11395
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 {
+it("shows 'Como funciona em 3 passos' in a card on the right half of the hero, next to the headline, instead of repeating the deliverables", () => {
render();
- const card = screen.getByTestId("ia-review-card");
- expect(within(card).getByText("O que você recebe")).toBeInTheDocument();
+ const card = screen.getByTestId("ia-steps-card");
expect(
- within(card)
- .getAllByRole("listitem")
- .map((item) => item.textContent?.replace(/^\d+/, "")),
+ within(card).getByText("Como funciona em 3 passos"),
+ ).toBeInTheDocument();
+ const steps = within(card).getAllByRole("listitem");
+ expect(steps).toHaveLength(3);
+ expect(
+ steps.map((item) => item.querySelector("strong")?.textContent),
).toEqual([
- "As falhas de acesso a dados",
- "O que dá para aproveitar",
- "O que é melhor refazer",
- "Um orçamento para pôr em produção",
+ "Conversa pelo WhatsApp.",
+ "Teste e avaliação.",
+ "Relatório e orçamento.",
]);
+ // a conversa vem primeiro e repete o aviso da página: nada de senhas nem acessos por mensagem
+ expect(steps[0]).toHaveTextContent(
+ "Não envie senhas nem acessos por mensagem.",
+ );
// o cartão divide a grade do hero com o título (metade direita no desktop) e não cria uma região de página
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
expect(hero).toContainElement(card);
@@ -120,13 +125,22 @@ it("shows the report list in a card on the right half of the hero, next to the h
"lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]",
);
expect(screen.queryByRole("complementary")).toBeNull();
- // o título do cartão não repete o h2 "O que você recebe na revisão" da seção seguinte
+ // os quatro entregáveis ficam só na seção "O que você recebe na revisão": o hero não os repete
+ expect(within(card).queryByText("O que você recebe")).toBeNull();
+ for (const item of [
+ "As falhas de acesso a dados",
+ "O que dá para aproveitar",
+ "O que é melhor refazer",
+ "Um orçamento para pôr em produção",
+ ]) {
+ expect(within(card).queryByText(item)).toBeNull();
+ }
expect(
screen.getAllByRole("heading", { name: /O que você recebe/ }),
).toHaveLength(1);
});
-it("keeps the secondary hero button inside the page: the portfolio cases are not reviews of AI-built apps", () => {
+it("keeps the in-page secondary link and brings back a secondary link to the cases in the hero, both as text links", () => {
render();
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
@@ -135,12 +149,66 @@ it("keeps the secondary hero button inside the page: the portfolio cases are not
});
expect(secondary).toHaveAttribute("href", "#ia-receber");
expect(document.querySelector(`section[id="ia-receber"]`)).not.toBeNull();
- for (const link of within(hero as HTMLElement).getAllByRole("link")) {
- expect(link.getAttribute("href")).not.toMatch(/^\/cases/);
+ const cases = within(hero as HTMLElement).getByRole("link", {
+ name: "Ver projetos entregues",
+ });
+ expect(cases).toHaveAttribute("href", "/cases");
+ // os dois são links de texto; o único botão cheio do hero é o do WhatsApp
+ expect(cases.className).toBe(secondary.className);
+ const hrefs = within(hero as HTMLElement)
+ .getAllByRole("link")
+ .map((link) => link.getAttribute("href"));
+ expect(hrefs.filter((href) => href?.startsWith("/cases"))).toEqual([
+ "/cases",
+ ]);
+});
+
+it("shows the report model with three illustrative findings (flaw, severity, suggested fix), labelled as examples and with no client name", () => {
+ const { container } = render();
+
+ const heading = screen.getByRole("heading", {
+ level: 2,
+ name: "Modelo do relatório",
+ });
+ const section = heading.closest("section") as HTMLElement;
+ expect(section).toHaveAttribute("id", "ia-modelo");
+ expect(section).toHaveTextContent("exemplos ilustrativos");
+ expect(section).toHaveTextContent("não são de nenhum cliente");
+
+ const findings = within(section).getAllByRole("article");
+ expect(findings).toHaveLength(3);
+ for (const [index, finding] of findings.entries()) {
+ expect(finding).toHaveTextContent(`Exemplo ilustrativo 0${index + 1}`);
+ expect(
+ Array.from(finding.querySelectorAll("dt")).map(
+ (term) => term.textContent,
+ ),
+ ).toEqual(["Falha", "Gravidade", "Correção sugerida"]);
+ for (const definition of finding.querySelectorAll("dd")) {
+ expect(definition.textContent?.trim()).not.toBe("");
+ }
}
+ // a gravidade é uma palavra só, de uma escala simples
expect(
- screen.queryByRole("link", { name: "Ver projetos entregues" }),
- ).toBeNull();
+ findings.map((finding) => finding.querySelectorAll("dd")[1].textContent),
+ ).toEqual(["Alta", "Alta", "Média"]);
+ // sem nome de cliente do portfólio, sem preço nem prazo
+ expect(section.textContent).not.toMatch(
+ /SEV|Lavação|SC Plus|AS Serviços|Apolo|R\$|\d+ dias|em até \d+/,
+ );
+ // o modelo vem depois do que se recebe e antes de "por que contratar"
+ const receive = container.querySelector("#ia-receber") as HTMLElement;
+ expect(
+ receive.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
+ const hire = screen
+ .getByRole("heading", {
+ name: "Se a IA cria um sistema, por que contratar um desenvolvedor?",
+ })
+ .closest("section") as HTMLElement;
+ expect(
+ section.compareDocumentPosition(hire) & Node.DOCUMENT_POSITION_FOLLOWING,
+ ).toBeTruthy();
});
it("keeps the incident news collapsed in a , closed by default", () => {
diff --git a/app/ia/page.tsx b/app/ia/page.tsx
index b9853cf..5e10659 100644
--- a/app/ia/page.tsx
+++ b/app/ia/page.tsx
@@ -5,6 +5,7 @@ import {
primaryButton,
textLinkDark,
} from "@/app/components/button-styles";
+import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -53,6 +54,51 @@ const DELIVERABLES = [
},
];
+/**
+ * Como a revisão anda, em três passos, no cartão do hero. Só o que o resto da página já diz: a conversa pelo WhatsApp
+ * (sem enviar senhas), o teste e a separação do que aproveitar do que refazer, e o orçamento por escrito. Sem prazo e
+ * sem preço: isso se combina na conversa.
+ */
+const STEPS = [
+ {
+ title: "Conversa pelo WhatsApp",
+ description:
+ "Você conta o que o app faz e o que ele precisa fazer. Não envie senhas nem acessos por mensagem.",
+ },
+ {
+ title: "Teste e avaliação",
+ description:
+ "Testo se uma pessoa consegue ver ou alterar os dados de outra e separo o que dá para aproveitar do que é melhor refazer.",
+ },
+ {
+ title: "Relatório e orçamento",
+ description:
+ "Você recebe o que precisa de correção e o orçamento por escrito para pôr o app em produção.",
+ },
+];
+
+/**
+ * Três achados de EXEMPLO do relatório, para mostrar como ele é organizado: a falha, a gravidade e a correção sugerida.
+ * São ilustrativos: não são de nenhum cliente nem de um app real, e a página os rotula assim.
+ */
+const REPORT_EXAMPLES = [
+ {
+ flaw: "Um usuário consegue abrir os dados de outro trocando o número no endereço da página.",
+ severity: "Alta",
+ fix: "Conferir no servidor se cada registro pertence a quem está logado e testar com contas de empresas diferentes.",
+ },
+ {
+ flaw: "A chave de acesso ao banco de dados está no código que o navegador baixa.",
+ severity: "Alta",
+ fix: "Levar a chave para o servidor, trocar a que ficou exposta e liberar no banco só o que cada perfil precisa.",
+ },
+ {
+ flaw: "Não há cópia de segurança com restauração testada.",
+ severity: "Média",
+ fix: "Ligar as cópias automáticas, separar o banco de testes do de produção e ensaiar a restauração.",
+ },
+];
+
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
@@ -161,24 +207,27 @@ export default function AiPage() {
>
Pedir a revisão do meu app
- {/* Segue na própria página: os cases do portfólio não são revisões de app feito com IA. */}
Ver como a revisão funciona
+ {/* Link secundário para os cases: mostram como eu entrego, não são revisões de app feito com IA. */}
+
+ Ver projetos entregues
+
))}
@@ -230,6 +282,61 @@ export default function AiPage() {
+
+
+
+ Modelo do relatório
+
+
+ Cada falha encontrada entra no relatório com a gravidade e a
+ correção sugerida. Os três achados abaixo são{" "}
+ exemplos ilustrativos de como ele é organizado: não
+ são de nenhum cliente nem de um app real.
+
diff --git a/app/layout.tsx b/app/layout.tsx
index 9f1d9f8..2e7ed43 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -18,7 +18,8 @@ import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header";
import "./globals.css";
// As molduras de navegador e de celular (o hero e os cases também as usam) vêm uma vez só, aqui, depois do global.
-// O CSS só da vitrine e da janela ampliada (reel-list, pinned-reel e lightbox) viaja com os componentes deles.
+// A lista da vitrine (reel-list.css) já vem no globals.css, por @import; só a vitrine fixa (pinned-reel.css) e a janela
+// ampliada (lightbox.css) viajam com os componentes deles.
import "./components/showcase/showcase.css";
const titleSuffix = " | Evandro.dev.br";
diff --git a/lib/next-config.paths.test.ts b/lib/next-config.paths.test.ts
index 6aca99d..8e6bf10 100644
--- a/lib/next-config.paths.test.ts
+++ b/lib/next-config.paths.test.ts
@@ -44,6 +44,28 @@ describe("next.config headers()", () => {
);
});
+ it("path: any environment -> the policy (still Report-Only) allows the domains the Google Ads tag uses after consent, in connect-src and img-src", async () => {
+ const headers = await siteHeaders();
+ const policy = header(headers, "Content-Security-Policy-Report-Only") ?? "";
+ const directive = (name: string) =>
+ policy.split("; ").find((part) => part.startsWith(`${name} `)) ?? "";
+
+ for (const name of ["connect-src", "img-src"]) {
+ for (const host of [
+ "https://pagead2.googlesyndication.com",
+ "https://googleads.g.doubleclick.net",
+ "https://www.googleadservices.com",
+ ]) {
+ expect(directive(name)).toContain(host);
+ }
+ // o que já estava liberado segue liberado
+ expect(directive(name)).toContain("https://*.google-analytics.com");
+ }
+ // nada de curinga aberto e nada enforçado: a política continua só em Report-Only
+ expect(policy).not.toMatch(/\* |https:(?!\/)/);
+ expect(header(headers, "Content-Security-Policy")).toBeUndefined();
+ });
+
it("path: any environment -> the video rule keeps the long cache", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const videos = rules.find(({ source }) => source === "/videos/:path*");
diff --git a/lib/next-config.test.ts b/lib/next-config.test.ts
index cc1da61..110f21c 100644
--- a/lib/next-config.test.ts
+++ b/lib/next-config.test.ts
@@ -13,7 +13,7 @@ describe("next.config", () => {
expect(nextConfig.images).not.toHaveProperty("minimumCacheTTL");
});
- it("keeps the CSP in Report-Only until Google Ads is allowed in it, never enforcing it by accident", async () => {
+ it("keeps the CSP in Report-Only (Google Ads is allowed in it now, but the owner activates it later), never enforcing it by accident", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const site = rules.find(({ source }) => source === "/(.*)");
const names = site?.headers.map(({ key }) => key) ?? [];
diff --git a/lib/proof.ts b/lib/proof.ts
index 1a9e020..82a803e 100644
--- a/lib/proof.ts
+++ b/lib/proof.ts
@@ -1,8 +1,5 @@
import { portfolio } from "./portfolio-data";
-/** Projetos feitos para clientes aparecem como prova; os pessoais e protótipos ficam no "Laboratório". */
-export type ProjectKind = "cliente" | "laboratorio";
-
export interface Testimonial {
quote: string;
name: string;
@@ -20,6 +17,7 @@ export interface Testimonial {
*/
export const testimonials: Testimonial[] = [];
+/** Projetos feitos para clientes (`kind: "cliente"`) aparecem como prova; os pessoais e protótipos ficam no "Laboratório". */
export function isClientProject(project: { kind?: string }) {
return project.kind === "cliente";
}
diff --git a/lib/whatsapp.test.ts b/lib/whatsapp.test.ts
index e16b543..ec95bdf 100644
--- a/lib/whatsapp.test.ts
+++ b/lib/whatsapp.test.ts
@@ -26,10 +26,10 @@ describe("whatsAppSubjectForPath", () => {
it("cites the case on /cases/, with the phrase every page uses for a similar project", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima", contexts)).toBe(
- "um projeto parecido com o SEV Moda Íntima",
+ "um projeto parecido com o case SEV Moda Íntima",
);
expect(similarProjectSubject("Lavação 047")).toBe(
- "um projeto parecido com o Lavação 047",
+ "um projeto parecido com o case Lavação 047",
);
});
diff --git a/lib/whatsapp.ts b/lib/whatsapp.ts
index 93739f6..b2612bc 100644
--- a/lib/whatsapp.ts
+++ b/lib/whatsapp.ts
@@ -39,9 +39,9 @@ export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio";
export const SERVICES_SUBJECT = "Sites, sistemas e aplicativos";
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
-/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). */
+/** 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". */
export function similarProjectSubject(title: string) {
- return `um projeto parecido com o ${title}`;
+ return `um projeto parecido com o case ${title}`;
}
/** Assunto de cada página sem parâmetro, pelo caminho sem a barra final (só a home mantém a barra). */
diff --git a/next.config.ts b/next.config.ts
index d58ce02..53f90d3 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -35,11 +35,19 @@ const nextConfig: NextConfig = {
...(process.env.NODE_ENV === "production" ? [] : ["'unsafe-eval'"]),
"https://www.googletagmanager.com",
].join(" ");
+ // O gtag do Google Ads, depois do consentimento: a medição vai para pagead2.googlesyndication.com (fetch, visto
+ // como violação de connect-src ao aceitar os cookies), e os pixels e as conversões usam googleads.g.doubleclick.net
+ // e www.googleadservices.com. Ainda em Report-Only: o que mais aparecer como violação em produção entra aqui.
+ const googleAdsSources = [
+ "https://pagead2.googlesyndication.com",
+ "https://googleads.g.doubleclick.net",
+ "https://www.googleadservices.com",
+ ].join(" ");
const contentSecurityPolicy = [
"default-src 'self'",
`script-src ${scriptSources}`,
- "connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
- "img-src 'self' data: blob: https://*.google-analytics.com https://www.googletagmanager.com",
+ `connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com ${googleAdsSources}`,
+ `img-src 'self' data: blob: https://*.google-analytics.com https://www.googletagmanager.com ${googleAdsSources}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"frame-ancestors 'none'",