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:
Evandro Fonseca Junior committed 2026-10-14 17:01:36 -03:00
1 parent de6af74a0c
commit f8545cd442
44 files changed
+2286 -571

No files matched your search

+21 -6
View File
@@ -17,15 +17,18 @@ jest.mock("./Section", () => ({
children,
id,
title,
description,
aside,
}: {
children?: React.ReactNode;
id?: string;
title?: string;
description?: string;
aside?: React.ReactNode;
}) => (
<section id={id}>
<h2>{title}</h2>
<p data-testid="about-description">{description}</p>
{aside}
{children}
</section>
@@ -92,7 +95,7 @@ describe("About", () => {
expect(
screen.getByRole("heading", {
level: 3,
name: "Você fala direto com quem desenvolve",
name: "Sem intermediários entre o pedido e o sistema",
}),
).toBeInTheDocument();
expect(
@@ -109,11 +112,23 @@ describe("About", () => {
expect(container.textContent).not.toMatch(
/protocolos de baixo nível|operabilidade|camada física|sinal físico/i,
);
expect(
screen.getByText(
"Desde 2022 desenvolvo sistemas internos, aplicativos e integrações IoT na Apolo Energia.",
),
).toBeInTheDocument();
// a seção abre pela credencial, logo depois do nome
expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
"Evandro Fonseca Junior",
);
expect(screen.getByTestId("about-description").textContent).toMatch(
/^Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia\./,
);
// a frase da Apolo aparece uma só vez
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
});
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(
/contato direto|fala direto|falar direto|conversa comigo/i,
);
});
it("path: the location label stays on one line and the columns stretch to the same height", () => {
+5 -9
View File
@@ -10,9 +10,9 @@ import { Section } from "./Section";
const STORY_DATA = [
{
label: "Como eu trabalho",
title: "Você fala direto com quem desenvolve",
title: "Sem intermediários entre o pedido e o sistema",
content:
"Você conversa comigo sobre o negócio e acompanha as decisões ao longo da entrega, sem intermediários entre o seu pedido e o sistema.",
"Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.",
},
{
label: "Sem começar do zero",
@@ -42,7 +42,7 @@ const PROFILE_POINTS = [
{
title: "Quem responde pelo projeto",
description:
"Você tem contato direto com quem decide a estrutura e desenvolve o sistema.",
"Uma só pessoa decide a estrutura e desenvolve o sistema, do começo ao fim.",
},
];
@@ -54,8 +54,8 @@ export default function About() {
<Section
id="about"
eyebrow="Quem conduz o projeto"
title="Evandro Fonseca Junior. Engenharia com contato direto."
description="Sou engenheiro da computação e desenvolvedor de software. Você conversa comigo sobre o negócio e acompanha as decisões técnicas ao longo da entrega."
title="Evandro Fonseca Junior"
description="Engenheiro da computação; desde 2022 desenvolvo os sistemas internos e a IoT da Apolo Energia. Você acompanha as decisões técnicas ao longo da entrega."
disableChildrenAnimation
aside={
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
@@ -115,10 +115,6 @@ export default function About() {
sistema. Ver tudo junto ajuda a decidir o que construir, o que
conectar e o que dá para aproveitar.
</p>
<p className="ds-copy">
Desde 2022 desenvolvo sistemas internos, aplicativos e integrações
IoT na Apolo Energia.
</p>
<div className="mt-sp-6 space-y-sp-4" data-reel-stagger="bottom">
{PROFILE_POINTS.map((point) => (
<div
@@ -0,0 +1,74 @@
import { act, render, screen } from "@testing-library/react";
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
* Contact.offer.test.tsx: tipo válido, inválido e ausente no endereço, e a ordem entre o endereço e o evento.
*/
const subjectSelect = () =>
screen.getByRole("combobox", {
name: /tipo de projeto/i,
}) as HTMLSelectElement;
const choose = (kind: unknown) =>
act(() => {
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
describe("Contact: tipo no endereço", () => {
it.each([
["/", "sem busca nenhuma (tipo ausente)"],
["/?utm_source=instagram#contact", "só outros parâmetros (tipo ausente)"],
["/?tipo=#contact", "tipo vazio"],
["/?tipo=SITE#contact", "tipo com outra caixa (só vale em minúsculas)"],
["/?tipo=constructor#contact", "nome herdado de todo objeto"],
["/?tipo=outra-coisa#contact", "tipo desconhecido"],
])("path: %s (%s) -> keeps the default option", (address) => {
window.history.replaceState(null, "", address);
render(<Contact />);
expect(subjectSelect()).toHaveValue(DEFAULT_CONTACT_SUBJECT);
});
it.each([
["/?tipo=site#contact", "Site para empresa"],
["/?tipo=sistema#contact", "Sistemas web sob medida"],
[
"/?utm_source=x&tipo=revisao#contact",
"Revisão ou diagnóstico de sistema",
],
// com o parâmetro repetido vale o primeiro
["/?tipo=site&tipo=revisao#contact", "Site para empresa"],
])("path: %s (valid type) -> selects %s", (address, option) => {
window.history.replaceState(null, "", address);
render(<Contact />);
expect(subjectSelect()).toHaveValue(option);
});
it("path: valid type in the address, then an unknown one from the event -> the address choice stays", () => {
window.history.replaceState(null, "", "/?tipo=sistema#contact");
render(<Contact />);
choose("desconhecido");
expect(subjectSelect()).toHaveValue("Sistemas web sob medida");
});
it("path: valid type in the address, then a valid one from the event -> the later choice wins", () => {
window.history.replaceState(null, "", "/?tipo=sistema#contact");
render(<Contact />);
choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
});
});
+149
View File
@@ -0,0 +1,149 @@
import { act, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Contact from "./Contact";
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.
*/
const subjectSelect = () =>
screen.getByRole("combobox", {
name: /tipo de projeto/i,
}) as HTMLSelectElement;
const choose = (kind: unknown) =>
act(() => {
window.dispatchEvent(new CustomEvent(CONTACT_KIND_EVENT, { detail: kind }));
});
afterEach(() => {
window.history.replaceState(null, "", "/");
});
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", () => {
render(<Contact />);
const options = within(subjectSelect()).getAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Ainda preciso definir",
"Site para empresa",
"Sistemas web sob medida",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão ou diagnóstico de sistema",
]);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
it("puts the review type in the message that goes to WhatsApp and e-mail", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
await user.selectOptions(
subjectSelect(),
"Revisão ou diagnóstico de sistema",
);
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",
);
});
});
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"],
])("the offer event with %s selects %s", (kind, option) => {
render(<Contact />);
choose(kind);
expect(subjectSelect()).toHaveValue(option);
});
it("a later offer replaces the earlier choice, and a person's own pick is replaced by the offer too", async () => {
const user = userEvent.setup({ delay: null });
render(<Contact />);
choose("site");
await user.selectOptions(subjectSelect(), "Aplicativos");
expect(subjectSelect()).toHaveValue("Aplicativos");
choose("revisao");
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
});
it.each([
"desconhecido",
"constructor",
"__proto__",
"",
undefined,
null,
42,
])("an unknown type %p changes nothing", (kind) => {
render(<Contact />);
choose("sistema");
choose(kind);
expect(subjectSelect()).toHaveValue("Sistemas web sob medida");
});
it("reads ?tipo= from the address when the form mounts (arrival from another page)", () => {
window.history.replaceState(null, "", "/?tipo=revisao#contact");
render(<Contact />);
expect(subjectSelect()).toHaveValue("Revisão ou diagnóstico de sistema");
});
it("ignores an unknown ?tipo= in the address and keeps the default", () => {
window.history.replaceState(null, "", "/?tipo=outra-coisa");
render(<Contact />);
expect(subjectSelect()).toHaveValue("Ainda preciso definir");
});
it("stops listening when it unmounts", () => {
const add = jest.spyOn(window, "addEventListener");
const remove = jest.spyOn(window, "removeEventListener");
const { unmount } = render(<Contact />);
const registered = add.mock.calls.find(
([type]) => type === CONTACT_KIND_EVENT,
);
expect(registered).toBeDefined();
unmount();
expect(remove).toHaveBeenCalledWith(CONTACT_KIND_EVENT, registered?.[1]);
add.mockRestore();
remove.mockRestore();
});
});
describe("Contact: texto da seção", () => {
it("keeps 'conversar direto' only on the WhatsApp line, with no other 'fala direto' phrasing", () => {
const { container } = render(<Contact />);
expect(screen.getByText("Prefere conversar direto?")).toBeInTheDocument();
expect(container.textContent).not.toMatch(/fala direto|fale direto/i);
expect(
screen.getByRole("heading", {
level: 3,
name: "Como a conversa começa.",
}),
).toBeInTheDocument();
});
});
+34 -23
View File
@@ -6,6 +6,13 @@ 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 {
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
CONTACT_SUBJECT_OPTIONS,
contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT,
} from "./contact-subject";
import "./conversion.css";
import WhatsAppLink from "./WhatsAppLink";
import { WhatsAppMark } from "./WhatsAppMark";
@@ -54,19 +61,9 @@ function buildMailtoUrl(message: string) {
const fieldClassName =
"mt-sp-2 w-full min-w-0 rounded-lg border border-white/15 bg-black/25 px-sp-4 py-sp-3 !font-display text-base leading-6 text-white placeholder:text-white/40 focus-visible:border-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] aria-[invalid=true]:border-[#ff8a80]";
const labelClassName =
"block font-display text-[15px] md:text-sm font-medium text-white/85";
"block font-display text-[15px] font-medium text-white/85";
const errorClassName =
"mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[#ff9d94]";
/** "Ainda preciso definir" vem primeiro e é o padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */
const SUBJECT_OPTIONS = [
"Ainda preciso definir",
"Site para empresa",
"Sistemas web sob medida",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
];
"mt-sp-2 font-display text-[15px] leading-6 text-[#ff9d94]";
/** Mensagem dos links diretos de quem está sem JavaScript (o formulário não funciona sem ele). */
const DIRECT_MESSAGE =
@@ -161,11 +158,25 @@ export default function Contact() {
const [formData, setFormData] = useState({
name: "",
email: "",
subject: "Ainda preciso definir",
subject: DEFAULT_CONTACT_SUBJECT as string,
message: "",
});
const [errors, setErrors] = useState<FieldErrors>({});
// 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 }));
};
choose(new URLSearchParams(window.location.search).get(CONTACT_KIND_PARAM));
const onChoose = (event: Event) =>
choose((event as CustomEvent<string | undefined>).detail);
window.addEventListener(CONTACT_KIND_EVENT, onChoose);
return () => window.removeEventListener(CONTACT_KIND_EVENT, onChoose);
}, []);
const updateField = (field: ContactField | "subject", value: string) => {
setFormData((current) => ({ ...current, [field]: value }));
if (field !== "subject" && errors[field]) {
@@ -222,9 +233,9 @@ export default function Contact() {
</p>
<div className="mt-sp-8 border-l-2 border-[var(--slash)] pl-sp-5">
<h3 className="font-display text-lg font-semibold text-white">
Você fala direto com quem desenvolve.
Como a conversa começa.
</h3>
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] md:text-sm leading-7 text-white/65">
<p className="mt-sp-2 max-w-[44ch] font-display text-[15px] leading-7 text-white/65">
Sou Evandro. A primeira conversa é para entender sua necessidade;
escopo, prazo e proposta vêm depois.
</p>
@@ -238,10 +249,10 @@ export default function Contact() {
<WhatsAppMark className="h-6 w-6" />
</span>
<span className="min-w-0 flex-1 font-display">
<span className="block text-[15px] md:text-sm font-semibold text-white group-hover:text-[#25d366]">
<span className="block text-[15px] font-semibold text-white group-hover:text-[#25d366]">
Prefere conversar direto?
</span>
<span className="mt-1 block text-[15px] md:text-sm text-white/65">
<span className="mt-1 block text-[15px] text-white/65">
WhatsApp · {whatsAppDisplay}
</span>
</span>
@@ -256,7 +267,7 @@ export default function Contact() {
className="group flex min-h-16 items-center gap-sp-4 rounded-sm py-sp-5 text-white/65 no-underline hover:text-white focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--slash)]"
>
<Mail className="h-5 w-5 shrink-0" aria-hidden="true" />
<span className="min-w-0 break-all font-display text-[15px] md:text-sm">
<span className="min-w-0 break-all font-display text-[15px]">
{CONTACT_EMAIL}
</span>
<ArrowUpRight
@@ -265,7 +276,7 @@ export default function Contact() {
/>
</a>
</div>
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] md:text-sm leading-6 text-white/55">
<p className="mt-sp-5 flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/55">
<MapPin className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
Garuva, Santa Catarina. Atendimento remoto em todo o Brasil.
</p>
@@ -288,7 +299,7 @@ export default function Contact() {
>
Prepare sua mensagem
</h3>
<p className="mt-1 font-display text-[15px] md:text-sm leading-6 text-white/65">
<p className="mt-1 font-display text-[15px] leading-6 text-white/65">
Você revisa no WhatsApp antes de enviar.
</p>
</div>
@@ -339,7 +350,7 @@ export default function Contact() {
onChange={(e) => updateField("subject", e.target.value)}
className={`${fieldClassName} [color-scheme:dark]`}
>
{SUBJECT_OPTIONS.map((option) => (
{CONTACT_SUBJECT_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
@@ -386,7 +397,7 @@ export default function Contact() {
</p>
<p
id="contact-privacy-note"
className="font-display text-[15px] md:text-xs leading-6 text-white/55"
className="font-display text-[15px] leading-6 text-white/55"
>
Nada é enviado automaticamente. O WhatsApp abre com a mensagem
para você revisar. Não inclua senhas nem dados pessoais de
@@ -396,7 +407,7 @@ export default function Contact() {
{/* Sem JavaScript o formulário não envia nada: no lugar do botão, links diretos que funcionam sem script. */}
<noscript>
<p className="font-display text-base leading-7 text-white/80">
Para enviar sua mensagem, fale direto comigo:
Para enviar sua mensagem, use um destes contatos:
</p>
<p className="mt-sp-3 flex flex-col gap-sp-3 font-display text-base">
<a
+69
View File
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from "@testing-library/react";
import ContactOfferLink from "./ContactOfferLink";
import { CONTACT_KIND_EVENT } from "./contact-subject";
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
describe("ContactOfferLink", () => {
it("links to the form with the type in the address, and prefetches nothing until hover or focus", () => {
render(
<ContactOfferLink kind="sistema" className="botao">
Começar pelo sistema
</ContactOfferLink>,
);
const link = screen.getByRole("link", { name: "Começar pelo sistema" });
expect(link).toHaveAttribute("href", "/?tipo=sistema#contact");
expect(link).toHaveClass("botao");
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("tells the form which type was chosen, through a window event, when it is clicked", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
try {
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(heard).toEqual(["revisao"]);
} finally {
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("still tells the form when another handler already cancelled the click (the home scroll manager does that to scroll by itself)", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
const cancel = (event: Event) => event.preventDefault();
window.addEventListener(CONTACT_KIND_EVENT, listener);
document.addEventListener("click", cancel, true);
try {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(heard).toEqual(["site"]);
} finally {
window.removeEventListener(CONTACT_KIND_EVENT, listener);
document.removeEventListener("click", cancel, true);
}
});
});
+38
View File
@@ -0,0 +1,38 @@
"use client";
import type { ReactNode } from "react";
import {
CONTACT_KIND_EVENT,
type ContactKind,
contactKindHref,
} from "./contact-subject";
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* 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 clique rola até o formulário (useCinematicScroll) sem trocar a busca do endereço, então o
* tipo também segue por evento; o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
*/
export default function ContactOfferLink({
kind,
className,
children,
}: {
kind: ContactKind;
className?: string;
children: ReactNode;
}) {
return (
<HoverPrefetchLink
href={contactKindHref(kind)}
className={className}
onClick={() =>
window.dispatchEvent(
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
)
}
>
{children}
</HoverPrefetchLink>
);
}
+14 -4
View File
@@ -61,11 +61,21 @@ describe("message by route", () => {
],
[
"/cases/sev-modaintima",
"Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.",
"Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
],
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
["/", "Quero conversar sobre um projeto."],
["/servicos", "Quero conversar sobre um projeto."],
// 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."],
[
"/cases",
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
],
[
"/curriculo",
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
],
["/privacidade", "Quero conversar sobre um projeto."],
["/servicos/inexistente", "Quero conversar sobre um projeto."],
])("on %s", (pathname, sentence) => {
jest.mocked(usePathname).mockReturnValue(pathname);
@@ -112,7 +122,7 @@ describe("page changes", () => {
]);
expect(floatingLink()).toBeInTheDocument();
expect(messageOf(floatingLink())).toContain(
"Quero conversar sobre um projeto.",
"Quero conversar sobre um site ou sistema para a minha empresa.",
);
// sai da home para um serviço: o observador antigo é desligado e o botão passa a falar do serviço
+25
View File
@@ -119,6 +119,31 @@ it("lifts itself by the height of the open cookie bar", () => {
);
});
it("lifts with a transform, not by animating `bottom`, so the layout does not shift", () => {
render(<Page />);
setVisibility(false, false);
const className = floatingLink()?.parentElement?.className ?? "";
expect(className).toContain("-translate-y-[var(--cookie-bar-height,0px)]");
expect(className).toContain("transition-transform");
expect(className).toContain("motion-reduce:transition-none");
// o `bottom` fica fixo (margem e área segura) e a barra de cookies não entra nele
expect(className).not.toContain("transition-[bottom]");
expect(className).toContain(
"bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))]",
);
});
it("sits in a named complementary region (the 'region' landmark rule), which only exists while the shortcut shows", () => {
render(<Page />);
expect(screen.queryByRole("complementary")).toBeNull();
setVisibility(false, false);
const region = screen.getByRole("complementary", { name: "Contato rápido" });
expect(region.tagName).toBe("ASIDE");
expect(region).toContainElement(floatingLink());
});
it("reports the click as a floating WhatsApp handoff", () => {
render(<Page />);
setVisibility(false, false);
+7 -2
View File
@@ -14,6 +14,8 @@ const CONTACT_SECTION_ID = "contact";
* Atalho para o WhatsApp só no celular (abaixo de 768 px), montado no layout para valer em todo o site. A mensagem
* cita o serviço ou o case da página aberta. Fica escondido quando já há um convite de contato à vista (hero e
* contato da home) e sobe a altura da barra de cookies (--cookie-bar-height, publicada por GoogleAnalytics) enquanto ela está aberta.
* Fica num <aside> com nome (região de contato rápido para leitores de tela) e sobe com `transform`, não com `bottom`:
* animar `bottom` refaz o layout e contava como deslocamento de layout (CLS) nas páginas internas.
*/
export default function FloatingWhatsApp({
contexts,
@@ -57,7 +59,10 @@ export default function FloatingWhatsApp({
if (!inviteVisible || Object.values(inviteVisible).some(Boolean)) return null;
return (
<div className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] z-[8900] transition-[bottom] duration-200 motion-reduce:transition-none md:hidden">
<aside
aria-label="Contato rápido"
className="fixed right-sp-4 bottom-[calc(var(--sp-4)+env(safe-area-inset-bottom))] z-[8900] -translate-y-[var(--cookie-bar-height,0px)] transition-transform duration-200 motion-reduce:transition-none md:hidden"
>
<WhatsAppLink
placement="floating"
service={subject}
@@ -66,6 +71,6 @@ export default function FloatingWhatsApp({
<WhatsAppMark className="h-7 w-7" />
<span className="sr-only">Conversar no WhatsApp</span>
</WhatsAppLink>
</div>
</aside>
);
}
+2 -2
View File
@@ -132,7 +132,7 @@ export default function Footer({
<li>
<Link
href="/curriculo"
className={`${linkClassName} !text-[15px] md:!text-[14px] !text-[var(--meta)]`}
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
>
Currículo
</Link>
@@ -151,7 +151,7 @@ export default function Footer({
para 4:1) e o resto do logo, já quase preto, quase não muda */}
<Logo theme="light" className="w-40 [filter:brightness(0.5)]" />
</Link>
<p className="font-display text-[15px] md:text-[14px] leading-6 text-[var(--meta)]">
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
© {currentYear} Evandro Fonseca Junior. Todos os direitos
reservados.
</p>
+4 -2
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import {
type AnalyticsConsent,
CONSENT_STORAGE_KEY,
COOKIE_BAR_ID,
GA_DISABLE_KEY,
GA_MEASUREMENT_ID,
GOOGLE_ADS_ID,
@@ -222,6 +223,7 @@ function GoogleAnalyticsConsent() {
{barOpen && (
<section
ref={barRef}
id={COOKIE_BAR_ID}
role="dialog"
aria-modal="false"
aria-label="Cookies e privacidade"
@@ -234,7 +236,7 @@ function GoogleAnalyticsConsent() {
<div className="mx-auto flex max-w-[1100px] items-center gap-sp-3 sm:gap-sp-6">
<p
id="analytics-consent-description"
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85 sm:text-sm"
className="min-w-0 flex-1 font-display text-[15px] leading-[1.3] text-white/85"
>
Usamos métricas de navegação do Google só se você permitir.{" "}
<a
@@ -273,7 +275,7 @@ function GoogleAnalyticsConsent() {
<p
role="alert"
aria-label="Falha ao salvar o consentimento"
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] md:text-[13px] leading-5 text-[#ff9d94]"
className="mx-auto mt-sp-1 max-w-[1100px] font-display text-[15px] leading-5 text-[#ff9d94]"
>
{consentError}
</p>
+378
View File
@@ -0,0 +1,378 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { COOKIE_BAR_ID } from "@/lib/analytics";
import { Header } from "./Header";
/**
* O menu do celular aberto (complemento de Header.test.tsx): botão de contato acima da barra de cookies, fecha ao
* trocar de rota e ao tocar no logo, deixa o conteúdo e o rodapé inertes, trava a rolagem, prende o foco e fecha
* com Esc (ouvido no document), devolvendo o foco ao botão do menu.
*/
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
/** O cabeçalho com as duas partes da página que o menu torna inertes: o conteúdo e o rodapé (ids do layout). */
function Page() {
return (
<>
<Header />
<div id="main-content" tabIndex={-1}>
<button type="button">Botão do conteúdo</button>
</div>
<div id="site-footer">
<a href="#privacidade">Privacidade</a>
</div>
</>
);
}
const main = () => document.getElementById("main-content") as HTMLElement;
const footer = () => document.getElementById("site-footer") as HTMLElement;
const menuButton = () =>
screen.getByRole("button", { name: /^(Abrir|Fechar) menu$/ });
const openMenu = () =>
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileMenu = () =>
screen.queryByRole("navigation", { name: "Navegação mobile" });
/** Os controles que o Tab percorre com o menu aberto: logo, botão do menu, os cinco links e o botão de contato do painel. */
function trapOrder() {
const panel = within(mobileMenu() as HTMLElement);
return [
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
menuButton(),
...panel.getAllByRole("link"),
];
}
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
document.documentElement.style.removeProperty("overflow");
});
afterEach(() => {
document.documentElement.style.removeProperty("overflow");
});
describe("Header: menu do celular e a barra de cookies", () => {
it("adds the height of the cookie bar to the bottom spacing, so the contact button stays above it", () => {
render(<Page />);
openMenu();
const panel = (mobileMenu() as HTMLElement).parentElement
?.parentElement as HTMLElement;
expect(panel.className).toContain(
"pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))]",
);
});
});
describe("Header: o menu fecha sozinho", () => {
it("closes when the route changes", () => {
const { rerender } = render(<Page />);
openMenu();
expect(mobileMenu()).toBeInTheDocument();
jest.mocked(usePathname).mockReturnValue("/cases");
rerender(<Page />);
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
expect(main()).not.toHaveAttribute("inert");
});
it("stays open on a re-render that keeps the same route", () => {
const { rerender } = render(<Page />);
openMenu();
rerender(<Page />);
expect(mobileMenu()).toBeInTheDocument();
});
it("closes when the logo is tapped", () => {
render(<Page />);
openMenu();
fireEvent.click(
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
);
expect(mobileMenu()).toBeNull();
expect(main()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("closes when the window grows to the desktop layout, where the menu button no longer exists", () => {
const original = window.matchMedia;
let onChange: ((event: { matches: boolean }) => void) | undefined;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: (_type: string, listener: typeof onChange) => {
onChange = listener;
},
removeEventListener: jest.fn(),
}));
try {
render(<Page />);
openMenu();
expect(window.matchMedia).toHaveBeenCalledWith("(min-width: 1280px)");
act(() => onChange?.({ matches: false }));
expect(mobileMenu()).toBeInTheDocument();
act(() => onChange?.({ matches: true }));
expect(mobileMenu()).toBeNull();
expect(document.documentElement.style.overflow).toBe("");
} finally {
window.matchMedia = original;
}
});
});
describe("Header: página inerte e sem rolagem com o menu aberto", () => {
it("makes the content and the footer inert and locks the page scroll while the menu is open, and undoes both on close", () => {
render(<Page />);
expect(main()).not.toHaveAttribute("inert");
openMenu();
expect(main()).toHaveAttribute("inert");
expect(footer()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
// o próprio cabeçalho nunca fica inerte
expect(screen.getByRole("banner")).not.toHaveAttribute("inert");
fireEvent.click(menuButton());
expect(main()).not.toHaveAttribute("inert");
expect(footer()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("gives back the overflow the page already had", () => {
document.documentElement.style.overflow = "scroll";
render(<Page />);
openMenu();
expect(document.documentElement.style.overflow).toBe("hidden");
fireEvent.click(menuButton());
expect(document.documentElement.style.overflow).toBe("scroll");
});
it("works on a page without a footer wrapper (the parts that exist are still made inert)", () => {
render(<Header />);
document.body.insertAdjacentHTML(
"beforeend",
'<div id="main-content"></div>',
);
try {
openMenu();
expect(main()).toHaveAttribute("inert");
} finally {
main().remove();
}
});
it("undoes everything when the header unmounts with the menu open", () => {
const { unmount } = render(<Page />);
openMenu();
unmount();
expect(document.documentElement.style.overflow).toBe("");
});
it("releases the page before any other click handler of the document runs, so a link to #id finds its target focusable", () => {
render(<Page />);
openMenu();
const states: Array<{ inert: boolean; overflow: string }> = [];
const record = () =>
states.push({
inert: main().hasAttribute("inert"),
overflow: document.documentElement.style.overflow,
});
// o gerenciador de âncoras da home escuta o clique no document, na fase de captura
document.addEventListener("click", record, true);
try {
fireEvent.click(
within(mobileMenu() as HTMLElement).getByRole("link", {
name: "Sobre",
}),
);
} finally {
document.removeEventListener("click", record, true);
}
expect(states).toEqual([{ inert: false, overflow: "" }]);
expect(mobileMenu()).toBeNull();
});
it("does not release the page for a click outside the header", () => {
render(<Page />);
openMenu();
// com o conteúdo inerte o clique real nem chega; o teste dispara direto para provar que o ouvinte só reage ao cabeçalho
fireEvent.click(footer().querySelector("a") as HTMLElement);
expect(main()).toHaveAttribute("inert");
expect(mobileMenu()).toBeInTheDocument();
});
});
describe("Header: Esc e foco com o menu aberto", () => {
it("closes with Escape heard on the document, wherever the focus is, and puts the focus back on the menu button", () => {
render(<Page />);
openMenu();
act(() => document.body.focus());
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(false);
expect(mobileMenu()).toBeNull();
expect(main()).not.toHaveAttribute("inert");
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
expect(menuButton()).toHaveFocus();
});
it("ignores Escape while the menu is closed", () => {
render(<Page />);
// sem menu aberto o ouvinte nem existe: o evento segue sem ser cancelado
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(true);
expect(mobileMenu()).toBeNull();
});
it("traps Tab: from the last control it goes to the first, and Shift+Tab from the first goes to the last", () => {
render(<Page />);
openMenu();
const order = trapOrder();
const first = order[0];
const last = order[order.length - 1];
// o botão de contato do painel é o último; o da barra de desktop e a navegação de desktop ficam fora do ciclo
expect(last).toHaveAccessibleName("Falar sobre meu projeto");
expect(order).toHaveLength(1 + 1 + 5 + 1);
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(first).toHaveFocus();
expect(fireEvent.keyDown(first, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
});
it("lets Tab move normally between two controls of the cycle", () => {
render(<Page />);
openMenu();
const order = trapOrder();
act(() => order[2].focus());
expect(fireEvent.keyDown(order[2], { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(order[2], { key: "Tab", shiftKey: true })).toBe(
true,
);
});
it("brings the focus back into the cycle when it is outside it, in the direction of the key", () => {
render(<Page />);
openMenu();
const order = trapOrder();
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(false);
expect(order[0]).toHaveFocus();
act(() => document.body.focus());
expect(
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
).toBe(false);
expect(order[order.length - 1]).toHaveFocus();
});
it("does not trap Tab while the menu is closed", () => {
render(<Page />);
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
});
it("closes with the menu button and keeps the focus on it", () => {
render(<Page />);
openMenu();
act(() => menuButton().focus());
fireEvent.click(menuButton());
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveFocus();
});
});
describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
/** Como no layout: a barra vem antes do cabeçalho e não fica inerte. */
function PageWithCookieBar() {
return (
<>
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
<button type="button">Aceitar</button>
<button type="button">Recusar</button>
</section>
<Page />
</>
);
}
it("cycles through Aceitar and Recusar before the header, and back from the contact button", () => {
render(<PageWithCookieBar />);
openMenu();
const accept = screen.getByRole("button", { name: "Aceitar" });
const decline = screen.getByRole("button", { name: "Recusar" });
const order = trapOrder();
const last = order[order.length - 1];
expect(accept).not.toHaveAttribute("inert");
// do botão de contato (o último do cabeçalho) o Tab vai para Aceitar, em vez de dar a volta no cabeçalho
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(accept).toHaveFocus();
// entre Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
act(() => decline.focus());
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
// Shift+Tab de Aceitar (o primeiro do ciclo) volta ao botão de contato
act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
});
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
render(<PageWithCookieBar />);
openMenu();
act(() => screen.getByRole("button", { name: "Recusar" }).focus());
fireEvent.keyDown(document, { key: "Escape" });
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveFocus();
});
});
+129 -5
View File
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { Header } from "./Header";
@@ -33,7 +33,8 @@ function setScrollY(value: number) {
}
const header = () => screen.getByRole("banner");
const isDark = () => header().className.includes("backdrop-blur-md");
/** Rolada: borda visível e fundo a 95% (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
const isDark = () => header().className.includes("border-white/12");
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
@@ -53,7 +54,7 @@ describe("Header: rolagem", () => {
expect(header()).toHaveClass("border-transparent");
});
it("path: scrolling down and back to the top -> blurred bar, then the plain one again", () => {
it("path: scrolling down and back to the top -> bordered bar, then the plain one again, never with a backdrop blur", () => {
render(<Header />);
setScrollY(120);
@@ -62,6 +63,7 @@ describe("Header: rolagem", () => {
});
expect(isDark()).toBe(true);
expect(header()).not.toHaveClass("border-transparent");
expect(header().className).not.toContain("backdrop-blur");
// outro evento de rolagem sem mudar de lado: nada muda
setScrollY(300);
@@ -77,7 +79,7 @@ describe("Header: rolagem", () => {
expect(isDark()).toBe(false);
});
it("path: page already scrolled when the header mounts -> starts with the blurred bar", () => {
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
setScrollY(500);
render(<Header />);
@@ -99,9 +101,12 @@ describe("Header: teclado no menu do celular", () => {
render(<Header />);
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const menu = screen.getByRole("navigation", { name: "Navegação mobile" });
// Tab entre dois controles do meio da lista: segue o caminho normal (o foco só é preso nas pontas)
const middle = within(menu).getByRole("link", { name: "Serviços" });
act(() => middle.focus());
// fireEvent devolve false só quando o evento foi cancelado (preventDefault)
expect(fireEvent.keyDown(menu, { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(middle, { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(menu, { key: "Enter" })).toBe(true);
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
@@ -142,6 +147,125 @@ describe("Header: teclado no menu do celular", () => {
});
});
describe("Header: menu aberto, caminhos de borda do foco, do clique e da janela", () => {
/** O cabeçalho com o conteúdo da página que o menu aberto torna inerte (id do layout). */
function Page() {
return (
<>
<Header />
<div id="main-content" tabIndex={-1}>
<button type="button">Botão do conteúdo</button>
</div>
</>
);
}
const openMenu = () =>
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const content = () => document.getElementById("main-content") as HTMLElement;
afterEach(() => {
document.documentElement.style.removeProperty("overflow");
});
it("path: Tab with no control left to focus in the header -> the key is not blocked and the focus does not move", () => {
render(<Page />);
openMenu();
const banner = screen.getByRole("banner");
for (const button of banner.querySelectorAll("button"))
button.setAttribute("disabled", "");
for (const link of banner.querySelectorAll("a"))
link.removeAttribute("href");
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
expect(
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
).toBe(true);
expect(document.body).toHaveFocus();
});
it("path: click whose target is not an element (the document itself) -> the page stays locked", () => {
render(<Page />);
openMenu();
fireEvent.click(document);
expect(content()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("path: click on something inside the header that is not a link -> the page stays locked", () => {
render(<Page />);
openMenu();
fireEvent.click(
screen.getByRole("navigation", { name: "Navegação mobile" }),
);
expect(content()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
});
it("path: click on a link inside the header -> the page is released at once", () => {
render(<Page />);
openMenu();
fireEvent.click(
within(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).getByRole("link", { name: "Cases" }),
);
expect(content()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("path: no matchMedia at all in the browser -> the menu still opens and closes", () => {
const original = window.matchMedia;
window.matchMedia = undefined as unknown as typeof window.matchMedia;
try {
render(<Page />);
openMenu();
expect(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
expect(content()).not.toHaveAttribute("inert");
} finally {
window.matchMedia = original;
}
});
it("path: matchMedia returns an object with no change listener (older browsers) -> the menu still opens and closes", () => {
const original = window.matchMedia;
window.matchMedia = jest.fn().mockReturnValue({
matches: false,
}) as unknown as typeof window.matchMedia;
try {
render(<Page />);
openMenu();
expect(
screen.getByRole("navigation", { name: "Navegação mobile" }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Fechar menu" }));
expect(
screen.queryByRole("navigation", { name: "Navegação mobile" }),
).toBeNull();
expect(content()).not.toHaveAttribute("inert");
} finally {
window.matchMedia = original;
}
});
});
describe("Header: rota ausente", () => {
it("path: usePathname gives null (outside the router) -> the header button goes to the home form", () => {
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
+29 -3
View File
@@ -213,8 +213,8 @@ describe("Header", () => {
expect(headerLinks[3]).toHaveAttribute("data-prefetch", "null");
});
it("sends the header button to the home form on the home and on other pages", () => {
for (const pathname of ["/", "/cases", "/ia", "/servicos"]) {
it("sends the header button to the home form on the home and on pages without a subject", () => {
for (const pathname of ["/", "/privacidade"]) {
jest.mocked(usePathname).mockReturnValue(pathname);
const { unmount } = render(<Header whatsAppContexts={contexts} />);
@@ -227,6 +227,32 @@ describe("Header", () => {
}
});
it.each([
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
[
"/cases",
"Quero conversar sobre um projeto parecido com os cases do portfólio.",
],
["/servicos", "Quero conversar sobre Sites, sistemas e aplicativos."],
[
"/curriculo",
"Quero conversar sobre um projeto, depois de ver o seu currículo.",
],
])(
"opens WhatsApp with the page subject in the header button on %s",
(pathname, sentence) => {
jest.mocked(usePathname).mockReturnValue(pathname);
render(<Header whatsAppContexts={contexts} />);
const contact = screen.getByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contact.getAttribute("href")).toMatch(/^https:\/\/wa\.me\/\d+\?/);
expect(contact).toHaveAttribute("target", "_blank");
expect(whatsAppHref(contact)).toContain(sentence);
},
);
it("opens WhatsApp with the service in the header button on a service page", () => {
jest.mocked(usePathname).mockReturnValue("/servicos/criacao-de-sites");
render(<Header whatsAppContexts={contexts} />);
@@ -252,7 +278,7 @@ describe("Header", () => {
expect(buttons).toHaveLength(2);
for (const button of buttons) {
expect(whatsAppHref(button)).toContain(
"Quero conversar sobre um projeto parecido com o da SEV Moda Íntima.",
"Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
);
}
+122 -21
View File
@@ -1,11 +1,11 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { type KeyboardEvent, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { COOKIE_BAR_ID } from "@/lib/analytics";
import { NAVIGATION_ITEMS } from "@/lib/site-navigation";
import {
isServiceOrCasePath,
headerOpensWhatsApp,
type WhatsAppContexts,
whatsAppSubjectForPath,
} from "@/lib/whatsapp";
@@ -15,10 +15,46 @@ import WhatsAppLink from "./WhatsAppLink";
const MENU_ID = "site-navigation-menu";
const CTA_LABEL = "Falar sobre meu projeto";
/** Ids que ficam inertes com o menu aberto: o conteúdo da página e o rodapé (o id do rodapé vem de app/layout.tsx). */
const INERT_IDS = ["main-content", "site-footer"];
/** A partir do breakpoint xl a barra mostra os links e o menu do celular deixa de existir. */
const DESKTOP_QUERY = "(min-width: 1280px)";
/**
* Botão de contato do cabeçalho. Nas páginas de serviço e de case ele abre o WhatsApp já com o assunto da página;
* nas demais leva ao formulário da home.
* Controles que o Tab percorre com o menu aberto: Aceitar e Recusar da barra de cookies, quando ela está aberta (ela
* vem antes do cabeçalho no layout e não fica inerte), e depois logo, botão do menu, links e botão de contato do painel.
* A navegação e o botão da barra de desktop ficam de fora (data-menu-trap-skip): estão escondidos no celular.
*/
function menuFocusables(header: HTMLElement) {
const cookieBar = document.getElementById(COOKIE_BAR_ID);
const roots = cookieBar ? [cookieBar, header] : [header];
return roots
.flatMap((root) =>
Array.from(
root.querySelectorAll<HTMLElement>("a[href], button:not([disabled])"),
),
)
.filter((element) => !element.closest("[data-menu-trap-skip]"));
}
/** Tab (e Shift+Tab) com o menu aberto: o foco dá a volta entre o cabeçalho e a barra de cookies, sem sair para a página inerte. */
function trapTab(event: KeyboardEvent, header: HTMLElement) {
const focusables = menuFocusables(header);
const first = focusables[0];
const last = focusables[focusables.length - 1];
const [edge, opposite] = event.shiftKey ? [first, last] : [last, first];
const active = document.activeElement;
const leaving =
active === edge || !focusables.some((element) => element === active);
if (!leaving || !opposite) return;
event.preventDefault();
opposite.focus();
}
/**
* Botão de contato do cabeçalho. Fora da home (serviço, case, /ia, /cases, /servicos e /curriculo) ele abre o WhatsApp
* já com o assunto da página; na home e nas demais páginas leva ao formulário da home.
*/
function ContactCta({
className,
@@ -46,9 +82,13 @@ function ContactCta({
);
}
return (
<Link href="/#contact" className={className} onClick={onNavigate}>
<HoverPrefetchLink
href="/#contact"
className={className}
onClick={onNavigate}
>
{CTA_LABEL}
</Link>
</HoverPrefetchLink>
);
}
@@ -59,12 +99,19 @@ export function Header({
whatsAppContexts?: WhatsAppContexts;
}) {
const pathname = usePathname();
const viaWhatsApp = isServiceOrCasePath(pathname);
const viaWhatsApp = headerOpensWhatsApp(pathname);
const subject = whatsAppSubjectForPath(pathname, whatsAppContexts);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
const headerRef = useRef<HTMLElement>(null);
const menuButtonRef = useRef<HTMLButtonElement>(null);
// Trocou a rota: o menu fecha (o cabeçalho vive no layout e não remonta entre as páginas).
// biome-ignore lint/correctness/useExhaustiveDependencies: pathname só dispara o fechamento a cada página
useEffect(() => {
setMobileMenuOpen(false);
}, [pathname]);
useEffect(() => {
let wasScrolled = false;
const updateScrolled = () => {
@@ -81,19 +128,72 @@ export function Header({
const closeMobileMenu = () => setMobileMenuOpen(false);
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Escape") return;
// Menu aberto: o conteúdo e o rodapé ficam inertes, a página não rola, o Tab não sai do cabeçalho e o Esc fecha.
useEffect(() => {
const header = headerRef.current;
const menuButton = menuButtonRef.current;
if (!mobileMenuOpen || !header || !menuButton) return;
event.preventDefault();
closeMobileMenu();
menuButtonRef.current?.focus();
};
const root = document.documentElement;
const inertTargets = INERT_IDS.flatMap((id) => {
const element = document.getElementById(id);
return element ? [element] : [];
});
for (const element of inertTargets) element.setAttribute("inert", "");
const previousOverflow = root.style.overflow;
root.style.overflow = "hidden";
// Devolve a página ao normal na hora, sem esperar o React: um link do menu para #id precisa achar o destino
// focável quando o gerenciador de âncoras da home (useHomeFragmentNavigation) tenta focá-lo no clique.
let released = false;
const release = () => {
if (released) return;
released = true;
for (const element of inertTargets) element.removeAttribute("inert");
root.style.overflow = previousOverflow;
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Tab") return trapTab(event, header);
if (event.key !== "Escape") return;
event.preventDefault();
release();
setMobileMenuOpen(false);
menuButton.focus();
};
// Na fase de captura da janela, antes do document: roda antes do gerenciador de âncoras da home.
const onLinkClick = (event: MouseEvent) => {
const link =
event.target instanceof Element
? event.target.closest("a[href]")
: null;
if (link && header.contains(link)) release();
};
const desktop = window.matchMedia?.(DESKTOP_QUERY);
const onViewportChange = (event: MediaQueryListEvent) => {
if (!event.matches) return;
release();
setMobileMenuOpen(false);
};
document.addEventListener("keydown", onKeyDown);
window.addEventListener("click", onLinkClick, true);
desktop?.addEventListener?.("change", onViewportChange);
return () => {
release();
document.removeEventListener("keydown", onKeyDown);
window.removeEventListener("click", onLinkClick, true);
desktop?.removeEventListener?.("change", onViewportChange);
};
}, [mobileMenuOpen]);
return (
<header
ref={headerRef}
className={`site-header fixed inset-x-0 top-0 z-[9000] h-[var(--site-header-height)] w-full border-b px-sp-6 text-[var(--white)] transition-colors duration-200 lg:px-sp-8 xl:px-sp-12 ${
scrolled
? "border-white/12 bg-[var(--ink)]/95 backdrop-blur-md"
? "border-white/12 bg-[var(--ink)]/95"
: "border-transparent bg-[var(--ink)]"
}`}
>
@@ -102,12 +202,14 @@ export function Header({
href="/#home"
aria-label="Evandro.dev.br — início"
className="flex min-h-11 shrink-0 items-center no-underline"
onClick={closeMobileMenu}
>
<Logo theme="dark" loading="eager" className="w-40 sm:w-44" />
</HoverPrefetchLink>
<nav
aria-label="Navegação principal"
data-menu-trap-skip=""
className="hidden items-center xl:flex"
>
<ul className="flex list-none items-center gap-sp-4">
@@ -115,7 +217,7 @@ export function Header({
<li key={item.href}>
<HoverPrefetchLink
href={item.href}
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-sm font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm px-sp-3 font-display text-[15px] font-medium normal-case tracking-normal text-white/80 no-underline transition-colors hover:bg-white/5 hover:text-white"
>
{item.label}
</HoverPrefetchLink>
@@ -125,9 +227,9 @@ export function Header({
</nav>
<div className="flex shrink-0 items-center">
<div className="hidden xl:block">
<div className="hidden xl:block" data-menu-trap-skip="">
<ContactCta
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-sm font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
className="inline-flex min-h-11 items-center whitespace-nowrap rounded-sm bg-[var(--slash)] px-sp-4 font-display text-[15px] font-semibold normal-case tracking-normal text-[var(--ink)] no-underline transition-colors hover:bg-[var(--white)]"
placement="header"
viaWhatsApp={viaWhatsApp}
subject={subject}
@@ -141,7 +243,6 @@ export function Header({
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
onKeyDown={handleMenuKeyDown}
>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
@@ -158,13 +259,13 @@ export function Header({
{mobileMenuOpen ? (
// Tela cheia abaixo da barra, com fundo opaco: o conteúdo da página não aparece por trás dos links.
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom))] lg:px-sp-8 xl:hidden">
// A barra de cookies (fixa, acima do cabeçalho) cobre a base da tela: o espaço dela sobe o botão de contato.
<div className="absolute inset-x-0 top-[var(--site-header-height)] flex h-[calc(100dvh-var(--site-header-height))] flex-col overflow-y-auto overscroll-contain bg-[var(--ink)] px-sp-6 pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))] lg:px-sp-8 xl:hidden">
<div className="ds-section-shell flex flex-1 flex-col">
<nav
id={MENU_ID}
aria-label="Navegação mobile"
className="flex flex-1 flex-col"
onKeyDown={handleMenuKeyDown}
>
<ul className="flex list-none flex-col">
{NAVIGATION_ITEMS.map((item) => (
+42 -14
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import Hero from "./Hero";
@@ -18,20 +18,30 @@ describe("Hero", () => {
jest.restoreAllMocks();
});
it("routes project conversations to contact without opening an external service", () => {
it("opens WhatsApp straight from the main button, with the subject of the home, while 'Ver projetos entregues' stays secondary", () => {
render(<Hero />);
const contactLink = screen.getByRole("link", {
name: /conversar/i,
});
expect(contactLink).toHaveAttribute("href", "/#contact");
expect(contactLink).not.toHaveAttribute("target", "_blank");
fireEvent.click(contactLink);
expect(window.open).not.toHaveBeenCalled();
expect(screen.getByRole("link", { name: /ver projetos/i })).toHaveAttribute(
"href",
"/cases",
expect(contactLink).toHaveAccessibleName("Conversar sobre meu projeto");
expect(contactLink.getAttribute("href")).toMatch(
/^https:\/\/wa\.me\/\d+\?/,
);
expect(contactLink).toHaveAttribute("target", "_blank");
expect(contactLink).toHaveAttribute("rel", "noopener noreferrer");
const text = new URL(
contactLink.getAttribute("href") ?? "",
).searchParams.get("text");
expect(text).toBe(
"Olá, Evandro! Quero conversar sobre um site ou sistema para a minha empresa. Contato iniciado pelo site evandro.dev.br.",
);
// o botão primário é o único com o fundo limão; o secundário é contorno
expect(contactLink.className).toContain("bg-[var(--slash)]");
const projects = screen.getByRole("link", { name: /ver projetos/i });
expect(projects).toHaveAttribute("href", "/cases");
expect(projects.className).not.toContain("bg-[var(--slash)]");
expect(projects).not.toHaveAttribute("target");
});
it("keeps the headline as one readable sentence despite the per-word animation", () => {
@@ -119,13 +129,31 @@ describe("Hero", () => {
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
}
expect(images[0]).toHaveAttribute(
"src",
"/videos/sev-modaintima/desktop.webp",
);
// pelo otimizador do next/image: o endereço original vai no parâmetro `url`
const original = (image: Element) =>
new URL(
image.getAttribute("src") ?? "",
"http://localhost",
).searchParams.get("url");
expect(original(images[0])).toBe("/videos/sev-modaintima/desktop.webp");
expect(images[0]).toHaveAccessibleName(
"Site da SEV Moda Íntima aberto no computador",
);
expect(images[1]).toHaveAttribute("src", "/videos/as-servicos/mobile.webp");
expect(original(images[1])).toBe("/videos/as-servicos/mobile.webp");
});
it("shows a small phone frame with the mobile poster of SEV below the buttons on small screens", () => {
render(<Hero clients={clients} />);
const small = screen.getByTestId("hero-work-mobile");
expect(small).toHaveClass("lg:hidden");
const image = small.querySelector("img") as HTMLImageElement;
expect(image).toHaveAccessibleName(
"Site da SEV Moda Íntima aberto no celular",
);
const buttons = screen.getByRole("link", { name: /ver projetos/i });
expect(
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
+7 -3
View File
@@ -4,10 +4,12 @@ import Link from "next/link";
import { type CSSProperties, Fragment } from "react";
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
import { HOME_SUBJECT } from "@/lib/whatsapp";
import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
import WhatsAppLink from "./WhatsAppLink";
const SOCIAL_LINKS = [
{
@@ -108,9 +110,11 @@ export default function Hero({
</p>
<div className="hero-in mt-sp-5 flex flex-col gap-sp-3 [--d:.65s] sm:flex-row sm:flex-wrap sm:items-center">
<Link
{/* Abre o WhatsApp direto, com o assunto da home: o formulário continua na seção de contato, ao fim da página. */}
<WhatsAppLink
id={HERO_CTA_ID}
href="/#contact"
service={HOME_SUBJECT}
placement="hero"
className="group inline-flex min-h-12 items-center justify-center gap-sp-3 bg-[var(--slash)] px-sp-4 py-sp-3 text-center text-[15px] font-semibold leading-6 text-[var(--ink)] no-underline transition-colors hover:bg-[#d9ff40] motion-reduce:transition-none sm:px-sp-6 sm:text-[16px]"
>
Conversar sobre meu projeto
@@ -124,7 +128,7 @@ export default function Hero({
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</Link>
</WhatsAppLink>
<Link
href="/cases"
className="inline-flex min-h-12 items-center justify-center border border-white/25 bg-transparent px-sp-5 py-sp-3 text-center text-[15px] font-medium leading-6 text-[#f4f2ed] no-underline transition-colors hover:border-white/55 motion-reduce:transition-none sm:text-[16px]"
+102
View File
@@ -0,0 +1,102 @@
import { render, screen, within } from "@testing-library/react";
import HeroWork from "./HeroWork";
/**
* Os pôsteres do hero: pelo next/image com `sizes` do tamanho em que aparecem, sempre lazy e com prioridade baixa
* (o título é o LCP), e a moldura de celular pequena para as telas abaixo de 1024 px.
*/
const original = (image: Element) =>
new URL(image.getAttribute("src") ?? "", "http://localhost").searchParams.get(
"url",
);
describe("HeroWork: pôsteres no tamanho certo", () => {
it("serves the three posters through the image optimizer, each with a `sizes` that matches how it shows", () => {
render(<HeroWork />);
const desktop = within(screen.getByTestId("hero-work")).getAllByRole("img");
expect(desktop).toHaveLength(2);
expect(original(desktop[0])).toBe("/videos/sev-modaintima/desktop.webp");
expect(original(desktop[1])).toBe("/videos/as-servicos/mobile.webp");
// 1440x900 e 390x844 cheios para quadros de ~491x307 e ~130x299 era banda de sobra
expect(desktop[0]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 500px, 36vw",
);
expect(desktop[1]).toHaveAttribute(
"sizes",
"(min-width: 1400px) 136px, 10vw",
);
const small = within(screen.getByTestId("hero-work-mobile")).getByRole(
"img",
);
expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
expect(small).toHaveAttribute("sizes", "84px");
});
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
render(<HeroWork />);
const [browser, phone] = within(
screen.getByTestId("hero-work"),
).getAllByRole("img");
expect(browser).toHaveAttribute("width", "1440");
expect(browser).toHaveAttribute("height", "900");
expect(phone).toHaveAttribute("width", "390");
expect(phone).toHaveAttribute("height", "844");
});
it("never asks for a high priority: lazy, low fetch priority and no preload, so the headline stays the LCP", () => {
const { container } = render(<HeroWork />);
const images = container.querySelectorAll("img");
expect(images).toHaveLength(3);
for (const image of images) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("fetchpriority", "low");
}
// sem <link rel="preload">: o next/image só o insere com `preload` (ou fetchPriority="high")
expect(document.head.querySelector('link[rel="preload"]')).toBeNull();
});
});
describe("HeroWork: moldura de celular pequena", () => {
it("shows a 180px phone with the SEV mobile poster only below 1024px, while the column stays desktop-only", () => {
render(<HeroWork />);
const column = screen.getByTestId("hero-work");
expect(column).toHaveClass("hidden", "lg:block");
const small = screen.getByTestId("hero-work-mobile");
expect(small).toHaveClass("lg:hidden");
const phone = small.querySelector(".ds-phone") as HTMLElement;
expect(phone.style.height).toBe("180px");
expect(phone).toContainElement(within(small).getByRole("img"));
expect(within(small).getByRole("img")).toHaveAccessibleName(
"Site da SEV Moda Íntima aberto no celular",
);
expect(
within(small).getByText(/Site da SEV Moda Íntima, como aparece/),
).toBeInTheDocument();
});
it("is not interactive: the frames only show the work", () => {
render(<HeroWork />);
expect(screen.queryByRole("link")).toBeNull();
expect(screen.getByTestId("hero-work-mobile")).toHaveClass(
"pointer-events-none",
);
expect(screen.getByTestId("hero-work")).toHaveClass("pointer-events-none");
});
it("passes the address to the browser bar of the desktop frame", () => {
render(<HeroWork url={{ text: "sevmodaintima.com.br", secure: true }} />);
expect(
within(screen.getByTestId("hero-work")).getByText("sevmodaintima.com.br"),
).toBeInTheDocument();
});
});
+69 -38
View File
@@ -1,3 +1,4 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
@@ -7,52 +8,82 @@ export type HeroWorkUrl = { text: string; secure: boolean } | null;
const DESKTOP_PROJECT = "sev-modaintima";
const MOBILE_PROJECT = "as-servicos";
/** No celular a moldura mostra o site da SEV, o mesmo projeto do navegador ao lado do título no computador. */
const SMALL_SCREEN_PROJECT = "sev-modaintima";
/** Altura da moldura de celular abaixo dos botões (px); a largura sai da proporção 390x844 do aparelho. */
const SMALL_PHONE_HEIGHT = 180;
/**
* Trabalho real ao lado do título, só em telas de 1024 px ou mais: o pôster do site da SEV Moda Íntima no navegador
* (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo da AS Serviços no celular.
* A coluna ocupa cerca de 40% da largura. Ela fica escondida (display: none) abaixo de 1024 px,
* então as imagens (lazy) nem são baixadas no celular.
* Trabalho real do hero, em dois formatos. Em telas de 1024 px ou mais, ao lado do título: o pôster do site da SEV
* Moda Íntima no navegador (com o domínio próprio, sevmodaintima.com.br, na barra de endereço) e o do aplicativo
* da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, uma moldura de celular
* pequena (180 px de altura) com o pôster mobile da SEV, abaixo dos botões.
* Os pôsteres vão pelo next/image com `sizes` do tamanho em que aparecem (um arquivo de 1440 px para 491 px de tela
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: o título segue como o maior elemento da dobra (LCP)
* e a imagem que fica escondida pelo breakpoint (display: none) nem é baixada.
*/
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
const smallPhone = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
return (
<div
data-testid="hero-work"
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
>
<BrowserFrame url={url} className="w-[88%]">
{/* biome-ignore lint/performance/noImgElement: pôster de 1440 px já otimizado em /public/videos; next/image pediria o otimizador para um arquivo menor que o necessário */}
<img
src={desktop.poster}
alt="Site da SEV Moda Íntima aberto no computador"
width={desktop.width}
height={desktop.height}
loading="lazy"
decoding="async"
fetchPriority="low"
className="block h-auto w-full"
/>
</BrowserFrame>
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
<PhoneFrame
className="right-0 bottom-[-12%] w-[26%]"
style={{ position: "absolute" }}
<>
<div
data-testid="hero-work"
className="hero-in pointer-events-none absolute top-1/2 right-sp-12 hidden w-[40%] -translate-y-[46%] [--d:.8s] lg:block"
>
{/* biome-ignore lint/performance/noImgElement: mesmo motivo do pôster desktop */}
<img
src={phone.poster}
alt="Site da AS Serviços aberto no celular"
width={phone.width}
height={phone.height}
loading="lazy"
decoding="async"
fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top"
/>
</PhoneFrame>
</div>
<BrowserFrame url={url} className="w-[88%]">
{/* a moldura mostra ~491 px em 1440 px de tela: 36vw até 1400 px, e fixo dali em diante */}
<Image
src={desktop.poster}
alt="Site da SEV Moda Íntima aberto no computador"
width={desktop.width}
height={desktop.height}
sizes="(min-width: 1400px) 500px, 36vw"
loading="lazy"
fetchPriority="low"
className="block h-auto w-full"
/>
</BrowserFrame>
{/* position inline: .ds-phone (showcase.css, sem camada) define position: relative e vence a utilidade "absolute" */}
<PhoneFrame
className="right-0 bottom-[-12%] w-[26%]"
style={{ position: "absolute" }}
>
<Image
src={phone.poster}
alt="Site da AS Serviços aberto no celular"
width={phone.width}
height={phone.height}
sizes="(min-width: 1400px) 136px, 10vw"
loading="lazy"
fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top"
/>
</PhoneFrame>
</div>
<div
data-testid="hero-work-mobile"
className="hero-in pointer-events-none mt-sp-5 flex items-center gap-sp-4 [--d:.8s] lg:hidden"
>
<PhoneFrame style={{ height: SMALL_PHONE_HEIGHT }}>
<Image
src={smallPhone.poster}
alt="Site da SEV Moda Íntima aberto no celular"
width={smallPhone.width}
height={smallPhone.height}
sizes="84px"
loading="lazy"
fetchPriority="low"
className="absolute inset-0 block h-full w-full object-cover object-top"
/>
</PhoneFrame>
<p className="max-w-[16em] font-display text-[15px] leading-6 text-[rgba(244,242,237,.72)]">
Site da SEV Moda Íntima, como aparece no celular.
</p>
</div>
</>
);
}
+19 -2
View File
@@ -2,7 +2,7 @@ import { render, screen, within } from "@testing-library/react";
import HomeFaq, { FAQ_ITEMS } from "./HomeFaq";
describe("HomeFaq", () => {
it("shows the four questions the site already backs up, closed by default", () => {
it("shows the five questions the site already backs up, closed by default", () => {
const { container } = render(<HomeFaq />);
expect(
@@ -13,6 +13,7 @@ describe("HomeFaq", () => {
);
expect(summaries).toEqual([
"Como começa um projeto?",
"Quanto custa?",
"Você atende fora de Santa Catarina?",
"Dá para continuar um sistema que já existe ou que foi feito com IA?",
"O que preciso ter para começar?",
@@ -54,7 +55,23 @@ describe("HomeFaq", () => {
// ...e fora da página
expect(screen.queryByText(unanswered)).toBeNull();
}
expect(container.querySelectorAll("details")).toHaveLength(4);
expect(container.querySelectorAll("details")).toHaveLength(5);
});
it("answers 'Quanto custa?' with the owner's sentence only: no value, no deadline, and no promise that the first talk is free", () => {
render(<HomeFaq />);
const details = screen
.getByText("Quanto custa?")
.closest("details") as HTMLElement;
expect(
within(details).getByText(
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
),
).toBeInTheDocument();
expect(details.textContent).not.toMatch(
/R\$|\d|grátis|gratuit|sem custo|sem compromisso/i,
);
});
it("starts showing a prepared question as soon as it gets an answer", () => {
+7 -1
View File
@@ -9,7 +9,8 @@ export interface FaqItem {
}
/**
* Perguntas da home. As quatro primeiras só dizem o que o site já sustenta (contato, rodapé, /ia).
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
* já autorizou ("Quanto custa?": escopo, valor e prazo por escrito depois da primeira conversa, sem número nenhum).
* As três últimas estão preparadas, sem resposta: o dono as responde depois e elas passam a aparecer.
* Nunca escrever aqui prazo, preço, garantia ou política de pagamento que ele não tenha dito.
*/
@@ -19,6 +20,11 @@ export const FAQ_ITEMS: FaqItem[] = [
answer:
"Com uma conversa para entender o problema, sem precisar chegar com a solução definida. Depois dela, você recebe o escopo e a proposta por escrito.",
},
{
question: "Quanto custa?",
answer:
"Depois da primeira conversa você recebe escopo, valor e prazo por escrito, antes de qualquer compromisso.",
},
{
question: "Você atende fora de Santa Catarina?",
answer:
+17
View File
@@ -41,6 +41,23 @@ it("talks about the service when one is given", () => {
);
});
it("takes an id, so the floating shortcut can find the invitation it replaces", () => {
render(
<WhatsAppLink id="hero-contact-cta" placement="hero">
Conversar
</WhatsAppLink>,
);
expect(screen.getByRole("link", { name: "Conversar" })).toHaveAttribute(
"id",
"hero-contact-cta",
);
render(<WhatsAppLink placement="hero">Sem id</WhatsAppLink>);
expect(screen.getByRole("link", { name: "Sem id" })).not.toHaveAttribute(
"id",
);
});
it("talks about 'um projeto' when no service is given", () => {
render(<WhatsAppLink placement="contact_direct">Conversar</WhatsAppLink>);
+4
View File
@@ -7,6 +7,8 @@ import { buildWhatsAppUrl } from "@/lib/whatsapp";
interface WhatsAppLinkProps {
children: ReactNode;
className?: string;
/** Para o botão flutuante achar o convite (HERO_CTA_ID). */
id?: string;
/** Assunto da conversa, na frase "Quero conversar sobre {service}". Sem ele, a mensagem fala de "um projeto". */
service?: string;
placement: string;
@@ -20,6 +22,7 @@ const DEFAULT_ANALYTICS_SERVICE = "Contato";
export default function WhatsAppLink({
children,
className = "",
id,
service,
placement,
onClick,
@@ -29,6 +32,7 @@ export default function WhatsAppLink({
const analyticsService = subject ?? DEFAULT_ANALYTICS_SERVICE;
return (
<a
id={id}
href={buildWhatsAppUrl(
`Olá, Evandro! Quero conversar sobre ${subject ?? "um projeto"}. Contato iniciado pelo site evandro.dev.br.`,
)}
+206 -134
View File
@@ -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();
});
});
+147 -188
View File
@@ -1,83 +1,73 @@
import Link from "next/link";
import type { ServiceLink } from "@/lib/site-navigation";
import { DrawLine } from "./animations/DrawLine";
import { primaryButton, secondaryButtonLight } from "./button-styles";
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { primaryButton, textLink } from "./button-styles";
import ContactOfferLink from "./ContactOfferLink";
import type { ContactKind } from "./contact-subject";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Reveal } from "./Reveal";
import { Section } from "./Section";
interface Tier {
interface Offer {
id: string;
badge: string;
title: string;
tagline: string;
description: string;
deliverables: string[];
/** Primeiro passo concreto, sem prazo nem preço. */
firstStep: string;
featured?: boolean;
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
kind: ContactKind;
ctaLabel: string;
ctaHref: string;
/** Case cujo pôster ilustra a oferta; sem ele, a miniatura é o cartão de checklist. */
project?: { id: string; title: string };
moreLabel: string;
moreHref: string;
}
const TIERS: Tier[] = [
/**
* As três ofertas da home, na ordem do H1: site que traz pedidos, sistema que tira a operação da planilha e revisão
* de sistema que já existe. Cada texto só diz o que o case ou a página de /ia citados já sustentam; sem prazo nem preço.
*/
const OFFERS: Offer[] = [
{
id: "build",
badge: "Do zero à produção",
title: "Um sistema para a sua operação",
tagline: "Quando as ferramentas prontas já não atendem.",
id: "site",
title: "Site que traz pedidos pelo WhatsApp",
description:
"Transforme regras de negócio, planilhas e processos dispersos em um sistema próprio, com os fluxos e as integrações que a empresa precisa.",
deliverables: [
"Definição de escopo e arquitetura",
"Interface, backend e banco de dados",
"Integrações e permissões de acesso",
"Validação, publicação e documentação",
],
firstStep:
"Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
featured: true,
ctaLabel: "Conversar sobre meu sistema",
ctaHref: "/#contact",
"Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho, como na Lavação 047.",
kind: "site",
ctaLabel: "Começar pelo site",
project: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver o case Lavação 047",
moreHref: "/cases/lavacao-047",
},
{
id: "discovery",
badge: "Antes de investir",
title: "Diagnóstico de um sistema existente",
tagline: "Entenda o que aproveitar e o que precisa mudar.",
id: "sistema",
title: "Sistema para tirar a operação da planilha",
description:
"Seu sistema ficou lento, frágil ou difícil de manter? Avalio o código e a operação para orientar a próxima decisão, inclusive em projetos iniciados com IA.",
deliverables: [
"Levantamento de riscos e dependências",
"Revisão de acesso aos dados",
"Análise dos gargalos da aplicação",
"Plano de correção por prioridade",
],
firstStep:
"Você me mostra o que já existe. Depois, recebe o levantamento de riscos e o plano de correção por prioridade.",
ctaLabel: "Avaliar meu projeto",
ctaHref: "/#contact",
"Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe. Um exemplo é a SEV Moda Íntima, com cadastro de peças, pedidos e estoque por tamanho.",
kind: "sistema",
ctaLabel: "Começar pelo sistema",
project: { id: "sev-modaintima", title: "SEV Moda Íntima" },
moreLabel: "Ver o case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
{
id: "evolution",
badge: "Continuidade",
title: "Integração e evolução",
tagline: "Faça o que já existe trabalhar melhor.",
id: "revisao",
title: "Revisão de sistema que já existe, inclusive feito com IA",
description:
"Conecte ferramentas, reduza tarefas manuais e acrescente funcionalidades ao sistema atual, com atenção às dependências e ao impacto de cada mudança.",
deliverables: [
"Integração entre sistemas e equipamentos",
"Novos fluxos e automações",
"Testes das partes afetadas",
"Planejamento de manutenção e operação",
],
firstStep:
"Primeira conversa sobre o sistema e as ferramentas que você já usa. Depois, escopo e proposta por escrito.",
ctaLabel: "Planejar a próxima etapa",
ctaHref: "/#contact",
"Avalio o que já existe, testo as falhas de acesso a dados e separo o que dá para aproveitar do que é melhor refazer, antes de o sistema receber mais clientes.",
kind: "revisao",
ctaLabel: "Começar pela revisão",
moreLabel: "Ver como funciona a revisão",
moreHref: "/ia",
},
];
/** O que a revisão entrega: os mesmos itens da página /ia, em versão curta para a miniatura da oferta. */
const REVIEW_CHECKLIST = [
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
];
const PROCESS = [
{
title: "Entender a operação",
@@ -101,167 +91,136 @@ const PROCESS = [
},
];
function TierCard({ tier }: Readonly<{ tier: Tier }>) {
/** Miniatura da revisão, sem case para mostrar: o que o relatório traz, em um cartão escuro do mesmo tamanho dos pôsteres. */
function ReviewChecklist() {
return (
<article
className={`ds-card cv-press cv-lift relative flex h-full min-w-0 flex-col overflow-hidden max-md:w-full max-md:max-w-none max-md:px-sp-5 max-md:py-sp-5 ${
tier.featured ? "border-[var(--slash)] shadow-sm" : ""
}`}
style={{
borderWidth: tier.featured ? "2px" : "1px",
}}
>
<div className="flex min-w-0 items-start justify-between gap-sp-4">
<span className="ds-kicker">{tier.badge}</span>
</div>
<DrawLine delay={0.04} />
<div className="mt-sp-4 min-w-0">
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
{tier.title}
</h3>
<p className="mt-sp-2 text-[15px] md:text-[13px] leading-[1.7] text-[var(--meta)]">
{tier.tagline}
</p>
</div>
<p
className="ds-copy !mt-sp-4 !text-[15px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[13px]"
data-reel="right"
>
{tier.description}
<div className="flex h-full flex-col justify-center gap-sp-3 px-sp-5 py-sp-4 text-white">
<p className="font-display text-[15px] font-semibold leading-6 text-white">
O que você recebe
</p>
<ul
className="ds-list !mt-sp-5 !pl-[1.15rem] !text-[15px] !leading-[1.75] [overflow-wrap:break-word] [word-break:normal] md:!text-[12px]"
data-reel-stagger="fade"
>
{tier.deliverables.map((deliverable) => (
<li key={deliverable} className="pl-sp-1">
{deliverable}
<ul className="flex flex-col gap-sp-2">
{REVIEW_CHECKLIST.map((item) => (
<li
key={item}
className="flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/85"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="mt-1 h-4 w-4 shrink-0 text-white/70"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m3 8.5 3.2 3L13 4.5" />
</svg>
{item}
</li>
))}
</ul>
</div>
);
}
<div className="mt-sp-6 space-y-sp-2 border-t border-[var(--border)] pt-sp-5">
<div className="font-display text-[clamp(17px,4.6vw,18px)] font-extrabold leading-none tracking-tight">
Primeiro passo
</div>
<div className="font-display text-[15px] md:text-[13px] leading-[1.7] text-[var(--ink-80)]">
{tier.firstStep}
</div>
/** Um único estilo de cartão para as três ofertas: miniatura, título com altura mínima, texto, botão e link do case. */
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
const poster = offer.project
? getShowcaseAssets(offer.project.id, "desktop")
: null;
return (
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
<div className="aspect-[16/10] overflow-hidden border border-[var(--border)] bg-[var(--ink)] md:aspect-[21/9] lg:aspect-[16/10]">
{poster && offer.project ? (
<Image
src={poster.poster}
alt={`Site ${offer.project.title} aberto no computador`}
width={poster.width}
height={poster.height}
sizes="(min-width: 1024px) 33vw, 100vw"
loading="lazy"
className="block h-full w-full object-cover object-top"
/>
) : (
<ReviewChecklist />
)}
</div>
<Link
href={tier.ctaHref}
className={`mt-sp-6 w-full ${
tier.featured ? primaryButton : secondaryButtonLight
}`}
{/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */}
<h3 className="ds-card-title !mt-sp-5 text-balance !text-[clamp(20px,5vw,26px)] !leading-[1.1] !tracking-[-0.03em] [overflow-wrap:break-word] [word-break:normal] lg:min-h-[3.3em] lg:!text-[22px] xl:min-h-[2.2em] xl:!text-[24px]">
{offer.title}
</h3>
<p
className="ds-copy !mt-sp-3 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
data-reel="right"
>
{tier.ctaLabel}
</Link>
{offer.description}
</p>
<ContactOfferLink
kind={offer.kind}
className={`mt-sp-6 w-full ${primaryButton}`}
>
{offer.ctaLabel}
</ContactOfferLink>
<HoverPrefetchLink
href={offer.moreHref}
className={`${textLink} mt-sp-2 self-start text-[var(--ink-80)]`}
>
{offer.moreLabel}
</HoverPrefetchLink>
</article>
);
}
/** A home mostra só os três serviços principais, nesta ordem: sites, sistemas web e integrações. O hub tem os demais. */
const HOME_SERVICE_SLUGS = [
"criacao-de-sites",
"engenharia-de-software",
"integracao-hardware-software-iot",
];
export function WorkModalities({
serviceLinks = [],
}: {
/** Os serviços do hub; app/page.tsx os lê de lib/site-content.ts. */
serviceLinks?: ServiceLink[];
}) {
const mainServices = HOME_SERVICE_SLUGS.flatMap((slug) => {
const service = serviceLinks.find(({ href }) => href.endsWith(`/${slug}`));
return service ? [service] : [];
});
export function WorkModalities() {
return (
<Section
id="modalities"
eyebrow="Soluções"
title="Software feito a partir do seu negócio."
description="Para empresas que precisam de um sistema próprio, de ferramentas conectadas ou de uma avaliação técnica antes de dar o próximo passo."
title="Escolha por onde começar"
description="Três pontos de partida. Escolha o que mais se parece com o seu caso: o formulário de contato já abre com o tipo de projeto marcado."
disableChildrenAnimation
>
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-[760px]">
<div className="ds-kicker">Reconhece algum destes cenários?</div>
<h3 className="ds-card-title !mt-sp-3" data-reel="left">
Planilhas demais. Sistemas isolados. Trabalho repetido.
</h3>
<p className="ds-copy !mt-sp-3 max-w-[62ch]" data-reel="right">
A operação cresceu, mas as ferramentas não acompanharam. Ou a
ideia precisa sair do protótipo para atender clientes. O projeto
começa pelo problema, sem exigir que você já saiba qual tecnologia
usar.
</p>
</div>
<Link href="/servicos" className={`shrink-0 ${secondaryButtonLight}`}>
Ver todos os serviços
</Link>
</div>
<div
className="mt-sp-6 grid gap-sp-3 md:grid-cols-3"
data-reel-stagger="left"
>
{mainServices.map((service) => (
<Link
key={service.href}
href={service.href}
className="min-w-0 rounded-[8px] border border-[var(--border)] bg-white/70 px-sp-4 py-sp-4 transition-colors duration-200 hover:border-[var(--slash)] hover:bg-white"
>
<div className="font-display text-base font-semibold leading-snug text-[var(--ink)]">
{service.label}
</div>
<div className="mt-sp-2 font-display text-[15px] md:text-sm leading-6 text-[var(--ink-80)]">
{service.description}
</div>
</Link>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-sp-4 md:grid-cols-2 md:gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{TIERS.map((tier, index) => (
<div className="grid grid-cols-1 gap-sp-5 lg:grid-cols-3 lg:gap-sp-6">
{OFFERS.map((offer, index) => (
<Reveal
key={tier.id}
key={offer.id}
y={20}
duration={0.75}
delay={0.08 * (index + 1)}
className="min-w-0"
>
<TierCard tier={tier} />
<OfferCard offer={offer} />
</Reveal>
))}
</div>
<div id="process" className="mt-sp-12 scroll-mt-24">
<h3 className="ds-card-title">Como o projeto acontece</h3>
<p className="ds-copy max-w-[65ch]">
Você acompanha as decisões e valida cada etapa. Prazo e investimento
dependem do escopo, das integrações e dos riscos identificados.
</p>
<ol className="mt-sp-6 grid gap-sp-6 sm:grid-cols-2 xl:grid-cols-4">
{/* Lista compacta: o título e a descrição de cada etapa numa linha só, sem a grade de quatro cartões. */}
<div
id="process"
className="mt-sp-12 grid scroll-mt-24 gap-sp-6 border-t border-[var(--border)] pt-sp-8 lg:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)] lg:gap-sp-12"
>
<div>
<h3 className="ds-card-title !mt-0">Como eu trabalho</h3>
<p className="ds-copy max-w-[40ch] !text-[15px]">
Você acompanha as decisões e valida cada etapa. Prazo e investimento
dependem do escopo, das integrações e dos riscos identificados.
</p>
</div>
<ol className="divide-y divide-[var(--border)] border-y border-[var(--border)]">
{PROCESS.map((step, index) => (
<li
key={step.title}
className="border-t border-[var(--border)] pt-sp-4"
>
<span className="ds-kicker">
<li key={step.title} className="flex gap-sp-4 py-sp-4">
<span className="w-7 shrink-0 font-mono text-[15px] leading-6 text-[var(--meta)]">
{String(index + 1).padStart(2, "0")}
</span>
<h4 className="mt-sp-3 text-base font-semibold">{step.title}</h4>
<p className="ds-copy">{step.description}</p>
<p className="min-w-0 font-display text-[15px] leading-6 text-[var(--ink-80)]">
<strong className="font-semibold text-[var(--ink)]">
{step.title}.
</strong>{" "}
{step.description}
</p>
</li>
))}
</ol>
@@ -1,3 +1,5 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { render, screen } from "@testing-library/react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import CinematicHome from "./CinematicHome";
@@ -137,16 +139,12 @@ describe("CinematicHome: props e estrutura", () => {
const testimonials = [
{ quote: "Ótimo.", name: "Ana", role: "Sócia", company: "Empresa" },
];
const serviceLinks = [
{ href: "/servicos/x", label: "X", description: "Serviço X" },
];
render(
<CinematicHome
projects={projects}
clients={clients}
testimonials={testimonials}
serviceLinks={serviceLinks}
heroStats={{ years: 5 }}
/>,
);
@@ -154,7 +152,8 @@ describe("CinematicHome: props e estrutura", () => {
expect(lastHeroProps()).toMatchObject({ stats: { years: 5 }, clients });
expect(mockProjectsProps).toHaveBeenCalledWith({ projects });
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: testimonials });
expect(mockModalitiesProps).toHaveBeenCalledWith({ serviceLinks });
// as ofertas não recebem mais os serviços do hub: a grade duplicada saiu da seção
expect(mockModalitiesProps).toHaveBeenCalledWith({});
});
it("path: optional props omitted -> the scenes receive undefined and decide their own defaults", () => {
@@ -165,9 +164,7 @@ describe("CinematicHome: props e estrutura", () => {
clients: undefined,
});
expect(mockTestimonialsProps).toHaveBeenCalledWith({ items: undefined });
expect(mockModalitiesProps).toHaveBeenCalledWith({
serviceLinks: undefined,
});
expect(mockModalitiesProps).toHaveBeenCalledWith({});
});
it("path: scenes sit in order inside <main> (FAQ right before the contact); the home has no footer of its own (it comes from the layout)", () => {
@@ -201,3 +198,25 @@ describe("CinematicHome: props e estrutura", () => {
expect(onLayoutReady).toHaveBeenCalledTimes(1);
});
});
describe("CinematicHome: componente de servidor", () => {
const source = readFileSync(join(__dirname, "CinematicHome.tsx"), "utf8");
it("path: the file is a server component: no 'use client' and no React hooks, which live in the CinematicMain island", () => {
expect(source).not.toMatch(/^["']use client["']/m);
expect(source).not.toMatch(
/\buse(?:State|Effect|LayoutEffect|Ref|Callback|Memo)\b/,
);
expect(source).not.toMatch(/useCinematicScroll|useHomeFragmentNavigation/);
expect(source).toContain('import CinematicMain from "./CinematicMain"');
});
it("path: the island is the only client file of the pair, and it wraps the scenes as children", () => {
const island = readFileSync(join(__dirname, "CinematicMain.tsx"), "utf8");
expect(island).toMatch(/^"use client";/);
expect(island).toMatch(/children/);
expect(island).toContain("useCinematicScroll");
expect(island).toContain("useHomeFragmentNavigation");
});
});
+15 -27
View File
@@ -1,9 +1,5 @@
"use client";
import { useEffect, useRef } from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import type { Testimonial } from "@/lib/proof";
import type { ServiceLink } from "@/lib/site-navigation";
import About from "../About";
import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact";
@@ -14,17 +10,19 @@ import Projects from "../Projects";
import { displayUrl } from "../showcase/BrowserFrame";
import Testimonials from "../Testimonials";
import { WorkModalities } from "../WorkModalities";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
import CinematicMain from "./CinematicMain";
/** Projeto cujo endereço aparece na barra do navegador de HeroWork (a SEV Moda Íntima, com domínio próprio). */
const HERO_WORK_PROJECT = "sev-modaintima";
/**
* Cenas da home, em componente de servidor: sem hooks aqui. Os hooks de rolagem e de chegada por fragmento ficam
* na ilha de cliente CinematicMain, que recebe as cenas como filhos.
*/
export default function CinematicHome({
projects,
clients,
testimonials,
serviceLinks,
heroStats,
}: {
heroStats?: HeroStats;
@@ -32,8 +30,6 @@ export default function CinematicHome({
projects: PortfolioProjectView[];
clients?: ClientLink[];
testimonials?: Testimonial[];
/** Serviços do hub; lib/site-content.ts só pode ser lido no servidor, então a página os entrega prontos. */
serviceLinks?: ServiceLink[];
}) {
const shownUrl = displayUrl({
demoUrl: projects.find((project) => project.id === HERO_WORK_PROJECT)
@@ -42,32 +38,24 @@ export default function CinematicHome({
// endereço de hospedagem (vercel.app) nunca vai para a barra: sem domínio próprio, ela mostra "pré-visualização"
const heroWorkUrl =
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
useEffect(() => onLayoutReady(), [onLayoutReady]);
return (
<div className="overflow-x-clip">
{/* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. O rodapé vem do layout. */}
<main ref={mainRef} className="ds-main relative z-0">
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<CinematicMain>
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<Projects projects={projects} />
<Projects projects={projects} />
<About />
<About />
<WorkModalities serviceLinks={serviceLinks} />
<WorkModalities />
<EngineeringWithAI />
<EngineeringWithAI />
<Testimonials items={testimonials} />
<Testimonials items={testimonials} />
<HomeFaq />
<HomeFaq />
<Contact />
</main>
</div>
<Contact />
</CinematicMain>
);
}
@@ -0,0 +1,70 @@
import { render, screen } from "@testing-library/react";
import CinematicMain from "./CinematicMain";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/**
* A ilha de cliente da home: tem os hooks de rolagem e de chegada por fragmento e entrega as cenas (filhos) dentro
* de <main>. Os hooks são trocados por dublês que registram o que recebem.
*/
jest.mock("./useCinematicScroll", () => ({ useCinematicScroll: jest.fn() }));
jest.mock("./useHomeFragmentNavigation", () => ({
useHomeFragmentNavigation: jest.fn(),
}));
const onLayoutReady = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(useHomeFragmentNavigation).mockReturnValue(onLayoutReady);
});
describe("CinematicMain", () => {
it("renders the scenes as direct children of <main>, inside the clipping wrapper", () => {
render(
<CinematicMain>
<section data-testid="a" />
<section data-testid="b" />
</CinematicMain>,
);
const main = screen.getByRole("main");
expect(main).toHaveClass("ds-main", "relative", "z-0");
expect(
Array.from(main.children).map((child) =>
child.getAttribute("data-testid"),
),
).toEqual(["a", "b"]);
expect(main.parentElement).toHaveClass("overflow-x-clip");
});
it("gives the scroll hook the <main> ref and runs the layout-ready callback once on mount", () => {
render(
<CinematicMain>
<section />
</CinematicMain>,
);
const ref = jest.mocked(useCinematicScroll).mock.calls[0][0];
expect(ref.current).toBe(screen.getByRole("main"));
expect(onLayoutReady).toHaveBeenCalledTimes(1);
});
it("does not run the layout-ready callback again on a re-render", () => {
const { rerender } = render(
<CinematicMain>
<section />
</CinematicMain>,
);
rerender(
<CinematicMain>
<section />
<section />
</CinematicMain>,
);
expect(onLayoutReady).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,27 @@
"use client";
import { type ReactNode, useEffect, useRef } from "react";
import { useCinematicScroll } from "./useCinematicScroll";
import { useHomeFragmentNavigation } from "./useHomeFragmentNavigation";
/**
* A ilha de cliente da home: só ela precisa de hooks (rolagem suave e passagens entre cenas, chegada por fragmento).
* Ela recebe as cenas como `children`, então CinematicHome e as seções continuam no servidor e só o que já era
* de cliente (Hero, Contact, Section...) vai para o pacote do navegador.
* Cada filho direto de <main> é uma cena; useCinematicScroll liga as passagens entre elas. O rodapé vem do layout.
*/
export default function CinematicMain({ children }: { children: ReactNode }) {
const onLayoutReady = useHomeFragmentNavigation();
const mainRef = useRef<HTMLElement>(null);
useCinematicScroll(mainRef);
// o layout da home é estável na montagem: a chegada por fragmento (#contato etc.) já pode ser resolvida
useEffect(() => onLayoutReady(), [onLayoutReady]);
return (
<div className="overflow-x-clip">
<main ref={mainRef} className="ds-main relative z-0">
{children}
</main>
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
import {
CONTACT_KIND_PARAM,
CONTACT_KINDS,
CONTACT_SUBJECT_OPTIONS,
contactKindHref,
contactSubjectForKind,
DEFAULT_CONTACT_SUBJECT,
} from "./contact-subject";
describe("contact-subject", () => {
it("starts the options with the default, 'Ainda preciso definir', so nobody is pushed into a type", () => {
expect(CONTACT_SUBJECT_OPTIONS[0]).toBe(DEFAULT_CONTACT_SUBJECT);
expect(DEFAULT_CONTACT_SUBJECT).toBe("Ainda preciso definir");
});
it("maps every type an offer asks for to an option of the form", () => {
for (const subject of Object.values(CONTACT_KINDS)) {
expect(CONTACT_SUBJECT_OPTIONS).toContain(subject);
}
expect(CONTACT_KINDS).toEqual({
site: "Site para empresa",
sistema: "Sistemas web sob medida",
revisao: "Revisão ou diagnóstico de sistema",
});
});
it("looks a type up by its own keys only", () => {
expect(contactSubjectForKind("site")).toBe("Site para empresa");
expect(contactSubjectForKind("revisao")).toBe(
"Revisão ou diagnóstico de sistema",
);
for (const unknown of [
"",
"Site",
"constructor",
"toString",
"__proto__",
]) {
expect(contactSubjectForKind(unknown)).toBeUndefined();
}
expect(contactSubjectForKind(null)).toBeUndefined();
expect(contactSubjectForKind(undefined)).toBeUndefined();
});
it("builds the address with the type in the search, before the fragment, so #contact still finds the section", () => {
expect(contactKindHref("site")).toBe(
`/?${CONTACT_KIND_PARAM}=site#contact`,
);
for (const kind of Object.keys(CONTACT_KINDS) as Array<
keyof typeof CONTACT_KINDS
>) {
const url = new URL(contactKindHref(kind), "https://example.com");
expect(url.hash).toBe("#contact");
expect(url.searchParams.get("tipo")).toBe(kind);
expect(url.pathname).toBe("/");
}
});
});
+46
View File
@@ -0,0 +1,46 @@
/**
* Tipos de projeto do formulário de contato (Contact.tsx) e o jeito de as ofertas da home escolherem um deles.
* Uma oferta abre o formulário com o tipo já marcado de dois jeitos: o endereço `/?tipo=<tipo>#contact` (vale ao
* chegar de outra página ou em um link copiado) e o evento CONTACT_KIND_EVENT (vale na própria home, onde o
* gerenciador de âncoras rola até o formulário sem recarregar a página nem mudar a busca do endereço).
* O tipo vai na busca, antes do `#`: um `/#contact?tipo=...` faria o fragmento virar "contact?tipo=...",
* que não é o id de nenhum elemento, e a página não rolaria até o formulário.
*/
/** Primeiro e padrão: quem chega sem saber o que pedir não é empurrado para um tipo. */
export const DEFAULT_CONTACT_SUBJECT = "Ainda preciso definir";
export const CONTACT_SUBJECT_OPTIONS = [
DEFAULT_CONTACT_SUBJECT,
"Site para empresa",
"Sistemas web sob medida",
"Aplicativos",
"Integrações e automações",
"Evolução de um sistema existente",
"Revisão ou diagnóstico de sistema",
] as const;
/** Tipo que uma oferta pede -> opção do formulário. */
export const CONTACT_KINDS = {
site: "Site para empresa",
sistema: "Sistemas web sob medida",
revisao: "Revisão ou diagnóstico de sistema",
} as const satisfies Record<string, (typeof CONTACT_SUBJECT_OPTIONS)[number]>;
export type ContactKind = keyof typeof CONTACT_KINDS;
/** Parâmetro de busca do endereço: `/?tipo=site#contact`. */
export const CONTACT_KIND_PARAM = "tipo";
/** Evento da janela com o tipo escolhido em `detail`. */
export const CONTACT_KIND_EVENT = "contact:kind";
/** Opção do formulário para um tipo; `undefined` para um tipo desconhecido (inclusive nomes herdados, como "constructor"). */
export function contactSubjectForKind(kind: string | null | undefined) {
return kind && Object.hasOwn(CONTACT_KINDS, kind)
? CONTACT_KINDS[kind as ContactKind]
: undefined;
}
export function contactKindHref(kind: ContactKind) {
return `/?${CONTACT_KIND_PARAM}=${kind}#contact`;
}
+57 -5
View File
@@ -10,17 +10,24 @@ it("uses a short search title: with the layout suffix it fits in 44 characters",
description: expect.any(String),
alternates: { canonical: "https://evandro.dev.br/ia" },
});
// as prévias de compartilhamento não passam pelo template: o título delas já leva o sufixo
expect(metadata.openGraph?.title).toBe(
`Revisão de app feito com IA${suffix}`,
);
// como nas outras páginas, o título da prévia de compartilhamento é o mesmo da busca, sem o sufixo
expect(metadata.openGraph?.title).toBe("Revisão de app feito com IA");
expect(metadata.twitter?.title).toBe("Revisão de app feito com IA");
});
it("uses no lime outside the contact actions (the quote on the paper background is ink)", () => {
const { container } = render(<AiPage />);
expect(
container.querySelector('[class*="border-[var(--slash)]"]'),
).toBeNull();
});
it("keeps the full AI content, with the cited incidents summarized only here", () => {
render(<AiPage />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
"Revisão de app feito com IA (Lovable, Bolt, Replit)",
"Seu app feito com IA está pronto para receber clientes? Eu testo antes deles.",
);
for (const heading of [
"Se a IA cria um sistema, por que contratar um desenvolvedor?",
@@ -91,6 +98,51 @@ it("presents the review as an offer: what the person receives, with no price or
expect(container.textContent).not.toMatch(/R\$|\d+ dias|em até \d+/);
});
it("shows the report list in a card on the right half of the hero, next to the headline", () => {
render(<AiPage />);
const card = screen.getByTestId("ia-review-card");
expect(within(card).getByText("O que você recebe")).toBeInTheDocument();
expect(
within(card)
.getAllByRole("listitem")
.map((item) => item.textContent?.replace(/^\d+/, "")),
).toEqual([
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
]);
// o cartão divide a grade do hero com o título (metade direita no desktop) e não cria uma região de página
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
expect(hero).toContainElement(card);
expect(card.parentElement).toHaveClass(
"lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]",
);
expect(screen.queryByRole("complementary")).toBeNull();
// o título do cartão não repete o h2 "O que você recebe na revisão" da seção seguinte
expect(
screen.getAllByRole("heading", { name: /O que você recebe/ }),
).toHaveLength(1);
});
it("keeps the secondary hero button inside the page: the portfolio cases are not reviews of AI-built apps", () => {
render(<AiPage />);
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
const secondary = within(hero as HTMLElement).getByRole("link", {
name: "Ver como a revisão funciona",
});
expect(secondary).toHaveAttribute("href", "#ia-receber");
expect(document.querySelector(`section[id="ia-receber"]`)).not.toBeNull();
for (const link of within(hero as HTMLElement).getAllByRole("link")) {
expect(link.getAttribute("href")).not.toMatch(/^\/cases/);
}
expect(
screen.queryByRole("link", { name: "Ver projetos entregues" }),
).toBeNull();
});
it("keeps the incident news collapsed in a <details>, closed by default", () => {
const { container } = render(<AiPage />);
+64 -27
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
@@ -25,8 +24,7 @@ export const metadata: Metadata = {
description: pageDescription,
...buildPageSocialMetadata({
url: pageUrl,
// as prévias de compartilhamento não passam pelo template do título
title: `${pageTitle} | Evandro.dev.br`,
title: pageTitle,
description: pageDescription,
type: "website",
}),
@@ -140,32 +138,71 @@ export default function AiPage() {
{ label: "Revisão de app feito com IA", href: "/ia" },
]}
/>
<p className="ds-eyebrow">IA no desenvolvimento</p>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Revisão de app feito com IA (Lovable, Bolt, Replit)
</h1>
<p className="ds-copy max-w-[62ch]">
A IA permite testar ideias e construir protótipos mesmo sem
experiência em programação. Quando o sistema passa a cuidar de
clientes, dinheiro ou dados pessoais, alguém precisa avaliar o que
foi gerado e validar como ele se comporta fora da demonstração.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3">
<WhatsAppLink
className={primaryButton}
service={WHATSAPP_SERVICE}
placement="ia_hero"
<div className="grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)] lg:gap-sp-12">
<div className="min-w-0">
<p className="ds-eyebrow">IA no desenvolvimento</p>
<h1 className="ds-section-title max-w-[24ch] text-balance">
Seu app feito com IA está pronto para receber clientes? Eu testo
antes deles.
</h1>
<p className="ds-copy max-w-[62ch]">
A IA permite testar ideias e construir protótipos mesmo sem
experiência em programação. Quando o sistema passa a cuidar de
clientes, dinheiro ou dados pessoais, alguém precisa avaliar o
que foi gerado e validar como ele se comporta fora da
demonstração. Revisão para apps criados com Lovable, Bolt,
Replit e ferramentas parecidas.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3">
<WhatsAppLink
className={primaryButton}
service={WHATSAPP_SERVICE}
placement="ia_hero"
>
Pedir a revisão do meu app
</WhatsAppLink>
{/* Segue na própria página: os cases do portfólio não são revisões de app feito com IA. */}
<a href="#ia-receber" className={textLinkDark}>
Ver como a revisão funciona
</a>
</div>
</div>
<div
data-testid="ia-review-card"
className="min-w-0 border border-white/15 bg-white/[0.04] p-sp-6 sm:p-sp-8"
>
Pedir a revisão do meu app
</WhatsAppLink>
<Link href="/cases" className={textLinkDark}>
Ver projetos entregues
</Link>
<p className="font-display text-xl font-semibold leading-7 text-white">
O que você recebe
</p>
<ol className="mt-sp-5 flex flex-col">
{DELIVERABLES.map((item, index) => (
<li
key={item.title}
className="flex items-start gap-sp-4 border-t border-white/12 py-sp-4 first:border-t-0 first:pt-0 last:pb-0"
>
<span
aria-hidden="true"
className="w-7 shrink-0 font-mono text-[15px] leading-6 text-white/60"
>
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0 font-display text-base leading-6 text-white/90">
{item.title}
</span>
</li>
))}
</ol>
</div>
</div>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-receber-title">
<section
id="ia-receber"
className="ds-section"
aria-labelledby="ia-receber-title"
>
<div className="ds-section-shell">
<h2 id="ia-receber-title" className="ds-section-title max-w-[26ch]">
O que você recebe na revisão
@@ -198,7 +235,7 @@ export default function AiPage() {
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">
Se a IA cria um sistema, por que contratar um desenvolvedor?
</h2>
<div className="mt-sp-6 border-l-2 border-[var(--slash)] pl-sp-5">
<div className="mt-sp-6 border-l-2 border-[var(--ink)] pl-sp-5">
<p className="max-w-[75ch] text-lg leading-8 text-[var(--ink)]">
Você contrata engenharia para transformar as regras do seu negócio
em um sistema que possa ser testado, operado e mantido. A IA pode
@@ -259,7 +296,7 @@ export default function AiPage() {
{incident.links.map((link) => (
<li key={link.href}>
<a
className="inline-flex min-h-11 items-center text-[15px] md:text-sm underline underline-offset-4"
className="inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
href={link.href}
target="_blank"
rel="noopener noreferrer"
@@ -298,7 +335,7 @@ export default function AiPage() {
adequação.
</p>
<a
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] md:text-sm underline underline-offset-4"
className="mt-sp-4 inline-flex min-h-11 items-center text-[15px] underline underline-offset-4"
href="https://www.gov.br/anpd/pt-br/acesso-a-informacao/institucional/atos-normativos/regulamentacoes_anpd/resolucao-cd-anpd-no-2-de-27-de-janeiro-de-2022"
target="_blank"
rel="noopener noreferrer"
+22
View File
@@ -106,6 +106,19 @@ describe("what the layout mounts", () => {
expect(serviceHubLinks.length).toBeGreaterThan(0);
});
it("gives the footer wrapper and the content the ids the mobile menu makes inert", () => {
const html = markup();
expect(html).toMatch(
/<div id="main-content"[^>]*>(?:(?!<\/div>).)*conteúdo/,
);
expect(html).toMatch(
/<div id="site-footer" class="relative z-10"><footer data-testid="footer"/,
);
// o botão flutuante fica fora dos dois: ele tem a própria região (<aside>)
expect(html.match(/id="site-footer"/g)).toHaveLength(1);
});
it("gives the header and the button the service and case names, and only names", () => {
markup();
@@ -131,6 +144,15 @@ describe("what the layout mounts", () => {
});
describe("fonts", () => {
it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => {
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
const mono = source.match(/Geist_Mono\(\{[^}]*\}\)/)?.[0] ?? "";
expect(mono).toMatch(/preload:\s*false/);
const display = source.match(/Space_Grotesk\(\{[^}]*\}\)/)?.[0] ?? "";
expect(display).not.toMatch(/preload:\s*false/);
});
it("no longer loads the unused Monument Extended font", () => {
const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
+8 -17
View File
@@ -11,14 +11,14 @@ import {
} from "@/lib/site-content";
import type { WhatsAppContexts } from "@/lib/whatsapp";
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
import { FRAGMENT_NAV_BOOT_SCRIPT } from "./components/cinematic/fragment-nav";
import FloatingWhatsApp from "./components/FloatingWhatsApp";
import Footer from "./components/Footer";
import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header";
import "./globals.css";
// O CSS da vitrine (molduras de navegador e de celular, vitrine animada) vem uma vez só, aqui, depois do global.
// Importado por cada componente que o usa, ele era pré-carregado pelos links do menu em páginas que não o usam
// (aviso de CSS pré-carregado e não usado).
// As molduras de navegador e de celular (o hero e os cases também as usam) vêm uma vez só, aqui, depois do global.
// O CSS só da vitrine e da janela ampliada (reel-list, pinned-reel e lightbox) viaja com os componentes deles.
import "./components/showcase/showcase.css";
const titleSuffix = " | Evandro.dev.br";
@@ -48,6 +48,8 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
weight: ["400", "500", "600"],
display: "swap",
// só rótulos pequenos e kickers: não disputa a rede com a fonte do título, o arquivo é baixado quando o texto usa
preload: false,
variable: "--font-geist-mono",
});
@@ -143,18 +145,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
const sheet = document.getElementById("arrival-scroll-policy")?.sheet;
if (sheet) sheet.disabled = true;
}, { once: true });
if (location.pathname === "/") {
const navigation = performance.getEntriesByType("navigation")[0];
if (navigation?.type !== "back_forward") {
history.scrollRestoration = "manual";
if (navigation?.type === "reload" && location.hash) {
history.replaceState(history.state, "", location.pathname + location.search);
}
addEventListener("pagehide", () => {
history.scrollRestoration = "auto";
}, { once: true });
}
}
${FRAGMENT_NAV_BOOT_SCRIPT}
`}</script>
</head>
<body>
@@ -169,8 +160,8 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
<div id="main-content" tabIndex={-1}>
{children}
</div>
{/* Rodapé de todas as páginas; z-10 o mantém acima das cenas da home (relative z-0). */}
<div className="relative z-10">
{/* Rodapé de todas as páginas; z-10 o mantém acima das cenas da home (relative z-0). O id deixa o menu do celular torná-lo inerte. */}
<div id="site-footer" className="relative z-10">
<Footer serviceLinks={serviceHubLinks} />
</div>
<FloatingWhatsApp contexts={whatsAppContexts} />
+2 -3
View File
@@ -2,7 +2,6 @@ import type { ReactElement } from "react";
import { portfolio } from "@/lib/portfolio-data";
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
import { clientNames, testimonials } from "@/lib/proof";
import { serviceHubLinks } from "@/lib/site-content";
import CinematicHome from "./components/cinematic/CinematicHome";
import Home from "./page";
@@ -90,11 +89,11 @@ describe("Home: projetos e provas", () => {
expect((await renderHome()).projects).toEqual([]);
});
it("path: the proof and the service links come straight from their modules", async () => {
it("path: the proof comes straight from its module, and the home no longer carries the service links (the offers do not list them)", async () => {
const props = await renderHome();
expect(props.clients).toBe(clientNames);
expect(props.testimonials).toBe(testimonials);
expect(props.serviceLinks).toBe(serviceHubLinks);
expect(props).not.toHaveProperty("serviceLinks");
});
});
-2
View File
@@ -2,7 +2,6 @@ import type { Metadata } from "next";
import { portfolio } from "@/lib/portfolio-data";
import { loadFeaturedProjects } from "@/lib/portfolio-data.server";
import { clientNames, isClientProject, testimonials } from "@/lib/proof";
import { serviceHubLinks } from "@/lib/site-content";
import CinematicHome from "./components/cinematic/CinematicHome";
/**
@@ -29,7 +28,6 @@ export default async function Home() {
clients={clientNames}
projects={projects}
testimonials={testimonials}
serviceLinks={serviceHubLinks}
/>
);
}