diff --git a/app/layout.test.tsx b/app/layout.test.tsx
index 1e6295d..fcc8860 100644
--- a/app/layout.test.tsx
+++ b/app/layout.test.tsx
@@ -1,5 +1,5 @@
-import { readFileSync } from "node:fs";
-import { join } from "node:path";
+import { Geist_Mono, Space_Grotesk } from "next/font/google";
+import localFont from "next/font/local";
import { renderToStaticMarkup } from "react-dom/server";
import { caseStudies, serviceHubLinks, servicePages } from "@/lib/site-content";
import RootLayout, { metadata } from "./layout";
@@ -14,9 +14,10 @@ const mockFloatingProps = jest.fn();
const mockFooterProps = jest.fn();
jest.mock("next/font/google", () => ({
- Geist_Mono: () => ({ variable: "font-mono-var" }),
- Space_Grotesk: () => ({ variable: "font-display-var" }),
+ Geist_Mono: jest.fn(() => ({ variable: "font-mono-var" })),
+ Space_Grotesk: jest.fn(() => ({ variable: "font-display-var" })),
}));
+jest.mock("next/font/local", () => ({ __esModule: true, default: jest.fn() }));
jest.mock("./globals.css", () => ({}));
jest.mock("./components/cinematic/CinematicOverlays", () => ({
__esModule: true,
@@ -47,6 +48,12 @@ jest.mock("./components/FloatingWhatsApp", () => ({
},
}));
+// as fontes são pedidas ao carregar o layout (uma vez, na importação): lê-se o que foi pedido antes de qualquer
+// jest.clearAllMocks
+const monoFontOptions = jest.mocked(Geist_Mono).mock.calls[0]?.[0];
+const displayFontOptions = jest.mocked(Space_Grotesk).mock.calls[0]?.[0];
+const localFontCalls = jest.mocked(localFont).mock.calls.length;
+
beforeEach(() => {
jest.clearAllMocks();
});
@@ -119,12 +126,12 @@ describe("what the layout mounts", () => {
expect(html.match(/id="site-footer"/g)).toHaveLength(1);
});
- it("gives the header and the button the service and case names, and only names", () => {
+ it("gives the header and the button the whatsappSubject of each service and the case names, and only those", () => {
markup();
const expected = {
services: Object.fromEntries(
- servicePages.map((service) => [service.slug, service.navLabel]),
+ servicePages.map((service) => [service.slug, service.whatsappSubject]),
),
cases: Object.fromEntries(
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
@@ -135,9 +142,19 @@ describe("what the layout mounts", () => {
whatsAppContexts: expected,
});
expect(Object.keys(expected.services).length).toBeGreaterThan(0);
+ // a mensagem lê "Quero conversar sobre {assunto}.": nenhum assunto de serviço começa em maiúscula
+ for (const subject of Object.values(expected.services)) {
+ expect(subject).toMatch(/^[a-zà-ú]/);
+ }
expect(Object.keys(expected.cases).length).toBeGreaterThan(0);
});
+ it("keeps the smooth anchors but opens each new page at the top: the html carries data-scroll-behavior, which makes Next turn the smooth scroll off only during the route change", () => {
+ // o atributo é o que o Next lê para desligar a rolagem suave só durante a troca de rota (o e2e navigation.spec.ts
+ // confere no navegador que a página nova abre em scrollY 0, sem deslizar)
+ expect(markup()).toMatch(/]* data-scroll-behavior="smooth"/);
+ });
+
it("hides the JavaScript-only parts of the page when scripts are off", () => {
expect(markup()).toContain(".js-only{display:none!important}");
});
@@ -145,18 +162,17 @@ describe("what the layout mounts", () => {
describe("fonts", () => {
it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => {
- const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
-
- const mono = source.match(/Geist_Mono\(\{[^}]*\}\)/)?.[0] ?? "";
- expect(mono).toMatch(/preload:\s*false/);
- const display = source.match(/Space_Grotesk\(\{[^}]*\}\)/)?.[0] ?? "";
- expect(display).not.toMatch(/preload:\s*false/);
+ expect(monoFontOptions).toEqual(
+ expect.objectContaining({ preload: false }),
+ );
+ expect(displayFontOptions).toBeDefined();
+ expect(displayFontOptions).not.toEqual(
+ expect.objectContaining({ preload: false }),
+ );
});
it("no longer loads the unused Monument Extended font", () => {
- const source = readFileSync(join(__dirname, "layout.tsx"), "utf8");
-
- expect(source).not.toMatch(/Monument|next\/font\/local/i);
+ expect(localFontCalls).toBe(0);
expect(markupClassName()).not.toMatch(/monument/i);
});
diff --git a/app/layout.tsx b/app/layout.tsx
index 2e7ed43..4205b6e 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -28,10 +28,14 @@ const defaultTitle = `Sites e sistemas em Joinville e SC${titleSuffix}`;
const seoDescription =
"Sites com pedido pelo WhatsApp e sistemas que tiram sua operação da planilha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil.";
-/** Para o WhatsApp falar do serviço ou do case da página aberta: só nomes, sem o conteúdo pesado das páginas. */
+/**
+ * Para o WhatsApp falar do serviço ou do case da página aberta: só os assuntos e nomes, sem o conteúdo pesado das
+ * páginas. O serviço entra pelo `whatsappSubject` (uma frase em minúsculas, que cabe em "Quero conversar sobre
+ * {assunto}."), e não pelo nome: o nome daria "sobre Sites profissionais.".
+ */
const whatsAppContexts: WhatsAppContexts = {
services: Object.fromEntries(
- servicePages.map((service) => [service.slug, service.navLabel]),
+ servicePages.map((service) => [service.slug, service.whatsappSubject]),
),
cases: Object.fromEntries(
caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]),
@@ -130,8 +134,11 @@ export default function RootLayout({ children }: Readonly
) {
};
return (
+ // data-scroll-behavior: com `scroll-behavior: smooth` no CSS global, o Next desliga a suavização só durante a troca
+ // de página (a página nova abre no topo, sem deslizar) e a restaura depois; as âncoras da mesma página seguem suaves.
diff --git a/app/metadata-limits.test.ts b/app/metadata-limits.test.ts
index 2936a3c..cdb0061 100644
--- a/app/metadata-limits.test.ts
+++ b/app/metadata-limits.test.ts
@@ -6,7 +6,6 @@ import { metadata as layoutMetadata } from "./layout";
import { metadata as notFoundMetadata } from "./not-found";
import sitemap from "./sitemap";
-jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
// o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe
jest.mock("next/font/google", () => ({
Geist_Mono: () => ({ variable: "font-mono-var" }),
diff --git a/app/servicos/[slug]/page.cover.paths.test.tsx b/app/servicos/[slug]/page.cover.paths.test.tsx
index 8834d92..b0e9413 100644
--- a/app/servicos/[slug]/page.cover.paths.test.tsx
+++ b/app/servicos/[slug]/page.cover.paths.test.tsx
@@ -1,4 +1,5 @@
import { render, screen } from "@testing-library/react";
+import { setNavigation } from "@/__mocks__/next/navigation";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
import { getServicePage } from "@/lib/site-content";
@@ -15,12 +16,9 @@ import ServicePage from "./page";
* Quando a capa vale, a legenda dela cita o case e o case não ganha cartão em "Trabalhos parecidos".
*/
-jest.mock("next/navigation", () => ({
- notFound: jest.fn(() => {
- throw new Error("NEXT_NOT_FOUND");
- }),
- usePathname: () => "/servicos",
-}));
+beforeEach(() => {
+ setNavigation({ pathname: "/servicos" });
+});
jest.mock("@/app/cases/_components/case-media", () => ({
...jest.requireActual("@/app/cases/_components/case-media"),
getCaseMedia: jest.fn(),
@@ -74,7 +72,8 @@ describe("ServicePage: escolha da capa do topo", () => {
expect(view.compact).toBe(false);
expect(view.caption).toHaveTextContent("Lavação 047");
expect(view.cards).not.toContain("Lavação 047");
- expect(view.cards).toEqual(["SC Plus Aquicultura", "AS Serviços"]);
+ // a SEV é o segundo trabalho; SC Plus e AS Serviços ficam no bloco menor, fora de "Onde já apliquei isso"
+ expect(view.cards).toEqual(["SEV Moda Íntima"]);
});
it("path: mobile cover with its capture on disk -> phone frame in the hero, no browser frame", async () => {
@@ -98,11 +97,7 @@ describe("ServicePage: escolha da capa do topo", () => {
expect(view.twoColumns).toBe(false);
expect(view.compact).toBe(true);
expect(screen.queryByRole("img")).toBeNull();
- expect(view.cards).toEqual([
- "Lavação 047",
- "SC Plus Aquicultura",
- "AS Serviços",
- ]);
+ expect(view.cards).toEqual(["Lavação 047", "SEV Moda Íntima"]);
});
it("path: only the OTHER device was captured (mobile cover, desktop capture) -> no cover, the check follows the device the service asks for", async () => {
diff --git a/app/servicos/[slug]/page.paths.test.tsx b/app/servicos/[slug]/page.paths.test.tsx
index d804062..b2d95ac 100644
--- a/app/servicos/[slug]/page.paths.test.tsx
+++ b/app/servicos/[slug]/page.paths.test.tsx
@@ -1,5 +1,6 @@
import { render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
+import { setNavigation } from "@/__mocks__/next/navigation";
import { getServicePage } from "@/lib/site-content";
import ServicePage, { generateMetadata } from "./page";
@@ -8,13 +9,9 @@ import ServicePage, { generateMetadata } from "./page";
* Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias.
*/
-// 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: () => "/servicos",
-}));
+beforeEach(() => {
+ setNavigation({ pathname: "/servicos" });
+});
beforeEach(() => {
jest.mocked(notFound).mockClear();
diff --git a/app/servicos/[slug]/page.sections.paths.test.tsx b/app/servicos/[slug]/page.sections.paths.test.tsx
new file mode 100644
index 0000000..889d6f1
--- /dev/null
+++ b/app/servicos/[slug]/page.sections.paths.test.tsx
@@ -0,0 +1,82 @@
+import { render, screen } from "@testing-library/react";
+import { setNavigation } from "@/__mocks__/next/navigation";
+import { getServiceOffer, getServicePage } from "@/lib/site-content";
+import ServicePage from "./page";
+
+/**
+ * Caminhos-base de ServicePage (CC 4, contado à mão: serviço que não existe | bloco menor `alsoCases` | oferta com
+ * "O que você recebe") nas combinações do bloco menor e da oferta, que page.test.tsx só mostra juntas ou separadas com
+ * dados reais:
+ * - nem bloco menor nem oferta (a base: tauri-rust);
+ * - bloco menor SEM oferta (nenhum serviço real é assim: montado trocando `alsoCases` de consultoria-nextjs);
+ * - bloco menor E oferta (criacao-de-sites): a ordem na página é cases, bloco menor, "O que você recebe".
+ * O slug desconhecido está em page.paths.test.tsx.
+ */
+
+beforeEach(() => {
+ setNavigation({ pathname: "/servicos" });
+});
+
+async function renderService(slug: string) {
+ const ui = await ServicePage({ params: Promise.resolve({ slug }) });
+ return render(ui);
+}
+
+const ALSO_TITLE = "Também faço sites institucionais";
+const ALSO_HEADING = { level: 2, name: ALSO_TITLE } as const;
+const DELIVERABLES_HEADING = {
+ level: 2,
+ name: "O que você recebe",
+} as const;
+
+describe("ServicePage: bloco menor e oferta", () => {
+ it("path: no smaller block and no offer -> neither 'Também faço' nor 'O que você recebe', and the rest of the page stays", async () => {
+ expect(getServicePage("tauri-rust")?.alsoCases).toBeUndefined();
+ expect(getServiceOffer("tauri-rust")).toBeUndefined();
+
+ await renderService("tauri-rust");
+
+ expect(screen.queryByRole("heading", ALSO_HEADING)).toBeNull();
+ expect(screen.queryByRole("heading", DELIVERABLES_HEADING)).toBeNull();
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Como trabalhamos" }),
+ ).toBeVisible();
+ });
+
+ it("path: a smaller block on a service that is not an offer -> the block shows, 'O que você recebe' does not", async () => {
+ const service = getServicePage("consultoria-nextjs");
+ if (!service) throw new Error("Missing service consultoria-nextjs");
+ expect(getServiceOffer(service.slug)).toBeUndefined();
+ const original = service.alsoCases;
+ service.alsoCases = { title: "Também faço isto", caseIds: ["sc-plus"] };
+ try {
+ await renderService(service.slug);
+
+ const block = screen
+ .getByRole("heading", { level: 2, name: "Também faço isto" })
+ .closest("section") as HTMLElement;
+ expect(block).toHaveTextContent("SC Plus");
+ expect(screen.queryByRole("heading", DELIVERABLES_HEADING)).toBeNull();
+ } finally {
+ service.alsoCases = original;
+ }
+ });
+
+ it("path: a smaller block on an offer -> the cases come first, then the block, then 'O que você recebe'", async () => {
+ await renderService("criacao-de-sites");
+
+ const cases = document.getElementById("casos-relacionados");
+ const also = screen
+ .getByRole("heading", ALSO_HEADING)
+ .closest("section") as HTMLElement;
+ const deliverables = screen
+ .getByRole("heading", DELIVERABLES_HEADING)
+ .closest("section") as HTMLElement;
+ expect(cases).not.toBeNull();
+ const follows = Node.DOCUMENT_POSITION_FOLLOWING;
+ expect(
+ (cases as HTMLElement).compareDocumentPosition(also) & follows,
+ ).toBeTruthy();
+ expect(also.compareDocumentPosition(deliverables) & follows).toBeTruthy();
+ });
+});
diff --git a/app/servicos/[slug]/page.test.tsx b/app/servicos/[slug]/page.test.tsx
index 5cb5ea3..b699689 100644
--- a/app/servicos/[slug]/page.test.tsx
+++ b/app/servicos/[slug]/page.test.tsx
@@ -1,4 +1,5 @@
import { render, screen, within } from "@testing-library/react";
+import { setNavigation } from "@/__mocks__/next/navigation";
import { SEO_IDS } from "@/lib/seo";
import {
buttonLabels,
@@ -14,11 +15,9 @@ import {
import ServicesIndexPage from "../page";
import ServicePage, { generateMetadata, generateStaticParams } from "./page";
-// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
-jest.mock("next/navigation", () => ({
- notFound: jest.fn(),
- usePathname: () => "/servicos",
-}));
+beforeEach(() => {
+ setNavigation({ pathname: "/servicos" });
+});
async function renderService(slug: string) {
const ui = await ServicePage({ params: Promise.resolve({ slug }) });
@@ -117,7 +116,13 @@ it("renders answer-first consulting, reciprocal case links and minimal schema",
for (const cta of primaryCtas) {
const destination = new URL(cta.getAttribute("href") ?? "");
expect(destination.origin).toBe("https://wa.me");
- expect(destination.searchParams.get("text")).toContain(service.navLabel);
+ // o assunto da mensagem é o `whatsappSubject` do serviço, em minúsculas, e não o nome com maiúscula
+ expect(destination.searchParams.get("text")).toContain(
+ `Quero conversar sobre ${service.whatsappSubject}.`,
+ );
+ expect(destination.searchParams.get("text")).toContain(
+ "uma consultoria em Next.js",
+ );
expect(cta).toHaveAttribute("target", "_blank");
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
}
@@ -236,9 +241,13 @@ it("fills the hub hero with a mosaic of three client posters, never opening with
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
const posters = Array.from(hero?.querySelectorAll("img") ?? []);
expect(posters).toHaveLength(3);
+ // a ordem é a de /cases (a vitrine), sem a SEV, que lidera a lista mas não o mosaico
+ const mosaicCases = clientCasesForListing.filter(
+ ({ id }) => id !== "sev-modaintima",
+ );
for (const [index, poster] of posters.entries()) {
expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain(
- `/videos/${clientCasesForListing[index].id}/desktop.webp`,
+ `/videos/${mosaicCases[index].id}/desktop.webp`,
);
expect(poster).toHaveAttribute("alt", "");
}
@@ -406,19 +415,33 @@ it("shows the client cases first, right after the hero, with the first cover in
),
).toBe(true);
- // o case da capa (Lavação 047) é citado pela legenda dela; os cartões trazem os demais, com os de cliente à frente
+ // o case da capa (Lavação 047) é citado pela legenda dela; "Onde já apliquei isso" traz a SEV como segundo trabalho
const caseLinks = caseCardLinks(container);
- expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual(
- getRelatedCases(siteService.relatedCaseIds)
- .filter(({ id }) => id !== siteService.heroCase?.id)
- .map(({ id }) => `/cases/${id}`),
- );
expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([
- "/cases/sc-plus",
- "/cases/as-servicos",
+ "/cases/sev-modaintima",
]);
+ // SC Plus e AS Serviços saem dali e vão para o bloco menor "Também faço sites institucionais"
+ const alsoHeading = screen.getByRole("heading", {
+ level: 2,
+ name: "Também faço sites institucionais",
+ });
+ expect(isBefore(casesHeading, alsoHeading)).toBe(true);
+ const alsoBlock = alsoHeading.closest("section") as HTMLElement;
+ expect(
+ within(alsoBlock)
+ .getAllByRole("link")
+ .map((link) => link.getAttribute("href")),
+ ).toEqual(["/cases/sc-plus", "/cases/as-servicos"]);
+ expect(
+ within(alsoBlock).getByText(siteService.caseRelevance["sc-plus"]),
+ ).toBeVisible();
+ // sem capa no bloco menor
+ expect(alsoBlock.querySelector("img")).toBeNull();
+ expect(
+ getRelatedCases(siteService.relatedCaseIds).map(({ id }) => id),
+ ).toEqual(["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"]);
- // pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde, e não a SEV
+ // pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde
const hero = container.querySelector("section");
const heroFrame = hero?.querySelector(".ds-bframe");
expect(heroFrame).not.toBeNull();
@@ -427,7 +450,11 @@ it("shows the client cases first, right after the hero, with the first cover in
"Captura de tela do site Lavação 047",
);
expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull();
- expect(container.textContent).not.toMatch(/SEV Moda Íntima/);
+ // a SEV tem cartão (segundo trabalho), mas não vira a capa do topo
+ expect(hero?.textContent).not.toMatch(/SEV Moda Íntima/);
+ expect(caseCardLinks(container)[0].closest("article")).toHaveTextContent(
+ siteService.caseRelevance["sev-modaintima"],
+ );
// a legenda da capa cita o case com a frase de relação e o botão "Ver case"
const caption = hero?.querySelector("figcaption");
expect(caption).toHaveTextContent("Lavação 047");
@@ -437,7 +464,7 @@ it("shows the client cases first, right after the hero, with the first cover in
name: "Ver case Lavação 047",
}),
).toHaveAttribute("href", "/cases/lavacao-047");
- // a mesma capa não aparece duas vezes: o case de destaque não ganha cartão, e os outros mantêm a capa deles
+ // a mesma capa não aparece duas vezes: o case de destaque não ganha cartão; a SEV mantém a dela e os do bloco menor não têm capa
expect(
container.querySelectorAll(
"img[alt='Captura de tela do site Lavação 047']",
@@ -447,7 +474,150 @@ it("shows the client cases first, right after the hero, with the first cover in
container.querySelectorAll(
"img[alt='Captura de tela do site SC Plus Aquicultura']",
),
- ).toHaveLength(1);
+ ).toHaveLength(0);
+});
+
+it("repeats on the two offer pages the 'O que você recebe' and the 'Primeiro passo' of the offer, from the same source as the home", async () => {
+ for (const [slug, offer] of [
+ ["criacao-de-sites", serviceOffers[0]],
+ ["engenharia-de-software", serviceOffers[1]],
+ ] as const) {
+ const { container, unmount } = await renderService(slug);
+
+ const receives = screen.getByRole("heading", {
+ level: 2,
+ name: "O que você recebe",
+ });
+ const block = receives.closest("section") as HTMLElement;
+ expect(
+ within(block)
+ .getAllByRole("listitem")
+ .map((item) => item.textContent),
+ ).toEqual(offer.deliverables);
+ const firstStep = within(block).getByRole("heading", {
+ level: 2,
+ name: "Primeiro passo",
+ });
+ expect(isBefore(receives, firstStep)).toBe(true);
+ expect(within(block).getByText(offer.firstStep)).toBeVisible();
+ // vem depois da prova e antes de "Para quem faz sentido"
+ expect(
+ isBefore(
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Onde já apliquei isso",
+ }),
+ receives,
+ ),
+ ).toBe(true);
+ expect(
+ isBefore(
+ receives,
+ screen.getByRole("heading", {
+ level: 2,
+ name: "Para quem faz sentido",
+ }),
+ ),
+ ).toBe(true);
+ // o bloco não traz botão de contato próprio: o do topo e o do fim bastam
+ expect(within(block).queryByRole("link")).toBeNull();
+ expect(container.textContent).not.toMatch(/\d+\s?dias|R\$/);
+ unmount();
+ }
+});
+
+it("keeps a single list of deliverables per page: the offer pages drop 'O que podemos entregar', the others keep it", async () => {
+ for (const slug of ["criacao-de-sites", "engenharia-de-software"]) {
+ const { unmount } = await renderService(slug);
+ expect(
+ screen.getByRole("heading", { level: 2, name: "O que você recebe" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("heading", { name: "O que podemos entregar" }),
+ ).toBeNull();
+ // "Para quem faz sentido" continua, sozinho
+ expect(
+ screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }),
+ ).toBeInTheDocument();
+ unmount();
+ }
+ for (const slug of [
+ "consultoria-nextjs",
+ "tauri-rust",
+ "integracao-hardware-software-iot",
+ "arquitetura-de-software",
+ ]) {
+ const { unmount } = await renderService(slug);
+ expect(
+ screen.getByRole("heading", { level: 2, name: "O que podemos entregar" }),
+ ).toBeInTheDocument();
+ unmount();
+ }
+});
+
+it("leaves 'O que você recebe' and 'Primeiro passo' out of the services that are not an offer", async () => {
+ for (const slug of [
+ "consultoria-nextjs",
+ "tauri-rust",
+ "integracao-hardware-software-iot",
+ "arquitetura-de-software",
+ ]) {
+ const { unmount } = await renderService(slug);
+ expect(
+ screen.queryByRole("heading", { name: "O que você recebe" }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("heading", { name: "Primeiro passo" }),
+ ).toBeNull();
+ expect(
+ screen.queryByRole("heading", {
+ name: "Também faço sites institucionais",
+ }),
+ ).toBeNull();
+ unmount();
+ }
+});
+
+it("opens the systems proof with the Apolo Energia block, as the résumé says it, and a subtitle that starts from the pain", async () => {
+ const { container } = await renderService("engenharia-de-software");
+
+ const proof = container.querySelector("#casos-relacionados");
+ if (!proof) throw new Error("Missing proof block");
+ // o bloco da Apolo vem no topo da prova, antes dos cartões de case
+ const note = within(proof).getByText(/Apolo Energia/);
+ expect(note).toHaveTextContent(
+ "aplicações web e mobile para rotinas internas, APIs e painéis de operação",
+ );
+ expect(note.textContent).toMatch(/uso interno/);
+ expect(isBefore(note, caseCardLinks(container)[0])).toBe(true);
+ // o subtítulo parte da dor: planilha e retrabalho
+ const subtitle = screen.getByText(/planilhas, mensagens e retrabalho/);
+ expect(isBefore(screen.getByRole("heading", { level: 1 }), subtitle)).toBe(
+ true,
+ );
+});
+
+it("says the offer name, not the old label, in the breadcrumb, the title and the WhatsApp message of every service", async () => {
+ for (const { slug, navLabel, whatsappSubject } of servicePages) {
+ const { unmount } = await renderService(slug);
+
+ const trail = within(
+ screen.getByRole("navigation", { name: "Trilha de navegação" }),
+ ).getAllByRole("link");
+ expect(trail[trail.length - 1]).toHaveTextContent(navLabel);
+ for (const cta of screen.getAllByRole("link", {
+ name: buttonLabels.contact,
+ })) {
+ const text = new URL(cta.getAttribute("href") ?? "").searchParams.get(
+ "text",
+ );
+ expect(text).toContain(`Quero conversar sobre ${whatsappSubject}.`);
+ expect(text).not.toMatch(
+ /Sites profissionais|Sites, sistemas e aplicativos/,
+ );
+ }
+ unmount();
+ }
});
it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => {
@@ -674,7 +844,8 @@ it("says in one sentence, per case, why it matters to the service", async () =>
container.querySelectorAll(`article a[href="/cases/${id}"]`),
).toHaveLength(0);
} else {
- const card = screen.getByText(sentence).closest("article");
+ // o cartão de "Onde já apliquei isso" ou a linha do bloco menor "Também faço sites institucionais"
+ const card = screen.getByText(sentence).closest("article, li");
expect(card).toContainElement(link);
}
}
@@ -715,13 +886,8 @@ it("cites the Apolo Energia experience in text on the IoT and architecture pages
}
});
-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",
- ]) {
+it("does not mention Apolo Energia on the other service pages (the systems page cites it, tested above)", async () => {
+ for (const slug of ["criacao-de-sites", "consultoria-nextjs", "tauri-rust"]) {
const { container, unmount } = await renderService(slug);
expect(container.textContent).not.toMatch(/Apolo/);
unmount();
diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx
index 59b08fd..098c5c8 100644
--- a/app/servicos/[slug]/page.tsx
+++ b/app/servicos/[slug]/page.tsx
@@ -1,13 +1,6 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
-import { getCaseMedia } from "@/app/cases/_components/case-media";
import {
- compactHero,
- heroWithMediaGrid,
-} from "@/app/cases/_components/hero-styles";
-import { Breadcrumb } from "@/app/components/Breadcrumb";
-import {
- pageTop,
primaryButton,
secondaryButtonDark,
} from "@/app/components/button-styles";
@@ -22,16 +15,20 @@ import {
} from "@/lib/seo";
import {
buttonLabels,
- caseStudies,
- getRelatedCases,
+ getServiceOffer,
getServicePage,
servicePages,
siteUrl,
} from "@/lib/site-content";
-import { ServiceDiagram } from "../_components/ServiceDiagram";
-import { ServiceHeroCover } from "../_components/ServiceHeroCover";
+import { ServiceAlsoCases } from "../_components/ServiceAlsoCases";
+import { ServiceDeliverables } from "../_components/ServiceDeliverables";
+import { ServiceHero } from "../_components/ServiceHero";
import { ServiceOffer } from "../_components/ServiceOffer";
import { ServiceRelatedCases } from "../_components/ServiceRelatedCases";
+import {
+ resolveServiceHero,
+ splitServiceCases,
+} from "../_components/service-page";
interface ServicePageProps {
params: Promise<{
@@ -77,18 +74,21 @@ export default async function ServicePage({ params }: ServicePageProps) {
notFound();
}
- const relatedCases = getRelatedCases(service.relatedCaseIds);
- // capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo
- const heroDevice = service.heroCase?.device ?? "desktop";
- const heroCase = caseStudies.find(
- ({ id }) =>
- id === service.heroCase?.id && Boolean(getCaseMedia(id, heroDevice)),
- );
- const diagram = heroCase ? undefined : service.diagram;
- const hasHeroSide = Boolean(heroCase || diagram);
- // o case da capa é citado pela legenda dela, e não ganha cartão em "Onde já apliquei isso"
- const hasCaseCards = relatedCases.some(({ id }) => id !== heroCase?.id);
+ const hero = resolveServiceHero(service);
+ const { cards, also } = splitServiceCases(service);
+ // as duas primeiras ofertas têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home
+ const offer = getServiceOffer(service.slug);
const pageUrl = `${siteUrl}/servicos/${service.slug}`;
+ const audienceCard = (
+
+ Para quem faz sentido
+
+ {service.audience.map((item) => (
+ - {item}
+ ))}
+
+
+ );
const jsonLd = {
"@context": "https://schema.org",
@@ -106,90 +106,44 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
-
-
-
-
{service.eyebrow}
-
- {service.headline}
-
-
- {service.answer}
-
-
-
- O botão abre uma conversa no WhatsApp, sem formulário. Garuva e
- Joinville (SC) · atendimento remoto em todo o Brasil.
-
-
- {diagram ? (
-
- ) : (
-
- )}
-
-
+
+ {also ? (
+
+ ) : null}
+
+ {offer ? : null}
+
-
-
- Para quem faz sentido
-
- {service.audience.map((item) => (
- - {item}
- ))}
-
-
+ {offer ? (
+ // a página da oferta já tem "O que você recebe": uma lista de entregáveis só, sem "O que podemos entregar"
+ audienceCard
+ ) : (
+
+ {audienceCard}
-
- O que podemos entregar
-
- {service.outcomes.map((item) => (
- - {item}
- ))}
-
-
-
+
+ O que podemos entregar
+
+ {service.outcomes.map((item) => (
+ - {item}
+ ))}
+
+
+
+ )}
@@ -223,7 +177,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
-