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:
1 parent
fc44a69fb3
commit
9e2d65a926
51 files changed
+1730
-420
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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(
|
||||
"·",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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" />
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 1.8 KiB |
+84
-16
@@ -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
@@ -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
@@ -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";
|
||||
|
||||
@@ -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*");
|
||||
|
||||
@@ -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
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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'",
|
||||
|
||||
Reference in new issue
Block a user