From 10f170abf4674bd1cc1dc85a3bc048ff6bfa8f26 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 14:15:25 -0300 Subject: [PATCH] feat(home): posicionamento com prova, menu sem recarregar e WhatsApp em todo o site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 'Sites e sistemas que trazem pedidos pelo WhatsApp e tiram sua operação da planilha.'; HeroWork maior com a SEV; menu Cases, Serviços, Como trabalho, Sobre e IA com next/link e pré-carregamento no hover; menu do celular em tela cheia; WhatsApp flutuante e no rodapé com a mensagem da rota; rodapé em todas as páginas; FAQ da home; /ia como oferta de revisão de app feito com IA; formulário com noscript, validação do mailto e plano B do pop-up; cartões sem foco indevido; sem a fonte Monument. --- app/components/About.paths.test.tsx | 189 ++++------ app/components/About.tsx | 217 +++++------ app/components/ClientStrip.test.tsx | 50 +++ app/components/ClientStrip.tsx | 67 ++-- app/components/Contact.paths.test.tsx | 31 +- app/components/Contact.test.tsx | 259 ++++++++++++- app/components/Contact.tsx | 345 +++++++++++------- app/components/Contact.window.paths.test.tsx | 108 ++++++ app/components/EngineeringWithAI.test.tsx | 6 +- app/components/EngineeringWithAI.tsx | 8 +- .../FloatingWhatsApp.route.test.tsx | 128 +++++++ app/components/FloatingWhatsApp.tsx | 22 +- app/components/Footer.test.tsx | 13 + app/components/Footer.tsx | 29 +- app/components/GoogleAnalytics.tsx | 4 +- app/components/Header.paths.test.tsx | 154 ++++++++ app/components/Header.test.tsx | 159 +++++++- app/components/Header.tsx | 115 ++++-- app/components/Hero.paths.test.tsx | 32 +- app/components/Hero.reducedMotion.test.tsx | 9 - app/components/Hero.test.tsx | 46 +-- app/components/Hero.tsx | 67 ++-- app/components/HeroWork.tsx | 20 +- app/components/HomeFaq.test.tsx | 95 +++++ app/components/HomeFaq.tsx | 91 +++++ .../HoverPrefetchLink.paths.test.tsx | 111 ++++++ app/components/HoverPrefetchLink.test.tsx | 81 ++++ app/components/HoverPrefetchLink.tsx | 38 ++ .../WhatsAppLink.onclick.paths.test.tsx | 79 ++++ app/components/WhatsAppLink.test.tsx | 19 + app/components/WhatsAppLink.tsx | 10 +- app/components/WorkModalities.paths.test.tsx | 246 +++++++------ app/components/WorkModalities.tsx | 119 +++--- .../cinematic/CinematicHome.paths.test.tsx | 53 +-- app/components/cinematic/CinematicHome.tsx | 22 +- app/components/conversion.css | 42 +++ app/home-positioning.test.ts | 27 ++ app/ia/opengraph-image.tsx | 6 +- app/ia/page.test.tsx | 76 +++- app/ia/page.tsx | 161 +++++--- app/layout.test.tsx | 150 ++++++++ app/layout.tsx | 68 ++-- app/manifest.ts | 4 +- app/opengraph-image.tsx | 8 +- app/page.paths.test.tsx | 100 +++++ app/page.test.tsx | 16 + app/page.tsx | 10 + lib/next-config.paths.test.ts | 58 +++ lib/next-config.test.ts | 16 + lib/site-navigation.ts | 7 +- lib/whatsapp.paths.test.ts | 63 ++++ lib/whatsapp.test.ts | 104 ++++++ lib/whatsapp.ts | 44 +++ next.config.ts | 3 + public/fonts/MonumentExtended-Regular.otf | Bin 34380 -> 0 bytes public/fonts/MonumentExtended-Ultrabold.otf | Bin 33528 -> 0 bytes 56 files changed, 3099 insertions(+), 906 deletions(-) create mode 100644 app/components/Contact.window.paths.test.tsx create mode 100644 app/components/FloatingWhatsApp.route.test.tsx create mode 100644 app/components/Header.paths.test.tsx create mode 100644 app/components/HomeFaq.test.tsx create mode 100644 app/components/HomeFaq.tsx create mode 100644 app/components/HoverPrefetchLink.paths.test.tsx create mode 100644 app/components/HoverPrefetchLink.test.tsx create mode 100644 app/components/HoverPrefetchLink.tsx create mode 100644 app/components/WhatsAppLink.onclick.paths.test.tsx create mode 100644 app/home-positioning.test.ts create mode 100644 app/layout.test.tsx create mode 100644 app/page.paths.test.tsx create mode 100644 app/page.test.tsx create mode 100644 lib/next-config.paths.test.ts create mode 100644 lib/next-config.test.ts create mode 100644 lib/whatsapp.paths.test.ts create mode 100644 lib/whatsapp.test.ts delete mode 100644 public/fonts/MonumentExtended-Regular.otf delete mode 100644 public/fonts/MonumentExtended-Ultrabold.otf diff --git a/app/components/About.paths.test.tsx b/app/components/About.paths.test.tsx index 783deae..6343968 100644 --- a/app/components/About.paths.test.tsx +++ b/app/components/About.paths.test.tsx @@ -1,53 +1,12 @@ import { render, screen } from "@testing-library/react"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import About from "./About"; /** - * Testes de caminhos-base de About: todas as decisões dependem de `shouldReduceMotion` - * (variantes dos cards, transição, hover e toque), então há dois caminhos: movimento normal e reduzido. - * O `motion/react` é trocado por elementos simples que registram as props de animação. + * Testes de caminhos-base de About: a entrada dos quatro cartões (Reveal, CSS) com atrasos crescentes, o + * feedback de toque e de mouse por classes (cv-press, cv-lift; cartões sem foco), o conteúdo e a coluna de + * apoio. Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS. */ -const mockSeen = jest.fn(); - -jest.mock("motion/react", () => { - const React = require("react"); - const cache = new Map(); - const motion = new Proxy( - {}, - { - get: (_target, tag: string) => { - if (!cache.has(tag)) { - cache.set( - tag, - ({ - children, - variants, - whileHover, - whileTap, - transition, - initial: _initial, - animate: _animate, - whileInView: _whileInView, - viewport: _viewport, - ...props - }: Record & { children?: unknown }) => { - mockSeen(tag, { variants, whileHover, whileTap, transition }); - return React.createElement(tag, props, children); - }, - ); - } - return cache.get(tag); - }, - }, - ); - return { motion }; -}); - -jest.mock("@/app/hooks/useReducedMotion", () => ({ - useReducedMotion: jest.fn(), -})); - jest.mock("@/public/edev.jpg", () => ({ __esModule: true, default: { src: "/edev.jpg", width: 10, height: 10 }, @@ -58,13 +17,16 @@ jest.mock("./Section", () => ({ children, id, title, + aside, }: { children?: React.ReactNode; id?: string; title?: string; + aside?: React.ReactNode; }) => (

{title}

+ {aside} {children}
), @@ -77,82 +39,93 @@ jest.mock("./animations/LazyRevealImage", () => ({ ), })); -type Seen = { - variants?: { - hidden: Record; - visible: { transition: Record }; - }; - whileHover?: unknown; - whileTap?: unknown; -}; - -function renderedCards(): Seen[] { - return mockSeen.mock.calls - .filter(([tag]) => tag === "article") - .map(([, seen]) => seen as Seen); -} - -beforeEach(() => { - mockSeen.mockClear(); -}); - describe("About", () => { - it("path: normal motion -> cards rise and clip in, with hover lift and press feedback", () => { - jest.mocked(useReducedMotion).mockReturnValue(false); + it("path: the four cards enter through Reveal -> same rise and duration, delays growing in steps", () => { + const { container } = render(); - render(); - - const cards = renderedCards(); - expect(cards).toHaveLength(4); // 3 etapas da história + o card do perfil - for (const card of cards) { - expect(card.variants?.hidden).toEqual({ - opacity: 0, - y: 20, - clipPath: "inset(100% 0 0 0)", - }); - expect(card.variants?.visible.transition).toEqual({ - duration: 0.8, - ease: [0.16, 1, 0.3, 1], - }); - expect(card.whileHover).toEqual({ - y: -4, - boxShadow: "0 18px 40px rgba(10,10,10,0.16)", - }); - expect(card.whileTap).toEqual({ scale: 0.99 }); + const reveals = [...container.querySelectorAll(".ds-reveal")]; + // 3 etapas da história + o card do perfil (o cabeçalho da seção é um dublê, sem Reveal) + expect(reveals).toHaveLength(4); + expect( + reveals.map((element) => element.style.getPropertyValue("--reveal-y")), + ).toEqual(["20px", "20px", "20px", "20px"]); + expect( + reveals.map((element) => + element.style.getPropertyValue("--reveal-duration"), + ), + ).toEqual(["0.8s", "0.8s", "0.8s", "0.8s"]); + const delays = reveals.map((element) => + Number.parseFloat(element.style.getPropertyValue("--reveal-delay")), + ); + expect(delays).toEqual([0.08, 0.16, 0.24, 0.32]); + for (const element of reveals) { + expect(element.style.opacity).toBe(""); + expect(element.style.transform).toBe(""); } }); - it("path: reduced motion -> cards only fade, with no hover lift and no press feedback", () => { - jest.mocked(useReducedMotion).mockReturnValue(true); + it("path: the four cards are not focus stops and use the CSS press feedback", () => { + const { container } = render(); - render(); - - const cards = renderedCards(); + const cards = container.querySelectorAll("article"); expect(cards).toHaveLength(4); for (const card of cards) { - expect(card.variants?.hidden).toEqual({ opacity: 0 }); - expect(card.variants?.visible.transition).toEqual({ duration: 0.25 }); - expect(card.whileHover).toBeUndefined(); - expect(card.whileTap).toBeUndefined(); + expect(card).not.toHaveAttribute("tabindex"); + expect(card).toHaveClass("cv-press"); } }); - it("path: content does not depend on motion -> same story, profile points and résumé link", () => { - for (const reduced of [false, true]) { - jest.mocked(useReducedMotion).mockReturnValue(reduced); - const { unmount } = render(); + it("path: the three story cards lift on hover and press with CSS; the profile card presses only", () => { + const { container } = render(); - expect( - screen.getByRole("heading", { - level: 3, - name: "Do sinal físico ao produto digital", - }), - ).toBeInTheDocument(); - expect(screen.getByText("Arquitetura pragmática")).toBeInTheDocument(); - expect( - screen.getByRole("link", { name: "Ver trajetória completa" }), - ).toHaveAttribute("href", "/curriculo"); - unmount(); + const cards = [...container.querySelectorAll("article")]; + expect(cards).toHaveLength(4); + for (const card of cards.slice(0, 3)) { + expect(card).toHaveClass("cv-press", "cv-lift"); + } + expect(cards[3]).toHaveClass("cv-press"); + }); + + it("path: the story, the profile points and the résumé link are all there", () => { + render(); + + expect( + screen.getByRole("heading", { + level: 3, + name: "Você fala direto com quem desenvolve", + }), + ).toBeInTheDocument(); + expect( + screen.getByText("Solução do tamanho do seu problema"), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Ver trajetória completa" }), + ).toHaveAttribute("href", "/curriculo"); + }); + + it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work", () => { + const { container } = render(); + + expect(container.textContent).not.toMatch( + /protocolos de baixo nível|operabilidade|camada física|sinal físico/i, + ); + expect( + screen.getByText( + "Desde 2022 desenvolvo sistemas internos, aplicativos e integrações IoT na Apolo Energia.", + ), + ).toBeInTheDocument(); + }); + + it("path: the location label stays on one line and the columns stretch to the same height", () => { + const { container } = render(); + + expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass( + "whitespace-nowrap", + ); + expect(container.querySelector(".ds-grid-2")).toHaveClass("items-stretch"); + const story = container.querySelectorAll("article"); + for (const card of Array.from(story).slice(0, 3)) { + expect(card).toHaveClass("flex-1"); } }); }); diff --git a/app/components/About.tsx b/app/components/About.tsx index 429cddc..83fa022 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -1,90 +1,55 @@ -"use client"; - -import { motion, type Variants } from "motion/react"; import Link from "next/link"; -import { useReducedMotion } from "@/app/hooks/useReducedMotion"; import profilePhoto from "@/public/edev.jpg"; import { DrawLine } from "./animations/DrawLine"; import { LazyRevealImage } from "./animations/LazyRevealImage"; import { textLink } from "./button-styles"; +import "./conversion.css"; +import { Reveal } from "./Reveal"; import { Section } from "./Section"; const STORY_DATA = [ { - label: "Hardware + software", - title: "Do sinal físico ao produto digital", + label: "Como eu trabalho", + title: "Você fala direto com quem desenvolve", content: - "Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.", + "Você conversa comigo sobre o negócio e acompanha as decisões ao longo da entrega, sem intermediários entre o seu pedido e o sistema.", }, { - label: "Decisões técnicas", - title: "Entrega clara mesmo sob restrição", + label: "Sem começar do zero", + title: "Aproveito o que você já tem", content: - "Avalio o que já existe, identifico dependências e combino prioridades antes de alterar sistemas em uso. Nem todo problema exige começar novamente.", + "Antes de mexer em um sistema que já está em uso, vejo o que existe e combino com você o que vem primeiro. Nem todo problema exige começar de novo.", }, { - label: "Operação", + label: "Depois da entrega", title: "Do código ao sistema no ar", content: - "Publicação, diagnóstico de falhas, documentação e manutenção entram no planejamento, junto com as funcionalidades.", + "Colocar no ar, corrigir falhas, documentar e manter o sistema entram no planejamento desde o começo, junto com as funcionalidades.", }, ]; const PROFILE_POINTS = [ { - title: "Arquitetura pragmática", + title: "Solução do tamanho do seu problema", description: - "Escolhas guiadas por contexto, operabilidade e impacto real no produto.", + "Escolho o que resolve o seu caso, sem complicar além do necessário.", }, { - title: "Integração profunda", + title: "Tudo funcionando junto", description: - "Capacidade de atravessar camada física, serviços e interface sem perder consistência.", + "A tela, os dados e os equipamentos são ligados para trabalhar como uma coisa só.", }, { - title: "Responsabilidade técnica", + title: "Quem responde pelo projeto", description: - "Contato direto com quem define a arquitetura e desenvolve o sistema.", + "Você tem contato direto com quem decide a estrutura e desenvolve o sistema.", }, ]; +/** Atraso (s) de cada cartão: entram em escada, como na vitrine de serviços. */ +const cardDelay = (index: number) => 0.08 * (index + 1); + export default function About() { - const shouldReduceMotion = useReducedMotion(); - - const staggerContainer: Variants = { - hidden: {}, - visible: { - transition: { - delayChildren: 0.08, - staggerChildren: 0.08, - }, - }, - }; - - const cardVariants: Variants = { - hidden: shouldReduceMotion - ? { opacity: 0 } - : { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" }, - visible: { - opacity: 1, - y: 0, - clipPath: "inset(0% 0 0 0)", - transition: shouldReduceMotion - ? { duration: 0.25 } - : { - duration: 0.8, - ease: [0.16, 1, 0.3, 1], - }, - }, - }; - - const cardHover = shouldReduceMotion - ? undefined - : { - y: -4, - boxShadow: "0 18px 40px rgba(10,10,10,0.16)", - }; - return (
- - Base em Garuva, SC | Atendimento remoto +
+ + Garuva, SC · Atendimento remoto
} > - - - {STORY_DATA.map((step) => ( - +
+ {STORY_DATA.map((step, index) => ( + -
{step.label}
- -

{step.title}

-

- {step.content} -

- +
+
{step.label}
+ +

{step.title}

+

+ {step.content} +

+
+
))} - +
- - -
Da arquitetura à implementação
- -

Uma visão do sistema inteiro

-

- Meu trabalho conecta interface, serviços, dados e, quando - necessário, equipamentos. Entender essas partes em conjunto ajuda a - definir o que construir, o que integrar e o que pode ser - aproveitado. -

-
- {PROFILE_POINTS.map((point) => ( -
-
- {point.title} -
-

{point.description}

+
+
+ +
+
Da ideia ao sistema no ar
+

+ Uma pessoa cuida do sistema inteiro +

- ))} -
- - Ver trajetória completa - - - +
+ +

+ Cuido da tela que sua equipe usa, dos dados por trás dela e, + quando o negócio tem máquinas ou sensores, da ligação deles com o + sistema. Ver tudo junto ajuda a decidir o que construir, o que + conectar e o que dá para aproveitar. +

+

+ Desde 2022 desenvolvo sistemas internos, aplicativos e integrações + IoT na Apolo Energia. +

+
+ {PROFILE_POINTS.map((point) => ( +
+
+ {point.title} +
+

{point.description}

+
+ ))} +
+ + Ver trajetória completa + + + +
); } diff --git a/app/components/ClientStrip.test.tsx b/app/components/ClientStrip.test.tsx index c0cc706..8f587d1 100644 --- a/app/components/ClientStrip.test.tsx +++ b/app/components/ClientStrip.test.tsx @@ -29,3 +29,53 @@ it("renders nothing without clients", () => { const withoutProp = render(); expect(withoutProp.container).toBeEmptyDOMElement(); }); + +it("puts the years of experience in the same line, after the clients", () => { + render(); + + const experience = screen.getByText("anos de experiência"); + expect(screen.getByText("5")).toBeInTheDocument(); + const lastClient = screen.getByRole("link", { name: "Lavação 047" }); + expect( + lastClient.compareDocumentPosition(experience) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(experience.closest("div")).toBe( + screen.getByText("Já entregue para:").parentElement, + ); +}); + +it("shows only the experience when there are no clients, and nothing without either", () => { + const only = render(); + expect(screen.queryByText("Já entregue para:")).toBeNull(); + expect(screen.getByText("anos de experiência")).toBeInTheDocument(); + only.unmount(); + + const none = render(); + expect(none.container).toBeEmptyDOMElement(); +}); + +it("gives every client link a touch target of at least 44 px (min-h-11)", () => { + render(); + + for (const link of screen.getAllByRole("link")) { + expect(link).toHaveClass("min-h-11"); + } +}); + +it("keeps the separator after the last client when the years follow, and none when they do not", () => { + const withYears = render(); + const separators = withYears.container.querySelectorAll( + "li [aria-hidden='true']", + ); + expect(separators).toHaveLength(clients.length); + withYears.unmount(); + + // zero anos não escreve um "0" solto nem um ponto a mais + const zero = render(); + expect( + zero.container.querySelectorAll("li [aria-hidden='true']"), + ).toHaveLength(clients.length - 1); + expect(zero.container).not.toHaveTextContent(/anos de experiência/); + expect(screen.queryByText("0")).toBeNull(); +}); diff --git a/app/components/ClientStrip.tsx b/app/components/ClientStrip.tsx index 738c584..fcde784 100644 --- a/app/components/ClientStrip.tsx +++ b/app/components/ClientStrip.tsx @@ -1,43 +1,62 @@ -import Link from "next/link"; +import HoverPrefetchLink from "./HoverPrefetchLink"; 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. - * Sem clientes, nada é desenhado. + * 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. */ export default function ClientStrip({ clients = [], + years, className = "", }: { clients?: ClientLink[]; + /** Anos de experiência, na mesma linha dos clientes. */ + years?: number; className?: string; }) { - if (clients.length === 0) return null; + if (clients.length === 0 && !years) return null; return (
-

Já entregue para:

-
    - {clients.map((client, index) => ( -
  • - - {client.name} - - {/* o ponto fica no fim do item: numa quebra de linha ele nunca abre a linha seguinte */} - {index < clients.length - 1 && ( - - )} -
  • - ))} -
+ {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}
); } diff --git a/app/components/Contact.paths.test.tsx b/app/components/Contact.paths.test.tsx index f0e6a67..4102cc5 100644 --- a/app/components/Contact.paths.test.tsx +++ b/app/components/Contact.paths.test.tsx @@ -12,7 +12,10 @@ beforeEach(() => { localStorage.setItem("evandro.analytics-consent.v1", "granted"); delete window["ga-disable-G-QTT9QT0T3S"]; window.gtag = jest.fn(); - jest.spyOn(window, "open").mockImplementation(() => null); + // uma aba aberta de verdade: se devolver null, o Contact cai para location.href + jest + .spyOn(window, "open") + .mockImplementation(() => ({ opener: {} }) as unknown as Window); }); afterEach(() => { @@ -48,7 +51,7 @@ function sentMessage() { describe("Contact: validate()", () => { it("path: only the name is missing -> only the name is flagged and gets the focus", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { email: "ana@example.com", @@ -69,7 +72,7 @@ describe("Contact: validate()", () => { }); it("path: only the e-mail is malformed -> only the e-mail is flagged and gets the focus (not the name)", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: "Ana", @@ -88,7 +91,7 @@ describe("Contact: validate()", () => { }); it("path: only the message is missing -> only the message is flagged and gets the focus", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: "Ana" }); @@ -105,7 +108,7 @@ describe("Contact: validate()", () => { }); it("path: name and message made only of spaces -> both rejected", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: " ", message: " " }); @@ -118,7 +121,7 @@ describe("Contact: validate()", () => { }); it("path: e-mail made only of spaces -> counts as omitted: no error and no e-mail line", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: "Ana", @@ -136,7 +139,7 @@ describe("Contact: validate()", () => { it.each(["a@b", "a@c.", "@c.com", "a@@c.com", "a b@c.com", "sem-arroba.com"])( "path: e-mail %j -> invalid format is blocked", async (email) => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: "Ana", email, message: "Organizar pedidos" }); @@ -150,7 +153,7 @@ describe("Contact: validate()", () => { it.each(["a@b.co", "ana+projeto@sub.empresa.com.br"])( "path: e-mail %j -> valid format is accepted and sent", async (email) => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: "Ana", email, message: "Organizar pedidos" }); @@ -165,7 +168,7 @@ describe("Contact: validate()", () => { describe("Contact: updateField() and the error state", () => { it("path: typing in one field clears only that field's error", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await user.click(submitButton()); expect(screen.getByText(NAME_ERROR)).toBeVisible(); @@ -180,7 +183,7 @@ describe("Contact: updateField() and the error state", () => { }); it("path: changing the project type keeps the other fields' errors untouched", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await user.click(submitButton()); @@ -197,7 +200,7 @@ describe("Contact: updateField() and the error state", () => { }); it("path: fixing every error and submitting again -> errors gone, WhatsApp opens once", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await user.click(submitButton()); expect(window.open).not.toHaveBeenCalled(); @@ -214,7 +217,7 @@ describe("Contact: updateField() and the error state", () => { describe("Contact: mensagem montada", () => { it("path: surrounding spaces are trimmed in name, e-mail and message", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await fill(user, { name: " Ana ", @@ -233,7 +236,7 @@ describe("Contact: mensagem montada", () => { }); it("path: the chosen project type and the e-mail line reach the mailto draft, and the e-mail line is omitted when blank", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); await user.selectOptions( screen.getByRole("combobox", { name: /tipo de projeto/i }), @@ -260,7 +263,7 @@ describe("Contact: mensagem montada", () => { }); it("path: the direct e-mail row reports its own placement", async () => { - const user = userEvent.setup(); + const user = userEvent.setup({ delay: null }); render(); const direct = screen.getByRole("link", { diff --git a/app/components/Contact.test.tsx b/app/components/Contact.test.tsx index bd75390..798e5ce 100644 --- a/app/components/Contact.test.tsx +++ b/app/components/Contact.test.tsx @@ -1,13 +1,26 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { renderToStaticMarkup } from "react-dom/server"; import Contact from "./Contact"; +/** + * Sem atraso entre as teclas: com o atraso padrão, digitar o contexto longo (cerca de 130 caracteres) levava + * mais de 5 s em máquina lenta e o teste estourava o tempo. O texto longo ainda entra por colagem. + */ +const setup = () => userEvent.setup({ delay: null }); + +/** Aba aberta de verdade; o Contact corta o vínculo com a página (opener = null) logo depois. */ +let openedWindow: { opener: unknown }; + beforeEach(() => { jest.replaceProperty(process.env, "NODE_ENV", "production"); localStorage.setItem("evandro.analytics-consent.v1", "granted"); delete window["ga-disable-G-QTT9QT0T3S"]; window.gtag = jest.fn(); - jest.spyOn(window, "open").mockImplementation(() => null); + openedWindow = { opener: {} }; + jest + .spyOn(window, "open") + .mockImplementation(() => openedWindow as unknown as Window); }); afterEach(() => { @@ -17,7 +30,7 @@ afterEach(() => { }); it("prepares an isolated WhatsApp draft with the entered project details only after submission", async () => { - const user = userEvent.setup(); + const user = setup(); render(); const name = "Ana & João"; @@ -28,15 +41,15 @@ it("prepares an isolated WhatsApp draft with the entered project details only af await user.type(screen.getByRole("textbox", { name: /e-mail/i }), email); const subject = screen.getByRole("combobox", { name: /tipo de projeto/i }); await user.selectOptions(subject, "Integrações e automações"); - await user.type(screen.getByRole("textbox", { name: /contexto/i }), context); + await user.click(screen.getByRole("textbox", { name: /contexto/i })); + await user.paste(context); expect(window.open).not.toHaveBeenCalled(); expect(window.gtag).not.toHaveBeenCalled(); await user.click(screen.getByRole("button", { name: /whatsapp/i })); expect(window.open).toHaveBeenCalledTimes(1); - const [destination, target, features] = jest.mocked(window.open).mock - .calls[0]; + const [destination, target, ...rest] = jest.mocked(window.open).mock.calls[0]; const url = new URL(String(destination)); expect(url.origin).toBe("https://wa.me"); expect(url.pathname).toMatch(/^\/\d+$/); @@ -47,7 +60,9 @@ it("prepares an isolated WhatsApp draft with the entered project details only af expect(message).toContain("Integrações e automações"); expect(message).toContain(context); expect(target).toBe("_blank"); - expect(features).toBe("noopener,noreferrer"); + // sem "noopener" na chamada (ele faria o window.open devolver sempre null); o vínculo é cortado depois + expect(rest).toEqual([]); + expect(openedWindow.opener).toBeNull(); expect(window.gtag).toHaveBeenCalledTimes(1); expect(window.gtag).toHaveBeenCalledWith("event", "whatsapp_open", { send_to: "G-QTT9QT0T3S", @@ -60,7 +75,7 @@ it("prepares an isolated WhatsApp draft with the entered project details only af }); it("does not open WhatsApp while required fields are empty or the email is invalid", async () => { - const user = userEvent.setup(); + const user = setup(); render(); const submit = screen.getByRole("button", { name: /whatsapp/i }); @@ -84,7 +99,7 @@ it("does not open WhatsApp while required fields are empty or the email is inval it("opens the project draft with analytics unavailable and consent denied", async () => { localStorage.setItem("evandro.analytics-consent.v1", "denied"); delete window.gtag; - const user = userEvent.setup(); + const user = setup(); render(); await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); await user.type( @@ -105,7 +120,7 @@ it("opens the project draft with analytics unavailable and consent denied", asyn }); it("lets the visitor skip the e-mail and leaves that line out of the message", async () => { - const user = userEvent.setup(); + const user = setup(); render(); expect(screen.getByRole("textbox", { name: /e-mail/i })).not.toBeRequired(); @@ -120,23 +135,31 @@ it("lets the visitor skip the e-mail and leaves that line out of the message", a const url = new URL(String(jest.mocked(window.open).mock.calls[0][0])); const message = url.searchParams.get("text") ?? ""; expect(message).toContain("Nome: Ana"); - expect(message).toContain("Tipo de projeto: Site para empresa"); + expect(message).toContain("Tipo de projeto: Ainda preciso definir"); expect(message).not.toContain("E-mail:"); }); -it("offers 'Site para empresa' as the first and default project type", () => { +it("offers 'Ainda preciso definir' as the first and default project type", () => { render(); const subject = screen.getByRole("combobox", { name: /tipo de projeto/i }); const options = Array.from(subject.querySelectorAll("option")).map( (option) => option.value, ); - expect(options[0]).toBe("Site para empresa"); - expect(subject).toHaveValue("Site para empresa"); + expect(options[0]).toBe("Ainda preciso definir"); + expect(options).toContain("Site para empresa"); + expect(subject).toHaveValue("Ainda preciso definir"); +}); + +it("labels the first field 'Seu nome'", () => { + render(); + + expect(screen.getByLabelText("Seu nome")).toHaveAttribute("name", "name"); + expect(screen.queryByLabelText("Nome completo")).toBeNull(); }); it("explains each invalid field in Portuguese and marks it for assistive technology", async () => { - const user = userEvent.setup(); + const user = setup(); render(); await user.type( @@ -171,7 +194,7 @@ it("explains each invalid field in Portuguese and marks it for assistive technol }); it("offers the same text by e-mail, below the WhatsApp button", async () => { - const user = userEvent.setup(); + const user = setup(); render(); await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); @@ -194,7 +217,7 @@ it("offers the same text by e-mail, below the WhatsApp button", async () => { const body = url.searchParams.get("body") ?? ""; expect(body).toContain("Nome: Ana"); expect(body).toContain("Organizar pedidos & estoque"); - expect(body).toContain("Tipo de projeto: Site para empresa"); + expect(body).toContain("Tipo de projeto: Ainda preciso definir"); await user.click(mail); expect(window.gtag).toHaveBeenCalledWith( @@ -214,3 +237,205 @@ it("does not call the direct WhatsApp row 'Contato'", () => { expect(text).toContain("Quero conversar sobre um projeto."); expect(text).not.toMatch(/sobre Contato/); }); + +describe("e-mail draft validation", () => { + it("does not open the e-mail client while the form is empty, and explains what is missing", () => { + render(); + + const mail = screen.getByRole("link", { + name: "Prefere e-mail? Enviar por e-mail", + }); + // fireEvent.click devolve false quando o clique foi cancelado (preventDefault) + expect(fireEvent.click(mail)).toBe(false); + + expect( + screen.getByText("Informe seu nome para eu saber com quem falo."), + ).toBeVisible(); + expect( + screen.getByText("Conte em poucas palavras o que você precisa resolver."), + ).toBeVisible(); + expect(screen.getByRole("textbox", { name: /nome/i })).toHaveFocus(); + expect(window.gtag).not.toHaveBeenCalled(); + }); + + it("blocks a malformed e-mail address the same way", async () => { + const user = setup(); + render(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /e-mail/i }), + "email-invalido", + ); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + + const mail = screen.getByRole("link", { + name: "Prefere e-mail? Enviar por e-mail", + }); + expect(fireEvent.click(mail)).toBe(false); + expect(screen.getByRole("textbox", { name: /e-mail/i })).toHaveFocus(); + }); + + it("lets the click through, and measures it, once the form is valid", async () => { + const user = setup(); + render(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + + const mail = screen.getByRole("link", { + name: "Prefere e-mail? Enviar por e-mail", + }); + // o clique não é cancelado pela validação: o navegador segue para o mailto: + expect(fireEvent.click(mail)).toBe(true); + + expect(window.gtag).toHaveBeenCalledWith( + "event", + "email_click", + expect.objectContaining({ placement: "contact_form" }), + ); + }); +}); + +describe("WhatsApp fallback", () => { + it("goes to WhatsApp in the same page when the browser blocks the new tab", async () => { + // o jsdom não navega: ele avisa "Not implemented: navigation" ao atribuir location.href + const errors = jest.spyOn(console, "error").mockImplementation(() => {}); + jest.mocked(window.open).mockImplementation(() => null); + const user = setup(); + render(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + + await user.click(screen.getByRole("button", { name: /whatsapp/i })); + + expect(window.open).toHaveBeenCalledTimes(1); + expect( + errors.mock.calls.some(([error]) => + String(error).includes("Not implemented: navigation"), + ), + ).toBe(true); + expect(window.gtag).toHaveBeenCalledWith( + "event", + "whatsapp_open", + expect.objectContaining({ placement: "contact_form" }), + ); + }); + + it("does not navigate the page when the new tab opened", async () => { + const errors = jest.spyOn(console, "error").mockImplementation(() => {}); + const user = setup(); + render(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + + await user.click(screen.getByRole("button", { name: /whatsapp/i })); + + expect(errors).not.toHaveBeenCalled(); + }); + + /** Preenche o mínimo e envia; devolve os erros que o jsdom registrou ao "navegar" para location.href. */ + async function submitWithMinimumFields() { + const errors = jest.spyOn(console, "error").mockImplementation(() => {}); + const user = setup(); + render(); + await user.type(screen.getByRole("textbox", { name: /nome/i }), "Ana"); + await user.type( + screen.getByRole("textbox", { name: /contexto/i }), + "Organizar pedidos", + ); + await user.click(screen.getByRole("button", { name: /whatsapp/i })); + return errors; + } + + const navigated = (errors: jest.SpyInstance) => + errors.mock.calls.some(([error]) => + String(error).includes("Not implemented: navigation"), + ); + + it("goes to WhatsApp in the same page when there is no window.open", async () => { + const original = window.open; + Object.defineProperty(window, "open", { + configurable: true, + value: undefined, + }); + try { + const errors = await submitWithMinimumFields(); + + expect(navigated(errors)).toBe(true); + expect(window.gtag).toHaveBeenCalledWith( + "event", + "whatsapp_open", + expect.objectContaining({ placement: "contact_form" }), + ); + } finally { + Object.defineProperty(window, "open", { + configurable: true, + value: original, + }); + } + }); + + it("goes to WhatsApp in the same page when window.open throws", async () => { + jest.mocked(window.open).mockImplementation(() => { + throw new DOMException("bloqueado", "SecurityError"); + }); + + const errors = await submitWithMinimumFields(); + + expect(navigated(errors)).toBe(true); + }); + + it("closes the new tab and stays on the same page when its link to this page cannot be cut", async () => { + const close = jest.fn(); + const tab = { + close, + get opener() { + return {}; + }, + set opener(_value: unknown) { + throw new DOMException("sem acesso", "SecurityError"); + }, + }; + jest.mocked(window.open).mockImplementation(() => tab as unknown as Window); + + const errors = await submitWithMinimumFields(); + + expect(close).toHaveBeenCalledTimes(1); + expect(navigated(errors)).toBe(true); + expect(window.gtag).toHaveBeenCalledWith( + "event", + "whatsapp_open", + expect.objectContaining({ placement: "contact_form" }), + ); + }); +}); + +describe("without JavaScript", () => { + it("keeps the form body out of the way and offers direct WhatsApp and e-mail links in a