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

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

No files matched your search

+120 -9
View File
@@ -4,6 +4,8 @@ import {
caseStudies,
getRelatedCases,
getServicePage,
isClientCase,
servicePages,
siteUrl,
} from "@/lib/site-content";
import ServicesIndexPage from "../page";
@@ -62,16 +64,16 @@ it("keeps static params and route metadata canonical", async () => {
const url = `${siteUrl}/servicos/${service.slug}`;
expect(metadata).toMatchObject({
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
alternates: { canonical: url },
openGraph: {
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
url,
},
twitter: {
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
},
});
@@ -192,7 +194,7 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
]);
const visibleBreadcrumb = screen.getByRole("navigation", {
name: "Breadcrumb",
name: "Trilha de navegação",
});
expect(
within(visibleBreadcrumb)
@@ -273,7 +275,7 @@ it("shows the client cases first, right after the hero, with the first cover in
const casesHeading = screen.getByRole("heading", {
level: 2,
name: "Veja projetos parecidos, já prontos",
name: "Onde já apliquei isso",
});
expect(
isBefore(screen.getByRole("heading", { level: 1 }), casesHeading),
@@ -311,9 +313,7 @@ it("hides the related block and the hero frame when a service has no case", asyn
const { container } = await renderService("tauri-rust");
expect(
screen.queryByRole("heading", {
name: "Veja projetos parecidos, já prontos",
}),
screen.queryByRole("heading", { name: "Onde já apliquei isso" }),
).toBeNull();
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(
@@ -331,11 +331,122 @@ it("labels the Twins Lab prototype as a laboratory project on the IoT service",
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
"/cases/twins-lab",
]);
expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible();
expect(screen.getByText("Twins Lab · Projeto próprio")).toBeVisible();
expect(screen.queryByText(/Protótipo de laboratório/)).toBeNull();
// sem captura gravada, não há moldura nem imagem
expect(container.querySelector(".ds-bframe")).toBeNull();
});
it("labels only the laboratory projects as 'Projeto próprio'", async () => {
for (const slug of ["criacao-de-sites", "integracao-hardware-software-iot"]) {
const { unmount } = await renderService(slug);
const related = getRelatedCases(getServicePage(slug)?.relatedCaseIds ?? []);
expect(related.length).toBeGreaterThan(0);
const labels = screen.queryAllByText(/· Projeto próprio$/);
expect(labels).toHaveLength(related.filter((c) => !isClientCase(c)).length);
unmount();
}
});
it("says in one sentence, per case, why it matters to the service", async () => {
for (const { slug, relatedCaseIds, caseRelevance } of servicePages) {
if (relatedCaseIds.length === 0) continue;
const { unmount } = await renderService(slug);
for (const id of relatedCaseIds) {
const sentence = caseRelevance[id];
expect(sentence).toBeTruthy();
const card = screen.getByText(sentence).closest("article");
expect(card).toContainElement(
screen.getByRole("link", {
name: `Ler case: ${caseStudies.find((c) => c.id === id)?.title}`,
}),
);
}
unmount();
}
});
it("cites the Apolo Energia experience in text on the IoT and architecture pages, with no link", async () => {
for (const slug of [
"integracao-hardware-software-iot",
"arquitetura-de-software",
]) {
const note = getServicePage(slug)?.experienceNote ?? "";
const { container, unmount } = await renderService(slug);
const paragraph = screen.getByText(/Apolo Energia/);
expect(paragraph).toBeVisible();
expect(paragraph.textContent).toContain(note);
expect(paragraph.textContent).toMatch(/Desde 2022/);
expect(paragraph.closest("a")).toBeNull();
expect(paragraph.querySelector("a")).toBeNull();
// dentro do bloco "Onde já apliquei isso", antes dos cards
const block = screen.getByRole("heading", {
name: "Onde já apliquei isso",
});
expect(isBefore(block, paragraph)).toBe(true);
expect(
isBefore(
paragraph,
container.querySelector("article.ds-card") as Element,
),
).toBe(true);
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs.some((href) => /apolo/i.test(href))).toBe(false);
unmount();
}
});
it("does not mention Apolo Energia on the other service pages", async () => {
for (const slug of [
"criacao-de-sites",
"engenharia-de-software",
"consultoria-nextjs",
"tauri-rust",
]) {
const { container, unmount } = await renderService(slug);
expect(container.textContent).not.toMatch(/Apolo/);
unmount();
}
});
it("puts the FAQ before the next step and ends every service page with a contact button", async () => {
for (const { slug, faq, ctaLabel } of servicePages) {
const { container, unmount } = await renderService(slug);
const nextStep = screen.getByRole("heading", {
level: 2,
name: "Conte o que você precisa resolver.",
});
if (faq?.length) {
const faqHeading = screen.getByRole("heading", {
level: 2,
name: "Perguntas frequentes",
});
expect(isBefore(faqHeading, nextStep)).toBe(true);
}
// o botão de contato vem depois das dúvidas: o último elemento da página é a seção do próximo passo
const last = container.querySelector("main")?.lastElementChild;
expect(last).toContainElement(nextStep);
const cta = within(last as HTMLElement).getByRole("link", {
name: `${ctaLabel} no WhatsApp`,
});
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe(
"https://wa.me",
);
for (const question of faq ?? []) {
expect(
isBefore(screen.getByRole("heading", { name: question.question }), cta),
).toBe(true);
}
unmount();
}
});
it("renders the offer block only when some field is filled in", async () => {
const empty = await renderService("criacao-de-sites");
expect(
+12 -8
View File
@@ -51,11 +51,11 @@ export async function generateMetadata({
const url = `${siteUrl}/servicos/${service.slug}`;
return {
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
...buildPageSocialMetadata({
url,
title: service.seoTitle,
title: service.metaTitle,
description: service.metaDescription,
type: "article",
}),
@@ -129,7 +129,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</a>
) : null}
</div>
<p className="mt-sp-4 text-sm leading-6 text-white/70">
<p className="mt-sp-4 text-[15px] md:text-sm leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
preencher um formulário. Atendimento remoto a partir de Garuva,
SC.
@@ -139,7 +139,11 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</section>
<ServiceRelatedCases cases={relatedCases} />
<ServiceRelatedCases
cases={relatedCases}
relevance={service.caseRelevance}
experienceNote={service.experienceNote}
/>
<section className="ds-section">
<div className="ds-section-shell">
@@ -171,7 +175,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
<article className="min-w-0">
<div className="ds-kicker">Da conversa à entrega</div>
<h2 className="ds-card-title">Como trabalhamos</h2>
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-sm leading-8 text-[var(--ink-80)]">
<ol className="mt-sp-5 list-decimal space-y-sp-4 pl-sp-5 text-[15px] md:text-sm leading-8 text-[var(--ink-80)]">
{service.process.map((step) => (
<li key={step} className="pl-sp-2">
{step}
@@ -224,7 +228,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
<ServiceOffer service={service} />
<section className="ds-section ds-section-dark pb-sp-6">
<ServiceFaq faq={service.faq} />
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
<div className="ds-kicker">Próximo passo</div>
@@ -252,8 +258,6 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</div>
</section>
<ServiceFaq faq={service.faq} />
</main>
);
}
@@ -0,0 +1,179 @@
import { render, screen, within } from "@testing-library/react";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { getShowcaseAssets } from "@/lib/project-showcase";
import { caseStudies, isClientCase } from "@/lib/site-content";
import { ServiceRelatedCases } from "./ServiceRelatedCases";
/**
* Testes de caminhos-base de ServiceRelatedCases, direto no componente:
* sem case nem experiência, só a experiência, case sem frase de relação, case de laboratório,
* case sem captura e case sem site no ar. A existência dos arquivos em disco é simulada em getCaseMedia.
*/
jest.mock("@/app/cases/_components/case-media", () => ({
getCaseMedia: jest.fn(),
}));
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
if (!caseStudy) throw new Error(`Missing case ${id}`);
return caseStudy;
}
function withDesktopCapture() {
mockedGetCaseMedia.mockImplementation((id, device) =>
device === "desktop" ? getShowcaseAssets(id, device) : undefined,
);
}
const sev = getCase("sev-modaintima"); // cliente, com site no ar
const asServicos = getCase("as-servicos"); // cliente, sem site público
const twins = getCase("twins-lab"); // laboratório, sem site público
const TITLE = "Onde já apliquei isso";
const NOTE = "Texto de experiência de teste.";
beforeEach(() => {
mockedGetCaseMedia.mockReturnValue(undefined);
});
afterEach(() => {
mockedGetCaseMedia.mockReset();
});
describe("ServiceRelatedCases", () => {
it("path: no case and no experience note -> renders nothing", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: no case but an experience note -> keeps the block with the note and no case card", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={NOTE} />,
);
expect(
screen.getByRole("heading", { level: 2, name: TITLE }),
).toBeVisible();
expect(screen.getByText(NOTE)).toBeVisible();
expect(container.querySelectorAll("article")).toHaveLength(0);
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
});
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
withDesktopCapture();
const { container } = render(
<ServiceRelatedCases
cases={[sev]}
relevance={{ [sev.id]: "Frase de relação do SEV." }}
/>,
);
expect(
screen.getByRole("heading", { level: 2, name: TITLE }),
).toBeVisible();
expect(screen.queryByText("Na prática:")).toBeNull();
const card = container.querySelector("article");
if (!card) throw new Error("Missing case card");
expect(within(card).getByText(sev.headline)).toBeVisible();
// case de cliente: o kicker é só o nome
expect(within(card).getByText(sev.title)).toBeVisible();
expect(within(card).queryByText(/Projeto próprio/)).toBeNull();
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
expect(
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }),
).toHaveAttribute("href", `/cases/${sev.id}`);
expect(
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
).toHaveAttribute("href", sev.demoUrl);
});
it("path: cases and an experience note -> the note comes first, in text only, with no link", () => {
render(
<ServiceRelatedCases
cases={[sev]}
relevance={{ [sev.id]: "Frase de relação do SEV." }}
experienceNote={NOTE}
/>,
);
const note = screen.getByText(NOTE, { exact: false });
expect(screen.getByText("Na prática:")).toBeVisible();
expect(note.closest("a")).toBeNull();
expect(note.querySelector("a")).toBeNull();
expect(
note.compareDocumentPosition(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
it("path: case with a desktop capture -> cover link with the poster, hidden from assistive tech", () => {
withDesktopCapture();
const { container } = render(
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
);
const coverLink = container.querySelector(
`article a[aria-hidden="true"][href="/cases/${sev.id}"]`,
);
expect(coverLink).not.toBeNull();
expect(coverLink).toHaveAttribute("tabindex", "-1");
expect(coverLink?.querySelector(".ds-bframe img")).toHaveAttribute(
"alt",
`Captura de tela do site ${sev.title}`,
);
});
it("path: case without a desktop capture -> no cover link, text and buttons stay", () => {
const { container } = render(
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
);
expect(container.querySelector("a[aria-hidden='true']")).toBeNull();
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
expect(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
).toBeVisible();
});
it("path: laboratory case -> kicker marks it as 'Projeto próprio'", () => {
expect(isClientCase(twins)).toBe(false);
render(
<ServiceRelatedCases
cases={[twins]}
relevance={{ [twins.id]: "Frase." }}
/>,
);
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
});
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
render(<ServiceRelatedCases cases={[sev]} relevance={{}} />);
const card = screen.getByRole("heading", { level: 3 }).closest("article");
expect(card).not.toBeNull();
expect(within(card as HTMLElement).getByText(sev.summary)).toBeVisible();
});
it("path: case without a public site -> no 'Abrir o site' link", () => {
expect(asServicos.demoUrl).toBeNull();
render(
<ServiceRelatedCases
cases={[asServicos]}
relevance={{ [asServicos.id]: "Frase." }}
/>,
);
expect(
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
});
@@ -0,0 +1,48 @@
import { render, screen } from "@testing-library/react";
import { caseStudies } from "@/lib/site-content";
import { ServiceRelatedCases } from "./ServiceRelatedCases";
const caseStudy = caseStudies[0];
const note = "Desde 2022 trabalho com isso, em sistemas internos.";
it("renders nothing without cases and without an experience note", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} />,
);
expect(container).toBeEmptyDOMElement();
});
it("shows only the experience note, without the 'open the case' sentence, when there are no cases", () => {
const { container } = render(
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={note} />,
);
expect(
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
).toBeVisible();
expect(screen.getByText(note)).toBeVisible();
expect(screen.queryByText(/Abra o case/)).toBeNull();
expect(screen.queryByRole("link")).toBeNull();
expect(container.querySelector(".ds-grid-2")).toBeNull();
});
it("invites to open the case and lists each case when there are some", () => {
render(
<ServiceRelatedCases
cases={[caseStudy]}
relevance={{ [caseStudy.id]: "Frase própria sobre a relação." }}
/>,
);
expect(screen.getByText(/Abra o case/)).toBeVisible();
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
expect(
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }),
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
});
it("falls back to the case summary when no relevance sentence is given", () => {
render(<ServiceRelatedCases cases={[caseStudy]} relevance={{}} />);
expect(screen.getByText(caseStudy.summary)).toBeVisible();
});
@@ -1,16 +1,67 @@
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
/** "Veja projetos parecidos": os cases ligados ao serviço. Sem case relacionado, não mostra nada. */
function ServiceCaseCard({
caseStudy,
relevance,
}: {
caseStudy: CaseStudyContent;
/** Por que este case interessa ao serviço; sem frase própria, vale o resumo do case. */
relevance?: string;
}) {
return (
<article className="ds-card">
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
<div className="ds-kicker">
{isClientCase(caseStudy)
? caseStudy.title
: `${caseStudy.title} · Projeto próprio`}
</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-kicker mt-sp-5">Relação com este serviço</p>
<p className="ds-copy !mt-sp-2">{relevance ?? caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<Link href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
</a>
) : null}
</div>
</article>
);
}
/**
* "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma frase sobre o que ele tem a ver com ele,
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase
* que manda abrir o case; sem case nem experiência, não mostra nada.
*/
export function ServiceRelatedCases({
cases,
relevance,
experienceNote,
}: {
cases: readonly CaseStudyContent[];
/** Por que cada case interessa a este serviço (chave: id do case). */
relevance: Readonly<Record<string, string>>;
experienceNote?: string;
}) {
if (cases.length === 0) return null;
const hasCases = cases.length > 0;
if (!hasCases && !experienceNote) return null;
return (
<section
@@ -23,61 +74,37 @@ export function ServiceRelatedCases({
<div className="ds-section-meta">
<p className="ds-eyebrow">Trabalhos parecidos</p>
<h2 id="casos-relacionados-titulo" className="ds-section-title">
Veja projetos parecidos, já prontos
Onde já apliquei isso
</h2>
<p className="ds-section-description">
Projetos reais nesta área. Abra o case para ver o que foi feito e
como ficou.
</p>
{hasCases ? (
<p className="ds-section-description">
Cada projeto tem a ver com este serviço de um jeito. Abra o case
para ver o que foi feito e como ficou.
</p>
) : null}
</div>
</div>
<div className="ds-grid-2">
{cases.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
{getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<div className="ds-kicker">
{isClientCase(caseStudy)
? caseStudy.title
: `${caseStudy.title} · Protótipo de laboratório`}
</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
</a>
) : null}
</div>
</article>
))}
</div>
{experienceNote ? (
<p className="ds-copy mb-sp-8 max-w-[72ch]">
<strong className="font-semibold text-[var(--ink)]">
Na prática:
</strong>{" "}
{experienceNote}
</p>
) : null}
{hasCases ? (
<div className="ds-grid-2">
{cases.map((caseStudy) => (
<ServiceCaseCard
key={caseStudy.id}
caseStudy={caseStudy}
relevance={relevance[caseStudy.id]}
/>
))}
</div>
) : null}
</div>
</section>
);
+9 -20
View File
@@ -1,7 +1,6 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CaseCover } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { Breadcrumb } from "@/app/components/Breadcrumb";
import {
pageTop,
@@ -15,9 +14,9 @@ import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/servicos`;
const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas";
const pageTitle = "Sites, sistemas e aplicativos sob medida";
const pageDescription =
"Sites, sistemas web e aplicativos sob medida para sua empresa apresentar o negócio e organizar a operação. Evandro, desenvolvedor em Garuva-SC, atende Joinville, região e todo o Brasil de forma remota.";
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Atendimento de Garuva-SC para todo o Brasil.";
const businessIntents = [
{
@@ -188,21 +187,11 @@ export default function ServicesIndexPage() {
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
{getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<CaseCoverLink
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
@@ -241,7 +230,7 @@ export default function ServicesIndexPage() {
</div>
<div>
<h2 className="ds-card-title">Antes de contratar</h2>
<dl className="mt-sp-5 space-y-sp-6 text-sm">
<dl className="mt-sp-5 space-y-sp-6 text-[15px] md:text-sm">
<div>
<dt className="font-semibold">
Quanto custa e quanto tempo leva?