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>
);
@@ -0,0 +1,67 @@
import { render, screen } from "@testing-library/react";
import { getRelatedServices } from "@/lib/site-content";
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.
*/
const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" };
const nutri = { id: "nutriscan", title: "NutriScan" };
function messageOf(link: HTMLElement) {
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
}
describe("CaseContactSection", () => {
it("path: client case -> 'projeto parecido' invitation, WhatsApp with the case title, services listed", () => {
render(<CaseContactSection caseStudy={sev} isClient />);
expect(
screen.getByRole("heading", {
level: 2,
name: "Quer um projeto parecido com este?",
}),
).toBeVisible();
const cta = screen.getByRole("link", {
name: "Conversar sobre um projeto parecido",
});
expect(cta).toHaveAttribute("target", "_blank");
expect(messageOf(cta)).toContain(
`Quero conversar sobre um projeto parecido com o da ${sev.title}.`,
);
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
expect(
screen.getAllByRole("link", { name: /^Ver o serviço:/ }).length,
).toBeGreaterThan(0);
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases");
});
it("path: laboratory case -> 'problema parecido' invitation and no mention of a similar project", () => {
render(<CaseContactSection caseStudy={nutri} isClient={false} />);
expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
const cta = screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
});
expect(messageOf(cta)).toContain(
`Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`,
);
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
}),
).toBeNull();
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua
expect(getRelatedServices(nutri.id)).toHaveLength(0);
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toBeVisible();
});
});
@@ -0,0 +1,64 @@
import Link from "next/link";
import {
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import type { CaseStudyContent } from "@/lib/site-content";
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. */
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;
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */
export function CaseContactSection({
caseStudy,
isClient,
}: {
caseStudy: Pick<CaseStudyContent, "id" | "title">;
isClient: boolean;
}) {
const copy = contactCopy[isClient ? "client" : "lab"];
return (
<section
className="ds-section ds-section-dark"
aria-labelledby="case-contact"
>
<div className="ds-section-shell">
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
{copy.title}
</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={copy.subject(caseStudy.title)}
placement="case_detail"
className={primaryButton}
>
{copy.action}
</WhatsAppLink>
<Link href="/cases" className={secondaryButtonDark}>
Ver outros cases
</Link>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
</section>
);
}
-1
View File
@@ -4,7 +4,6 @@ import {
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { getCaseMedia } from "./case-media";
import "../../components/showcase/showcase.css";
/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */
export function CaseCover({
@@ -0,0 +1,102 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
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.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
title: "SEV Moda Íntima",
demoUrl: "https://sevmodaintima.com.br",
};
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseCoverLink", () => {
it("path: no desktop capture -> renders nothing, not even the link", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCoverLink {...props} />);
expect(container).toBeEmptyDOMElement();
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
});
it("path: capture found -> a link to the case, out of reading order and tab order, around the cover", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCoverLink {...props} />);
const link = container.querySelector("a");
expect(link).toHaveAttribute("href", `/cases/${props.id}`);
expect(link).toHaveAttribute("aria-hidden", "true");
expect(link).toHaveAttribute("tabindex", "-1");
expect(link?.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
expect(
screen.getByAltText(`Captura de tela do site ${props.title}`),
).toBeInTheDocument();
});
it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => {
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",
);
});
it("path: sizes, priority and prefetch given -> all of them are used", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<CaseCoverLink {...props} sizes="320px" priority prefetch={false} />,
);
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",
);
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<CaseCoverLink id={props.id} title={props.title} demoUrl={null} />,
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
props.title,
);
});
});
+43
View File
@@ -0,0 +1,43 @@
import Link from "next/link";
import { CaseCover } 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.
*/
export function CaseCoverLink({
id,
title,
demoUrl,
sizes = "(min-width: 1024px) 560px, 100vw",
priority = false,
prefetch,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
prefetch?: boolean;
}) {
if (!getCaseMedia(id, "desktop")) return null;
return (
<Link
href={`/cases/${id}`}
aria-hidden="true"
tabIndex={-1}
prefetch={prefetch}
className="mb-sp-5 block"
>
<CaseCover
id={id}
title={title}
demoUrl={demoUrl}
sizes={sizes}
priority={priority}
/>
</Link>
);
}
@@ -0,0 +1,130 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies } from "@/lib/site-content";
import { CaseHero } from "./CaseHero";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de
* laboratório, e a lista de fatos ausente, vazia ou preenchida. A existência da captura é simulada em getCaseMedia.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
const client = getCase("sev-modaintima");
const lab = getCase("twins-lab");
function withCapture(has: boolean) {
mockedGetCaseMedia.mockImplementation((id, device) =>
has ? getShowcaseAssets(id, device) : undefined,
);
}
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseHero: captura gravada e tipo de case", () => {
it("path: client case with a capture -> two columns, the cover on the right, the client eyebrow", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector(".ds-section-shell")).toHaveClass("grid");
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
expect(screen.getByText(/Case de cliente/)).toBeVisible();
expect(
screen.getByRole("heading", { level: 1, name: client.headline }),
).toBeVisible();
});
it("path: client case without a capture -> one column and no cover", () => {
withCapture(false);
const { container } = render(<CaseHero caseStudy={client} isClient />);
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
"grid",
);
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(screen.getByText(client.summary)).toBeVisible();
});
it("path: laboratory case with a capture -> the cover shows, with the laboratory eyebrow", () => {
withCapture(true);
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
expect(container.querySelector(".ds-bframe")).toBeInTheDocument();
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByText(/Case de cliente/)).toBeNull();
});
it("path: laboratory case without a capture -> one column, laboratory eyebrow, title and summary", () => {
withCapture(false);
const { container } = render(<CaseHero caseStudy={lab} isClient={false} />);
expect(container.querySelector(".ds-section-shell")).not.toHaveClass(
"grid",
);
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.getByText(lab.summary)).toBeVisible();
});
it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => {
withCapture(false);
render(<CaseHero caseStudy={client} isClient />);
const links = screen.getAllByRole("link");
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/",
"/cases",
`/cases/${client.id}`,
]);
expect(links[2]).toHaveAttribute("aria-current", "page");
});
});
describe("CaseHero: fatos do projeto", () => {
const withFacts = (facts: string[] | undefined) => ({
...client,
facts,
});
it("path: no facts field -> no list", () => {
withCapture(false);
render(<CaseHero caseStudy={withFacts(undefined)} isClient />);
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
});
it("path: empty facts list -> no list", () => {
withCapture(false);
render(<CaseHero caseStudy={withFacts([])} isClient />);
expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull();
});
it("path: filled facts -> one item each, after the summary", () => {
withCapture(false);
render(
<CaseHero
caseStudy={withFacts(["No ar desde 2025", "Dois painéis"])}
isClient
/>,
);
const list = screen.getByRole("list", { name: "Fatos do projeto" });
expect(list.querySelectorAll("li")).toHaveLength(2);
expect(
screen.getByText(client.summary).compareDocumentPosition(list) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
+86
View File
@@ -0,0 +1,86 @@
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 { getCaseMedia } from "./case-media";
/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */
function CaseFacts({ facts }: { facts?: string[] }) {
if (!facts?.length) return null;
return (
<ul
className="mt-sp-5 flex flex-wrap gap-x-sp-6 gap-y-sp-2 text-[15px] leading-6 text-white/80"
aria-label="Fatos do projeto"
>
{facts.map((fact) => (
<li key={fact}>{fact}</li>
))}
</ul>
);
}
/** Pôster do site na coluna da direita (no celular, logo abaixo do título). */
function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) {
return (
<div className="min-w-0 lg:col-start-2 lg:row-span-2 lg:row-start-1">
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
priority
/>
</div>
);
}
/**
* 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.
*/
export function CaseHero({
caseStudy,
isClient,
}: {
caseStudy: CaseStudyContent;
isClient: boolean;
}) {
const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop"));
return (
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div
className={`ds-section-shell ${
hasMedia
? "grid items-center gap-y-sp-6 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16 lg:gap-y-0"
: ""
}`}
>
<div className="max-w-[860px] lg:col-start-1 lg:row-start-1 lg:self-end">
<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>
</div>
{hasMedia ? <CaseHeroCover caseStudy={caseStudy} /> : null}
<div className="max-w-[68ch] lg:col-start-1 lg:row-start-2 lg:self-start">
<p className="ds-copy !text-[rgba(255,255,255,.78)]">
{caseStudy.summary}
</p>
<CaseFacts facts={caseStudy.facts} />
</div>
</div>
</section>
);
}
+48 -9
View File
@@ -1,10 +1,11 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CaseMedia } from "./CaseMedia";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum.
* Cada quadro mostra o pôster com um botão de play; o vídeo só aparece depois do clique.
* A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/.
*/
@@ -23,8 +24,10 @@ const props = {
title: "SEV Moda Íntima",
demoUrl: "https://sevmodaintima.com.br",
};
const desktopLabel = `Vídeo do site ${props.title} no computador`;
const mobileLabel = `Vídeo do site ${props.title} no celular`;
const desktopPlay = `Reproduzir o vídeo do site ${props.title} no computador`;
const mobilePlay = `Reproduzir o vídeo do site ${props.title} no celular`;
const desktopVideo = `Vídeo do site ${props.title} no computador`;
const mobileVideo = `Vídeo do site ${props.title} no celular`;
afterEach(() => {
mockedGetCaseMedia.mockReset();
@@ -36,10 +39,10 @@ describe("CaseMedia", () => {
const { container } = render(<CaseMedia {...props} />);
expect(container.querySelector(".ds-bframe")).toContainElement(
screen.getByLabelText(desktopLabel),
screen.getByRole("button", { name: desktopPlay }),
);
expect(container.querySelector(".ds-phone")).toContainElement(
screen.getByLabelText(mobileLabel),
screen.getByRole("button", { name: mobilePlay }),
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
@@ -63,8 +66,10 @@ describe("CaseMedia", () => {
withCaptures(["mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(screen.getByLabelText(mobileLabel)).toBeInTheDocument();
expect(screen.queryByLabelText(desktopLabel)).toBeNull();
expect(
screen.getByRole("button", { name: mobilePlay }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: desktopPlay })).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector(".ds-phone")).not.toBeNull();
expect(screen.queryByText(/no computador\./)).toBeNull();
@@ -74,13 +79,47 @@ describe("CaseMedia", () => {
withCaptures(["desktop"]);
const { container } = render(<CaseMedia {...props} />);
expect(screen.getByLabelText(desktopLabel)).toBeInTheDocument();
expect(screen.queryByLabelText(mobileLabel)).toBeNull();
expect(
screen.getByRole("button", { name: desktopPlay }),
).toBeInTheDocument();
expect(screen.queryByRole("button", { name: mobilePlay })).toBeNull();
expect(container.querySelector(".ds-phone")).toBeNull();
expect(container.querySelector(".ds-bframe")).not.toBeNull();
expect(screen.queryByText("No celular.")).toBeNull();
});
it("path: both captures -> no video until play, then only the pressed frame plays", () => {
withCaptures(["desktop", "mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(container.querySelector("video")).toBeNull();
// o pôster do computador vem com texto alternativo vazio: o botão já descreve o quadro
expect(container.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
"",
);
fireEvent.click(screen.getByRole("button", { name: mobilePlay }));
const phone = screen.getByLabelText(mobileVideo);
expect(container.querySelector(".ds-phone")).toContainElement(phone);
expect(phone).toHaveAttribute("poster", `/videos/${props.id}/mobile.webp`);
expect(phone.querySelectorAll("source")).toHaveLength(2);
expect(screen.queryByLabelText(desktopVideo)).toBeNull();
expect(
screen.getByRole("button", { name: desktopPlay }),
).toBeInTheDocument();
expect(container.querySelectorAll("video")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: desktopPlay }));
expect(screen.getByLabelText(desktopVideo)).toHaveAttribute(
"poster",
`/videos/${props.id}/desktop.webp`,
);
expect(container.querySelectorAll("video")).toHaveLength(2);
expect(screen.queryByRole("button")).toBeNull();
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
withCaptures(["desktop"]);
const { container } = render(
+6 -44
View File
@@ -3,43 +3,13 @@ import {
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
import type { ShowcaseAssets } from "@/lib/project-showcase";
import { CasePlayer } from "./CasePlayer";
import { getCaseMedia } from "./case-media";
import "../../components/showcase/showcase.css";
function CaseVideo({
assets,
label,
className,
}: {
assets: ShowcaseAssets;
label: string;
className: string;
}) {
return (
<video
className={className}
controls
muted
loop
playsInline
preload="none"
poster={assets.poster}
width={assets.width}
height={assets.height}
aria-label={label}
disablePictureInPicture
>
{assets.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}
/**
* Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular).
* O vídeo só baixa quando a pessoa aperta o play. Sem captura gravada, não mostra nada.
* Cada quadro mostra o pôster com um botão de play; o vídeo só baixa quando a pessoa aperta o botão.
* Sem captura gravada, não mostra nada.
*/
export function CaseMedia({
id,
@@ -55,16 +25,12 @@ export function CaseMedia({
if (!desktop && !mobile) return null;
return (
<div className="mt-sp-8 grid items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
<div className="mt-sp-8 grid max-w-[1040px] items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
{desktop ? (
<figure className="min-w-0">
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<CaseVideo
assets={desktop}
label={`Vídeo do site ${title} no computador`}
className="absolute inset-0 h-full w-full object-cover object-top"
/>
<CasePlayer assets={desktop} title={title} device="desktop" />
</div>
</BrowserFrame>
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
@@ -75,11 +41,7 @@ export function CaseMedia({
{mobile ? (
<figure className="mx-auto w-[min(62vw,220px)] md:mx-0">
<PhoneFrame poster={mobile.poster}>
<CaseVideo
assets={mobile}
label={`Vídeo do site ${title} no celular`}
className="absolute inset-0 h-full w-full object-cover object-top"
/>
<CasePlayer assets={mobile} title={title} device="mobile" />
</PhoneFrame>
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
No celular.
+1 -1
View File
@@ -28,7 +28,7 @@ export function CaseMediaSection({
className="ds-section ds-section-dark"
aria-labelledby="case-media"
>
<div className="ds-section-shell max-w-[960px]">
<div className="ds-section-shell">
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
Como ficou
</h2>
+85
View File
@@ -0,0 +1,85 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CasePlayer } from "./CasePlayer";
const title = "SEV Moda Íntima";
describe("CasePlayer", () => {
it("shows the poster and an accessible play button instead of a paused video", () => {
const { container } = render(
<div className="relative">
<CasePlayer
assets={getShowcaseAssets("sev-modaintima", "desktop")}
title={title}
device="desktop"
/>
</div>,
);
expect(container.querySelector("video")).toBeNull();
// a imagem é decorativa: o botão já diz o que o quadro mostra
const poster = container.querySelector("img");
expect(poster).toHaveAttribute("alt", "");
expect(poster?.getAttribute("src")).toContain(
encodeURIComponent("/videos/sev-modaintima/desktop.webp"),
);
const button = screen.getByRole("button", {
name: `Reproduzir o vídeo do site ${title} no computador`,
});
expect(button).toHaveAttribute("type", "button");
// só botão de contato é limão: o de play é escuro, com contorno branco
expect(button.innerHTML).not.toContain("--slash");
expect(button.querySelector("[aria-hidden='true']")).not.toBeNull();
});
it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => {
const assets = getShowcaseAssets("lavacao-047", "desktop");
render(
<div className="relative">
<CasePlayer assets={assets} title="Lavação 047" device="desktop" />
</div>,
);
fireEvent.click(screen.getByRole("button"));
const video = screen.getByLabelText(
"Vídeo do site Lavação 047 no computador",
);
expect(video).toHaveFocus();
expect(video).toHaveAttribute("controls");
expect(video).toHaveAttribute("poster", assets.poster);
expect(video).toHaveAttribute("width", String(assets.width));
expect(video).toHaveAttribute("height", String(assets.height));
expect((video as HTMLVideoElement).muted).toBe(true);
expect((video as HTMLVideoElement).loop).toBe(true);
expect(
[...video.querySelectorAll("source")].map((source) => [
source.getAttribute("src"),
source.getAttribute("type"),
]),
).toEqual(assets.sources.map(({ src, type }) => [src, type]));
expect(screen.queryByRole("button")).toBeNull();
});
it("leaves the poster to the phone frame on mobile and names the device in the labels", () => {
const { container } = render(
<div className="relative">
<CasePlayer
assets={getShowcaseAssets("sc-plus", "mobile")}
title="SC Plus Aquicultura"
device="mobile"
/>
</div>,
);
expect(container.querySelector("img")).toBeNull();
fireEvent.click(
screen.getByRole("button", {
name: "Reproduzir o vídeo do site SC Plus Aquicultura no celular",
}),
);
expect(
screen.getByLabelText("Vídeo do site SC Plus Aquicultura no celular"),
).toHaveAttribute("poster", "/videos/sc-plus/mobile.webp");
});
});
+88
View File
@@ -0,0 +1,88 @@
"use client";
import { Play } from "lucide-react";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import type { Device, ShowcaseAssets } from "@/lib/project-showcase";
const deviceLabel: Record<Device, string> = {
desktop: "no computador",
mobile: "no celular",
};
/**
* 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.
*/
export function CasePlayer({
assets,
title,
device,
sizes = "(min-width: 1280px) 900px, 100vw",
}: {
assets: ShowcaseAssets;
title: string;
device: Device;
sizes?: string;
}) {
const [playing, setPlaying] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
// o botão sai de cena ao tocar: o foco vai para o vídeo, para quem usa o teclado não o perder
useEffect(() => {
if (playing) videoRef.current?.focus();
}, [playing]);
if (playing) {
return (
<video
ref={videoRef}
className="absolute inset-0 h-full w-full object-cover object-top"
autoPlay
controls
muted
loop
playsInline
poster={assets.poster}
width={assets.width}
height={assets.height}
tabIndex={0}
aria-label={`Vídeo do site ${title} ${deviceLabel[device]}`}
disablePictureInPicture
>
{assets.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}
return (
<>
{/* o quadro do celular já mostra o pôster como fundo; no computador a imagem entra aqui */}
{device === "desktop" ? (
<Image
src={assets.poster}
alt=""
fill
sizes={sizes}
className="object-cover object-top"
/>
) : null}
<button
type="button"
onClick={() => setPlaying(true)}
aria-label={`Reproduzir o vídeo do site ${title} ${deviceLabel[device]}`}
className="group absolute inset-0 grid place-items-center focus-visible:outline-2 focus-visible:-outline-offset-4 focus-visible:outline-white"
>
<span
aria-hidden="true"
className="grid size-14 place-items-center rounded-full border border-white/70 bg-black/65 text-white transition-colors group-hover:bg-black/85 motion-reduce:transition-none sm:size-16"
>
<Play className="ml-1 size-6" fill="currentColor" />
</span>
</button>
</>
);
}
+24 -13
View File
@@ -1,23 +1,34 @@
import Link from "next/link";
import { textLink } from "@/app/components/button-styles";
import { textLinkDark } from "@/app/components/button-styles";
import { getRelatedServices } from "@/lib/site-content";
/** Links para os serviços que citam este case. Sem serviço relacionado, não mostra nada. */
/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */
export const MAX_RELATED_SERVICES = 2;
/** Cards dos serviços que citam este case (no máximo dois), sobre fundo escuro. Sem serviço relacionado, não mostra nada. */
export function CaseRelatedServices({ caseId }: { caseId: string }) {
const services = getRelatedServices(caseId);
const services = getRelatedServices(caseId).slice(0, MAX_RELATED_SERVICES);
if (services.length === 0) return null;
return (
<div className="mt-sp-6 flex flex-wrap gap-x-sp-6">
{services.map((service) => (
<Link
key={service.slug}
className={textLink}
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
<div className="mt-sp-10">
<p className="ds-kicker">Serviços ligados a este projeto</p>
<ul className="mt-sp-4 grid gap-sp-4 md:grid-cols-2">
{services.map((service) => (
<li key={service.slug} className="flex">
<article className="ds-card w-full">
<h3 className="ds-card-title !mt-0">{service.navLabel}</h3>
<p className="ds-copy">{service.intro}</p>
<Link
className={`${textLinkDark} mt-sp-4 self-start`}
href={`/servicos/${service.slug}`}
>
Ver o serviço: {service.navLabel}
</Link>
</article>
</li>
))}
</ul>
</div>
);
}
+2 -2
View File
@@ -4,11 +4,11 @@ export function CaseResultSection({ result }: { result?: string[] }) {
return (
<section className="ds-section" aria-labelledby="case-result">
<div className="ds-section-shell max-w-[960px]">
<div className="ds-section-shell">
<h2 id="case-result" className="ds-card-title !mt-0">
Resultado
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
{result.map((item) => (
<li key={item}>{item}</li>
))}
+1
View File
@@ -1,3 +1,4 @@
import "server-only";
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
+66
View File
@@ -0,0 +1,66 @@
import { render } from "@testing-library/react";
import type { ComponentProps } from "react";
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.
*/
type LinkProps = ComponentProps<typeof import("next/link").default>;
/** Propriedades de cada `Link` renderizado: o `prefetch` não aparece no HTML, então a rota é espiada aqui. */
const mockLinkProps: LinkProps[] = [];
jest.mock("next/link", () => ({
__esModule: true,
default: ({ children, href, prefetch, ...anchor }: LinkProps) => {
mockLinkProps.push({ children, href, prefetch, ...anchor });
return (
<a href={String(href)} {...(anchor as Record<string, unknown>)}>
{children}
</a>
);
},
}));
beforeEach(() => {
mockLinkProps.length = 0;
});
function coverImages(container: HTMLElement) {
return clientCases.map((caseStudy) => {
const image = container
.querySelector(`a[href="/cases/${caseStudy.id}"][aria-hidden="true"]`)
?.querySelector("img");
if (!image) throw new Error(`Missing cover of ${caseStudy.id}`);
return image;
});
}
describe("CasesPage: carregamento das capas", () => {
it("loads the first cover with priority and the others 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) {
expect(image).toHaveAttribute("loading", "lazy");
}
});
it("does not prefetch the case pages from the cover links, nor from the card buttons", () => {
render(<CasesPage />);
const toCases = mockLinkProps.filter(({ href }) =>
String(href).startsWith("/cases/"),
);
// uma capa e um botão por case de cliente, e um botão por projeto do laboratório
expect(toCases.length).toBeGreaterThanOrEqual(clientCases.length * 2);
for (const props of toCases) {
expect(props.prefetch).toBe(false);
}
});
});
+21 -20
View File
@@ -17,13 +17,12 @@ import {
labCases,
siteUrl,
} from "@/lib/site-content";
import { CaseCover } from "./_components/CaseCover";
import { getCaseMedia } from "./_components/case-media";
import { CaseCoverLink } from "./_components/CaseCoverLink";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases: Sites e Sistemas Feitos para Empresas";
const pageTitle = "Cases: sites e sistemas para empresas";
const pageDescription =
"Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono, páginas institucionais e mais. Confira cada projeto e converse sobre o seu.";
"Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
const allCases = [...clientCases, ...labCases];
export const metadata: Metadata = {
@@ -41,33 +40,34 @@ function CaseCard({
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. */
priority?: boolean;
}) {
return (
<article className="ds-card !h-auto">
{withCover && getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
{withCover ? (
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
priority={priority}
prefetch={false}
/>
) : null}
<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 href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
<Link
href={`/cases/${caseStudy.id}`}
prefetch={false}
className={secondaryButtonLight}
>
{ctaLabel}
</Link>
{caseStudy.demoUrl ? (
@@ -142,12 +142,13 @@ export default function CasesPage() {
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy) => (
{clientCases.map((caseStudy, index) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover
ctaLabel="Ver o case"
priority={index === 0}
/>
))}
</div>
+53
View File
@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
/**
* Testes de caminhos-base de Breadcrumb: o primeiro item não tem separador,
* os seguintes têm, e só o último é a página atual (inclusive quando é o único).
*/
describe("Breadcrumb", () => {
it("path: several items -> a separator before every item but the first", () => {
const { container } = render(
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
]}
/>,
);
const entries = Array.from(container.querySelectorAll("li"));
expect(entries).toHaveLength(3);
expect(entries[0].querySelector("span[aria-hidden='true']")).toBeNull();
expect(
entries[1].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(
entries[2].querySelector("span[aria-hidden='true']"),
).toHaveTextContent("/");
expect(entries[0].className).toBe("");
expect(entries[1].className).toContain("flex");
});
it("path: a single item -> no separator, and it is the current page", () => {
const { container } = render(
<Breadcrumb items={[{ label: "Início", href: "/" }]} />,
);
expect(container.querySelector("span[aria-hidden='true']")).toBeNull();
expect(screen.getByRole("link", { name: "Início" })).toHaveAttribute(
"aria-current",
"page",
);
});
it("path: no items -> an empty list inside the navigation", () => {
render(<Breadcrumb items={[]} />);
const nav = screen.getByRole("navigation");
expect(nav.querySelectorAll("li")).toHaveLength(0);
expect(screen.queryByRole("link")).toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
import { render, screen, within } from "@testing-library/react";
import { Breadcrumb } from "./Breadcrumb";
describe("Breadcrumb", () => {
const items = [
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: "SEV Moda Íntima", href: "/cases/sev-modaintima" },
];
it("marks only the last item as the current page", () => {
render(<Breadcrumb items={items} />);
const links = within(
screen.getByRole("navigation", { name: "Trilha de navegação" }),
).getAllByRole("link");
expect(links.map((link) => link.getAttribute("aria-current"))).toEqual([
null,
null,
"page",
]);
});
it("keeps the text at 15 px or more, as the body text of the inner pages", () => {
const { container } = render(<Breadcrumb items={items} />);
const list = container.querySelector("ol");
expect(list).toHaveClass("text-[15px]");
expect(list?.className).not.toMatch(/text-(xs|sm)\b|text-\[1[0-4]px\]/);
});
});
+2 -2
View File
@@ -7,8 +7,8 @@ export function Breadcrumb({
items: readonly { label: string; href: string }[];
}) {
return (
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<nav aria-label="Trilha de navegação" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-[15px]">
{items.map(({ label, href }, index) => (
<li
key={href}
+28 -2
View File
@@ -1,13 +1,39 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { trackCvDownload } from "@/lib/analytics";
import CurriculoPage from "./page";
import CurriculoPage, { metadata } from "./page";
jest.mock("@/lib/analytics", () => ({ trackCvDownload: jest.fn() }));
it("uses the same shell as the home, with no narrow max-width, and a template-friendly title", () => {
const { container } = render(CurriculoPage());
const shells = container.querySelectorAll(".ds-section-shell");
expect(shells.length).toBeGreaterThan(0);
for (const shell of shells) {
expect(shell.className).not.toMatch(/max-w-/);
}
// o sufixo " | Evandro.dev.br" vem do layout: o título não o repete
expect(metadata.title).toBe("Currículo de Evandro Fonseca Junior");
expect(metadata.alternates?.canonical).toBe(
"https://evandro.dev.br/curriculo",
);
});
it("shows the Innovo company name with the right accents", () => {
render(CurriculoPage());
expect(
screen.getByText(/Innovo Tecnologia da Informação Ltda\./),
).toBeVisible();
expect(document.body.textContent).not.toMatch(/Informacao/);
});
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
render(CurriculoPage());
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
const breadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
"Início",
+9 -8
View File
@@ -9,20 +9,21 @@ import {
buildPageSocialMetadata,
buildProfilePageJsonLd,
} from "@/lib/seo";
import { siteName, siteUrl } from "@/lib/site-content";
import { siteUrl } from "@/lib/site-content";
import CvDownloadLink from "./CvDownloadLink";
const pageUrl = `${siteUrl}/curriculo`;
const pageTitle = "Currículo de Evandro Fonseca Junior";
const pageDescription =
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software: experiência, formação e competências técnicas.";
export const metadata: Metadata = {
title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` },
description:
"Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.",
title: pageTitle,
description: pageDescription,
...buildPageSocialMetadata({
url: pageUrl,
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
description:
"Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.",
title: pageTitle,
description: pageDescription,
type: "profile",
}),
};
@@ -43,7 +44,7 @@ export default function CurriculoPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<div className="ds-section-shell">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
+162
View File
@@ -0,0 +1,162 @@
import type { Metadata } from "next";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import { generateMetadata as generateCaseMetadata } from "./cases/[slug]/page";
import { metadata as casesMetadata } from "./cases/page";
import { metadata as curriculoMetadata } from "./curriculo/page";
import { metadata as layoutMetadata } from "./layout";
import { metadata as notFoundMetadata } from "./not-found";
import { metadata as privacyMetadata } from "./privacidade/page";
import { generateMetadata as generateServiceMetadata } from "./servicos/[slug]/page";
import { metadata as servicesMetadata } from "./servicos/page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe
jest.mock("next/font/google", () => ({
Geist_Mono: () => ({ variable: "font-mono-var" }),
Space_Grotesk: () => ({ variable: "font-display-var" }),
}));
jest.mock("./globals.css", () => ({}));
/** Sufixo que o layout acrescenta a todo título (" | Evandro.dev.br"): 17 caracteres, não 16; por isso o teto efetivo do título é 43. */
const TITLE_SUFFIX = " | Evandro.dev.br";
const MAX_META_TITLE = 44;
const MAX_FULL_TITLE = 60;
const MAX_DESCRIPTION = 155;
interface Route {
route: string;
metadata: Metadata;
}
async function collectRoutes(): Promise<Route[]> {
const cases = await Promise.all(
caseStudies.map(async ({ id }) => ({
route: `/cases/${id}`,
metadata: await generateCaseMetadata({
params: Promise.resolve({ slug: id }),
}),
})),
);
const services = await Promise.all(
servicePages.map(async ({ slug }) => ({
route: `/servicos/${slug}`,
metadata: await generateServiceMetadata({
params: Promise.resolve({ slug }),
}),
})),
);
return [
{ route: "/cases", metadata: casesMetadata },
...cases,
{ route: "/servicos", metadata: servicesMetadata },
...services,
{ route: "/curriculo", metadata: curriculoMetadata },
{ route: "/privacidade", metadata: privacyMetadata },
{ route: "/not-found", metadata: notFoundMetadata },
];
}
function textOf(value: unknown): string {
if (typeof value === "string") return value;
throw new Error(`Esperava um texto, recebi ${JSON.stringify(value)}`);
}
describe("títulos e descrições das rotas de cases, serviços, currículo, privacidade e 404", () => {
it("cobre as 19 rotas do escopo", async () => {
expect(await collectRoutes()).toHaveLength(1 + 8 + 1 + 6 + 1 + 1 + 1);
expect(TITLE_SUFFIX).toHaveLength(17);
});
it(`mantém o título em até ${MAX_META_TITLE} caracteres, ${MAX_FULL_TITLE} com o sufixo do layout`, async () => {
const violations = (await collectRoutes()).flatMap(
({ route, metadata }) => {
const title = textOf(metadata.title);
const full = `${title}${TITLE_SUFFIX}`;
return title.length > MAX_META_TITLE || full.length > MAX_FULL_TITLE
? [`${route}: "${title}" tem ${title.length} caracteres`]
: [];
},
);
expect(violations).toEqual([]);
});
it("não repete o nome do site dentro do título (o layout já o acrescenta)", async () => {
const violations = (await collectRoutes()).flatMap(
({ route, metadata }) => {
const title = textOf(metadata.title);
return /\||Evandro\.dev\.br/.test(title) ? [`${route}: ${title}`] : [];
},
);
expect(violations).toEqual([]);
});
it(`mantém a descrição em até ${MAX_DESCRIPTION} caracteres`, async () => {
const violations = (await collectRoutes()).flatMap(
({ route, metadata }) => {
const description = textOf(metadata.description);
return description.length > MAX_DESCRIPTION || description.length < 40
? [`${route}: a descrição tem ${description.length} caracteres`]
: [];
},
);
expect(violations).toEqual([]);
});
it("repete no cartão de compartilhamento o título e a descrição, dentro dos mesmos limites", async () => {
for (const { route, metadata } of await collectRoutes()) {
// o 404 não tem cartão próprio: só noindex
if (route === "/not-found") continue;
for (const card of [metadata.openGraph, metadata.twitter]) {
expect(textOf(card?.title)).toBe(textOf(metadata.title));
expect(textOf(card?.description)).toBe(textOf(metadata.description));
}
}
});
it("não repete título nem descrição entre rotas", async () => {
const routes = await collectRoutes();
const titles = routes.map(({ metadata }) => textOf(metadata.title));
const descriptions = routes.map(({ metadata }) =>
textOf(metadata.description),
);
expect(new Set(titles).size).toBe(titles.length);
expect(new Set(descriptions).size).toBe(descriptions.length);
});
it("dá a cada página a própria canônica, e nenhuma ao 404", async () => {
for (const { route, metadata } of await collectRoutes()) {
if (route === "/not-found") {
expect(metadata.alternates).toEqual({ canonical: null });
} else {
expect(metadata.alternates?.canonical).toBe(`${siteUrl}${route}`);
}
}
});
});
describe("título e descrição da home, que moram no layout", () => {
// o `title.default` não passa pelo template do layout, então já leva o sufixo
const homeTitle = (layoutMetadata.title as { default: string }).default;
it(`mantém o título da home em até ${MAX_FULL_TITLE} caracteres, sufixo incluído`, () => {
expect(homeTitle.endsWith(TITLE_SUFFIX)).toBe(true);
expect(homeTitle.length).toBeLessThanOrEqual(MAX_FULL_TITLE);
});
it(`mantém a descrição da home em até ${MAX_DESCRIPTION} caracteres`, () => {
const description = textOf(layoutMetadata.description);
expect(description.length).toBeLessThanOrEqual(MAX_DESCRIPTION);
expect(description.length).toBeGreaterThanOrEqual(40);
});
it("não repete o título nem a descrição da home em outra rota", async () => {
const routes = await collectRoutes();
const titles = routes.map(({ metadata }) => textOf(metadata.title));
const descriptions = routes.map(({ metadata }) =>
textOf(metadata.description),
);
expect(titles).not.toContain(homeTitle.slice(0, -TITLE_SUFFIX.length));
expect(descriptions).not.toContain(textOf(layoutMetadata.description));
});
});
+33
View File
@@ -0,0 +1,33 @@
import { render, screen } from "@testing-library/react";
import NotFound, { metadata } from "./not-found";
describe("not-found", () => {
it("has no canonical (the home one does not apply to a 404) and no generic robots tag of its own", () => {
// o Next já escreve <meta name="robots" content="noindex"> no 404: repeti-lo duplicaria a tag
expect(metadata.alternates).toEqual({ canonical: null });
expect(metadata.robots).not.toHaveProperty("index");
expect(metadata.robots).not.toHaveProperty("follow");
});
it("keeps Googlebot out, since the root layout lets it index", () => {
expect(metadata.robots).toEqual({
googleBot: { index: false, follow: false },
});
});
it("explains the problem and offers a way back, in Portuguese", () => {
render(<NotFound />);
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("404");
expect(
screen.getByRole("heading", { level: 2, name: "Página não encontrada" }),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Voltar ao início" }),
).toHaveAttribute("href", "/");
expect(screen.getByRole("link", { name: "Ver os cases" })).toHaveAttribute(
"href",
"/cases",
);
});
});
+4 -6
View File
@@ -2,15 +2,13 @@ import { ArrowLeft, Code2, Home } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
// O layout raiz libera a indexação (inclusive para o Googlebot); aqui a regra inteira é trocada por noindex.
// O Next já escreve <meta name="robots" content="noindex"> em toda página 404; repetir `robots` aqui duplicaria a tag.
// O layout raiz libera a indexação para o Googlebot, então só essa regra é trocada. A canônica da home também não vale num 404.
export const metadata: Metadata = {
title: "Página não encontrada",
description: "A página que você procura não existe ou foi movida.",
robots: {
index: false,
follow: false,
googleBot: { index: false, follow: false },
},
alternates: { canonical: null },
robots: { googleBot: { index: false, follow: false } },
};
export default function NotFound() {
+15 -1
View File
@@ -4,7 +4,9 @@ import PrivacyPage, { metadata } from "./page";
it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () => {
const { container } = render(<PrivacyPage />);
const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" });
const breadcrumb = screen.getByRole("navigation", {
name: "Trilha de navegação",
});
const breadcrumbLinks = within(breadcrumb).getAllByRole("link");
expect(breadcrumbLinks.map((link) => link.textContent)).toEqual([
"Início",
@@ -50,6 +52,18 @@ it("mentions no GitHub link and no English jargon in the visible text", () => {
}
});
it("uses the same shell as the home, with no narrow max-width", () => {
const { container } = render(<PrivacyPage />);
const shells = container.querySelectorAll(".ds-section-shell");
expect(shells.length).toBeGreaterThan(0);
for (const shell of shells) {
expect(shell.className).not.toMatch(/max-w-/);
}
// o sufixo " | Evandro.dev.br" vem do layout: o título não o repete
expect(metadata.title).toBe("Privacidade e consentimento");
});
it("keeps the lime fill for contact actions only", () => {
const { container } = render(<PrivacyPage />);
+9 -8
View File
@@ -4,19 +4,20 @@ import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteName, siteUrl } from "@/lib/site-content";
import { siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/privacidade`;
const pageTitle = "Privacidade e consentimento";
const pageDescription =
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.";
export const metadata: Metadata = {
title: "Privacidade e consentimento",
description:
"Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.",
title: pageTitle,
description: pageDescription,
...buildPageSocialMetadata({
url: pageUrl,
title: `Privacidade e consentimento | ${siteName}`,
description:
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
title: pageTitle,
description: pageDescription,
type: "website",
}),
};
@@ -37,7 +38,7 @@ export default function PrivacyPage() {
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<div className="ds-section-shell">
<Breadcrumb
items={[
{ label: "Início", href: "/" },
+29
View File
@@ -0,0 +1,29 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { siteUrl } from "@/lib/site-content";
import robots from "./robots";
describe("robots", () => {
it("allows everything, points to the sitemap at the site address and lists no route that does not exist", () => {
expect(robots()).toEqual({
rules: [
{
userAgent: "*",
allow: ["/", "/_next/static/", "/_next/image/"],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
});
});
it("has no host (Yandex only, deprecated) and blocks no /api/ or /admin/", () => {
const result = robots();
expect(result).not.toHaveProperty("host");
expect(JSON.stringify(result)).not.toMatch(/\/api\/|\/admin\/|disallow/i);
});
it("is saved without a byte order mark", () => {
const bytes = readFileSync(join(__dirname, "robots.ts"));
expect([...bytes.subarray(0, 3)]).not.toEqual([0xef, 0xbb, 0xbf]);
});
});
+3 -4
View File
@@ -1,4 +1,5 @@
import type { MetadataRoute } from "next";
import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/site-content";
export default function robots(): MetadataRoute.Robots {
return {
@@ -6,10 +7,8 @@ export default function robots(): MetadataRoute.Robots {
{
userAgent: "*",
allow: ["/", "/_next/static/", "/_next/image/"],
disallow: ["/api/", "/admin/"],
},
],
sitemap: "https://evandro.dev.br/sitemap.xml",
host: "https://evandro.dev.br",
sitemap: `${siteUrl}/sitemap.xml`,
};
}
+120 -9
View File
@@ -4,6 +4,8 @@ import {
caseStudies,
getRelatedCases,
getServicePage,
isClientCase,
servicePages,
siteUrl,
} from "@/lib/site-content";
import ServicesIndexPage from "../page";
@@ -62,16 +64,16 @@ it("keeps static params and route metadata canonical", async () => {
const url = `${siteUrl}/servicos/${service.slug}`;
expect(metadata).toMatchObject({
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
alternates: { canonical: url },
openGraph: {
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
url,
},
twitter: {
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
},
});
@@ -192,7 +194,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
@@ -273,7 +275,7 @@ it("shows the client cases first, right after the hero, with the first cover in
const casesHeading = screen.getByRole("heading", {
level: 2,
name: "Veja projetos parecidos, já prontos",
name: "Onde já apliquei isso",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
@@ -311,9 +313,7 @@ it("hides the related block and the hero frame when a service has no case", asyn
const { container } = await renderService("tauri-rust");
expect(
screen.queryByRole("heading", {
name: "Veja projetos parecidos, já prontos",
}),
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
).toBeNull();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(
@@ -331,11 +331,122 @@ it("labels the Twins Lab prototype as a laboratory project on the IoT service",
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab",
]);
expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible();
expect(screen.getByText("Twins Lab · Projeto próprio")).toBeVisible();
expect(screen.queryByText(/Protótipo de laboratório/)).toBeNull();
// sem captura gravada, não há moldura nem imagem
expect(container.querySelector(".ds-bframe")).toBeNull();
});
it("labels only the laboratory projects as 'Projeto próprio'", async () => {
for (const slug of ["criacao-de-sites", "integracao-hardware-software-iot"]) {
const { unmount } = await renderService(slug);
const related = getRelatedCases(getServicePage(slug)?.relatedCaseIds ?? []);
expect(related.length).toBeGreaterThan(0);
const labels = screen.queryAllByText(/· Projeto próprio$/);
expect(labels).toHaveLength(related.filter((c) => !isClientCase(c)).length);
unmount();
}
});
it("says in one sentence, per case, why it matters to the service", async () => {
for (const { slug, relatedCaseIds, caseRelevance } of servicePages) {
if (relatedCaseIds.length === 0) continue;
const { unmount } = await renderService(slug);
for (const id of relatedCaseIds) {
const sentence = caseRelevance[id];
expect(sentence).toBeTruthy();
const card = screen.getByText(sentence).closest("article");
expect(card).toContainElement(
screen.getByRole("link", {
name: `Ler case: ${caseStudies.find((c) => c.id === id)?.title}`,
}),
);
}
unmount();
}
});
it("cites the Apolo Energia experience in text on the IoT and architecture pages, with no link", async () => {
for (const slug of [
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
const note = getServicePage(slug)?.experienceNote ?? "";
const { container, unmount } = await renderService(slug);
const paragraph = screen.getByText(/Apolo Energia/);
expect(paragraph).toBeVisible();
expect(paragraph.textContent).toContain(note);
expect(paragraph.textContent).toMatch(/Desde 2022/);
expect(paragraph.closest("a")).toBeNull();
expect(paragraph.querySelector("a")).toBeNull();
// dentro do bloco "Onde já apliquei isso", antes dos cards
const block = screen.getByRole("heading", {
name: "Onde já apliquei isso",
});
expect(isBefore(block, paragraph)).toBe(true);
expect(
isBefore(
paragraph,
container.querySelector("article.ds-card") as Element,
),
).toBe(true);
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs.some((href) => /apolo/i.test(href))).toBe(false);
unmount();
}
});
it("does not mention Apolo Energia on the other service pages", async () => {
for (const slug of [
"criacao-de-sites",
"engenharia-de-software",
"consultoria-nextjs",
"tauri-rust",
]) {
const { container, unmount } = await renderService(slug);
expect(container.textContent).not.toMatch(/Apolo/);
unmount();
}
});
it("puts the FAQ before the next step and ends every service page with a contact button", async () => {
for (const { slug, faq, ctaLabel } of servicePages) {
const { container, unmount } = await renderService(slug);
const nextStep = screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
});
if (faq?.length) {
const faqHeading = screen.getByRole("heading", {
level: 2,
name: "Perguntas frequentes",
});
expect(isBefore(faqHeading, nextStep)).toBe(true);
}
// o botão de contato vem depois das dúvidas: o último elemento da página é a seção do próximo passo
const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", {
name: `${ctaLabel} no WhatsApp`,
});
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me",
);
for (const question of faq ?? []) {
expect(
isBefore(screen.getByRole("heading", { name: question.question }), cta),
).toBe(true);
}
unmount();
}
});
it("renders the offer block only when some field is filled in", async () => {
const empty = await renderService("criacao-de-sites");
expect(
+12 -8
View File
@@ -51,11 +51,11 @@ export async function generateMetadata({
const url = `${siteUrl}/servicos/${service.slug}`;
return {
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
...buildPageSocialMetadata({
url,
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
type: "article",
}),
@@ -129,7 +129,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</a>
) : null}
</div>
<p className="mt-sp-4 text-sm leading-6 text-white/70">
<p className="mt-sp-4 text-[15px] md:text-sm leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
preencher um formulário. Atendimento remoto a partir de Garuva,
SC.
@@ -139,7 +139,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</section>
<ServiceRelatedCases cases={relatedCases} />
<ServiceRelatedCases
cases={relatedCases}
relevance={service.caseRelevance}
experienceNote={service.experienceNote}
/>
<section className="ds-section">
<div className="ds-section-shell">
@@ -171,7 +175,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<article className="min-w-0">
<div className="ds-kicker">Da conversa à entrega</div>
<h2 className="ds-card-title">Como trabalhamos</h2>
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-sm leading-8 text-[var(--ink-80)]">
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-[15px] md:text-sm leading-8 text-[var(--ink-80)]">
{service.process.map((step) => (
<li key={step} className="pl-sp-2">
{step}
@@ -224,7 +228,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
<ServiceOffer service={service} />
<section className="ds-section ds-section-dark pb-sp-6">
<ServiceFaq faq={service.faq} />
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
<div className="ds-kicker">Próximo passo</div>
@@ -252,8 +258,6 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</div>
</section>
<ServiceFaq faq={service.faq} />
</main>
);
}
@@ -0,0 +1,179 @@
import { render, screen, within } from "@testing-library/react";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies, isClientCase } from "@/lib/site-content";
import { ServiceRelatedCases } from "./ServiceRelatedCases";
/**
* Testes de caminhos-base de ServiceRelatedCases, direto no componente:
* sem case nem experiência, só a experiência, case sem frase de relação, case de laboratório,
* case sem captura e case sem site no ar. A existência dos arquivos em disco é simulada em getCaseMedia.
*/
jest.mock("@/app/cases/_components/case-media", () => ({
getCaseMedia: jest.fn(),
}));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
function withDesktopCapture() {
mockedGetCaseMedia.mockImplementation((id, device) =>
device === "desktop" ? getShowcaseAssets(id, device) : undefined,
);
}
const sev = getCase("sev-modaintima"); // cliente, com site no ar
const asServicos = getCase("as-servicos"); // cliente, sem site público
const twins = getCase("twins-lab"); // laboratório, sem site público
const TITLE = "Onde já apliquei isso";
const NOTE = "Texto de experiência de teste.";
beforeEach(() => {
mockedGetCaseMedia.mockReturnValue(undefined);
});
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("ServiceRelatedCases", () => {
it("path: no case and no experience note -> renders nothing", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: no case but an experience note -> keeps the block with the note and no case card", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={NOTE} />,
);
expect(
screen.getByRole("heading", { level: 2, name: TITLE }),
).toBeVisible();
expect(screen.getByText(NOTE)).toBeVisible();
expect(container.querySelectorAll("article")).toHaveLength(0);
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
});
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
withDesktopCapture();
const { container } = render(
<ServiceRelatedCases
cases={[sev]}
relevance={{ [sev.id]: "Frase de relação do SEV." }}
/>,
);
expect(
screen.getByRole("heading", { level: 2, name: TITLE }),
).toBeVisible();
expect(screen.queryByText("Na prática:")).toBeNull();
const card = container.querySelector("article");
if (!card) throw new Error("Missing case card");
expect(within(card).getByText(sev.headline)).toBeVisible();
// case de cliente: o kicker é só o nome
expect(within(card).getByText(sev.title)).toBeVisible();
expect(within(card).queryByText(/Projeto próprio/)).toBeNull();
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
expect(
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }),
).toHaveAttribute("href", `/cases/${sev.id}`);
expect(
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
).toHaveAttribute("href", sev.demoUrl);
});
it("path: cases and an experience note -> the note comes first, in text only, with no link", () => {
render(
<ServiceRelatedCases
cases={[sev]}
relevance={{ [sev.id]: "Frase de relação do SEV." }}
experienceNote={NOTE}
/>,
);
const note = screen.getByText(NOTE, { exact: false });
expect(screen.getByText("Na prática:")).toBeVisible();
expect(note.closest("a")).toBeNull();
expect(note.querySelector("a")).toBeNull();
expect(
note.compareDocumentPosition(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("path: case with a desktop capture -> cover link with the poster, hidden from assistive tech", () => {
withDesktopCapture();
const { container } = render(
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
);
const coverLink = container.querySelector(
`article a[aria-hidden="true"][href="/cases/${sev.id}"]`,
);
expect(coverLink).not.toBeNull();
expect(coverLink).toHaveAttribute("tabindex", "-1");
expect(coverLink?.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
`Captura de tela do site ${sev.title}`,
);
});
it("path: case without a desktop capture -> no cover link, text and buttons stay", () => {
const { container } = render(
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
);
expect(container.querySelector("a[aria-hidden='true']")).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
expect(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
).toBeVisible();
});
it("path: laboratory case -> kicker marks it as 'Projeto próprio'", () => {
expect(isClientCase(twins)).toBe(false);
render(
<ServiceRelatedCases
cases={[twins]}
relevance={{ [twins.id]: "Frase." }}
/>,
);
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
});
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
render(<ServiceRelatedCases cases={[sev]} relevance={{}} />);
const card = screen.getByRole("heading", { level: 3 }).closest("article");
expect(card).not.toBeNull();
expect(within(card as HTMLElement).getByText(sev.summary)).toBeVisible();
});
it("path: case without a public site -> no 'Abrir o site' link", () => {
expect(asServicos.demoUrl).toBeNull();
render(
<ServiceRelatedCases
cases={[asServicos]}
relevance={{ [asServicos.id]: "Frase." }}
/>,
);
expect(
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
});
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { caseStudies } from "@/lib/site-content";
import { ServiceRelatedCases } from "./ServiceRelatedCases";
const caseStudy = caseStudies[0];
const note = "Desde 2022 trabalho com isso, em sistemas internos.";
it("renders nothing without cases and without an experience note", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("shows only the experience note, without the 'open the case' sentence, when there are no cases", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={note} />,
);
expect(
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
).toBeVisible();
expect(screen.getByText(note)).toBeVisible();
expect(screen.queryByText(/Abra o case/)).toBeNull();
expect(screen.queryByRole("link")).toBeNull();
expect(container.querySelector(".ds-grid-2")).toBeNull();
});
it("invites to open the case and lists each case when there are some", () => {
render(
<ServiceRelatedCases
cases={[caseStudy]}
relevance={{ [caseStudy.id]: "Frase própria sobre a relação." }}
/>,
);
expect(screen.getByText(/Abra o case/)).toBeVisible();
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
expect(
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }),
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
});
it("falls back to the case summary when no relevance sentence is given", () => {
render(<ServiceRelatedCases cases={[caseStudy]} relevance={{}} />);
expect(screen.getByText(caseStudy.summary)).toBeVisible();
});
@@ -1,16 +1,67 @@
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
/** "Veja projetos parecidos": os cases ligados ao serviço. Sem case relacionado, não mostra nada. */
function ServiceCaseCard({
caseStudy,
relevance,
}: {
caseStudy: CaseStudyContent;
/** Por que este case interessa ao serviço; sem frase própria, vale o resumo do case. */
relevance?: string;
}) {
return (
<article className="ds-card">
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
<div className="ds-kicker">
{isClientCase(caseStudy)
? caseStudy.title
: `${caseStudy.title} · Projeto próprio`}
</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-kicker mt-sp-5">Relação com este serviço</p>
<p className="ds-copy !mt-sp-2">{relevance ?? caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<Link href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
</a>
) : null}
</div>
</article>
);
}
/**
* "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma frase sobre o que ele tem a ver com ele,
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase
* que manda abrir o case; sem case nem experiência, não mostra nada.
*/
export function ServiceRelatedCases({
cases,
relevance,
experienceNote,
}: {
cases: readonly CaseStudyContent[];
/** Por que cada case interessa a este serviço (chave: id do case). */
relevance: Readonly<Record<string, string>>;
experienceNote?: string;
}) {
if (cases.length === 0) return null;
const hasCases = cases.length > 0;
if (!hasCases && !experienceNote) return null;
return (
<section
@@ -23,61 +74,37 @@ export function ServiceRelatedCases({
<div className="ds-section-meta">
<p className="ds-eyebrow">Trabalhos parecidos</p>
<h2 id="casos-relacionados-titulo" className="ds-section-title">
Veja projetos parecidos, já prontos
Onde já apliquei isso
</h2>
<p className="ds-section-description">
Projetos reais nesta área. Abra o case para ver o que foi feito e
como ficou.
</p>
{hasCases ? (
<p className="ds-section-description">
Cada projeto tem a ver com este serviço de um jeito. Abra o case
para ver o que foi feito e como ficou.
</p>
) : null}
</div>
</div>
<div className="ds-grid-2">
{cases.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
{getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<div className="ds-kicker">
{isClientCase(caseStudy)
? caseStudy.title
: `${caseStudy.title} · Protótipo de laboratório`}
</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
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
</a>
) : null}
</div>
</article>
))}
</div>
{experienceNote ? (
<p className="ds-copy mb-sp-8 max-w-[72ch]">
<strong className="font-semibold text-[var(--ink)]">
Na prática:
</strong>{" "}
{experienceNote}
</p>
) : null}
{hasCases ? (
<div className="ds-grid-2">
{cases.map((caseStudy) => (
<ServiceCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
relevance={relevance[caseStudy.id]}
/>
))}
</div>
) : null}
</div>
</section>
);
+9 -20
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
@@ -15,9 +14,9 @@ import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas";
const pageTitle = "Sites, sistemas e aplicativos sob medida";
const pageDescription =
"Sites, sistemas web e aplicativos sob medida para sua empresa apresentar o negócio e organizar a operação. Evandro, desenvolvedor em Garuva-SC, atende Joinville, região e todo o Brasil de forma remota.";
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Atendimento de Garuva-SC para todo o Brasil.";
const businessIntents = [
{
@@ -188,21 +187,11 @@ export default function ServicesIndexPage() {
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
{getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
@@ -241,7 +230,7 @@ export default function ServicesIndexPage() {
</div>
<div>
<h2 className="ds-card-title">Antes de contratar</h2>
<dl className="mt-sp-5 space-y-sp-6 text-sm">
<dl className="mt-sp-5 space-y-sp-6 text-[15px] md:text-sm">
<div>
<dt className="font-semibold">
Quanto custa e quanto tempo leva?
+2
View File
@@ -0,0 +1,2 @@
// "server-only" lança fora do servidor do Next; nos testes (jsdom) o módulo não faz nada.
export {};
+74 -8
View File
@@ -1,16 +1,82 @@
import { statSync } from "node:fs";
import { render } from "@testing-library/react";
import { OgCard, readProjectOgBackground } from "./og-card";
import { OG_IMAGE_MAX_BYTES, OgCard, readProjectOgImage } from "./og-card";
import { PROJECT_VIDEO_IDS } from "./project-showcase";
describe("readProjectOgBackground", () => {
it("returns the recorded cover of a showcase project as a JPEG data URI", () => {
const uri = readProjectOgBackground("nutriscan");
expect(uri).toMatch(/^data:image\/jpeg;base64,/);
expect((uri ?? "").length).toBeGreaterThan(5_000);
jest.mock("node:fs", () => {
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
return { ...actual, statSync: jest.fn(actual.statSync) };
});
const mockedStatSync = jest.mocked(statSync);
const actualStatSync =
jest.requireActual<typeof import("node:fs")>("node:fs").statSync;
afterEach(() => {
mockedStatSync.mockImplementation(actualStatSync);
});
/** Largura e altura de um JPEG, lidas do primeiro marcador de quadro (SOF). */
function jpegSize(image: Buffer) {
let offset = 2;
while (offset < image.length) {
const marker = image[offset + 1];
const length = image.readUInt16BE(offset + 2);
if (
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8
) {
return {
height: image.readUInt16BE(offset + 5),
width: image.readUInt16BE(offset + 7),
};
}
offset += 2 + length;
}
throw new Error("JPEG sem marcador de quadro");
}
describe("readProjectOgImage", () => {
it("returns the recorded cover of a showcase project as it is, in JPEG", () => {
const image = readProjectOgImage("nutriscan");
expect(image?.subarray(0, 2).toString("hex")).toBe("ffd8");
expect(image?.length).toBeGreaterThan(5_000);
});
it("serves every recorded cover below 150 KB and in the 1200x630 card size", () => {
expect(OG_IMAGE_MAX_BYTES).toBe(150_000);
for (const id of PROJECT_VIDEO_IDS) {
const image = readProjectOgImage(id);
expect(image).toBeDefined();
expect(image?.length ?? Infinity).toBeLessThan(150_000);
expect(jpegSize(image as Buffer)).toEqual({ width: 1200, height: 630 });
}
});
it("returns undefined for a cover that weighs the limit or more, so the plain card is used", () => {
mockedStatSync.mockReturnValue({
size: OG_IMAGE_MAX_BYTES,
} as ReturnType<typeof statSync>);
expect(readProjectOgImage("nutriscan")).toBeUndefined();
mockedStatSync.mockReturnValue({
size: OG_IMAGE_MAX_BYTES - 1,
} as ReturnType<typeof statSync>);
expect(readProjectOgImage("nutriscan")).toBeDefined();
});
it("returns undefined for projects without a recorded cover", () => {
expect(readProjectOgBackground("twins-lab")).toBeUndefined();
expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined();
expect(readProjectOgImage("twins-lab")).toBeUndefined();
expect(readProjectOgImage("../../etc/passwd")).toBeUndefined();
});
it("returns undefined when the file is missing on disk", () => {
mockedStatSync.mockImplementation(() => {
throw new Error("ENOENT");
});
expect(readProjectOgImage("nutriscan")).toBeUndefined();
});
});
+17 -5
View File
@@ -1,4 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import "server-only";
import { readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase";
@@ -8,13 +9,24 @@ const brandMark = `data:image/png;base64,${readFileSync(
export const OG_SIZE = { width: 1200, height: 630 } as const;
export const OG_CONTENT_TYPE = "image/png";
export const OG_PHOTO_CONTENT_TYPE = "image/jpeg";
/** Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), como data URI; `undefined` se não existir. */
export function readProjectOgBackground(id: string): string | undefined {
/** Teto de uma imagem de compartilhamento: quem recebe o link baixa a imagem a cada prévia, então ela precisa ser leve. */
export const OG_IMAGE_MAX_BYTES = 150_000;
/**
* Capa 1200x630 gravada da página do projeto (public/videos/<id>/og.jpg), usada como está na imagem de compartilhamento.
* `undefined` se não existir ou se pesar o teto ou mais: nesse caso quem chama gera o cartão simples, sem imagem de fundo.
*/
export function readProjectOgImage(id: string): Buffer | undefined {
if (!PROJECT_VIDEO_IDS.includes(id)) return undefined;
const path = join(process.cwd(), "public", getOgImagePath(id));
if (!existsSync(path)) return undefined;
return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`;
try {
if (statSync(path).size >= OG_IMAGE_MAX_BYTES) return undefined;
return readFileSync(path);
} catch {
return undefined;
}
}
export function OgCard({
+41
View File
@@ -0,0 +1,41 @@
import { portfolio } from "./portfolio-data";
describe("public/portfolio-data.json", () => {
it("writes the Innovo company name with the correct accents and case", () => {
const innovo = portfolio.experiences.find(
({ id }) => id === "innovo-tecnologia",
);
expect(innovo?.company).toBe("Innovo Tecnologia da Informação Ltda.");
expect(JSON.stringify(portfolio)).not.toMatch(/Informacao|Da Informa/);
});
it("keeps the kind of every project, which separates client work from the laboratory", () => {
expect(
portfolio.projects.map(({ id, kind }) => [id, kind] as const),
).toEqual([
["sev-modaintima", "cliente"],
["lavacao-047", "cliente"],
["nutriscan", "laboratorio"],
["twins-lab", "laboratorio"],
["reddit-explorer", "laboratorio"],
["sc-plus", "cliente"],
["gcc-installer", "laboratorio"],
["as-servicos", "cliente"],
]);
});
it("describes each project in a subtitle a business owner understands", () => {
const subtitles = Object.fromEntries(
portfolio.projects.map(({ id, subtitle }) => [id, subtitle]),
);
expect(subtitles["sc-plus"]).toBe(
"Site institucional com áreas, projetos e contato",
);
expect(subtitles["gcc-installer"]).toBe(
"Instalador do compilador GCC para Windows",
);
expect(Object.values(subtitles).join(" ")).not.toMatch(
/Next\.js|MongoDB|MinGW|Gêmeo Digital|gerada em código/,
);
});
});
+1
View File
@@ -1,3 +1,4 @@
import "server-only";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import {
+66 -1
View File
@@ -1,15 +1,23 @@
import sitemap from "../app/sitemap";
import {
buildBreadcrumbList,
buildCaseJsonLd,
buildPageSocialMetadata,
buildPersonJsonLd,
buildProfilePageJsonLd,
buildServiceJsonLd,
buildWebSiteJsonLd,
getCaseImageUrl,
SEO_IDS,
twitterHandle,
} from "./seo";
import { caseStudies, servicePages, siteName, siteUrl } from "./site-content";
import {
caseStudies,
isClientCase,
servicePages,
siteName,
siteUrl,
} from "./site-content";
const representativeService = servicePages[0];
if (!representativeService) throw new Error("Missing representative service");
@@ -89,6 +97,63 @@ it("builds a canonical service with only the verified service area", () => {
});
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
it("describes a client case as a CreativeWork made by the person for the client", () => {
const caseStudy = getCase("sev-modaintima");
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
expect(buildCaseJsonLd(caseStudy)).toEqual({
"@type": "CreativeWork",
"@id": `${pageUrl}#creative-work`,
name: "SEV Moda Íntima: Catálogo digital e loja virtual",
description: caseStudy.metaDescription,
url: pageUrl,
image: `${pageUrl}/opengraph-image/${caseStudy.id}`,
inLanguage: "pt-BR",
creator: { "@id": SEO_IDS.person },
sourceOrganization: {
"@type": "Organization",
name: "SEV Moda Íntima",
url: "https://sevmodaintima.com.br",
},
keywords:
"Next.js 16, TypeScript, PostgreSQL, Prisma, Tailwind CSS 4, Framer Motion",
});
});
it("builds a CreativeWork for every case, with the client only on client work", () => {
for (const caseStudy of caseStudies) {
const work = buildCaseJsonLd(caseStudy);
expect(work).toMatchObject({
"@type": "CreativeWork",
url: `${siteUrl}/cases/${caseStudy.id}`,
image: getCaseImageUrl(caseStudy.id),
creator: { "@id": SEO_IDS.person },
});
expect(work.name).toContain(caseStudy.title);
expect(work.description.length).toBeGreaterThan(40);
expect(work.keywords).toBe(caseStudy.stack.join(", "));
expect("sourceOrganization" in work).toBe(isClientCase(caseStudy));
}
});
it("omits the client address when a client case has no public site", () => {
const work = buildCaseJsonLd(getCase("as-servicos")) as {
url: string;
sourceOrganization?: Record<string, unknown>;
};
expect(work.sourceOrganization).toStrictEqual({
"@type": "Organization",
name: "AS Serviços",
});
expect(work.url).toBe(`${siteUrl}/cases/as-servicos`);
});
it("builds contiguous breadcrumbs from readonly tuples", () => {
const pageUrl = `${siteUrl}/servicos/x`;
const items = [
+38 -1
View File
@@ -1,5 +1,11 @@
import type { Metadata } from "next";
import { type ServicePageContent, siteName, siteUrl } from "./site-content";
import {
type CaseStudyContent,
isClientCase,
type ServicePageContent,
siteName,
siteUrl,
} from "./site-content";
export const twitterHandle = "@evandrodevbr";
@@ -78,6 +84,37 @@ export function buildServiceJsonLd(service: ServicePageContent) {
} as const;
}
/** Imagem de compartilhamento do case: a mesma que as redes recebem em `og:image` (rota opengraph-image do case). */
export function getCaseImageUrl(id: string) {
return `${siteUrl}/cases/${id}/opengraph-image/${id}`;
}
/** Trabalho do case como `CreativeWork`: autor ligado à pessoa e, nos projetos de cliente, o cliente em nome de quem foi feito. */
export function buildCaseJsonLd(caseStudy: CaseStudyContent) {
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
return {
"@type": "CreativeWork",
"@id": `${pageUrl}#creative-work`,
name: `${caseStudy.title}: ${caseStudy.subtitle}`,
description: caseStudy.metaDescription,
url: pageUrl,
image: getCaseImageUrl(caseStudy.id),
inLanguage: "pt-BR",
creator: { "@id": SEO_IDS.person },
...(isClientCase(caseStudy)
? {
sourceOrganization: {
"@type": "Organization",
name: caseStudy.title,
...(caseStudy.demoUrl ? { url: caseStudy.demoUrl } : {}),
},
}
: {}),
keywords: caseStudy.stack.join(", "),
};
}
export function buildBreadcrumbList(
pageUrl: string,
items: readonly (readonly [name: string, url: string])[],
+36
View File
@@ -0,0 +1,36 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const root = join(__dirname, "..");
describe("fronteira entre servidor e cliente", () => {
it.each([
"app/cases/_components/case-media.ts",
"lib/og-card.tsx",
"lib/portfolio-data.server.ts",
])("%s só pode ser importado no servidor", (file) => {
const source = readFileSync(join(root, file), "utf8");
expect(source).toMatch(/^import "server-only";$/m);
});
it("declara server-only como dependência", () => {
const manifest = JSON.parse(
readFileSync(join(root, "package.json"), "utf8"),
) as { dependencies: Record<string, string> };
expect(manifest.dependencies["server-only"]).toBeDefined();
});
it("não deixa nenhum componente de cliente importar um módulo só de servidor", () => {
const serverOnly =
/from "(?:@\/lib\/og-card|@\/lib\/portfolio-data\.server|(?:\.{1,2}\/)+(?:[\w-]+\/)*case-media)"/;
const clientFiles = [
"app/cases/_components/CasePlayer.tsx",
"app/curriculo/CvDownloadLink.tsx",
];
for (const file of clientFiles) {
const source = readFileSync(join(root, file), "utf8");
expect(source).toMatch(/^"use client";/);
expect(source).not.toMatch(serverOnly);
}
});
});
+85 -1
View File
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import portfolioData from "@/public/portfolio-data.json";
import {
caseStudies,
clientCases,
@@ -77,7 +78,7 @@ it("leaves the commercial offer of every service empty for the owner to fill in"
it("mentions the region and a benefit in the title and description of every service", () => {
for (const service of servicePages) {
expect(`${service.seoTitle} ${service.metaDescription}`).toMatch(
expect(`${service.metaTitle} ${service.metaDescription}`).toMatch(
/Garuva|Joinville|Santa Catarina/,
);
expect(service.metaDescription).toMatch(/remoto|Brasil/);
@@ -148,6 +149,89 @@ it("resolves every declared related case ID", () => {
expect(unresolved).toEqual([]);
});
it("only cites project ids that exist in public/portfolio-data.json", () => {
const projectIds = new Set(portfolioData.projects.map(({ id }) => id));
const cited = [
...caseStudies.map(({ id }) => id),
...servicePages.flatMap(({ relatedCaseIds }) => relatedCaseIds),
...servicePages.flatMap(({ caseRelevance }) => Object.keys(caseRelevance)),
];
expect(cited.length).toBeGreaterThan(0);
expect(cited.filter((id) => !projectIds.has(id))).toEqual([]);
});
it("writes one sentence on why each related case matters to its service", () => {
for (const service of servicePages) {
expect(Object.keys(service.caseRelevance).sort()).toEqual(
[...service.relatedCaseIds].sort(),
);
for (const sentence of Object.values(service.caseRelevance)) {
expect(sentence.length).toBeGreaterThan(30);
expect(sentence).toMatch(/[.]$/);
}
}
});
it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => {
const apolo = portfolioData.experiences.find(
({ id }) => id === "apolo-energia",
);
expect(apolo?.period).toMatch(/2022/);
const withNote = servicePages
.filter(({ experienceNote }) => experienceNote)
.map(({ slug }) => slug);
expect(withNote).toEqual([
"integracao-hardware-software-iot",
"arquitetura-de-software",
]);
for (const slug of withNote) {
const note = getService(slug).experienceNote ?? "";
expect(note).toMatch(/Apolo Energia/);
expect(note).toMatch(/desde 2022/i);
expect(note).toMatch(/sistemas internos/i);
expect(note).toMatch(/aplicativos/i);
expect(note).toMatch(/APIs/);
// sem link e sem detalhe de operação do cliente
expect(note).not.toMatch(/https?:|www\.|\.com/i);
}
});
it("keeps the problem of every client case concrete, with no invented figure or quote", () => {
for (const caseStudy of clientCases) {
expect(caseStudy.problem.length).toBeGreaterThan(200);
expect(caseStudy.problem).not.toMatch(/["“”]/);
expect(caseStudy.problem).not.toMatch(/\d+\s?%|R\$\s?\d/);
}
expect(
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
).toMatch(/tamanho/);
expect(
caseStudies.find(({ id }) => id === "sev-modaintima")?.problem,
).toMatch(/WhatsApp/);
});
it("leaves the optional facts of every case empty until there is real data", () => {
for (const caseStudy of caseStudies) {
expect(caseStudy.facts ?? []).toEqual([]);
}
});
it("writes project subtitles without technical jargon", () => {
const jargon =
/Next\.js|React|MongoDB|MinGW|\/GCC|tkinter|\bSSE\b|Three\.js|em código|r\/artificial/i;
for (const caseStudy of caseStudies) {
expect(caseStudy.subtitle).not.toMatch(jargon);
}
for (const project of portfolioData.projects) {
expect(project.subtitle).not.toMatch(jargon);
}
expect(
portfolioData.projects.find(({ id }) => id === "sc-plus")?.subtitle,
).toBe("Site institucional com áreas, projetos e contato");
});
it("does not publish the unavailable Twins Lab demo", () => {
expect(
caseStudies.find((item) => item.id === "twins-lab")?.demoUrl,
+102 -27
View File
@@ -8,7 +8,9 @@ export interface ServicePageContent {
slug: string;
navLabel: string;
eyebrow: string;
seoTitle: string;
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */
metaDescription: string;
headline: string;
intro: string;
@@ -22,6 +24,10 @@ export interface ServicePageContent {
ctaLabel: string;
/** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */
relatedCaseIds: string[];
/** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */
caseRelevance: Record<string, string>;
/** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */
experienceNote?: string;
/** Oferta: preencha só com valores reais. O bloco "Investimento e prazo" só aparece quando algum campo existe. */
startingPrice?: string;
typicalTimeline?: string;
@@ -35,9 +41,13 @@ export interface CaseStudyContent {
title: string;
/** Tipo do projeto, em poucas palavras. */
subtitle: string;
/** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */
metaTitle: string;
/** Descrição para busca, com até 155 caracteres. */
metaDescription: string;
/** Título da página, em linguagem de negócio. */
headline: string;
/** Resumo de uma ou duas frases (cards, descrição de busca e abertura da página). */
/** Resumo de uma ou duas frases (cards e abertura da página). */
summary: string;
/** O que muda para quem usa, em uma frase. */
impact: string;
@@ -45,11 +55,13 @@ export interface CaseStudyContent {
context: string;
/** O problema que o projeto resolve. */
problem: string;
/** Fatos verificáveis do projeto, como "No ar desde ...". Só aparece na página quando há algum; sem dado real, fica de fora. */
facts?: string[];
/** O que foi feito, escrito como benefício. */
deliveries: string[];
/** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */
result?: string[];
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos no fim da página). */
/** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */
stack: string[];
decisions: string[];
demoUrl?: string | null;
@@ -62,9 +74,9 @@ export const servicePages: ServicePageContent[] = [
slug: "criacao-de-sites",
navLabel: "Sites profissionais",
eyebrow: "Presença digital",
seoTitle: "Criação de Sites para Empresas em Garuva e Joinville",
metaTitle: "Criação de sites para empresas em Garuva",
metaDescription:
"Site profissional para apresentar sua empresa e levar o cliente ao seu WhatsApp. Desenvolvido em Garuva-SC, com atendimento em Joinville, região e remoto no Brasil.",
"Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
headline: "Um site que explica seu negócio.",
answer:
"Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.",
@@ -99,6 +111,16 @@ export const servicePages: ServicePageContent[] = [
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
ctaLabel: "Conversar sobre meu site",
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"],
caseRelevance: {
"sev-modaintima":
"Um catálogo com busca e filtros que leva o pedido da cliente direto ao WhatsApp da loja.",
"lavacao-047":
"Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.",
"sc-plus":
"Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.",
"as-servicos":
"Um site institucional que conta duas frentes da empresa em uma só história, com cena 3D na rolagem.",
},
faq: [
{
question: "Quanto custa e o que preciso ter pronto?",
@@ -121,9 +143,9 @@ export const servicePages: ServicePageContent[] = [
slug: "engenharia-de-software",
navLabel: "Sistemas web e aplicativos mobile",
eyebrow: "Engenharia de software",
seoTitle: "Sistemas Web e Aplicativos sob Medida em Santa Catarina",
metaTitle: "Sistemas web e aplicativos sob medida",
metaDescription:
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas em um só lugar. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
"Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.",
headline: "Sistemas e apps para o seu trabalho.",
answer:
"Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.",
@@ -158,6 +180,12 @@ export const servicePages: ServicePageContent[] = [
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
ctaLabel: "Conversar sobre meu sistema ou app",
relatedCaseIds: ["sev-modaintima", "lavacao-047"],
caseRelevance: {
"sev-modaintima":
"Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.",
"lavacao-047":
"Um painel em que o dono altera preços, textos e fotos, e a mudança aparece no site sem nova publicação.",
},
faq: [
{
question: "Preciso saber qual tecnologia usar?",
@@ -180,9 +208,9 @@ export const servicePages: ServicePageContent[] = [
slug: "consultoria-nextjs",
navLabel: "Melhorias em sites e sistemas Next.js",
eyebrow: "Consultoria Next.js",
seoTitle: "Consultoria Next.js para Melhorar Seu Site ou Sistema",
metaTitle: "Consultoria Next.js para site ou sistema",
metaDescription:
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Atendimento a partir de Garuva-SC, em Joinville e remoto no Brasil.",
"Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Garuva-SC, Joinville e remoto no Brasil.",
headline: "Seu projeto Next.js pode funcionar melhor.",
answer:
"Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.",
@@ -217,6 +245,14 @@ export const servicePages: ServicePageContent[] = [
stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"],
ctaLabel: "Conversar sobre meu projeto Next.js",
relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"],
caseRelevance: {
"sev-modaintima":
"Um projeto em Next.js 16 com catálogo, busca, estoque e painel, que conheço por dentro.",
"lavacao-047":
"Um projeto em Next.js 16 em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.",
"sc-plus":
"Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.",
},
faq: [
{
question: "Não sei se meu site usa Next.js. Posso conversar?",
@@ -239,9 +275,9 @@ export const servicePages: ServicePageContent[] = [
slug: "tauri-rust",
navLabel: "Aplicativos desktop para Windows e Linux",
eyebrow: "Aplicativos desktop",
seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust",
metaTitle: "Aplicativos desktop para Windows e Linux",
metaDescription:
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da sua empresa. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
"Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.",
headline: "Aplicativos para Windows e Linux.",
answer:
"Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são as plataformas atendidas; as versões e distribuições compatíveis são combinadas em cada projeto.",
@@ -281,6 +317,7 @@ export const servicePages: ServicePageContent[] = [
],
ctaLabel: "Conversar sobre meu aplicativo desktop",
relatedCaseIds: [],
caseRelevance: {},
faq: [
{
question: "O mesmo aplicativo pode funcionar em Windows e Linux?",
@@ -303,9 +340,9 @@ export const servicePages: ServicePageContent[] = [
slug: "integracao-hardware-software-iot",
navLabel: "Integração de equipamentos e sistemas",
eyebrow: "Hardware, software e IoT",
seoTitle: "Integração de Equipamentos e Sistemas (IoT) em Santa Catarina",
metaTitle: "Integração de equipamentos e sistemas (IoT)",
metaDescription:
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.",
"Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva-SC, Joinville e remoto no Brasil.",
headline: "Dados dos equipamentos no seu sistema.",
answer:
"Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro vemos o que cada dispositivo permite acessar; a integração segue essas interfaces e os requisitos de segurança e funcionamento.",
@@ -340,6 +377,12 @@ export const servicePages: ServicePageContent[] = [
stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"],
ctaLabel: "Avaliar minha integração",
relatedCaseIds: ["twins-lab"],
caseRelevance: {
"twins-lab":
"Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.",
},
experienceNote:
"Desde 2022 trabalho com isso na Apolo Energia: sistemas internos, aplicativos, APIs e integração de dispositivos IoT para a troca de dados entre serviços e parceiros. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
faq: [
{
question: "Qualquer equipamento pode ser conectado?",
@@ -362,10 +405,9 @@ export const servicePages: ServicePageContent[] = [
slug: "arquitetura-de-software",
navLabel: "Diagnóstico e arquitetura de software",
eyebrow: "Diagnóstico e decisão",
seoTitle:
"Diagnóstico e Arquitetura de Software para Decidir com Segurança",
metaTitle: "Diagnóstico e arquitetura de software",
metaDescription:
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano de ação, de Garuva-SC para Joinville e todo o Brasil.",
"Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano, de Garuva-SC para todo o Brasil.",
headline: "Entenda o problema antes de mudar tudo.",
answer:
"Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.",
@@ -406,6 +448,12 @@ export const servicePages: ServicePageContent[] = [
],
ctaLabel: "Conversar sobre um diagnóstico",
relatedCaseIds: ["sev-modaintima"],
caseRelevance: {
"sev-modaintima":
"Uma loja completa, com catálogo, pedidos, estoque e painel, em que as decisões técnicas estão explicadas no case.",
},
experienceNote:
"Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.",
faq: [
{
question: "Preciso ter uma equipe técnica para contratar?",
@@ -431,6 +479,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "sev-modaintima",
title: "SEV Moda Íntima",
subtitle: "Catálogo digital e loja virtual",
metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp",
metaDescription:
"Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.",
headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp",
summary:
"Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.",
@@ -439,7 +490,7 @@ export const caseStudies: CaseStudyContent[] = [
context:
"A SEV Moda Íntima vende moda íntima pela internet. O site funciona como vitrine e loja virtual: apresenta os produtos, recebe os pedidos pelo WhatsApp e dá à loja um painel para cuidar do catálogo.",
problem:
"Uma loja de moda íntima tem muitos produtos, em vários modelos e tamanhos. A cliente precisa achar o que procura com facilidade e fechar o pedido sem complicação. A loja precisa manter catálogo, estoque e pedidos organizados.",
"A loja vende moda íntima em várias peças e tamanhos. A cliente precisa achar o que procura entre as opções e escolher a medida certa, e o pedido segue pelo WhatsApp, com o frete calculado pela distância. A loja, por sua vez, precisa saber o que ainda tem de cada tamanho.",
deliveries: [
"Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.",
"Filtros e busca inteligente: a cliente encontra o que procura e os itens mais relevantes aparecem primeiro.",
@@ -468,6 +519,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "lavacao-047",
title: "Lavação 047",
subtitle: "Site e painel para lavação automotiva",
metaTitle: "Lavação 047: site com preços e WhatsApp",
metaDescription:
"Site da Lavação 047, em Garuva-SC: preço pelo porte do veículo, pedido pelo WhatsApp e painel para o dono editar tudo sozinho.",
headline:
"Site com tabela de preços e pedido pelo WhatsApp para uma lavação em Garuva",
summary:
@@ -477,7 +531,7 @@ export const caseStudies: CaseStudyContent[] = [
context:
"A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.",
problem:
"Quem procura uma lavação quer saber quanto custa para o seu veículo e como chegar. O dono, por sua vez, precisa manter preços, textos e fotos em dia sem depender de alguém a cada mudança.",
"O cliente chega com perguntas diretas: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.",
deliveries: [
"Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.",
"Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.",
@@ -508,6 +562,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "nutriscan",
title: "NutriScan",
subtitle: "Consulta de produtos por nome e código de barras",
metaTitle: "NutriScan: consulta de alimentos",
metaDescription:
"Consulta de alimentos por nome ou código de barras, com cache próprio e a base pública Open Food Facts. Projeto próprio, no ar para teste.",
headline: "Consulta de alimentos por nome ou código de barras",
summary:
"Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando um cache próprio com a base pública Open Food Facts.",
@@ -534,7 +591,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "twins-lab",
title: "Twins Lab",
subtitle: "Protótipo de laboratório de gêmeo digital industrial",
subtitle: "Painel ao vivo de um reator simulado",
metaTitle: "Twins Lab: painel de reator simulado",
metaDescription:
"Protótipo de painel ao vivo que mostra a simulação de um reator industrial, com acesso por login e histórico guardado. Projeto próprio.",
headline:
"Painel ao vivo para acompanhar a simulação de um reator industrial",
summary:
@@ -561,7 +621,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "reddit-explorer",
title: "Reddit Posts Explorer",
subtitle: "Posts do r/artificial em MongoDB, filtrados por período",
subtitle: "Posts de um fórum de IA, filtrados por período",
metaTitle: "Reddit Posts Explorer: coleta e filtros",
metaDescription:
"Coleta os posts em alta do r/artificial sem duplicar e permite filtrar por período e ordenar por votos ou comentários. Projeto próprio.",
headline:
"Posts de um fórum de inteligência artificial, coletados e filtrados por período",
summary:
@@ -588,7 +651,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "sc-plus",
title: "SC Plus Aquicultura",
subtitle: "Página institucional",
subtitle: "Site institucional com áreas, projetos e contato",
metaTitle: "SC Plus Aquicultura: página institucional",
metaDescription:
"Página institucional da SC Plus Aquicultura, com áreas de atuação, projetos, apoiadores e contato, preparada para os buscadores.",
headline:
"Página institucional para a SC Plus Aquicultura ser encontrada na internet",
summary:
@@ -596,9 +662,9 @@ export const caseStudies: CaseStudyContent[] = [
impact:
"A instituição ganhou uma página própria, preparada para ser encontrada nos buscadores.",
context:
"A SC Plus Aquicultura é uma instituição do setor de aquicultura. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
"A SC Plus Aquicultura é uma instituição de Garuva, em Santa Catarina, que atua com educação, ciência e tecnologia sustentável. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.",
problem:
"Sem uma página própria, quem procura a SC Plus na internet não encontra uma apresentação clara do que a instituição faz. Faltava um endereço próprio, com as áreas, os projetos, os apoiadores e o contato.",
"A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.",
deliveries: [
"Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.",
"Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.",
@@ -616,7 +682,10 @@ export const caseStudies: CaseStudyContent[] = [
{
id: "gcc-installer",
title: "MinGW GCC Installer",
subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows",
subtitle: "Instalador do compilador GCC para Windows",
metaTitle: "MinGW GCC Installer: instalador no Windows",
metaDescription:
"Aplicativo para Windows que lista as versões do MinGW-w64, instala o compilador GCC e ajusta o PATH, tudo em uma janela. Projeto próprio.",
headline: "Instalador do compilador GCC para Windows em uma única janela",
summary:
"Aplicativo para Windows que lista as versões do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e deixa o GCC pronto para uso, tudo em uma janela.",
@@ -644,6 +713,9 @@ export const caseStudies: CaseStudyContent[] = [
id: "as-servicos",
title: "AS Serviços",
subtitle: "Site institucional com cena 3D",
metaTitle: "AS Serviços: site com cena 3D",
metaDescription:
"Site institucional da AS Serviços com uma cena 3D que acompanha a rolagem e conta a engenharia física e o software numa só história.",
headline: "Site institucional com uma cena 3D que acompanha a rolagem",
summary:
"Site institucional da AS Serviços com uma cena 3D criada em código: uma casa e uma placa-mãe desenhadas em linhas que reagem à rolagem da página.",
@@ -652,7 +724,7 @@ export const caseStudies: CaseStudyContent[] = [
context:
"A AS Serviços reúne engenharia física e software. O site precisava mostrar as duas frentes na mesma história.",
problem:
"Como contar, em uma única página, duas frentes tão diferentes quanto a engenharia física e o software, de um jeito que prenda a atenção de quem rola a página?",
"A empresa reúne duas frentes bem diferentes: a engenharia física, de um lado, e o software, do outro. Em um site comum, as duas podem virar uma lista de serviços sem relação entre si. O desafio era mostrar as duas na mesma história, de um jeito que prenda a atenção de quem rola a página e que continue leve em celulares e conexões mais simples.",
deliveries: [
"Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.",
"As duas frentes da empresa, a física e a digital, aparecem na mesma história visual.",
@@ -670,10 +742,13 @@ export const caseStudies: CaseStudyContent[] = [
},
];
const clientProjectIds = new Set(
portfolio.projects.filter(isClientProject).map(({ id }) => id),
);
/** Projetos feitos para clientes ficam à frente; os pessoais e de estudo vão para o Laboratório. */
export function isClientCase(caseStudy: { id: string }) {
const project = portfolio.projects.find(({ id }) => id === caseStudy.id);
return project ? isClientProject(project) : false;
return clientProjectIds.has(caseStudy.id);
}
export const clientCases = caseStudies.filter(isClientCase);
+6 -6
View File
@@ -12,7 +12,7 @@
{
"id": "innovo-tecnologia",
"title": "Desenvolvedor Fullstack",
"company": "Innovo Tecnologia Da Informacao Ltda.",
"company": "Innovo Tecnologia da Informação Ltda.",
"period": "Dezembro 2024 - Fevereiro 2025",
"location": "Joinville, Santa Catarina, Brasil",
"type": "Tempo Integral · Híbrido",
@@ -166,7 +166,7 @@
"id": "twins-lab",
"kind": "laboratorio",
"title": "Twins Lab",
"subtitle": "Protótipo de Gêmeo Digital Industrial",
"subtitle": "Painel ao vivo de um reator simulado",
"description": "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e transmissão de eventos do servidor (SSE) a cada dois segundos.",
"longDescription": "A rota exige sessão do NextAuth antes de abrir a transmissão SSE. O simulador salva dados e estado no MongoDB e notifica clientes quando execução ou cenário mudam.",
"technologies": ["Next.js", "TypeScript", "NextAuth", "MongoDB", "SSE"],
@@ -191,7 +191,7 @@
"id": "reddit-explorer",
"kind": "laboratorio",
"title": "Reddit Posts Explorer",
"subtitle": "Posts do r/artificial em MongoDB, filtrados por período",
"subtitle": "Posts de um fórum de IA, filtrados por período",
"description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB sem duplicar, pelo id, e permite filtrar por período e ordenar por votos ou comentários.",
"longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação.",
"technologies": [
@@ -224,7 +224,7 @@
"id": "sc-plus",
"kind": "cliente",
"title": "SC Plus Aquicultura",
"subtitle": "Página institucional em Next.js 15",
"subtitle": "Site institucional com áreas, projetos e contato",
"description": "Página institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.",
"longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.",
"technologies": [
@@ -254,7 +254,7 @@
"id": "gcc-installer",
"kind": "laboratorio",
"title": "MinGW GCC Installer",
"subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows",
"subtitle": "Instalador do compilador GCC para Windows",
"description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o compilador ao PATH do usuário no registro do Windows.",
"longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Feito para Windows, com o 7-Zip no caminho padrão.",
"technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"],
@@ -281,7 +281,7 @@
"id": "as-servicos",
"kind": "cliente",
"title": "AS Serviços",
"subtitle": "Site institucional com cena 3D gerada em código",
"subtitle": "Site institucional com cena 3D na rolagem",
"description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa desenhada em linhas e uma placa-mãe ligadas ao progresso da rolagem.",
"longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, preferência por menos movimento, economia de dados, conexão e capacidade do dispositivo.",
"technologies": ["Next.js 16", "TypeScript", "Three.js"],