feat(cases): mídia no topo, títulos de busca curtos e CreativeWork

Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 14:15:25 -03:00
1 parent c9a0b80656
commit e80f73d075
52 files changed
+2593 -439

No files matched your search

@@ -1,15 +1,50 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { isValidElement } from "react";
import { OG_SIZE, OgCard } from "@/lib/og-card";
import {
OG_IMAGE_MAX_BYTES,
OG_SIZE,
OgCard,
readProjectOgImage,
} from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
import Image from "./opengraph-image";
import Image, { generateImageMetadata } from "./opengraph-image";
/**
* Testes de caminhos-base de Image (imagem de compartilhamento do case):
* slug desconhecido, id da imagem diferente do case, case de cliente e projeto de laboratório.
* slug desconhecido, id da imagem diferente do case, case com capa gravada (entregue como está, em JPEG,
* abaixo de 150 KB), capa pesada demais (cartão simples) e projeto de laboratório.
*/
/** O jsdom não tem `Response`; a rota roda no Node e devolve uma. Aqui basta guardar o corpo e os cabeçalhos. */
class FakeResponse {
constructor(
readonly body: Uint8Array,
readonly init: { headers: Record<string, string> },
) {}
}
beforeAll(() => {
Object.assign(globalThis, { Response: FakeResponse });
});
jest.mock("@/lib/og-card", () => {
const actual = jest.requireActual("@/lib/og-card");
return { ...actual, readProjectOgImage: jest.fn(actual.readProjectOgImage) };
});
const mockedReadProjectOgImage = jest.mocked(readProjectOgImage);
const actualReadProjectOgImage =
jest.requireActual<typeof import("@/lib/og-card")>(
"@/lib/og-card",
).readProjectOgImage;
afterEach(() => {
mockedReadProjectOgImage.mockImplementation(actualReadProjectOgImage);
});
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
@@ -47,11 +82,32 @@ function sentCard() {
}
describe("opengraph-image Image", () => {
it("path: client case with the matching image id -> card labelled as a client case", async () => {
it("path: case with a recorded cover -> the cover itself, in JPEG, under 150 KB, and no generated card", async () => {
const caseStudy = getCase("sev-modaintima");
await renderImage(caseStudy.id, caseStudy.id);
const response = (await renderImage(
caseStudy.id,
caseStudy.id,
)) as unknown as FakeResponse;
expect(notFound).not.toHaveBeenCalled();
expect(mockedImageResponse).not.toHaveBeenCalled();
expect(response).toBeInstanceOf(FakeResponse);
expect(response.init.headers["Content-Type"]).toBe("image/jpeg");
const cover = readFileSync(
join(process.cwd(), "public/videos/sev-modaintima/og.jpg"),
);
expect(Buffer.from(response.body).equals(cover)).toBe(true);
expect(response.body.length).toBeLessThan(150_000);
});
it("path: cover that weighs 150 KB or more -> the plain generated card, with no background", async () => {
const caseStudy = getCase("sev-modaintima");
mockedReadProjectOgImage.mockReturnValue(undefined);
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
const response = await renderImage(caseStudy.id, caseStudy.id);
expect(response).not.toBeInstanceOf(FakeResponse);
expect(mockedImageResponse).toHaveBeenCalledTimes(1);
const { card, options } = sentCard();
expect(card.type).toBe(OgCard);
@@ -60,10 +116,31 @@ describe("opengraph-image Image", () => {
title: caseStudy.title,
description: caseStudy.summary,
});
expect(card.props.backgroundImage).toMatch(/^data:image\/jpeg;base64,/);
expect(card.props.backgroundImage).toBeUndefined();
expect(options).toEqual(OG_SIZE);
});
it("path: image metadata -> JPEG for covers, PNG for generated cards, and every case listed without a slug", () => {
const [sev] = generateImageMetadata({ params: { slug: "sev-modaintima" } });
const [twins] = generateImageMetadata({ params: { slug: "twins-lab" } });
expect(sev).toMatchObject({
id: "sev-modaintima",
contentType: "image/jpeg",
size: OG_SIZE,
});
expect(twins).toMatchObject({
id: "twins-lab",
contentType: "image/png",
size: OG_SIZE,
});
const all = generateImageMetadata({ params: {} });
expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id));
expect(
all.filter(({ contentType }) => contentType === "image/jpeg"),
).toHaveLength(5);
});
it("path: unknown slug -> notFound() and no image", async () => {
await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow(
"NEXT_NOT_FOUND",
+16 -3
View File
@@ -2,9 +2,10 @@ import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import {
OG_CONTENT_TYPE,
OG_PHOTO_CONTENT_TYPE,
OG_SIZE,
OgCard,
readProjectOgBackground,
readProjectOgImage,
} from "@/lib/og-card";
import { caseStudies, isClientCase } from "@/lib/site-content";
@@ -12,12 +13,15 @@ function getCaseStudy(slug: string) {
return caseStudies.find(({ id }) => id === slug);
}
// Quem tem a capa gravada (public/videos/<id>/og.jpg, abaixo de 150 KB) a entrega como está, em JPEG: o cartão em PNG com a capa de fundo passava de 270 KB.
function getImageMetadata(caseStudy: (typeof caseStudies)[number]) {
return {
id: caseStudy.id,
alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`,
size: OG_SIZE,
contentType: OG_CONTENT_TYPE,
contentType: readProjectOgImage(caseStudy.id)
? OG_PHOTO_CONTENT_TYPE
: OG_CONTENT_TYPE,
};
}
@@ -46,6 +50,16 @@ export default async function Image({
const caseStudy = getCaseStudy(slug);
if (!caseStudy || imageId !== caseStudy.id) notFound();
const photo = readProjectOgImage(caseStudy.id);
if (photo) {
return new Response(new Uint8Array(photo), {
headers: {
"Content-Type": OG_PHOTO_CONTENT_TYPE,
"Cache-Control": "public, max-age=86400",
},
});
}
return new ImageResponse(
<OgCard
eyebrow={
@@ -53,7 +67,6 @@ export default async function Image({
}
title={caseStudy.title}
description={caseStudy.summary}
backgroundImage={readProjectOgBackground(caseStudy.id)}
/>,
OG_SIZE,
);
@@ -0,0 +1,36 @@
import { render, screen } from "@testing-library/react";
import { caseStudies } from "@/lib/site-content";
import CasePage from "./page";
/**
* Teste de caminhos-base de CasePage: os "fatos do projeto" no topo.
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
*/
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
describe("CasePage: fatos do projeto", () => {
it("path: empty facts list -> no list in the hero, summary stays", async () => {
const caseStudy = getCase("lavacao-047");
caseStudy.facts = [];
try {
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
render(ui);
expect(
screen.queryByRole("list", { name: "Fatos do projeto" }),
).toBeNull();
expect(screen.getByText(caseStudy.summary)).toBeVisible();
} finally {
delete caseStudy.facts;
}
});
});
+15 -6
View File
@@ -66,12 +66,20 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
expect(
screen.getByLabelText(`Vídeo do site ${caseStudy.title} no celular`),
screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
}),
).toBeInTheDocument();
expect(
screen.queryByLabelText(`Vídeo do site ${caseStudy.title} no computador`),
screen.queryByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
}),
).toBeNull();
// sem captura do computador, o topo fica em uma coluna, sem moldura de navegador
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(
container.querySelector("section")?.querySelector(".grid"),
).toBeNull();
expect(container.querySelector(".ds-phone")).not.toBeNull();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
@@ -115,11 +123,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
await renderCase(caseStudy.id);
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
expect(
screen.getByRole("link", { name: "Conversar sobre um projeto parecido" }),
screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
}),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
+261 -58
View File
@@ -1,4 +1,4 @@
import { render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
@@ -49,24 +49,34 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
});
}
/** Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case. */
function expectContactAndNoDeadLinks(caseStudy: { title: string }) {
/**
* 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".
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const isClient = clientCaseIds.includes(caseStudy.id);
const cta = screen.getByRole("link", {
name: "Conversar sobre um projeto parecido",
name: isClient
? "Conversar sobre um projeto parecido"
: "Tem um problema parecido? Vamos conversar.",
});
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
`um projeto parecido com o da ${caseStudy.title}`,
isClient
? `um projeto parecido com o da ${caseStudy.title}`
: `um problema parecido com o do projeto ${caseStudy.title}`,
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
// limão preenchido só no contato: o botão de play e os demais não o usam
expect(cta.className).toContain("bg-[var(--slash)]");
}
it("exposes the eight public cases, the two new client cases among them", async () => {
@@ -80,16 +90,16 @@ it("exposes the eight public cases, the two new client cases among them", async
for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) {
const caseStudy = getCase(id);
const url = `${siteUrl}/cases/${caseStudy.id}`;
const title = `${caseStudy.title}: ${caseStudy.subtitle}`;
const { metaTitle: title, metaDescription: description } = caseStudy;
const metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title,
description: caseStudy.summary,
description,
alternates: { canonical: url },
openGraph: { title, description: caseStudy.summary, url },
twitter: { title, description: caseStudy.summary },
openGraph: { title, description, url },
twitter: { title, description },
});
expect(metadata).not.toHaveProperty("keywords");
}
@@ -101,7 +111,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
const heading = screen.getByRole("heading", { level: 1 });
expect(heading).toHaveTextContent(caseStudy.headline);
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
expect(isBefore(heading, screen.getByText(caseStudy.summary))).toBe(true);
expect(screen.getByText(/Case de cliente/)).toBeVisible();
const sectionNames = [
@@ -130,33 +140,28 @@ it("renders a client case in the sales order, with media, contact and collapsed
// sem dado real, não há seção de resultado
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
// mídia: pôster e vídeo do computador e do celular
const desktopVideo = screen.getByLabelText(
`Vídeo do site ${caseStudy.title} no computador`,
// mídia: o pôster do computador e do celular com um botão de play; nenhum vídeo parado com controles
expect(container.querySelector("video")).toBeNull();
const desktopPlay = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
});
const mobilePlay = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
});
const mediaSection = screen
.getByRole("heading", { level: 2, name: "Como ficou" })
.closest("section");
expect(mediaSection?.querySelector(".ds-bframe")).toContainElement(
desktopPlay,
);
const mobileVideo = screen.getByLabelText(
`Vídeo do site ${caseStudy.title} no celular`,
);
expect(desktopVideo).toHaveAttribute(
"poster",
`/videos/${caseStudy.id}/desktop.webp`,
);
expect(mobileVideo).toHaveAttribute(
"poster",
`/videos/${caseStudy.id}/mobile.webp`,
);
expect(
desktopVideo.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
).toHaveLength(2);
expect(container.querySelector(".ds-bframe")).toContainElement(desktopVideo);
expect(container.querySelector(".ds-phone")).toContainElement(mobileVideo);
expect(mediaSection?.querySelector(".ds-phone")).toContainElement(mobilePlay);
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expectContactAndNoDeadLinks(caseStudy);
// detalhes técnicos: recolhidos, no fim da página
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
const details = container.querySelector("details");
if (!details) throw new Error("Missing technical details");
expect(details).not.toHaveAttribute("open");
@@ -167,11 +172,16 @@ it("renders a client case in the sales order, with media, contact and collapsed
for (const decision of caseStudy.decisions) {
expect(within(details).getByText(decision)).not.toBeVisible();
}
const contactHeading = sectionHeadings[sectionHeadings.length - 1];
expect(isBefore(contactHeading, details)).toBe(true);
const doneSection = sectionHeadings[2].closest("section");
expect(doneSection).toContainElement(details);
expect(isBefore(screen.getByText(caseStudy.deliveries[0]), details)).toBe(
true,
);
expect(isBefore(details, sectionHeadings[3])).toBe(true);
expect(container.querySelectorAll("details")).toHaveLength(1);
const lastSection = container.querySelector("article")?.lastElementChild;
expect(lastSection).toContainElement(details);
expect(
container.querySelector("section[aria-label='Detalhes técnicos']"),
).toBeNull();
// o que saiu: desafio verificável, limites da evidência, fontes e commits
for (const name of [
@@ -186,11 +196,14 @@ it("renders a client case in the sales order, with media, contact and collapsed
expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
expect(graph.map((node) => node["@type"])).toEqual([
"CreativeWork",
"BreadcrumbList",
]);
expect(JSON.stringify(graph)).not.toMatch(
/Article|FAQPage|ProfessionalService|LocalBusiness/,
);
const breadcrumb = graph[0] as {
const breadcrumb = graph[1] as {
itemListElement: Array<{
"@type": string;
position: number;
@@ -216,7 +229,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
@@ -253,7 +266,7 @@ it("renders the two new client cases from the portfolio data", async () => {
}
});
it("labels a laboratory case, skips the media block and links its service", async () => {
it("labels a laboratory case, skips the media block and shows its service as a card", async () => {
const caseStudy = getCase("twins-lab");
const { container } = await renderCase(caseStudy.id);
@@ -263,11 +276,12 @@ it("labels a laboratory case, skips the media block and links its service", asyn
).toBeVisible();
expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull();
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
expect(screen.queryByText(/indispon/i)).toBeNull();
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver serviço relacionado:/,
name: /^Ver o serviço:/,
});
expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual(
getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`),
@@ -276,6 +290,24 @@ it("labels a laboratory case, skips the media block and links its service", asyn
expectContactAndNoDeadLinks(caseStudy);
});
it("turns the contact of a laboratory case into the 'problema parecido' invitation", async () => {
for (const id of labCaseIds) {
const { unmount } = await renderCase(id);
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
}),
).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
expectContactAndNoDeadLinks(getCase(id));
unmount();
}
});
it("shows the author's role and no site button for a client project without a public site", async () => {
const caseStudy = getCase("as-servicos");
expect(caseStudy.demoUrl).toBeNull();
@@ -289,7 +321,9 @@ it("shows the author's role and no site button for a client project without a pu
expect(screen.queryByText(/indispon/i)).toBeNull();
// a capa continua nas molduras
expect(
screen.getByLabelText(`Vídeo do site ${caseStudy.title} no computador`),
screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
}),
).toBeInTheDocument();
});
@@ -327,26 +361,195 @@ it("shows the result section only when the case has real data", async () => {
}
});
it("links every service related to a client case", async () => {
const caseStudy = getCase("sc-plus");
it("shows at most two related services, as cards under the contact button", async () => {
// sev-modaintima tem quatro serviços ligados; só os dois primeiros viram cards
const sev = getCase("sev-modaintima");
const all = getRelatedServices(sev.id);
expect(all.length).toBeGreaterThan(2);
const { unmount } = await renderCase(sev.id);
const cards = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
expect(cards).toHaveLength(2);
expect(cards.map((link) => link.getAttribute("href"))).toEqual(
all.slice(0, 2).map(({ slug }) => `/servicos/${slug}`),
);
for (const link of cards) {
const card = link.closest("article");
expect(card).toHaveClass("ds-card");
expect(
within(card as HTMLElement).getByRole("heading", { level: 3 }),
).toBeVisible();
}
expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
const contact = screen.getByRole("link", {
name: "Conversar sobre um projeto parecido",
});
expect(isBefore(contact, cards[0])).toBe(true);
unmount();
// sc-plus tem dois; as-servicos, um
for (const [id, expected] of [
["sc-plus", 2],
["as-servicos", 1],
] as const) {
const { unmount: unmountCase } = await renderCase(id);
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
expect(links).toHaveLength(expected);
expect(links.map((link) => link.getAttribute("href"))).toEqual(
getRelatedServices(id)
.slice(0, 2)
.map(({ slug }) => `/servicos/${slug}`),
);
expectContactAndNoDeadLinks(getCase(id));
unmountCase();
}
});
it("writes the CreativeWork of a client case with creator, client and keywords", async () => {
const caseStudy = getCase("lavacao-047");
const { container } = await renderCase(caseStudy.id);
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver serviço relacionado:/,
});
expect(
relatedLinks
.map((link) => link.getAttribute("href"))
.sort((a, b) => (a ?? "").localeCompare(b ?? "")),
).toEqual(
getRelatedServices(caseStudy.id)
.map(({ slug }) => `/servicos/${slug}`)
.sort(),
const work = readJsonLdEntities(container).find(
(node) => node["@type"] === "CreativeWork",
);
expectContactAndNoDeadLinks(caseStudy);
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
"BreadcrumbList",
]);
expect(work).toEqual({
"@type": "CreativeWork",
"@id": `${siteUrl}/cases/lavacao-047#creative-work`,
name: "Lavação 047: Site e painel para lavação automotiva",
description: caseStudy.metaDescription,
url: `${siteUrl}/cases/lavacao-047`,
image: `${siteUrl}/cases/lavacao-047/opengraph-image/lavacao-047`,
inLanguage: "pt-BR",
creator: { "@id": `${siteUrl}/#person` },
sourceOrganization: {
"@type": "Organization",
name: "Lavação 047",
url: "https://landing-page-lavacao047.vercel.app",
},
keywords: caseStudy.stack.join(", "),
});
});
it("writes the CreativeWork of a laboratory case without a client", async () => {
const caseStudy = getCase("twins-lab");
const { container } = await renderCase(caseStudy.id);
const work = readJsonLdEntities(container).find(
(node) => node["@type"] === "CreativeWork",
);
expect(work).toMatchObject({
name: "Twins Lab: Painel ao vivo de um reator simulado",
url: `${siteUrl}/cases/twins-lab`,
creator: { "@id": `${siteUrl}/#person` },
keywords: "Next.js, TypeScript, NextAuth, MongoDB, SSE",
});
expect(work).not.toHaveProperty("sourceOrganization");
});
it("puts the project cover next to the title, and below it on a phone, in a browser frame", async () => {
const caseStudy = getCase("sev-modaintima");
const { container } = await renderCase(caseStudy.id);
const hero = container.querySelector("section");
if (!hero) throw new Error("Missing hero");
const frame = hero.querySelector(".ds-bframe");
if (!frame) throw new Error("Missing hero frame");
expect(frame.querySelector("img")).toHaveAttribute(
"alt",
`Captura de tela do site ${caseStudy.title}`,
);
expect(frame.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
// a capa abre a página: carrega antes das demais imagens
expect(frame.querySelector("img")).not.toHaveAttribute("loading", "lazy");
// ordem do documento (a do celular): título, mídia, resumo
const heading = screen.getByRole("heading", { level: 1 });
const summary = screen.getByText(caseStudy.summary);
expect(hero).toContainElement(summary);
expect(isBefore(heading, frame)).toBe(true);
expect(isBefore(frame, summary)).toBe(true);
// no computador: duas colunas, texto à esquerda e mídia à direita
const grid = frame.closest(".grid");
expect(grid?.className).toContain("lg:grid-cols-[");
expect(frame.parentElement?.className).toContain("lg:col-start-2");
expect(heading.parentElement?.className).toContain("lg:col-start-1");
});
it("keeps the hero in one column when the case has no capture", async () => {
const { container } = await renderCase("twins-lab");
const hero = container.querySelector("section");
expect(hero?.querySelector(".ds-bframe")).toBeNull();
expect(hero?.querySelector(".grid")).toBeNull();
});
it("shows the optional facts only when they are filled in", async () => {
const caseStudy = getCase("lavacao-047");
const empty = await renderCase(caseStudy.id);
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
empty.unmount();
caseStudy.facts = ["Fato de teste um", "Fato de teste dois"];
try {
await renderCase(caseStudy.id);
const list = screen.getByRole("list", { name: "Fatos do projeto" });
expect(within(list).getAllByRole("listitem")).toHaveLength(2);
expect(within(list).getByText("Fato de teste um")).toBeVisible();
expect(isBefore(screen.getByText(caseStudy.summary), list)).toBe(true);
} finally {
delete caseStudy.facts;
}
});
it("starts the video only when the visitor presses play, and moves focus to it", async () => {
const caseStudy = getCase("sev-modaintima");
const { container } = await renderCase(caseStudy.id);
const play = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
});
expect(play).toBeVisible();
expect(play.className).not.toContain("--slash");
expect(container.querySelector("video")).toBeNull();
fireEvent.click(play);
const video = screen.getByLabelText(
`Vídeo do site ${caseStudy.title} no computador`,
);
expect(video.tagName).toBe("VIDEO");
expect(video).toHaveAttribute(
"poster",
`/videos/${caseStudy.id}/desktop.webp`,
);
expect(video).toHaveAttribute("controls");
expect(video).toHaveFocus();
expect(
video.querySelectorAll("source[src^='/videos/sev-modaintima/']"),
).toHaveLength(2);
expect(video.closest(".ds-bframe")).not.toBeNull();
expect(
screen.queryByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`,
}),
).toBeNull();
// o celular continua com o pôster até a pessoa tocar nele
expect(
screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`,
}),
).toBeVisible();
});
it("gives the case page the same shell as the home, with no narrow max-width", async () => {
const { container } = await renderCase("sev-modaintima");
for (const shell of container.querySelectorAll(".ds-section-shell")) {
expect(shell.className).not.toMatch(/max-w-/);
}
});
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
@@ -409,7 +612,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
+52 -104
View File
@@ -1,18 +1,15 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import {
buildBreadcrumbList,
buildCaseJsonLd,
buildPageSocialMetadata,
} from "@/lib/seo";
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
import { CaseContactSection } from "../_components/CaseContactSection";
import { CaseHero } from "../_components/CaseHero";
import { CaseMediaSection } from "../_components/CaseMediaSection";
import { CaseRelatedServices } from "../_components/CaseRelatedServices";
import { CaseResultSection } from "../_components/CaseResultSection";
interface CasePageProps {
@@ -32,14 +29,13 @@ export async function generateMetadata({
if (!caseStudy) return { title: "Case não encontrado" };
const url = `${siteUrl}/cases/${caseStudy.id}`;
const title = `${caseStudy.title}: ${caseStudy.subtitle}`;
return {
title,
description: caseStudy.summary,
title: caseStudy.metaTitle,
description: caseStudy.metaDescription,
...buildPageSocialMetadata({
url,
title,
description: caseStudy.summary,
title: caseStudy.metaTitle,
description: caseStudy.metaDescription,
type: "article",
}),
};
@@ -55,6 +51,7 @@ export default async function CasePage({ params }: CasePageProps) {
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
buildCaseJsonLd(caseStudy),
buildBreadcrumbList(pageUrl, [
["Início", siteUrl],
["Cases", `${siteUrl}/cases`],
@@ -67,32 +64,12 @@ export default async function CasePage({ params }: CasePageProps) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
]}
/>
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
{caseStudy.title}
</p>
<h1 className="ds-section-title text-balance text-white">
{caseStudy.headline}
</h1>
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.summary}
</p>
</div>
</section>
<CaseHero caseStudy={caseStudy} isClient={isClient} />
<section className="ds-section" aria-labelledby="case-context">
<div className="ds-section-shell max-w-[960px]">
<div className="ds-section-shell">
<div className="ds-grid-2 items-start">
<div>
<div className="max-w-[68ch]">
<h2 id="case-context" className="ds-card-title !mt-0">
{isClient
? "O cliente e o contexto"
@@ -108,7 +85,7 @@ export default async function CasePage({ params }: CasePageProps) {
</p>
) : null}
</div>
<div>
<div className="max-w-[68ch]">
<h2 id="case-problem" className="ds-card-title !mt-0">
O problema
</h2>
@@ -119,15 +96,41 @@ export default async function CasePage({ params }: CasePageProps) {
</section>
<section className="ds-section" aria-labelledby="case-done">
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-done" className="ds-card-title !mt-0">
O que foi feito
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
{caseStudy.deliveries.map((delivery) => (
<li key={delivery}>{delivery}</li>
))}
</ul>
<div className="ds-section-shell">
<div className="max-w-[860px]">
<h2 id="case-done" className="ds-card-title !mt-0">
O que foi feito
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
{caseStudy.deliveries.map((delivery) => (
<li key={delivery}>{delivery}</li>
))}
</ul>
<details className="mt-sp-8 border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
Detalhes técnicos
</summary>
<div className="max-w-[80ch]">
<p className="ds-kicker mt-sp-5">Tecnologias</p>
<ul
className="ds-tags !mt-sp-3 !pt-0"
aria-label="Tecnologias do projeto"
>
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
<ul className="ds-list !mt-sp-3 space-y-sp-2">
{caseStudy.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</div>
</details>
</div>
</div>
</section>
@@ -135,62 +138,7 @@ export default async function CasePage({ params }: CasePageProps) {
<CaseResultSection result={caseStudy.result} />
<section
className="ds-section ds-section-dark"
aria-labelledby="case-contact"
>
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
Quer um projeto parecido com este?
</h2>
<p className="ds-copy max-w-[60ch]">
Conte o que você precisa. A conversa começa direto no WhatsApp,
sem formulário.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={`um projeto parecido com o da ${caseStudy.title}`}
placement="case_detail"
className={primaryButton}
>
Conversar sobre um projeto parecido
</WhatsAppLink>
<Link href="/cases" className={secondaryButtonDark}>
Ver outros cases
</Link>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
</section>
<section className="ds-section" aria-label="Detalhes técnicos">
<div className="ds-section-shell max-w-[960px]">
<details className="border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
Detalhes técnicos
</summary>
<div className="max-w-[80ch]">
<p className="ds-kicker mt-sp-5">Tecnologias</p>
<ul
className="ds-tags !mt-sp-3 !pt-0"
aria-label="Tecnologias do projeto"
>
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
<ul className="ds-list !mt-sp-3 space-y-sp-2">
{caseStudy.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</div>
</details>
</div>
</section>
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />
</article>
</main>
);