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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user