feat(cases): cases de clientes, serviços ligados à prova e SEO por página

Cases da SEV Moda Íntima e da Lavação 047; case no formato cliente, problema, entrega, mídia e contato, com detalhes técnicos recolhidos; sem GitHub, ressalvas e fontes de commit. /cases separa clientes e Laboratório. Serviços mostram o case de cliente certo e o bloco de investimento e prazo só aparece preenchido. Metadata social própria por página, not-found com noindex, sitemap com /ia.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 03:31:57 -03:00
1 parent 3756905a1b
commit 6e1022b58c
44 files changed
+2691 -1424

No files matched your search

@@ -0,0 +1,94 @@
import { notFound } from "next/navigation";
import { ImageResponse } from "next/og";
import { isValidElement } from "react";
import { OG_SIZE, OgCard } from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
import Image 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.
*/
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
}));
jest.mock("next/og", () => ({ ImageResponse: jest.fn() }));
const mockedImageResponse = jest.mocked(ImageResponse);
beforeEach(() => {
jest.mocked(notFound).mockClear();
mockedImageResponse.mockClear();
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
function renderImage(slug: string, imageId: string) {
return Image({
params: Promise.resolve({ slug }),
id: Promise.resolve(imageId),
});
}
function sentCard() {
const [element, options] = mockedImageResponse.mock.calls[0];
if (!isValidElement<React.ComponentProps<typeof OgCard>>(element)) {
throw new Error("ImageResponse did not receive an element");
}
return { card: element, options };
}
describe("opengraph-image Image", () => {
it("path: client case with the matching image id -> card labelled as a client case", async () => {
const caseStudy = getCase("sev-modaintima");
await renderImage(caseStudy.id, caseStudy.id);
expect(notFound).not.toHaveBeenCalled();
expect(mockedImageResponse).toHaveBeenCalledTimes(1);
const { card, options } = sentCard();
expect(card.type).toBe(OgCard);
expect(card.props).toMatchObject({
eyebrow: "Case de cliente",
title: caseStudy.title,
description: caseStudy.summary,
});
expect(card.props.backgroundImage).toMatch(/^data:image\/jpeg;base64,/);
expect(options).toEqual(OG_SIZE);
});
it("path: unknown slug -> notFound() and no image", async () => {
await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow(
"NEXT_NOT_FOUND",
);
expect(notFound).toHaveBeenCalledTimes(1);
expect(mockedImageResponse).not.toHaveBeenCalled();
});
it("path: known slug but another case's image id -> notFound() and no image", async () => {
await expect(renderImage("sev-modaintima", "lavacao-047")).rejects.toThrow(
"NEXT_NOT_FOUND",
);
expect(notFound).toHaveBeenCalledTimes(1);
expect(mockedImageResponse).not.toHaveBeenCalled();
});
it("path: laboratory case -> card labelled as a laboratory project, no cover when none is recorded", async () => {
const caseStudy = getCase("twins-lab");
await renderImage(caseStudy.id, caseStudy.id);
const { card } = sentCard();
expect(card.props).toMatchObject({
eyebrow: "Projeto de laboratório",
title: caseStudy.title,
});
expect(card.props.backgroundImage).toBeUndefined();
});
});
+4 -2
View File
@@ -6,7 +6,7 @@ import {
OgCard,
readProjectOgBackground,
} from "@/lib/og-card";
import { caseStudies } from "@/lib/site-content";
import { caseStudies, isClientCase } from "@/lib/site-content";
function getCaseStudy(slug: string) {
return caseStudies.find(({ id }) => id === slug);
@@ -48,7 +48,9 @@ export default async function Image({
return new ImageResponse(
<OgCard
eyebrow="Case de engenharia de software"
eyebrow={
isClientCase(caseStudy) ? "Case de cliente" : "Projeto de laboratório"
}
title={caseStudy.title}
description={caseStudy.summary}
backgroundImage={readProjectOgBackground(caseStudy.id)}
+137
View File
@@ -0,0 +1,137 @@
import { render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { caseStudies, getRelatedServices } from "@/lib/site-content";
import { getCaseMedia } from "../_components/case-media";
import CasePage, { generateMetadata } from "./page";
/**
* Testes de caminhos-base de CasePage e generateMetadata (complemento de page.test.tsx).
* Cobrem só o que o arquivo principal não exercita: slug desconhecido, mídia só no celular,
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
*/
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
}));
jest.mock("../_components/case-media", () => {
const actual = jest.requireActual("../_components/case-media");
return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) };
});
const actualGetCaseMedia = jest.requireActual<
typeof import("../_components/case-media")
>("../_components/case-media").getCaseMedia;
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
beforeEach(() => {
jest.mocked(notFound).mockClear();
});
afterEach(() => {
mockedGetCaseMedia.mockImplementation(actualGetCaseMedia);
});
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
async function renderCase(id: string) {
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
return render(ui);
}
describe("CasePage: caminhos que o teste principal não cobre", () => {
it("path: unknown slug -> calls notFound() and renders nothing", async () => {
await expect(
CasePage({ params: Promise.resolve({ slug: "nao-existe" }) }),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(notFound).toHaveBeenCalledTimes(1);
});
it("path: no demo and no desktop capture, but a mobile capture -> shows only the phone", async () => {
const caseStudy = getCase("as-servicos");
expect(caseStudy.demoUrl).toBeNull();
mockedGetCaseMedia.mockImplementation((id, device) =>
device === "mobile" ? actualGetCaseMedia(id, device) : undefined,
);
const { container } = await renderCase(caseStudy.id);
expect(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
expect(
screen.getByLabelText(`Vídeo do site ${caseStudy.title} no celular`),
).toBeInTheDocument();
expect(
screen.queryByLabelText(`Vídeo do site ${caseStudy.title} no computador`),
).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector(".ds-phone")).not.toBeNull();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
it("path: demo published but no capture on disk -> keeps the block with the site button and no video", async () => {
const caseStudy = getCase("sev-modaintima");
expect(caseStudy.demoUrl).toBeTruthy();
mockedGetCaseMedia.mockImplementation(() => undefined);
const { container } = await renderCase(caseStudy.id);
expect(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector(".ds-phone")).toBeNull();
});
it("path: result is an empty list -> no result section", async () => {
const caseStudy = getCase("lavacao-047");
caseStudy.result = [];
try {
await renderCase(caseStudy.id);
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
).toBeVisible();
} finally {
delete caseStudy.result;
}
});
it("path: case that no service points to -> no related service links, contact stays", async () => {
const caseStudy = getCase("nutriscan");
expect(getRelatedServices(caseStudy.id)).toHaveLength(0);
await renderCase(caseStudy.id);
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
expect(
screen.getByRole("link", { name: "Conversar sobre um projeto parecido" }),
).toBeVisible();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases");
});
});
describe("generateMetadata: caminho do slug desconhecido", () => {
it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => {
const metadata = await generateMetadata({
params: Promise.resolve({ slug: "nao-existe" }),
});
expect(metadata).toEqual({ title: "Case não encontrado" });
});
});
+292 -163
View File
@@ -1,49 +1,41 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
const clientCaseIds = [
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
];
const labCaseIds = [
"nutriscan",
"twins-lab",
"reddit-explorer",
"gcc-installer",
];
const publicCaseIds = [...clientCaseIds, ...labCaseIds];
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
const publicCases = [
{ id: "nutriscan", title: "NutriScan" },
{ id: "twins-lab", title: "Twins Lab" },
{ id: "reddit-explorer", title: "Reddit Posts Explorer" },
{ id: "sc-plus", title: "SC Plus Aquicultura" },
{ id: "gcc-installer", title: "MinGW GCC Installer" },
{ id: "as-servicos", title: "AS Serviços" },
] as const;
const publicCaseIds = publicCases.map(({ id }) => id);
function getCase(id: (typeof publicCases)[number]["id"]) {
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
async function renderCase(id: string) {
const ui = await CasePage({ params: Promise.resolve({ slug: id }) });
return render(ui);
}
function isBefore(first: Element, second: Element) {
return Boolean(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
@@ -57,104 +49,141 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
});
}
function expectCaseCtas() {
const destinations = screen
/** Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case. */
function expectContactAndNoDeadLinks(caseStudy: { title: string }) {
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href"));
expect(destinations).toContain("/#contact");
expect(destinations).toContain("/servicos");
expect(destinations).not.toContain("/curriculo");
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const cta = screen.getByRole("link", {
name: "Conversar sobre um projeto parecido",
});
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}`,
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
}
it("keeps the six public params and factual case metadata", async () => {
it("exposes the eight public cases, the two new client cases among them", async () => {
const staticSlugs = generateStaticParams().map(({ slug }) => slug);
expect(staticSlugs).toHaveLength(6);
expect(new Set(staticSlugs).size).toBe(6);
expect(staticSlugs).toHaveLength(8);
expect(new Set(staticSlugs).size).toBe(8);
expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort());
expect(staticSlugs).toContain("sev-modaintima");
expect(staticSlugs).toContain("lavacao-047");
const caseStudy = getCase("as-servicos");
const url = `${siteUrl}/cases/${caseStudy.id}`;
const metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title: expect.stringContaining(caseStudy.title),
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
title: caseStudy.title,
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 metadata = await generateMetadata({
params: Promise.resolve({ slug: caseStudy.id }),
});
expect(metadata).toMatchObject({
title,
description: caseStudy.summary,
url,
},
twitter: {
title: caseStudy.title,
description: caseStudy.summary,
},
});
expect(metadata).not.toHaveProperty("keywords");
alternates: { canonical: url },
openGraph: { title, description: caseStudy.summary, url },
twitter: { title, description: caseStudy.summary },
});
expect(metadata).not.toHaveProperty("keywords");
}
});
it("renders the authorized detailed case in order with traceable sources", async () => {
const caseStudy = getCase("as-servicos");
if (!caseStudy.detail) throw new Error("Missing detailed case content");
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
it("renders a client case in the sales order, with media, contact and collapsed technical details", async () => {
const caseStudy = getCase("sev-modaintima");
const { container } = await renderCase(caseStudy.id);
const heading = screen.getByRole("heading", { level: 1 });
const answer = screen.getByText(caseStudy.detail.answer.text);
expect(heading.nextElementSibling).toBe(answer);
expect(heading).toHaveTextContent(caseStudy.headline);
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
expect(screen.getByText(/Case de cliente/)).toBeVisible();
const sectionNames = [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
"O cliente e o contexto",
"O problema",
"O que foi feito",
"Como ficou",
"Quer um projeto parecido com este?",
];
const sectionHeadings = sectionNames.map((name) =>
screen.getByRole("heading", { level: 2, name }),
);
for (let index = 1; index < sectionHeadings.length; index += 1) {
expect(
sectionHeadings[index - 1].compareDocumentPosition(
sectionHeadings[index],
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(isBefore(sectionHeadings[index - 1], sectionHeadings[index])).toBe(
true,
);
}
expect(isBefore(heading, sectionHeadings[0])).toBe(true);
expect(screen.getByText(caseStudy.context)).toBeVisible();
expect(screen.getByText(caseStudy.problem)).toBeVisible();
for (const delivery of caseStudy.deliveries) {
expect(screen.getByText(delivery)).toBeVisible();
}
const challengeSection = screen
.getByRole("heading", { level: 2, name: "Desafio verificável" })
.closest("section");
if (!challengeSection) throw new Error("Missing challenge section");
const challengeText = within(challengeSection).getByText(
caseStudy.detail.challenge.text,
// 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`,
);
expect(challengeText).toBeVisible();
expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]);
for (const statement of caseStudy.detail.implementation) {
expect(screen.getByText(statement.text)).toBeVisible();
}
for (const boundary of caseStudy.detail.boundaries) {
const boundaryText = screen.getByText(boundary.text);
expect(boundaryText).toBeVisible();
expect(boundaryText.closest("a")).toBeNull();
}
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(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ });
expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length);
for (const source of caseStudy.detail.sources) {
const link = screen.getByRole("link", { name: `Fonte: ${source.label}` });
expect(link).toHaveAttribute("href", source.url);
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/));
expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//);
const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1];
if (!sha) throw new Error(`Missing source SHA for ${source.id}`);
expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible();
}
expectContactAndNoDeadLinks(caseStudy);
expectCaseCtas();
// detalhes técnicos: recolhidos, no fim da página
const details = container.querySelector("details");
if (!details) throw new Error("Missing technical details");
expect(details).not.toHaveAttribute("open");
expect(within(details).getByText("Detalhes técnicos")).toBeVisible();
for (const technology of caseStudy.stack) {
expect(within(details).getByText(technology)).not.toBeVisible();
}
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);
expect(container.querySelectorAll("details")).toHaveLength(1);
const lastSection = container.querySelector("article")?.lastElementChild;
expect(lastSection).toContainElement(details);
// o que saiu: desafio verificável, limites da evidência, fontes e commits
for (const name of [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
]) {
expect(screen.queryByRole("heading", { name })).toBeNull();
}
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]);
@@ -208,75 +237,176 @@ it("renders the authorized detailed case in order with traceable sources", async
]);
});
it("keeps the fallback concise and links its declared services", async () => {
const caseStudy = getCase("sc-plus");
expect(caseStudy.detail).toBeUndefined();
const ui = await CasePage({
params: Promise.resolve({ slug: caseStudy.id }),
});
const { container } = render(ui);
it("renders the two new client cases from the portfolio data", async () => {
for (const id of ["sev-modaintima", "lavacao-047"]) {
const caseStudy = getCase(id);
const { unmount } = await renderCase(id);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary);
expect(screen.getByText(caseStudy.impact)).toBeVisible();
for (const technology of caseStudy.stack) {
expect(screen.getByText(technology)).toBeVisible();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(
caseStudy.headline,
);
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expectContactAndNoDeadLinks(caseStudy);
unmount();
}
});
it("labels a laboratory case, skips the media block and links its service", async () => {
const caseStudy = getCase("twins-lab");
const { container } = await renderCase(caseStudy.id);
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(
screen.getByRole("link", { name: "Abrir demonstração" }),
).toHaveAttribute("href", caseStudy.demoUrl);
expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute(
"href",
caseStudy.githubUrl,
screen.getByRole("heading", { level: 2, name: "O contexto do projeto" }),
).toBeVisible();
expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull();
expect(container.querySelector("video")).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:/,
});
expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual(
getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`),
);
for (const name of [
"Desafio verificável",
"Decisões de implementação",
"Limites desta evidência",
"Fontes",
]) {
expect(screen.queryByRole("heading", { name })).toBeNull();
expect(relatedLinks).toHaveLength(1);
expectContactAndNoDeadLinks(caseStudy);
});
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();
await renderCase(caseStudy.id);
expect(screen.getByText("Minha participação:")).toBeVisible();
expect(
screen.getByText(caseStudy.role ?? "", { exact: false }),
).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
expect(screen.queryByText(/indispon/i)).toBeNull();
// a capa continua nas molduras
expect(
screen.getByLabelText(`Vídeo do site ${caseStudy.title} no computador`),
).toBeInTheDocument();
});
it("shows the result section only when the case has real data", async () => {
const caseStudy = getCase("lavacao-047");
const { unmount } = await renderCase(caseStudy.id);
expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull();
unmount();
caseStudy.result = ["Dado real informado pelo cliente."];
try {
await renderCase(caseStudy.id);
const heading = screen.getByRole("heading", {
level: 2,
name: "Resultado",
});
expect(screen.getByText("Dado real informado pelo cliente.")).toBeVisible();
expect(
isBefore(
screen.getByRole("heading", { level: 2, name: "Como ficou" }),
heading,
),
).toBe(true);
expect(
isBefore(
heading,
screen.getByRole("heading", {
level: 2,
name: "Quer um projeto parecido com este?",
}),
),
).toBe(true);
} finally {
delete caseStudy.result;
}
expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull();
});
it("links every service related to a client case", async () => {
const caseStudy = getCase("sc-plus");
const { container } = await renderCase(caseStudy.id);
const expectedServices = servicePages
.filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id))
.map(({ slug, navLabel }) => ({
name: `Ver serviço relacionado: ${navLabel}`,
href: `/servicos/${slug}`,
}))
.sort((a, b) => a.href.localeCompare(b.href));
const relatedLinks = screen.getAllByRole("link", {
name: /^Ver serviço relacionado:/,
});
expect(
relatedLinks
.map((link) => ({
name: link.textContent,
href: link.getAttribute("href"),
}))
.sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")),
).toEqual(expectedServices);
expectCaseCtas();
.map((link) => link.getAttribute("href"))
.sort((a, b) => (a ?? "").localeCompare(b ?? "")),
).toEqual(
getRelatedServices(caseStudy.id)
.map(({ slug }) => `/servicos/${slug}`)
.sort(),
);
expectContactAndNoDeadLinks(caseStudy);
expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([
"BreadcrumbList",
]);
});
it("keeps the hub cards, schema and distinct intents aligned", () => {
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
const { container } = render(<CasesPage />);
const heading = screen.getByRole("heading", { level: 1 });
expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i);
const caseLinks = screen.getAllByRole("link", { name: "Ler case" });
const caseHrefs = caseLinks.map((link) => link.getAttribute("href"));
expect(caseHrefs).toHaveLength(6);
expect(new Set(caseHrefs).size).toBe(6);
expect([...caseHrefs].sort()).toEqual(
publicCaseIds.map((id) => `/cases/${id}`).sort(),
expect(heading.nextElementSibling).toHaveTextContent(
/foi feito para empresas/i,
);
expectCaseCtas();
expect(container.textContent).not.toMatch(/Selected work/i);
const clientHeading = screen.getByRole("heading", {
level: 2,
name: "Projetos de clientes",
});
const labHeading = screen.getByRole("heading", {
level: 2,
name: "Laboratório",
});
expect(isBefore(heading, clientHeading)).toBe(true);
expect(isBefore(clientHeading, labHeading)).toBe(true);
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible();
// cases de cliente: capa (pôster) em moldura de navegador e botão do case em contorno
const clientLinks = clientCaseIds.map((id) => {
const card = container
.querySelector(`a[href="/cases/${id}"]`)
?.closest("article");
if (!card) throw new Error(`Missing client card ${id}`);
expect(isBefore(clientHeading, card)).toBe(true);
expect(isBefore(card, labHeading)).toBe(true);
expect(card.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
`Captura de tela do site ${getCase(id).title}`,
);
return within(card).getByRole("link", { name: "Ver o case" });
});
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
clientCaseIds.map((id) => `/cases/${id}`),
);
// laboratório: sem capa
const labLinks = screen.getAllByRole("link", { name: "Ver o projeto" });
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`),
);
for (const link of labLinks) {
const card = link.closest("article");
expect(isBefore(labHeading, link)).toBe(true);
expect(card?.querySelector(".ds-bframe")).toBeNull();
}
// contato pelo WhatsApp, sem /#contact nem GitHub
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);
expect(hrefs).toContain("/servicos");
const cta = screen.getByRole("link", { name: "Conversar sobre meu projeto" });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
@@ -304,13 +434,12 @@ it("keeps the hub cards, schema and distinct intents aligned", () => {
url: string;
}>;
};
const visibleItems = caseLinks.map((link, index) => {
const visibleItems = [...clientLinks, ...labLinks].map((link, index) => {
const href = link.getAttribute("href");
const expected = publicCases.find(({ id }) => href === `/cases/${id}`);
if (!expected) throw new Error(`Unexpected visible case link ${href}`);
const card = link.closest("article");
if (!card) throw new Error(`Missing visible card for ${href}`);
expect(within(card).getByText(expected.title)).toBeVisible();
const expected = getCase((href ?? "").replace("/cases/", ""));
expect(card.querySelector(".ds-kicker")).toHaveTextContent(expected.title);
return {
"@type": "ListItem",
position: index + 1,
+129 -196
View File
@@ -1,26 +1,24 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
caseStudies,
getRelatedServices,
intentCtas,
siteName,
siteUrl,
} from "@/lib/site-content";
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 { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
import { CaseMediaSection } from "../_components/CaseMediaSection";
import { CaseRelatedServices } from "../_components/CaseRelatedServices";
import { CaseResultSection } from "../_components/CaseResultSection";
interface CasePageProps {
params: Promise<{ slug: string }>;
}
const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//;
function getSourceSha(url: string) {
return sourceShaPattern.exec(url)?.[1];
}
export function generateStaticParams() {
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
}
@@ -34,23 +32,16 @@ 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: `${caseStudy.title} | Case de Engenharia`,
title,
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
title: caseStudy.title,
description: caseStudy.summary,
...buildPageSocialMetadata({
url,
siteName,
type: "article",
},
twitter: {
card: "summary_large_image",
title: caseStudy.title,
title,
description: caseStudy.summary,
},
type: "article",
}),
};
}
@@ -60,7 +51,7 @@ export default async function CasePage({ params }: CasePageProps) {
if (!caseStudy) notFound();
const pageUrl = `${siteUrl}/cases/${caseStudy.id}`;
const relatedServices = getRelatedServices(caseStudy.id);
const isClient = isClientCase(caseStudy);
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
@@ -76,186 +67,128 @@ export default async function CasePage({ params }: CasePageProps) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/cases">
Cases
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/cases/${caseStudy.id}`}
>
{caseStudy.title}
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Case de engenharia de software</p>
<h1 className="ds-section-title text-white">
{caseStudy.title}: {caseStudy.subtitle}
<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.detail?.answer.text ?? caseStudy.summary}
{caseStudy.summary}
</p>
{caseStudy.role ? (
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
<strong className="font-semibold text-white">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
</p>
) : null}
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl ? (
<a
className="ds-link ds-link-accent"
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir demonstração
</a>
) : null}
{caseStudy.githubUrl ? (
<a
className="ds-link ds-link-muted"
href={caseStudy.githubUrl}
target="_blank"
rel="noreferrer"
>
Ver código
</a>
) : null}
</div>
</section>
<section className="ds-section" aria-labelledby="case-context">
<div className="ds-section-shell max-w-[960px]">
<div className="ds-grid-2 items-start">
<div>
<h2 id="case-context" className="ds-card-title !mt-0">
{isClient
? "O cliente e o contexto"
: "O contexto do projeto"}
</h2>
<p className="ds-copy">{caseStudy.context}</p>
{caseStudy.role ? (
<p className="ds-copy">
<strong className="font-semibold text-[var(--ink)]">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
</div>
<div>
<h2 id="case-problem" className="ds-card-title !mt-0">
O problema
</h2>
<p className="ds-copy">{caseStudy.problem}</p>
</div>
</div>
</div>
</section>
{caseStudy.detail ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px] space-y-sp-10">
<section aria-labelledby="case-challenge">
<h2 id="case-challenge" className="ds-section-title">
Desafio verificável
</h2>
<p className="ds-section-description">
{caseStudy.detail.challenge.text}
</p>
</section>
<section aria-labelledby="case-implementation">
<h2 id="case-implementation" className="ds-section-title">
Decisões de implementação
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.implementation.map((statement) => (
<li key={statement.text}>{statement.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-boundaries">
<h2 id="case-boundaries" className="ds-section-title">
Limites desta evidência
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.boundaries.map((boundary) => (
<li key={boundary.text}>{boundary.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-sources">
<h2 id="case-sources" className="ds-section-title">
Fontes
</h2>
<ul className="mt-sp-5 space-y-sp-4">
{caseStudy.detail.sources.map((source) => {
const sha = getSourceSha(source.url);
return (
<li key={source.id}>
<a
className="ds-link ds-link-muted"
href={source.url}
target="_blank"
rel="noreferrer"
>
Fonte: {source.label}
</a>
{sha ? (
<span className="mt-sp-2 block text-[12px] text-[var(--meta)]">
Commit: <code className="break-all">{sha}</code>
</span>
) : null}
</li>
);
})}
</ul>
</section>
</div>
</section>
) : null}
{relatedServices.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Serviços relacionados</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{relatedServices.map((service) => (
<Link
key={service.slug}
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
</div>
</div>
</section>
) : null}
<section className="ds-section ds-section-dark pb-sp-6">
<section className="ds-section" aria-labelledby="case-done">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-card-title text-white">Próximos caminhos</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
className="ds-link ds-link-accent"
href={intentCtas.project.href}
<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>
</section>
<CaseMediaSection caseStudy={caseStudy} />
<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}
>
{intentCtas.project.label}
</Link>
<Link
className="ds-link ds-link-muted"
href={intentCtas.consulting.href}
>
{intentCtas.consulting.label}
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>
</article>
@@ -0,0 +1,64 @@
import { render, screen } from "@testing-library/react";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { CaseCover } from "./CaseCover";
import { getCaseMedia } from "./case-media";
/**
* Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster)
* e os valores padrão de `sizes` e `priority`.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
const props = {
id: "sev-modaintima",
title: "SEV Moda Íntima",
demoUrl: "https://sevmodaintima.com.br",
};
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseCover", () => {
it("path: no desktop capture -> renders nothing", () => {
mockedGetCaseMedia.mockReturnValue(undefined);
const { container } = render(<CaseCover {...props} />);
expect(container).toBeEmptyDOMElement();
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
});
it("path: capture found -> poster inside the browser frame, with the real domain in the bar", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(container.querySelector(".ds-bframe")).toContainElement(image);
expect(image.getAttribute("src")).toContain(
encodeURIComponent(`/videos/${props.id}/desktop.webp`),
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
});
it("path: sizes and priority omitted -> default sizes and lazy loading", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw");
expect(image).toHaveAttribute("loading", "lazy");
});
it("path: sizes and priority given -> uses them", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<CaseCover {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).not.toHaveAttribute("loading", "lazy");
});
});
+40
View File
@@ -0,0 +1,40 @@
import Image from "next/image";
import {
BrowserFrame,
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({
id,
title,
demoUrl,
sizes = "(min-width: 1024px) 560px, 100vw",
priority = false,
}: {
id: string;
title: string;
demoUrl?: string | null;
sizes?: string;
priority?: boolean;
}) {
const media = getCaseMedia(id, "desktop");
if (!media) return null;
return (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
priority={priority}
className="object-cover object-top"
/>
</div>
</BrowserFrame>
);
}
@@ -0,0 +1,94 @@
import { 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.
* A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/.
*/
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
function withCaptures(devices: ReadonlyArray<"desktop" | "mobile">) {
mockedGetCaseMedia.mockImplementation((id, device) =>
devices.includes(device) ? getShowcaseAssets(id, device) : undefined,
);
}
const props = {
id: "sev-modaintima",
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`;
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("CaseMedia", () => {
it("path: desktop and mobile captures -> both frames, the browser bar shows the real domain", () => {
withCaptures(["desktop", "mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(container.querySelector(".ds-bframe")).toContainElement(
screen.getByLabelText(desktopLabel),
);
expect(container.querySelector(".ds-phone")).toContainElement(
screen.getByLabelText(mobileLabel),
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
"sevmodaintima.com.br",
);
expect(
screen.getByText(`O site ${props.title} no computador.`),
).toBeVisible();
expect(screen.getByText("No celular.")).toBeVisible();
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop");
expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "mobile");
});
it("path: no capture at all -> renders nothing", () => {
withCaptures([]);
const { container } = render(<CaseMedia {...props} />);
expect(container).toBeEmptyDOMElement();
});
it("path: only the mobile capture -> phone frame without the browser frame", () => {
withCaptures(["mobile"]);
const { container } = render(<CaseMedia {...props} />);
expect(screen.getByLabelText(mobileLabel)).toBeInTheDocument();
expect(screen.queryByLabelText(desktopLabel)).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector(".ds-phone")).not.toBeNull();
expect(screen.queryByText(/no computador\./)).toBeNull();
});
it("path: only the desktop capture -> browser frame without the phone frame", () => {
withCaptures(["desktop"]);
const { container } = render(<CaseMedia {...props} />);
expect(screen.getByLabelText(desktopLabel)).toBeInTheDocument();
expect(screen.queryByLabelText(mobileLabel)).toBeNull();
expect(container.querySelector(".ds-phone")).toBeNull();
expect(container.querySelector(".ds-bframe")).not.toBeNull();
expect(screen.queryByText("No celular.")).toBeNull();
});
it("path: no demo address -> the browser bar falls back to the project title", () => {
withCaptures(["desktop"]);
const { container } = render(
<CaseMedia id={props.id} title={props.title} demoUrl={null} />,
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
props.title,
);
});
});
+91
View File
@@ -0,0 +1,91 @@
import {
BrowserFrame,
displayUrl,
} from "@/app/components/showcase/BrowserFrame";
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
import type { ShowcaseAssets } from "@/lib/project-showcase";
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.
*/
export function CaseMedia({
id,
title,
demoUrl,
}: {
id: string;
title: string;
demoUrl?: string | null;
}) {
const desktop = getCaseMedia(id, "desktop");
const mobile = getCaseMedia(id, "mobile");
if (!desktop && !mobile) return null;
return (
<div className="mt-sp-8 grid 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"
/>
</div>
</BrowserFrame>
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
O site {title} no computador.
</figcaption>
</figure>
) : null}
{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"
/>
</PhoneFrame>
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
No celular.
</figcaption>
</figure>
) : null}
</div>
);
}
@@ -0,0 +1,55 @@
import { secondaryButtonDark } from "@/app/components/button-styles";
import type { CaseStudyContent } from "@/lib/site-content";
import { CaseMedia } from "./CaseMedia";
import { getCaseMedia } from "./case-media";
/** O bloco "Como ficou" existe quando há site no ar ou captura gravada, no computador ou no celular. */
export function hasCaseMedia({
id,
demoUrl,
}: Pick<CaseStudyContent, "id" | "demoUrl">) {
return (
Boolean(demoUrl) ||
Boolean(getCaseMedia(id, "desktop")) ||
Boolean(getCaseMedia(id, "mobile"))
);
}
/** "Como ficou": pôster e vídeo do projeto e o botão para abrir o site. Sem site nem captura, não mostra nada. */
export function CaseMediaSection({
caseStudy,
}: {
caseStudy: Pick<CaseStudyContent, "id" | "title" | "demoUrl">;
}) {
if (!hasCaseMedia(caseStudy)) return null;
return (
<section
className="ds-section ds-section-dark"
aria-labelledby="case-media"
>
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
Como ficou
</h2>
<CaseMedia
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
{caseStudy.demoUrl ? (
<div className="mt-sp-8">
<a
className={secondaryButtonDark}
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir o site {caseStudy.title}
</a>
</div>
) : null}
</div>
</section>
);
}
@@ -0,0 +1,23 @@
import Link from "next/link";
import { textLink } 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. */
export function CaseRelatedServices({ caseId }: { caseId: string }) {
const services = getRelatedServices(caseId);
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>
);
}
@@ -0,0 +1,19 @@
/** Resultado do case, só com dado real e autorizado. Sem dado, a seção não aparece. */
export function CaseResultSection({ result }: { result?: string[] }) {
if (!result || result.length === 0) return null;
return (
<section className="ds-section" aria-labelledby="case-result">
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-result" className="ds-card-title !mt-0">
Resultado
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
{result.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</section>
);
}
@@ -0,0 +1,41 @@
import { existsSync } from "node:fs";
import { getCaseMedia } from "./case-media";
/** Testes de caminhos-base de getCaseMedia: id inválido, pôster ausente e pôster presente em disco. */
jest.mock("node:fs", () => {
const actual = jest.requireActual<typeof import("node:fs")>("node:fs");
return { ...actual, existsSync: jest.fn(actual.existsSync) };
});
const mockedExistsSync = jest.mocked(existsSync);
beforeEach(() => {
mockedExistsSync.mockClear();
});
describe("getCaseMedia", () => {
it.each([
["with path traversal", "../../etc/passwd"],
["with upper case", "Sev-Modaintima"],
["with a slash", "sev/modaintima"],
["empty", ""],
])("path: id %s -> undefined without touching the disk", (_label, id) => {
expect(getCaseMedia(id, "desktop")).toBeUndefined();
expect(mockedExistsSync).not.toHaveBeenCalled();
});
it("path: valid id with no poster on disk -> undefined", () => {
expect(getCaseMedia("twins-lab", "desktop")).toBeUndefined();
expect(mockedExistsSync).toHaveBeenCalledTimes(1);
});
it("path: valid id with the poster on disk -> the showcase assets of that device", () => {
const desktop = getCaseMedia("sev-modaintima", "desktop");
const mobile = getCaseMedia("sev-modaintima", "mobile");
expect(desktop?.poster).toBe("/videos/sev-modaintima/desktop.webp");
expect(mobile?.poster).toBe("/videos/sev-modaintima/mobile.webp");
expect(desktop?.sources.length).toBeGreaterThan(0);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import {
type Device,
getShowcaseAssets,
type ShowcaseAssets,
} from "@/lib/project-showcase";
const idPattern = /^[a-z0-9-]+$/;
/**
* Pôster e vídeo gravados em public/videos/<id>/, ou `undefined` quando o projeto não tem captura.
* Roda no servidor (na geração das páginas): olha se o pôster existe no disco.
*/
export function getCaseMedia(
id: string,
device: Device,
): ShowcaseAssets | undefined {
if (!idPattern.test(id)) return undefined;
const assets = getShowcaseAssets(id, device);
const file = join(process.cwd(), "public", assets.poster);
return existsSync(file) ? assets : undefined;
}
+4 -4
View File
@@ -2,16 +2,16 @@ import { ImageResponse } from "next/og";
import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card";
export const alt =
"Cases de software, web e sistemas conectados — Evandro Fonseca Junior";
"Sites e sistemas feitos para empresas — Evandro Fonseca Junior";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default async function Image() {
return new ImageResponse(
<OgCard
eyebrow="Trabalho verificável"
title="Cases de software, web e sistemas conectados"
description="Decisões, implementação, limites e fontes."
eyebrow="Trabalhos entregues"
title="Sites e sistemas feitos para empresas"
description="Veja como cada projeto ficou e converse sobre o seu."
/>,
OG_SIZE,
);
+65
View File
@@ -0,0 +1,65 @@
import { render, screen, within } from "@testing-library/react";
import { clientCases } from "@/lib/site-content";
import { getCaseMedia } from "./_components/case-media";
import CasesPage from "./page";
/**
* Teste de caminhos-base da lista de cases de cliente em CasesPage:
* o caminho "case de cliente sem captura gravada" (a página principal só vê cases com captura).
*/
jest.mock("./_components/case-media", () => {
const actual = jest.requireActual("./_components/case-media");
return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) };
});
const actualGetCaseMedia = jest.requireActual<
typeof import("./_components/case-media")
>("./_components/case-media").getCaseMedia;
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
afterEach(() => {
mockedGetCaseMedia.mockImplementation(actualGetCaseMedia);
});
function clientCard(container: HTMLElement, id: string) {
const card = container
.querySelector(`a[href="/cases/${id}"]`)
?.closest("article");
if (!card) throw new Error(`Missing client card ${id}`);
return card;
}
describe("CasesPage: cases de cliente", () => {
it("path: client case without a desktop capture -> card keeps text and links but no cover", () => {
const [withoutCapture, withCapture] = clientCases;
mockedGetCaseMedia.mockImplementation((id, device) =>
id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device),
);
const { container } = render(<CasesPage />);
const bare = clientCard(container, withoutCapture.id);
expect(bare.querySelector(".ds-bframe")).toBeNull();
expect(bare.querySelector("img")).toBeNull();
expect(
within(bare).queryByRole("link", {
name: `Ver o case ${withoutCapture.title}`,
}),
).toBeNull();
expect(within(bare).getByText(withoutCapture.headline)).toBeVisible();
expect(
within(bare).getByRole("link", { name: "Ver o case" }),
).toHaveAttribute("href", `/cases/${withoutCapture.id}`);
// os outros cases de cliente continuam com a capa
const covered = clientCard(container, withCapture.id);
expect(covered.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
`Captura de tela do site ${withCapture.title}`,
);
expect(screen.getAllByRole("link", { name: "Ver o case" })).toHaveLength(
clientCases.length,
);
});
});
+138 -94
View File
@@ -1,32 +1,90 @@
import type { Metadata } from "next";
import Link from "next/link";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
secondaryButtonLight,
textLink,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import {
type CaseStudyContent,
clientCases,
labCases,
siteUrl,
} from "@/lib/site-content";
import { CaseCover } from "./_components/CaseCover";
import { getCaseMedia } from "./_components/case-media";
const pageUrl = `${siteUrl}/cases`;
const pageTitle = "Cases de Engenharia de Software, Next.js e IoT";
const pageTitle = "Cases: Sites e Sistemas Feitos para Empresas";
const pageDescription =
"Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados.";
"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.";
const allCases = [...clientCases, ...labCases];
export const metadata: Metadata = {
title: pageTitle,
description: pageDescription,
alternates: {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
...buildPageSocialMetadata({
url: pageUrl,
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
}),
};
function CaseCard({
caseStudy,
withCover,
ctaLabel,
}: {
caseStudy: CaseStudyContent;
withCover: boolean;
ctaLabel: string;
}) {
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>
) : 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}>
{ctaLabel}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site
</a>
) : null}
</div>
</article>
);
}
export default function CasesPage() {
const jsonLd = {
"@context": "https://schema.org",
@@ -40,7 +98,7 @@ export default function CasesPage() {
},
{
"@type": "ItemList",
itemListElement: caseStudies.map((caseStudy, index) => ({
itemListElement: allCases.map((caseStudy, index) => ({
"@type": "ListItem",
position: index + 1,
name: caseStudy.title,
@@ -58,32 +116,18 @@ export default function CasesPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/cases"
>
Cases
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Selected work</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Cases em software, Next.js, integrações e produtos conectados.
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
]}
/>
<p className="ds-eyebrow">Trabalhos entregues</p>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Veja o que já fiz para empresas.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{pageDescription}
@@ -92,65 +136,65 @@ export default function CasesPage() {
</div>
</section>
<section className="ds-section">
<section className="ds-section" aria-labelledby="cases-clientes">
<div className="ds-section-shell">
<div className="ds-grid-2 items-start">
{caseStudies.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card !h-auto">
<div className="ds-kicker">{caseStudy.title}</div>
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
<p className="ds-copy">{caseStudy.summary}</p>
<p className="mt-sp-4 text-[13px] leading-7 text-[var(--meta)]">
{caseStudy.impact}
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case
</Link>
{caseStudy.demoUrl ? (
<MagneticButton
as="a"
href={caseStudy.demoUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
strength={0.25}
>
Abrir projeto
</MagneticButton>
) : null}
</div>
</article>
<h2 id="cases-clientes" className="ds-card-title !mt-0">
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover
ctaLabel="Ver o case"
/>
))}
</div>
</div>
</section>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href={intentCtas.project.href}
className="ds-link ds-link-accent"
strength={0.25}
<section className="ds-section" aria-labelledby="cases-laboratorio">
<div className="ds-section-shell">
<h2 id="cases-laboratorio" className="ds-card-title !mt-0">
Laboratório
</h2>
<p className="ds-copy max-w-[64ch]">
Projetos pessoais e de estudo, feitos para praticar e testar ideias.
</p>
<div className="ds-grid-2 mt-sp-8 items-start">
{labCases.map((caseStudy) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover={false}
ctaLabel="Ver o projeto"
/>
))}
</div>
</div>
</section>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<h2 className="ds-card-title !mt-0 text-white">
Quer um projeto parecido com algum destes?
</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 os cases do portfólio"
placement="cases_hub_footer"
className={primaryButton}
>
{intentCtas.project.label}
</MagneticButton>
<MagneticButton
as="a"
href={intentCtas.consulting.href}
className="ds-link ds-link-muted"
strength={0.2}
>
{intentCtas.consulting.label}
</MagneticButton>
Conversar sobre meu projeto
</WhatsAppLink>
<Link href="/servicos" className={secondaryButtonDark}>
Ver os serviços
</Link>
</div>
</div>
</section>
+30
View File
@@ -0,0 +1,30 @@
import Link from "next/link";
/** Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual. */
export function Breadcrumb({
items,
}: {
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">
{items.map(({ label, href }, index) => (
<li
key={href}
className={index > 0 ? "flex items-center gap-sp-2" : undefined}
>
{index > 0 ? <span aria-hidden="true">/</span> : null}
<Link
aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text"
href={href}
>
{label}
</Link>
</li>
))}
</ol>
</nav>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { trackCvDownload } from "@/lib/analytics";
import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation";
/** Link do PDF do currículo; mede o clique (só com consentimento de analytics). */
export default function CvDownloadLink({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<a
className={className}
href={CV_URL}
download={CV_DOWNLOAD_NAME}
onClick={() => trackCvDownload({ placement: "curriculo" })}
>
{children}
</a>
);
}
+30 -1
View File
@@ -1,6 +1,9 @@
import { render, screen, within } from "@testing-library/react";
import { fireEvent, render, screen, within } from "@testing-library/react";
import { trackCvDownload } from "@/lib/analytics";
import CurriculoPage from "./page";
jest.mock("@/lib/analytics", () => ({ trackCvDownload: jest.fn() }));
it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => {
render(CurriculoPage());
@@ -23,3 +26,29 @@ it("shows a matching breadcrumb and keeps consulting secondary to hiring", () =>
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("links to no GitHub profile", () => {
render(CurriculoPage());
// "GitHub Actions" continua como competência técnica; o que não pode haver é endereço do GitHub
expect(document.body.textContent).not.toMatch(/github\.com/i);
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href")).not.toMatch(/github/i);
}
});
it("offers the résumé PDF as a secondary action and measures the download", () => {
render(CurriculoPage());
const pdf = screen.getByRole("link", { name: "Baixar currículo em PDF" });
expect(pdf).toHaveAttribute("href", "/Profile.pdf");
expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
// limão só para o contato
expect(pdf).not.toHaveClass("ds-link-accent");
expect(screen.getByRole("link", { name: /oportunidade/i })).toHaveClass(
"ds-link-accent",
);
fireEvent.click(pdf);
expect(trackCvDownload).toHaveBeenCalledWith({ placement: "curriculo" });
});
+24 -33
View File
@@ -1,9 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import { safeJsonLd } from "@/lib/jsonld";
import { portfolio } from "@/lib/portfolio-data";
import { buildBreadcrumbList, buildProfilePageJsonLd } from "@/lib/seo";
import {
buildBreadcrumbList,
buildPageSocialMetadata,
buildProfilePageJsonLd,
} from "@/lib/seo";
import { siteName, siteUrl } from "@/lib/site-content";
import CvDownloadLink from "./CvDownloadLink";
const pageUrl = `${siteUrl}/curriculo`;
@@ -11,16 +18,13 @@ 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.",
alternates: { canonical: pageUrl },
openGraph: {
locale: "pt_BR",
...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.",
url: pageUrl,
siteName,
type: "profile",
},
}),
};
export default function CurriculoPage() {
@@ -38,27 +42,14 @@ export default function CurriculoPage() {
return (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/curriculo"
>
Currículo
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Currículo", href: "/curriculo" },
]}
/>
<p className="ds-eyebrow">Currículo profissional</p>
<h1 className="ds-section-title text-white">
Engenheiro da Computação e Desenvolvedor de Software.
@@ -69,13 +60,13 @@ export default function CurriculoPage() {
formação e competências técnicas para avaliação de contratação.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<a className="ds-link ds-link-accent" href="/Profile.pdf" download>
Baixar currículo em PDF
</a>
<Link className="ds-link ds-link-muted" href="/#contact">
<Link className="ds-link ds-link-accent" href="/#contact">
Falar sobre uma oportunidade
</Link>
<Link className="ds-link ds-link-muted" href="/servicos">
<CvDownloadLink className="ds-link ds-link-muted">
Baixar currículo em PDF
</CvDownloadLink>
<Link className="ds-link ds-link-text" href="/servicos">
Procurando consultoria? Ver serviços
</Link>
</div>
@@ -131,7 +122,7 @@ export default function CurriculoPage() {
))}
</div>
<div className="mt-sp-8">
<Link className="ds-link ds-link-accent" href="/cases">
<Link className="ds-link ds-link-muted" href="/cases">
Ver projetos e cases
</Link>
</div>
+23 -11
View File
@@ -1,19 +1,31 @@
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.
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 },
},
};
export default function NotFound() {
return (
<div className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
<main className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-8">
<Code2 className="w-24 h-24 text-blue-500 mx-auto mb-4 opacity-50" />
<h1 className="text-6xl md:text-8xl font-black text-white mb-4">
<Code2 className="w-24 h-24 text-[var(--slash)] mx-auto mb-4 opacity-60" />
<h1 className="font-[family-name:var(--font-display)] text-6xl md:text-8xl font-extrabold text-white mb-4">
404
</h1>
<h2 className="text-2xl md:text-3xl font-bold text-slate-400 mb-4">
<h2 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold text-slate-300 mb-4">
Página não encontrada
</h2>
<p className="text-slate-500 mb-8">
<p className="font-[family-name:var(--font-body)] text-slate-400 mb-8">
A página que você está procurando não existe ou foi movida.
</p>
</div>
@@ -21,20 +33,20 @@ export default function NotFound() {
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2"
className="px-6 py-3 border border-slate-600 hover:border-slate-400 bg-transparent text-white rounded-lg font-[family-name:var(--font-body)] text-base font-semibold transition-all flex items-center justify-center gap-2"
>
<Home className="w-5 h-5" />
Voltar para Home
Voltar ao início
</Link>
<Link
href="/#projects"
className="px-6 py-3 border border-slate-800 hover:border-slate-600 text-slate-400 hover:text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2"
href="/cases"
className="px-6 py-3 font-[family-name:var(--font-body)] text-base text-slate-300 hover:text-white underline underline-offset-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
Ver Projetos
Ver os cases
</Link>
</div>
</div>
</div>
</main>
);
}
+16
View File
@@ -40,6 +40,22 @@ it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () =>
]);
});
it("mentions no GitHub link and no English jargon in the visible text", () => {
const { container } = render(<PrivacyPage />);
expect(container.textContent).not.toMatch(/github/i);
expect(container.textContent).not.toMatch(/\b(loader|endpoint)\b/i);
for (const link of screen.getAllByRole("link")) {
expect(link.getAttribute("href")).not.toMatch(/github/i);
}
});
it("keeps the lime fill for contact actions only", () => {
const { container } = render(<PrivacyPage />);
expect(container.querySelector(".ds-link-accent")).toBeNull();
});
it("uses privacy-specific canonical metadata", () => {
expect(metadata).toMatchObject({
title: expect.stringMatching(/privacidade/i),
+24 -38
View File
@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import { pageTop } from "@/app/components/button-styles";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/privacidade`;
@@ -10,16 +12,13 @@ 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.",
alternates: { canonical: pageUrl },
openGraph: {
locale: "pt_BR",
...buildPageSocialMetadata({
url: pageUrl,
title: `Privacidade e consentimento | ${siteName}`,
description:
"Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.",
url: pageUrl,
siteName,
type: "website",
},
}),
};
export default function PrivacyPage() {
@@ -37,27 +36,14 @@ export default function PrivacyPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/privacidade"
>
Privacidade
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Privacidade", href: "/privacidade" },
]}
/>
<p className="ds-eyebrow">Privacidade e consentimento</p>
<h1 className="ds-section-title text-white">
Privacidade e consentimento
@@ -71,7 +57,7 @@ export default function PrivacyPage() {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<div className="ds-grid-2 items-start">
<article className="ds-card">
<p className="ds-kicker">Responsável e contato</p>
<h2 className="ds-card-title">Evandro Fonseca Junior</h2>
@@ -79,7 +65,7 @@ export default function PrivacyPage() {
O responsável pelo site é Evandro Fonseca Junior. Para contato
ou solicitações relacionadas a esta página, escreva para{" "}
<a
className="ds-link ds-link-accent"
className="font-medium underline underline-offset-4"
href="mailto:[email protected]"
>
evandrofonsecajunior@gmail.com
@@ -93,8 +79,8 @@ export default function PrivacyPage() {
<h2 className="ds-card-title">Mensagem pelo WhatsApp</h2>
<p className="ds-copy">
Os dados digitados no formulário são transformados em uma
mensagem e abrem o WhatsApp. O site não possui endpoint próprio
de armazenamento desse formulário.
mensagem e abrem o WhatsApp. O site não guarda esse formulário
em nenhum servidor próprio.
</p>
</article>
@@ -111,8 +97,8 @@ export default function PrivacyPage() {
Cookies de publicidade e personalização permanecem negados. Ao
conceder analytics, o Google pode criar cookies{" "}
<code>_ga*</code>. Ao revogar, o consentimento é atualizado para
negado, o loader é removido e os cookies de analytics acessíveis
são expirados.
negado, o carregador da tag é removido e os cookies de analytics
acessíveis são expirados.
</p>
<p className="ds-copy mt-sp-4">
A tag compartilhada conecta Google Analytics e Google Ads. O
@@ -137,16 +123,16 @@ export default function PrivacyPage() {
<p className="ds-copy">
Para alterar a preferência, use o controle existente{" "}
<strong>“Preferências de analytics”</strong> para acessar suas
preferências de privacidade. Links externos para GitHub,
LinkedIn, Instagram, demos e WhatsApp seguem as políticas
próprias de cada serviço.
preferências de privacidade. Links externos para LinkedIn,
Instagram, demonstrações e WhatsApp seguem as políticas próprias
de cada serviço.
</p>
<p className="ds-copy mt-sp-4">
Solicitações relacionadas aos seus direitos devem ser
encaminhadas ao e-mail publicado acima.
</p>
<Link
className="ds-link ds-link-accent mt-sp-5 inline-block"
className="ds-link ds-link-muted mt-sp-5 self-start"
href="/"
>
Voltar ao início
@@ -158,7 +144,7 @@ export default function PrivacyPage() {
<p className="ds-kicker">Vigência</p>
<p className="ds-copy mt-sp-2">
Esta versão está vigente desde{" "}
<time dateTime="2026-09-13">2026-09-13</time>.
<time dateTime="2026-09-13">13 de setembro de 2026</time>.
</p>
</div>
</div>
+98
View File
@@ -0,0 +1,98 @@
import { render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { getServicePage } from "@/lib/site-content";
import ServicePage, { generateMetadata } from "./page";
/**
* Testes de caminhos-base de ServicePage e generateMetadata (complemento de page.test.tsx).
* Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias.
*/
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
}));
beforeEach(() => {
jest.mocked(notFound).mockClear();
});
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
return render(ui);
}
const FAQ_HEADING = "Perguntas frequentes";
describe("ServicePage: caminhos que o teste principal não cobre", () => {
it("path: unknown slug -> calls notFound() and renders nothing", async () => {
await expect(
ServicePage({ params: Promise.resolve({ slug: "nao-existe" }) }),
).rejects.toThrow("NEXT_NOT_FOUND");
expect(notFound).toHaveBeenCalledTimes(1);
});
it("path: service with a FAQ (main path) -> shows every question", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq?.length) throw new Error("Missing service FAQ");
await renderService(service.slug);
expect(
screen.getByRole("heading", { level: 2, name: FAQ_HEADING }),
).toBeVisible();
for (const item of service.faq) {
expect(
screen.getByRole("heading", { level: 3, name: item.question }),
).toBeVisible();
}
});
it("path: service without the faq field -> no FAQ section, the rest of the page stays", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq) throw new Error("Missing service FAQ");
const originalFaq = service.faq;
delete service.faq;
try {
await renderService(service.slug);
expect(screen.queryByRole("heading", { name: FAQ_HEADING })).toBeNull();
expect(
screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
}),
).toBeVisible();
} finally {
service.faq = originalFaq;
}
});
it("path: service with an empty FAQ list -> no FAQ section", async () => {
const service = getServicePage("criacao-de-sites");
if (!service?.faq) throw new Error("Missing service FAQ");
const originalFaq = service.faq;
service.faq = [];
try {
await renderService(service.slug);
expect(screen.queryByRole("heading", { name: FAQ_HEADING })).toBeNull();
expect(
screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
}),
).toBeVisible();
} finally {
service.faq = originalFaq;
}
});
});
describe("generateMetadata: caminho do slug desconhecido", () => {
it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => {
const metadata = await generateMetadata({
params: Promise.resolve({ slug: "nao-existe" }),
});
expect(metadata).toEqual({ title: "Serviço não encontrado" });
});
});
+148 -26
View File
@@ -1,33 +1,26 @@
import { render, screen, within } from "@testing-library/react";
import type { ReactNode } from "react";
import { SEO_IDS } from "@/lib/seo";
import { caseStudies, getServicePage, siteUrl } from "@/lib/site-content";
import {
caseStudies,
getRelatedCases,
getServicePage,
siteUrl,
} from "@/lib/site-content";
import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
jest.mock("@/app/components/cinematic/HeroBackground", () => ({
__esModule: true,
default: () => null,
}));
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
return render(ui);
}
jest.mock("@/app/components/cinematic/MagneticButton", () => ({
__esModule: true,
default: ({
as = "button",
strength: _strength,
children,
...props
}: {
as?: string;
strength?: number;
children?: ReactNode;
} & Record<string, unknown>) => {
const React = require("react");
return React.createElement(as, props, children);
},
}));
function isBefore(first: Element, second: Element) {
return Boolean(
first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING,
);
}
const service = getServicePage("consultoria-nextjs");
if (!service) throw new Error("Missing representative service");
@@ -86,10 +79,7 @@ it("keeps static params and route metadata canonical", async () => {
});
it("renders answer-first consulting, reciprocal case links and minimal schema", async () => {
const ui = await ServicePage({
params: Promise.resolve({ slug: service.slug }),
});
const { container } = render(ui);
const { container } = await renderService(service.slug);
const answer = screen.getByText(service.answer);
expect(answer).toBeVisible();
@@ -153,7 +143,15 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
"rel",
expect.stringMatching(/noreferrer/),
);
expect(externalLink.getAttribute("href")).not.toMatch(/github/i);
}
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(container.textContent).not.toMatch(
/Exemplos ilustrativos|não projetos já entregues|Sem promessas|indispon|Ver código/i,
);
const graph = readJsonLdEntities(container);
expect(graph.map((node) => node["@type"])).toEqual([
@@ -243,3 +241,127 @@ it("keeps hub cards and structured items aligned", () => {
],
});
});
it("shows the client projects with covers on the hub, without disclaimers", () => {
const { container } = render(<ServicesIndexPage />);
for (const id of [
"sev-modaintima",
"lavacao-047",
"sc-plus",
"as-servicos",
]) {
const link = container.querySelector(`a[href="/cases/${id}"]`);
expect(
link?.closest("article")?.querySelector(".ds-bframe img"),
).not.toBeNull();
}
expect(container.textContent).not.toMatch(
/sem promessas|Demonstração publicada|limites|indispon/i,
);
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);
});
it("shows the client cases first, right after the hero, with the first cover in a frame", async () => {
const { container } = await renderService("criacao-de-sites");
const siteService = getServicePage("criacao-de-sites");
if (!siteService) throw new Error("Missing service");
const casesHeading = screen.getByRole("heading", {
level: 2,
name: "Veja projetos parecidos, já prontos",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
).toBe(true);
expect(
isBefore(
casesHeading,
screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
),
).toBe(true);
const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ });
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual(
getRelatedCases(siteService.relatedCaseIds).map(({ id }) => `/cases/${id}`),
);
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
"/cases/sc-plus",
"/cases/as-servicos",
]);
// pôster do primeiro case numa moldura no topo, no lugar do fundo verde
const hero = container.querySelector("section");
const heroFrame = hero?.querySelector(".ds-bframe");
expect(heroFrame).not.toBeNull();
expect(heroFrame?.querySelector("img")).toHaveAttribute(
"alt",
"Captura de tela do site SEV Moda Íntima",
);
expect(hero?.querySelector("a[href='/cases/sev-modaintima']")).not.toBeNull();
});
it("hides the related block and the hero frame when a service has no case", async () => {
const { container } = await renderService("tauri-rust");
expect(
screen.queryByRole("heading", {
name: "Veja projetos parecidos, já prontos",
}),
).toBeNull();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(
screen.queryByRole("link", { name: "Ver projetos parecidos" }),
).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
// GCC Installer (Python e Tkinter) não pertence a um serviço de Tauri e Rust
expect(container.textContent).not.toMatch(/GCC|Tkinter/);
});
it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => {
const { container } = await renderService("integracao-hardware-software-iot");
const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ });
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab",
]);
expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible();
// sem captura gravada, não há moldura nem imagem
expect(container.querySelector(".ds-bframe")).toBeNull();
});
it("renders the offer block only when some field is filled in", async () => {
const empty = await renderService("criacao-de-sites");
expect(
screen.queryByRole("heading", { name: "Investimento e prazo" }),
).toBeNull();
empty.unmount();
const offerService = getServicePage("criacao-de-sites");
if (!offerService) throw new Error("Missing service");
offerService.typicalTimeline = "Texto de prazo de teste";
try {
await renderService("criacao-de-sites");
expect(
screen.getByRole("heading", { level: 2, name: "Investimento e prazo" }),
).toBeVisible();
expect(screen.getByText("Prazo típico")).toBeVisible();
expect(screen.getByText("Texto de prazo de teste")).toBeVisible();
expect(screen.queryByText("A partir de")).toBeNull();
expect(screen.queryByText("Garantia")).toBeNull();
} finally {
delete offerService.typicalTimeline;
}
});
it("keeps related case ids resolving to real cases", () => {
for (const id of service.relatedCaseIds) {
expect(caseStudies.some((item) => item.id === id)).toBe(true);
}
});
+55 -157
View File
@@ -1,18 +1,30 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { getCaseMedia } from "@/app/cases/_components/case-media";
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, buildServiceJsonLd } from "@/lib/seo";
import {
buildBreadcrumbList,
buildPageSocialMetadata,
buildServiceJsonLd,
} from "@/lib/seo";
import {
getRelatedCases,
getServicePage,
servicePages,
siteName,
siteUrl,
} from "@/lib/site-content";
import { ServiceFaq } from "../_components/ServiceFaq";
import { ServiceHeroCover } from "../_components/ServiceHeroCover";
import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
interface ServicePageProps {
params: Promise<{
@@ -41,22 +53,12 @@ export async function generateMetadata({
return {
title: service.seoTitle,
description: service.metaDescription,
alternates: {
canonical: url,
},
openGraph: {
locale: "pt_BR",
title: service.seoTitle,
description: service.metaDescription,
...buildPageSocialMetadata({
url,
siteName,
type: "article",
},
twitter: {
card: "summary_large_image",
title: service.seoTitle,
description: service.metaDescription,
},
type: "article",
}),
};
}
@@ -69,6 +71,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
}
const relatedCases = getRelatedCases(service.relatedCaseIds);
const heroCase = relatedCases.find((caseStudy) =>
getCaseMedia(caseStudy.id, "desktop"),
);
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
const jsonLd = {
@@ -87,35 +92,22 @@ export default async function ServicePage({ params }: ServicePageProps) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div
className={`ds-section-shell ${
heroCase
? "grid items-center gap-sp-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-sp-16"
: ""
}`}
>
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/servicos">
Serviços
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
{service.navLabel}
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
{ label: service.navLabel, href: `/servicos/${service.slug}` },
]}
/>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
{service.headline}
@@ -127,18 +119,15 @@ export default async function ServicePage({ params }: ServicePageProps) {
<WhatsAppLink
service={service.navLabel}
placement="service_detail_hero"
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver casos relacionados
</MagneticButton>
{relatedCases.length > 0 ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
Ver projetos parecidos
</a>
) : null}
</div>
<p className="mt-sp-4 text-sm leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
@@ -146,9 +135,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
SC.
</p>
</div>
<ServiceHeroCover caseStudy={heroCase} />
</div>
</section>
<ServiceRelatedCases cases={relatedCases} />
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
@@ -192,8 +184,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-kicker">Possibilidades de uso</div>
<h2 className="ds-card-title">Imagine na sua rotina</h2>
<p className="ds-copy">
Exemplos ilustrativos para ajudar a explicar o serviço, não
projetos já entregues. O seu escopo será definido na conversa.
Situações comuns em que este serviço ajuda. A sua será combinada
na conversa.
</p>
<ul className="ds-list">
{service.examples.map((example) => (
@@ -215,9 +207,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
))}
</ul>
<p className="ds-copy">
Tecnologias consideradas neste serviço. A seleção depende do
problema e do ambiente; não é uma lista obrigatória para todo
projeto.
Tecnologias que costumo usar neste serviço. A escolha depende do
problema e do ambiente.
</p>
<ul className="ds-tags" aria-label="Tecnologias do serviço">
{service.stack.map((item) => (
@@ -231,71 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Casos relacionados</p>
<h2 className="ds-section-title">
Projetos para conhecer meu trabalho
</h2>
<p className="ds-section-description">
Referências de problemas e soluções relacionados. Cada projeto
usa as tecnologias indicadas no próprio case; não representa
todas as plataformas ou tecnologias oferecidas neste serviço.
</p>
</div>
</div>
<div className="ds-grid-2">
{relatedCases.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.subtitle}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<p className="mt-sp-4 text-[13px] leading-7 text-[var(--meta)]">
{caseStudy.impact}
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className="ds-link ds-link-accent"
target="_blank"
rel="noreferrer"
>
Abrir projeto: {caseStudy.title}
</a>
) : null}
{caseStudy.githubUrl ? (
<a
href={caseStudy.githubUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
>
Ver código: {caseStudy.title}
</a>
) : null}
</div>
</article>
))}
</div>
</div>
</section>
<ServiceOffer service={service} />
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell">
@@ -314,48 +241,19 @@ export default async function ServicePage({ params }: ServicePageProps) {
<WhatsAppLink
service={service.navLabel}
placement="service_detail_footer"
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
<Link href="/servicos" className={secondaryButtonDark}>
Ver todos os serviços
</MagneticButton>
</Link>
</div>
</div>
</div>
</section>
{service.faq && service.faq.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de
cada projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{service.faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
) : null}
<ServiceFaq faq={service.faq} />
</main>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { ServicePageContent } from "@/lib/site-content";
/** "Perguntas frequentes" do serviço. Sem perguntas, a seção não aparece. */
export function ServiceFaq({ faq }: { faq: ServicePageContent["faq"] }) {
if (!faq || faq.length === 0) return null;
return (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de cada
projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,25 @@
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import type { CaseStudyContent } from "@/lib/site-content";
/** Capa do case de destaque ao lado do título do serviço. Sem case com captura, não mostra nada. */
export function ServiceHeroCover({
caseStudy,
}: {
caseStudy?: CaseStudyContent;
}) {
if (!caseStudy) return null;
return (
<Link href={`/cases/${caseStudy.id}`} className="block min-w-0">
<span className="sr-only">Ver o case {caseStudy.title}</span>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 520px, 100vw"
priority
/>
</Link>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { ServicePageContent } from "@/lib/site-content";
/** "Investimento e prazo": só mostra os campos preenchidos com valor real; sem nenhum, a seção não aparece. */
export function ServiceOffer({
service,
}: {
service: Pick<
ServicePageContent,
"startingPrice" | "typicalTimeline" | "guarantee"
>;
}) {
const offer = [
{ label: "A partir de", value: service.startingPrice },
{ label: "Prazo típico", value: service.typicalTimeline },
{ label: "Garantia", value: service.guarantee },
].filter((item): item is { label: string; value: string } =>
Boolean(item.value),
);
if (offer.length === 0) return null;
return (
<section className="ds-section" aria-labelledby="investimento-prazo">
<div className="ds-section-shell">
<h2 id="investimento-prazo" className="ds-card-title !mt-0">
Investimento e prazo
</h2>
<dl className="mt-sp-6 grid gap-sp-6 md:grid-cols-3">
{offer.map((item) => (
<div
key={item.label}
className="border-t border-[var(--border)] pt-sp-4"
>
<dt className="ds-kicker">{item.label}</dt>
<dd className="ds-copy !mt-sp-2">{item.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
@@ -0,0 +1,84 @@
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
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. */
export function ServiceRelatedCases({
cases,
}: {
cases: readonly CaseStudyContent[];
}) {
if (cases.length === 0) return null;
return (
<section
id="casos-relacionados"
className="ds-section"
aria-labelledby="casos-relacionados-titulo"
>
<div className="ds-section-shell">
<div className="ds-section-header">
<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
</h2>
<p className="ds-section-description">
Projetos reais nesta área. Abra o case para ver o que foi feito e
como ficou.
</p>
</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>
</div>
</section>
);
}
+50 -53
View File
@@ -1,19 +1,23 @@
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 { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
primaryButton,
secondaryButtonDark,
textLink,
} from "@/app/components/button-styles";
import WhatsAppLink from "@/app/components/WhatsAppLink";
import { safeJsonLd } from "@/lib/jsonld";
import { buildBreadcrumbList } from "@/lib/seo";
import {
caseStudies,
serviceHubLinks,
siteName,
siteUrl,
} from "@/lib/site-content";
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";
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas";
const pageDescription =
"Sites, sistemas web e aplicativos sob medida com Evandro, desenvolvedor em Garuva-SC e atendimento remoto no Brasil. Conheça projetos e converse pelo WhatsApp.";
"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.";
const businessIntents = [
{
@@ -45,24 +49,15 @@ const businessIntents = [
},
];
const featuredCases = caseStudies.filter((caseStudy) =>
["sc-plus", "nutriscan"].includes(caseStudy.id),
);
export const metadata: Metadata = {
title: pageTitle,
description: pageDescription,
alternates: {
canonical: pageUrl,
},
openGraph: {
locale: "pt_BR",
...buildPageSocialMetadata({
url: pageUrl,
title: pageTitle,
description: pageDescription,
url: pageUrl,
siteName,
type: "website",
},
}),
};
export default function ServicesIndexPage() {
@@ -96,24 +91,14 @@ export default function ServicesIndexPage() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-3 text-sm">
<li>
<Link
className="inline-flex min-h-11 items-center underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
href="/"
>
Início
</Link>
</li>
<li className="flex items-center gap-sp-3">
<span aria-hidden="true">/</span>
<span aria-current="page">Serviços</span>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
]}
/>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Sites, sistemas e aplicativos sob medida.
</h1>
@@ -124,7 +109,7 @@ export default function ServicesIndexPage() {
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
<WhatsAppLink
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
service="Sites, sistemas e aplicativos"
placement="service_hub_hero"
>
@@ -132,7 +117,7 @@ export default function ServicesIndexPage() {
</WhatsAppLink>
<a
href="#projetos"
className="ds-link ds-link-muted min-h-12 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver projetos realizados
</a>
@@ -165,7 +150,7 @@ export default function ServicesIndexPage() {
<p className="ds-copy">{intent.description}</p>
<div className="mt-sp-4 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2">
<WhatsAppLink
className="ds-link ds-link-accent min-h-11"
className={primaryButton}
service={intent.service}
placement="service_hub_intent"
>
@@ -173,7 +158,7 @@ export default function ServicesIndexPage() {
</WhatsAppLink>
<Link
href={intent.href}
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{intent.linkLabel}
</Link>
@@ -194,24 +179,36 @@ export default function ServicesIndexPage() {
Veja o trabalho antes de conversar.
</h2>
<p className="ds-copy max-w-[64ch]">
Dois projetos com demonstrações publicadas. Os cases mostram o que
foi implementado e as tecnologias usadas, sem promessas de resultado
comercial.
Projetos feitos para empresas. Abra o case para ver o que foi feito
e como o site ficou.
</p>
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2">
{featuredCases.map((caseStudy) => (
{clientCases.map((caseStudy) => (
<article
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
<p className="mb-sp-3 text-sm text-[var(--meta)]">
Demonstração publicada
</p>
<h3 className="ds-card-title">{caseStudy.title}</h3>
{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}
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<Link
href={`/cases/${caseStudy.id}`}
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${textLink} mt-sp-4 focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver o case {caseStudy.title}
</Link>
@@ -286,7 +283,7 @@ export default function ServicesIndexPage() {
</h2>
<p className="ds-copy max-w-[64ch]">
Consulte as áreas de atuação, incluindo desktop, integrações,
consultoria e IoT. Cada página explica possibilidades e limites.
consultoria e IoT. Cada página explica o que posso fazer por você.
</p>
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service) => (
@@ -318,7 +315,7 @@ export default function ServicesIndexPage() {
formulário para começar.
</p>
<WhatsAppLink
className="ds-link ds-link-accent mt-sp-6 min-h-12"
className={`${primaryButton} mt-sp-6`}
service="Sites, sistemas e aplicativos"
placement="service_hub_footer"
>
+5 -1
View File
@@ -1,6 +1,9 @@
import type { MetadataRoute } from "next";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
/** Data fixa da última mudança de conteúdo; o sitemap precisa ser igual a cada build. Atualize quando as páginas mudarem. */
const lastModified = new Date("2026-10-14T00:00:00Z");
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: siteUrl },
@@ -8,11 +11,12 @@ export default function sitemap(): MetadataRoute.Sitemap {
{ url: `${siteUrl}/privacidade` },
{ url: `${siteUrl}/servicos` },
{ url: `${siteUrl}/cases` },
{ url: `${siteUrl}/ia` },
...servicePages.map(({ slug }) => ({
url: `${siteUrl}/servicos/${slug}`,
})),
...caseStudies.map(({ id }) => ({
url: `${siteUrl}/cases/${id}`,
})),
];
].map((entry) => ({ ...entry, lastModified }));
}