feat(servicos): topo, entregáveis e cases por serviço, assunto próprio no WhatsApp e títulos curtos

This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 22:54:18 -03:00
1 parent cf360041bd
commit 2fd8b063d9
33 files changed
+1578 -300

No files matched your search

+7 -12
View File
@@ -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 () => {
+4 -7
View File
@@ -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();
@@ -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();
});
});
+193 -27
View File
@@ -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<HTMLElement>("#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();
+53 -99
View File
@@ -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 = (
<article className="ds-card">
<h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
<ul className="ds-list space-y-sp-2">
{service.audience.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
);
const jsonLd = {
"@context": "https://schema.org",
@@ -106,90 +106,44 @@ export default async function ServicePage({ params }: ServicePageProps) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section
className={`ds-section ds-section-dark ${pageTop} ${
hasHeroSide ? "" : compactHero
}`}
>
<div
className={`ds-section-shell ${hasHeroSide ? heroWithMediaGrid : ""}`}
>
<div className="max-w-[860px]">
<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-[22ch] text-balance text-white">
{service.headline}
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{service.answer}
</p>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<WhatsAppLink
service={service.navLabel}
placement="service_detail_hero"
className={primaryButton}
>
{buttonLabels.contact}
</WhatsAppLink>
{hasCaseCards ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
Ver projetos parecidos
</a>
) : null}
</div>
<p className="mt-sp-4 text-[15px] leading-6 text-white/70">
O botão abre uma conversa no WhatsApp, sem formulário. Garuva e
Joinville (SC) · atendimento remoto em todo o Brasil.
</p>
</div>
{diagram ? (
<ServiceDiagram kind={diagram} />
) : (
<ServiceHeroCover
caseStudy={heroCase}
device={heroDevice}
relevance={
heroCase ? service.caseRelevance[heroCase.id] : undefined
}
/>
)}
</div>
</section>
<ServiceHero service={service} hero={hero} />
<ServiceRelatedCases
cases={relatedCases}
cases={cards}
relevance={service.caseRelevance}
experienceNote={service.experienceNote}
heroCaseId={heroCase?.id}
heroCaseId={hero.heroCase?.id}
/>
{also ? (
<ServiceAlsoCases
title={also.title}
cases={also.cases}
relevance={service.caseRelevance}
/>
) : null}
{offer ? <ServiceDeliverables offer={offer} /> : null}
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<article className="ds-card">
<h2 className="ds-card-title !mt-0">Para quem faz sentido</h2>
<ul className="ds-list space-y-sp-2">
{service.audience.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
{offer ? (
// a página da oferta já tem "O que você recebe": uma lista de entregáveis só, sem "O que podemos entregar"
audienceCard
) : (
<div className="ds-grid-2">
{audienceCard}
<article className="ds-card">
<h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
<ul className="ds-list space-y-sp-2">
{service.outcomes.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
</div>
<article className="ds-card">
<h2 className="ds-card-title !mt-0">O que podemos entregar</h2>
<ul className="ds-list space-y-sp-2">
{service.outcomes.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</article>
</div>
)}
</div>
</section>
@@ -223,7 +177,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</article>
</div>
</div>
<div className="ds-section-shell mt-sp-8">
<div className="ds-section-shell !mt-sp-8">
<details className="border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
Detalhes técnicos e tecnologias
@@ -268,7 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<WhatsAppLink
service={service.navLabel}
service={service.whatsappSubject}
placement="service_detail_footer"
className={primaryButton}
>