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