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

@@ -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à-ú]/);
});
});
+87
View File
@@ -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();
});
});
+64
View File
@@ -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),
};
}