Hero e movimento - cena 3D em tempo real com os blocos da marca (kit feito no Blender, 3d/blocos.py): marca, tela, integrações e dados; segue a rolagem, tem vida em repouso, reage ao cursor e, parada no topo, passa sozinha pelos estados com legenda - três níveis de aparelho (full, lite, static); sem WebGL fica o pôster - título novo: "Sites e sistemas sob medida para o jeito que a sua empresa trabalha." - faixa de clientes com o ramo, linha do primeiro passo, hero alinhado às seções - rótulos digitados, entrada dos blocos, cortina de blocos entre páginas e capa compartilhada entre a lista de cases e o case Ofertas - atendimento no WhatsApp com IA e nota fiscal para a reforma tributária (páginas de serviço, cartões da home, formulário e aviso com data de validade) - página /ia atualizada, com fontes - peças 3D em loop nos cartões e topos de página (3d/pecas.py) Design - hover por papel (bloco nos botões, barra nos links), rótulos sem caixa alta - um destino por rótulo: "Falar sobre meu projeto" leva ao formulário, "Chamar no WhatsApp" abre o WhatsApp - logo montado em blocos, 404 redesenhado, grades sem célula vazia - ícones do PWA e manifesto novos Specs em specs/, contexto de design em DESIGN.md e PRODUCT.md.
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
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=<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)");
|
|
});
|
|
});
|