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:
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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user