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.
|
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.
|
- 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.
|
- Replit: exclusão de dados e restauração integral, conforme a resposta oficial da empresa.
|
||||||
|
|||||||
@@ -117,12 +117,23 @@ describe("About", () => {
|
|||||||
"Evandro Fonseca Junior",
|
"Evandro Fonseca Junior",
|
||||||
);
|
);
|
||||||
expect(screen.getByTestId("about-description").textContent).toMatch(
|
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
|
// a frase da Apolo aparece uma só vez
|
||||||
expect(container.textContent?.match(/Apolo Energia/g)).toHaveLength(1);
|
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", () => {
|
it("path: 'contato direto', 'fala direto' and 'conversa comigo' no longer repeat in the section", () => {
|
||||||
const { container } = render(<About />);
|
const { container } = render(<About />);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Section } from "./Section";
|
|||||||
|
|
||||||
const STORY_DATA = [
|
const STORY_DATA = [
|
||||||
{
|
{
|
||||||
label: "Como eu trabalho",
|
label: "Acompanhamento",
|
||||||
title: "Sem intermediários entre o pedido e o sistema",
|
title: "Sem intermediários entre o pedido e o sistema",
|
||||||
content:
|
content:
|
||||||
"Você acompanha as decisões ao longo da entrega e sabe quem responde por cada parte do projeto.",
|
"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"
|
id="about"
|
||||||
eyebrow="Quem conduz o projeto"
|
eyebrow="Quem conduz o projeto"
|
||||||
title="Evandro Fonseca Junior"
|
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
|
disableChildrenAnimation
|
||||||
aside={
|
aside={
|
||||||
<div className="mt-sp-4 ds-metric whitespace-nowrap" data-reel="left">
|
<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
|
* 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 = [
|
const clients = [
|
||||||
@@ -20,15 +20,15 @@ describe("ClientStrip", () => {
|
|||||||
expect(container.querySelectorAll("[aria-hidden='true']")).toHaveLength(0);
|
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} />);
|
render(<ClientStrip clients={clients} />);
|
||||||
|
|
||||||
const items = screen.getAllByRole("listitem");
|
const items = screen.getAllByRole("listitem");
|
||||||
expect(items).toHaveLength(3);
|
expect(items).toHaveLength(3);
|
||||||
expect(
|
expect(
|
||||||
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
|
items.map((item) => item.querySelector("[aria-hidden='true']") !== null),
|
||||||
).toEqual([true, true, false]);
|
).toEqual([false, true, true]);
|
||||||
expect(items[0].querySelector("[aria-hidden='true']")).toHaveTextContent(
|
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 withYears = render(<ClientStrip clients={clients} years={5} />);
|
||||||
const separators = withYears.container.querySelectorAll(
|
const items = withYears.container.querySelectorAll("li");
|
||||||
"li [aria-hidden='true']",
|
expect(items).toHaveLength(clients.length + 1);
|
||||||
);
|
expect(
|
||||||
expect(separators).toHaveLength(clients.length);
|
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();
|
withYears.unmount();
|
||||||
|
|
||||||
// zero anos não escreve um "0" solto nem um ponto a mais
|
// 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(zero.container).not.toHaveTextContent(/anos de experiência/);
|
||||||
expect(screen.queryByText("0")).toBeNull();
|
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
|
* 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.
|
* 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.
|
* 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({
|
export default function ClientStrip({
|
||||||
clients = [],
|
clients = [],
|
||||||
@@ -19,44 +26,58 @@ export default function ClientStrip({
|
|||||||
}) {
|
}) {
|
||||||
if (clients.length === 0 && !years) return null;
|
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 (
|
return (
|
||||||
<div
|
<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()}
|
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 && (
|
{clients.length > 0 && <p>Já entregue para:</p>}
|
||||||
<>
|
<ul className="-ml-1 flex flex-wrap items-center overflow-x-clip pl-1">
|
||||||
<p>Já entregue para:</p>
|
{items.map((item, index) => (
|
||||||
<ul className="flex flex-wrap items-center gap-x-sp-3">
|
<li
|
||||||
{clients.map((client, index) => (
|
key={item.key}
|
||||||
<li key={client.id} className="flex items-center gap-x-sp-3">
|
className={`flex items-center pr-4${index > 0 ? " -ml-4" : ""}`}
|
||||||
<HoverPrefetchLink
|
>
|
||||||
href={`/cases/${client.id}`}
|
{index > 0 && (
|
||||||
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)]"
|
<span
|
||||||
>
|
aria-hidden="true"
|
||||||
{client.name}
|
className="w-4 shrink-0 text-center text-[rgba(244,242,237,.4)]"
|
||||||
</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>
|
||||||
<span
|
)}
|
||||||
aria-hidden="true"
|
{item.node}
|
||||||
className="text-[rgba(244,242,237,.4)]"
|
</li>
|
||||||
>
|
))}
|
||||||
·
|
</ul>
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{years ? (
|
|
||||||
<p>
|
|
||||||
<span className="font-mono font-semibold text-[var(--slash)]">
|
|
||||||
{years}
|
|
||||||
</span>{" "}
|
|
||||||
anos de experiência
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</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";
|
"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 {
|
import {
|
||||||
CONTACT_KIND_EVENT,
|
CONTACT_KIND_EVENT,
|
||||||
|
CONTACT_KIND_PARAM,
|
||||||
type ContactKind,
|
type ContactKind,
|
||||||
contactKindHref,
|
contactKindHref,
|
||||||
} from "./contact-subject";
|
} from "./contact-subject";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
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.
|
* 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
|
* Na própria home o tipo segue por evento e a página rola até `#contact` (sem navegar pelo Next); o `href` com
|
||||||
* tipo também segue por evento; o `href` com `?tipo=` cobre quem chega de outra página ou abre o link em outra aba.
|
* `?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({
|
export default function ContactOfferLink({
|
||||||
kind,
|
kind,
|
||||||
@@ -22,15 +51,33 @@ export default function ContactOfferLink({
|
|||||||
className?: string;
|
className?: string;
|
||||||
children: ReactNode;
|
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 (
|
return (
|
||||||
<HoverPrefetchLink
|
<HoverPrefetchLink
|
||||||
href={contactKindHref(kind)}
|
href={contactKindHref(kind)}
|
||||||
className={className}
|
className={className}
|
||||||
onClick={() =>
|
onClick={onClick}
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent<ContactKind>(CONTACT_KIND_EVENT, { detail: kind }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</HoverPrefetchLink>
|
</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",
|
"/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."],
|
["/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
|
// 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 { serviceHubLinks } from "@/lib/site-content";
|
||||||
import Footer from "./Footer";
|
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", () => {
|
describe("Footer contact and navigation", () => {
|
||||||
it("offers a direct mailto link without repeating the contact call to action", () => {
|
it("offers a direct mailto link without repeating the contact call to action", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
// o convite principal fica na secao Contato, logo acima do rodape
|
// o convite principal fica na secao Contato, logo acima do rodape
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("link", { name: "Conversar sobre meu projeto" }),
|
screen.queryByRole("link", { name: "Falar sobre meu projeto" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
const emailLink = screen.getByRole("link", {
|
const emailLink = screen.getByRole("link", {
|
||||||
@@ -125,4 +142,19 @@ describe("Footer social links", () => {
|
|||||||
expect(instagram).toHaveAttribute("target", "_blank");
|
expect(instagram).toHaveAttribute("target", "_blank");
|
||||||
expect(instagram).toHaveAttribute("rel", "noopener noreferrer");
|
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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import {
|
import {
|
||||||
openCookiePreferences,
|
openCookiePreferences,
|
||||||
trackEmailClick,
|
trackEmailClick,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
type ServiceLink,
|
type ServiceLink,
|
||||||
} from "@/lib/site-navigation";
|
} from "@/lib/site-navigation";
|
||||||
import "./conversion.css";
|
import "./conversion.css";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
import { Logo } from "./Logo";
|
import { Logo } from "./Logo";
|
||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
@@ -92,9 +92,12 @@ export default function Footer({
|
|||||||
<ul className="mt-sp-2">
|
<ul className="mt-sp-2">
|
||||||
{serviceLinks.map((service) => (
|
{serviceLinks.map((service) => (
|
||||||
<li key={service.href}>
|
<li key={service.href}>
|
||||||
<Link href={service.href} className={linkClassName}>
|
<HoverPrefetchLink
|
||||||
|
href={service.href}
|
||||||
|
className={linkClassName}
|
||||||
|
>
|
||||||
{service.label}
|
{service.label}
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -106,19 +109,22 @@ export default function Footer({
|
|||||||
</p>
|
</p>
|
||||||
<ul className="mt-sp-2">
|
<ul className="mt-sp-2">
|
||||||
<li>
|
<li>
|
||||||
<Link href="/cases" className={linkClassName}>
|
<HoverPrefetchLink href="/cases" className={linkClassName}>
|
||||||
Cases
|
Cases
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/ia" className={linkClassName}>
|
<HoverPrefetchLink href="/ia" className={linkClassName}>
|
||||||
Revisão de app feito com IA
|
Revisão de app feito com IA
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link href="/privacidade" className={linkClassName}>
|
<HoverPrefetchLink
|
||||||
|
href="/privacidade"
|
||||||
|
className={linkClassName}
|
||||||
|
>
|
||||||
Privacidade
|
Privacidade
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -130,19 +136,19 @@ export default function Footer({
|
|||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<Link
|
<HoverPrefetchLink
|
||||||
href="/curriculo"
|
href="/curriculo"
|
||||||
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
|
className={`${linkClassName} !text-[15px] !text-[var(--meta)]`}
|
||||||
>
|
>
|
||||||
Currículo
|
Currículo
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</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">
|
<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"
|
href="/#home"
|
||||||
aria-label="Evandro.dev.br — início"
|
aria-label="Evandro.dev.br — início"
|
||||||
className="inline-flex min-h-11 items-center"
|
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
|
{/* 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 */}
|
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)]" />
|
<Logo theme="light" className="w-40 [filter:brightness(0.5)]" />
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
|
<p className="font-display text-[15px] leading-6 text-[var(--meta)]">
|
||||||
© {currentYear} Evandro Fonseca Junior. Todos os direitos
|
© {currentYear} Evandro Fonseca Junior. Todos os direitos
|
||||||
reservados.
|
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", () => {
|
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() {
|
function PageWithCookieBar() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
|
<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">Aceitar</button>
|
||||||
<button type="button">Recusar</button>
|
<button type="button">Recusar</button>
|
||||||
</section>
|
</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 />);
|
render(<PageWithCookieBar />);
|
||||||
openMenu();
|
openMenu();
|
||||||
|
const policy = screen.getByRole("link", {
|
||||||
|
name: "Política de privacidade",
|
||||||
|
});
|
||||||
const accept = screen.getByRole("button", { name: "Aceitar" });
|
const accept = screen.getByRole("button", { name: "Aceitar" });
|
||||||
const decline = screen.getByRole("button", { name: "Recusar" });
|
const decline = screen.getByRole("button", { name: "Recusar" });
|
||||||
const order = trapOrder();
|
const order = trapOrder();
|
||||||
const last = order[order.length - 1];
|
const last = order[order.length - 1];
|
||||||
|
expect(policy).not.toHaveAttribute("inert");
|
||||||
expect(accept).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());
|
act(() => last.focus());
|
||||||
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
|
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);
|
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
|
||||||
act(() => decline.focus());
|
act(() => decline.focus());
|
||||||
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
|
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
|
||||||
|
|
||||||
// Shift+Tab de Aceitar (o primeiro do ciclo) volta ao botão de contato
|
// Shift+Tab do link da política (o primeiro do ciclo) volta ao botão de contato
|
||||||
act(() => accept.focus());
|
act(() => policy.focus());
|
||||||
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
|
expect(fireEvent.keyDown(policy, { key: "Tab", shiftKey: true })).toBe(
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
expect(last).toHaveFocus();
|
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", () => {
|
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");
|
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");
|
const isDark = () => header().className.includes("border-white/12");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -79,6 +79,20 @@ describe("Header: rolagem", () => {
|
|||||||
expect(isDark()).toBe(false);
|
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", () => {
|
it("path: page already scrolled when the header mounts -> starts with the bordered bar", () => {
|
||||||
setScrollY(500);
|
setScrollY(500);
|
||||||
render(<Header />);
|
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", () => {
|
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", () => {
|
it("path: a key other than Escape inside the open menu -> the menu stays open and the key is not blocked", () => {
|
||||||
render(<Header />);
|
render(<Header />);
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ describe("Header", () => {
|
|||||||
expect(buttons).toHaveLength(2);
|
expect(buttons).toHaveLength(2);
|
||||||
for (const button of buttons) {
|
for (const button of buttons) {
|
||||||
expect(whatsAppHref(button)).toContain(
|
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)";
|
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
|
* Controles que o Tab percorre com o menu aberto: todos os da barra de cookies, quando ela está aberta (o link "Política
|
||||||
* 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.
|
* 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.
|
* 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) {
|
function menuFocusables(header: HTMLElement) {
|
||||||
@@ -193,7 +194,7 @@ export function Header({
|
|||||||
ref={headerRef}
|
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 ${
|
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
|
scrolled
|
||||||
? "border-white/12 bg-[var(--ink)]/95"
|
? "border-white/12 bg-[var(--ink)]"
|
||||||
: "border-transparent bg-[var(--ink)]"
|
: "border-transparent bg-[var(--ink)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -239,19 +240,20 @@ export function Header({
|
|||||||
ref={menuButtonRef}
|
ref={menuButtonRef}
|
||||||
type="button"
|
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"
|
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)}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||||
aria-expanded={mobileMenuOpen}
|
aria-expanded={mobileMenuOpen}
|
||||||
>
|
>
|
||||||
<span
|
<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
|
<span
|
||||||
className={`h-0.5 w-5 bg-white transition-opacity duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}
|
className={`h-0.5 w-5 bg-white transition-opacity duration-300 ${mobileMenuOpen ? "opacity-0" : ""}`}
|
||||||
/>
|
/>
|
||||||
<span
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ describe("Hero: faixa de clientes e trabalho em destaque", () => {
|
|||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("link", { name: /Conversar sobre meu projeto/ }),
|
screen.getByRole("link", { name: /Falar sobre meu projeto/ }),
|
||||||
).toHaveAttribute("id", "hero-contact-cta");
|
).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 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 = [
|
const clients = [
|
||||||
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
|
{ id: "sev-modaintima", name: "SEV Moda Íntima" },
|
||||||
@@ -22,9 +40,9 @@ describe("Hero", () => {
|
|||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
const contactLink = screen.getByRole("link", {
|
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(
|
expect(contactLink.getAttribute("href")).toMatch(
|
||||||
/^https:\/\/wa\.me\/\d+\?/,
|
/^https:\/\/wa\.me\/\d+\?/,
|
||||||
);
|
);
|
||||||
@@ -88,13 +106,12 @@ describe("Hero", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText(
|
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();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: /conversar/i })).toHaveAttribute(
|
expect(
|
||||||
"id",
|
screen.getByRole("link", { name: /falar sobre meu projeto/i }),
|
||||||
"hero-contact-cta",
|
).toHaveAttribute("id", "hero-contact-cta");
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only the years of experience, with no repository numbers", () => {
|
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");
|
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} />);
|
render(<Hero clients={clients} />);
|
||||||
|
|
||||||
const small = screen.getByTestId("hero-work-mobile");
|
const small = screen.getByTestId("hero-work-mobile");
|
||||||
expect(small).toHaveClass("lg:hidden");
|
expect(small).toHaveClass("lg:hidden");
|
||||||
const image = small.querySelector("img") as HTMLImageElement;
|
expect(small).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
expect(image).toHaveAccessibleName(
|
expect(small).toHaveAccessibleName("Site da SEV Moda Íntima Ver case");
|
||||||
"Site da SEV Moda Íntima aberto no celular",
|
|
||||||
);
|
|
||||||
const buttons = screen.getByRole("link", { name: /ver projetos/i });
|
const buttons = screen.getByRole("link", { name: /ver projetos/i });
|
||||||
expect(
|
expect(
|
||||||
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
|
buttons.compareDocumentPosition(small) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||||
).toBeTruthy();
|
).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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { type CSSProperties, Fragment } from "react";
|
import { type CSSProperties, Fragment } from "react";
|
||||||
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
import { trackEmailClick, trackSocialClick } from "@/lib/analytics";
|
||||||
import { CONTACT_EMAIL, LINKEDIN_URL } from "@/lib/site-navigation";
|
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 HeroField from "./cinematic/HeroField";
|
||||||
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
import { HERO_CTA_ID } from "./FloatingWhatsApp";
|
||||||
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
|
import HeroWork, { type HeroWorkUrl } from "./HeroWork";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
import { HERO_CAPTIONS } from "./hero-captions";
|
||||||
import WhatsAppLink from "./WhatsAppLink";
|
import WhatsAppLink from "./WhatsAppLink";
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
@@ -45,16 +46,6 @@ const TITLE =
|
|||||||
accent: ACCENT.has(index),
|
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({
|
export default function Hero({
|
||||||
stats,
|
stats,
|
||||||
clients,
|
clients,
|
||||||
@@ -76,8 +67,13 @@ export default function Hero({
|
|||||||
<div className="hero-scrim" aria-hidden="true" />
|
<div className="hero-scrim" aria-hidden="true" />
|
||||||
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
{/* Legendas do mergulho: aparecem enquanto a câmera atravessa cada camada (só no hero fixo, telas grandes). */}
|
||||||
<ol className="hero-captions">
|
<ol className="hero-captions">
|
||||||
{CAPTIONS.map(([title, text], index) => (
|
{HERO_CAPTIONS.map(({ title, text, from, to }, index) => (
|
||||||
<li key={title} style={{ "--i": index } as CSSProperties}>
|
<li
|
||||||
|
key={title}
|
||||||
|
style={
|
||||||
|
{ "--i": index, "--from": from, "--to": to } as CSSProperties
|
||||||
|
}
|
||||||
|
>
|
||||||
<span className="hero-caption-title">{title}</span>
|
<span className="hero-caption-title">{title}</span>
|
||||||
{text}
|
{text}
|
||||||
</li>
|
</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. */}
|
{/* 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">
|
<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)]" />
|
<span aria-hidden="true" className="h-2 w-2 bg-[var(--slash)]" />
|
||||||
Evandro Fonseca Junior · Engenharia de software
|
Evandro Fonseca Junior · Engenharia de software
|
||||||
</p>
|
</p>
|
||||||
@@ -106,7 +103,7 @@ export default function Hero({
|
|||||||
</h1>
|
</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%)]">
|
<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ê
|
Do catálogo com pedido no WhatsApp ao painel da sua equipe. Você
|
||||||
fala direto com quem desenvolve.
|
fala com quem desenvolve.
|
||||||
</p>
|
</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">
|
<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"
|
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]"
|
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
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
viewBox="0 0 16 16"
|
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" />
|
<path d="M2 8h11M9 3.5 13.5 8 9 12.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</WhatsAppLink>
|
</WhatsAppLink>
|
||||||
<Link
|
<HoverPrefetchLink
|
||||||
href="/cases"
|
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]"
|
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
|
Ver projetos entregues
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ClientStrip
|
<ClientStrip
|
||||||
@@ -188,7 +185,7 @@ export default function Hero({
|
|||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
aria-hidden="true"
|
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
|
Role para mergulhar
|
||||||
<span className="hero-cue-line" />
|
<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
|
* 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) =>
|
const original = (image: Element) =>
|
||||||
@@ -29,11 +29,12 @@ describe("HeroWork: pôsteres no tamanho certo", () => {
|
|||||||
"(min-width: 1400px) 136px, 10vw",
|
"(min-width: 1400px) 136px, 10vw",
|
||||||
);
|
);
|
||||||
|
|
||||||
const small = within(screen.getByTestId("hero-work-mobile")).getByRole(
|
// 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)
|
||||||
"img",
|
const small = screen
|
||||||
);
|
.getByTestId("hero-work-mobile")
|
||||||
expect(original(small)).toBe("/videos/sev-modaintima/mobile.webp");
|
.querySelector("img") as HTMLImageElement;
|
||||||
expect(small).toHaveAttribute("sizes", "84px");
|
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", () => {
|
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", () => {
|
describe("HeroWork: cartão do celular", () => {
|
||||||
it("shows a 180px phone with the SEV mobile poster only below 1024px, while the column stays desktop-only", () => {
|
it("shows a wide card with the SEV poster only below 1024px, while the column stays desktop-only", () => {
|
||||||
render(<HeroWork />);
|
render(<HeroWork />);
|
||||||
|
|
||||||
const column = screen.getByTestId("hero-work");
|
const column = screen.getByTestId("hero-work");
|
||||||
expect(column).toHaveClass("hidden", "lg:block");
|
expect(column).toHaveClass("hidden", "lg:block");
|
||||||
|
|
||||||
const small = screen.getByTestId("hero-work-mobile");
|
const card = screen.getByTestId("hero-work-mobile");
|
||||||
expect(small).toHaveClass("lg:hidden");
|
expect(card).toHaveClass("lg:hidden", "w-full");
|
||||||
const phone = small.querySelector(".ds-phone") as HTMLElement;
|
// sem a moldura de celular de 65x160 px: a imagem é larga e ocupa a coluna inteira
|
||||||
expect(phone.style.height).toBe("180px");
|
expect(card.querySelector(".ds-phone")).toBeNull();
|
||||||
expect(phone).toContainElement(within(small).getByRole("img"));
|
const imageBox = card.querySelector("img")?.parentElement as HTMLElement;
|
||||||
expect(within(small).getByRole("img")).toHaveAccessibleName(
|
expect(imageBox).toHaveClass("w-full");
|
||||||
"Site da SEV Moda Íntima aberto no celular",
|
expect(imageBox.style.height).toBe("72px");
|
||||||
);
|
expect(card).toHaveTextContent("Site da SEV Moda Íntima");
|
||||||
expect(
|
expect(card).toHaveTextContent("Ver case");
|
||||||
within(small).getByText(/Site da SEV Moda Íntima, como aparece/),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 />);
|
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(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", () => {
|
it("passes the address to the browser bar of the desktop frame", () => {
|
||||||
|
|||||||
+47
-21
@@ -1,5 +1,6 @@
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
import { BrowserFrame } from "./showcase/BrowserFrame";
|
import { BrowserFrame } from "./showcase/BrowserFrame";
|
||||||
import { PhoneFrame } from "./showcase/PhoneFrame";
|
import { PhoneFrame } from "./showcase/PhoneFrame";
|
||||||
|
|
||||||
@@ -8,24 +9,32 @@ export type HeroWorkUrl = { text: string; secure: boolean } | null;
|
|||||||
|
|
||||||
const DESKTOP_PROJECT = "sev-modaintima";
|
const DESKTOP_PROJECT = "sev-modaintima";
|
||||||
const MOBILE_PROJECT = "as-servicos";
|
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";
|
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_SCREEN_TITLE = "SEV Moda Íntima";
|
||||||
const SMALL_PHONE_HEIGHT = 180;
|
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
|
* 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
|
* 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
|
* 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
|
||||||
* pequena (180 px de altura) com o pôster mobile da SEV, abaixo dos botões.
|
* (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
|
* 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)
|
* era banda desperdiçada), em `loading="lazy"` e prioridade baixa: no celular o título segue como o maior elemento da
|
||||||
* e a imagem que fica escondida pelo breakpoint (display: none) nem é baixada.
|
* 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 }) {
|
export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
||||||
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
const desktop = getShowcaseAssets(DESKTOP_PROJECT, "desktop");
|
||||||
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
|
const phone = getShowcaseAssets(MOBILE_PROJECT, "mobile");
|
||||||
const smallPhone = getShowcaseAssets(SMALL_SCREEN_PROJECT, "mobile");
|
const smallCard = getShowcaseAssets(SMALL_SCREEN_PROJECT, "desktop");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -64,26 +73,43 @@ export default function HeroWork({ url = null }: { url?: HeroWorkUrl }) {
|
|||||||
</PhoneFrame>
|
</PhoneFrame>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<HoverPrefetchLink
|
||||||
data-testid="hero-work-mobile"
|
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
|
<Image
|
||||||
src={smallPhone.poster}
|
src={smallCard.poster}
|
||||||
alt="Site da SEV Moda Íntima aberto no celular"
|
alt=""
|
||||||
width={smallPhone.width}
|
width={smallCard.width}
|
||||||
height={smallPhone.height}
|
height={smallCard.height}
|
||||||
sizes="84px"
|
sizes="(min-width: 1024px) 1px, 100vw"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
fetchPriority="low"
|
fetchPriority="low"
|
||||||
className="absolute inset-0 block h-full w-full object-cover object-top"
|
className="absolute inset-0 block h-full w-full object-cover object-top"
|
||||||
/>
|
/>
|
||||||
</PhoneFrame>
|
</span>
|
||||||
<p className="max-w-[16em] font-display text-[15px] leading-6 text-[rgba(244,242,237,.72)]">
|
<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]">
|
||||||
Site da SEV Moda Íntima, como aparece no celular.
|
<span className="flex flex-col">
|
||||||
</p>
|
<span className="font-semibold">Site da {SMALL_SCREEN_TITLE}</span>{" "}
|
||||||
</div>
|
<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 { Faq, type FaqEntry } from "./Faq";
|
||||||
import { textLink } from "./button-styles";
|
|
||||||
|
|
||||||
export interface FaqItem {
|
export type FaqItem = FaqEntry;
|
||||||
question: string;
|
|
||||||
/** Resposta vazia = pergunta preparada, que ainda não é exibida. */
|
|
||||||
answer: string;
|
|
||||||
link?: { href: string; label: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perguntas da home. As cinco primeiras só dizem o que o site já sustenta (contato, rodapé, /ia) ou o que o dono
|
* 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: "" },
|
{ 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[] }) {
|
export default function HomeFaq({ items = FAQ_ITEMS }: { items?: FaqItem[] }) {
|
||||||
const answered = items.filter((item) => item.answer.trim() !== "");
|
return <Faq items={items} />;
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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 { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
import HoverPrefetchLink from "./HoverPrefetchLink";
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
|
jest.mock("next/navigation", () => ({ usePathname: jest.fn() }));
|
||||||
|
|
||||||
jest.mock("next/link", () => ({
|
jest.mock("next/link", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: ({
|
default: ({
|
||||||
@@ -19,6 +22,10 @@ jest.mock("next/link", () => ({
|
|||||||
|
|
||||||
const link = () => screen.getByRole("link", { name: "Cases" });
|
const link = () => screen.getByRole("link", { name: "Cases" });
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.mocked(usePathname).mockReturnValue("/");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not prefetch when the page opens", () => {
|
it("does not prefetch when the page opens", () => {
|
||||||
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
|
render(<HoverPrefetchLink href="/cases">Cases</HoverPrefetchLink>);
|
||||||
|
|
||||||
@@ -79,3 +86,95 @@ it("only prefetches the link that was reached, not its siblings", () => {
|
|||||||
"false",
|
"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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import * as navigation from "next/navigation";
|
||||||
import { type ComponentProps, useState } from "react";
|
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).
|
* 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
|
* 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.
|
* 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.
|
* 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({
|
export default function HoverPrefetchLink({
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
@@ -15,12 +36,15 @@ export default function HoverPrefetchLink({
|
|||||||
onTouchStart,
|
onTouchStart,
|
||||||
...props
|
...props
|
||||||
}: Omit<ComponentProps<typeof Link>, "prefetch">) {
|
}: Omit<ComponentProps<typeof Link>, "prefetch">) {
|
||||||
|
const pathname = usePathname();
|
||||||
const [active, setActive] = useState(false);
|
const [active, setActive] = useState(false);
|
||||||
|
const homeAnchorFromElsewhere =
|
||||||
|
pathname !== null && pathname !== "/" && isHomeAnchor(props.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
{...props}
|
{...props}
|
||||||
prefetch={active ? null : false}
|
prefetch={active && !homeAnchorFromElsewhere ? null : false}
|
||||||
onMouseEnter={(event) => {
|
onMouseEnter={(event) => {
|
||||||
setActive(true);
|
setActive(true);
|
||||||
onMouseEnter?.(event);
|
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 type { ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
getFeaturedProjects,
|
getFeaturedProjects,
|
||||||
@@ -7,6 +7,23 @@ import {
|
|||||||
import { isClientProject } from "@/lib/proof";
|
import { isClientProject } from "@/lib/proof";
|
||||||
import Projects from "./Projects";
|
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", () => ({
|
jest.mock("./Section", () => ({
|
||||||
Section: ({ children, title }: { children: ReactNode; title: string }) => (
|
Section: ({ children, title }: { children: ReactNode; title: string }) => (
|
||||||
<section>
|
<section>
|
||||||
@@ -74,11 +91,20 @@ describe("Projects", () => {
|
|||||||
it("ends with a secondary link to all the cases", () => {
|
it("ends with a secondary link to all the cases", () => {
|
||||||
render(<Projects projects={clientProjects} />);
|
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");
|
expect(link).toHaveAttribute("href", "/cases");
|
||||||
// secundário: contorno, sem o fundo verde-limão do contato
|
// secundário: contorno, sem o fundo verde-limão do contato
|
||||||
expect(link.className).toContain("border");
|
expect(link.className).toContain("border");
|
||||||
expect(link.className).not.toContain("bg-[var(--slash)]");
|
expect(link.className).not.toContain("bg-[var(--slash)]");
|
||||||
expect(screen.getAllByRole("link")).toEqual([link]);
|
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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||||
import { secondaryButtonDark } from "./button-styles";
|
import { secondaryButtonDark } from "./button-styles";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
import { ProjectReel } from "./showcase/ProjectReel";
|
import { ProjectReel } from "./showcase/ProjectReel";
|
||||||
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
|
import { ShowcaseProvider } from "./showcase/ShowcaseProvider";
|
||||||
@@ -27,9 +27,9 @@ export default function Projects({
|
|||||||
</ShowcaseProvider>
|
</ShowcaseProvider>
|
||||||
|
|
||||||
<div className="mt-sp-8" data-reel-stagger="left">
|
<div className="mt-sp-8" data-reel-stagger="left">
|
||||||
<Link href="/cases" className={secondaryButtonDark}>
|
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||||
Ver todos os cases
|
Ver os cases
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("Testimonials", () => {
|
|||||||
it("path: a testimonial with a project -> the case link points to that project", () => {
|
it("path: a testimonial with a project -> the case link points to that project", () => {
|
||||||
render(<Testimonials items={[{ ...base, projectId: "sc-plus" }]} />);
|
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",
|
"href",
|
||||||
"/cases/sc-plus",
|
"/cases/sc-plus",
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ it("shows the quote, who said it and a link to the case", () => {
|
|||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
expect(screen.getByText("Maria Souza")).toBeInTheDocument();
|
expect(screen.getByText("Maria Souza")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Sócia, Empresa de Teste")).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",
|
"href",
|
||||||
"/cases/lavacao-047",
|
"/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 }]} />);
|
render(<Testimonials items={[{ ...item, projectId: undefined }]} />);
|
||||||
|
|
||||||
expect(screen.getByText("MS")).toBeInTheDocument();
|
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 Image from "next/image";
|
||||||
import Link from "next/link";
|
|
||||||
import type { Testimonial } from "@/lib/proof";
|
import type { Testimonial } from "@/lib/proof";
|
||||||
import { textLink } from "./button-styles";
|
import { textLink } from "./button-styles";
|
||||||
|
import HoverPrefetchLink from "./HoverPrefetchLink";
|
||||||
|
|
||||||
function initials(name: string) {
|
function initials(name: string) {
|
||||||
return name
|
return name
|
||||||
@@ -67,12 +67,12 @@ export default function Testimonials({
|
|||||||
{item.role}, {item.company}
|
{item.role}, {item.company}
|
||||||
</p>
|
</p>
|
||||||
{item.projectId && (
|
{item.projectId && (
|
||||||
<Link
|
<HoverPrefetchLink
|
||||||
href={`/cases/${item.projectId}`}
|
href={`/cases/${item.projectId}`}
|
||||||
className={`${textLink} !min-h-0 text-[var(--ink-80)]`}
|
className={`${textLink} !min-h-0 text-[var(--ink-80)]`}
|
||||||
>
|
>
|
||||||
Ver o case
|
Ver case
|
||||||
</Link>
|
</HoverPrefetchLink>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</figcaption>
|
</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 { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const [site] = Array.from(container.querySelectorAll("article"));
|
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(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 { 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");
|
const siteImage = within(site).getByRole("img");
|
||||||
expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp");
|
expect(original(siteImage)).toBe("/videos/lavacao-047/desktop.webp");
|
||||||
expect(siteImage).toHaveAccessibleName(
|
expect(siteImage).toHaveAccessibleName(
|
||||||
@@ -133,27 +142,72 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
"(min-width: 1024px) 33vw, 100vw",
|
"(min-width: 1024px) 33vw, 100vw",
|
||||||
);
|
);
|
||||||
expect(siteImage).toHaveAttribute("loading", "lazy");
|
expect(siteImage).toHaveAttribute("loading", "lazy");
|
||||||
expect(original(within(system).getByRole("img"))).toBe(
|
// a vitrine da SEV é uma loja, não um painel: contradiria o texto do sistema
|
||||||
"/videos/sev-modaintima/desktop.webp",
|
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 { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
const review = Array.from(container.querySelectorAll("article"))[2];
|
const [, system, review] = Array.from(
|
||||||
expect(within(review).queryByRole("img")).toBeNull();
|
container.querySelectorAll("article"),
|
||||||
expect(within(review).getByText("O que você recebe")).toBeInTheDocument();
|
);
|
||||||
for (const item of [
|
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",
|
"As falhas de acesso a dados",
|
||||||
"O que dá para aproveitar",
|
"O que dá para aproveitar",
|
||||||
"O que é melhor refazer",
|
"O que é melhor refazer",
|
||||||
"Um orçamento para pôr em produção",
|
"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", () => {
|
it("path: each button opens the contact form with the project type already chosen, all three with the same filled style", () => {
|
||||||
const { container } = render(<WorkModalities />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
@@ -175,11 +229,11 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
|
|
||||||
const articles = Array.from(container.querySelectorAll("article"));
|
const articles = Array.from(container.querySelectorAll("article"));
|
||||||
expect(
|
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");
|
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||||
expect(
|
expect(
|
||||||
within(articles[1]).getByRole("link", {
|
within(articles[1]).getByRole("link", {
|
||||||
name: "Ver o case SEV Moda Íntima",
|
name: "Ver case SEV Moda Íntima",
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||||
expect(
|
expect(
|
||||||
@@ -189,18 +243,23 @@ describe("WorkModalities: as três ofertas", () => {
|
|||||||
).toHaveAttribute("href", "/ia");
|
).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 />);
|
const { container } = render(<WorkModalities />);
|
||||||
|
|
||||||
expect(screen.queryByText(/Reconhece algum destes cenários/)).toBeNull();
|
expect(screen.queryByText(/Reconhece algum destes cenários/)).toBeNull();
|
||||||
expect(screen.queryByText(/Planilhas demais/)).toBeNull();
|
expect(screen.queryByText(/Planilhas demais/)).toBeNull();
|
||||||
expect(
|
const services = screen.getByRole("link", {
|
||||||
screen.queryByRole("link", { name: "Ver todos os serviços" }),
|
name: "Ver todos os serviços",
|
||||||
).toBeNull();
|
});
|
||||||
for (const link of screen.getAllByRole("link")) {
|
expect(services).toHaveAttribute("href", "/servicos");
|
||||||
expect(link.getAttribute("href")).not.toMatch(/^\/servicos/);
|
|
||||||
}
|
|
||||||
expect(container.querySelectorAll("article")).toHaveLength(3);
|
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(
|
expect(container.textContent).not.toMatch(
|
||||||
/R\$|em contrato|prazo de \d|\d+ dias|grátis|gratuit/i,
|
/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 Image from "next/image";
|
||||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||||
import { primaryButton, textLink } from "./button-styles";
|
import { primaryButton, textLink } from "./button-styles";
|
||||||
@@ -12,11 +13,17 @@ interface Offer {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: 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. */
|
/** Tipo de projeto que o botão deixa marcado no formulário de contato. */
|
||||||
kind: ContactKind;
|
kind: ContactKind;
|
||||||
ctaLabel: string;
|
ctaLabel: string;
|
||||||
/** Case cujo pôster ilustra a oferta; sem ele, a miniatura é o cartão de checklist. */
|
/** Case cujo pôster ilustra a oferta; sem ele, o cartão não tem foto e mostra só o ícone. */
|
||||||
project?: { id: string; title: string };
|
poster?: { id: string; title: string };
|
||||||
|
/** Ícone do cartão sem foto. */
|
||||||
|
icon?: LucideIcon;
|
||||||
moreLabel: string;
|
moreLabel: string;
|
||||||
moreHref: 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
|
* 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.
|
* 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[] = [
|
const OFFERS: Offer[] = [
|
||||||
{
|
{
|
||||||
id: "site",
|
id: "site",
|
||||||
title: "Site que traz pedidos pelo WhatsApp",
|
title: "Site que traz pedidos pelo WhatsApp",
|
||||||
description:
|
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",
|
kind: "site",
|
||||||
ctaLabel: "Começar pelo site",
|
ctaLabel: "Começar pelo site",
|
||||||
project: { id: "lavacao-047", title: "Lavação 047" },
|
poster: { id: "lavacao-047", title: "Lavação 047" },
|
||||||
moreLabel: "Ver o case Lavação 047",
|
moreLabel: "Ver case Lavação 047",
|
||||||
moreHref: "/cases/lavacao-047",
|
moreHref: "/cases/lavacao-047",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -42,32 +60,41 @@ const OFFERS: Offer[] = [
|
|||||||
title: "Sistema para tirar a operação da planilha",
|
title: "Sistema para tirar a operação da planilha",
|
||||||
description:
|
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.",
|
"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",
|
kind: "sistema",
|
||||||
ctaLabel: "Começar pelo sistema",
|
ctaLabel: "Começar pelo sistema",
|
||||||
project: { id: "sev-modaintima", title: "SEV Moda Íntima" },
|
icon: LayoutDashboard,
|
||||||
moreLabel: "Ver o case SEV Moda Íntima",
|
moreLabel: "Ver case SEV Moda Íntima",
|
||||||
moreHref: "/cases/sev-modaintima",
|
moreHref: "/cases/sev-modaintima",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "revisao",
|
id: "revisao",
|
||||||
title: "Revisão de sistema que já existe, inclusive feito com IA",
|
title: "Revisão de sistema que já existe, inclusive feito com IA",
|
||||||
description:
|
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",
|
kind: "revisao",
|
||||||
ctaLabel: "Começar pela revisão",
|
ctaLabel: "Começar pela revisão",
|
||||||
|
icon: ShieldCheck,
|
||||||
moreLabel: "Ver como funciona a revisão",
|
moreLabel: "Ver como funciona a revisão",
|
||||||
moreHref: "/ia",
|
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 = [
|
const PROCESS = [
|
||||||
{
|
{
|
||||||
title: "Entender a operação",
|
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. */
|
/** Miniatura sem foto: um painel escuro do mesmo tamanho dos pôsteres, só com o ícone da oferta. */
|
||||||
function ReviewChecklist() {
|
function OfferIcon({ icon: Icon }: Readonly<{ icon: LucideIcon }>) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col justify-center gap-sp-3 px-sp-5 py-sp-4 text-white">
|
<div
|
||||||
<p className="font-display text-[15px] font-semibold leading-6 text-white">
|
data-testid="offer-icon"
|
||||||
O que você recebe
|
className="flex h-full items-center justify-center text-white/85"
|
||||||
</p>
|
>
|
||||||
<ul className="flex flex-col gap-sp-2">
|
<Icon aria-hidden="true" className="h-14 w-14" strokeWidth={1.25} />
|
||||||
{REVIEW_CHECKLIST.map((item) => (
|
|
||||||
<li
|
|
||||||
key={item}
|
|
||||||
className="flex items-start gap-sp-3 font-display text-[15px] leading-6 text-white/85"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
viewBox="0 0 16 16"
|
|
||||||
className="mt-1 h-4 w-4 shrink-0 text-white/70"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="m3 8.5 3.2 3L13 4.5" />
|
|
||||||
</svg>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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 }>) {
|
function OfferCard({ offer }: Readonly<{ offer: Offer }>) {
|
||||||
const poster = offer.project
|
const poster = offer.poster
|
||||||
? getShowcaseAssets(offer.project.id, "desktop")
|
? getShowcaseAssets(offer.poster.id, "desktop")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="ds-card cv-press cv-lift relative h-full min-w-0 overflow-hidden">
|
<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]">
|
<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
|
<Image
|
||||||
src={poster.poster}
|
src={poster.poster}
|
||||||
alt={`Site ${offer.project.title} aberto no computador`}
|
alt={`Site ${offer.poster.title} aberto no computador`}
|
||||||
width={poster.width}
|
width={poster.width}
|
||||||
height={poster.height}
|
height={poster.height}
|
||||||
sizes="(min-width: 1024px) 33vw, 100vw"
|
sizes="(min-width: 1024px) 33vw, 100vw"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
className="block h-full w-full object-cover object-top"
|
className="block h-full w-full object-cover object-top"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : offer.icon ? (
|
||||||
<ReviewChecklist />
|
<OfferIcon icon={offer.icon} />
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</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 */}
|
{/* 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>
|
</h3>
|
||||||
|
|
||||||
<p
|
<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"
|
data-reel="right"
|
||||||
>
|
>
|
||||||
{offer.description}
|
{offer.description}
|
||||||
</p>
|
</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
|
<ContactOfferLink
|
||||||
kind={offer.kind}
|
kind={offer.kind}
|
||||||
className={`mt-sp-6 w-full ${primaryButton}`}
|
className={`mt-sp-5 w-full ${primaryButton}`}
|
||||||
>
|
>
|
||||||
{offer.ctaLabel}
|
{offer.ctaLabel}
|
||||||
</ContactOfferLink>
|
</ContactOfferLink>
|
||||||
@@ -197,6 +241,13 @@ export function WorkModalities() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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. */}
|
{/* Lista compacta: o título e a descrição de cada etapa numa linha só, sem a grade de quatro cartões. */}
|
||||||
<div
|
<div
|
||||||
id="process"
|
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" />;
|
return <section data-testid="scene-modalities" />;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
jest.mock("../EngineeringWithAI", () => ({
|
|
||||||
__esModule: true,
|
|
||||||
default: () => <section data-testid="scene-ai" />,
|
|
||||||
}));
|
|
||||||
jest.mock("../HomeFaq", () => ({
|
jest.mock("../HomeFaq", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
default: () => <section data-testid="scene-faq" />,
|
default: () => <section data-testid="scene-faq" />,
|
||||||
@@ -167,7 +163,7 @@ describe("CinematicHome: props e estrutura", () => {
|
|||||||
expect(mockModalitiesProps).toHaveBeenCalledWith({});
|
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={[]} />);
|
render(<CinematicHome projects={[]} />);
|
||||||
|
|
||||||
const main = screen.getByRole("main");
|
const main = screen.getByRole("main");
|
||||||
@@ -178,9 +174,8 @@ describe("CinematicHome: props e estrutura", () => {
|
|||||||
).toEqual([
|
).toEqual([
|
||||||
"scene-hero",
|
"scene-hero",
|
||||||
"scene-projects",
|
"scene-projects",
|
||||||
"scene-about",
|
|
||||||
"scene-modalities",
|
"scene-modalities",
|
||||||
"scene-ai",
|
"scene-about",
|
||||||
"scene-testimonials",
|
"scene-testimonials",
|
||||||
"scene-faq",
|
"scene-faq",
|
||||||
"scene-contact",
|
"scene-contact",
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||||
import type { Testimonial } from "@/lib/proof";
|
import type { Testimonial } from "@/lib/proof";
|
||||||
|
import { buildProfessionalServiceJsonLd } from "@/lib/seo";
|
||||||
import About from "../About";
|
import About from "../About";
|
||||||
import type { ClientLink } from "../ClientStrip";
|
import type { ClientLink } from "../ClientStrip";
|
||||||
import Contact from "../Contact";
|
import Contact from "../Contact";
|
||||||
import EngineeringWithAI from "../EngineeringWithAI";
|
|
||||||
import Hero, { type HeroStats } from "../Hero";
|
import Hero, { type HeroStats } from "../Hero";
|
||||||
import HomeFaq from "../HomeFaq";
|
import HomeFaq from "../HomeFaq";
|
||||||
import Projects from "../Projects";
|
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). */
|
/** 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";
|
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
|
* 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.
|
* 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({
|
export default function CinematicHome({
|
||||||
projects,
|
projects,
|
||||||
@@ -40,22 +53,23 @@ export default function CinematicHome({
|
|||||||
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
|
shownUrl && !/vercel\.app/i.test(shownUrl.text) ? shownUrl : null;
|
||||||
|
|
||||||
return (
|
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.
|
* 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
|
* 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
|
* 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=...",
|
* 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.
|
* 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+/);
|
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 />);
|
render(<AiPage />);
|
||||||
|
|
||||||
const card = screen.getByTestId("ia-review-card");
|
const card = screen.getByTestId("ia-steps-card");
|
||||||
expect(within(card).getByText("O que você recebe")).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
within(card)
|
within(card).getByText("Como funciona em 3 passos"),
|
||||||
.getAllByRole("listitem")
|
).toBeInTheDocument();
|
||||||
.map((item) => item.textContent?.replace(/^\d+/, "")),
|
const steps = within(card).getAllByRole("listitem");
|
||||||
|
expect(steps).toHaveLength(3);
|
||||||
|
expect(
|
||||||
|
steps.map((item) => item.querySelector("strong")?.textContent),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
"As falhas de acesso a dados",
|
"Conversa pelo WhatsApp.",
|
||||||
"O que dá para aproveitar",
|
"Teste e avaliação.",
|
||||||
"O que é melhor refazer",
|
"Relatório e orçamento.",
|
||||||
"Um orçamento para pôr em produção",
|
|
||||||
]);
|
]);
|
||||||
|
// 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
|
// 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");
|
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
|
||||||
expect(hero).toContainElement(card);
|
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)]",
|
"lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.9fr)]",
|
||||||
);
|
);
|
||||||
expect(screen.queryByRole("complementary")).toBeNull();
|
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(
|
expect(
|
||||||
screen.getAllByRole("heading", { name: /O que você recebe/ }),
|
screen.getAllByRole("heading", { name: /O que você recebe/ }),
|
||||||
).toHaveLength(1);
|
).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 />);
|
render(<AiPage />);
|
||||||
|
|
||||||
const hero = screen.getByRole("heading", { level: 1 }).closest("section");
|
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(secondary).toHaveAttribute("href", "#ia-receber");
|
||||||
expect(document.querySelector(`section[id="ia-receber"]`)).not.toBeNull();
|
expect(document.querySelector(`section[id="ia-receber"]`)).not.toBeNull();
|
||||||
for (const link of within(hero as HTMLElement).getAllByRole("link")) {
|
const cases = within(hero as HTMLElement).getByRole("link", {
|
||||||
expect(link.getAttribute("href")).not.toMatch(/^\/cases/);
|
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(
|
expect(
|
||||||
screen.queryByRole("link", { name: "Ver projetos entregues" }),
|
findings.map((finding) => finding.querySelectorAll("dd")[1].textContent),
|
||||||
).toBeNull();
|
).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", () => {
|
it("keeps the incident news collapsed in a <details>, closed by default", () => {
|
||||||
|
|||||||
+113
-6
@@ -5,6 +5,7 @@ import {
|
|||||||
primaryButton,
|
primaryButton,
|
||||||
textLinkDark,
|
textLinkDark,
|
||||||
} from "@/app/components/button-styles";
|
} from "@/app/components/button-styles";
|
||||||
|
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||||
import { safeJsonLd } from "@/lib/jsonld";
|
import { safeJsonLd } from "@/lib/jsonld";
|
||||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
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 = [
|
const RESPONSIBILITIES = [
|
||||||
{
|
{
|
||||||
title: "Quem pode acessar cada dado?",
|
title: "Quem pode acessar cada dado?",
|
||||||
@@ -161,24 +207,27 @@ export default function AiPage() {
|
|||||||
>
|
>
|
||||||
Pedir a revisão do meu app
|
Pedir a revisão do meu app
|
||||||
</WhatsAppLink>
|
</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}>
|
<a href="#ia-receber" className={textLinkDark}>
|
||||||
Ver como a revisão funciona
|
Ver como a revisão funciona
|
||||||
</a>
|
</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>
|
</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"
|
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">
|
<p className="font-display text-xl font-semibold leading-7 text-white">
|
||||||
O que você recebe
|
Como funciona em 3 passos
|
||||||
</p>
|
</p>
|
||||||
<ol className="mt-sp-5 flex flex-col">
|
<ol className="mt-sp-5 flex flex-col">
|
||||||
{DELIVERABLES.map((item, index) => (
|
{STEPS.map((step, index) => (
|
||||||
<li
|
<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"
|
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
|
<span
|
||||||
@@ -188,7 +237,10 @@ export default function AiPage() {
|
|||||||
{String(index + 1).padStart(2, "0")}
|
{String(index + 1).padStart(2, "0")}
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 font-display text-base leading-6 text-white/90">
|
<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>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -230,6 +282,61 @@ export default function AiPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section className="ds-section" aria-labelledby="ia-contratar-title">
|
||||||
<div className="ds-section-shell">
|
<div className="ds-section-shell">
|
||||||
<h2 id="ia-contratar-title" className="ds-section-title max-w-[26ch]">
|
<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 { Header } from "./components/Header";
|
||||||
import "./globals.css";
|
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.
|
// 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";
|
import "./components/showcase/showcase.css";
|
||||||
|
|
||||||
const titleSuffix = " | Evandro.dev.br";
|
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 () => {
|
it("path: any environment -> the video rule keeps the long cache", async () => {
|
||||||
const rules = (await nextConfig.headers?.()) ?? [];
|
const rules = (await nextConfig.headers?.()) ?? [];
|
||||||
const videos = rules.find(({ source }) => source === "/videos/:path*");
|
const videos = rules.find(({ source }) => source === "/videos/:path*");
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ describe("next.config", () => {
|
|||||||
expect(nextConfig.images).not.toHaveProperty("minimumCacheTTL");
|
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 rules = (await nextConfig.headers?.()) ?? [];
|
||||||
const site = rules.find(({ source }) => source === "/(.*)");
|
const site = rules.find(({ source }) => source === "/(.*)");
|
||||||
const names = site?.headers.map(({ key }) => key) ?? [];
|
const names = site?.headers.map(({ key }) => key) ?? [];
|
||||||
|
|||||||
+1
-3
@@ -1,8 +1,5 @@
|
|||||||
import { portfolio } from "./portfolio-data";
|
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 {
|
export interface Testimonial {
|
||||||
quote: string;
|
quote: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,6 +17,7 @@ export interface Testimonial {
|
|||||||
*/
|
*/
|
||||||
export const testimonials: 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 }) {
|
export function isClientProject(project: { kind?: string }) {
|
||||||
return project.kind === "cliente";
|
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", () => {
|
it("cites the case on /cases/<slug>, with the phrase every page uses for a similar project", () => {
|
||||||
expect(whatsAppSubjectForPath("/cases/sev-modaintima", contexts)).toBe(
|
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(
|
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 SERVICES_SUBJECT = "Sites, sistemas e aplicativos";
|
||||||
export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo";
|
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) {
|
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). */
|
/** 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'"]),
|
...(process.env.NODE_ENV === "production" ? [] : ["'unsafe-eval'"]),
|
||||||
"https://www.googletagmanager.com",
|
"https://www.googletagmanager.com",
|
||||||
].join(" ");
|
].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 = [
|
const contentSecurityPolicy = [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
`script-src ${scriptSources}`,
|
`script-src ${scriptSources}`,
|
||||||
"connect-src 'self' https://*.google-analytics.com https://*.analytics.google.com https://*.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",
|
`img-src 'self' data: blob: https://*.google-analytics.com https://www.googletagmanager.com ${googleAdsSources}`,
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"font-src 'self' data:",
|
"font-src 'self' data:",
|
||||||
"frame-ancestors 'none'",
|
"frame-ancestors 'none'",
|
||||||
|
|||||||
Reference in new issue
Block a user