fix(home): oferta leva ao formulário, ofertas logo depois da vitrine

O botão das ofertas rola até o contato com o tipo marcado e não quebra mais as âncoras; ofertas com o que você recebe e primeiro passo, logo depois da vitrine; bloco de IA fora da home; cartão largo da SEV no hero do celular; separador da faixa de clientes só entre itens; favicon de 13 para 1,8 KB; pré-carregamento só no hover e nunca de /#... fora da home; X do menu e cabeçalho opaco; WhatsApp com "o case"; /ia com 3 passos e modelo de relatório ilustrativo; Sobre alinhado ao currículo; CSP Report-Only com os domínios do Ads.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:47 -03:00
1 parent fc44a69fb3
commit 9e2d65a926
51 files changed
+1730 -420

No files matched your search

+1 -1
View File
@@ -112,7 +112,7 @@ A comunicação apresenta Evandro como responsável direto pelo projeto, sem sug
Cada card de projeto aparece ao entrar na tela, observado individualmente e sem recorte por `clip-path`. A revelação não depende de uma porcentagem visível do grid inteiro: em celulares, a lista de uma coluna pode ser várias vezes mais alta que a tela e tornar esse gatilho inalcançável.
`app/components/EngineeringWithAI.tsx` explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 e links junto de cada afirmação:
`app/ia/page.tsx` (rota `/ia`) explica arquitetura, permissões, validação e recuperação de dados, com três relatos históricos de 2025 (na seção recolhida "O que casos documentados ensinam") e links junto de cada afirmação:
- Lovable: exposição relatada pela Semafor e CVE-2025-48757, incluindo a contestação do fornecedor no NVD; exposição não é apresentada como roubo comprovado.
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
+12 -1
View File
@@ -117,12 +117,23 @@ describe("About", () => {
"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\./,
/^Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas da Apolo Energia\./,
);
// só o que o currículo sustenta: nada de "os sistemas internos e a IoT" da empresa inteira
expect(container.textContent).not.toMatch(
/os sistemas internos e a IoT da Apolo/,
);
// a frase da Apolo aparece uma só vez
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
});
it("path: the first story card does not reuse the name 'Como eu trabalho' of the process list in the offers section right before", () => {
const { container } = render(<About />);
expect(container.textContent).not.toMatch(/Como eu trabalho/);
expect(screen.getByText("Acompanhamento")).toBeInTheDocument();
});
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
const { container } = render(<About />);
+2 -2
View File
@@ -9,7 +9,7 @@ import { Section } from "./Section";
const STORY_DATA = [
{
label: "Como eu trabalho",
label: "Acompanhamento",
title: "Sem intermediários entre o pedido e o sistema",
content:
"Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.",
@@ -55,7 +55,7 @@ export default function About() {
id="about"
eyebrow="Quem conduz o projeto"
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."
description="Engenheiro da computação; desde 2022 desenvolvo aplicações web e mobile, APIs e integrações com dispositivos IoT para as rotinas internas 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">
+4 -4
View File
@@ -3,7 +3,7 @@ import ClientStrip from "./ClientStrip";
/**
* Testes de caminhos-base de ClientStrip (complemento de ClientStrip.test.tsx): o separador entre clientes
* (um cliente, vários) e o className opcional.
* (um cliente, vários; só entre itens, nunca no fim de uma linha) e o className opcional.
*/
const clients = [
@@ -20,15 +20,15 @@ describe("ClientStrip", () => {
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
});
it("path: several clients -> a separator after every client except the last", () => {
it("path: several clients -> a separator before every client except the first", () => {
render(<ClientStrip clients={clients} />);
const items = screen.getAllByRole("listitem");
expect(items).toHaveLength(3);
expect(
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
).toEqual([true, true, false]);
expect(items[0].querySelector("[aria-hidden='true']")).toHaveTextContent(
).toEqual([false, true, true]);
expect(items[1].querySelector("[aria-hidden='true']")).toHaveTextContent(
"·",
);
});
+33 -5
View File
@@ -63,12 +63,19 @@ it("gives every client link a touch target of at least 44 px (min-h-11)", () =>
}
});
it("keeps the separator after the last client when the years follow, and none when they do not", () => {
it("puts the separator only BETWEEN items: none before the first, one before each of the others (years included)", () => {
const withYears = render(<ClientStrip clients={clients} years={5} />);
const separators = withYears.container.querySelectorAll(
"li [aria-hidden='true']",
);
expect(separators).toHaveLength(clients.length);
const items = withYears.container.querySelectorAll("li");
expect(items).toHaveLength(clients.length + 1);
expect(
Array.from(items).map(
(item) => item.querySelector("[aria-hidden='true']") !== null,
),
).toEqual([false, true, true]);
// nenhum item termina com o ponto: ele sempre abre o item, para a margem negativa escondê-lo no começo de uma linha
for (const item of items) {
expect(item.lastElementChild).not.toHaveAttribute("aria-hidden");
}
withYears.unmount();
// zero anos não escreve um "0" solto nem um ponto a mais
@@ -79,3 +86,24 @@ it("keeps the separator after the last client when the years follow, and none wh
expect(zero.container).not.toHaveTextContent(/anos de experiência/);
expect(screen.queryByText("0")).toBeNull();
});
it("hides the separator of an item that wraps to the next line: negative margin on every item but the first, and the list clips the overflow on the x axis", () => {
const { container } = render(<ClientStrip clients={clients} years={5} />);
const list = container.querySelector("ul") as HTMLElement;
expect(list).toHaveClass("flex-wrap", "overflow-x-clip");
const items = Array.from(list.querySelectorAll("li"));
expect(items[0]).not.toHaveClass("-ml-4");
for (const item of items.slice(1)) {
expect(item).toHaveClass("-ml-4");
// o ponto ocupa os mesmos 16 px que a margem negativa
expect(item.querySelector("[aria-hidden='true']")).toHaveClass("w-4");
}
for (const item of items) expect(item).toHaveClass("pr-4");
});
it("keeps the years number in the display font (the mono font is not downloaded above the fold)", () => {
render(<ClientStrip clients={clients} years={5} />);
expect(screen.getByText("5").className).not.toContain("font-mono");
});
+55 -34
View File
@@ -6,6 +6,13 @@ export type ClientLink = { id: string; name: string };
* Faixa "Já entregue para" do hero: só projetos de cliente (lib/proof.ts), cada um levando ao próprio case, e na
* mesma linha os anos de experiência (calculados em app/page.tsx). Sem clientes e sem anos, nada é desenhado.
* Os links têm 44 px de altura de toque; a rota só é pré-carregada quando a pessoa chega no link.
*
* O ponto separador vem ANTES de cada item, menos do primeiro, e só aparece ENTRE itens da mesma linha. Cada item
* (menos o primeiro) tem 16 px de margem negativa (`-ml-4`), que cobrem os 16 px de respiro (`pr-4`) do item
* anterior, e é nesses 16 px que o ponto (`w-4`) fica. Quando a lista quebra e um item abre a linha seguinte, o
* ponto dele cai na margem à esquerda da lista e o `overflow-x: clip` o esconde: nenhuma linha termina nem começa
* com "·". O recuo de 4 px da lista (`-ml-1 pl-1`) só dá espaço ao contorno de foco do primeiro link (2 px de
* contorno e 2 px de afastamento); o ponto, centrado na margem, fica 6 px além dele.
*/
export default function ClientStrip({
clients = [],
@@ -19,44 +26,58 @@ export default function ClientStrip({
}) {
if (clients.length === 0 && !years) return null;
const items = [
...clients.map((client) => ({
key: client.id,
node: (
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
>
{client.name}
</HoverPrefetchLink>
),
})),
...(years
? [
{
key: "years",
node: (
<p>
<span className="font-semibold text-[var(--slash)]">
{years}
</span>{" "}
anos de experiência
</p>
),
},
]
: []),
];
return (
<div
className={`flex flex-wrap items-center gap-x-sp-3 gap-y-sp-1 font-display text-[15px] leading-6 text-[rgba(244,242,237,.64)] ${className}`.trim()}
>
{clients.length > 0 && (
<>
<p>Já entregue para:</p>
<ul className="flex flex-wrap items-center gap-x-sp-3">
{clients.map((client, index) => (
<li key={client.id} className="flex items-center gap-x-sp-3">
<HoverPrefetchLink
href={`/cases/${client.id}`}
className="inline-flex min-h-11 items-center font-medium text-[#f4f2ed] no-underline underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)]"
>
{client.name}
</HoverPrefetchLink>
{/* o ponto fica no fim do item (e do último cliente, quando vêm os anos depois): numa quebra de linha ele nunca abre a linha seguinte */}
{(index < clients.length - 1 || Boolean(years)) && (
<span
aria-hidden="true"
className="text-[rgba(244,242,237,.4)]"
>
·
</span>
)}
</li>
))}
</ul>
</>
)}
{years ? (
<p>
<span className="font-mono font-semibold text-[var(--slash)]">
{years}
</span>{" "}
anos de experiência
</p>
) : null}
{clients.length > 0 && <p>Já entregue para:</p>}
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
{items.map((item, index) => (
<li
key={item.key}
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
>
{index > 0 && (
<span
aria-hidden="true"
className="w-4 shrink-0 text-center text-[rgba(244,242,237,.4)]"
>
·
</span>
)}
{item.node}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,189 @@
import { fireEvent, render, screen } from "@testing-library/react";
import ContactOfferLink from "./ContactOfferLink";
import { focusFragment, onFragmentClick } from "./cinematic/fragment-nav";
import { CONTACT_KIND_EVENT } from "./contact-subject";
/**
* Testes de caminhos-base de ContactOfferLink (complemento de ContactOfferLink.test.tsx).
*
* onClick (CC 6): fora da home | sem #contact | clique já tratado pelo gerenciador de âncoras | tecla modificadora ou
* botão que não é o principal | rolagem suave | rolagem em salto (movimento reduzido). Os seis caminhos já estão
* em ContactOfferLink.test.tsx; aqui entram o que faltava:
* - o gerenciador de âncoras REAL (fragment-nav.onFragmentClick) tratando o clique, em vez de um ouvinte avulso;
* - Alt como tecla modificadora;
* - showContactInAddress (CC 2): o endereço já é /#contact, então não há replaceState; e o `history.state` do roteador
* do Next sobrevive ao replaceState.
*/
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>
),
}));
const scrollIntoView = jest.fn();
const scrollTo = jest.fn();
let form: HTMLElement;
let replaceState: jest.SpyInstance;
const originalMatchMedia = window.matchMedia;
const originalScrollTo = window.scrollTo;
beforeEach(() => {
scrollIntoView.mockReset();
scrollTo.mockReset();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
window.scrollTo = scrollTo as unknown as typeof window.scrollTo;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
window.history.replaceState(null, "", "/");
replaceState = jest.spyOn(window.history, "replaceState");
form = document.createElement("section");
form.id = "contact";
document.body.append(form);
});
afterEach(() => {
replaceState.mockRestore();
form.remove();
window.matchMedia = originalMatchMedia;
window.scrollTo = originalScrollTo;
window.history.replaceState(null, "", "/");
});
/** O que useCinematicScroll faz com um clique numa âncora: cancela, empurra o hash, rola e dá o foco. */
function installAnchorManager() {
return onFragmentClick((hit, event) => {
event.preventDefault();
if (window.location.hash !== hit.hash)
window.history.pushState(null, "", hit.hash);
window.scrollTo({ top: 1234, behavior: "smooth" });
focusFragment(hit.target);
});
}
describe("ContactOfferLink: gerenciador de âncoras real", () => {
it("path: the manager takes the click -> its scroll is the only one, the offer cleans the old ?tipo= and still tells the form", () => {
window.history.replaceState(null, "", "/?tipo=site");
replaceState.mockClear();
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
const stop = installAnchorManager();
try {
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
const entries = window.history.length;
expect(
fireEvent.click(screen.getByRole("link", { name: "Começar" })),
).toBe(false);
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(form).toHaveFocus();
expect(heard).toEqual(["sistema"]);
// o gerenciador abriu uma entrada de histórico (/?tipo=site#contact); a oferta só a reescreve
expect(window.history.length).toBe(entries + 1);
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact");
expect(replaceState).toHaveBeenCalledTimes(1);
} finally {
stop();
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("path: the manager takes the click and the address is already /#contact -> no replaceState at all", () => {
window.history.replaceState(null, "", "/#contact");
replaceState.mockClear();
const stop = installAnchorManager();
try {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollTo).toHaveBeenCalledTimes(1);
expect(replaceState).not.toHaveBeenCalled();
expect(window.location.pathname + window.location.hash).toBe("/#contact");
} finally {
stop();
}
});
it("path: no manager mounted -> the offer scrolls and focuses by itself, and a second click scrolls again", () => {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" });
expect(fireEvent.click(link)).toBe(false);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(form).toHaveFocus();
expect(window.location.hash).toBe("#contact");
// o endereço já é /#contact (o navegador não rolaria): a oferta rola de novo mesmo assim, sem tocar no endereço
(document.activeElement as HTMLElement).blur();
replaceState.mockClear();
expect(fireEvent.click(link)).toBe(false);
expect(scrollIntoView).toHaveBeenCalledTimes(2);
expect(form).toHaveFocus();
expect(replaceState).not.toHaveBeenCalled();
});
});
describe("ContactOfferLink: teclas e histórico", () => {
it("path: Alt held down -> not taken over (the browser keeps its own behavior)", () => {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
expect(
fireEvent.click(screen.getByRole("link", { name: "Começar" }), {
altKey: true,
}),
).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(replaceState).not.toHaveBeenCalled();
expect(form).not.toHaveFocus();
});
it("path: a modified click still tells the form which type was chosen (the form may be in the same tab)", () => {
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" }), {
ctrlKey: true,
});
expect(heard).toEqual(["revisao"]);
} finally {
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("path: replaceState keeps the history.state of the router (the Next router stores its tree there)", () => {
const routerState = { __NA: true, tree: ["", {}] };
window.history.replaceState(routerState, "", "/?tipo=site#contact");
replaceState.mockClear();
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(replaceState).toHaveBeenCalledWith(routerState, "", "/#contact");
expect(window.history.state).toEqual(routerState);
});
});
+132
View File
@@ -67,3 +67,135 @@ describe("ContactOfferLink", () => {
}
});
});
describe("ContactOfferLink: rolagem até o formulário", () => {
const scrollIntoView = jest.fn();
const original = window.matchMedia;
let form: HTMLElement;
const setReducedMotion = (reduced: boolean) => {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: reduced && query === "(prefers-reduced-motion: reduce)",
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
};
beforeEach(() => {
scrollIntoView.mockReset();
HTMLElement.prototype.scrollIntoView = scrollIntoView;
window.history.replaceState(null, "", "/");
setReducedMotion(false);
form = document.createElement("section");
form.id = "contact";
document.body.append(form);
});
afterEach(() => {
form.remove();
window.matchMedia = original;
window.history.replaceState(null, "", "/");
});
it("cancels the navigation on the home, rolls to #contact, focuses the form and leaves the address as /#contact", () => {
const heard: unknown[] = [];
const listener = (event: Event) =>
heard.push((event as CustomEvent).detail);
window.addEventListener(CONTACT_KIND_EVENT, listener);
const pushState = jest.spyOn(window.history, "pushState");
try {
render(
<ContactOfferLink kind="site">Começar pelo site</ContactOfferLink>,
);
const link = screen.getByRole("link", { name: "Começar pelo site" });
// fireEvent devolve false quando alguém chamou preventDefault: o Link do Next não navega
expect(fireEvent.click(link)).toBe(false);
expect(heard).toEqual(["site"]);
expect(scrollIntoView).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "smooth",
block: "start",
});
expect(form).toHaveFocus();
expect(window.location.pathname + window.location.search).toBe("/");
expect(window.location.hash).toBe("#contact");
expect(pushState).not.toHaveBeenCalled();
} finally {
pushState.mockRestore();
window.removeEventListener(CONTACT_KIND_EVENT, listener);
}
});
it("jumps without animation when the visitor asks for reduced motion", () => {
setReducedMotion(true);
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollIntoView).toHaveBeenCalledWith({
behavior: "instant",
block: "start",
});
});
it("drops the ?tipo= left by an earlier offer or by the arrival from another page, and keeps the other parameters", () => {
window.history.replaceState(null, "", "/?utm_source=ads&tipo=site#contact");
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(window.location.search).toBe("?utm_source=ads");
expect(window.location.hash).toBe("#contact");
});
it("leaves the click alone when the scroll manager already took it (no second scroll), still cleaning the address", () => {
window.history.replaceState(null, "", "/?tipo=site#contact");
const cancel = (event: Event) => event.preventDefault();
document.addEventListener("click", cancel, true);
try {
render(<ContactOfferLink kind="sistema">Começar</ContactOfferLink>);
fireEvent.click(screen.getByRole("link", { name: "Começar" }));
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.search).toBe("");
expect(window.location.hash).toBe("#contact");
} finally {
document.removeEventListener("click", cancel, true);
}
});
it("does not take over a click with a modifier key (new tab) or the middle button", () => {
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" });
expect(fireEvent.click(link, { ctrlKey: true })).toBe(true);
expect(fireEvent.click(link, { metaKey: true })).toBe(true);
expect(fireEvent.click(link, { shiftKey: true })).toBe(true);
expect(fireEvent.click(link, { button: 1 })).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.hash).toBe("");
});
it("does not cancel anything outside the home: the Link navigates to /?tipo=…#contact", () => {
window.history.replaceState(null, "", "/privacidade");
render(<ContactOfferLink kind="revisao">Começar</ContactOfferLink>);
const link = screen.getByRole("link", { name: "Começar" });
expect(link).toHaveAttribute("href", "/?tipo=revisao#contact");
expect(fireEvent.click(link)).toBe(true);
expect(scrollIntoView).not.toHaveBeenCalled();
expect(window.location.pathname).toBe("/privacidade");
expect(window.location.hash).toBe("");
});
it("falls back to the Link navigation when the page has no #contact", () => {
form.remove();
render(<ContactOfferLink kind="site">Começar</ContactOfferLink>);
expect(fireEvent.click(screen.getByRole("link", { name: "Começar" }))).toBe(
true,
);
expect(scrollIntoView).not.toHaveBeenCalled();
document.body.append(form);
});
});
+55 -8
View File
@@ -1,17 +1,46 @@
"use client";
import type { ReactNode } from "react";
import type { MouseEvent, ReactNode } from "react";
import { prefersReducedMotion } from "@/lib/device";
import {
elementForHash,
focusFragment,
isPlainPrimaryClick,
} from "./cinematic/fragment-nav";
import {
CONTACT_KIND_EVENT,
CONTACT_KIND_PARAM,
type ContactKind,
contactKindHref,
} from "./contact-subject";
import HoverPrefetchLink from "./HoverPrefetchLink";
const CONTACT_HASH = "#contact";
/**
* Deixa o endereço da home como o do link do cabeçalho (`/#contact`): com o fragmento do formulário e sem o `?tipo=`
* que uma oferta anterior ou a chegada de outra página deixou (o tipo já está no formulário). `replaceState`, e não
* uma navegação do Next: nada recarrega, e as âncoras seguintes (cabeçalho, rodapé) continuam a rolar a página.
*/
function showContactInAddress() {
const url = new URL(window.location.href);
url.searchParams.delete(CONTACT_KIND_PARAM);
url.hash = CONTACT_HASH;
const next = `${url.pathname}${url.search}${url.hash}`;
const current = `${window.location.pathname}${window.location.search}${window.location.hash}`;
if (next !== current)
window.history.replaceState(window.history.state, "", next);
}
/**
* 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.
* Na própria home o tipo segue por evento e a página rola até `#contact` (sem navegar pelo Next); o `href` com
* `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
*
* O normal é o gerenciador de âncoras (fragment-nav + useCinematicScroll) tratar o clique antes deste código, do
* mesmo jeito que trata o link do cabeçalho: rola (com movimento reduzido, em salto), dá o foco ao formulário e
* cancela o clique. Quando ele não o trata (ainda não montou, por exemplo), este botão cancela a navegação, rola e dá
* o foco por conta própria. Em outra página, nada é cancelado: o Link navega para `/?tipo=…#contact`.
*/
export default function ContactOfferLink({
kind,
@@ -22,15 +51,33 @@ export default function ContactOfferLink({
className?: string;
children: ReactNode;
}) {
const onClick = (event: MouseEvent<HTMLAnchorElement>) => {
window.dispatchEvent(
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
);
if (window.location.pathname !== "/") return;
const form = elementForHash(CONTACT_HASH);
if (!form) return;
if (event.defaultPrevented) {
showContactInAddress();
return;
}
// aba nova, clique com tecla modificadora ou botão do meio: o navegador cuida, com o `?tipo=` do href
if (!isPlainPrimaryClick(event.nativeEvent)) return;
event.preventDefault();
showContactInAddress();
form.scrollIntoView({
behavior: prefersReducedMotion() ? "instant" : "smooth",
block: "start",
});
focusFragment(form);
};
return (
<HoverPrefetchLink
href={contactKindHref(kind)}
className={className}
onClick={() =>
window.dispatchEvent(
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
)
}
onClick={onClick}
>
{children}
</HoverPrefetchLink>
-38
View File
@@ -1,38 +0,0 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import EngineeringWithAI from "./EngineeringWithAI";
jest.mock("./Section", () => ({
Section: ({
children,
title,
description,
}: {
children: ReactNode;
title: string;
description?: string;
}) => (
<section>
<h2>{title}</h2>
<p>{description}</p>
{children}
</section>
),
}));
it("is the short home version and points to /ia for the full content", () => {
render(<EngineeringWithAI />);
expect(
screen.getByRole("heading", {
name: "Fez um app com IA? Eu reviso.",
}),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: "Ver a revisão de app feito com IA" }),
).toHaveAttribute("href", "/ia");
// a oferta não promete prazo nem preço
expect(screen.queryByText(/R\$|prazo de|dias/)).toBeNull();
// os incidentes e a LGPD ficam só em /ia
expect(screen.queryByText(/Lovable|Replit|Base44|LGPD/)).toBeNull();
});
-20
View File
@@ -1,20 +0,0 @@
import Link from "next/link";
import { secondaryButtonLight } from "./button-styles";
import { Section } from "./Section";
/** Versão curta da home, perto do fim da página: a oferta de revisão de app feito com IA. O conteúdo completo e os incidentes citados ficam em /ia. */
export default function EngineeringWithAI() {
return (
<Section
id="engineering-ai"
eyebrow="IA no desenvolvimento"
title="Fez um app com IA? Eu reviso."
description="As ferramentas de IA ajudam a tirar a ideia do papel. Antes de colocar clientes e dados dentro, avalio o que pode ser aproveitado, quais falhas de acesso a dados precisam de correção e o que falta para ir à produção."
disableChildrenAnimation
>
<Link href="/ia" className={secondaryButtonLight}>
Ver a revisão de app feito com IA
</Link>
</Section>
);
}
@@ -61,7 +61,7 @@ describe("message by route", () => {
],
[
"/cases/sev-modaintima",
"Quero conversar sobre um projeto parecido com o SEV Moda Íntima.",
"Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
],
["/ia", "Quero conversar sobre a revisão de um aplicativo feito com IA."],
// a mesma frase do botão do hero e do cabeçalho de cada página
+33 -1
View File
@@ -3,13 +3,30 @@ import { OPEN_COOKIE_PREFERENCES_EVENT } from "@/lib/analytics";
import { serviceHubLinks } from "@/lib/site-content";
import Footer from "./Footer";
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
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("Footer contact and navigation", () => {
it("offers a direct mailto link without repeating the contact call to action", () => {
render(<Footer />);
// o convite principal fica na secao Contato, logo acima do rodape
expect(
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
).not.toBeInTheDocument();
const emailLink = screen.getByRole("link", {
@@ -125,4 +142,19 @@ describe("Footer social links", () => {
expect(instagram).toHaveAttribute("target", "_blank");
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
});
it("pre-fetches the internal links only when the visitor reaches them, never by viewport (services, cases, /ia, privacy, résumé, home)", () => {
render(<Footer serviceLinks={serviceHubLinks} />);
const internal = screen
.getAllByRole("link")
.filter((link) => (link.getAttribute("href") ?? "").startsWith("/"));
// serviços do hub + Cases, /ia, Privacidade, Currículo + o logo para /#home
expect(internal.length).toBeGreaterThanOrEqual(serviceHubLinks.length + 5);
for (const link of internal) {
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
}
});
});
+19 -13
View File
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import {
openCookiePreferences,
trackEmailClick,
@@ -13,6 +12,7 @@ import {
type ServiceLink,
} from "@/lib/site-navigation";
import "./conversion.css";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Logo } from "./Logo";
import WhatsAppLink from "./WhatsAppLink";
@@ -92,9 +92,12 @@ export default function Footer({
<ul className="mt-sp-2">
{serviceLinks.map((service) => (
<li key={service.href}>
<Link href={service.href} className={linkClassName}>
<HoverPrefetchLink
href={service.href}
className={linkClassName}
>
{service.label}
</Link>
</HoverPrefetchLink>
</li>
))}
</ul>
@@ -106,19 +109,22 @@ export default function Footer({
</p>
<ul className="mt-sp-2">
<li>
<Link href="/cases" className={linkClassName}>
<HoverPrefetchLink href="/cases" className={linkClassName}>
Cases
</Link>
</HoverPrefetchLink>
</li>
<li>
<Link href="/ia" className={linkClassName}>
<HoverPrefetchLink href="/ia" className={linkClassName}>
Revisão de app feito com IA
</Link>
</HoverPrefetchLink>
</li>
<li>
<Link href="/privacidade" className={linkClassName}>
<HoverPrefetchLink
href="/privacidade"
className={linkClassName}
>
Privacidade
</Link>
</HoverPrefetchLink>
</li>
<li>
<button
@@ -130,19 +136,19 @@ export default function Footer({
</button>
</li>
<li>
<Link
<HoverPrefetchLink
href="/curriculo"
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
>
Currículo
</Link>
</HoverPrefetchLink>
</li>
</ul>
</nav>
</div>
<div className="mt-sp-8 flex flex-wrap items-center justify-between gap-x-sp-6 gap-y-sp-3 border-t border-[var(--border)] pt-sp-5">
<Link
<HoverPrefetchLink
href="/#home"
aria-label="Evandro.dev.br — início"
className="inline-flex min-h-11 items-center"
@@ -150,7 +156,7 @@ export default function Footer({
{/* o ".dev" do logo é limão (#ccff00), quase invisível sobre o papel: o filtro o escurece (de 1,04:1
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>
</HoverPrefetchLink>
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
© {currentYear} Evandro Fonseca Junior. Todos os direitos
reservados.
+24 -8
View File
@@ -325,11 +325,15 @@ describe("Header: Esc e foco com o menu aberto", () => {
});
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. */
/** Como no layout (GoogleAnalytics.tsx): a barra vem antes do cabeçalho e não fica inerte; link da política, Aceitar e Recusar. */
function PageWithCookieBar() {
return (
<>
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
<p>
Usamos métricas de navegação do Google só se você permitir.{" "}
<a href="/privacidade">Política de privacidade</a>
</p>
<button type="button">Aceitar</button>
<button type="button">Recusar</button>
</section>
@@ -338,31 +342,43 @@ describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
);
}
it("cycles through Aceitar and Recusar before the header, and back from the contact button", () => {
it("cycles through the policy link, Aceitar and Recusar before the header, and back from the contact button", () => {
render(<PageWithCookieBar />);
openMenu();
const policy = screen.getByRole("link", {
name: "Política de privacidade",
});
const accept = screen.getByRole("button", { name: "Aceitar" });
const decline = screen.getByRole("button", { name: "Recusar" });
const order = trapOrder();
const last = order[order.length - 1];
expect(policy).not.toHaveAttribute("inert");
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
// do botão de contato (o último do cabeçalho) o Tab vai para o link da política, em vez de dar a volta no cabeçalho
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(accept).toHaveFocus();
expect(policy).toHaveFocus();
// entre Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
// entre o link, Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
expect(fireEvent.keyDown(policy, { key: "Tab" })).toBe(true);
act(() => accept.focus());
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(
// Shift+Tab do link da política (o primeiro do ciclo) volta ao botão de contato
act(() => policy.focus());
expect(fireEvent.keyDown(policy, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
// e Shift+Tab de Aceitar ou de Recusar não é preso: o navegador anda um controle para trás
act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
true,
);
});
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
+38 -1
View File
@@ -33,7 +33,7 @@ function setScrollY(value: number) {
}
const header = () => screen.getByRole("banner");
/** Rolada: borda visível e fundo a 95% (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
/** Rolada: borda visível e fundo opaco (sem backdrop-blur: o filtro refazia a pintura a cada quadro da rolagem). */
const isDark = () => header().className.includes("border-white/12");
beforeEach(() => {
@@ -79,6 +79,20 @@ describe("Header: rolagem", () => {
expect(isDark()).toBe(false);
});
it("path: the background is fully opaque at the top and scrolled (no 95% fill: the titles showed through)", () => {
render(<Header />);
expect(header().className).toContain("bg-[var(--ink)]");
expect(header().className).not.toContain("bg-[var(--ink)]/");
setScrollY(120);
act(() => {
window.dispatchEvent(new Event("scroll"));
});
expect(header().className).toContain("bg-[var(--ink)]");
expect(header().className).not.toContain("bg-[var(--ink)]/");
});
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
setScrollY(500);
render(<Header />);
@@ -96,6 +110,29 @@ describe("Header: rolagem", () => {
});
});
describe("Header: ícone do menu", () => {
it("path: open -> the outer bars move 10px (2px bar + 8px gap) to the middle and turn into a straight X; closed -> nothing moves", () => {
render(<Header />);
const button = screen.getByRole("button", { name: "Abrir menu" });
const bars = () =>
Array.from(button.querySelectorAll("span")) as HTMLElement[];
// 2 px de altura + 8 px de espaço entre as linhas = 10 px de centro a centro
expect(bars()[0]).toHaveClass("h-0.5");
expect(button).toHaveClass("gap-sp-2");
for (const bar of bars()) expect(bar.className).not.toMatch(/translate-y/);
fireEvent.click(button);
const open = bars();
expect(open).toHaveLength(3);
expect(open[0]).toHaveClass("translate-y-[10px]", "rotate-45");
expect(open[1]).toHaveClass("opacity-0");
expect(open[2]).toHaveClass("-translate-y-[10px]", "-rotate-45");
expect(open[0].className).not.toContain("6px");
expect(open[2].className).not.toContain("6px");
});
});
describe("Header: teclado no menu do celular", () => {
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
render(<Header />);
+1 -1
View File
@@ -278,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 SEV Moda Íntima.",
"Quero conversar sobre um projeto parecido com o case SEV Moda Íntima.",
);
}
+7 -5
View File
@@ -21,8 +21,9 @@ const INERT_IDS = ["main-content", "site-footer"];
const DESKTOP_QUERY = "(min-width: 1280px)";
/**
* 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.
* Controles que o Tab percorre com o menu aberto: todos os da barra de cookies, quando ela está aberta (o link "Política
* de privacidade", Aceitar e Recusar; 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) {
@@ -193,7 +194,7 @@ export function 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"
? "border-white/12 bg-[var(--ink)]"
: "border-transparent bg-[var(--ink)]"
}`}
>
@@ -239,19 +240,20 @@ export function Header({
ref={menuButtonRef}
type="button"
className="flex h-11 w-11 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] xl:hidden"
// as linhas têm 2 px (h-0.5) e 8 px de espaço (gap-sp-2): de uma a outra são 10 px, e as de cima e de baixo andam 10 px até o meio, para o X fechar sem torcer
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
aria-expanded={mobileMenuOpen}
>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[10px] rotate-45" : ""}`}
/>
<span
className={`h-0.5 w-5 bg-white transition-opacity duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}
/>
<span
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "-translate-y-[6px] -rotate-45" : ""}`}
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "-translate-y-[10px] -rotate-45" : ""}`}
/>
</button>
</div>
+1 -1
View File
@@ -125,7 +125,7 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
render(<Hero />);
expect(
screen.getByRole("link", { name: /Conversar sobre meu projeto/ }),
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
).toHaveAttribute("id", "hero-contact-cta");
});
});
+76 -13
View File
@@ -1,6 +1,24 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import Hero from "./Hero";
import { HERO_CAPTIONS } from "./hero-captions";
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
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>
),
}));
const clients = [
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
@@ -22,9 +40,9 @@ describe("Hero", () => {
render(<Hero />);
const contactLink = screen.getByRole("link", {
name: /conversar/i,
name: /falar sobre meu projeto/i,
});
expect(contactLink).toHaveAccessibleName("Conversar sobre meu projeto");
expect(contactLink).toHaveAccessibleName("Falar sobre meu projeto");
expect(contactLink.getAttribute("href")).toMatch(
/^https:\/\/wa\.me\/\d+\?/,
);
@@ -88,13 +106,12 @@ describe("Hero", () => {
expect(
screen.getByText(
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala direto com quem desenvolve.",
"Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você fala com quem desenvolve.",
),
).toBeInTheDocument();
expect(screen.getByRole("link", { name: /conversar/i })).toHaveAttribute(
"id",
"hero-contact-cta",
);
expect(
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
).toHaveAttribute("id", "hero-contact-cta");
});
it("shows only the years of experience, with no repository numbers", () => {
@@ -142,18 +159,64 @@ describe("Hero", () => {
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", () => {
it("shows a wide card to the SEV case 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",
);
expect(small).toHaveAttribute("href", "/cases/sev-modaintima");
expect(small).toHaveAccessibleName("Site da SEV Moda Íntima Ver case");
const buttons = screen.getByRole("link", { name: /ver projetos/i });
expect(
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the label above the headline in the display font, so the mono font is not downloaded before the LCP", () => {
render(<Hero />);
const label = screen.getByText(
/Evandro Fonseca Junior · Engenharia de software/,
);
expect(label.className).toContain("font-display");
expect(label.className).not.toContain("font-mono");
});
it("uses no mono font anywhere in the first fold (label, years, scroll cue)", () => {
const { container } = render(
<Hero clients={clients} stats={{ years: 5 }} />,
);
expect(container.querySelector(".font-mono")).toBeNull();
});
it("pre-fetches 'Ver projetos entregues', the clients and the SEV card only when reached (hover, focus or touch), never by viewport", () => {
render(<Hero clients={clients} />);
const links = [
screen.getByRole("link", { name: /ver projetos/i }),
screen.getByRole("link", { name: "SEV Moda Íntima" }),
screen.getByTestId("hero-work-mobile"),
];
for (const link of links) {
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
}
});
it("writes the range of each dive caption (--from and --to, from hero-captions.ts) on its own item, in order", () => {
const { container } = render(<Hero clients={clients} />);
const items = Array.from(
container.querySelectorAll<HTMLElement>(".hero-captions li"),
);
expect(items).toHaveLength(HERO_CAPTIONS.length);
items.forEach((item, index) => {
const { title, text, from, to } = HERO_CAPTIONS[index];
expect(item).toHaveTextContent(`${title}${text}`);
expect(item.style.getPropertyValue("--from")).toBe(String(from));
expect(item.style.getPropertyValue("--to")).toBe(String(to));
});
});
});
+16 -19
View File
@@ -1,6 +1,5 @@
"use client";
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";
@@ -9,6 +8,8 @@ import ClientStrip, { type ClientLink } from "./ClientStrip";
import HeroField from "./cinematic/HeroField";
import { HERO_CTA_ID } from "./FloatingWhatsApp";
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { HERO_CAPTIONS } from "./hero-captions";
import WhatsAppLink from "./WhatsAppLink";
const SOCIAL_LINKS = [
@@ -45,16 +46,6 @@ const TITLE =
accent: ACCENT.has(index),
}));
/** O que cada camada da cena significa, em linguagem simples. */
const CAPTIONS = [
["A tela", "O que sua equipe e seus clientes usam no dia a dia."],
[
"As integrações",
"Os programas que você já usa conversando entre si, sem retrabalho.",
],
["Os dados", "Suas informações guardadas, organizadas e seguras."],
] as const;
export default function Hero({
stats,
clients,
@@ -76,8 +67,13 @@ export default function Hero({
<div className="hero-scrim" aria-hidden="true" />
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
<ol className="hero-captions">
{CAPTIONS.map(([title, text], index) => (
<li key={title} style={{ "--i": index } as CSSProperties}>
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
<li
key={title}
style={
{ "--i": index, "--from": from, "--to": to } as CSSProperties
}
>
<span className="hero-caption-title">{title}</span>
{text}
</li>
@@ -86,7 +82,8 @@ export default function Hero({
{/* Os espaços verticais acompanham a altura da tela (svh): subtítulo, botões e faixa de clientes precisam caber no palco fixo. */}
<div className="hero-content relative mx-auto flex w-full max-w-[max(1400px,var(--shell))] flex-1 flex-col justify-center px-sp-6 pt-[calc(var(--site-header-height)+clamp(8px,2.5svh,32px))] pb-[clamp(16px,3svh,32px)] lg:px-sp-12">
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-mono text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
{/* Space Grotesk (a fonte do título, já carregada), e não a Geist Mono: assim nada de fonte nova é baixado antes do LCP. */}
<p className="hero-in mb-sp-4 flex items-center gap-sp-3 font-display text-[12px] uppercase leading-[1.5] tracking-[0.14em] text-[rgba(244,242,237,.72)]">
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
Evandro Fonseca Junior · Engenharia de software
</p>
@@ -106,7 +103,7 @@ export default function Hero({
</h1>
<p className="hero-in mt-sp-4 max-w-[34em] text-[clamp(16px,0.95rem+0.4vw,22px)] leading-[1.55] text-[rgba(244,242,237,.8)] [--d:.55s] lg:max-w-[min(36em,54%)]">
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
fala direto com quem desenvolve.
fala com quem desenvolve.
</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">
@@ -117,7 +114,7 @@ export default function Hero({
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
Falar sobre meu projeto
<svg
aria-hidden="true"
viewBox="0 0 16 16"
@@ -129,12 +126,12 @@ export default function Hero({
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</WhatsAppLink>
<Link
<HoverPrefetchLink
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]"
>
Ver projetos entregues
</Link>
</HoverPrefetchLink>
</div>
<ClientStrip
@@ -188,7 +185,7 @@ export default function Hero({
</div>
<p
aria-hidden="true"
className="hero-cue hidden items-center gap-sp-3 font-mono text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
className="hero-cue hidden items-center gap-sp-3 font-display text-[12px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)] lg:flex"
>
Role para mergulhar
<span className="hero-cue-line" />
+43 -24
View File
@@ -3,7 +3,7 @@ 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.
* (o título é o LCP), e o cartão largo e clicável da SEV para as telas abaixo de 1024 px.
*/
const original = (image: Element) =>
@@ -29,11 +29,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
"(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");
// o cartão do celular usa o pôster largo da SEV, na largura da coluna (cerca de 350 px num celular de 390 px)
const small = screen
.getByTestId("hero-work-mobile")
.querySelector("img") as HTMLImageElement;
expect(original(small)).toBe("/videos/sev-modaintima/desktop.webp");
expect(small).toHaveAttribute("sizes", "(min-width: 1024px) 1px, 100vw");
});
it("keeps the dimensions of the files, so the frames reserve their space before the images load", () => {
@@ -62,34 +63,52 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
});
});
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", () => {
describe("HeroWork: cartão do celular", () => {
it("shows a wide card with the SEV 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();
const card = screen.getByTestId("hero-work-mobile");
expect(card).toHaveClass("lg:hidden", "w-full");
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
expect(card.querySelector(".ds-phone")).toBeNull();
const imageBox = card.querySelector("img")?.parentElement as HTMLElement;
expect(imageBox).toHaveClass("w-full");
expect(imageBox.style.height).toBe("72px");
expect(card).toHaveTextContent("Site da SEV Moda Íntima");
expect(card).toHaveTextContent("Ver case");
});
it("is not interactive: the frames only show the work", () => {
it("is a link to the SEV case, with a touch target of at least 48px and its own focus ring", () => {
render(<HeroWork />);
const card = screen.getByRole("link", {
name: "Site da SEV Moda Íntima Ver case",
});
expect(card).toBe(screen.getByTestId("hero-work-mobile"));
expect(card).toHaveAttribute("href", "/cases/sev-modaintima");
expect(card).not.toHaveClass("pointer-events-none");
expect(card.className).toContain("focus-visible:outline-2");
expect(card.querySelector("span.min-h-12")).not.toBeNull();
// a imagem é decorativa: o texto do link já diz o que ela mostra
expect(card.querySelector("img")).toHaveAttribute("alt", "");
});
it("draws no other link: the card is the only one in HeroWork", () => {
render(<HeroWork />);
expect(screen.getAllByRole("link")).toHaveLength(1);
});
it("keeps the desktop column as a plain picture: not interactive, nothing to click", () => {
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");
expect(
within(screen.getByTestId("hero-work")).queryByRole("link"),
).toBeNull();
});
it("passes the address to the browser bar of the desktop frame", () => {
+47 -21
View File
@@ -1,5 +1,6 @@
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { BrowserFrame } from "./showcase/BrowserFrame";
import { PhoneFrame } from "./showcase/PhoneFrame";
@@ -8,24 +9,32 @@ 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. */
/** No celular o cartão mostra o site da SEV, o mesmo projeto do navegador ao lado do título no computador, e leva ao case. */
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;
const SMALL_SCREEN_TITLE = "SEV Moda Íntima";
const SMALL_SCREEN_HREF = `/cases/${SMALL_SCREEN_PROJECT}`;
/**
* Altura da imagem do cartão de celular (px): a largura é a da coluna (cerca de 350 px num celular de 390 px). Com 72 px
* a área da imagem (cerca de 25 mil px²) fica abaixo da do H1 (cerca de 29 mil px² a 390 px): com 96 px ela era maior, e
* numa rede lenta a imagem, que chega depois, passava a ser o LCP no lugar do título (medido com rede e CPU limitadas).
*/
const SMALL_CARD_IMAGE_HEIGHT = 72;
/**
* 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.
* da AS Serviços no celular, numa coluna de cerca de 40% da largura. Abaixo de 1024 px, um cartão largo e clicável
* (a largura da coluna, cerca de 350 px num celular) com o pôster do site da SEV, abaixo dos botões, que leva ao
* case da SEV; antes era uma moldura de celular de 65 por 160 px, pequena demais para ver o que era.
* 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.
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
* dobra (LCP; ver SMALL_CARD_IMAGE_HEIGHT), e a imagem que fica escondida pelo breakpoint (display: none) nem é
* baixada. Em tela larga o pôster do navegador é maior que o título e, numa rede lenta, vira o LCP quando chega.
*/
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");
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "desktop");
return (
<>
@@ -64,26 +73,43 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
</PhoneFrame>
</div>
<div
<HoverPrefetchLink
data-testid="hero-work-mobile"
className="hero-in pointer-events-none mt-sp-5 flex items-center gap-sp-4 [--d:.8s] lg:hidden"
href={SMALL_SCREEN_HREF}
className="hero-in group mt-sp-5 flex w-full flex-col overflow-hidden border border-white/20 bg-white/[0.05] font-display no-underline transition-colors hover:border-white/45 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--slash)] motion-reduce:transition-none [--d:.8s] lg:hidden"
>
<PhoneFrame style={{ height: SMALL_PHONE_HEIGHT }}>
<span
className="relative block w-full overflow-hidden bg-[var(--ink)]"
style={{ height: SMALL_CARD_IMAGE_HEIGHT }}
>
<Image
src={smallPhone.poster}
alt="Site da SEV Moda Íntima aberto no celular"
width={smallPhone.width}
height={smallPhone.height}
sizes="84px"
src={smallCard.poster}
alt=""
width={smallCard.width}
height={smallCard.height}
sizes="(min-width: 1024px) 1px, 100vw"
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>
</span>
<span className="flex min-h-12 items-center justify-between gap-sp-4 px-sp-4 py-sp-2 text-[15px] leading-6 text-[#f4f2ed]">
<span className="flex flex-col">
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
<span className="text-[rgba(244,242,237,.72)]">Ver case</span>
</span>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="h-4 w-4 shrink-0 transition-transform group-hover:translate-x-1 motion-reduce:transition-none"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</span>
</HoverPrefetchLink>
</>
);
}
+7 -54
View File
@@ -1,12 +1,6 @@
import Link from "next/link";
import { textLink } from "./button-styles";
import { Faq, type FaqEntry } from "./Faq";
export interface FaqItem {
question: string;
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
answer: string;
link?: { href: string; label: string };
}
export type FaqItem = FaqEntry;
/**
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
@@ -47,51 +41,10 @@ export const FAQ_ITEMS: FaqItem[] = [
{ question: "Como funciona o pagamento?", answer: "" },
];
/** Perguntas frequentes da home, antes do contato. Só aparecem as que têm resposta; sem nenhuma, a seção não existe. */
/**
* Perguntas frequentes da home, antes do contato (`#faq`). O desenho é o de `Faq`, o mesmo das páginas de serviço:
* só aparecem as perguntas com resposta; sem nenhuma, a seção não existe.
*/
export default function HomeFaq({ items = FAQ_ITEMS }: { items?: FaqItem[] }) {
const answered = items.filter((item) => item.answer.trim() !== "");
if (answered.length === 0) return null;
return (
<section id="faq" aria-labelledby="faq-title" className="ds-section">
<div className="ds-section-shell">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2
id="faq-title"
className="ds-section-title max-w-[22ch] text-balance"
>
Perguntas frequentes
</h2>
<div className="mt-sp-8 max-w-[860px]">
{answered.map((item) => (
<details
key={item.question}
className="group border-t border-[var(--border)] last:border-b"
>
<summary className="flex min-h-14 cursor-pointer list-none items-center justify-between gap-sp-4 py-sp-4 font-display text-[17px] font-semibold leading-snug text-[var(--ink)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--ink)] [&::-webkit-details-marker]:hidden">
{item.question}
<span
aria-hidden="true"
className="shrink-0 font-mono text-xl leading-none text-[var(--meta)] transition-transform group-open:rotate-45 motion-reduce:transition-none"
>
+
</span>
</summary>
<div className="pb-sp-5">
<p className="ds-copy max-w-[65ch]">{item.answer}</p>
{item.link ? (
<Link
href={item.link.href}
className={`${textLink} mt-sp-2 text-[var(--ink-80)]`}
>
{item.link.label}
</Link>
) : null}
</div>
</details>
))}
</div>
</div>
</section>
);
return <Faq items={items} />;
}
@@ -0,0 +1,127 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink";
/**
* Testes de caminhos-base do pré-carregamento de âncoras da home em HoverPrefetchLink (complemento de
* HoverPrefetchLink.test.tsx).
*
* isHomeAnchor (CC 3): href em texto (é âncora da home | não é) | href em objeto (caminho "/" e hash | caminho "/" sem
* hash | outro caminho). O que faltava: o objeto com caminho "/" e SEM hash, que não é âncora e pré-carrega.
* HoverPrefetchLink (CC 5): caminho desconhecido | na home | fora da home com âncora da home | fora da home com outro
* link, cada um ativado ou não. Já cobertos; aqui entram as trocas de página com o link já ativado.
* O dublê de next/navigation sem `usePathname` está em HoverPrefetchLink.nopathname.paths.test.tsx.
*/
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>
),
}));
const link = () => screen.getByRole("link", { name: "Ir" });
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
});
describe("isHomeAnchor, vista de fora da home", () => {
it.each([
["objeto com caminho / e sem hash", { pathname: "/" }],
["objeto com caminho / e hash vazio", { pathname: "/", hash: "" }],
["objeto com outro caminho e hash", { pathname: "/cases", hash: "#x" }],
["objeto só com hash (a página aberta)", { hash: "#x" }],
["objeto com caminho / e só a busca", { pathname: "/", query: { a: "b" } }],
])(
"path: %s -> não é âncora da home, pré-carrega ao chegar",
(_name, href) => {
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
},
);
it.each([
[
"objeto com caminho /, hash e busca",
{ pathname: "/", hash: "#contact", query: { tipo: "site" } },
],
["objeto com caminho / e hash sem #", { pathname: "/", hash: "contact" }],
["texto com busca e hash", "/?tipo=site#contact"],
["texto com busca de vários parâmetros e hash", "/?a=b&c=d#contact"],
])("path: %s -> é âncora da home, nunca pré-carrega", (_name, href) => {
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it.each([
["texto com busca e sem hash", "/?tipo=site"],
["texto de outra página com hash", "/cases#x"],
["texto de outra página com busca e hash", "/cases?a=b#x"],
["texto com dois caminhos e hash (//host)", "//cdn.example.com/#x"],
])(
"path: %s -> não é âncora da home, pré-carrega ao chegar",
(_name, href) => {
render(<HoverPrefetchLink href={href}>Ir</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
},
);
});
describe("HoverPrefetchLink: quando a página aberta muda", () => {
it("path: leaves the home anchor, comes back to the home and goes away again -> prefetching follows the page, never the link's earlier state", () => {
jest.mocked(usePathname).mockReturnValue("/");
const { rerender } = render(
<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
jest.mocked(usePathname).mockReturnValue("/servicos");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
jest.mocked(usePathname).mockReturnValue("/");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "null");
jest.mocked(usePathname).mockReturnValue("/cases/lavacao-047");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("path: a home anchor never reached from another page, then the visitor reaches the home -> prefetching only starts with the first touch", () => {
const { rerender } = render(
<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>,
);
expect(link()).toHaveAttribute("data-prefetch", "false");
jest.mocked(usePathname).mockReturnValue("/");
rerender(<HoverPrefetchLink href="/#contact">Ir</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
});
@@ -0,0 +1,63 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type HoverPrefetchLinkType from "./HoverPrefetchLink";
/**
* Caminho-base de HoverPrefetchLink que só existe com next/navigation sem `usePathname` (os testes que trocam o
* módulo por um dublê só com `notFound`): o `navigation.usePathname ?? (() => null)` do módulo cai no `null` e
* nada muda no pré-carregamento, nem para uma âncora da home vista de outra página.
* Arquivo à parte: o módulo é carregado isolado, depois de registrar o dublê, e não pode ter sido importado antes.
*/
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>
),
}));
function loadWithoutUsePathname() {
const React = jest.requireActual<typeof import("react")>("react");
let loaded: typeof HoverPrefetchLinkType | undefined;
jest.isolateModules(() => {
// o mesmo React do teste, para os hooks do componente isolado
jest.doMock("react", () => React);
jest.doMock("next/navigation", () => ({ notFound: jest.fn() }));
loaded = require("./HoverPrefetchLink").default;
});
jest.dontMock("react");
jest.dontMock("next/navigation");
if (!loaded) throw new Error("o módulo isolado não carregou");
return loaded;
}
describe("HoverPrefetchLink sem usePathname em next/navigation", () => {
it("path: unknown page -> a home anchor waits for the first touch and then prefetches as usual", () => {
const Isolated = loadWithoutUsePathname();
render(<Isolated href="/#contact">Ir</Isolated>);
const link = screen.getByRole("link", { name: "Ir" });
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("path: unknown page -> a link to another page behaves the same", () => {
const Isolated = loadWithoutUsePathname();
render(<Isolated href="/cases">Ir</Isolated>);
const link = screen.getByRole("link", { name: "Ir" });
fireEvent.focus(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
});
+99
View File
@@ -1,6 +1,9 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { usePathname } from "next/navigation";
import HoverPrefetchLink from "./HoverPrefetchLink";
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
@@ -19,6 +22,10 @@ jest.mock("next/link", () => ({
const link = () => screen.getByRole("link", { name: "Cases" });
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
});
it("does not prefetch when the page opens", () => {
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
@@ -79,3 +86,95 @@ it("only prefetches the link that was reached, not its siblings", () => {
"false",
);
});
describe("links para um ponto da home", () => {
it.each([["/#contact"], ["/#process"], ["/?tipo=site#contact"]])(
"never prefetches %s from another page, even on hover, focus or touch (it would pull the whole home)",
(href) => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
render(<HoverPrefetchLink href={href}>Cases</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link());
fireEvent.focus(link());
fireEvent.touchStart(link());
expect(link()).toHaveAttribute("data-prefetch", "false");
},
);
it("never prefetches an object href with the home path and a hash from another page", () => {
jest.mocked(usePathname).mockReturnValue("/cases");
render(
<HoverPrefetchLink href={{ pathname: "/", hash: "contact" }}>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("stops prefetching a home anchor that was reached on the home once the visitor moves to another page (the header does not remount)", () => {
jest.mocked(usePathname).mockReturnValue("/");
const { rerender } = render(
<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
jest.mocked(usePathname).mockReturnValue("/cases");
rerender(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
expect(link()).toHaveAttribute("data-prefetch", "false");
});
it("keeps the hover prefetch for a home anchor while on the home itself (it is the same page)", () => {
jest.mocked(usePathname).mockReturnValue("/");
render(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
it("keeps the hover prefetch for a link to another page, and for the home without a hash, from anywhere", () => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
render(
<>
<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>
<HoverPrefetchLink href="/">Início</HoverPrefetchLink>
<HoverPrefetchLink href="/cases/sev-modaintima#video">
Vídeo
</HoverPrefetchLink>
<HoverPrefetchLink href={{ pathname: "/servicos", hash: "x" }}>
Serviços
</HoverPrefetchLink>
</>,
);
for (const name of ["Cases", "Início", "Vídeo", "Serviços"]) {
const item = screen.getByRole("link", { name });
fireEvent.mouseEnter(item);
expect(item).toHaveAttribute("data-prefetch", "null");
}
});
it("still runs the caller's handlers on a home anchor seen from another page", () => {
jest.mocked(usePathname).mockReturnValue("/privacidade");
const onMouseEnter = jest.fn();
render(
<HoverPrefetchLink href="/#contact" onMouseEnter={onMouseEnter}>
Cases
</HoverPrefetchLink>,
);
fireEvent.mouseEnter(link());
expect(onMouseEnter).toHaveBeenCalledTimes(1);
});
it("prefetches as usual when the router is unknown (no pathname)", () => {
jest.mocked(usePathname).mockReturnValue(null as unknown as string);
render(<HoverPrefetchLink href="/#contact">Cases</HoverPrefetchLink>);
fireEvent.mouseEnter(link());
expect(link()).toHaveAttribute("data-prefetch", "null");
});
});
+25 -1
View File
@@ -1,13 +1,34 @@
"use client";
import Link from "next/link";
import * as navigation from "next/navigation";
import { type ComponentProps, useState } from "react";
type LinkHref = ComponentProps<typeof Link>["href"];
/**
* O caminho da página aberta. Sem `usePathname` (testes que trocam next/navigation por um dublê só com
* `notFound`, por exemplo) vale `null`: sem caminho conhecido, nada muda no pré-carregamento.
*/
const usePathname: () => string | null = navigation.usePathname ?? (() => null);
/** Link para um ponto da home: `/#contact` ou `/?tipo=site#contact`. */
function isHomeAnchor(href: LinkHref) {
if (typeof href === "string") return /^\/(\?[^#]*)?#/.test(href);
return href.pathname === "/" && Boolean(href.hash);
}
/**
* Link interno que só pré-carrega a rota quando a pessoa chega nele (mouse, teclado ou toque).
* O padrão do Link pré-carrega todo link visível ao abrir a página, e o cabeçalho e a faixa de clientes
* somavam 15 pedidos disputando a rede na abertura. Com `prefetch={false}` o App Router não pré-carrega nem no hover.
* Padrão da documentação do Next: node_modules/next/dist/docs/01-app/02-guides/prefetching.md.
*
* Fora da home, um link para um ponto dela (`/#contact`) não pré-carrega nem no hover: o pré-carregamento puxaria
* a home inteira, com o CSS da vitrine, para quem só passou o mouse (e o navegador avisava "preloaded but not
* used"). Na própria home o link leva à página em que a pessoa já está, e o pré-carregamento segue o padrão. O
* caminho vem de `usePathname`, e não de uma leitura única no hover: o cabeçalho e o rodapé não remontam entre as
* páginas, e um link ativado na home não pode continuar pré-carregando depois que a pessoa sair dela.
*/
export default function HoverPrefetchLink({
onMouseEnter,
@@ -15,12 +36,15 @@ export default function HoverPrefetchLink({
onTouchStart,
...props
}: Omit<ComponentProps<typeof Link>, "prefetch">) {
const pathname = usePathname();
const [active, setActive] = useState(false);
const homeAnchorFromElsewhere =
pathname !== null && pathname !== "/" && isHomeAnchor(props.href);
return (
<Link
{...props}
prefetch={active ? null : false}
prefetch={active && !homeAnchorFromElsewhere ? null : false}
onMouseEnter={(event) => {
setActive(true);
onMouseEnter?.(event);
+28 -2
View File
@@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import {
getFeaturedProjects,
@@ -7,6 +7,23 @@ import {
import { isClientProject } from "@/lib/proof";
import Projects from "./Projects";
// o Link do Next, trocado por um <a> que mostra o `prefetch` recebido: HoverPrefetchLink começa em false
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>
),
}));
jest.mock("./Section", () => ({
Section: ({ children, title }: { children: ReactNode; title: string }) => (
<section>
@@ -74,11 +91,20 @@ describe("Projects", () => {
it("ends with a secondary link to all the cases", () => {
render(<Projects projects={clientProjects} />);
const link = screen.getByRole("link", { name: "Ver todos os cases" });
const link = screen.getByRole("link", { name: "Ver os cases" });
expect(link).toHaveAttribute("href", "/cases");
// secundário: contorno, sem o fundo verde-limão do contato
expect(link.className).toContain("border");
expect(link.className).not.toContain("bg-[var(--slash)]");
expect(screen.getAllByRole("link")).toEqual([link]);
});
it("pre-fetches /cases only when the visitor reaches the link, not when it comes into view", () => {
render(<Projects projects={clientProjects} />);
const link = screen.getByRole("link", { name: "Ver os cases" });
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link);
expect(link).toHaveAttribute("data-prefetch", "null");
});
});
+4 -4
View File
@@ -1,8 +1,8 @@
"use client";
import Link from "next/link";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { secondaryButtonDark } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink";
import { Section } from "./Section";
import { ProjectReel } from "./showcase/ProjectReel";
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
@@ -27,9 +27,9 @@ export default function Projects({
</ShowcaseProvider>
<div className="mt-sp-8" data-reel-stagger="left">
<Link href="/cases" className={secondaryButtonDark}>
Ver todos os cases
</Link>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver os cases
</HoverPrefetchLink>
</div>
</Section>
);
+1 -1
View File
@@ -60,7 +60,7 @@ describe("Testimonials", () => {
it("path: a testimonial with a project -> the case link points to that project", () => {
render(<Testimonials items={[{ ...base, projectId: "sc-plus" }]} />);
expect(screen.getByRole("link", { name: "Ver o case" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "Ver case" })).toHaveAttribute(
"href",
"/cases/sc-plus",
);
+2 -2
View File
@@ -34,7 +34,7 @@ it("shows the quote, who said it and a link to the case", () => {
).toBeInTheDocument();
expect(screen.getByText("Maria Souza")).toBeInTheDocument();
expect(screen.getByText("Sócia, Empresa de Teste")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Ver o case" })).toHaveAttribute(
expect(screen.getByRole("link", { name: "Ver case" })).toHaveAttribute(
"href",
"/cases/lavacao-047",
);
@@ -44,5 +44,5 @@ it("falls back to initials without a photo and has no case link without a projec
render(<Testimonials items={[{ ...item, projectId: undefined }]} />);
expect(screen.getByText("MS")).toBeInTheDocument();
expect(screen.queryByRole("link", { name: "Ver o case" })).toBeNull();
expect(screen.queryByRole("link", { name: "Ver case" })).toBeNull();
});
+4 -4
View File
@@ -1,7 +1,7 @@
import Image from "next/image";
import Link from "next/link";
import type { Testimonial } from "@/lib/proof";
import { textLink } from "./button-styles";
import HoverPrefetchLink from "./HoverPrefetchLink";
function initials(name: string) {
return name
@@ -67,12 +67,12 @@ export default function Testimonials({
{item.role}, {item.company}
</p>
{item.projectId && (
<Link
<HoverPrefetchLink
href={`/cases/${item.projectId}`}
className={`${textLink} !min-h-0 text-[var(--ink-80)]`}
>
Ver o case
</Link>
Ver case
</HoverPrefetchLink>
)}
</div>
</figcaption>
+84 -26
View File
@@ -109,20 +109,29 @@ describe("WorkModalities: as três ofertas", () => {
]);
});
it("path: the site offer cites the Lavação 047 (catalog or price table, ready message, panel to edit alone)", () => {
it("path: the site offer cites the Lavação 047 and delivers a catalog or price table, a ready WhatsApp message, a panel to edit prices and photos and the publication", () => {
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/);
expect(
within(site)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual([
"Catálogo ou tabela de preços",
"Mensagem pronta no WhatsApp",
"Painel para editar preços e fotos",
"Publicação do site",
]);
});
it("path: the first two offers show the poster of their case, sized for a third of the row", () => {
it("path: only the site offer shows a photo (the Lavação 047 poster, sized for a third of the row)", () => {
const { container } = render(<WorkModalities />);
const [site, system] = Array.from(container.querySelectorAll("article"));
const [site, system, review] = Array.from(
container.querySelectorAll("article"),
);
const siteImage = within(site).getByRole("img");
expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp");
expect(siteImage).toHaveAccessibleName(
@@ -133,27 +142,72 @@ describe("WorkModalities: as três ofertas", () => {
"(min-width: 1024px) 33vw, 100vw",
);
expect(siteImage).toHaveAttribute("loading", "lazy");
expect(original(within(system).getByRole("img"))).toBe(
"/videos/sev-modaintima/desktop.webp",
);
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
expect(within(system).queryByRole("img")).toBeNull();
expect(system.querySelector("img")).toBeNull();
expect(container.innerHTML).not.toContain("sev-modaintima/desktop.webp");
expect(within(review).queryByRole("img")).toBeNull();
});
it("path: the review offer has a checklist card instead of a poster, with what the review delivers", () => {
it("path: the system and the review offers have a card with no photo, only an icon (decorative), and the deliverables below", () => {
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 [
const [, system, review] = Array.from(
container.querySelectorAll("article"),
);
for (const card of [system, review]) {
const icon = within(card).getByTestId("offer-icon");
expect(icon.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
expect(icon.textContent).toBe("");
}
expect(
within(system)
.getAllByRole("listitem")
.map((item) => item.textContent),
).toEqual([
"Cadastro de clientes e produtos",
"Pedidos e acompanhamento",
"Relatórios",
"Acesso por perfil",
]);
expect(
within(review)
.getAllByRole("listitem")
.map((item) => item.textContent),
).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",
]) {
expect(within(review).getByText(item)).toBeInTheDocument();
]);
});
it("path: every card says what you receive, with three or four items, once", () => {
const { container } = render(<WorkModalities />);
for (const card of container.querySelectorAll("article")) {
expect(
within(card as HTMLElement).getAllByText("O que você recebe"),
).toHaveLength(1);
const items = within(card as HTMLElement).getAllByRole("listitem");
expect(items.length).toBeGreaterThanOrEqual(3);
expect(items.length).toBeLessThanOrEqual(4);
}
});
it("path: every card has its first step: a first conversation and, after it, the scope and the proposal in writing", () => {
const { container } = render(<WorkModalities />);
const cards = Array.from(container.querySelectorAll("article"));
for (const card of cards) {
expect(within(card).getAllByText("Primeiro passo")).toHaveLength(1);
expect(card.textContent).toMatch(
/Primeira conversa para entender .+\. Depois, escopo e proposta por escrito\./,
);
}
expect(screen.getAllByText("Primeiro passo")).toHaveLength(3);
});
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 />);
@@ -175,11 +229,11 @@ describe("WorkModalities: as três ofertas", () => {
const articles = Array.from(container.querySelectorAll("article"));
expect(
within(articles[0]).getByRole("link", { name: "Ver o case Lavação 047" }),
within(articles[0]).getByRole("link", { name: "Ver case Lavação 047" }),
).toHaveAttribute("href", "/cases/lavacao-047");
expect(
within(articles[1]).getByRole("link", {
name: "Ver o case SEV Moda Íntima",
name: "Ver case SEV Moda Íntima",
}),
).toHaveAttribute("href", "/cases/sev-modaintima");
expect(
@@ -189,18 +243,23 @@ describe("WorkModalities: as três ofertas", () => {
).toHaveAttribute("href", "/ia");
});
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel", () => {
it("path: no duplicate service grid and no 'Reconhece algum destes cenários?' panel, only one link to /servicos after the cards", () => {
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/);
}
const services = screen.getByRole("link", {
name: "Ver todos os serviços",
});
expect(services).toHaveAttribute("href", "/servicos");
expect(container.querySelectorAll("article")).toHaveLength(3);
// o link fica fora dos cartões, depois da grade
expect(services.closest("article")).toBeNull();
expect(
screen
.getAllByRole("link")
.filter((link) => /^\/servicos/.test(link.getAttribute("href") ?? "")),
).toEqual([services]);
});
});
@@ -278,6 +337,5 @@ describe("WorkModalities: sem prometer o que o dono não disse", () => {
expect(container.textContent).not.toMatch(
/R\$|em contrato|prazo de \d|\d+ dias|grátis|gratuit/i,
);
expect(screen.queryByText("Primeiro passo")).toBeNull();
});
});
+103 -52
View File
@@ -1,3 +1,4 @@
import { LayoutDashboard, type LucideIcon, ShieldCheck } from "lucide-react";
import Image from "next/image";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { primaryButton, textLink } from "./button-styles";
@@ -12,11 +13,17 @@ interface Offer {
id: string;
title: string;
description: string;
/** O que a pessoa recebe: de três a quatro itens, só o que o case ou a página /ia citados já sustentam. */
deliverables: string[];
/** Primeira etapa: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). */
firstStep: string;
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
kind: ContactKind;
ctaLabel: string;
/** Case cujo pôster ilustra a oferta; sem ele, a miniatura é o cartão de checklist. */
project?: { id: string; title: string };
/** Case cujo pôster ilustra a oferta; sem ele, o cartão não tem foto e mostra só o ícone. */
poster?: { id: string; title: string };
/** Ícone do cartão sem foto. */
icon?: LucideIcon;
moreLabel: string;
moreHref: string;
}
@@ -24,17 +31,28 @@ interface Offer {
/**
* 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.
* Só o site tem foto: a Lavação 047 é um site de catálogo e tabela de preços, o que o texto descreve. O sistema e a
* revisão não têm um case que mostre o que descrevem (a vitrine da SEV é uma loja, não um painel), então o cartão
* traz só um ícone, e os entregáveis dizem o que a pessoa recebe.
*/
const OFFERS: Offer[] = [
{
id: "site",
title: "Site que traz pedidos pelo WhatsApp",
description:
"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.",
"O cliente vê o que você oferece e vai para a conversa no WhatsApp, como na Lavação 047.",
deliverables: [
"Catálogo ou tabela de preços",
"Mensagem pronta no WhatsApp",
"Painel para editar preços e fotos",
"Publicação do site",
],
firstStep:
"Primeira conversa para entender o problema. Depois, escopo e proposta por escrito.",
kind: "site",
ctaLabel: "Começar pelo site",
project: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver o case Lavação 047",
poster: { id: "lavacao-047", title: "Lavação 047" },
moreLabel: "Ver case Lavação 047",
moreHref: "/cases/lavacao-047",
},
{
@@ -42,32 +60,41 @@ const OFFERS: Offer[] = [
title: "Sistema para tirar a operação da planilha",
description:
"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.",
deliverables: [
"Cadastro de clientes e produtos",
"Pedidos e acompanhamento",
"Relatórios",
"Acesso por perfil",
],
firstStep:
"Primeira conversa para entender a operação. Depois, escopo e proposta por escrito.",
kind: "sistema",
ctaLabel: "Começar pelo sistema",
project: { id: "sev-modaintima", title: "SEV Moda Íntima" },
moreLabel: "Ver o case SEV Moda Íntima",
icon: LayoutDashboard,
moreLabel: "Ver case SEV Moda Íntima",
moreHref: "/cases/sev-modaintima",
},
{
id: "revisao",
title: "Revisão de sistema que já existe, inclusive feito com IA",
description:
"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.",
"Para quem já tem um sistema, como um app feito no Lovable, no Bolt ou no Replit, e quer saber se ele está pronto para receber clientes.",
deliverables: [
"As falhas de acesso a dados",
"O que dá para aproveitar",
"O que é melhor refazer",
"Um orçamento para pôr em produção",
],
firstStep:
"Primeira conversa para entender o app e o que ele precisa fazer. Depois, escopo e proposta por escrito.",
kind: "revisao",
ctaLabel: "Começar pela revisão",
icon: ShieldCheck,
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",
@@ -91,59 +118,40 @@ const PROCESS = [
},
];
/** 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() {
/** Miniatura sem foto: um painel escuro do mesmo tamanho dos pôsteres, só com o ícone da oferta. */
function OfferIcon({ icon: Icon }: Readonly<{ icon: LucideIcon }>) {
return (
<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="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
data-testid="offer-icon"
className="flex h-full items-center justify-center text-white/85"
>
<Icon aria-hidden="true" className="h-14 w-14" strokeWidth={1.25} />
</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. */
/** Um único estilo de cartão para as três ofertas: foto ou ícone, título com altura mínima, texto, o que você recebe, primeiro passo, botão e link do case. */
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
const poster = offer.project
? getShowcaseAssets(offer.project.id, "desktop")
const poster = offer.poster
? getShowcaseAssets(offer.poster.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 ? (
{poster && offer.poster ? (
<Image
src={poster.poster}
alt={`Site ${offer.project.title} aberto no computador`}
alt={`Site ${offer.poster.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 />
)}
) : offer.icon ? (
<OfferIcon icon={offer.icon} />
) : null}
</div>
{/* min-h de 3 linhas no desktop: os títulos de 2 e 3 linhas terminam na mesma altura e os textos se alinham */}
@@ -152,15 +160,51 @@ function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
</h3>
<p
className="ds-copy !mt-sp-3 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
className="ds-copy !mt-sp-3 !grow-0 !text-[15px] !leading-[1.7] [overflow-wrap:break-word] [word-break:normal]"
data-reel="right"
>
{offer.description}
</p>
<div className="my-sp-5">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
O que você recebe
</p>
<ul className="mt-sp-2 flex flex-col gap-sp-2">
{offer.deliverables.map((item) => (
<li
key={item}
className="flex items-start gap-sp-3 font-display text-[15px] leading-6 text-[var(--ink-80)]"
>
<svg
aria-hidden="true"
viewBox="0 0 16 16"
className="mt-1 h-4 w-4 shrink-0 text-[var(--ink)]"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="m3 8.5 3.2 3L13 4.5" />
</svg>
{item}
</li>
))}
</ul>
</div>
{/* mt-auto: com cartões de alturas diferentes, o primeiro passo e o botão descem juntos até o pé do cartão */}
<div className="mt-auto border-t border-[var(--border)] pt-sp-4">
<p className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Primeiro passo
</p>
<p className="mt-sp-1 font-display text-[15px] leading-6 text-[var(--ink-80)]">
{offer.firstStep}
</p>
</div>
<ContactOfferLink
kind={offer.kind}
className={`mt-sp-6 w-full ${primaryButton}`}
className={`mt-sp-5 w-full ${primaryButton}`}
>
{offer.ctaLabel}
</ContactOfferLink>
@@ -197,6 +241,13 @@ export function WorkModalities() {
))}
</div>
<HoverPrefetchLink
href="/servicos"
className={`${textLink} mt-sp-6 text-[var(--ink-80)]`}
>
Ver todos os serviços
</HoverPrefetchLink>
{/* 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"
@@ -0,0 +1,53 @@
import { render } from "@testing-library/react";
import { SEO_IDS } from "@/lib/seo";
import CinematicHome from "./CinematicHome";
/**
* Dados estruturados da home: um único script `application/ld+json` com o negócio (ProfessionalService). O WebSite e
* a Person já saem do layout (app/layout.tsx), e repeti-los aqui duplicaria os dados.
*/
jest.mock("../Hero", () => ({ __esModule: true, default: () => null }));
jest.mock("../Projects", () => ({ __esModule: true, default: () => null }));
jest.mock("../About", () => ({ __esModule: true, default: () => null }));
jest.mock("../WorkModalities", () => ({ WorkModalities: () => null }));
jest.mock("../HomeFaq", () => ({ __esModule: true, default: () => null }));
jest.mock("../Testimonials", () => ({ __esModule: true, default: () => null }));
jest.mock("../Contact", () => ({ __esModule: true, default: () => null }));
jest.mock("./CinematicMain", () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }) => (
<main>{children}</main>
),
}));
function structuredData() {
const { container } = render(<CinematicHome projects={[]} />);
return Array.from(
container.querySelectorAll('script[type="application/ld+json"]'),
).map((script) => JSON.parse(script.textContent ?? ""));
}
describe("CinematicHome: dados estruturados", () => {
it("emits exactly one ld+json script, before the scenes", () => {
const { container } = render(<CinematicHome projects={[]} />);
const scripts = container.querySelectorAll(
'script[type="application/ld+json"]',
);
expect(scripts).toHaveLength(1);
expect(container.firstElementChild).toBe(scripts[0]);
});
it("describes only the business as a ProfessionalService, without repeating what the layout emits", () => {
const [data] = structuredData();
expect(data["@context"]).toBe("https://schema.org");
expect(data["@graph"]).toHaveLength(1);
const [business] = data["@graph"];
expect(business["@type"]).toBe("ProfessionalService");
expect(business["@id"]).toBe(SEO_IDS.professionalService);
expect(business.founder).toEqual({ "@id": SEO_IDS.person });
expect(JSON.stringify(data)).not.toMatch(/"@type":"(WebSite|Person)"/);
});
});
@@ -41,10 +41,6 @@ jest.mock("../WorkModalities", () => ({
return <section data-testid="scene-modalities" />;
},
}));
jest.mock("../EngineeringWithAI", () => ({
__esModule: true,
default: () => <section data-testid="scene-ai" />,
}));
jest.mock("../HomeFaq", () => ({
__esModule: true,
default: () => <section data-testid="scene-faq" />,
@@ -167,7 +163,7 @@ describe("CinematicHome: props e estrutura", () => {
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)", () => {
it("path: scenes sit in order inside <main> (hero, projects, offers, about, FAQ, contact; no AI block); the home has no footer of its own (it comes from the layout)", () => {
render(<CinematicHome projects={[]} />);
const main = screen.getByRole("main");
@@ -178,9 +174,8 @@ describe("CinematicHome: props e estrutura", () => {
).toEqual([
"scene-hero",
"scene-projects",
"scene-about",
"scene-modalities",
"scene-ai",
"scene-about",
"scene-testimonials",
"scene-faq",
"scene-contact",
+26 -12
View File
@@ -1,9 +1,10 @@
import { safeJsonLd } from "@/lib/jsonld";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import type { Testimonial } from "@/lib/proof";
import { buildProfessionalServiceJsonLd } from "@/lib/seo";
import About from "../About";
import type { ClientLink } from "../ClientStrip";
import Contact from "../Contact";
import EngineeringWithAI from "../EngineeringWithAI";
import Hero, { type HeroStats } from "../Hero";
import HomeFaq from "../HomeFaq";
import Projects from "../Projects";
@@ -15,9 +16,21 @@ 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";
/**
* Dados estruturados da home: o negócio como ProfessionalService (área de atendimento e as três ofertas). Só ele: o
* WebSite e a Person já saem do layout, e repeti-los aqui duplicaria os dados. O script fica aqui, e não em
* app/page.tsx, para a página continuar devolvendo só a home.
*/
const jsonLd = {
"@context": "https://schema.org",
"@graph": [buildProfessionalServiceJsonLd()],
};
/**
* 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.
* A ordem é a da conversa de venda: hero, projetos (a prova), ofertas ("Escolha por onde começar"), quem conduz,
* perguntas frequentes e contato. A revisão de app feito com IA não tem cena própria: a oferta e a FAQ levam a /ia.
*/
export default function CinematicHome({
projects,
@@ -40,22 +53,23 @@ export default function CinematicHome({
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
return (
<CinematicMain>
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<CinematicMain>
<Hero stats={heroStats} clients={clients} workUrl={heroWorkUrl} />
<Projects projects={projects} />
<Projects projects={projects} />
<About />
<WorkModalities />
<WorkModalities />
<About />
<EngineeringWithAI />
<Testimonials items={testimonials} />
<Testimonials items={testimonials} />
<HomeFaq />
<HomeFaq />
<Contact />
</CinematicMain>
<Contact />
</CinematicMain>
</>
);
}
+1 -1
View File
@@ -2,7 +2,7 @@
* 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).
* gerenciador de âncoras rola até o formulário sem recarregar a página, e ContactOfferLink deixa o endereço como `/#contact`).
* 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.
*/
+44
View File
@@ -0,0 +1,44 @@
import { HERO_CAPTIONS } from "./hero-captions";
/*
* Sem janela de rolagem sem texto no mergulho do hero: o texto do hero sai de cena e as legendas entram uma depois da
* outra (globals.css lê --from e --to de cada item). Aqui ficam as garantias sobre os trechos; o e2e de rolagem
* (e2e/scroll.spec.ts) confere, no navegador, que nenhuma posição amostrada fica sem texto.
*/
/** O texto do hero some em `1 / 4,2` do progresso: `opacity: calc(1 - var(--hero-p) * 4.2)` em globals.css. */
const HERO_TEXT_GONE = 1 / 4.2;
/** Maior intervalo sem legenda entre duas: 0,1 de progresso são cerca de 100 px de rolagem. */
const MAX_GAP = 0.1;
describe("hero dive captions", () => {
it("has three captions, each with a valid range, in the order of the scene layers", () => {
expect(HERO_CAPTIONS).toHaveLength(3);
for (const { from, to } of HERO_CAPTIONS) {
expect(from).toBeGreaterThanOrEqual(0);
expect(to).toBeGreaterThan(from);
}
for (let i = 1; i < HERO_CAPTIONS.length; i++) {
expect(HERO_CAPTIONS[i].from).toBeGreaterThanOrEqual(
HERO_CAPTIONS[i - 1].from,
);
expect(HERO_CAPTIONS[i].to).toBeGreaterThan(HERO_CAPTIONS[i - 1].to);
}
});
it("starts the first caption before the text of the hero is gone", () => {
expect(HERO_CAPTIONS[0].from).toBeLessThanOrEqual(HERO_TEXT_GONE);
});
it("leaves a short gap, at most 0.1 of the dive, between one caption and the next", () => {
for (let i = 1; i < HERO_CAPTIONS.length; i++) {
expect(
HERO_CAPTIONS[i].from - HERO_CAPTIONS[i - 1].to,
).toBeLessThanOrEqual(MAX_GAP);
}
});
it("keeps the last caption on stage past the end of the dive, until Projects covers it", () => {
expect(HERO_CAPTIONS[HERO_CAPTIONS.length - 1].to).toBeGreaterThan(1);
});
});
+31
View File
@@ -0,0 +1,31 @@
/**
* Legendas do mergulho do hero (as camadas da cena 3D, em linguagem simples) e o trecho do progresso do mergulho
* (`--hero-p`, de 0 a 1) em que cada uma fica em cena. O Hero as escreve com `--from` e `--to` em cada item, e o CSS
* (globals.css, `.hero-captions li`) as liga e desliga por esses dois valores: uma rampa de 1/14 de progresso para
* entrar a partir de `from` e para sair até `to`.
*
* Os trechos são os da câmera (cameraFrames em cinematic/HeroFieldScene.tsx: a câmera enquadra a camada 1 entre
* 0,28 e 0,44, a 2 entre 0,55 e 0,69 e a 3 entre 0,78 e 0,89), com folga: cada legenda entra um pouco antes de a
* câmera chegar e sai um pouco depois de ela partir. Assim o intervalo sem texto entre uma e outra é de poucos pixels
* de rolagem, e a última passa de 1 para ficar em cena até Projetos subir e cobri-la.
*/
export const HERO_CAPTIONS = [
{
title: "A tela",
text: "O que sua equipe e seus clientes usam no dia a dia.",
from: 0.18,
to: 0.49,
},
{
title: "As integrações",
text: "Os programas que você já usa conversando entre si, sem retrabalho.",
from: 0.51,
to: 0.75,
},
{
title: "Os dados",
text: "Suas informações guardadas, organizadas e seguras.",
from: 0.79,
to: 1.07,
},
] as const;
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

+84 -16
View File
@@ -98,21 +98,26 @@ 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", () => {
it("shows 'Como funciona em 3 passos' in a card on the right half of the hero, next to the headline, instead of repeating the deliverables", () => {
render(<AiPage />);
const card = screen.getByTestId("ia-review-card");
expect(within(card).getByText("O que você recebe")).toBeInTheDocument();
const card = screen.getByTestId("ia-steps-card");
expect(
within(card)
.getAllByRole("listitem")
.map((item) => item.textContent?.replace(/^\d+/, "")),
within(card).getByText("Como funciona em 3 passos"),
).toBeInTheDocument();
const steps = within(card).getAllByRole("listitem");
expect(steps).toHaveLength(3);
expect(
steps.map((item) => item.querySelector("strong")?.textContent),
).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",
"Conversa pelo WhatsApp.",
"Teste e avaliação.",
"Relatório e orçamento.",
]);
// a conversa vem primeiro e repete o aviso da página: nada de senhas nem acessos por mensagem
expect(steps[0]).toHaveTextContent(
"Não envie senhas nem acessos por mensagem.",
);
// 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);
@@ -120,13 +125,22 @@ it("shows the report list in a card on the right half of the hero, next to the h
"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
// os quatro entregáveis ficam só na seção "O que você recebe na revisão": o hero não os repete
expect(within(card).queryByText("O que você recebe")).toBeNull();
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(card).queryByText(item)).toBeNull();
}
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", () => {
it("keeps the in-page secondary link and brings back a secondary link to the cases in the hero, both as text links", () => {
render(<AiPage />);
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
@@ -135,12 +149,66 @@ it("keeps the secondary hero button inside the page: the portfolio cases are not
});
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/);
const cases = within(hero as HTMLElement).getByRole("link", {
name: "Ver projetos entregues",
});
expect(cases).toHaveAttribute("href", "/cases");
// os dois são links de texto; o único botão cheio do hero é o do WhatsApp
expect(cases.className).toBe(secondary.className);
const hrefs = within(hero as HTMLElement)
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(hrefs.filter((href) => href?.startsWith("/cases"))).toEqual([
"/cases",
]);
});
it("shows the report model with three illustrative findings (flaw, severity, suggested fix), labelled as examples and with no client name", () => {
const { container } = render(<AiPage />);
const heading = screen.getByRole("heading", {
level: 2,
name: "Modelo do relatório",
});
const section = heading.closest("section") as HTMLElement;
expect(section).toHaveAttribute("id", "ia-modelo");
expect(section).toHaveTextContent("exemplos ilustrativos");
expect(section).toHaveTextContent("não são de nenhum cliente");
const findings = within(section).getAllByRole("article");
expect(findings).toHaveLength(3);
for (const [index, finding] of findings.entries()) {
expect(finding).toHaveTextContent(`Exemplo ilustrativo 0${index + 1}`);
expect(
Array.from(finding.querySelectorAll("dt")).map(
(term) => term.textContent,
),
).toEqual(["Falha", "Gravidade", "Correção sugerida"]);
for (const definition of finding.querySelectorAll("dd")) {
expect(definition.textContent?.trim()).not.toBe("");
}
}
// a gravidade é uma palavra só, de uma escala simples
expect(
screen.queryByRole("link", { name: "Ver projetos entregues" }),
).toBeNull();
findings.map((finding) => finding.querySelectorAll("dd")[1].textContent),
).toEqual(["Alta", "Alta", "Média"]);
// sem nome de cliente do portfólio, sem preço nem prazo
expect(section.textContent).not.toMatch(
/SEV|Lavação|SC Plus|AS Serviços|Apolo|R\$|\d+ dias|em até \d+/,
);
// o modelo vem depois do que se recebe e antes de "por que contratar"
const receive = container.querySelector("#ia-receber") as HTMLElement;
expect(
receive.compareDocumentPosition(section) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const hire = screen
.getByRole("heading", {
name: "Se a IA cria um sistema, por que contratar um desenvolvedor?",
})
.closest("section") as HTMLElement;
expect(
section.compareDocumentPosition(hire) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("keeps the incident news collapsed in a <details>, closed by default", () => {
+113 -6
View File
@@ -5,6 +5,7 @@ import {
primaryButton,
textLinkDark,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -53,6 +54,51 @@ const DELIVERABLES = [
},
];
/**
* Como a revisão anda, em três passos, no cartão do hero. Só o que o resto da página já diz: a conversa pelo WhatsApp
* (sem enviar senhas), o teste e a separação do que aproveitar do que refazer, e o orçamento por escrito. Sem prazo e
* sem preço: isso se combina na conversa.
*/
const STEPS = [
{
title: "Conversa pelo WhatsApp",
description:
"Você conta o que o app faz e o que ele precisa fazer. Não envie senhas nem acessos por mensagem.",
},
{
title: "Teste e avaliação",
description:
"Testo se uma pessoa consegue ver ou alterar os dados de outra e separo o que dá para aproveitar do que é melhor refazer.",
},
{
title: "Relatório e orçamento",
description:
"Você recebe o que precisa de correção e o orçamento por escrito para pôr o app em produção.",
},
];
/**
* Três achados de EXEMPLO do relatório, para mostrar como ele é organizado: a falha, a gravidade e a correção sugerida.
* São ilustrativos: não são de nenhum cliente nem de um app real, e a página os rotula assim.
*/
const REPORT_EXAMPLES = [
{
flaw: "Um usuário consegue abrir os dados de outro trocando o número no endereço da página.",
severity: "Alta",
fix: "Conferir no servidor se cada registro pertence a quem está logado e testar com contas de empresas diferentes.",
},
{
flaw: "A chave de acesso ao banco de dados está no código que o navegador baixa.",
severity: "Alta",
fix: "Levar a chave para o servidor, trocar a que ficou exposta e liberar no banco só o que cada perfil precisa.",
},
{
flaw: "Não há cópia de segurança com restauração testada.",
severity: "Média",
fix: "Ligar as cópias automáticas, separar o banco de testes do de produção e ensaiar a restauração.",
},
];
const RESPONSIBILITIES = [
{
title: "Quem pode acessar cada dado?",
@@ -161,24 +207,27 @@ export default function AiPage() {
>
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>
{/* Link secundário para os cases: mostram como eu entrego, não são revisões de app feito com IA. */}
<HoverPrefetchLink href="/cases" className={textLinkDark}>
Ver projetos entregues
</HoverPrefetchLink>
</div>
</div>
<div
data-testid="ia-review-card"
data-testid="ia-steps-card"
className="min-w-0 border border-white/15 bg-white/[0.04] p-sp-6 sm:p-sp-8"
>
<p className="font-display text-xl font-semibold leading-7 text-white">
O que você recebe
Como funciona em 3 passos
</p>
<ol className="mt-sp-5 flex flex-col">
{DELIVERABLES.map((item, index) => (
{STEPS.map((step, index) => (
<li
key={item.title}
key={step.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
@@ -188,7 +237,10 @@ export default function AiPage() {
{String(index + 1).padStart(2, "0")}
</span>
<span className="min-w-0 font-display text-base leading-6 text-white/90">
{item.title}
<strong className="font-semibold text-white">
{step.title}.
</strong>{" "}
{step.description}
</span>
</li>
))}
@@ -230,6 +282,61 @@ export default function AiPage() {
</div>
</section>
<section
id="ia-modelo"
className="ds-section"
aria-labelledby="ia-modelo-title"
>
<div className="ds-section-shell">
<h2 id="ia-modelo-title" className="ds-section-title max-w-[26ch]">
Modelo do relatório
</h2>
<p className="ds-copy max-w-[65ch]">
Cada falha encontrada entra no relatório com a gravidade e a
correção sugerida. Os três achados abaixo são{" "}
<strong>exemplos ilustrativos</strong> de como ele é organizado: não
são de nenhum cliente nem de um app real.
</p>
<ol className="mt-sp-8 grid gap-sp-6 lg:grid-cols-3">
{REPORT_EXAMPLES.map((example, index) => (
<li key={example.flaw} className="flex">
<article className="ds-card w-full">
<p className="ds-kicker">
Exemplo ilustrativo {String(index + 1).padStart(2, "0")}
</p>
<dl className="mt-sp-4 flex flex-col gap-sp-4">
<div>
<dt className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Falha
</dt>
<dd className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{example.flaw}
</dd>
</div>
<div>
<dt className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Gravidade
</dt>
<dd className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{example.severity}
</dd>
</div>
<div>
<dt className="font-display text-[15px] font-semibold leading-6 text-[var(--ink)]">
Correção sugerida
</dt>
<dd className="font-display text-[15px] leading-6 text-[var(--ink-80)]">
{example.fix}
</dd>
</div>
</dl>
</article>
</li>
))}
</ol>
</div>
</section>
<section className="ds-section" aria-labelledby="ia-contratar-title">
<div className="ds-section-shell">
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">
+2 -1
View File
@@ -18,7 +18,8 @@ import GoogleAnalytics from "./components/GoogleAnalytics";
import { Header } from "./components/Header";
import "./globals.css";
// 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.
// A lista da vitrine (reel-list.css) já vem no globals.css, por @import; só a vitrine fixa (pinned-reel.css) e a janela
// ampliada (lightbox.css) viajam com os componentes deles.
import "./components/showcase/showcase.css";
const titleSuffix = " | Evandro.dev.br";
+22
View File
@@ -44,6 +44,28 @@ describe("next.config headers()", () => {
);
});
it("path: any environment -> the policy (still Report-Only) allows the domains the Google Ads tag uses after consent, in connect-src and img-src", async () => {
const headers = await siteHeaders();
const policy = header(headers, "Content-Security-Policy-Report-Only") ?? "";
const directive = (name: string) =>
policy.split("; ").find((part) => part.startsWith(`${name} `)) ?? "";
for (const name of ["connect-src", "img-src"]) {
for (const host of [
"https://pagead2.googlesyndication.com",
"https://googleads.g.doubleclick.net",
"https://www.googleadservices.com",
]) {
expect(directive(name)).toContain(host);
}
// o que já estava liberado segue liberado
expect(directive(name)).toContain("https://*.google-analytics.com");
}
// nada de curinga aberto e nada enforçado: a política continua só em Report-Only
expect(policy).not.toMatch(/\* |https:(?!\/)/);
expect(header(headers, "Content-Security-Policy")).toBeUndefined();
});
it("path: any environment -> the video rule keeps the long cache", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const videos = rules.find(({ source }) => source === "/videos/:path*");
+1 -1
View File
@@ -13,7 +13,7 @@ describe("next.config", () => {
expect(nextConfig.images).not.toHaveProperty("minimumCacheTTL");
});
it("keeps the CSP in Report-Only until Google Ads is allowed in it, never enforcing it by accident", async () => {
it("keeps the CSP in Report-Only (Google Ads is allowed in it now, but the owner activates it later), never enforcing it by accident", async () => {
const rules = (await nextConfig.headers?.()) ?? [];
const site = rules.find(({ source }) => source === "/(.*)");
const names = site?.headers.map(({ key }) => key) ?? [];
+1 -3
View File
@@ -1,8 +1,5 @@
import { portfolio } from "./portfolio-data";
/** Projetos feitos para clientes aparecem como prova; os pessoais e protótipos ficam no "Laboratório". */
export type ProjectKind = "cliente" | "laboratorio";
export interface Testimonial {
quote: string;
name: string;
@@ -20,6 +17,7 @@ export interface Testimonial {
*/
export const testimonials: Testimonial[] = [];
/** Projetos feitos para clientes (`kind: "cliente"`) aparecem como prova; os pessoais e protótipos ficam no "Laboratório". */
export function isClientProject(project: { kind?: string }) {
return project.kind === "cliente";
}
+2 -2
View File
@@ -26,10 +26,10 @@ describe("whatsAppSubjectForPath", () => {
it("cites the case on /cases/<slug>, with the phrase every page uses for a similar project", () => {
expect(whatsAppSubjectForPath("/cases/sev-modaintima", contexts)).toBe(
"um projeto parecido com o SEV Moda Íntima",
"um projeto parecido com o case SEV Moda Íntima",
);
expect(similarProjectSubject("Lavação 047")).toBe(
"um projeto parecido com o Lavação 047",
"um projeto parecido com o case Lavação 047",
);
});
+2 -2
View File
@@ -39,9 +39,9 @@ export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio";
export const SERVICES_SUBJECT = "Sites, sistemas e aplicativos";
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). */
/** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). "O case" concorda com qualquer título: sem ele, saía "o Lavação 047" e "o SEV Moda Íntima". */
export function similarProjectSubject(title: string) {
return `um projeto parecido com o ${title}`;
return `um projeto parecido com o case ${title}`;
}
/** Assunto de cada página sem parâmetro, pelo caminho sem a barra final (só a home mantém a barra). */
+10 -2
View File
@@ -35,11 +35,19 @@ const nextConfig: NextConfig = {
...(process.env.NODE_ENV === "production" ? [] : ["'unsafe-eval'"]),
"https://www.googletagmanager.com",
].join(" ");
// O gtag do Google Ads, depois do consentimento: a medição vai para pagead2.googlesyndication.com (fetch, visto
// como violação de connect-src ao aceitar os cookies), e os pixels e as conversões usam googleads.g.doubleclick.net
// e www.googleadservices.com. Ainda em Report-Only: o que mais aparecer como violação em produção entra aqui.
const googleAdsSources = [
"https://pagead2.googlesyndication.com",
"https://googleads.g.doubleclick.net",
"https://www.googleadservices.com",
].join(" ");
const contentSecurityPolicy = [
"default-src 'self'",
`script-src ${scriptSources}`,
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com",
"img-src 'self' data: blob: https://*.google-analytics.com https://www.googletagmanager.com",
`connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.googletagmanager.com ${googleAdsSources}`,
`img-src 'self' data: blob: https://*.google-analytics.com https://www.googletagmanager.com ${googleAdsSources}`,
"style-src 'self' 'unsafe-inline'",
"font-src 'self' data:",
"frame-ancestors 'none'",