feat(home): ofertas que vendem o título, WhatsApp no hero e menu do celular acessível
Três ofertas (site que traz pedidos pelo WhatsApp, sistema para tirar a operação da planilha, revisão de sistema existente) com miniatura e formulário com o tipo escolhido; botão principal do hero abre o WhatsApp; moldura da SEV no hero do celular; menu fecha ao navegar, Esc, foco preso incluindo a barra de cookies e espaço para ela; CinematicHome como componente de servidor com ilha cliente; /ia com H1 de oferta e cartão do que você recebe; botão flutuante em aside com transform; AVIF antes de WebP; copy sem repetir contato direto.
This commit is contained in:
1 parent
de6af74a0c
commit
f8545cd442
44 files changed
+2286
-571
No files matched your search
@@ -1,58 +1,43 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { primaryButton, secondaryButtonLight } from "./button-styles";
|
||||
import { primaryButton } from "./button-styles";
|
||||
import { WorkModalities } from "./WorkModalities";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de WorkModalities e TierCard: a entrada dos planos (Reveal, CSS), o toque e o passe do
|
||||
* mouse (classes cv-press e cv-lift, sem foco nos cartões), plano em destaque (botão e borda) x demais planos,
|
||||
* primeiro passo de cada plano, e serviços do hub (só os três principais).
|
||||
* Testes de caminhos-base de WorkModalities e OfferCard: a entrada das ofertas (Reveal, CSS), o toque e o passe do
|
||||
* mouse (classes cv-press e cv-lift, sem foco nos cartões), as três ofertas (miniatura do case, botão que abre o
|
||||
* formulário com o tipo escolhido), o estilo único de cartão, o texto de pelo menos 15 px e a lista compacta de etapas.
|
||||
* Nada de animação em JavaScript: o movimento reduzido é tratado pelo CSS (globals.css e conversion.css).
|
||||
*/
|
||||
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, id }: { children?: React.ReactNode; id?: string }) => (
|
||||
<section id={id}>{children}</section>
|
||||
Section: ({
|
||||
children,
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
id?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) => (
|
||||
<section id={id}>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock("./animations/DrawLine", () => ({ DrawLine: () => null }));
|
||||
|
||||
/** O hub inteiro, fora da ordem da home: a home mostra só sites, sistemas web e integrações, nesta ordem. */
|
||||
const serviceLinks = [
|
||||
{
|
||||
href: "/servicos/tauri-rust",
|
||||
label: "Aplicativos de desktop",
|
||||
description: "Programas rápidos para o computador.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/integracao-hardware-software-iot",
|
||||
label: "Integração de equipamentos e sistemas",
|
||||
description: "Máquinas e sistemas trocando dados.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/consultoria-nextjs",
|
||||
label: "Melhorias em sites e sistemas",
|
||||
description: "Evolução do que já existe.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/criacao-de-sites",
|
||||
label: "Sites profissionais",
|
||||
description: "Presença digital para a empresa.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/engenharia-de-software",
|
||||
label: "Sistemas web e aplicativos mobile",
|
||||
description: "Sistemas sob medida.",
|
||||
},
|
||||
{
|
||||
href: "/servicos/arquitetura-de-software",
|
||||
label: "Diagnóstico e arquitetura",
|
||||
description: "Avaliação técnica.",
|
||||
},
|
||||
];
|
||||
const original = (image: Element) =>
|
||||
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
|
||||
"url",
|
||||
);
|
||||
|
||||
describe("WorkModalities: movimento", () => {
|
||||
it("path: tiers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => {
|
||||
it("path: offers enter through Reveal -> same rise, same duration and a growing delay, no JavaScript animation props", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const reveals = [...container.querySelectorAll<HTMLElement>(".ds-reveal")];
|
||||
@@ -89,7 +74,7 @@ describe("WorkModalities: movimento", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("path: each tier sits alone in its own reveal wrapper, which can shrink in the grid", () => {
|
||||
it("path: each offer sits alone in its own reveal wrapper, which can shrink in the grid", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
for (const card of container.querySelectorAll("article")) {
|
||||
@@ -100,112 +85,199 @@ describe("WorkModalities: movimento", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: plano em destaque", () => {
|
||||
it("path: the featured tier has the filled button and the thick border; the others have outline buttons", () => {
|
||||
describe("WorkModalities: as três ofertas", () => {
|
||||
it("path: the section title is 'Escolha por onde começar' and the offers follow the headline, in order", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const featured = screen.getByRole("link", {
|
||||
name: "Conversar sobre meu sistema",
|
||||
});
|
||||
expect(featured.className).toContain(primaryButton);
|
||||
expect(featured.closest("article")).toHaveStyle({ borderWidth: "2px" });
|
||||
expect(featured.closest("article")).toHaveClass("border-[var(--slash)]");
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Escolha por onde começar",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
expect(articles).toHaveLength(3);
|
||||
expect(
|
||||
articles.map(
|
||||
(article) =>
|
||||
within(article).getByRole("heading", { level: 3 }).textContent,
|
||||
),
|
||||
).toEqual([
|
||||
"Site que traz pedidos pelo WhatsApp",
|
||||
"Sistema para tirar a operação da planilha",
|
||||
"Revisão de sistema que já existe, inclusive feito com IA",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const name of ["Avaliar meu projeto", "Planejar a próxima etapa"]) {
|
||||
const other = screen.getByRole("link", { name });
|
||||
expect(other.className).toContain(secondaryButtonLight);
|
||||
expect(other.className).not.toContain(primaryButton);
|
||||
expect(other.closest("article")).toHaveStyle({ borderWidth: "1px" });
|
||||
expect(other.closest("article")).not.toHaveClass("border-[var(--slash)]");
|
||||
it("path: the site offer cites the Lavação 047 (catalog or price table, ready message, panel to edit alone)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const [site] = Array.from(container.querySelectorAll("article"));
|
||||
expect(site.textContent).toMatch(/Catálogo ou tabela de preços/);
|
||||
expect(site.textContent).toMatch(/mensagem pronta/);
|
||||
expect(site.textContent).toMatch(/painel para você editar/);
|
||||
expect(site.textContent).toMatch(/Lavação 047/);
|
||||
});
|
||||
|
||||
it("path: the first two offers show the poster of their case, sized for a third of the row", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const [site, system] = Array.from(container.querySelectorAll("article"));
|
||||
const siteImage = within(site).getByRole("img");
|
||||
expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp");
|
||||
expect(siteImage).toHaveAccessibleName(
|
||||
"Site Lavação 047 aberto no computador",
|
||||
);
|
||||
expect(siteImage).toHaveAttribute(
|
||||
"sizes",
|
||||
"(min-width: 1024px) 33vw, 100vw",
|
||||
);
|
||||
expect(siteImage).toHaveAttribute("loading", "lazy");
|
||||
expect(original(within(system).getByRole("img"))).toBe(
|
||||
"/videos/sev-modaintima/desktop.webp",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the review offer has a checklist card instead of a poster, with what the review delivers", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const review = Array.from(container.querySelectorAll("article"))[2];
|
||||
expect(within(review).queryByRole("img")).toBeNull();
|
||||
expect(within(review).getByText("O que você recebe")).toBeInTheDocument();
|
||||
for (const item of [
|
||||
"As falhas de acesso a dados",
|
||||
"O que dá para aproveitar",
|
||||
"O que é melhor refazer",
|
||||
"Um orçamento para pôr em produção",
|
||||
]) {
|
||||
expect(within(review).getByText(item)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("path: each button opens the contact form with the project type already chosen, all three with the same filled style", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
const buttons = [
|
||||
["Começar pelo site", "/?tipo=site#contact"],
|
||||
["Começar pelo sistema", "/?tipo=sistema#contact"],
|
||||
["Começar pela revisão", "/?tipo=revisao#contact"],
|
||||
] as const;
|
||||
buttons.forEach(([name, href], index) => {
|
||||
const button = within(articles[index]).getByRole("link", { name });
|
||||
expect(button).toHaveAttribute("href", href);
|
||||
expect(button.className).toContain(primaryButton);
|
||||
});
|
||||
});
|
||||
|
||||
it("path: each offer links to the case or the page that backs it", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
expect(
|
||||
within(articles[0]).getByRole("link", { name: "Ver o case Lavação 047" }),
|
||||
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||
expect(
|
||||
within(articles[1]).getByRole("link", {
|
||||
name: "Ver o case SEV Moda Íntima",
|
||||
}),
|
||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||
expect(
|
||||
within(articles[2]).getByRole("link", {
|
||||
name: "Ver como funciona a revisão",
|
||||
}),
|
||||
).toHaveAttribute("href", "/ia");
|
||||
});
|
||||
|
||||
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
expect(screen.queryByText(/Reconhece algum destes cenários/)).toBeNull();
|
||||
expect(screen.queryByText(/Planilhas demais/)).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toBeNull();
|
||||
for (const link of screen.getAllByRole("link")) {
|
||||
expect(link.getAttribute("href")).not.toMatch(/^\/servicos/);
|
||||
}
|
||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: serviços do hub", () => {
|
||||
it("path: no serviceLinks prop -> no service cards, the 'all services' link stays", () => {
|
||||
render(<WorkModalities />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toHaveAttribute("href", "/servicos");
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Sites profissionais/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: serviceLinks given -> only the three main services (sites, sistemas web, integrações), in that order", () => {
|
||||
const { container } = render(
|
||||
<WorkModalities serviceLinks={serviceLinks} />,
|
||||
);
|
||||
|
||||
const grid = container.querySelector(
|
||||
"[data-reel-stagger='left']",
|
||||
) as HTMLElement;
|
||||
const links = within(grid).getAllByRole("link");
|
||||
expect(links.map((link) => link.getAttribute("href"))).toEqual([
|
||||
"/servicos/criacao-de-sites",
|
||||
"/servicos/engenharia-de-software",
|
||||
"/servicos/integracao-hardware-software-iot",
|
||||
]);
|
||||
expect(
|
||||
within(links[0]).getByText("Sites profissionais"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(links[1]).getByText("Sistemas sob medida."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver todos os serviços" }),
|
||||
).toHaveAttribute("href", "/servicos");
|
||||
});
|
||||
|
||||
it("path: a main service missing from the hub -> it is skipped, the others stay", () => {
|
||||
const { container } = render(
|
||||
<WorkModalities
|
||||
serviceLinks={serviceLinks.filter(
|
||||
({ href }) => !href.endsWith("/engenharia-de-software"),
|
||||
)}
|
||||
/>,
|
||||
);
|
||||
|
||||
const grid = container.querySelector(
|
||||
"[data-reel-stagger='left']",
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(grid)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href")),
|
||||
).toEqual([
|
||||
"/servicos/criacao-de-sites",
|
||||
"/servicos/integracao-hardware-software-iot",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: primeiro passo", () => {
|
||||
it("path: each tier states a concrete first step, with no price, deadline or contract promise", () => {
|
||||
describe("WorkModalities: um só estilo de cartão", () => {
|
||||
it("path: the three cards share the same classes and no card is highlighted (no featured border, no shadow variant)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
expect(articles).toHaveLength(3);
|
||||
const classNames = articles.map((article) => article.className);
|
||||
expect(new Set(classNames).size).toBe(1);
|
||||
for (const article of articles) {
|
||||
expect(within(article).getByText("Primeiro passo")).toBeInTheDocument();
|
||||
expect(article).not.toHaveAttribute("style");
|
||||
expect(article.className).not.toContain("border-[var(--slash)]");
|
||||
expect(article.className).not.toContain("shadow");
|
||||
}
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
/Primeira conversa para entender o problema\. Depois, escopo e proposta por escrito\./,
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
screen.getByText(/Você me mostra o que já existe\./),
|
||||
).toBeInTheDocument();
|
||||
for (const gone of [
|
||||
"Proposta por projeto",
|
||||
"Escopo de análise definido",
|
||||
"Etapas acordadas",
|
||||
]) {
|
||||
expect(screen.queryByText(gone)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: every title reserves the same minimum height, so the texts below line up", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
for (const article of container.querySelectorAll("article")) {
|
||||
const title = within(article as HTMLElement).getByRole("heading", {
|
||||
level: 3,
|
||||
});
|
||||
expect(title.className).toContain("lg:min-h-[3.3em]");
|
||||
expect(title.className).toContain("xl:min-h-[2.2em]");
|
||||
}
|
||||
expect(container.textContent).not.toMatch(/R\$|em contrato|prazo de \d/);
|
||||
});
|
||||
|
||||
it("path: no text under 15px on the desktop either (the old 12 and 13px classes are gone)", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
expect(container.innerHTML).not.toMatch(
|
||||
/(?:^|[\s:!"'])(?:md:|lg:)?!?text-\[(?:[0-9]|1[0-4])px\]|text-(?:xs|sm)\b/,
|
||||
);
|
||||
expect(container.innerHTML).not.toContain("ds-kicker");
|
||||
expect(container.innerHTML).not.toContain("md:!text-[13px]");
|
||||
expect(container.innerHTML).not.toContain("md:!text-[12px]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: como eu trabalho", () => {
|
||||
it("path: the process is a compact list of four steps, with the title and the description in one line each", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
const process = container.querySelector("#process") as HTMLElement;
|
||||
expect(
|
||||
within(process).getByRole("heading", {
|
||||
level: 3,
|
||||
name: "Como eu trabalho",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
const steps = within(process).getAllByRole("listitem");
|
||||
expect(steps).toHaveLength(4);
|
||||
expect(
|
||||
steps.map((step) => step.querySelector("strong")?.textContent),
|
||||
).toEqual([
|
||||
"Entender a operação.",
|
||||
"Definir a entrega.",
|
||||
"Construir e validar.",
|
||||
"Publicar e dar continuidade.",
|
||||
]);
|
||||
// sem a grade de quatro cartões com subtítulo por etapa
|
||||
expect(process.querySelector("h4")).toBeNull();
|
||||
expect(process.textContent).toMatch(
|
||||
/Prazo e investimento dependem do escopo/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkModalities: sem prometer o que o dono não disse", () => {
|
||||
it("path: no price, deadline or contract promise anywhere in the section", () => {
|
||||
const { container } = render(<WorkModalities />);
|
||||
|
||||
expect(container.textContent).not.toMatch(
|
||||
/R\$|em contrato|prazo de \d|\d+ dias|grátis|gratuit/i,
|
||||
);
|
||||
expect(screen.queryByText("Primeiro passo")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user