feat(cases): mídia no topo, títulos de busca curtos e CreativeWork
Case com mídia ao lado do título e play acessível; problema de cada cliente reescrito com base nos dados; serviços relacionados em 2 cards; FAQ antes do próximo passo e bloco 'Onde já apliquei isso'; Apolo citada em IoT e arquitetura. metaTitle até 43 caracteres e descrições até 155 com teste; JSON-LD CreativeWork; imagens de compartilhamento abaixo de 100 KB; 404 e robots corrigidos; server-only nos módulos com node:.
This commit is contained in:
1 parent
c9a0b80656
commit
e80f73d075
52 files changed
+2593
-439
No files matched your search
@@ -0,0 +1,179 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { caseStudies, isClientCase } from "@/lib/site-content";
|
||||
import { ServiceRelatedCases } from "./ServiceRelatedCases";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ServiceRelatedCases, direto no componente:
|
||||
* sem case nem experiência, só a experiência, case sem frase de relação, case de laboratório,
|
||||
* case sem captura e case sem site no ar. A existência dos arquivos em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("@/app/cases/_components/case-media", () => ({
|
||||
getCaseMedia: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
if (!caseStudy) throw new Error(`Missing case ${id}`);
|
||||
return caseStudy;
|
||||
}
|
||||
|
||||
function withDesktopCapture() {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
device === "desktop" ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
const TITLE = "Onde já apliquei isso";
|
||||
const NOTE = "Texto de experiência de teste.";
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockedGetCaseMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("ServiceRelatedCases", () => {
|
||||
it("path: no case and no experience note -> renders nothing", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("path: no case but an experience note -> keeps the block with the note and no case card", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={NOTE} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: TITLE }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(NOTE)).toBeVisible();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: cases and no experience note (main path) -> one card per case, no 'Na prática' paragraph", () => {
|
||||
withDesktopCapture();
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases
|
||||
cases={[sev]}
|
||||
relevance={{ [sev.id]: "Frase de relação do SEV." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: TITLE }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText("Na prática:")).toBeNull();
|
||||
const card = container.querySelector("article");
|
||||
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();
|
||||
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}` }),
|
||||
).toHaveAttribute("href", `/cases/${sev.id}`);
|
||||
expect(
|
||||
within(card).getByRole("link", { name: `Abrir o site: ${sev.title}` }),
|
||||
).toHaveAttribute("href", sev.demoUrl);
|
||||
});
|
||||
|
||||
it("path: cases and an experience note -> the note comes first, in text only, with no link", () => {
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[sev]}
|
||||
relevance={{ [sev.id]: "Frase de relação do SEV." }}
|
||||
experienceNote={NOTE}
|
||||
/>,
|
||||
);
|
||||
|
||||
const note = screen.getByText(NOTE, { exact: false });
|
||||
expect(screen.getByText("Na prática:")).toBeVisible();
|
||||
expect(note.closest("a")).toBeNull();
|
||||
expect(note.querySelector("a")).toBeNull();
|
||||
expect(
|
||||
note.compareDocumentPosition(
|
||||
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("path: case with a desktop capture -> cover link with the poster, hidden from assistive tech", () => {
|
||||
withDesktopCapture();
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
|
||||
);
|
||||
|
||||
const coverLink = container.querySelector(
|
||||
`article a[aria-hidden="true"][href="/cases/${sev.id}"]`,
|
||||
);
|
||||
expect(coverLink).not.toBeNull();
|
||||
expect(coverLink).toHaveAttribute("tabindex", "-1");
|
||||
expect(coverLink?.querySelector(".ds-bframe img")).toHaveAttribute(
|
||||
"alt",
|
||||
`Captura de tela do site ${sev.title}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: case without a desktop capture -> no cover link, text and buttons stay", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[sev]} relevance={{ [sev.id]: "Frase." }} />,
|
||||
);
|
||||
|
||||
expect(container.querySelector("a[aria-hidden='true']")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${sev.title}` }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: laboratory case -> kicker marks it as 'Projeto próprio'", () => {
|
||||
expect(isClientCase(twins)).toBe(false);
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[twins]}
|
||||
relevance={{ [twins.id]: "Frase." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(`${twins.title} · Projeto próprio`)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: no relevance sentence for the case -> falls back to the case summary", () => {
|
||||
render(<ServiceRelatedCases cases={[sev]} relevance={{}} />);
|
||||
|
||||
const card = screen.getByRole("heading", { level: 3 }).closest("article");
|
||||
expect(card).not.toBeNull();
|
||||
expect(within(card as HTMLElement).getByText(sev.summary)).toBeVisible();
|
||||
});
|
||||
|
||||
it("path: case without a public site -> no 'Abrir o site' link", () => {
|
||||
expect(asServicos.demoUrl).toBeNull();
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[asServicos]}
|
||||
relevance={{ [asServicos.id]: "Frase." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Ler case: ${asServicos.title}` }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { caseStudies } from "@/lib/site-content";
|
||||
import { ServiceRelatedCases } from "./ServiceRelatedCases";
|
||||
|
||||
const caseStudy = caseStudies[0];
|
||||
const note = "Desde 2022 trabalho com isso, em sistemas internos.";
|
||||
|
||||
it("renders nothing without cases and without an experience note", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} />,
|
||||
);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows only the experience note, without the 'open the case' sentence, when there are no cases", () => {
|
||||
const { container } = render(
|
||||
<ServiceRelatedCases cases={[]} relevance={{}} experienceNote={note} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Onde já apliquei isso" }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText(note)).toBeVisible();
|
||||
expect(screen.queryByText(/Abra o case/)).toBeNull();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(container.querySelector(".ds-grid-2")).toBeNull();
|
||||
});
|
||||
|
||||
it("invites to open the case and lists each case when there are some", () => {
|
||||
render(
|
||||
<ServiceRelatedCases
|
||||
cases={[caseStudy]}
|
||||
relevance={{ [caseStudy.id]: "Frase própria sobre a relação." }}
|
||||
/>,
|
||||
);
|
||||
|
||||
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}` }),
|
||||
).toHaveAttribute("href", `/cases/${caseStudy.id}`);
|
||||
});
|
||||
|
||||
it("falls back to the case summary when no relevance sentence is given", () => {
|
||||
render(<ServiceRelatedCases cases={[caseStudy]} relevance={{}} />);
|
||||
|
||||
expect(screen.getByText(caseStudy.summary)).toBeVisible();
|
||||
});
|
||||
@@ -1,16 +1,67 @@
|
||||
import Link from "next/link";
|
||||
import { CaseCover } from "@/app/cases/_components/CaseCover";
|
||||
import { getCaseMedia } from "@/app/cases/_components/case-media";
|
||||
import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink";
|
||||
import { secondaryButtonLight, textLink } from "@/app/components/button-styles";
|
||||
import { type CaseStudyContent, isClientCase } from "@/lib/site-content";
|
||||
|
||||
/** "Veja projetos parecidos": os cases ligados ao serviço. Sem case relacionado, não mostra nada. */
|
||||
function ServiceCaseCard({
|
||||
caseStudy,
|
||||
relevance,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
/** Por que este case interessa ao serviço; sem frase própria, vale o resumo do case. */
|
||||
relevance?: string;
|
||||
}) {
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<CaseCoverLink
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
/>
|
||||
<div className="ds-kicker">
|
||||
{isClientCase(caseStudy)
|
||||
? caseStudy.title
|
||||
: `${caseStudy.title} · Projeto próprio`}
|
||||
</div>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-kicker mt-sp-5">Relação com este serviço</p>
|
||||
<p className="ds-copy !mt-sp-2">{relevance ?? caseStudy.summary}</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<Link href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site: {caseStudy.title}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "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.
|
||||
*/
|
||||
export function ServiceRelatedCases({
|
||||
cases,
|
||||
relevance,
|
||||
experienceNote,
|
||||
}: {
|
||||
cases: readonly CaseStudyContent[];
|
||||
/** Por que cada case interessa a este serviço (chave: id do case). */
|
||||
relevance: Readonly<Record<string, string>>;
|
||||
experienceNote?: string;
|
||||
}) {
|
||||
if (cases.length === 0) return null;
|
||||
const hasCases = cases.length > 0;
|
||||
if (!hasCases && !experienceNote) return null;
|
||||
|
||||
return (
|
||||
<section
|
||||
@@ -23,61 +74,37 @@ export function ServiceRelatedCases({
|
||||
<div className="ds-section-meta">
|
||||
<p className="ds-eyebrow">Trabalhos parecidos</p>
|
||||
<h2 id="casos-relacionados-titulo" className="ds-section-title">
|
||||
Veja projetos parecidos, já prontos
|
||||
Onde já apliquei isso
|
||||
</h2>
|
||||
<p className="ds-section-description">
|
||||
Projetos reais nesta área. Abra o case para ver o que foi feito e
|
||||
como ficou.
|
||||
</p>
|
||||
{hasCases ? (
|
||||
<p className="ds-section-description">
|
||||
Cada projeto tem a ver com este serviço de um jeito. Abra o case
|
||||
para ver o que foi feito e como ficou.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ds-grid-2">
|
||||
{cases.map((caseStudy) => (
|
||||
<article key={caseStudy.id} className="ds-card">
|
||||
{getCaseMedia(caseStudy.id, "desktop") ? (
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
aria-hidden="true"
|
||||
tabIndex={-1}
|
||||
className="mb-sp-5 block"
|
||||
>
|
||||
<CaseCover
|
||||
id={caseStudy.id}
|
||||
title={caseStudy.title}
|
||||
demoUrl={caseStudy.demoUrl}
|
||||
sizes="(min-width: 1024px) 560px, 100vw"
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
<div className="ds-kicker">
|
||||
{isClientCase(caseStudy)
|
||||
? caseStudy.title
|
||||
: `${caseStudy.title} · Protótipo de laboratório`}
|
||||
</div>
|
||||
<h3 className="ds-card-title">{caseStudy.headline}</h3>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
<Link
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
Ler case: {caseStudy.title}
|
||||
</Link>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className={textLink}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site: {caseStudy.title}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
{experienceNote ? (
|
||||
<p className="ds-copy mb-sp-8 max-w-[72ch]">
|
||||
<strong className="font-semibold text-[var(--ink)]">
|
||||
Na prática:
|
||||
</strong>{" "}
|
||||
{experienceNote}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{hasCases ? (
|
||||
<div className="ds-grid-2">
|
||||
{cases.map((caseStudy) => (
|
||||
<ServiceCaseCard
|
||||
key={caseStudy.id}
|
||||
caseStudy={caseStudy}
|
||||
relevance={relevance[caseStudy.id]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user