feat(site): blocos 3D da marca, novas ofertas e hero mais abrangente
CI / audit (push) Successful in 10m38s
CI / verify (push) Successful in 13m10s
CI / deploy (push) Successful in 1m50s
CI / e2e (push) Successful in 18m48s

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.
This commit is contained in:
evandro committed 2026-11-10 03:48:25 -03:00
1 parent 4671837c44
commit c7d09d63d4
165 files changed
+7940 -3790

No files matched your search

+82 -15
View File
@@ -1,17 +1,21 @@
import { expect, type Page, test } from "@playwright/test";
import { DESKTOP_USE, PHONE_USE } from "./helpers";
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 oferta 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` (quem chega de outra página) abre a home com o formulário na tela e o
* tipo marcado;
* - 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.
* - 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).
*/
@@ -20,14 +24,19 @@ 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 reprova o primeiro teste.
* (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. */
@@ -97,7 +106,7 @@ async function expectContactOnScreen(page: Page, what: string, card = false) {
.toBe("ok");
}
test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async ({
test("a home tem os quatro cartões de oferta e cada um tem o seu tipo de projeto", async ({
page,
}) => {
await open(page, "/");
@@ -105,13 +114,12 @@ test("a home tem as três ofertas e cada uma tem o seu tipo de projeto", async (
.locator("#modalities a[href*='?tipo=']")
.evaluateAll((links) => links.map((link) => link.getAttribute("href")));
expect(hrefs.sort()).toEqual(
Object.keys(SUBJECT_BY_KIND)
.map((kind) => `/?tipo=${kind}#contact-form`)
.sort(),
[...HOME_KINDS].map((kind) => `/?tipo=${kind}#contact-form`).sort(),
);
});
for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
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,
}) => {
@@ -155,10 +163,28 @@ for (const [kind, subject] of Object.entries(SUBJECT_BY_KIND)) {
});
}
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, subject] of Object.entries(SUBJECT_BY_KIND)) {
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,
}) => {
@@ -175,6 +201,47 @@ test.describe("no celular (toque)", () => {
);
});
}
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 ({