diff --git a/app/components/About.paths.test.tsx b/app/components/About.paths.test.tsx index 52ee1cd..094c3bc 100644 --- a/app/components/About.paths.test.tsx +++ b/app/components/About.paths.test.tsx @@ -1,6 +1,23 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import About from "./About"; +// 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} + + ), +})); + /** * 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 @@ -89,24 +106,69 @@ describe("About", () => { expect(cards[3]).toHaveClass("cv-press"); }); - it("path: the story, the profile points and the résumé link are all there", () => { - render(); + it("path: the section has three points, each with the proof that backs it, and the résumé link", () => { + const { container } = render(); + const titles = [ + "Quem conversa com você escreve o código", + "Aproveito o que você já tem", + "Do código ao sistema no ar", + ]; + for (const name of titles) { + expect( + screen.getByRole("heading", { level: 3, name }), + ).toBeInTheDocument(); + } + // no máximo três pontos: os três cartões da história; o cartão do perfil não é um quarto ponto, só diz do que cuido + const story = [...container.querySelectorAll("article")].slice(0, 3); + expect(story).toHaveLength(3); + for (const card of story) { + expect(within(card).getAllByText("Na prática:")).toHaveLength(1); + } + // a prova de cada ponto é concreta: a Apolo desde 2022, a revisão e os cases entregues + expect(story[0]).toHaveTextContent( + /Na prática: Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT/, + ); + expect(story[1]).toHaveTextContent( + /Na prática: Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer/, + ); + expect(story[2]).toHaveTextContent( + /Na prática: Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços\./, + ); expect( - screen.getByRole("heading", { - level: 3, - name: "Sem intermediários entre o pedido e o sistema", + within(story[1]).getByRole("link", { + name: "Ver como funciona a revisão", }), - ).toBeInTheDocument(); - expect( - screen.getByText("Solução do tamanho do seu problema"), - ).toBeInTheDocument(); + ).toHaveAttribute("href", "/ia"); 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", () => { + it("path: the idea of talking to whoever develops shows up once, not in five texts", () => { + const { container } = render(); + + expect(container.textContent).not.toMatch( + /Sem intermediários|Uma pessoa cuida|Quem responde pelo projeto|acompanha as decisões|fala com quem desenvolve|Solução do tamanho|Tudo funcionando junto/, + ); + expect(container.textContent?.match(/mesma pessoa/g)).toHaveLength(1); + }); + + it("path: the résumé and the review links pre-fetch only when the visitor reaches them (HoverPrefetchLink), as in the rest of the home", () => { + render(); + + for (const name of [ + "Ver trajetória completa", + "Ver como funciona a revisão", + ]) { + const link = screen.getByRole("link", { name }); + expect(link).toHaveAttribute("data-prefetch", "false"); + fireEvent.mouseEnter(link); + expect(link).toHaveAttribute("data-prefetch", "null"); + } + }); + + it("path: speaks in client benefits, with no technical jargon, and cites the Apolo Energia work once, as the proof of the first point", () => { const { container } = render(); expect(container.textContent).not.toMatch( @@ -116,14 +178,14 @@ describe("About", () => { expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent( "Evandro Fonseca Junior", ); - expect(screen.getByTestId("about-description").textContent).toMatch( - /^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\./, + expect(screen.getByTestId("about-description").textContent).toBe( + "Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas.", ); // 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 + // a frase da Apolo aparece uma só vez (na prova do primeiro ponto) expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1); }); @@ -131,7 +193,7 @@ describe("About", () => { const { container } = render(); expect(container.textContent).not.toMatch(/Como eu trabalho/); - expect(screen.getByText("Acompanhamento")).toBeInTheDocument(); + expect(screen.getByText("Quem desenvolve")).toBeInTheDocument(); }); it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => { @@ -142,13 +204,14 @@ describe("About", () => { ); }); - it("path: the location label stays on one line and the columns stretch to the same height", () => { + it("path: the location label stays on one line and the portrait card keeps its own height next to the text cards", () => { const { container } = render(); expect(screen.getByText(/Garuva, SC · Atendimento remoto/)).toHaveClass( "whitespace-nowrap", ); - expect(container.querySelector(".ds-grid-2")).toHaveClass("items-stretch"); + // items-start: o retrato tem a altura do próprio conteúdo e acompanha a rolagem; só os cartões de texto se esticam entre si + expect(container.querySelector(".ds-grid-2")).toHaveClass("items-start"); 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 2b523ce..eb2350c 100644 --- a/app/components/About.tsx +++ b/app/components/About.tsx @@ -1,48 +1,42 @@ -import Link from "next/link"; 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 HoverPrefetchLink from "./HoverPrefetchLink"; import { Reveal } from "./Reveal"; import { Section } from "./Section"; +/** + * Os três pontos da seção, cada um com a prova que o sustenta. A ideia de que quem conversa com você é quem desenvolve + * aparece só no primeiro cartão: antes ela se repetia em cinco textos ("Sem intermediários", "Uma pessoa cuida", + * "Quem responde pelo projeto", "Você acompanha as decisões" e "Você fala com quem desenvolve", que segue só no hero). + */ const STORY_DATA = [ { - label: "Acompanhamento", - title: "Sem intermediários entre o pedido e o sistema", + label: "Quem desenvolve", + title: "Quem conversa com você escreve o código", content: - "Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.", + "A mesma pessoa entende o problema, decide a estrutura e desenvolve o sistema, do começo ao fim.", + proof: + "Na Apolo Energia, desde 2022, desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas.", }, { label: "Sem começar do zero", title: "Aproveito o que você já tem", content: "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.", + proof: + "Na revisão, você recebe o que dá para aproveitar e o que é melhor refazer, com o motivo de cada parte.", + link: { href: "/ia", label: "Ver como funciona a revisão" }, }, { label: "Depois da entrega", title: "Do código ao sistema no ar", content: "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: "Solução do tamanho do seu problema", - description: - "Escolho o que resolve o seu caso, sem complicar além do necessário.", - }, - { - title: "Tudo funcionando junto", - description: - "A tela, os dados e os equipamentos são ligados para trabalhar como uma coisa só.", - }, - { - title: "Quem responde pelo projeto", - description: - "Uma só pessoa decide a estrutura e desenvolve o sistema, do começo ao fim.", + proof: + "Já entregue para SEV Moda Íntima, Lavação 047, SC Plus Aquicultura e AS Serviços.", }, ]; @@ -55,7 +49,7 @@ export default function About() { id="about" eyebrow="Quem conduz o projeto" title="Evandro Fonseca Junior" - 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." + description="Engenheiro da computação e desenvolvedor de software, com projetos entregues para empresas." disableChildrenAnimation aside={
@@ -64,7 +58,8 @@ export default function About() {
} > -
+ {/* items-start: o cartão do retrato tem a altura do próprio conteúdo, e não a das três colunas de texto; no desktop ele acompanha a rolagem */} +
{STORY_DATA.map((step, index) => ( {step.content}

+

+ Na prática: + {step.proof} +

+ {step.link && ( + + {step.link.label} + + )}
))} @@ -90,24 +97,25 @@ export default function About() { y={20} duration={0.8} delay={cardDelay(STORY_DATA.length)} - className="h-full" + className="lg:sticky lg:top-28" > -
-
+
+
-
-
Da ideia ao sistema no ar
-

- Uma pessoa cuida do sistema inteiro -

-
-
+
+ Evandro Fonseca Junior · Engenheiro da computação · Garuva, SC +
+ +
Do que eu cuido
+

+ Tela, dados e equipamentos +

Cuido da tela que sua equipe usa, dos dados por trás dela e, @@ -115,25 +123,12 @@ export default function About() { sistema. Ver tudo junto ajuda a decidir o que construir, o que conectar e o que dá para aproveitar.

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

{point.description}

-
- ))} -
- Ver trajetória completa - +
diff --git a/app/components/Contact.kind.paths.test.tsx b/app/components/Contact.kind.paths.test.tsx index b15e97b..a3a2338 100644 --- a/app/components/Contact.kind.paths.test.tsx +++ b/app/components/Contact.kind.paths.test.tsx @@ -3,7 +3,7 @@ import Contact from "./Contact"; import { CONTACT_KIND_EVENT, DEFAULT_CONTACT_SUBJECT } from "./contact-subject"; /** - * Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact`), complemento de + * Testes de caminhos-base da leitura do tipo da oferta em Contact (`/?tipo=…#contact-form`), complemento de * Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento. */ @@ -38,14 +38,22 @@ describe("Contact: tipo no endereço", () => { }); it.each([ - ["/?tipo=site#contact", "Site para empresa"], - ["/?tipo=sistema#contact", "Sistemas web sob medida"], + ["/?tipo=site#contact-form", "Site que traz pedidos pelo WhatsApp"], [ - "/?utm_source=x&tipo=revisao#contact", - "Revisão ou diagnóstico de sistema", + "/?tipo=sistema#contact-form", + "Sistema para tirar a operação da planilha", ], + [ + "/?utm_source=x&tipo=revisao#contact-form", + "Revisão de sistema que já existe", + ], + // o endereço antigo, com #contact, continua valendo: o tipo vem da busca, não do fragmento + ["/?tipo=site#contact", "Site que traz pedidos pelo WhatsApp"], // com o parâmetro repetido vale o primeiro - ["/?tipo=site&tipo=revisao#contact", "Site para empresa"], + [ + "/?tipo=site&tipo=revisao#contact-form", + "Site que traz pedidos pelo WhatsApp", + ], ])("path: %s (valid type) -> selects %s", (address, option) => { window.history.replaceState(null, "", address); @@ -60,7 +68,9 @@ describe("Contact: tipo no endereço", () => { choose("desconhecido"); - expect(subjectSelect()).toHaveValue("Sistemas web sob medida"); + expect(subjectSelect()).toHaveValue( + "Sistema para tirar a operação da planilha", + ); }); it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => { @@ -69,6 +79,6 @@ describe("Contact: tipo no endereço", () => { choose("revisao"); - expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); + expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe"); }); }); diff --git a/app/components/Contact.offer.test.tsx b/app/components/Contact.offer.test.tsx index c7cd055..ab67227 100644 --- a/app/components/Contact.offer.test.tsx +++ b/app/components/Contact.offer.test.tsx @@ -5,7 +5,8 @@ import { CONTACT_KIND_EVENT } from "./contact-subject"; /** * O tipo de projeto do formulário vindo da oferta escolhida na home: pelo evento que o botão da oferta dispara - * (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página), mais a opção de revisão de sistema. + * (na própria home) ou pelo `?tipo=` do endereço (ao chegar de outra página). As três ofertas entram no formulário + * com o nome que têm na home, o formulário avisa "Você escolheu: " e o link direto do WhatsApp leva o tipo. */ const subjectSelect = () => @@ -23,18 +24,18 @@ afterEach(() => { }); describe("Contact: opções do tipo de projeto", () => { - it("lists 'Revisão ou diagnóstico de sistema' and keeps 'Ainda preciso definir' first and selected", () => { + it("lists the three offers by their names and keeps 'Ainda preciso definir' first and selected", () => { render(); const options = within(subjectSelect()).getAllByRole("option"); expect(options.map((option) => option.textContent)).toEqual([ "Ainda preciso definir", - "Site para empresa", - "Sistemas web sob medida", + "Site que traz pedidos pelo WhatsApp", + "Sistema para tirar a operação da planilha", "Aplicativos", "Integrações e automações", "Evolução de um sistema existente", - "Revisão ou diagnóstico de sistema", + "Revisão de sistema que já existe", ]); expect(subjectSelect()).toHaveValue("Ainda preciso definir"); }); @@ -45,24 +46,22 @@ describe("Contact: opções do tipo de projeto", () => { await user.selectOptions( subjectSelect(), - "Revisão ou diagnóstico de sistema", + "Revisão de sistema que já existe", ); const mail = screen.getByRole("link", { name: /e-mail/i }); const body = new URL(mail.getAttribute("href") ?? "").searchParams.get( "body", ); - expect(body).toContain( - "Tipo de projeto: Revisão ou diagnóstico de sistema", - ); + expect(body).toContain("Tipo de projeto: Revisão de sistema que já existe"); }); }); describe("Contact: tipo escolhido pela oferta", () => { it.each([ - ["site", "Site para empresa"], - ["sistema", "Sistemas web sob medida"], - ["revisao", "Revisão ou diagnóstico de sistema"], + ["site", "Site que traz pedidos pelo WhatsApp"], + ["sistema", "Sistema para tirar a operação da planilha"], + ["revisao", "Revisão de sistema que já existe"], ])("the offer event with %s selects %s", (kind, option) => { render(); @@ -80,7 +79,7 @@ describe("Contact: tipo escolhido pela oferta", () => { expect(subjectSelect()).toHaveValue("Aplicativos"); choose("revisao"); - expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); + expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe"); }); it.each([ @@ -97,15 +96,20 @@ describe("Contact: tipo escolhido pela oferta", () => { choose(kind); - expect(subjectSelect()).toHaveValue("Sistemas web sob medida"); + expect(subjectSelect()).toHaveValue( + "Sistema para tirar a operação da planilha", + ); }); it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => { - window.history.replaceState(null, "", "/?tipo=revisao#contact"); + window.history.replaceState(null, "", "/?tipo=revisao#contact-form"); render(); - expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema"); + expect(subjectSelect()).toHaveValue("Revisão de sistema que já existe"); + expect(screen.getByRole("status")).toHaveTextContent( + "Você escolheu: Revisão de sistema que já existe", + ); }); it("ignores an unknown ?tipo= in the address and keeps the default", () => { @@ -133,6 +137,86 @@ describe("Contact: tipo escolhido pela oferta", () => { }); }); +describe("Contact: você escolheu e o WhatsApp direto", () => { + const directLink = () => + screen.getByRole("link", { name: /Prefere conversar direto/ }); + const directMessage = () => + new URL(directLink().getAttribute("href") ?? "").searchParams.get("text"); + + it("renders the form card with the id the offers scroll to, and an empty status until an offer is chosen", () => { + const { container } = render(); + + expect(container.querySelector("form")).toHaveAttribute( + "id", + "contact-form", + ); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it.each([ + ["site", "Site que traz pedidos pelo WhatsApp"], + ["sistema", "Sistema para tirar a operação da planilha"], + ["revisao", "Revisão de sistema que já existe"], + ])( + "after the offer %s the form says 'Você escolheu: '", + (kind, name) => { + render(); + + choose(kind); + + expect(screen.getByRole("status")).toHaveTextContent( + `Você escolheu: ${name}`, + ); + expect(within(screen.getByRole("status")).getByText(name).tagName).toBe( + "STRONG", + ); + }, + ); + + it("drops the message when the person picks another type in the form, and keeps it for an unknown offer", async () => { + const user = userEvent.setup({ delay: null }); + render(); + + choose("desconhecido"); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + choose("sistema"); + expect(screen.getByRole("status")).not.toBeEmptyDOMElement(); + await user.selectOptions(subjectSelect(), "Aplicativos"); + + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + }); + + it("takes the chosen type to the direct WhatsApp link of the contact column, as a phrase, and back to 'um projeto'", async () => { + const user = userEvent.setup({ delay: null }); + render(); + expect(directMessage()).toBe( + "Olá, Evandro! Quero conversar sobre um projeto. Contato iniciado pelo site evandro.dev.br.", + ); + + choose("sistema"); + expect(directMessage()).toBe( + "Olá, Evandro! Quero conversar sobre um sistema para tirar a operação da planilha. Contato iniciado pelo site evandro.dev.br.", + ); + choose("revisao"); + expect(directMessage()).toContain( + "Quero conversar sobre a revisão de um sistema que já existe.", + ); + // quem escolhe o mesmo tipo pelo formulário também o leva para a mensagem + await user.selectOptions( + subjectSelect(), + "Site que traz pedidos pelo WhatsApp", + ); + expect(directMessage()).toContain( + "Quero conversar sobre um site que traz pedidos pelo WhatsApp.", + ); + // um tipo sem oferta volta para a frase de "um projeto" + await user.selectOptions(subjectSelect(), "Aplicativos"); + expect(directMessage()).toContain("Quero conversar sobre um projeto."); + // nada de nome de oferta com maiúscula na frase + expect(directMessage()).not.toMatch(/sobre [A-Z]/); + }); +}); + describe("Contact: texto da seção", () => { it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => { const { container } = render(); diff --git a/app/components/Contact.test.tsx b/app/components/Contact.test.tsx index 798e5ce..beb4c60 100644 --- a/app/components/Contact.test.tsx +++ b/app/components/Contact.test.tsx @@ -147,7 +147,7 @@ it("offers 'Ainda preciso definir' as the first and default project type", () => (option) => option.value, ); expect(options[0]).toBe("Ainda preciso definir"); - expect(options).toContain("Site para empresa"); + expect(options).toContain("Site que traz pedidos pelo WhatsApp"); expect(subject).toHaveValue("Ainda preciso definir"); }); diff --git a/app/components/Contact.tsx b/app/components/Contact.tsx index 690caf5..2c7fccb 100644 --- a/app/components/Contact.tsx +++ b/app/components/Contact.tsx @@ -5,11 +5,19 @@ import type { FormEvent, MouseEvent, ReactNode } from "react"; import { useEffect, useState } from "react"; import { trackEmailClick, trackWhatsAppOpen } from "@/lib/analytics"; import { CONTACT_EMAIL } from "@/lib/site-navigation"; -import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp"; import { + buildWhatsAppUrl, + getWhatsAppDisplayNumber, + OFFER_SUBJECTS, +} from "@/lib/whatsapp"; +import { + CONTACT_FORM_ID, CONTACT_KIND_EVENT, CONTACT_KIND_PARAM, + CONTACT_KINDS, CONTACT_SUBJECT_OPTIONS, + type ContactKind, + contactKindForSubject, contactSubjectForKind, DEFAULT_CONTACT_SUBJECT, } from "./contact-subject"; @@ -162,13 +170,17 @@ export default function Contact() { message: "", }); const [errors, setErrors] = useState({}); + // A oferta que a pessoa escolheu na home (botão ou `?tipo=`); some se ela trocar o tipo no próprio formulário. + const [chosenOffer, setChosenOffer] = useState(null); // O tipo de projeto pode vir da oferta escolhida na home: pelo endereço (`/?tipo=site#contact`, ao chegar de outra // página) ou pelo evento que o botão da oferta dispara na própria página. Tipo desconhecido não muda nada. useEffect(() => { const choose = (kind: string | null | undefined) => { const subject = contactSubjectForKind(kind); - if (subject) setFormData((current) => ({ ...current, subject })); + if (!subject) return; + setFormData((current) => ({ ...current, subject })); + setChosenOffer(contactKindForSubject(subject) ?? null); }; choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM)); const onChoose = (event: Event) => @@ -179,6 +191,7 @@ export default function Contact() { const updateField = (field: ContactField | "subject", value: string) => { setFormData((current) => ({ ...current, [field]: value })); + if (field === "subject") setChosenOffer(null); if (field !== "subject" && errors[field]) { setErrors((current) => ({ ...current, [field]: undefined })); } @@ -195,6 +208,12 @@ export default function Contact() { return firstInvalid !== undefined; }; + // O link direto de WhatsApp leva o tipo escolhido (o nome da oferta, em frase) na mensagem; sem oferta, fala de "um projeto". + const selectedOffer = contactKindForSubject(formData.subject); + const directSubject = selectedOffer + ? OFFER_SUBJECTS[selectedOffer] + : undefined; + const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (hasInvalidField(e.currentTarget)) return; @@ -243,6 +262,7 @@ export default function Contact() {
@@ -283,6 +303,7 @@ export default function Contact() {
)} + {/* (role status): o texto muda quando uma oferta da home é escolhida, e o leitor de tela o anuncia; vazio, não ocupa espaço */} + + {chosenOffer && ( + <> + Você escolheu:{" "} + + {CONTACT_KINDS[chosenOffer]} + + + )} + { window.history.replaceState(null, "", "/"); replaceState = jest.spyOn(window.history, "replaceState"); form = document.createElement("section"); - form.id = "contact"; + form.id = "contact-form"; document.body.append(form); }); @@ -96,10 +96,10 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => { 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 + // o gerenciador abriu uma entrada de histórico (/?tipo=site#contact-form); a oferta só a reescreve expect(window.history.length).toBe(entries + 1); expect(window.location.search).toBe(""); - expect(window.location.hash).toBe("#contact"); + expect(window.location.hash).toBe("#contact-form"); expect(replaceState).toHaveBeenCalledTimes(1); } finally { stop(); @@ -107,8 +107,8 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => { } }); - it("path: the manager takes the click and the address is already /#contact -> no replaceState at all", () => { - window.history.replaceState(null, "", "/#contact"); + it("path: the manager takes the click and the address is already /#contact-form -> no replaceState at all", () => { + window.history.replaceState(null, "", "/#contact-form"); replaceState.mockClear(); const stop = installAnchorManager(); try { @@ -118,7 +118,9 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => { expect(scrollTo).toHaveBeenCalledTimes(1); expect(replaceState).not.toHaveBeenCalled(); - expect(window.location.pathname + window.location.hash).toBe("/#contact"); + expect(window.location.pathname + window.location.hash).toBe( + "/#contact-form", + ); } finally { stop(); } @@ -131,9 +133,9 @@ describe("ContactOfferLink: gerenciador de âncoras real", () => { expect(fireEvent.click(link)).toBe(false); expect(scrollIntoView).toHaveBeenCalledTimes(1); expect(form).toHaveFocus(); - expect(window.location.hash).toBe("#contact"); + expect(window.location.hash).toBe("#contact-form"); - // o endereço já é /#contact (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço + // o endereço já é /#contact-form (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); @@ -177,13 +179,17 @@ describe("ContactOfferLink: teclas e histórico", () => { 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"); + window.history.replaceState(routerState, "", "/?tipo=site#contact-form"); replaceState.mockClear(); render(Começar); fireEvent.click(screen.getByRole("link", { name: "Começar" })); - expect(replaceState).toHaveBeenCalledWith(routerState, "", "/#contact"); + expect(replaceState).toHaveBeenCalledWith( + routerState, + "", + "/#contact-form", + ); expect(window.history.state).toEqual(routerState); }); }); diff --git a/app/components/ContactOfferLink.test.tsx b/app/components/ContactOfferLink.test.tsx index 0a64754..543d9f5 100644 --- a/app/components/ContactOfferLink.test.tsx +++ b/app/components/ContactOfferLink.test.tsx @@ -27,7 +27,7 @@ describe("ContactOfferLink", () => { ); const link = screen.getByRole("link", { name: "Começar pelo sistema" }); - expect(link).toHaveAttribute("href", "/?tipo=sistema#contact"); + expect(link).toHaveAttribute("href", "/?tipo=sistema#contact-form"); expect(link).toHaveClass("botao"); expect(link).toHaveAttribute("data-prefetch", "false"); fireEvent.mouseEnter(link); @@ -88,7 +88,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => { window.history.replaceState(null, "", "/"); setReducedMotion(false); form = document.createElement("section"); - form.id = "contact"; + form.id = "contact-form"; document.body.append(form); }); @@ -98,7 +98,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => { window.history.replaceState(null, "", "/"); }); - it("cancels the navigation on the home, rolls to #contact, focuses the form and leaves the address as /#contact", () => { + it("cancels the navigation on the home, rolls to #contact-form, focuses the form and leaves the address as /#contact-form", () => { const heard: unknown[] = []; const listener = (event: Event) => heard.push((event as CustomEvent).detail); @@ -120,7 +120,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => { }); expect(form).toHaveFocus(); expect(window.location.pathname + window.location.search).toBe("/"); - expect(window.location.hash).toBe("#contact"); + expect(window.location.hash).toBe("#contact-form"); expect(pushState).not.toHaveBeenCalled(); } finally { pushState.mockRestore(); @@ -140,16 +140,20 @@ describe("ContactOfferLink: rolagem até o formulário", () => { }); 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"); + window.history.replaceState( + null, + "", + "/?utm_source=ads&tipo=site#contact-form", + ); 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"); + expect(window.location.hash).toBe("#contact-form"); }); 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"); + window.history.replaceState(null, "", "/?tipo=site#contact-form"); const cancel = (event: Event) => event.preventDefault(); document.addEventListener("click", cancel, true); try { @@ -158,7 +162,7 @@ describe("ContactOfferLink: rolagem até o formulário", () => { fireEvent.click(screen.getByRole("link", { name: "Começar" })); expect(scrollIntoView).not.toHaveBeenCalled(); expect(window.location.search).toBe(""); - expect(window.location.hash).toBe("#contact"); + expect(window.location.hash).toBe("#contact-form"); } finally { document.removeEventListener("click", cancel, true); } @@ -176,19 +180,19 @@ describe("ContactOfferLink: rolagem até o formulário", () => { expect(window.location.hash).toBe(""); }); - it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact", () => { + it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact-form", () => { window.history.replaceState(null, "", "/privacidade"); render(Começar); const link = screen.getByRole("link", { name: "Começar" }); - expect(link).toHaveAttribute("href", "/?tipo=revisao#contact"); + expect(link).toHaveAttribute("href", "/?tipo=revisao#contact-form"); 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", () => { + it("falls back to the Link navigation when the page has no #contact-form", () => { form.remove(); render(Começar); diff --git a/app/components/ContactOfferLink.tsx b/app/components/ContactOfferLink.tsx index f925fea..78e4095 100644 --- a/app/components/ContactOfferLink.tsx +++ b/app/components/ContactOfferLink.tsx @@ -8,6 +8,7 @@ import { isPlainPrimaryClick, } from "./cinematic/fragment-nav"; import { + CONTACT_FORM_HASH, CONTACT_KIND_EVENT, CONTACT_KIND_PARAM, type ContactKind, @@ -15,17 +16,15 @@ import { } 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. + * Deixa o endereço da home com o fragmento do cartão do formulário (`/#contact-form`) 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; + url.hash = CONTACT_FORM_HASH; const next = `${url.pathname}${url.search}${url.hash}`; const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; if (next !== current) @@ -33,14 +32,14 @@ function showContactInAddress() { } /** - * 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 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. + * Botão de uma oferta da home: leva ao cartão do formulário de contato (`#contact-form`, com o campo do tipo de projeto + * à vista, também no celular) com o tipo já escolhido. Na própria home o tipo segue por evento e a página rola até o + * cartão (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`. + * o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact-form`. */ export default function ContactOfferLink({ kind, @@ -56,7 +55,7 @@ export default function ContactOfferLink({ new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }), ); if (window.location.pathname !== "/") return; - const form = elementForHash(CONTACT_HASH); + const form = elementForHash(CONTACT_FORM_HASH); if (!form) return; if (event.defaultPrevented) { showContactInAddress(); diff --git a/app/components/FloatingWhatsApp.paths.test.tsx b/app/components/FloatingWhatsApp.paths.test.tsx index 1c94d73..d9ab24f 100644 --- a/app/components/FloatingWhatsApp.paths.test.tsx +++ b/app/components/FloatingWhatsApp.paths.test.tsx @@ -1,6 +1,8 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ComponentProps } from "react"; +import { resetNavigation, setNavigation } from "@/__mocks__/next/navigation"; +import { HOME_SUBJECT } from "@/lib/whatsapp"; import FloatingWhatsApp from "./FloatingWhatsApp"; import GoogleAnalytics from "./GoogleAnalytics"; @@ -50,6 +52,7 @@ const floatingLink = () => screen.queryByRole("link", { name: "Conversar no WhatsApp" }); beforeEach(() => { + resetNavigation(); instances.length = 0; global.IntersectionObserver = FakeObserver as unknown as typeof IntersectionObserver; @@ -191,7 +194,20 @@ describe("FloatingWhatsApp: conteúdo e cliques", () => { expect(link?.parentElement).toHaveClass("md:hidden", "fixed", "right-sp-4"); }); - it("path: click reports 'Contato' as the service because the floating link has no subject", () => { + it("path: click on the home reports the home subject as the service (the central navigation mock opens on '/')", () => { + render(); + + fireEvent.click(floatingLink() as HTMLElement, { button: 0 }); + + expect(window.gtag).toHaveBeenCalledWith( + "event", + "whatsapp_open", + expect.objectContaining({ service: HOME_SUBJECT, placement: "floating" }), + ); + }); + + it("path: click on a page without a subject reports 'Contato' as the service", () => { + setNavigation({ pathname: "/privacidade" }); render(); fireEvent.click(floatingLink() as HTMLElement, { button: 0 }); diff --git a/app/components/FloatingWhatsApp.route.test.tsx b/app/components/FloatingWhatsApp.route.test.tsx index a16aa3e..9702964 100644 --- a/app/components/FloatingWhatsApp.route.test.tsx +++ b/app/components/FloatingWhatsApp.route.test.tsx @@ -8,10 +8,9 @@ import FloatingWhatsApp from "./FloatingWhatsApp"; * do hero e do contato (que só existem na home) recomeça a cada troca de página. */ -jest.mock("next/navigation", () => ({ usePathname: jest.fn() })); - const contexts: WhatsAppContexts = { - services: { "criacao-de-sites": "Sites profissionais" }, + // o whatsappSubject do serviço (lib/site-content.ts): uma frase em minúsculas, que cabe em "Quero conversar sobre {assunto}." + services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" }, cases: { "sev-modaintima": "SEV Moda Íntima" }, }; @@ -57,7 +56,7 @@ describe("message by route", () => { it.each([ [ "/servicos/criacao-de-sites", - "Quero conversar sobre Sites profissionais.", + "Quero conversar sobre um site que traz pedidos pelo WhatsApp.", ], [ "/cases/sev-modaintima", @@ -66,7 +65,7 @@ describe("message by route", () => { ["/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 ["/", "Quero conversar sobre um site ou sistema para a minha empresa."], - ["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."], + ["/servicos", "Quero conversar sobre sites, sistemas e aplicativos."], [ "/cases", "Quero conversar sobre um projeto parecido com os cases do portfólio.", @@ -132,7 +131,7 @@ describe("page changes", () => { rerender(); expect(instances[0].disconnect).toHaveBeenCalled(); expect(messageOf(floatingLink())).toContain( - "Quero conversar sobre Sites profissionais.", + "Quero conversar sobre um site que traz pedidos pelo WhatsApp.", ); }); }); diff --git a/app/components/FloatingWhatsApp.test.tsx b/app/components/FloatingWhatsApp.test.tsx index 26b093b..26ee1a6 100644 --- a/app/components/FloatingWhatsApp.test.tsx +++ b/app/components/FloatingWhatsApp.test.tsx @@ -1,4 +1,5 @@ import { act, fireEvent, render, screen } from "@testing-library/react"; +import { HOME_SUBJECT } from "@/lib/whatsapp"; import FloatingWhatsApp from "./FloatingWhatsApp"; const CONSENT_KEY = "evandro.analytics-consent.v1"; @@ -79,8 +80,9 @@ it("stays hidden until the observer reports, then shows a 56px WhatsApp shortcut expect(link).toHaveAttribute("rel", "noopener noreferrer"); const url = new URL(link?.getAttribute("href") ?? ""); expect(url.origin).toBe("https://wa.me"); + // o dublê central de next/navigation abre na home ("/"): o botão fala do assunto da home expect(url.searchParams.get("text")).toContain( - "Quero conversar sobre um projeto.", + `Quero conversar sobre ${HOME_SUBJECT}.`, ); // alvo de 56 px, canto inferior direito, só abaixo de 768 px expect(link).toHaveClass("h-14", "w-14"); diff --git a/app/components/Footer.test.tsx b/app/components/Footer.test.tsx index 9bc908a..b52d4fc 100644 --- a/app/components/Footer.test.tsx +++ b/app/components/Footer.test.tsx @@ -73,7 +73,9 @@ describe("Footer contact and navigation", () => { "/cases", ); expect( - within(more).getByRole("link", { name: "Revisão de app feito com IA" }), + within(more).getByRole("link", { + name: "Revisão de sistema que já existe", + }), ).toHaveAttribute("href", "/ia"); expect( within(more).getByRole("link", { name: "Privacidade" }), @@ -143,6 +145,30 @@ describe("Footer social links", () => { expect(instagram).toHaveAttribute("rel", "noopener noreferrer"); }); + it("names the three offers once, with the same words as the home, and no old 'app feito com IA' name", () => { + render(