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

+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>
);
}