feat(cases): prova certa por serviço, páginas internas com trabalho no topo

Diagnóstico sem case que não cabe; desktop com o GCC Installer como projeto próprio; IoT com o Twins Lab e a nota da Apolo. Mosaico de pôsteres em /servicos e /cases, faixa baixa em uma coluna no currículo, privacidade e laboratório; Laboratório como lista compacta; SEV começando por catálogo, pedido no WhatsApp e painel; Joinville e SC nos títulos; imagens do LCP com fetchPriority e sizes reais; pré-carregamento só no hover; 404 com Open Graph próprio.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:35 -03:00
1 parent 7b633f0f4a
commit de6af74a0c
37 files changed
+1062 -254

No files matched your search

+32 -12
View File
@@ -51,7 +51,8 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
/**
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case.
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido".
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma
* frase nos dois, a do cabeçalho: "um projeto parecido com o {título}".
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const hrefs = screen
@@ -69,9 +70,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
isClient
? `um projeto parecido com o da ${caseStudy.title}`
: `um problema parecido com o do projeto ${caseStudy.title}`,
`um projeto parecido com o ${caseStudy.title}`,
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
@@ -590,16 +589,36 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
clientCaseIds.map((id) => `/cases/${id}`),
);
// laboratório: sem capa
const labLinks = screen.getAllByRole("link", { name: "Ver o projeto" });
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
const labLinks = screen.getAllByRole("link", { name: /^Ver o projeto / });
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`),
);
for (const link of labLinks) {
const card = link.closest("article");
const labList = labLinks[0].closest("ul");
expect(labList?.querySelectorAll(":scope > li")).toHaveLength(
labCaseIds.length,
);
expect(labList?.querySelector("article, .ds-card, .ds-bframe")).toBeNull();
labLinks.forEach((link, index) => {
const row = link.closest("li");
const caseStudy = getCase(labCaseIds[index]);
expect(isBefore(labHeading, link)).toBe(true);
expect(card?.querySelector(".ds-bframe")).toBeNull();
}
expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`);
expect(
within(row as HTMLElement).getByRole("heading", {
level: 3,
name: caseStudy.title,
}),
).toBeVisible();
expect(
within(row as HTMLElement).getByText(caseStudy.subtitle),
).toBeVisible();
expect(
within(row as HTMLElement).getByText("Projeto próprio"),
).toBeVisible();
// sem o resumo longo do cartão antigo
expect(row).not.toHaveTextContent(caseStudy.summary);
});
// contato pelo WhatsApp, sem /#contact nem GitHub
const hrefs = screen
@@ -639,10 +658,11 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
};
const visibleItems = [...clientLinks, ...labLinks].map((link, index) => {
const href = link.getAttribute("href");
const card = link.closest("article");
// cartão do cliente ou linha da lista do laboratório
const card = link.closest("article, li");
if (!card) throw new Error(`Missing visible card for ${href}`);
const expected = getCase((href ?? "").replace("/cases/", ""));
expect(card.querySelector(".ds-kicker")).toHaveTextContent(expected.title);
expect(card).toHaveTextContent(expected.title);
return {
"@type": "ListItem",
position: index + 1,
@@ -4,7 +4,8 @@ import { CaseContactSection } from "./CaseContactSection";
/**
* Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de
* um projeto de laboratório ("problema parecido"), o link para a lista de cases e os serviços ligados.
* um projeto de laboratório ("problema parecido" no botão), o link para a lista de cases e os serviços ligados.
* A mensagem do WhatsApp é a mesma frase nos dois: "um projeto parecido com o {título}".
*/
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
@@ -29,7 +30,7 @@ describe("CaseContactSection", () => {
});
expect(cta).toHaveAttribute("target", "_blank");
expect(messageOf(cta)).toContain(
`Quero conversar sobre um projeto parecido com o da ${sev.title}.`,
`Quero conversar sobre um projeto parecido com o ${sev.title}.`,
);
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
expect(
@@ -49,9 +50,11 @@ describe("CaseContactSection", () => {
const cta = screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
});
// a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome
expect(messageOf(cta)).toContain(
`Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`,
`Quero conversar sobre um projeto parecido com o ${nutri.title}.`,
);
expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
+9 -8
View File
@@ -1,24 +1,25 @@
import Link from "next/link";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import type { CaseStudyContent } from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import { CaseRelatedServices } from "./CaseRelatedServices";
/** Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido. */
/**
* Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido.
* O assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`.
*/
const contactCopy = {
client: {
title: "Quer um projeto parecido com este?",
action: "Conversar sobre um projeto parecido",
subject: (title: string) => `um projeto parecido com o da ${title}`,
},
lab: {
title: "Fale sobre o seu caso",
action: "Tem um problema parecido? Vamos conversar.",
subject: (title: string) =>
`um problema parecido com o do projeto ${title}`,
},
} as const;
@@ -47,15 +48,15 @@ export function CaseContactSection({
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={copy.subject(caseStudy.title)}
service={similarProjectSubject(caseStudy.title)}
placement="case_detail"
className={primaryButton}
>
{copy.action}
</WhatsAppLink>
<Link href="/cases" className={secondaryButtonDark}>
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
Ver outros cases
</Link>
</HoverPrefetchLink>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
+15 -5
View File
@@ -1,11 +1,11 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CaseCover } from "./CaseCover";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
* e os valores padrão de `sizes` e `priority`.
* e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
@@ -49,16 +49,26 @@ describe("CaseCover", () => {
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
});
it("path: sizes and priority given -> uses them", () => {
it("path: sizes and priority given -> uses them, with eager loading and high fetch priority", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).not.toHaveAttribute("loading", "lazy");
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no 100vw sizes -> every preset describes the width the cover really has", () => {
// 100vw baixava a imagem de 750 px para uma capa de 300 px no celular
for (const sizes of Object.values(coverSizes)) {
expect(sizes).not.toMatch(/(^|, )100vw$/);
expect(sizes).toMatch(/calc\(100vw - \d+px\)$/);
}
});
});
+32 -3
View File
@@ -5,12 +5,40 @@ import {
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */
/**
* Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo
* (com 100vw, um celular de 390 px pedia a versão de 750 px para uma capa de 300 px). As contas seguem o
* padding das seções (20 px até 768 px; depois 6vw, no máximo 64 px), a coluna central de 1240 px e os cartões
* (borda e 20 a 28 px de padding).
*/
export const coverSizes = {
/** Topo do case e do serviço: coluna da direita a partir de 1024 px, e a largura da página abaixo disso. */
hero: "(min-width: 1440px) 560px, (min-width: 1024px) 40vw, (min-width: 769px) 88vw, calc(100vw - 40px)",
/** Capa dentro de um cartão (`ds-card`) da grade de duas colunas, que vira uma só até 1024 px. */
card: "(min-width: 1440px) 560px, (min-width: 1025px) calc(50vw - 134px), (min-width: 769px) calc(88vw - 56px), calc(100vw - 88px)",
/** Capa solta numa grade de duas colunas, sem cartão em volta (a partir de 768 px). */
column:
"(min-width: 1440px) 604px, (min-width: 1068px) calc(50vw - 80px), (min-width: 768px) calc(44vw - 16px), calc(100vw - 40px)",
} as const;
/**
* Props da imagem que abre a página (a maior acima da dobra): carrega na hora e com prioridade de rede alta.
* No Next 16 a prop `priority` do `Image` só gera o `<link rel="preload">`; a prioridade de rede vem do `fetchPriority`.
*/
export const priorityImageProps = {
loading: "eager",
fetchPriority: "high",
} as const;
/**
* Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada.
* `priority` é para a imagem que abre a página: aplica `priorityImageProps`.
*/
export function CaseCover({
id,
title,
demoUrl,
sizes = "(min-width: 1024px) 560px, 100vw",
sizes = coverSizes.card,
priority = false,
}: {
id: string;
@@ -21,6 +49,7 @@ export function CaseCover({
}) {
const media = getCaseMedia(id, "desktop");
if (!media) return null;
const priorityProps = priority ? priorityImageProps : {};
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
@@ -30,7 +59,7 @@ export function CaseCover({
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
priority={priority}
{...priorityProps}
className="object-cover object-top"
/>
</div>
@@ -1,11 +1,13 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { coverSizes } from "./CaseCover";
import { CaseCoverLink } from "./CaseCoverLink";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado,
* com a capa dentro), os valores padrão de `sizes`, `priority` e `prefetch`, e os valores dados.
* com a capa dentro), os valores padrão de `sizes` e `priority`, o pré-carregamento só ao chegar no link
* e os valores dados.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
@@ -61,32 +63,34 @@ describe("CaseCoverLink", () => {
).toBeInTheDocument();
});
it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => {
it("path: sizes and priority omitted -> default sizes and lazy image", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCoverLink {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", coverSizes.card);
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
});
it("path: link -> pre-fetches the case only when the pointer or focus reaches it", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCoverLink {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
expect(image).toHaveAttribute("loading", "lazy");
expect(container.querySelector("a")).toHaveAttribute(
"data-prefetch",
"undefined",
);
const link = container.querySelector("a");
expect(link).toHaveAttribute("data-prefetch", "false");
fireEvent.mouseEnter(link as HTMLAnchorElement);
expect(link).toHaveAttribute("data-prefetch", "null");
});
it("path: sizes, priority and prefetch given -> all of them are used", () => {
it("path: sizes and priority given -> both are used", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<CaseCoverLink {...props} sizes="320px" priority prefetch={false} />,
);
render(<CaseCoverLink {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).not.toHaveAttribute("loading", "lazy");
expect(container.querySelector("a")).toHaveAttribute(
"data-prefetch",
"false",
);
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
+6 -8
View File
@@ -1,34 +1,32 @@
import Link from "next/link";
import { CaseCover } from "./CaseCover";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação,
* porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada.
* A rota do case só é pré-carregada quando a pessoa chega no link (HoverPrefetchLink).
*/
export function CaseCoverLink({
id,
title,
demoUrl,
sizes = "(min-width: 1024px) 560px, 100vw",
sizes = coverSizes.card,
priority = false,
prefetch,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
prefetch?: boolean;
}) {
if (!getCaseMedia(id, "desktop")) return null;
return (
<Link
<HoverPrefetchLink
href={`/cases/${id}`}
aria-hidden="true"
tabIndex={-1}
prefetch={prefetch}
className="mb-sp-5 block"
>
<CaseCover
@@ -38,6 +36,6 @@ export function CaseCoverLink({
sizes={sizes}
priority={priority}
/>
</Link>
</HoverPrefetchLink>
);
}
@@ -3,6 +3,7 @@ import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseHero } from "./CaseHero";
import { getCaseMedia } from "./case-media";
import { compactHero } from "./hero-styles";
/**
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
@@ -77,6 +78,27 @@ describe("CaseHero: captura gravada e tipo de case", () => {
expect(screen.getByText(lab.summary)).toBeVisible();
});
it("path: with a capture -> the top strip keeps the default height", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector("section")).not.toHaveClass(compactHero);
});
it("path: without a capture -> the top strip is the lower one-column strip, for client and laboratory cases", () => {
withCapture(false);
for (const [caseStudy, isClient] of [
[client, true],
[lab, false],
] as const) {
const { container, unmount } = render(
<CaseHero caseStudy={caseStudy} isClient={isClient} />,
);
expect(container.querySelector("section")).toHaveClass(compactHero);
unmount();
}
});
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
withCapture(false);
render(<CaseHero caseStudy={client} isClient />);
+9 -4
View File
@@ -1,8 +1,9 @@
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import type { CaseStudyContent } from "@/lib/site-content";
import { CaseCover } from "./CaseCover";
import { CaseCover, coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { compactHero } from "./hero-styles";
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
function CaseFacts({ facts }: { facts?: string[] }) {
@@ -28,7 +29,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
sizes={coverSizes.hero}
priority
/>
</div>
@@ -37,7 +38,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
/**
* Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita;
* no celular a mídia vem logo abaixo do título.
* no celular a mídia vem logo abaixo do título. Sem captura, o topo é uma coluna só e mais baixo.
*/
export function CaseHero({
caseStudy,
@@ -49,7 +50,11 @@ export function CaseHero({
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
return (
<section className={`ds-section ds-section-dark ${pageTop}`}>
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMedia ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${
hasMedia
+3 -1
View File
@@ -14,12 +14,14 @@ const deviceLabel: Record<Device, string> = {
* Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão:
* antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador.
* Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho.
* `sizes` segue a grade de CaseMedia: a coluna da esquerda de um bloco de até 1040 px (88 vw da página abaixo de 1182 px)
* com a moldura do celular ao lado (220 px mais 32 px de vão); abaixo de 768 px, a largura da página menos 40 px.
*/
export function CasePlayer({
assets,
title,
device,
sizes = "(min-width: 1280px) 900px, 100vw",
sizes = "(min-width: 1182px) 788px, (min-width: 768px) calc(88vw - 252px), calc(100vw - 40px)",
}: {
assets: ShowcaseAssets;
title: string;
@@ -1,5 +1,5 @@
import Link from "next/link";
import { textLinkDark } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { getRelatedServices } from "@/lib/site-content";
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
@@ -19,12 +19,12 @@ export function CaseRelatedServices({ caseId }: { caseId: string }) {
<article className="ds-card w-full">
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
<p className="ds-copy">{service.intro}</p>
<Link
<HoverPrefetchLink
className={`${textLinkDark} mt-sp-4 self-start`}
href={`/servicos/${service.slug}`}
>
Ver o serviço: {service.navLabel}
</Link>
</HoverPrefetchLink>
</article>
</li>
))}
+108
View File
@@ -0,0 +1,108 @@
import { render } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { coverSizes } from "./CaseCover";
import { getCaseMedia } from "./case-media";
import { getMosaicPosters, MOSAIC_SIZE, PosterMosaic } from "./PosterMosaic";
/**
* Mosaico de pôsteres do topo de /servicos e /cases: quais pôsteres entram (só os com captura, no máximo três),
* e como cada imagem carrega (a primeira, a maior, na hora e com prioridade alta; as outras só quando aparecem).
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("getMosaicPosters", () => {
it("path: every id has a capture -> keeps the order and stops at three", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const posters = getMosaicPosters(["a", "b", "c", "d"]);
expect(MOSAIC_SIZE).toBe(3);
expect(posters).toEqual([
{ id: "a", poster: "/videos/a/desktop.webp" },
{ id: "b", poster: "/videos/b/desktop.webp" },
{ id: "c", poster: "/videos/c/desktop.webp" },
]);
});
it("path: some ids have no capture -> skips them and fills with the next ones", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === "b" ? undefined : getShowcaseAssets(id, device),
);
expect(getMosaicPosters(["a", "b", "c", "d"]).map(({ id }) => id)).toEqual([
"a",
"c",
"d",
]);
expect(mockedGetCaseMedia).toHaveBeenCalledWith("b", "desktop");
});
it("path: no id has a capture -> an empty list", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
expect(getMosaicPosters(["a", "b"])).toEqual([]);
});
});
describe("PosterMosaic", () => {
const three = ["a", "b", "c"].map((id) => ({
id,
poster: `/videos/${id}/desktop.webp`,
}));
it("path: no posters -> renders nothing", () => {
const { container } = render(<PosterMosaic posters={[]} />);
expect(container).toBeEmptyDOMElement();
});
it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => {
const { container } = render(<PosterMosaic posters={three} />);
expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true");
const images = Array.from(container.querySelectorAll("img"));
expect(images).toHaveLength(3);
for (const image of images) {
expect(image).toHaveAttribute("alt", "");
}
const [main, ...side] = images;
expect(main).toHaveAttribute("loading", "eager");
expect(main).toHaveAttribute("fetchpriority", "high");
expect(main).toHaveAttribute("sizes", coverSizes.hero);
expect(main.closest("div")).toHaveClass("col-span-2");
for (const image of side) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
// metade da coluna, e nunca a largura toda da tela
expect(image.getAttribute("sizes")).toBe(
"(min-width: 1440px) 274px, 20vw",
);
}
});
it("path: the smaller posters are hidden below 1024 px, so they are not downloaded on a phone", () => {
const { container } = render(<PosterMosaic posters={three} />);
const [main, ...side] = Array.from(container.querySelectorAll("img")).map(
(image) => image.closest("div"),
);
expect(main).not.toHaveClass("hidden");
for (const frame of side) {
expect(frame).toHaveClass("hidden", "lg:block");
}
});
it("path: a single poster -> only the main one, with no side posters", () => {
const { container } = render(<PosterMosaic posters={three.slice(0, 1)} />);
expect(container.querySelectorAll("img")).toHaveLength(1);
});
});
+68
View File
@@ -0,0 +1,68 @@
import Image from "next/image";
import { coverSizes, priorityImageProps } from "./CaseCover";
import { getCaseMedia } from "./case-media";
export interface MosaicPoster {
id: string;
poster: string;
}
/** Quantos pôsteres entram no mosaico: um grande em cima e dois menores embaixo. */
export const MOSAIC_SIZE = 3;
/**
* Pôsteres gravados dos cases, na ordem dada, só dos que têm captura no disco (no máximo `MOSAIC_SIZE`).
* Roda no servidor, como `getCaseMedia`.
*/
export function getMosaicPosters(ids: readonly string[]): MosaicPoster[] {
return ids
.flatMap((id) => {
const media = getCaseMedia(id, "desktop");
return media ? [{ id, poster: media.poster }] : [];
})
.slice(0, MOSAIC_SIZE);
}
const frame =
"relative aspect-[16/10] overflow-hidden border border-white/15 bg-[#0a0a0a]";
/**
* Mosaico de pôsteres dos sites no topo das páginas internas, na coluna que ficaria vazia ao lado do título.
* É decorativo (texto alternativo vazio): quem lê os cases tem os links logo abaixo. O primeiro pôster é o maior
* e o que abre a página, então carrega na hora e com prioridade alta; no celular só ele aparece, e os outros dois
* (que não são baixados enquanto estão ocultos) entram a partir de 1024 px.
*/
export function PosterMosaic({
posters,
}: {
posters: readonly MosaicPoster[];
}) {
const [main, ...others] = posters;
if (!main) return null;
return (
<div className="grid min-w-0 grid-cols-2 gap-sp-3" aria-hidden="true">
<div className={`${frame} col-span-2`}>
<Image
src={main.poster}
alt=""
fill
sizes={coverSizes.hero}
{...priorityImageProps}
className="object-cover object-top"
/>
</div>
{others.map(({ id, poster }) => (
<div key={id} className={`${frame} hidden lg:block`}>
<Image
src={poster}
alt=""
fill
sizes="(min-width: 1440px) 274px, 20vw"
className="object-cover object-top"
/>
</div>
))}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
/**
* Faixa de topo de uma página interna que não tem imagem ao lado: uma coluna só e mais baixa que a padrão.
* O "!" vence o CSS sem camada de globals.css (.ds-section: 72 a 112 px de folga em cima e embaixo).
* Em cima fica a altura do cabeçalho fixo mais um respiro, para a trilha não ficar por baixo dele; embaixo, 48 px.
* No celular vale o padding de `pageTop` (button-styles.ts) e o padrão de baixo.
*/
export const compactHero =
"md:!pt-[calc(var(--site-header-height)+var(--sp-10))] md:!pb-sp-12";
/**
* Topo com imagem: texto à esquerda e a mídia à direita a partir de 1024 px (1,1 parte para 1), uma coluna abaixo disso.
* Os `sizes` das imagens (CaseCover e PosterMosaic) seguem esta grade.
*/
export const heroWithMediaGrid =
"grid items-center gap-y-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16";
+21 -7
View File
@@ -4,8 +4,8 @@ import { clientCases } from "@/lib/site-content";
import CasesPage from "./page";
/**
* Carregamento da lista de cases: a primeira capa (a maior imagem acima da dobra) tem prioridade,
* e os links das capas não pré-carregam as páginas dos cases sozinhos.
* Carregamento da lista de cases: o primeiro pôster do mosaico do topo (a maior imagem acima da dobra) carrega na hora
* e com prioridade alta, as capas dos cartões são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
*/
type LinkProps = ComponentProps<typeof import("next/link").default>;
@@ -40,14 +40,28 @@ function coverImages(container: HTMLElement) {
}
describe("CasesPage: carregamento das capas", () => {
it("loads the first cover with priority and the others lazily", () => {
it("loads the first mosaic poster at once with high priority, and every other image lazily", () => {
const { container } = render(<CasesPage />);
const [first, ...others] = coverImages(container);
expect(first).not.toHaveAttribute("loading", "lazy");
expect(others.length).toBeGreaterThan(0);
for (const image of others) {
const mosaic = Array.from(
container.querySelectorAll<HTMLImageElement>("section [aria-hidden] img"),
).filter((image) => image.getAttribute("alt") === "");
const [main, ...side] = mosaic;
expect(main).toHaveAttribute("loading", "eager");
expect(main).toHaveAttribute("fetchpriority", "high");
// a largura real do pôster, e não 100vw (que baixava 750 px para 330 px)
expect(main.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
expect(side.length).toBeGreaterThan(0);
for (const image of side) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
}
const covers = coverImages(container);
expect(covers.length).toBeGreaterThan(0);
for (const image of covers) {
expect(image).toHaveAttribute("loading", "lazy");
expect(image).not.toHaveAttribute("fetchpriority");
}
});
+50
View File
@@ -63,3 +63,53 @@ describe("CasesPage: cases de cliente", () => {
);
});
});
describe("CasesPage: topo com mosaico de pôsteres", () => {
it("path: captures found -> two columns with three posters of the other clients, not the first card's", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(hero?.className).not.toContain("md:!pb-sp-12");
const posters = Array.from(hero?.querySelectorAll("img") ?? []).map(
(image) => decodeURIComponent(image.getAttribute("src") ?? ""),
);
expect(posters).toHaveLength(3);
expect(posters.join(" ")).not.toContain(clientCases[0].id);
for (const [index, { id }] of clientCases.slice(1).entries()) {
expect(posters[index]).toContain(`/videos/${id}/desktop.webp`);
}
});
it("path: no capture for the other clients -> no mosaic, a lower one-column strip, and the first card cover takes the priority", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
id === clientCases[0].id ? actualGetCaseMedia(id, device) : undefined,
);
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toBeNull();
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
expect(hero?.className).toContain("md:!pb-sp-12");
const firstCover = clientCard(container, clientCases[0].id).querySelector(
".ds-bframe img",
);
expect(firstCover).toHaveAttribute("loading", "eager");
expect(firstCover).toHaveAttribute("fetchpriority", "high");
});
});
describe("CasesPage: prioridade da capa do primeiro cartão", () => {
it("path: mosaic at the top -> the first card cover stays lazy, the priority is the mosaic's first poster", () => {
const { container } = render(<CasesPage />);
const hero = container.querySelector("section");
expect(hero?.querySelector("img")).toHaveAttribute("loading", "eager");
for (const { id } of clientCases) {
const cover = clientCard(container, id).querySelector(".ds-bframe img");
expect(cover).toHaveAttribute("loading", "lazy");
expect(cover).not.toHaveAttribute("fetchpriority");
}
});
});
+58 -37
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
@@ -8,6 +7,7 @@ import {
secondaryButtonLight,
textLink,
} from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
@@ -17,7 +17,10 @@ import {
labCases,
siteUrl,
} from "@/lib/site-content";
import { CASES_SUBJECT } from "@/lib/whatsapp";
import { CaseCoverLink } from "./_components/CaseCoverLink";
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
import { getMosaicPosters, PosterMosaic } from "./_components/PosterMosaic";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: sites e sistemas para empresas";
@@ -36,40 +39,33 @@ export const metadata: Metadata = {
}),
};
function CaseCard({
/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */
function ClientCaseCard({
caseStudy,
withCover,
ctaLabel,
priority = false,
}: {
caseStudy: CaseStudyContent;
withCover: boolean;
ctaLabel: string;
/** Capa que abre a página (a primeira, o maior elemento visível): carrega antes das demais. */
/** Capa que abre a página: só quando não há mosaico no topo, a capa passa a ser a maior imagem visível. */
priority?: boolean;
}) {
return (
<article className="ds-card !h-auto">
{withCover ? (
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
prefetch={false}
/>
) : null}
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
/>
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<Link
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
prefetch={false}
className={secondaryButtonLight}
>
{ctaLabel}
</Link>
Ver o case
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
@@ -85,7 +81,32 @@ function CaseCard({
);
}
/**
* Uma linha por projeto do laboratório: nome, o que é, a marca "Projeto próprio" e o link. Sem capa nem resumo, para a lista
* ocupar pouco espaço (no celular, o nome e a marca ficam na primeira linha).
*/
function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
return (
<li className="grid grid-cols-[minmax(0,1fr)_auto] items-baseline gap-x-sp-6 gap-y-sp-1 py-sp-4 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto_auto]">
<h3 className="text-base font-semibold leading-6">{caseStudy.title}</h3>
<p className="ds-kicker justify-self-end md:order-3">Projeto próprio</p>
<p className="col-span-2 text-[15px] leading-6 text-[var(--ink-80)] md:order-2 md:col-span-1">
{caseStudy.subtitle}
</p>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`}
>
Ver o projeto<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
</li>
);
}
export default function CasesPage() {
// o mosaico mostra os outros clientes: o primeiro já aparece, com a capa, no primeiro cartão logo abaixo
const posters = getMosaicPosters(clientCases.slice(1).map(({ id }) => id));
const hasMosaic = posters.length > 0;
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -116,8 +137,14 @@ export default function CasesPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasMosaic ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${hasMosaic ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<Breadcrumb
items={[
@@ -133,6 +160,7 @@ export default function CasesPage() {
{pageDescription}
</p>
</div>
<PosterMosaic posters={posters} />
</div>
</section>
@@ -143,12 +171,10 @@ export default function CasesPage() {
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy, index) => (
<CaseCard
<ClientCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover
ctaLabel="Ver o case"
priority={index === 0}
priority={index === 0 && !hasMosaic}
/>
))}
</div>
@@ -163,16 +189,11 @@ export default function CasesPage() {
<p className="ds-copy max-w-[64ch]">
Projetos pessoais e de estudo, feitos para praticar e testar ideias.
</p>
<div className="ds-grid-2 mt-sp-8 items-start">
<ul className="mt-sp-6 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{labCases.map((caseStudy) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover={false}
ctaLabel="Ver o projeto"
/>
<LabCaseRow key={caseStudy.id} caseStudy={caseStudy} />
))}
</div>
</ul>
</div>
</section>
@@ -187,15 +208,15 @@ export default function CasesPage() {
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service="um projeto parecido com os cases do portfólio"
service={CASES_SUBJECT}
placement="cases_hub_footer"
className={primaryButton}
>
Conversar sobre meu projeto
</WhatsAppLink>
<Link href="/servicos" className={secondaryButtonDark}>
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
Ver os serviços
</Link>
</HoverPrefetchLink>
</div>
</div>
</section>