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

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

No files matched your search

@@ -0,0 +1,70 @@
import { render, screen } from "@testing-library/react";
import type { ServiceDiagramKind } from "@/lib/site-content";
import { ServiceDiagram } from "./ServiceDiagram";
/** Esquema de fluxo do topo dos serviços sem case com captura: SVG em linha, acessível, sem dado real nem logo de cliente. */
const expected: Record<ServiceDiagramKind, string[]> = {
iot: ["Dispositivo ou sensor", "Gateway", "API", "Painel"],
arquitetura: ["Interface", "API", "Serviços", "Banco de dados"],
desktop: ["Aplicativo no computador", "Arquivos", "Sistemas locais"],
};
describe("ServiceDiagram", () => {
for (const kind of Object.keys(expected) as ServiceDiagramKind[]) {
it(`path: ${kind} -> an inline SVG image with the flow in its label and the steps in order`, () => {
const { container } = render(<ServiceDiagram kind={kind} />);
const svg = screen.getByRole("img");
expect(svg.tagName.toLowerCase()).toBe("svg");
expect(svg).toHaveAttribute("aria-label");
expect(svg.getAttribute("aria-label")?.length).toBeGreaterThan(60);
const labels = Array.from(container.querySelectorAll("svg text")).map(
(text) => text.textContent,
);
expect(labels).toEqual(expected[kind]);
// o texto do desenho está no rótulo da imagem: quem lê a tela ouve o fluxo inteiro
for (const label of expected[kind]) {
expect(svg.getAttribute("aria-label")?.toLowerCase()).toContain(
label.toLowerCase().replace("dispositivo ou sensor", "dispositivo"),
);
}
expect(container.querySelectorAll("svg img, svg image")).toHaveLength(0);
});
}
it("path: IoT and architecture -> three arrows between four steps; desktop -> two arrows, both ways", () => {
const iot = render(<ServiceDiagram kind="iot" />);
expect(iot.container.querySelectorAll("svg line")).toHaveLength(3);
// uma ponta por seta
expect(iot.container.querySelectorAll("svg polygon")).toHaveLength(3);
iot.unmount();
const arquitetura = render(<ServiceDiagram kind="arquitetura" />);
expect(arquitetura.container.querySelectorAll("svg line")).toHaveLength(3);
arquitetura.unmount();
const desktop = render(<ServiceDiagram kind="desktop" />);
expect(desktop.container.querySelectorAll("svg line")).toHaveLength(2);
// duas pontas por seta: a informação vai e volta
expect(desktop.container.querySelectorAll("svg polygon")).toHaveLength(4);
});
it("path: colors -> only site tokens (paper and white), never the lime that belongs to contact, never a client logo", () => {
const { container } = render(<ServiceDiagram kind="iot" />);
const html = container.innerHTML;
expect(html).toMatch(/var\(--paper\)/);
expect(html).toMatch(/var\(--white\)/);
expect(html).not.toMatch(/--slash|#ccff00|ccff00/i);
expect(html).not.toMatch(/<image|<img|href=/i);
});
it("path: caption -> says it is an illustration, with no client data", () => {
render(<ServiceDiagram kind="arquitetura" />);
expect(
screen.getByText("Esquema ilustrativo, sem dados de clientes."),
).toBeVisible();
});
});
+163
View File
@@ -0,0 +1,163 @@
import type { ServiceDiagramKind } from "@/lib/site-content";
/**
* Esquema de fluxo no topo do serviço que não tem case com captura gravada, no lugar da coluna vazia ao lado do título.
* SVG em linha, só com as cores do site (papel e branco sobre o fundo escuro, sem o verde-limão, que é do contato),
* sem dado real e sem logo de cliente. Para quem lê a tela, o esquema é uma imagem com o fluxo descrito em `aria-label`.
*/
const WIDTH = 480;
const HEIGHT = 404;
const ARROW_HEAD = 12;
interface DiagramNode {
label: string;
x: number;
y: number;
width: number;
height: number;
}
interface DiagramArrow {
x: number;
from: number;
to: number;
/** Ponta nas duas extremidades: a informação anda nos dois sentidos. */
both?: boolean;
}
interface DiagramShape {
description: string;
nodes: DiagramNode[];
arrows: DiagramArrow[];
}
/** Quatro etapas em coluna, de cima para baixo, ligadas por setas. */
function column(labels: [string, string, string, string]) {
const nodes = labels.map((label, index) => ({
label,
x: 90,
y: 24 + index * 100,
width: 300,
height: 56,
}));
const arrows = nodes.slice(1).map((node, index) => ({
x: WIDTH / 2,
from: nodes[index].y + nodes[index].height,
to: node.y,
}));
return { nodes, arrows };
}
const diagrams: Record<ServiceDiagramKind, DiagramShape> = {
iot: {
description:
"Esquema do caminho dos dados: o dispositivo envia as leituras ao gateway, o gateway repassa à API e a API alimenta o painel.",
...column(["Dispositivo ou sensor", "Gateway", "API", "Painel"]),
},
arquitetura: {
description:
"Esquema das camadas de um sistema: a interface chama a API, a API aciona os serviços e os serviços guardam e consultam o banco de dados.",
...column(["Interface", "API", "Serviços", "Banco de dados"]),
},
desktop: {
description:
"Esquema de um aplicativo desktop: o aplicativo no computador troca informações, nos dois sentidos, com os arquivos e com os sistemas locais.",
nodes: [
{
label: "Aplicativo no computador",
x: 90,
y: 60,
width: 300,
height: 64,
},
{ label: "Arquivos", x: 30, y: 280, width: 190, height: 64 },
{ label: "Sistemas locais", x: 260, y: 280, width: 190, height: 64 },
],
arrows: [
{ x: 125, from: 124, to: 280, both: true },
{ x: 355, from: 124, to: 280, both: true },
],
},
};
function Arrow({ x, from, to, both = false }: DiagramArrow) {
const lineStart = both ? from + ARROW_HEAD : from;
return (
<g>
<line
x1={x}
y1={lineStart}
x2={x}
y2={to - ARROW_HEAD}
stroke="var(--paper)"
strokeOpacity={0.7}
strokeWidth={2}
/>
<polygon
points={`${x - 7},${to - ARROW_HEAD} ${x + 7},${to - ARROW_HEAD} ${x},${to}`}
fill="var(--paper)"
fillOpacity={0.7}
/>
{both ? (
<polygon
points={`${x - 7},${from + ARROW_HEAD} ${x + 7},${from + ARROW_HEAD} ${x},${from}`}
fill="var(--paper)"
fillOpacity={0.7}
/>
) : null}
</g>
);
}
export function ServiceDiagram({ kind }: { kind: ServiceDiagramKind }) {
const { description, nodes, arrows } = diagrams[kind];
return (
<figure className="mx-auto w-full min-w-0 max-w-[520px]">
<svg
role="img"
aria-label={description}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
className="block h-auto w-full border border-white/15 bg-[#0a0a0a]"
data-diagram={kind}
>
{arrows.map((arrow) => (
<Arrow key={`${arrow.x}-${arrow.from}`} {...arrow} />
))}
{nodes.map(({ label, x, y, width, height }) => (
<g key={label}>
<rect
x={x}
y={y}
width={width}
height={height}
fill="var(--white)"
fillOpacity={0.06}
stroke="var(--paper)"
strokeOpacity={0.35}
strokeWidth={1.5}
/>
<text
x={x + width / 2}
y={y + height / 2}
textAnchor="middle"
dominantBaseline="central"
fill="var(--paper)"
style={{
fontFamily: "var(--font-display)",
fontSize: 20,
fontWeight: 600,
}}
>
{label}
</text>
</g>
))}
</svg>
<figcaption className="mt-sp-3 text-[15px] leading-6 text-white/70">
Esquema ilustrativo, sem dados de clientes.
</figcaption>
</figure>
);
}
-32
View File
@@ -1,32 +0,0 @@
import type { ServicePageContent } from "@/lib/site-content";
/** "Perguntas frequentes" do serviço. Sem perguntas, a seção não aparece. */
export function ServiceFaq({ faq }: { faq: ServicePageContent["faq"] }) {
if (!faq || faq.length === 0) return null;
return (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de cada
projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
);
}
@@ -43,18 +43,76 @@ describe("ServiceHeroCover", () => {
expect(container).toBeEmptyDOMElement();
});
it("path: case with a capture -> a link to the case around a cover that loads first", () => {
it("path: case with a capture -> a cover that loads first and a caption with the case name, the relevance and 'Ver case'", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
render(<ServiceHeroCover caseStudy={sev} />);
const { container } = render(
<ServiceHeroCover
caseStudy={sev}
relevance="Frase sobre a relação com o serviço."
/>,
);
const link = screen.getByRole("link", {
name: new RegExp(`^Ver o case ${sev.title}`),
name: `Ver case ${sev.title}`,
});
expect(link).toHaveAttribute("href", `/cases/${sev.id}`);
const image = screen.getByAltText(`Captura de tela do site ${sev.title}`);
expect(image).toHaveAttribute("loading", "eager");
expect(image).toHaveAttribute("fetchpriority", "high");
expect(image).toHaveAttribute("sizes", coverSizes.hero);
// a imagem também leva ao case, mas fica fora da leitura e da tabulação: o link da legenda já leva ao mesmo lugar
const coverLink = image.closest("a");
expect(coverLink).toHaveAttribute("aria-hidden", "true");
expect(coverLink).toHaveAttribute("tabindex", "-1");
const caption = container.querySelector("figcaption");
expect(caption).toHaveTextContent(sev.title);
expect(caption).toHaveTextContent("Frase sobre a relação com o serviço.");
expect(caption).toContainElement(link);
});
it("path: no relevance sentence -> the caption keeps the name and the button", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(<ServiceHeroCover caseStudy={sev} />);
expect(container.querySelector("figcaption")).toHaveTextContent(
new RegExp(`^${sev.title}Ver case`),
);
expect(
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible();
});
it("path: mobile device -> the phone poster in a phone frame, a link to the case, no browser frame", () => {
mockedGetCaseMedia.mockImplementation(getShowcaseAssets);
const { container } = render(
<ServiceHeroCover caseStudy={sev} device="mobile" />,
);
expect(mockedGetCaseMedia).toHaveBeenCalledWith(sev.id, "mobile");
const link = screen.getByRole("link", {
name: `Ver case ${sev.title}`,
});
expect(link).toHaveAttribute("href", `/cases/${sev.id}`);
const screenElement = container.querySelector(".ds-phone-screen");
expect(screenElement).not.toBeNull();
expect(
decodeURIComponent(
(screenElement as HTMLElement).style.backgroundImage ?? "",
),
).toContain(`/videos/${sev.id}/mobile.webp`);
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
});
it("path: mobile device but no mobile capture -> renders nothing", () => {
mockedGetCaseMedia.mockImplementation((id, device) =>
device === "mobile" ? undefined : getShowcaseAssets(id, device),
);
const { container } = render(
<ServiceHeroCover caseStudy={sev} device="mobile" />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: link -> pre-fetches the case only after the pointer reaches it", () => {
+62 -14
View File
@@ -1,28 +1,76 @@
import { CaseCover, coverSizes } from "@/app/cases/_components/CaseCover";
import { getCaseMedia } from "@/app/cases/_components/case-media";
import { textLinkDark } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import type { CaseStudyContent } from "@/lib/site-content";
import { PhoneFrame } from "@/app/components/showcase/PhoneFrame";
import {
buttonLabels,
type CaseStudyContent,
type HeroDevice,
} from "@/lib/site-content";
/** Capa do case de destaque ao lado do título do serviço. Sem case com captura, não mostra nada. */
/**
* Capa do case de destaque ao lado do título do serviço: o site no computador (moldura de navegador) ou no celular
* (moldura de celular), conforme `device`. Sem captura do aparelho pedido, não mostra nada.
* Embaixo, a legenda: o nome do case, por que ele interessa a este serviço (`relevance`) e o botão "Ver case". É esta
* legenda que cita o case na página: ele não ganha um cartão a mais mais abaixo, porque a mesma imagem e o mesmo nome
* não aparecem duas vezes. A imagem também leva ao case, mas fica fora da leitura e da tabulação (o link da legenda
* já leva ao mesmo lugar).
*/
export function ServiceHeroCover({
caseStudy,
device = "desktop",
relevance,
}: {
caseStudy?: CaseStudyContent;
device?: HeroDevice;
/** Por que o case interessa ao serviço, em uma frase. */
relevance?: string;
}) {
if (!caseStudy) return null;
const href = `/cases/${caseStudy.id}`;
const media =
device === "mobile" ? getCaseMedia(caseStudy.id, "mobile") : null;
if (device === "mobile" && !media) return null;
return (
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className="block min-w-0"
<figure
className={`min-w-0 ${device === "mobile" ? "mx-auto w-full max-w-[360px]" : ""}`}
>
<span className="sr-only">Ver o case {caseStudy.title}</span>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.hero}
priority
/>
</HoverPrefetchLink>
<HoverPrefetchLink
href={href}
aria-hidden="true"
tabIndex={-1}
className={
media ? "mx-auto block w-[min(62vw,240px)] min-w-0" : "block min-w-0"
}
>
{media ? (
<PhoneFrame poster={media.poster} />
) : (
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes={coverSizes.hero}
priority
/>
)}
</HoverPrefetchLink>
<figcaption className="mt-sp-4 text-[15px] leading-6 text-white/75">
<strong className="font-semibold text-white">{caseStudy.title}</strong>
{relevance ? `: ${relevance}` : null}
<span className="mt-sp-1 block">
<HoverPrefetchLink
href={href}
className={`${textLinkDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
</span>
</figcaption>
</figure>
);
}
@@ -0,0 +1,111 @@
import { render, screen, within } from "@testing-library/react";
import { caseStudies, type ServiceOffer } from "@/lib/site-content";
import { ServiceOfferCards } from "./ServiceOfferCards";
/**
* Testes de caminhos-base de ServiceOfferCards / OfferCard (CC 4): id de case que existe | que não existe (ignorado);
* com cases | sem cases (a lista de links some); com observação | sem observação.
*/
jest.mock("next/navigation", () => ({ usePathname: () => "/servicos" }));
function offer(overrides: Partial<ServiceOffer["proof"]>): ServiceOffer {
return {
id: "site",
name: "Oferta de teste",
summary: "Resumo.",
href: "/servicos/criacao-de-sites",
deliverables: ["Um", "Dois", "Três"],
proof: { label: "Veja funcionando", caseIds: [], ...overrides },
};
}
const card = () => screen.getByRole("article");
describe("ServiceOfferCards", () => {
it("path: cases that exist and a note -> a link per case in the order given, then the note", () => {
render(
<ServiceOfferCards
offers={[
offer({ caseIds: ["sev-modaintima", "lavacao-047"], note: "Nota." }),
]}
/>,
);
const links = within(card()).getAllByRole("link", { name: /^Ver case / });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/cases/sev-modaintima",
"/cases/lavacao-047",
]);
expect(within(card()).getByText("Nota.")).toBeVisible();
});
it("path: an id with no case behind it -> skipped, the real cases around it stay", () => {
render(
<ServiceOfferCards
offers={[
offer({ caseIds: ["nao-existe", "lavacao-047", "tambem-nao"] }),
]}
/>,
);
const links = within(card()).getAllByRole("link", { name: /^Ver case / });
expect(links.map((link) => link.getAttribute("href"))).toEqual([
"/cases/lavacao-047",
]);
});
it("path: only ids with no case -> no list of cases at all, the label and the service link stay", () => {
render(<ServiceOfferCards offers={[offer({ caseIds: ["nao-existe"] })]} />);
expect(
within(card()).queryAllByRole("link", { name: /^Ver case / }),
).toEqual([]);
expect(within(card()).queryAllByRole("listitem")).toHaveLength(3);
expect(within(card()).getByText("Veja funcionando")).toBeVisible();
expect(
within(card()).getByRole("link", { name: /Ver o serviço|Ver serviço/ }),
).toHaveAttribute("href", "/servicos/criacao-de-sites");
});
it("path: no cases and a note (the review offer) -> only the note under the label", () => {
render(
<ServiceOfferCards
offers={[
offer({
caseIds: [],
label: "Veja como funciona",
note: "A página mostra o que você recebe.",
}),
]}
/>,
);
expect(within(card()).getByText("Veja como funciona")).toBeVisible();
expect(
within(card()).getByText("A página mostra o que você recebe."),
).toBeVisible();
expect(
within(card()).queryAllByRole("link", { name: /^Ver case / }),
).toEqual([]);
});
it("path: cases and no note -> no empty paragraph for the note", () => {
const { container } = render(
<ServiceOfferCards offers={[offer({ caseIds: ["lavacao-047"] })]} />,
);
expect(container.querySelectorAll("article p")).toHaveLength(2);
});
it("path: every real case id of the real offers resolves to a case (no silently dropped proof)", () => {
const { serviceOffers } =
jest.requireActual<typeof import("@/lib/site-content")>(
"@/lib/site-content",
);
const known = new Set(caseStudies.map(({ id }) => id));
for (const { proof } of serviceOffers)
for (const id of proof.caseIds) expect(known.has(id)).toBe(true);
});
});
@@ -0,0 +1,71 @@
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import {
buttonLabels,
caseStudies,
type ServiceOffer,
} from "@/lib/site-content";
function OfferCard({ offer }: { offer: ServiceOffer }) {
const proofCases = offer.proof.caseIds.flatMap((id) => {
const caseStudy = caseStudies.find((item) => item.id === id);
return caseStudy ? [caseStudy] : [];
});
return (
<article className="ds-card">
<h3 className="ds-card-title !mt-0 text-balance">{offer.name}</h3>
<p className="ds-copy !flex-grow-0">{offer.summary}</p>
<ul className="ds-list space-y-sp-2">
{offer.deliverables.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="ds-kicker mt-sp-6">{offer.proof.label}</p>
{proofCases.length > 0 ? (
<ul className="mt-sp-2 flex flex-wrap gap-x-sp-4">
{proofCases.map((caseStudy) => (
<li key={caseStudy.id}>
<HoverPrefetchLink
href={`/cases/${caseStudy.id}`}
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
<span className="sr-only">{buttonLabels.case} </span>
{caseStudy.title}
</HoverPrefetchLink>
</li>
))}
</ul>
) : null}
{offer.proof.note ? (
<p className="ds-copy !mt-sp-2 !flex-grow-0 text-[15px]">
{offer.proof.note}
</p>
) : null}
<div className="mt-sp-6">
<HoverPrefetchLink
href={offer.href}
className={`${secondaryButtonLight} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{buttonLabels.service}
<span className="sr-only"> {offer.name}</span>
</HoverPrefetchLink>
</div>
</article>
);
}
/** As três ofertas, lado a lado no computador: nome, o que a pessoa recebe, onde ver funcionando e o link da página certa. */
export function ServiceOfferCards({
offers,
}: {
offers: readonly ServiceOffer[];
}) {
return (
<div className="mt-sp-8 grid items-stretch gap-sp-6 lg:grid-cols-3">
{offers.map((offer) => (
<OfferCard key={offer.id} offer={offer} />
))}
</div>
);
}
@@ -31,7 +31,9 @@ function withDesktopCapture() {
const sev = getCase("sev-modaintima"); // cliente, com site no ar
const asServicos = getCase("as-servicos"); // cliente, sem site público
const twins = getCase("twins-lab"); // laboratório, sem site público
/** Título com algum case de cliente; sem cliente (só experiência ou projeto próprio), é "Como eu trabalho nisso". */
const TITLE = "Onde já apliquei isso";
const TITLE_WITHOUT_CLIENT = "Como eu trabalho nisso";
const NOTE = "Texto de experiência de teste.";
beforeEach(() => {
@@ -57,11 +59,12 @@ describe("ServiceRelatedCases", () => {
);
expect(
screen.getByRole("heading", { level: 2, name: TITLE }),
screen.getByRole("heading", { level: 2, name: TITLE_WITHOUT_CLIENT }),
).toBeVisible();
expect(screen.queryByRole("heading", { name: TITLE })).toBeNull();
expect(screen.getByText(NOTE)).toBeVisible();
expect(container.querySelectorAll("article")).toHaveLength(0);
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
expect(screen.queryByRole("link", { name: /^Ver case/ })).toBeNull();
});
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
@@ -81,14 +84,15 @@ describe("ServiceRelatedCases", () => {
if (!card) throw new Error("Missing case card");
expect(within(card).getByText(sev.headline)).toBeVisible();
// case de cliente: o kicker é só o nome
expect(within(card).getByText(sev.title)).toBeVisible();
const kicker = card.querySelector(".ds-kicker");
expect(kicker).toHaveTextContent(new RegExp(`^${sev.title}$`));
expect(within(card).queryByText(/Projeto próprio/)).toBeNull();
expect(within(card).getByText("Frase de relação do SEV.")).toBeVisible();
expect(
within(card).getByRole("link", { name: `Ler case: ${sev.title}` }),
within(card).getByRole("link", { name: `Ver case ${sev.title}` }),
).toHaveAttribute("href", `/cases/${sev.id}`);
expect(
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
within(card).getByRole("link", { name: `Abrir o site ${sev.title}` }),
).toHaveAttribute("href", sev.demoUrl);
});
@@ -107,7 +111,7 @@ describe("ServiceRelatedCases", () => {
expect(note.querySelector("a")).toBeNull();
expect(
note.compareDocumentPosition(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
@@ -138,7 +142,7 @@ describe("ServiceRelatedCases", () => {
expect(container.querySelector(".ds-bframe")).toBeNull();
expect(container.querySelector("img")).toBeNull();
expect(
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible();
});
@@ -152,6 +156,42 @@ describe("ServiceRelatedCases", () => {
);
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
// só projeto próprio, sem cliente: o título não promete trabalho aplicado para alguém
expect(
screen.getByRole("heading", { level: 2, name: TITLE_WITHOUT_CLIENT }),
).toBeVisible();
});
it("path: case whose cover already opens the page -> it gets no card at all (the cover caption cites it), the others keep theirs", () => {
withDesktopCapture();
const lavacao = getCase("lavacao-047");
const { container } = render(
<ServiceRelatedCases
cases={[lavacao, sev]}
relevance={{}}
heroCaseId={lavacao.id}
/>,
);
expect(container.querySelectorAll("article")).toHaveLength(1);
expect(
container.querySelector(`a[href="/cases/${lavacao.id}"]`),
).toBeNull();
expect(container.textContent).not.toContain(lavacao.title);
expect(
container.querySelector(`a[aria-hidden="true"][href="/cases/${sev.id}"]`),
).not.toBeNull();
expect(
screen.getByRole("link", { name: `Ver case ${sev.title}` }),
).toBeVisible();
});
it("path: the cover case is the only one -> no cases to list and no experience note -> renders nothing", () => {
const { container } = render(
<ServiceRelatedCases cases={[sev]} relevance={{}} heroCaseId={sev.id} />,
);
expect(container).toBeEmptyDOMElement();
});
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
@@ -172,7 +212,7 @@ describe("ServiceRelatedCases", () => {
);
expect(
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
screen.getByRole("link", { name: `Ver case ${asServicos.title}` }),
).toBeVisible();
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
});
@@ -18,7 +18,7 @@ it("shows only the experience note, without the 'open the case' sentence, when t
);
expect(
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
screen.getByRole("heading", { level: 2, name: "Como eu trabalho nisso" }),
).toBeVisible();
expect(screen.getByText(note)).toBeVisible();
expect(screen.queryByText(/Abra o case/)).toBeNull();
@@ -37,7 +37,7 @@ it("invites to open the case and lists each case when there are some", () => {
expect(screen.getByText(/Abra o case/)).toBeVisible();
expect(screen.getByText("Frase própria sobre a relação.")).toBeVisible();
expect(
screen.getByRole("link", { name: `Ler case: ${caseStudy.title}` }),
screen.getByRole("link", { name: `Ver case ${caseStudy.title}` }),
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
});
@@ -1,7 +1,11 @@
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
import {
buttonLabels,
type CaseStudyContent,
isClientCase,
} from "@/lib/site-content";
function ServiceCaseCard({
caseStudy,
@@ -31,7 +35,8 @@ function ServiceCaseCard({
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ler case: {caseStudy.title}
{buttonLabels.case}
<span className="sr-only"> {caseStudy.title}</span>
</HoverPrefetchLink>
{caseStudy.demoUrl ? (
<a
@@ -40,7 +45,8 @@ function ServiceCaseCard({
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
{buttonLabels.site}
<span className="sr-only"> {caseStudy.title}</span>
</a>
) : null}
</div>
@@ -49,22 +55,30 @@ function ServiceCaseCard({
}
/**
* "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma frase sobre o que ele tem a ver com ele,
* e a experiência profissional citada em texto (sem link). Sem case, fica só a nota de experiência, sem a frase
* que manda abrir o case; sem case nem experiência, não mostra nada.
* Prova do serviço. Com algum case de cliente, é "Onde já apliquei isso": os cases ligados ao serviço, cada um com uma
* frase sobre o que ele tem a ver com ele, e a experiência profissional citada em texto (sem link). Sem case de cliente
* (só projeto próprio, só experiência ou nada), o título é "Como eu trabalho nisso", porque não há cliente para citar.
* Sem case, fica só a nota de experiência, sem a frase que manda abrir o case; sem case nem experiência, não mostra nada.
* O case cuja capa abre a página (`heroCaseId`) não ganha cartão aqui: a legenda da capa já o cita, com a mesma frase de
* relação, e a imagem e o nome dele não aparecem duas vezes na mesma página.
*/
export function ServiceRelatedCases({
cases,
cases: allCases,
relevance,
experienceNote,
heroCaseId,
}: {
cases: readonly CaseStudyContent[];
/** Por que cada case interessa a este serviço (chave: id do case). */
relevance: Readonly<Record<string, string>>;
experienceNote?: string;
/** Case cuja capa já está no topo da página, com legenda: fica de fora dos cartões. */
heroCaseId?: string;
}) {
const cases = allCases.filter(({ id }) => id !== heroCaseId);
const hasCases = cases.length > 0;
if (!hasCases && !experienceNote) return null;
const hasClientCase = cases.some(isClientCase);
return (
<section
@@ -75,9 +89,13 @@ export function ServiceRelatedCases({
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Trabalhos parecidos</p>
<p className="ds-eyebrow">
{hasClientCase ? "Trabalhos parecidos" : "Experiência"}
</p>
<h2 id="casos-relacionados-titulo" className="ds-section-title">
Onde já apliquei isso
{hasClientCase
? "Onde já apliquei isso"
: "Como eu trabalho nisso"}
</h2>
{hasCases ? (
<p className="ds-section-description">