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)) && ( - - )} -
  • - ))} -
- - )} - {years ? ( -

- - {years} - {" "} - anos de experiência -

- ) : null} + {clients.length > 0 &&

Já entregue para:

} +
    + {items.map((item, index) => ( +
  • 0 ? " -ml-4" : ""}`} + > + {index > 0 && ( + + )} + {item.node} +
  • + ))} +
); } 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({

  • - + Cases - +
  • - + Revisão de app feito com IA - +
  • - + Privacidade - +
  • - Currículo - +
- - +

© {currentYear} Evandro Fonseca Junior. Todos os direitos reservados. diff --git a/app/components/Header.menu.test.tsx b/app/components/Header.menu.test.tsx index 1dc5b9e..c28a731 100644 --- a/app/components/Header.menu.test.tsx +++ b/app/components/Header.menu.test.tsx @@ -325,11 +325,15 @@ describe("Header: Esc e foco com o menu aberto", () => { }); describe("Header: a barra de cookies aberta entra na armadilha de foco", () => { - /** Como no layout: a barra vem antes do cabeçalho e não fica inerte. */ + /** Como no layout (GoogleAnalytics.tsx): a barra vem antes do cabeçalho e não fica inerte; link da política, Aceitar e Recusar. */ function PageWithCookieBar() { return ( <>

@@ -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({ 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 (
- {poster && offer.project ? ( + {poster && offer.poster ? ( {`Site - ) : ( - - )} + ) : offer.icon ? ( + + ) : null}
{/* 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 */} +
+

+ Primeiro passo +

+

+ {offer.firstStep} +

+
+ {offer.ctaLabel} @@ -197,6 +241,13 @@ export function WorkModalities() { ))}
+ + 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 +

- O que você recebe + Como funciona em 3 passos

    - {DELIVERABLES.map((item, index) => ( + {STEPS.map((step, index) => (
  1. - {item.title} + + {step.title}. + {" "} + {step.description}
  2. ))} @@ -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. +

+
    + {REPORT_EXAMPLES.map((example, index) => ( +
  1. +
    +

    + Exemplo ilustrativo {String(index + 1).padStart(2, "0")} +

    +
    +
    +
    + Falha +
    +
    + {example.flaw} +
    +
    +
    +
    + Gravidade +
    +
    + {example.severity} +
    +
    +
    +
    + Correção sugerida +
    +
    + {example.fix} +
    +
    +
    +
    +
  2. + ))} +
+
+
+

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'",