feat(servicos): ofertas no topo de /servicos, capa por assunto e diagramas

/servicos abre com as 3 ofertas e depois outros serviços; H1 de criação de sites e de engenharia com o nome da oferta; capa de cada serviço pelo assunto (SEV fora do próprio case cai de ~11 para 2 imagens); diagrama de fluxo em SVG nos serviços sem case; cases com o que o cliente passou a poder fazer e contato logo depois de Como ficou; FAQ num componente só; lastmod por rota; ProfessionalService com areaServed.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 19:14:46 -03:00
1 parent e63f3c2ebd
commit fc44a69fb3
41 files changed
+2486 -550

No files matched your search

+5 -1
View File
@@ -7,7 +7,11 @@ import CasePage from "./page";
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
*/
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(),
usePathname: () => "/cases",
}));
function getCase(id: string) {
const caseStudy = caseStudies.find((item) => item.id === id);
+8 -5
View File
@@ -10,10 +10,12 @@ import CasePage, { generateMetadata } from "./page";
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
*/
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(() => {
throw new Error("NEXT_NOT_FOUND");
}),
usePathname: () => "/cases",
}));
jest.mock("../_components/case-media", () => {
@@ -125,11 +127,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
expect(
screen.getByRole("link", {
name: "Tem um problema parecido? Vamos conversar.",
}),
).toBeVisible();
// o NutriScan tem "Como ficou": o botão de contato vem depois dele e de novo no fecho, com o mesmo rótulo
const contacts = screen.getAllByRole("link", {
name: "Falar sobre meu projeto",
});
expect(contacts).toHaveLength(2);
for (const contact of contacts) expect(contact).toBeVisible();
expect(
screen.getByRole("link", { name: "Ver outros cases" }),
).toHaveAttribute("href", "/cases");
+132 -44
View File
@@ -1,9 +1,20 @@
import { fireEvent, render, screen, within } from "@testing-library/react";
import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content";
import {
buttonLabels,
caseStudies,
clientCasesForListing,
getRelatedServices,
siteUrl,
} from "@/lib/site-content";
import { similarProjectSubject } from "@/lib/whatsapp";
import CasesPage from "../page";
import CasePage, { generateMetadata, generateStaticParams } from "./page";
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
jest.mock("next/navigation", () => ({
notFound: jest.fn(),
usePathname: () => "/cases",
}));
const clientCaseIds = [
"sev-modaintima",
@@ -50,32 +61,33 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
}
/**
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case.
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma
* frase nos dois, a do cabeçalho: "um projeto parecido com o {título}".
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
* `similarProjectSubject(título)`. Com "Como ficou" na página, o botão aparece duas vezes: logo depois dele e no fecho.
*/
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
function expectContactAndNoDeadLinks(
caseStudy: { id: string; title: string },
expectedButtons = 1,
) {
const hrefs = screen
.getAllByRole("link")
.map((link) => link.getAttribute("href") ?? "");
expect(hrefs).not.toContain("/#contact");
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
const isClient = clientCaseIds.includes(caseStudy.id);
const cta = screen.getByRole("link", {
name: isClient
? "Conversar sobre um projeto parecido"
: "Tem um problema parecido? Vamos conversar.",
});
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
`um projeto parecido com o ${caseStudy.title}`,
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
// limão preenchido só no contato: o botão de play e os demais não o usam
expect(cta.className).toContain("bg-[var(--slash)]");
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
expect(ctas).toHaveLength(expectedButtons);
for (const cta of ctas) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
expect(destination.searchParams.get("text")).toContain(
similarProjectSubject(caseStudy.title),
);
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
// limão preenchido só no contato: o botão de play e os demais não o usam
expect(cta.className).toContain("bg-[var(--slash)]");
}
}
it("exposes the eight public cases, the two new client cases among them", async () => {
@@ -118,6 +130,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
"O problema",
"O que foi feito",
"Como ficou",
"O que o cliente passou a poder fazer",
"Quer um projeto parecido com este?",
];
const sectionHeadings = sectionNames.map((name) =>
@@ -158,7 +171,27 @@ it("renders a client case in the sales order, with media, contact and collapsed
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expectContactAndNoDeadLinks(caseStudy);
// o botão de contato se repete logo depois de "Como ficou", dentro da seção das capacidades
expectContactAndNoDeadLinks(caseStudy, 2);
const capabilitiesSection = sectionHeadings[4].closest("section");
expect(capabilitiesSection).toContainElement(
within(capabilitiesSection as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
}),
);
for (const capability of caseStudy.capabilities ?? []) {
expect(
within(capabilitiesSection as HTMLElement).getByText(capability),
).toBeVisible();
}
expect(
isBefore(
sectionHeadings[3],
within(capabilitiesSection as HTMLElement).getByRole("link", {
name: buttonLabels.contact,
}),
),
).toBe(true);
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
const details = container.querySelector("details");
@@ -260,7 +293,8 @@ it("renders the two new client cases from the portfolio data", async () => {
expect(
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
).toHaveAttribute("href", caseStudy.demoUrl);
expectContactAndNoDeadLinks(caseStudy);
// com "Como ficou" na página, o contato aparece logo depois dele e de novo no fecho
expectContactAndNoDeadLinks(caseStudy, 2);
unmount();
}
});
@@ -289,24 +323,71 @@ it("labels a laboratory case, skips the media block and shows its service as a c
expectContactAndNoDeadLinks(caseStudy);
});
it("turns the contact of a laboratory case into the 'problema parecido' invitation", async () => {
it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invitation, with the same button label as a client case", async () => {
for (const id of labCaseIds) {
const { unmount } = await renderCase(id);
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
expect(
screen.queryByRole("link", {
name: "Conversar sobre um projeto parecido",
name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
}),
).toBeNull();
expect(
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
).toBeVisible();
expectContactAndNoDeadLinks(getCase(id));
// a repetição do botão só vem junto de "Como ficou"
const hasMedia = screen.queryByRole("heading", { name: "Como ficou" });
expectContactAndNoDeadLinks(getCase(id), hasMedia ? 2 : 1);
// o laboratório não tem "o que o cliente passou a poder fazer"
expect(
screen.queryByRole("heading", {
name: "O que o cliente passou a poder fazer",
}),
).toBeNull();
unmount();
}
});
it("writes what the client can do now right after 'Como ficou', then repeats the contact button, for every client case", async () => {
for (const id of clientCaseIds) {
const caseStudy = getCase(id);
const { unmount } = await renderCase(id);
const media = screen.getByRole("heading", { level: 2, name: "Como ficou" });
const capabilities = screen.getByRole("heading", {
level: 2,
name: "O que o cliente passou a poder fazer",
});
// a seção vem logo depois da mídia, sem outra seção no meio
const mediaSection = media.closest("section");
const capabilitiesSection = capabilities.closest("section");
expect(mediaSection?.nextElementSibling).toBe(capabilitiesSection);
const items = within(capabilitiesSection as HTMLElement)
.getAllByRole("listitem")
.map((item) => item.textContent);
expect(items).toEqual(caseStudy.capabilities);
expect(items.length).toBeGreaterThanOrEqual(2);
expect(items.length).toBeLessThanOrEqual(3);
// sem número inventado
expect(items.join(" ")).not.toMatch(/\d/);
const button = within(capabilitiesSection as HTMLElement).getByRole(
"link",
{ name: buttonLabels.contact },
);
expect(isBefore(media, button)).toBe(true);
unmount();
}
// a frase do dono da Lavação 047 vem dos dados do projeto
await renderCase("lavacao-047");
expect(
screen.getByText(
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
),
).toBeVisible();
});
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();
@@ -361,8 +442,8 @@ it("shows the result section only when the case has real data", async () => {
});
it("shows at most two related services, as cards under the contact button", async () => {
// sev-modaintima tem quatro serviços ligados; só os dois primeiros viram cards
const sev = getCase("sev-modaintima");
// lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
const sev = getCase("lavacao-047");
const all = getRelatedServices(sev.id);
expect(all.length).toBeGreaterThan(2);
const { unmount } = await renderCase(sev.id);
@@ -382,16 +463,16 @@ it("shows at most two related services, as cards under the contact button", asyn
expect(
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
).toBeNull();
const contact = screen.getByRole("link", {
name: "Conversar sobre um projeto parecido",
});
expect(isBefore(contact, cards[0])).toBe(true);
// o botão do fecho (o último) vem antes dos cartões
const contacts = screen.getAllByRole("link", { name: buttonLabels.contact });
expect(isBefore(contacts[contacts.length - 1], cards[0])).toBe(true);
unmount();
// sc-plus tem dois; as-servicos, um
// sc-plus tem dois; a SEV e a AS Serviços, um cada (a SEV só aparece na página de sistemas)
for (const [id, expected] of [
["sc-plus", 2],
["as-servicos", 1],
["sev-modaintima", 1],
] as const) {
const { unmount: unmountCase } = await renderCase(id);
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
@@ -401,7 +482,7 @@ it("shows at most two related services, as cards under the contact button", asyn
.slice(0, 2)
.map(({ slug }) => `/servicos/${slug}`),
);
expectContactAndNoDeadLinks(getCase(id));
expectContactAndNoDeadLinks(getCase(id), 2);
unmountCase();
}
});
@@ -554,9 +635,9 @@ it("gives the case page the same shell as the home, with no narrow max-width", a
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(
/foi feito para empresas/i,
);
expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
// o título e o subtítulo não repetem "Veja o que já"
expect(heading.nextElementSibling).not.toHaveTextContent(/Veja o que já/);
expect(container.textContent).not.toMatch(/Selected work/i);
const clientHeading = screen.getByRole("heading", {
@@ -571,8 +652,11 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
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) => {
// cases de cliente, com a SEV por último: capa (pôster) em moldura de navegador e botão "Ver case" em contorno
const listedIds = clientCasesForListing.map(({ id }) => id);
expect([...listedIds].sort()).toEqual([...clientCaseIds].sort());
expect(listedIds[listedIds.length - 1]).toBe("sev-modaintima");
const clientLinks = listedIds.map((id) => {
const card = container
.querySelector(`a[href="/cases/${id}"]`)
?.closest("article");
@@ -583,14 +667,18 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
"alt",
`Captura de tela do site ${getCase(id).title}`,
);
return within(card).getByRole("link", { name: "Ver o case" });
return within(card).getByRole("link", {
name: `${buttonLabels.case} ${getCase(id).title}`,
});
});
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
clientCaseIds.map((id) => `/cases/${id}`),
listedIds.map((id) => `/cases/${id}`),
);
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
const labLinks = screen.getAllByRole("link", { name: /^Ver o projeto / });
const labLinks = within(
labHeading.closest("section") as HTMLElement,
).getAllByRole("link", { name: /^Ver case / });
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
labCaseIds.map((id) => `/cases/${id}`),
);
@@ -603,7 +691,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
const row = link.closest("li");
const caseStudy = getCase(labCaseIds[index]);
expect(isBefore(labHeading, link)).toBe(true);
expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`);
expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
expect(
within(row as HTMLElement).getByRole("heading", {
level: 3,
@@ -627,7 +715,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
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" });
const cta = screen.getByRole("link", { name: buttonLabels.contact });
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
const visibleBreadcrumb = screen.getByRole("navigation", {
+3
View File
@@ -7,6 +7,7 @@ import {
buildPageSocialMetadata,
} from "@/lib/seo";
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
import { CaseCapabilitiesSection } from "../_components/CaseCapabilitiesSection";
import { CaseContactSection } from "../_components/CaseContactSection";
import { CaseHero } from "../_components/CaseHero";
import { CaseMediaSection } from "../_components/CaseMediaSection";
@@ -136,6 +137,8 @@ export default async function CasePage({ params }: CasePageProps) {
<CaseMediaSection caseStudy={caseStudy} />
<CaseCapabilitiesSection caseStudy={caseStudy} isClient={isClient} />
<CaseResultSection result={caseStudy.result} />
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />