feat(servicos): topo, entregáveis e cases por serviço, assunto próprio no WhatsApp e títulos curtos
This commit is contained in:
1 parent
cf360041bd
commit
2fd8b063d9
33 files changed
+1578
-300
No files matched your search
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import { type CaseStudyContent, caseStudies } from "@/lib/site-content";
|
||||
import { ServiceAlsoCases } from "./ServiceAlsoCases";
|
||||
|
||||
/**
|
||||
* ServiceAlsoCases (CC 3): sem case (nada) | com case; frase de relação própria | só o resumo do case.
|
||||
*/
|
||||
|
||||
beforeEach(() => {
|
||||
setNavigation({ pathname: "/servicos" });
|
||||
});
|
||||
|
||||
function getCase(id: string): CaseStudyContent {
|
||||
const found = caseStudies.find((item) => item.id === id);
|
||||
if (!found) throw new Error(`Missing case ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("ServiceAlsoCases", () => {
|
||||
it("path: cases with a relation sentence -> one line per case, with the title, the sentence and a 'Ver case' link, and no cover", () => {
|
||||
render(
|
||||
<ServiceAlsoCases
|
||||
title="Também faço sites institucionais"
|
||||
cases={[getCase("sc-plus"), getCase("as-servicos")]}
|
||||
relevance={{
|
||||
"sc-plus": "Frase do SC Plus.",
|
||||
"as-servicos": "Frase da AS.",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Também faço sites institucionais",
|
||||
}),
|
||||
).toBeVisible();
|
||||
const rows = screen.getAllByRole("listitem");
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(
|
||||
within(rows[0]).getByRole("heading", {
|
||||
level: 3,
|
||||
name: "SC Plus Aquicultura",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(within(rows[0]).getByText("Frase do SC Plus.")).toBeVisible();
|
||||
expect(
|
||||
within(rows[0]).getByRole("link", {
|
||||
name: "Ver case SC Plus Aquicultura",
|
||||
}),
|
||||
).toHaveAttribute("href", "/cases/sc-plus");
|
||||
expect(
|
||||
within(rows[1]).getByRole("link", { name: "Ver case AS Serviços" }),
|
||||
).toHaveAttribute("href", "/cases/as-servicos");
|
||||
expect(screen.queryByRole("img")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no relation sentence for a case -> the summary of the case stands in", () => {
|
||||
const caseStudy = getCase("sc-plus");
|
||||
render(
|
||||
<ServiceAlsoCases title="Também" cases={[caseStudy]} relevance={{}} />,
|
||||
);
|
||||
|
||||
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: no case -> renders nothing", () => {
|
||||
const { container } = render(
|
||||
<ServiceAlsoCases title="Também" cases={[]} relevance={{}} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { textLink } from "@/app/components/button-styles";
|
||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
|
||||
/**
|
||||
* Bloco menor depois de "Onde já apliquei isso": trabalhos parecidos que não são a prova principal do serviço, em uma linha
|
||||
* cada (nome, por que interessam e o link do case), sem capa. Sem case, não mostra nada.
|
||||
*/
|
||||
export function ServiceAlsoCases({
|
||||
title,
|
||||
cases,
|
||||
relevance,
|
||||
}: {
|
||||
title: string;
|
||||
cases: readonly CaseStudyContent[];
|
||||
/** Por que cada case interessa ao serviço (chave: id do case); sem frase própria, vale o resumo do case. */
|
||||
relevance: Readonly<Record<string, string>>;
|
||||
}) {
|
||||
if (cases.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="ds-section" aria-labelledby="tambem-faco-titulo">
|
||||
<div className="ds-section-shell">
|
||||
<h2 id="tambem-faco-titulo" className="ds-card-title !mt-0">
|
||||
{title}
|
||||
</h2>
|
||||
<ul className="mt-sp-6 divide-y divide-[var(--border)] border-y border-[var(--border)]">
|
||||
{cases.map((caseStudy) => (
|
||||
<li
|
||||
key={caseStudy.id}
|
||||
className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_auto] md:gap-sp-8"
|
||||
>
|
||||
<h3 className="text-base font-semibold leading-6">
|
||||
{caseStudy.title}
|
||||
</h3>
|
||||
<p className="ds-copy !mt-0">
|
||||
{relevance[caseStudy.id] ?? caseStudy.summary}
|
||||
</p>
|
||||
<HoverPrefetchLink
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
|
||||
>
|
||||
{buttonLabels.case}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</HoverPrefetchLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { serviceOffers } from "@/lib/site-content";
|
||||
import { ServiceDeliverables } from "./ServiceDeliverables";
|
||||
|
||||
describe("ServiceDeliverables", () => {
|
||||
it("shows 'O que você recebe' with the deliverables of the offer, then 'Primeiro passo' with its first step", () => {
|
||||
const [offer] = serviceOffers;
|
||||
render(<ServiceDeliverables offer={offer} />);
|
||||
|
||||
const receives = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que você recebe",
|
||||
});
|
||||
const list = within(receives.closest("article") as HTMLElement).getByRole(
|
||||
"list",
|
||||
);
|
||||
expect(
|
||||
within(list)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.textContent),
|
||||
).toEqual(offer.deliverables);
|
||||
|
||||
const firstStep = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "Primeiro passo",
|
||||
});
|
||||
expect(
|
||||
receives.compareDocumentPosition(firstStep) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(offer.firstStep)).toBeVisible();
|
||||
});
|
||||
|
||||
it("names the section by its heading and has no contact button or link of its own", () => {
|
||||
const { container } = render(
|
||||
<ServiceDeliverables offer={serviceOffers[1]} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("region", { name: "O que você recebe" }),
|
||||
).toBeVisible();
|
||||
expect(container.querySelectorAll("a")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ServiceOffer } from "@/lib/site-content";
|
||||
|
||||
/**
|
||||
* "O que você recebe" e "Primeiro passo" da oferta. A lista é a mesma do cartão da home (`serviceOffers` em
|
||||
* lib/site-content.ts): a página repete o que o cartão prometeu. O primeiro passo é o da oferta, que termina como a frase
|
||||
* geral da home (`generalFirstStep`): uma conversa e, depois dela, escopo e proposta por escrito. Só as páginas que
|
||||
* explicam uma oferta mostram este bloco.
|
||||
*/
|
||||
export function ServiceDeliverables({
|
||||
offer,
|
||||
}: {
|
||||
offer: Pick<ServiceOffer, "deliverables" | "firstStep">;
|
||||
}) {
|
||||
return (
|
||||
<section className="ds-section" aria-labelledby="o-que-voce-recebe">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-grid-2">
|
||||
<article className="ds-card">
|
||||
<h2 id="o-que-voce-recebe" className="ds-card-title !mt-0">
|
||||
O que você recebe
|
||||
</h2>
|
||||
<ul className="ds-list space-y-sp-2">
|
||||
{offer.deliverables.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article className="ds-card">
|
||||
<h2 className="ds-card-title !mt-0">Primeiro passo</h2>
|
||||
<p className="ds-copy">{offer.firstStep}</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import {
|
||||
compactHero,
|
||||
heroWithMediaGrid,
|
||||
} from "@/app/cases/_components/hero-styles";
|
||||
import {
|
||||
caseStudies,
|
||||
getServicePage,
|
||||
type ServicePageContent,
|
||||
} from "@/lib/site-content";
|
||||
import { ServiceHero } from "./ServiceHero";
|
||||
import type { ServiceHeroData } from "./service-page";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ServiceHero (CC 6, contado à mão: as cinco escolhas do JSX):
|
||||
* hasHeroSide (classe do topo: compacto ou não), hasHeroSide (grade de duas colunas), hasCaseCards (botão "Ver projetos
|
||||
* parecidos"), diagram (esquema ou capa) e heroCase (a frase da capa).
|
||||
* Os dois filhos que desenham a coluna ao lado (esquema e capa) são trocados por dublês que mostram o que receberam.
|
||||
*/
|
||||
|
||||
jest.mock("./ServiceDiagram", () => ({
|
||||
ServiceDiagram: ({ kind }: { kind: string }) => (
|
||||
<div data-testid="diagram">{kind}</div>
|
||||
),
|
||||
}));
|
||||
jest.mock("./ServiceHeroCover", () => ({
|
||||
ServiceHeroCover: (props: {
|
||||
caseStudy?: { id: string };
|
||||
device?: string;
|
||||
relevance?: string;
|
||||
}) => (
|
||||
<div
|
||||
data-testid="cover"
|
||||
data-case={props.caseStudy?.id ?? ""}
|
||||
data-device={props.device ?? ""}
|
||||
data-relevance={props.relevance ?? ""}
|
||||
data-has-relevance={String(props.relevance !== undefined)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
function service(slug = "criacao-de-sites"): ServicePageContent {
|
||||
const found = getServicePage(slug);
|
||||
if (!found) throw new Error(`Missing service ${slug}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function caseStudy(id: string) {
|
||||
const found = caseStudies.find((item) => item.id === id);
|
||||
if (!found) throw new Error(`Missing case ${id}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
const noSide: ServiceHeroData = {
|
||||
heroCase: undefined,
|
||||
heroDevice: "desktop",
|
||||
diagram: undefined,
|
||||
hasHeroSide: false,
|
||||
hasCaseCards: false,
|
||||
};
|
||||
|
||||
const shell = (container: HTMLElement) =>
|
||||
container.querySelector(".ds-section-shell") as HTMLElement;
|
||||
const section = (container: HTMLElement) =>
|
||||
container.querySelector("section") as HTMLElement;
|
||||
const projectsLink = () =>
|
||||
screen.queryByRole("link", { name: "Ver projetos parecidos" });
|
||||
|
||||
describe("ServiceHero", () => {
|
||||
it("path: no cover, no diagram and no card -> one low column, no 'Ver projetos parecidos', and an empty slot where the cover would be", () => {
|
||||
const { container } = render(
|
||||
<ServiceHero service={service()} hero={noSide} />,
|
||||
);
|
||||
|
||||
for (const token of compactHero.split(/\s+/).filter(Boolean))
|
||||
expect(section(container)).toHaveClass(token);
|
||||
for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean))
|
||||
expect(shell(container)).not.toHaveClass(token);
|
||||
expect(projectsLink()).toBeNull();
|
||||
expect(screen.queryByTestId("diagram")).toBeNull();
|
||||
// sem esquema, quem ocupa a coluna é a capa, que por conta própria não desenha nada sem case
|
||||
expect(screen.getByTestId("cover")).toHaveAttribute("data-case", "");
|
||||
expect(screen.getByTestId("cover")).toHaveAttribute(
|
||||
"data-has-relevance",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: a side column -> the compact class goes away and the two-column grid comes in", () => {
|
||||
const { container } = render(
|
||||
<ServiceHero
|
||||
service={service("tauri-rust")}
|
||||
hero={{ ...noSide, diagram: "desktop", hasHeroSide: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const token of compactHero.split(/\s+/).filter(Boolean))
|
||||
expect(section(container)).not.toHaveClass(token);
|
||||
for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean))
|
||||
expect(shell(container)).toHaveClass(token);
|
||||
});
|
||||
|
||||
it("path: cards below -> 'Ver projetos parecidos' points at the related cases", () => {
|
||||
render(
|
||||
<ServiceHero
|
||||
service={service()}
|
||||
hero={{ ...noSide, hasCaseCards: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(projectsLink()).toHaveAttribute("href", "#casos-relacionados");
|
||||
});
|
||||
|
||||
it("path: a diagram -> the diagram fills the side column and the cover is not mounted", () => {
|
||||
render(
|
||||
<ServiceHero
|
||||
service={service("integracao-hardware-software-iot")}
|
||||
hero={{ ...noSide, diagram: "iot", hasHeroSide: true }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("diagram")).toHaveTextContent("iot");
|
||||
expect(screen.queryByTestId("cover")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: a cover case -> the cover gets the case, the device and the service's sentence about that case", () => {
|
||||
const lavacao = service();
|
||||
render(
|
||||
<ServiceHero
|
||||
service={lavacao}
|
||||
hero={{
|
||||
...noSide,
|
||||
heroCase: caseStudy("lavacao-047"),
|
||||
hasHeroSide: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cover = screen.getByTestId("cover");
|
||||
expect(cover).toHaveAttribute("data-case", "lavacao-047");
|
||||
expect(cover).toHaveAttribute("data-device", "desktop");
|
||||
expect(cover).toHaveAttribute(
|
||||
"data-relevance",
|
||||
lavacao.caseRelevance["lavacao-047"],
|
||||
);
|
||||
expect(screen.queryByTestId("diagram")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: a cover case the service has no sentence for -> the cover gets no sentence (it falls back on its own)", () => {
|
||||
// o serviço de esquema não tem frase para a SEV
|
||||
const withoutSentence = service("tauri-rust");
|
||||
expect(withoutSentence.caseRelevance["sev-modaintima"]).toBeUndefined();
|
||||
|
||||
render(
|
||||
<ServiceHero
|
||||
service={withoutSentence}
|
||||
hero={{
|
||||
...noSide,
|
||||
heroCase: caseStudy("sev-modaintima"),
|
||||
heroDevice: "mobile",
|
||||
hasHeroSide: true,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const cover = screen.getByTestId("cover");
|
||||
expect(cover).toHaveAttribute("data-case", "sev-modaintima");
|
||||
expect(cover).toHaveAttribute("data-device", "mobile");
|
||||
expect(cover).toHaveAttribute("data-has-relevance", "false");
|
||||
});
|
||||
|
||||
it("path: the contact button speaks of the service by its whatsappSubject, in lower case, with the hero placement", () => {
|
||||
const sistemas = service("engenharia-de-software");
|
||||
render(<ServiceHero service={sistemas} hero={noSide} />);
|
||||
|
||||
const button = screen.getByRole("link", {
|
||||
name: /falar sobre meu projeto/i,
|
||||
});
|
||||
const message = new URL(button.getAttribute("href") ?? "").searchParams.get(
|
||||
"text",
|
||||
);
|
||||
expect(message).toContain(
|
||||
`Quero conversar sobre ${sistemas.whatsappSubject}.`,
|
||||
);
|
||||
expect(sistemas.whatsappSubject).toMatch(/^[a-zà-ú]/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
compactHero,
|
||||
heroWithMediaGrid,
|
||||
} from "@/app/cases/_components/hero-styles";
|
||||
import { Breadcrumb } from "@/app/components/Breadcrumb";
|
||||
import {
|
||||
pageTop,
|
||||
primaryButton,
|
||||
secondaryButtonDark,
|
||||
} from "@/app/components/button-styles";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { buttonLabels, type ServicePageContent } from "@/lib/site-content";
|
||||
import { ServiceDiagram } from "./ServiceDiagram";
|
||||
import { ServiceHeroCover } from "./ServiceHeroCover";
|
||||
import type { ServiceHeroData } from "./service-page";
|
||||
|
||||
/**
|
||||
* Topo da página do serviço: trilha, título, resposta, botão de contato e, na coluna ao lado, a capa do case escolhido
|
||||
* ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo.
|
||||
*/
|
||||
export function ServiceHero({
|
||||
service,
|
||||
hero,
|
||||
}: {
|
||||
service: ServicePageContent;
|
||||
hero: ServiceHeroData;
|
||||
}) {
|
||||
const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero;
|
||||
|
||||
return (
|
||||
<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.whatsappSubject}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { setNavigation } from "@/__mocks__/next/navigation";
|
||||
import { caseStudies, type ServiceOffer } from "@/lib/site-content";
|
||||
import { ServiceOfferCards } from "./ServiceOfferCards";
|
||||
|
||||
@@ -7,15 +8,19 @@ import { ServiceOfferCards } from "./ServiceOfferCards";
|
||||
* com cases | sem cases (a lista de links some); com observação | sem observação.
|
||||
*/
|
||||
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => "/servicos" }));
|
||||
beforeEach(() => {
|
||||
setNavigation({ pathname: "/servicos" });
|
||||
});
|
||||
|
||||
function offer(overrides: Partial<ServiceOffer["proof"]>): ServiceOffer {
|
||||
return {
|
||||
id: "site",
|
||||
name: "Oferta de teste",
|
||||
whatsappSubject: "uma oferta de teste",
|
||||
summary: "Resumo.",
|
||||
href: "/servicos/criacao-de-sites",
|
||||
deliverables: ["Um", "Dois", "Três"],
|
||||
firstStep: "Primeira conversa. Depois, escopo e proposta por escrito.",
|
||||
proof: { label: "Veja funcionando", caseIds: [], ...overrides },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { getServicePage, type ServicePageContent } from "@/lib/site-content";
|
||||
import { resolveServiceHero, splitServiceCases } from "./service-page";
|
||||
|
||||
/**
|
||||
* Caminhos-base de resolveServiceHero (CC 6) e splitServiceCases (CC 3) que service-page.test.ts não separa, todos com um
|
||||
* serviço montado à mão, porque nenhum serviço real reúne essas combinações:
|
||||
* - capa e esquema ao mesmo tempo: a capa vale e o esquema some; sem captura da capa, o esquema entra no lugar;
|
||||
* - os cases do bloco menor não contam para o botão "Ver projetos parecidos" (ele leva a "Onde já apliquei isso");
|
||||
* - todos os cases no bloco menor: nenhum cartão, e o bloco leva todos, na ordem de `relatedCaseIds`.
|
||||
* A existência das capturas em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||
getCaseMedia: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function service(overrides: Partial<ServicePageContent>): ServicePageContent {
|
||||
const base = getServicePage("consultoria-nextjs");
|
||||
if (!base) throw new Error("Missing service consultoria-nextjs");
|
||||
return {
|
||||
...base,
|
||||
heroCase: undefined,
|
||||
diagram: undefined,
|
||||
alsoCases: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Só as capturas listadas existem no disco. */
|
||||
function capturesOnDisk(captures: readonly (readonly [string, string])[]) {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
captures.some(
|
||||
([knownId, knownDevice]) => knownId === id && knownDevice === device,
|
||||
)
|
||||
? getShowcaseAssets(id, device)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("resolveServiceHero: combinações que os serviços reais não têm", () => {
|
||||
it("path: a cover with its capture AND a diagram -> the cover wins and the diagram is dropped", () => {
|
||||
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||
|
||||
const hero = resolveServiceHero(
|
||||
service({
|
||||
relatedCaseIds: ["lavacao-047"],
|
||||
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||
diagram: "iot",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||
expect(hero.diagram).toBeUndefined();
|
||||
expect(hero.hasHeroSide).toBe(true);
|
||||
});
|
||||
|
||||
it("path: a cover WITHOUT its capture and a diagram -> the diagram takes the side column", () => {
|
||||
capturesOnDisk([]);
|
||||
|
||||
const hero = resolveServiceHero(
|
||||
service({
|
||||
relatedCaseIds: ["lavacao-047"],
|
||||
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||
diagram: "iot",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hero.heroCase).toBeUndefined();
|
||||
expect(hero.diagram).toBe("iot");
|
||||
expect(hero.hasHeroSide).toBe(true);
|
||||
// a capa não valeu, então o case não está citado em legenda nenhuma: ele ganha o cartão
|
||||
expect(hero.hasCaseCards).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the only card left after the smaller block is the cover -> no 'Ver projetos parecidos' target, even with related cases in the block", () => {
|
||||
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||
|
||||
const hero = resolveServiceHero(
|
||||
service({
|
||||
relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"],
|
||||
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||
alsoCases: { title: "Também", caseIds: ["sc-plus", "as-servicos"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||
expect(hero.hasCaseCards).toBe(false);
|
||||
});
|
||||
|
||||
it("path: a cover in the smaller block as well -> it is not a card, and the cover stays", () => {
|
||||
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||
|
||||
const hero = resolveServiceHero(
|
||||
service({
|
||||
relatedCaseIds: ["lavacao-047"],
|
||||
heroCase: { id: "lavacao-047", device: "desktop" },
|
||||
alsoCases: { title: "Também", caseIds: ["lavacao-047"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||
expect(hero.hasCaseCards).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitServiceCases: o bloco leva todos os cases", () => {
|
||||
it("path: every related case in the smaller block -> no card at all, and the block keeps the order of relatedCaseIds", () => {
|
||||
const { cards, also } = splitServiceCases(
|
||||
service({
|
||||
relatedCaseIds: ["sc-plus", "as-servicos"],
|
||||
// fora de ordem de propósito: quem manda é a ordem de `relatedCaseIds`
|
||||
alsoCases: { title: "Também", caseIds: ["as-servicos", "sc-plus"] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cards).toEqual([]);
|
||||
expect(also?.title).toBe("Também");
|
||||
expect(also?.cases.map(({ id }) => id)).toEqual(["sc-plus", "as-servicos"]);
|
||||
});
|
||||
|
||||
it("path: a smaller block with an empty list of ids -> null, and every related case stays a card", () => {
|
||||
const { cards, also } = splitServiceCases(
|
||||
service({
|
||||
relatedCaseIds: ["sc-plus", "lavacao-047"],
|
||||
alsoCases: { title: "Também", caseIds: [] },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(also).toBeNull();
|
||||
expect(cards.map(({ id }) => id)).toEqual(["sc-plus", "lavacao-047"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { getServicePage, type ServicePageContent } from "@/lib/site-content";
|
||||
import { resolveServiceHero, splitServiceCases } from "./service-page";
|
||||
|
||||
/**
|
||||
* resolveServiceHero (CC 6): capa com captura | sem captura do aparelho pedido; esquema | sem esquema; o aparelho padrão
|
||||
* (computador) | o do serviço (celular); algum case com cartão | só o da capa | nenhum.
|
||||
* splitServiceCases (CC 3): serviço com bloco menor | sem ele; ids do bloco que o serviço relaciona | que não relaciona.
|
||||
* A existência das capturas em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||
getCaseMedia: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function service(slug: string): ServicePageContent {
|
||||
const found = getServicePage(slug);
|
||||
if (!found) throw new Error(`Missing service ${slug}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Só as capturas listadas existem no disco. */
|
||||
function capturesOnDisk(captures: readonly (readonly [string, string])[]) {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
captures.some(
|
||||
([knownId, knownDevice]) => knownId === id && knownDevice === device,
|
||||
)
|
||||
? getShowcaseAssets(id, device)
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("resolveServiceHero", () => {
|
||||
it("path: cover with its capture on disk -> the case is the cover, no diagram, two columns", () => {
|
||||
capturesOnDisk([["lavacao-047", "desktop"]]);
|
||||
|
||||
const hero = resolveServiceHero(service("criacao-de-sites"));
|
||||
|
||||
expect(hero.heroCase?.id).toBe("lavacao-047");
|
||||
expect(hero.heroDevice).toBe("desktop");
|
||||
expect(hero.diagram).toBeUndefined();
|
||||
expect(hero.hasHeroSide).toBe(true);
|
||||
// a SEV, que não é a capa, ganha cartão
|
||||
expect(hero.hasCaseCards).toBe(true);
|
||||
});
|
||||
|
||||
it("path: a service that asks for the phone -> the capture is checked for the phone", () => {
|
||||
capturesOnDisk([["sev-modaintima", "mobile"]]);
|
||||
|
||||
const hero = resolveServiceHero(service("engenharia-de-software"));
|
||||
|
||||
expect(hero.heroDevice).toBe("mobile");
|
||||
expect(hero.heroCase?.id).toBe("sev-modaintima");
|
||||
expect(mockedGetCaseMedia).toHaveBeenCalledWith("sev-modaintima", "mobile");
|
||||
});
|
||||
|
||||
it("path: cover without a capture and no diagram -> no side column, and the case that would be the cover gets its card back", () => {
|
||||
capturesOnDisk([]);
|
||||
|
||||
const hero = resolveServiceHero(service("criacao-de-sites"));
|
||||
|
||||
expect(hero.heroCase).toBeUndefined();
|
||||
expect(hero.diagram).toBeUndefined();
|
||||
expect(hero.hasHeroSide).toBe(false);
|
||||
expect(hero.hasCaseCards).toBe(true);
|
||||
});
|
||||
|
||||
it("path: service with a diagram -> the diagram fills the side column, desktop by default", () => {
|
||||
capturesOnDisk([]);
|
||||
|
||||
const hero = resolveServiceHero(service("tauri-rust"));
|
||||
|
||||
expect(hero.heroCase).toBeUndefined();
|
||||
expect(hero.heroDevice).toBe("desktop");
|
||||
expect(hero.diagram).toBe("desktop");
|
||||
expect(hero.hasHeroSide).toBe(true);
|
||||
expect(hero.hasCaseCards).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the only related case is the cover -> no card, so no 'Ver projetos parecidos' target", () => {
|
||||
capturesOnDisk([["sc-plus", "desktop"]]);
|
||||
const onlyCover: ServicePageContent = {
|
||||
...service("consultoria-nextjs"),
|
||||
relatedCaseIds: ["sc-plus"],
|
||||
};
|
||||
|
||||
const hero = resolveServiceHero(onlyCover);
|
||||
|
||||
expect(hero.heroCase?.id).toBe("sc-plus");
|
||||
expect(hero.hasCaseCards).toBe(false);
|
||||
});
|
||||
|
||||
it("path: no related case at all -> no cards", () => {
|
||||
capturesOnDisk([]);
|
||||
|
||||
expect(resolveServiceHero(service("arquitetura-de-software"))).toEqual({
|
||||
heroCase: undefined,
|
||||
heroDevice: "desktop",
|
||||
diagram: "arquitetura",
|
||||
hasHeroSide: true,
|
||||
hasCaseCards: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitServiceCases", () => {
|
||||
it("path: service with a smaller block -> the sites institucionais leave the cards and go to the block", () => {
|
||||
const { cards, also } = splitServiceCases(service("criacao-de-sites"));
|
||||
|
||||
expect(cards.map(({ id }) => id)).toEqual([
|
||||
"lavacao-047",
|
||||
"sev-modaintima",
|
||||
]);
|
||||
expect(also?.title).toBe("Também faço sites institucionais");
|
||||
expect(also?.cases.map(({ id }) => id)).toEqual(["sc-plus", "as-servicos"]);
|
||||
});
|
||||
|
||||
it("path: service without a smaller block -> every related case is a card and `also` is null", () => {
|
||||
const { cards, also } = splitServiceCases(
|
||||
service("engenharia-de-software"),
|
||||
);
|
||||
|
||||
expect(cards.map(({ id }) => id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
]);
|
||||
expect(also).toBeNull();
|
||||
});
|
||||
|
||||
it("path: block ids that the service does not relate -> ignored, and with none left the block is null", () => {
|
||||
const stray: ServicePageContent = {
|
||||
...service("consultoria-nextjs"),
|
||||
alsoCases: { title: "Também", caseIds: ["as-servicos", "nao-existe"] },
|
||||
};
|
||||
|
||||
const { cards, also } = splitServiceCases(stray);
|
||||
|
||||
expect(cards.map(({ id }) => id)).toEqual(["sc-plus", "lavacao-047"]);
|
||||
expect(also).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import {
|
||||
type CaseStudyContent,
|
||||
caseStudies,
|
||||
getRelatedCases,
|
||||
type HeroDevice,
|
||||
type ServiceDiagramKind,
|
||||
type ServicePageContent,
|
||||
} from "@/lib/site-content";
|
||||
|
||||
/** O que ocupa a coluna ao lado do título do serviço, e se há "Onde já apliquei isso" para o botão do topo levar até lá. */
|
||||
export interface ServiceHeroData {
|
||||
/** Case da capa: o escolhido para o serviço, quando tem captura gravada do aparelho pedido. */
|
||||
heroCase?: CaseStudyContent;
|
||||
heroDevice: HeroDevice;
|
||||
/** Esquema de fluxo no lugar da capa; só existe quando não há case com captura. */
|
||||
diagram?: ServiceDiagramKind;
|
||||
/** Há coluna ao lado do título (capa ou esquema): sem ela, o topo é uma coluna só e mais baixo. */
|
||||
hasHeroSide: boolean;
|
||||
/** Algum case ganha cartão em "Onde já apliquei isso" (o da capa não ganha: a legenda dela o cita). */
|
||||
hasCaseCards: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Os cases do serviço, em dois grupos: os cartões de "Onde já apliquei isso" e os do bloco menor `alsoCases`
|
||||
* (sem capa), que o serviço pode declarar. Sem `alsoCases`, `also` é nulo.
|
||||
*/
|
||||
export function splitServiceCases(service: ServicePageContent) {
|
||||
const related = getRelatedCases(service.relatedCaseIds);
|
||||
const alsoIds = new Set(service.alsoCases?.caseIds);
|
||||
const alsoCases = related.filter(({ id }) => alsoIds.has(id));
|
||||
|
||||
return {
|
||||
cards: related.filter(({ id }) => !alsoIds.has(id)),
|
||||
also:
|
||||
service.alsoCases && alsoCases.length > 0
|
||||
? { title: service.alsoCases.title, cases: alsoCases }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo.
|
||||
* O case da capa é citado pela legenda dela e não ganha cartão em "Onde já apliquei isso".
|
||||
*/
|
||||
export function resolveServiceHero(
|
||||
service: ServicePageContent,
|
||||
): ServiceHeroData {
|
||||
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 { cards } = splitServiceCases(service);
|
||||
|
||||
return {
|
||||
heroCase,
|
||||
heroDevice,
|
||||
diagram,
|
||||
hasHeroSide: Boolean(heroCase || diagram),
|
||||
hasCaseCards: cards.some(({ id }) => id !== heroCase?.id),
|
||||
};
|
||||
}
|
||||
@@ -33,6 +33,9 @@ const pageTitle = "Sites, sistemas e aplicativos sob medida";
|
||||
const pageDescription =
|
||||
"Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Garuva e Joinville (SC), remoto em todo o Brasil.";
|
||||
|
||||
/** Cliente que não entra no mosaico do topo: a lista de /cases abre com a SEV (ordem da vitrine), mas o hub mostra os outros três. */
|
||||
const MOSAIC_EXCLUDED_ID = "sev-modaintima";
|
||||
|
||||
/** Páginas que as três ofertas já abrem; os demais serviços ficam na lista "Outros serviços". */
|
||||
const offerHrefs = new Set(serviceOffers.map(({ href }) => href));
|
||||
const otherServices = serviceHubLinks.filter(
|
||||
@@ -69,9 +72,13 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export default function ServicesIndexPage() {
|
||||
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (ela vai por último)
|
||||
// três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (o hub não abre com ela)
|
||||
// e nenhum cliente se repete mais abaixo, onde as ofertas citam os cases só por link
|
||||
const posters = getMosaicPosters(clientCasesForListing.map(({ id }) => id));
|
||||
const posters = getMosaicPosters(
|
||||
clientCasesForListing
|
||||
.map(({ id }) => id)
|
||||
.filter((id) => id !== MOSAIC_EXCLUDED_ID),
|
||||
);
|
||||
const hasMosaic = posters.length > 0;
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
|
||||
Reference in new issue
Block a user