import { expect, type Page, test } from "@playwright/test"; import { DESKTOP_USE, PHONE, PHONE_USE } from "./helpers"; /** * (h) Contato, no computador (1440x900) e, onde dito, no celular (390x844, toque): * - o botão de cada um dos quatro cartões da home ("Escolha por onde começar") leva o cartão do formulário * (#contact-form) para a tela, com o tipo de projeto da oferta já escolhido e o endereço `/#contact-form` (o `?tipo=` * sai), e um clique seguinte em "Falar sobre meu projeto" do cabeçalho continua rolando até #contact (a página rola * para outro lugar entre os dois cliques); * - o link direto `/?tipo=#contact-form` das cinco ofertas (quem chega de outra página, inclusive da nota * fiscal, que na home é uma faixa e não um cartão) abre a home com o formulário na tela e o tipo marcado; * - no celular, o toque no botão da oferta deixa o cartão do formulário e o campo do tipo à vista; * - de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact; * - com movimento reduzido, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois; * - um destino só: "Falar sobre meu projeto" do hero leva ao cartão do formulário; o WhatsApp é o link "Chamar no * WhatsApp", e nenhum controle chamado "Falar sobre meu projeto" abre o WhatsApp; * - no celular: os dois botões do hero e o link do WhatsApp na primeira dobra; a barra de cookies com até 72 px e o * link da política com 24 px de alvo; o botão flutuante some enquanto a barra está aberta. * "Formulário na tela": o título do contato e o campo "Tipo de projeto" ficam inteiros dentro da janela, abaixo do * cabeçalho fixo e acima da barra de cookies, quando ela está aberta (primeira visita, em qualquer servidor). */ const CTA_LABEL = "Falar sobre meu projeto"; /** * Tipo de cada oferta (o `?tipo=` do href do botão) -> opção que o formulário mostra marcada. Espelha CONTACT_KINDS * (app/components/contact-subject.ts): uma oferta nova sem entrada aqui fica sem o teste do link direto. */ const SUBJECT_BY_KIND = { site: "Site que traz pedidos pelo WhatsApp", sistema: "Sistema para tirar a operação da planilha", whatsapp: "Atendimento no WhatsApp com IA sob controle", revisao: "Revisão de sistema que já existe", fiscal: "Nota fiscal pronta para a reforma tributária", } as const; /** As ofertas com cartão e botão na home; a da nota fiscal aparece numa faixa com link para a página dela. */ const HOME_KINDS = ["site", "sistema", "whatsapp", "revisao"] as const; test.use(DESKTOP_USE); /** Abre a página e espera o tempo de a hidratação assentar, como os demais testes de interação. */ async function open(page: Page, path: string) { await page.goto(path, { waitUntil: "load" }); await page.waitForTimeout(1500); } const headerContact = (page: Page) => page.locator("header").getByRole("link", { name: CTA_LABEL }); const scrollToTop = (page: Page) => page.evaluate(() => window.scrollTo({ top: 0, behavior: "instant" })); /** * "ok" se as partes do contato estão inteiras na janela, abaixo do cabeçalho e acima da barra de cookies (se aberta); * senão, onde estão. `card`: o destino das ofertas (o cartão do formulário e o campo de tipo); sem ele, o destino do * botão do cabeçalho (o título do contato e o campo de tipo). */ function contactFormOnScreen(page: Page, card = false) { return page.evaluate((onlyCard) => { const box = (selector: string) => { const rect = document.querySelector(selector)?.getBoundingClientRect(); return rect ? { top: Math.round(rect.top), bottom: Math.round(rect.bottom) } : null; }; const headerBottom = Math.round( document.querySelector("header")?.getBoundingClientRect().bottom ?? 0, ); // a barra de cookies fixa na base cobre o que estiver abaixo do topo dela const barTop = document .getElementById("cookie-bar") ?.getBoundingClientRect().top; const limit = Math.min(innerHeight, barTop ?? innerHeight); const parts = onlyCard ? { "cartão do formulário": box("#contact-form"), "campo de tipo": box("#contact select[name='subject']"), } : { título: box("#contact h2"), "campo de tipo": box("#contact select[name='subject']"), }; const hidden = Object.entries(parts).filter( ([name, rect]) => !rect || rect.top < headerBottom - 1 || // o cartão inteiro pode ser mais alto que a janela: basta o topo dele estar à vista (name === "cartão do formulário" ? rect.top > limit + 1 : rect.bottom > limit + 1), ); return hidden.length === 0 ? "ok" : `fora da tela: ${hidden.map(([name]) => name).join(", ")}; ${JSON.stringify({ ...parts, headerBottom, janela: innerHeight, barraDeCookies: barTop ?? null, rolagem: Math.round(scrollY) })}`; }, card); } /** Espera a rolagem (suave ou não) levar o formulário de contato para a tela. */ async function expectContactOnScreen(page: Page, what: string, card = false) { await expect .poll(() => contactFormOnScreen(page, card), { message: `${what}: o formulário de contato na tela`, timeout: 8_000, }) .toBe("ok"); } test("a home tem os quatro cartões de oferta e cada um tem o seu tipo de projeto", async ({ page, }) => { await open(page, "/"); const hrefs = await page .locator("#modalities a[href*='?tipo=']") .evaluateAll((links) => links.map((link) => link.getAttribute("href"))); expect(hrefs.sort()).toEqual( [...HOME_KINDS].map((kind) => `/?tipo=${kind}#contact-form`).sort(), ); }); for (const kind of HOME_KINDS) { const subject = SUBJECT_BY_KIND[kind]; test(`oferta "${kind}": o botão leva o formulário à tela com "${subject}" marcado, e o botão do cabeçalho rola de novo até o contato`, async ({ page, }) => { await open(page, "/"); const offer = page.locator( `#modalities a[href='/?tipo=${kind}#contact-form']`, ); await offer.scrollIntoViewIfNeeded(); await offer.click(); await expectContactOnScreen(page, `depois da oferta "${kind}"`, true); await expect(page.locator("#contact select[name='subject']")).toHaveValue( subject, ); // o endereço fica na home, no cartão do formulário, sem o ?tipo= (o tipo já está no formulário) await expect(page).toHaveURL(/\/#contact-form$/); // a pessoa lê outro trecho da página e usa o botão do cabeçalho: ele ainda rola até o contato await scrollToTop(page); await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok"); await headerContact(page).click(); await expectContactOnScreen(page, "depois do clique no cabeçalho"); // e o tipo escolhido pela oferta continua marcado await expect(page.locator("#contact select[name='subject']")).toHaveValue( subject, ); }); } for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) { test(`link direto /?tipo=${kind}#contact-form: abre a home com o formulário à vista e "${subject}" marcado`, async ({ page, }) => { await page.goto(`/?tipo=${kind}#contact-form`, { waitUntil: "load" }); await page.waitForTimeout(1500); await expectContactOnScreen(page, `link direto "${kind}"`, true); await expect(page.locator("#contact select[name='subject']")).toHaveValue( subject, ); }); } test("the hero 'Falar sobre meu projeto' leads to the form card, and WhatsApp is the 'Chamar no WhatsApp' link", async ({ page, }) => { await open(page, "/"); const hero = page.locator("#home"); await expect( hero.getByRole("link", { name: "Chamar no WhatsApp" }), ).toHaveAttribute("href", /^https:\/\/wa\.me\/\d+\?/); // nenhum controle com o nome do formulário abre o WhatsApp for (const link of await page.getByRole("link", { name: CTA_LABEL }).all()) expect(await link.getAttribute("href")).not.toMatch(/wa\.me/); await hero.getByRole("link", { name: CTA_LABEL }).click(); await page.waitForURL(/\/#contact-form$/); await expectContactOnScreen(page, "clique no botão do hero", true); }); test.describe("no celular (toque)", () => { test.use(PHONE_USE); for (const kind of HOME_KINDS) { const subject = SUBJECT_BY_KIND[kind]; test(`oferta "${kind}": o toque deixa o cartão do formulário e o campo de tipo à vista, com "${subject}" marcado`, async ({ page, }) => { await open(page, "/"); const offer = page.locator( `#modalities a[href='/?tipo=${kind}#contact-form']`, ); await offer.scrollIntoViewIfNeeded(); await offer.tap(); await expectContactOnScreen(page, `toque na oferta "${kind}"`, true); await expect(page.locator("#contact select[name='subject']")).toHaveValue( subject, ); }); } test("keeps both hero buttons and the WhatsApp link in the first screen", async ({ page, }) => { await open(page, "/"); for (const name of [ CTA_LABEL, "Ver projetos entregues", "Chamar no WhatsApp", ]) { const box = await page .locator("#home") .getByRole("link", { name }) .boundingBox(); expect(box, name).not.toBeNull(); if (!box) continue; expect(box.y, name).toBeGreaterThanOrEqual(0); expect(box.y + box.height, name).toBeLessThanOrEqual(PHONE.height); } }); test("keeps the cookie bar within 72 px with a 24 px policy target, and hides the floating button while it is open", async ({ page, }) => { // fora da home não há convite à vista: só a barra esconde o botão flutuante await open(page, "/privacidade"); const bar = page.getByRole("dialog", { name: "Cookies e privacidade" }); const barBox = await bar.boundingBox(); expect(barBox?.height).toBeLessThanOrEqual(72); const policy = await bar .getByRole("link", { name: "Política de privacidade" }) .boundingBox(); expect(policy?.height).toBeGreaterThanOrEqual(24); const floating = page.getByRole("complementary", { name: "Contato rápido", }); await expect(floating).toBeHidden(); await bar.getByRole("button", { name: "Recusar" }).tap(); await expect(floating).toBeVisible(); }); }); test("de /privacidade, o botão do cabeçalho leva a /#contact e deixa o foco dentro de #contact", async ({ page, }) => { await open(page, "/privacidade"); await headerContact(page).click(); await page.waitForURL(/\/#contact$/); await expect .poll( () => page.evaluate(() => { const contact = document.getElementById("contact"); const active = document.activeElement; return contact !== null && active !== null && contact.contains(active) ? "dentro de #contact" : `fora de #contact: ${active?.tagName.toLowerCase()}${active?.id ? `#${active.id}` : ""}`; }), { message: "foco depois de chegar a /#contact", timeout: 8_000 }, ) .toBe("dentro de #contact"); }); test.describe("com movimento reduzido", () => { test.use({ reducedMotion: "reduce" }); test("na home, dois cliques seguidos no link /#contact do cabeçalho rolam nos dois", async ({ page, }) => { await open(page, "/"); for (const click of ["primeiro", "segundo"]) { await scrollToTop(page); await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok"); await headerContact(page).click(); await expectContactOnScreen(page, `${click} clique`); } }); test("chegando de /privacidade, o clique no cabeçalho e um segundo clique na home rolam nos dois", async ({ page, }) => { await open(page, "/privacidade"); await headerContact(page).click(); await page.waitForURL(/\/#contact$/); await expectContactOnScreen(page, "primeiro clique (de /privacidade)"); await scrollToTop(page); await expect.poll(() => contactFormOnScreen(page)).not.toBe("ok"); await headerContact(page).click(); await expectContactOnScreen(page, "segundo clique (já na home)"); }); });