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
@@ -7,7 +7,11 @@ import CasePage from "./page";
|
||||
* O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`).
|
||||
*/
|
||||
|
||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
|
||||
jest.mock("next/navigation", () => ({
|
||||
notFound: jest.fn(),
|
||||
usePathname: () => "/cases",
|
||||
}));
|
||||
|
||||
function getCase(id: string) {
|
||||
const caseStudy = caseStudies.find((item) => item.id === id);
|
||||
|
||||
@@ -10,10 +10,12 @@ import CasePage, { generateMetadata } from "./page";
|
||||
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
|
||||
*/
|
||||
|
||||
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
|
||||
jest.mock("next/navigation", () => ({
|
||||
notFound: jest.fn(() => {
|
||||
throw new Error("NEXT_NOT_FOUND");
|
||||
}),
|
||||
usePathname: () => "/cases",
|
||||
}));
|
||||
|
||||
jest.mock("../_components/case-media", () => {
|
||||
@@ -125,11 +127,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => {
|
||||
expect(screen.getByText(/Projeto de laboratório/)).toBeVisible();
|
||||
expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull();
|
||||
expect(screen.queryByText("Serviços ligados a este projeto")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", {
|
||||
name: "Tem um problema parecido? Vamos conversar.",
|
||||
}),
|
||||
).toBeVisible();
|
||||
// o NutriScan tem "Como ficou": o botão de contato vem depois dele e de novo no fecho, com o mesmo rótulo
|
||||
const contacts = screen.getAllByRole("link", {
|
||||
name: "Falar sobre meu projeto",
|
||||
});
|
||||
expect(contacts).toHaveLength(2);
|
||||
for (const contact of contacts) expect(contact).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Ver outros cases" }),
|
||||
).toHaveAttribute("href", "/cases");
|
||||
|
||||
+132
-44
@@ -1,9 +1,20 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content";
|
||||
import {
|
||||
buttonLabels,
|
||||
caseStudies,
|
||||
clientCasesForListing,
|
||||
getRelatedServices,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import CasesPage from "../page";
|
||||
import CasePage, { generateMetadata, generateStaticParams } from "./page";
|
||||
|
||||
jest.mock("next/navigation", () => ({ notFound: jest.fn() }));
|
||||
// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home
|
||||
jest.mock("next/navigation", () => ({
|
||||
notFound: jest.fn(),
|
||||
usePathname: () => "/cases",
|
||||
}));
|
||||
|
||||
const clientCaseIds = [
|
||||
"sev-modaintima",
|
||||
@@ -50,32 +61,33 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case.
|
||||
* Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma
|
||||
* frase nos dois, a do cabeçalho: "um projeto parecido com o {título}".
|
||||
* Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case, com o mesmo rótulo
|
||||
* ("Falar sobre meu projeto") no case de cliente e no de laboratório. A mensagem é a mesma frase nos dois, a do cabeçalho:
|
||||
* `similarProjectSubject(título)`. Com "Como ficou" na página, o botão aparece duas vezes: logo depois dele e no fecho.
|
||||
*/
|
||||
function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) {
|
||||
function expectContactAndNoDeadLinks(
|
||||
caseStudy: { id: string; title: string },
|
||||
expectedButtons = 1,
|
||||
) {
|
||||
const hrefs = screen
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.getAttribute("href") ?? "");
|
||||
expect(hrefs).not.toContain("/#contact");
|
||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||
|
||||
const isClient = clientCaseIds.includes(caseStudy.id);
|
||||
const cta = screen.getByRole("link", {
|
||||
name: isClient
|
||||
? "Conversar sobre um projeto parecido"
|
||||
: "Tem um problema parecido? Vamos conversar.",
|
||||
});
|
||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||
expect(destination.origin).toBe("https://wa.me");
|
||||
expect(destination.searchParams.get("text")).toContain(
|
||||
`um projeto parecido com o ${caseStudy.title}`,
|
||||
);
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||
// limão preenchido só no contato: o botão de play e os demais não o usam
|
||||
expect(cta.className).toContain("bg-[var(--slash)]");
|
||||
const ctas = screen.getAllByRole("link", { name: buttonLabels.contact });
|
||||
expect(ctas).toHaveLength(expectedButtons);
|
||||
for (const cta of ctas) {
|
||||
const destination = new URL(cta.getAttribute("href") ?? "");
|
||||
expect(destination.origin).toBe("https://wa.me");
|
||||
expect(destination.searchParams.get("text")).toContain(
|
||||
similarProjectSubject(caseStudy.title),
|
||||
);
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener"));
|
||||
// limão preenchido só no contato: o botão de play e os demais não o usam
|
||||
expect(cta.className).toContain("bg-[var(--slash)]");
|
||||
}
|
||||
}
|
||||
|
||||
it("exposes the eight public cases, the two new client cases among them", async () => {
|
||||
@@ -118,6 +130,7 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
"O problema",
|
||||
"O que foi feito",
|
||||
"Como ficou",
|
||||
"O que o cliente passou a poder fazer",
|
||||
"Quer um projeto parecido com este?",
|
||||
];
|
||||
const sectionHeadings = sectionNames.map((name) =>
|
||||
@@ -158,7 +171,27 @@ it("renders a client case in the sales order, with media, contact and collapsed
|
||||
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
||||
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||
|
||||
expectContactAndNoDeadLinks(caseStudy);
|
||||
// o botão de contato se repete logo depois de "Como ficou", dentro da seção das capacidades
|
||||
expectContactAndNoDeadLinks(caseStudy, 2);
|
||||
const capabilitiesSection = sectionHeadings[4].closest("section");
|
||||
expect(capabilitiesSection).toContainElement(
|
||||
within(capabilitiesSection as HTMLElement).getByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
}),
|
||||
);
|
||||
for (const capability of caseStudy.capabilities ?? []) {
|
||||
expect(
|
||||
within(capabilitiesSection as HTMLElement).getByText(capability),
|
||||
).toBeVisible();
|
||||
}
|
||||
expect(
|
||||
isBefore(
|
||||
sectionHeadings[3],
|
||||
within(capabilitiesSection as HTMLElement).getByRole("link", {
|
||||
name: buttonLabels.contact,
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte
|
||||
const details = container.querySelector("details");
|
||||
@@ -260,7 +293,8 @@ it("renders the two new client cases from the portfolio data", async () => {
|
||||
expect(
|
||||
screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }),
|
||||
).toHaveAttribute("href", caseStudy.demoUrl);
|
||||
expectContactAndNoDeadLinks(caseStudy);
|
||||
// com "Como ficou" na página, o contato aparece logo depois dele e de novo no fecho
|
||||
expectContactAndNoDeadLinks(caseStudy, 2);
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
@@ -289,24 +323,71 @@ it("labels a laboratory case, skips the media block and shows its service as a c
|
||||
expectContactAndNoDeadLinks(caseStudy);
|
||||
});
|
||||
|
||||
it("turns the contact of a laboratory case into the 'problema parecido' invitation", async () => {
|
||||
it("turns the contact of a laboratory case into the 'Fale sobre o seu caso' invitation, with the same button label as a client case", async () => {
|
||||
for (const id of labCaseIds) {
|
||||
const { unmount } = await renderCase(id);
|
||||
|
||||
expect(screen.queryByText("Quer um projeto parecido com este?")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
name: /Conversar sobre um projeto parecido|Tem um problema parecido/,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||
).toBeVisible();
|
||||
expectContactAndNoDeadLinks(getCase(id));
|
||||
// a repetição do botão só vem junto de "Como ficou"
|
||||
const hasMedia = screen.queryByRole("heading", { name: "Como ficou" });
|
||||
expectContactAndNoDeadLinks(getCase(id), hasMedia ? 2 : 1);
|
||||
// o laboratório não tem "o que o cliente passou a poder fazer"
|
||||
expect(
|
||||
screen.queryByRole("heading", {
|
||||
name: "O que o cliente passou a poder fazer",
|
||||
}),
|
||||
).toBeNull();
|
||||
unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it("writes what the client can do now right after 'Como ficou', then repeats the contact button, for every client case", async () => {
|
||||
for (const id of clientCaseIds) {
|
||||
const caseStudy = getCase(id);
|
||||
const { unmount } = await renderCase(id);
|
||||
|
||||
const media = screen.getByRole("heading", { level: 2, name: "Como ficou" });
|
||||
const capabilities = screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que o cliente passou a poder fazer",
|
||||
});
|
||||
// a seção vem logo depois da mídia, sem outra seção no meio
|
||||
const mediaSection = media.closest("section");
|
||||
const capabilitiesSection = capabilities.closest("section");
|
||||
expect(mediaSection?.nextElementSibling).toBe(capabilitiesSection);
|
||||
const items = within(capabilitiesSection as HTMLElement)
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => item.textContent);
|
||||
expect(items).toEqual(caseStudy.capabilities);
|
||||
expect(items.length).toBeGreaterThanOrEqual(2);
|
||||
expect(items.length).toBeLessThanOrEqual(3);
|
||||
// sem número inventado
|
||||
expect(items.join(" ")).not.toMatch(/\d/);
|
||||
const button = within(capabilitiesSection as HTMLElement).getByRole(
|
||||
"link",
|
||||
{ name: buttonLabels.contact },
|
||||
);
|
||||
expect(isBefore(media, button)).toBe(true);
|
||||
unmount();
|
||||
}
|
||||
|
||||
// a frase do dono da Lavação 047 vem dos dados do projeto
|
||||
await renderCase("lavacao-047");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"O dono muda preços, textos, fotos e o mapa sem pedir a ninguém, e a mudança aparece no site sem nova publicação.",
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows the author's role and no site button for a client project without a public site", async () => {
|
||||
const caseStudy = getCase("as-servicos");
|
||||
expect(caseStudy.demoUrl).toBeNull();
|
||||
@@ -361,8 +442,8 @@ it("shows the result section only when the case has real data", async () => {
|
||||
});
|
||||
|
||||
it("shows at most two related services, as cards under the contact button", async () => {
|
||||
// sev-modaintima tem quatro serviços ligados; só os dois primeiros viram cards
|
||||
const sev = getCase("sev-modaintima");
|
||||
// lavacao-047 tem três serviços ligados; só os dois primeiros viram cards
|
||||
const sev = getCase("lavacao-047");
|
||||
const all = getRelatedServices(sev.id);
|
||||
expect(all.length).toBeGreaterThan(2);
|
||||
const { unmount } = await renderCase(sev.id);
|
||||
@@ -382,16 +463,16 @@ it("shows at most two related services, as cards under the contact button", asyn
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }),
|
||||
).toBeNull();
|
||||
const contact = screen.getByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
});
|
||||
expect(isBefore(contact, cards[0])).toBe(true);
|
||||
// o botão do fecho (o último) vem antes dos cartões
|
||||
const contacts = screen.getAllByRole("link", { name: buttonLabels.contact });
|
||||
expect(isBefore(contacts[contacts.length - 1], cards[0])).toBe(true);
|
||||
unmount();
|
||||
|
||||
// sc-plus tem dois; as-servicos, um
|
||||
// sc-plus tem dois; a SEV e a AS Serviços, um cada (a SEV só aparece na página de sistemas)
|
||||
for (const [id, expected] of [
|
||||
["sc-plus", 2],
|
||||
["as-servicos", 1],
|
||||
["sev-modaintima", 1],
|
||||
] as const) {
|
||||
const { unmount: unmountCase } = await renderCase(id);
|
||||
const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ });
|
||||
@@ -401,7 +482,7 @@ it("shows at most two related services, as cards under the contact button", asyn
|
||||
.slice(0, 2)
|
||||
.map(({ slug }) => `/servicos/${slug}`),
|
||||
);
|
||||
expectContactAndNoDeadLinks(getCase(id));
|
||||
expectContactAndNoDeadLinks(getCase(id), 2);
|
||||
unmountCase();
|
||||
}
|
||||
});
|
||||
@@ -554,9 +635,9 @@ it("gives the case page the same shell as the home, with no narrow max-width", a
|
||||
it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
const heading = screen.getByRole("heading", { level: 1 });
|
||||
expect(heading.nextElementSibling).toHaveTextContent(
|
||||
/foi feito para empresas/i,
|
||||
);
|
||||
expect(heading.nextElementSibling).toHaveTextContent(/Abra um case/);
|
||||
// o título e o subtítulo não repetem "Veja o que já"
|
||||
expect(heading.nextElementSibling).not.toHaveTextContent(/Veja o que já/);
|
||||
expect(container.textContent).not.toMatch(/Selected work/i);
|
||||
|
||||
const clientHeading = screen.getByRole("heading", {
|
||||
@@ -571,8 +652,11 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
||||
expect(isBefore(clientHeading, labHeading)).toBe(true);
|
||||
expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible();
|
||||
|
||||
// cases de cliente: capa (pôster) em moldura de navegador e botão do case em contorno
|
||||
const clientLinks = clientCaseIds.map((id) => {
|
||||
// cases de cliente, com a SEV por último: capa (pôster) em moldura de navegador e botão "Ver case" em contorno
|
||||
const listedIds = clientCasesForListing.map(({ id }) => id);
|
||||
expect([...listedIds].sort()).toEqual([...clientCaseIds].sort());
|
||||
expect(listedIds[listedIds.length - 1]).toBe("sev-modaintima");
|
||||
const clientLinks = listedIds.map((id) => {
|
||||
const card = container
|
||||
.querySelector(`a[href="/cases/${id}"]`)
|
||||
?.closest("article");
|
||||
@@ -583,14 +667,18 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
||||
"alt",
|
||||
`Captura de tela do site ${getCase(id).title}`,
|
||||
);
|
||||
return within(card).getByRole("link", { name: "Ver o case" });
|
||||
return within(card).getByRole("link", {
|
||||
name: `${buttonLabels.case} ${getCase(id).title}`,
|
||||
});
|
||||
});
|
||||
expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||
clientCaseIds.map((id) => `/cases/${id}`),
|
||||
listedIds.map((id) => `/cases/${id}`),
|
||||
);
|
||||
|
||||
// laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão
|
||||
const labLinks = screen.getAllByRole("link", { name: /^Ver o projeto / });
|
||||
const labLinks = within(
|
||||
labHeading.closest("section") as HTMLElement,
|
||||
).getAllByRole("link", { name: /^Ver case / });
|
||||
expect(labLinks.map((link) => link.getAttribute("href"))).toEqual(
|
||||
labCaseIds.map((id) => `/cases/${id}`),
|
||||
);
|
||||
@@ -603,7 +691,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
||||
const row = link.closest("li");
|
||||
const caseStudy = getCase(labCaseIds[index]);
|
||||
expect(isBefore(labHeading, link)).toBe(true);
|
||||
expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`);
|
||||
expect(link).toHaveAccessibleName(`Ver case ${caseStudy.title}`);
|
||||
expect(
|
||||
within(row as HTMLElement).getByRole("heading", {
|
||||
level: 3,
|
||||
@@ -627,7 +715,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc
|
||||
expect(hrefs).not.toContain("/#contact");
|
||||
expect(hrefs.some((href) => /github/i.test(href))).toBe(false);
|
||||
expect(hrefs).toContain("/servicos");
|
||||
const cta = screen.getByRole("link", { name: "Conversar sobre meu projeto" });
|
||||
const cta = screen.getByRole("link", { name: buttonLabels.contact });
|
||||
expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me");
|
||||
|
||||
const visibleBreadcrumb = screen.getByRole("navigation", {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildPageSocialMetadata,
|
||||
} from "@/lib/seo";
|
||||
import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content";
|
||||
import { CaseCapabilitiesSection } from "../_components/CaseCapabilitiesSection";
|
||||
import { CaseContactSection } from "../_components/CaseContactSection";
|
||||
import { CaseHero } from "../_components/CaseHero";
|
||||
import { CaseMediaSection } from "../_components/CaseMediaSection";
|
||||
@@ -136,6 +137,8 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
|
||||
<CaseMediaSection caseStudy={caseStudy} />
|
||||
|
||||
<CaseCapabilitiesSection caseStudy={caseStudy} isClient={isClient} />
|
||||
|
||||
<CaseResultSection result={caseStudy.result} />
|
||||
|
||||
<CaseContactSection caseStudy={caseStudy} isClient={isClient} />
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { buttonLabels } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { CaseCapabilitiesSection } from "./CaseCapabilitiesSection";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de CaseCapabilitiesSection: com itens e "Como ficou" (lista e botão), só com "Como ficou"
|
||||
* (só o botão), só com itens (só a lista, sem repetir o botão que o fecho da página já traz) e sem nada (não aparece).
|
||||
* A existência das capturas em disco é simulada em getCaseMedia.
|
||||
*/
|
||||
|
||||
jest.mock("next/navigation", () => ({ usePathname: () => "/cases" }));
|
||||
jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() }));
|
||||
|
||||
const mockedGetCaseMedia = jest.mocked(getCaseMedia);
|
||||
|
||||
const capabilities = [
|
||||
"O dono muda os preços sem pedir a ninguém.",
|
||||
"O cliente pede pelo WhatsApp com a mensagem pronta.",
|
||||
];
|
||||
const withSite = {
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: "https://exemplo.com.br",
|
||||
};
|
||||
|
||||
function messageOf(link: HTMLElement) {
|
||||
return new URL(link.getAttribute("href") ?? "").searchParams.get("text");
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedGetCaseMedia.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("CaseCapabilitiesSection", () => {
|
||||
it("path: items and a site on the air -> the list under the client title, then the contact button", () => {
|
||||
render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{ ...withSite, capabilities }}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que o cliente passou a poder fazer",
|
||||
}),
|
||||
).toBeVisible();
|
||||
for (const item of capabilities) {
|
||||
expect(screen.getByText(item)).toBeVisible();
|
||||
}
|
||||
const button = screen.getByRole("link", { name: buttonLabels.contact });
|
||||
expect(button).toHaveAttribute("target", "_blank");
|
||||
expect(button.className).toContain("bg-[var(--slash)]");
|
||||
expect(messageOf(button)).toContain(
|
||||
`Quero conversar sobre ${similarProjectSubject(withSite.title)}.`,
|
||||
);
|
||||
// o botão vem depois da lista
|
||||
expect(
|
||||
screen.getByText(capabilities[1]).compareDocumentPosition(button) &
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("path: a laboratory case with items -> the title does not talk about a client", () => {
|
||||
render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{ ...withSite, capabilities }}
|
||||
isClient={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 2,
|
||||
name: "O que dá para fazer com ele",
|
||||
}),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText(/o cliente passou a poder fazer/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no items but a capture on disk (no site) -> only the contact button, under a heading that only screen readers get", () => {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
id === "projeto-teste" ? getShowcaseAssets(id, device) : undefined,
|
||||
);
|
||||
const { container } = render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: null,
|
||||
}}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("ul")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: buttonLabels.contact }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole("heading", { level: 2 })).toHaveClass("sr-only");
|
||||
});
|
||||
|
||||
it("path: items but no 'Como ficou' -> the list stays and the button is not repeated", () => {
|
||||
render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: null,
|
||||
capabilities,
|
||||
}}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(capabilities[0])).toBeVisible();
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no items and no 'Como ficou' -> renders nothing", () => {
|
||||
const { container } = render(
|
||||
<CaseCapabilitiesSection
|
||||
caseStudy={{
|
||||
id: "projeto-teste",
|
||||
title: "Projeto Teste",
|
||||
demoUrl: null,
|
||||
}}
|
||||
isClient
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { primaryButton } from "@/app/components/button-styles";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { hasCaseMedia } from "./CaseMediaSection";
|
||||
|
||||
/**
|
||||
* Logo depois de "Como ficou": o que o cliente passou a poder fazer (2 ou 3 itens, sem número, só com o que as entregas já sustentam)
|
||||
* e, de novo, o botão de contato, para quem acabou de ver o resultado não precisar rolar até o fim da página.
|
||||
* Sem itens, só o botão (e só quando há "Como ficou"); sem itens e sem mídia, a seção não aparece.
|
||||
*/
|
||||
export function CaseCapabilitiesSection({
|
||||
caseStudy,
|
||||
isClient,
|
||||
}: {
|
||||
caseStudy: Pick<
|
||||
CaseStudyContent,
|
||||
"id" | "title" | "demoUrl" | "capabilities"
|
||||
>;
|
||||
isClient: boolean;
|
||||
}) {
|
||||
const capabilities = caseStudy.capabilities ?? [];
|
||||
const showContact = hasCaseMedia(caseStudy);
|
||||
if (capabilities.length === 0 && !showContact) return null;
|
||||
|
||||
return (
|
||||
<section className="ds-section" aria-labelledby="case-capabilities">
|
||||
<div className="ds-section-shell">
|
||||
{capabilities.length > 0 ? (
|
||||
<>
|
||||
<h2 id="case-capabilities" className="ds-card-title !mt-0">
|
||||
{isClient
|
||||
? "O que o cliente passou a poder fazer"
|
||||
: "O que dá para fazer com ele"}
|
||||
</h2>
|
||||
<ul className="ds-list max-w-[78ch] space-y-sp-3 pl-[1.15rem]">
|
||||
{capabilities.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<h2 id="case-capabilities" className="sr-only">
|
||||
Fale sobre o seu projeto
|
||||
</h2>
|
||||
)}
|
||||
{showContact ? (
|
||||
<div className="mt-sp-8">
|
||||
<WhatsAppLink
|
||||
service={similarProjectSubject(caseStudy.title)}
|
||||
placement="case_after_media"
|
||||
className={primaryButton}
|
||||
>
|
||||
{buttonLabels.contact}
|
||||
</WhatsAppLink>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getRelatedServices } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { CaseContactSection } from "./CaseContactSection";
|
||||
|
||||
/**
|
||||
@@ -25,12 +26,11 @@ describe("CaseContactSection", () => {
|
||||
name: "Quer um projeto parecido com este?",
|
||||
}),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
});
|
||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||
expect(cta).toHaveAttribute("target", "_blank");
|
||||
// sem escrever a frase à mão: ela vem de `similarProjectSubject`, que a frente de contato pode reescrever
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre um projeto parecido com o ${sev.title}.`,
|
||||
`Quero conversar sobre ${similarProjectSubject(sev.title)}.`,
|
||||
);
|
||||
expect(getRelatedServices(sev.id).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
@@ -47,17 +47,16 @@ describe("CaseContactSection", () => {
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }),
|
||||
).toBeVisible();
|
||||
const cta = screen.getByRole("link", {
|
||||
name: "Tem um problema parecido? Vamos conversar.",
|
||||
});
|
||||
// a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome
|
||||
// o rótulo do botão é o mesmo do case de cliente: uma lista só
|
||||
const cta = screen.getByRole("link", { name: "Falar sobre meu projeto" });
|
||||
// a mensagem usa a mesma frase do cabeçalho (`similarProjectSubject`), sem "da" nem "do projeto" na frente do nome
|
||||
expect(messageOf(cta)).toContain(
|
||||
`Quero conversar sobre um projeto parecido com o ${nutri.title}.`,
|
||||
`Quero conversar sobre ${similarProjectSubject(nutri.title)}.`,
|
||||
);
|
||||
expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /);
|
||||
expect(
|
||||
screen.queryByRole("link", {
|
||||
name: "Conversar sobre um projeto parecido",
|
||||
name: /Tem um problema parecido|Conversar sobre um projeto parecido/,
|
||||
}),
|
||||
).toBeNull();
|
||||
// o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua
|
||||
|
||||
@@ -4,23 +4,18 @@ import {
|
||||
} from "@/app/components/button-styles";
|
||||
import HoverPrefetchLink from "@/app/components/HoverPrefetchLink";
|
||||
import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
import { similarProjectSubject } from "@/lib/whatsapp";
|
||||
import { CaseRelatedServices } from "./CaseRelatedServices";
|
||||
|
||||
/**
|
||||
* Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido.
|
||||
* O assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`.
|
||||
* Títulos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, do caso de quem lê.
|
||||
* O botão tem o mesmo rótulo nos dois (`buttonLabels.contact`), e o assunto da conversa de WhatsApp também
|
||||
* (e no botão do cabeçalho): `similarProjectSubject`.
|
||||
*/
|
||||
const contactCopy = {
|
||||
client: {
|
||||
title: "Quer um projeto parecido com este?",
|
||||
action: "Conversar sobre um projeto parecido",
|
||||
},
|
||||
lab: {
|
||||
title: "Fale sobre o seu caso",
|
||||
action: "Tem um problema parecido? Vamos conversar.",
|
||||
},
|
||||
client: { title: "Quer um projeto parecido com este?" },
|
||||
lab: { title: "Fale sobre o seu caso" },
|
||||
} as const;
|
||||
|
||||
/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */
|
||||
@@ -43,7 +38,7 @@ export function CaseContactSection({
|
||||
{copy.title}
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[60ch]">
|
||||
Conte o que você precisa. A conversa começa direto no WhatsApp, sem
|
||||
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
|
||||
formulário.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
@@ -52,7 +47,7 @@ export function CaseContactSection({
|
||||
placement="case_detail"
|
||||
className={primaryButton}
|
||||
>
|
||||
{copy.action}
|
||||
{buttonLabels.contact}
|
||||
</WhatsAppLink>
|
||||
<HoverPrefetchLink href="/cases" className={secondaryButtonDark}>
|
||||
Ver outros cases
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { secondaryButtonDark } from "@/app/components/button-styles";
|
||||
import type { CaseStudyContent } from "@/lib/site-content";
|
||||
import { buttonLabels, type CaseStudyContent } from "@/lib/site-content";
|
||||
import { CaseMedia } from "./CaseMedia";
|
||||
import { getCaseMedia } from "./case-media";
|
||||
|
||||
@@ -45,7 +45,8 @@ export function CaseMediaSection({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site {caseStudy.title}
|
||||
{buttonLabels.site}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { clientCases } from "@/lib/site-content";
|
||||
import { clientCasesForListing as clientCases } from "@/lib/site-content";
|
||||
import CasesPage from "./page";
|
||||
|
||||
/**
|
||||
* Carregamento da lista de cases: o primeiro pôster do mosaico do topo (a maior imagem acima da dobra) carrega na hora
|
||||
* e com prioridade alta, as capas dos cartões são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
|
||||
* Carregamento da lista de cases: sem mosaico no topo, a capa do primeiro cartão (a maior imagem acima da dobra) carrega
|
||||
* na hora e com prioridade alta, as outras capas são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos.
|
||||
*/
|
||||
|
||||
type LinkProps = ComponentProps<typeof import("next/link").default>;
|
||||
@@ -40,26 +40,20 @@ function coverImages(container: HTMLElement) {
|
||||
}
|
||||
|
||||
describe("CasesPage: carregamento das capas", () => {
|
||||
it("loads the first mosaic poster at once with high priority, and every other image lazily", () => {
|
||||
it("loads the first card cover at once with high priority, and every other image lazily", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const mosaic = Array.from(
|
||||
container.querySelectorAll<HTMLImageElement>("section [aria-hidden] img"),
|
||||
// sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página
|
||||
const decorative = Array.from(
|
||||
container.querySelectorAll<HTMLImageElement>("img"),
|
||||
).filter((image) => image.getAttribute("alt") === "");
|
||||
const [main, ...side] = mosaic;
|
||||
expect(main).toHaveAttribute("loading", "eager");
|
||||
expect(main).toHaveAttribute("fetchpriority", "high");
|
||||
// a largura real do pôster, e não 100vw (que baixava 750 px para 330 px)
|
||||
expect(main.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/);
|
||||
expect(side.length).toBeGreaterThan(0);
|
||||
for (const image of side) {
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
expect(image).not.toHaveAttribute("fetchpriority");
|
||||
}
|
||||
expect(decorative).toHaveLength(0);
|
||||
|
||||
const covers = coverImages(container);
|
||||
expect(covers.length).toBeGreaterThan(0);
|
||||
for (const image of covers) {
|
||||
const [first, ...others] = coverImages(container);
|
||||
expect(first).toHaveAttribute("loading", "eager");
|
||||
expect(first).toHaveAttribute("fetchpriority", "high");
|
||||
expect(others.length).toBeGreaterThan(0);
|
||||
for (const image of others) {
|
||||
expect(image).toHaveAttribute("loading", "lazy");
|
||||
expect(image).not.toHaveAttribute("fetchpriority");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import { clientCases } from "@/lib/site-content";
|
||||
import {
|
||||
buttonLabels,
|
||||
clientCasesForListing,
|
||||
labCases,
|
||||
} from "@/lib/site-content";
|
||||
import { getCaseMedia } from "./_components/case-media";
|
||||
import CasesPage from "./page";
|
||||
|
||||
@@ -32,7 +36,7 @@ function clientCard(container: HTMLElement, id: string) {
|
||||
|
||||
describe("CasesPage: cases de cliente", () => {
|
||||
it("path: client case without a desktop capture -> card keeps text and links but no cover", () => {
|
||||
const [withoutCapture, withCapture] = clientCases;
|
||||
const [withoutCapture, withCapture] = clientCasesForListing;
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device),
|
||||
);
|
||||
@@ -42,14 +46,12 @@ describe("CasesPage: cases de cliente", () => {
|
||||
const bare = clientCard(container, withoutCapture.id);
|
||||
expect(bare.querySelector(".ds-bframe")).toBeNull();
|
||||
expect(bare.querySelector("img")).toBeNull();
|
||||
expect(
|
||||
within(bare).queryByRole("link", {
|
||||
name: `Ver o case ${withoutCapture.title}`,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(bare.querySelector("a[aria-hidden='true']")).toBeNull();
|
||||
expect(within(bare).getByText(withoutCapture.headline)).toBeVisible();
|
||||
expect(
|
||||
within(bare).getByRole("link", { name: "Ver o case" }),
|
||||
within(bare).getByRole("link", {
|
||||
name: `${buttonLabels.case} ${withoutCapture.title}`,
|
||||
}),
|
||||
).toHaveAttribute("href", `/cases/${withoutCapture.id}`);
|
||||
|
||||
// os outros cases de cliente continuam com a capa
|
||||
@@ -58,55 +60,88 @@ describe("CasesPage: cases de cliente", () => {
|
||||
"alt",
|
||||
`Captura de tela do site ${withCapture.title}`,
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: "Ver o case" })).toHaveLength(
|
||||
clientCases.length,
|
||||
});
|
||||
|
||||
it("path: every case -> one 'Ver case' button, named after its project, and no 'Ver o case' or 'Ler case'", () => {
|
||||
render(<CasesPage />);
|
||||
|
||||
const buttons = screen.getAllByRole("link", { name: /^Ver case / });
|
||||
expect(buttons).toHaveLength(
|
||||
clientCasesForListing.length + labCases.length,
|
||||
);
|
||||
for (const link of buttons) {
|
||||
expect(link.textContent?.startsWith(buttonLabels.case)).toBe(true);
|
||||
}
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /Ver o case|Ler case|Ver o projeto/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("path: contact -> 'Falar sobre meu projeto', and the list of clients opens with Lavação 047 and ends with the SEV", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: buttonLabels.contact }),
|
||||
).toHaveAttribute("href", expect.stringContaining("https://wa.me/"));
|
||||
const clientTitles = Array.from(
|
||||
container.querySelectorAll("#cases-clientes ~ div article .ds-kicker"),
|
||||
).map((kicker) => kicker.textContent);
|
||||
expect(clientTitles).toEqual(
|
||||
clientCasesForListing.map(({ title }) => title),
|
||||
);
|
||||
expect(clientTitles[0]).toBe("Lavação 047");
|
||||
expect(clientTitles[clientTitles.length - 1]).toBe("SEV Moda Íntima");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CasesPage: topo com mosaico de pôsteres", () => {
|
||||
it("path: captures found -> two columns with three posters of the other clients, not the first card's", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid");
|
||||
expect(hero?.className).not.toContain("md:!pb-sp-12");
|
||||
const posters = Array.from(hero?.querySelectorAll("img") ?? []).map(
|
||||
(image) => decodeURIComponent(image.getAttribute("src") ?? ""),
|
||||
);
|
||||
expect(posters).toHaveLength(3);
|
||||
expect(posters.join(" ")).not.toContain(clientCases[0].id);
|
||||
for (const [index, { id }] of clientCases.slice(1).entries()) {
|
||||
expect(posters[index]).toContain(`/videos/${id}/desktop.webp`);
|
||||
}
|
||||
});
|
||||
|
||||
it("path: no capture for the other clients -> no mosaic, a lower one-column strip, and the first card cover takes the priority", () => {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
id === clientCases[0].id ? actualGetCaseMedia(id, device) : undefined,
|
||||
);
|
||||
|
||||
describe("CasesPage: topo sem mosaico", () => {
|
||||
it("path: captures found -> no poster mosaic (the cards below already carry the covers), a lower one-column strip, no 'Veja o que já' twice", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector("img")).toBeNull();
|
||||
expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid");
|
||||
expect(hero?.className).toContain("md:!pb-sp-12");
|
||||
const firstCover = clientCard(container, clientCases[0].id).querySelector(
|
||||
".ds-bframe img",
|
||||
// o título e o subtítulo não repetem a mesma frase
|
||||
const heading = within(hero as HTMLElement).getByRole("heading", {
|
||||
level: 1,
|
||||
});
|
||||
expect(heading).toHaveTextContent("Veja o que já fiz para empresas.");
|
||||
expect(hero?.textContent?.match(/Veja o que já/g)).toHaveLength(1);
|
||||
expect(hero?.textContent).not.toMatch(/\bdireto\b/);
|
||||
});
|
||||
|
||||
it("path: no capture for the other clients -> still no mosaic, and the first card cover takes the priority", () => {
|
||||
mockedGetCaseMedia.mockImplementation((id, device) =>
|
||||
id === clientCasesForListing[0].id
|
||||
? actualGetCaseMedia(id, device)
|
||||
: undefined,
|
||||
);
|
||||
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector("img")).toBeNull();
|
||||
const firstCover = clientCard(
|
||||
container,
|
||||
clientCasesForListing[0].id,
|
||||
).querySelector(".ds-bframe img");
|
||||
expect(firstCover).toHaveAttribute("loading", "eager");
|
||||
expect(firstCover).toHaveAttribute("fetchpriority", "high");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CasesPage: prioridade da capa do primeiro cartão", () => {
|
||||
it("path: mosaic at the top -> the first card cover stays lazy, the priority is the mosaic's first poster", () => {
|
||||
it("path: no mosaic -> the first card cover is the page's biggest image, so it loads at once with high priority; the others stay lazy", () => {
|
||||
const { container } = render(<CasesPage />);
|
||||
|
||||
const hero = container.querySelector("section");
|
||||
expect(hero?.querySelector("img")).toHaveAttribute("loading", "eager");
|
||||
for (const { id } of clientCases) {
|
||||
const [first, ...others] = clientCasesForListing;
|
||||
const firstCover = clientCard(container, first.id).querySelector(
|
||||
".ds-bframe img",
|
||||
);
|
||||
expect(firstCover).toHaveAttribute("loading", "eager");
|
||||
expect(firstCover).toHaveAttribute("fetchpriority", "high");
|
||||
for (const { id } of others) {
|
||||
const cover = clientCard(container, id).querySelector(".ds-bframe img");
|
||||
expect(cover).toHaveAttribute("loading", "lazy");
|
||||
expect(cover).not.toHaveAttribute("fetchpriority");
|
||||
|
||||
+24
-25
@@ -12,21 +12,24 @@ import WhatsAppLink from "@/app/components/WhatsAppLink";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo";
|
||||
import {
|
||||
buttonLabels,
|
||||
type CaseStudyContent,
|
||||
clientCases,
|
||||
clientCasesForListing,
|
||||
labCases,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import { CASES_SUBJECT } from "@/lib/whatsapp";
|
||||
import { CaseCoverLink } from "./_components/CaseCoverLink";
|
||||
import { compactHero, heroWithMediaGrid } from "./_components/hero-styles";
|
||||
import { getMosaicPosters, PosterMosaic } from "./_components/PosterMosaic";
|
||||
import { compactHero } from "./_components/hero-styles";
|
||||
|
||||
const pageUrl = `${siteUrl}/cases`;
|
||||
const pageTitle = "Cases: sites e sistemas para empresas";
|
||||
const pageDescription =
|
||||
"Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
|
||||
const allCases = [...clientCases, ...labCases];
|
||||
"Cases de sites e sistemas para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono e páginas institucionais.";
|
||||
/** Abertura da página: o que o visitante encontra nos cases, sem repetir a frase do título. */
|
||||
const pageLead =
|
||||
"Abra um case para ver o problema do cliente, o que foi feito e como o site ficou, da loja virtual ao site que o dono edita sozinho.";
|
||||
const allCases = [...clientCasesForListing, ...labCases];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: pageTitle,
|
||||
@@ -45,7 +48,7 @@ function ClientCaseCard({
|
||||
priority = false,
|
||||
}: {
|
||||
caseStudy: CaseStudyContent;
|
||||
/** Capa que abre a página: só quando não há mosaico no topo, a capa passa a ser a maior imagem visível. */
|
||||
/** Capa que abre a página: sem mosaico no topo, a primeira capa é a maior imagem visível. */
|
||||
priority?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -64,7 +67,8 @@ function ClientCaseCard({
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={secondaryButtonLight}
|
||||
>
|
||||
Ver o case
|
||||
{buttonLabels.case}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</HoverPrefetchLink>
|
||||
{caseStudy.demoUrl ? (
|
||||
<a
|
||||
@@ -73,7 +77,8 @@ function ClientCaseCard({
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir o site
|
||||
{buttonLabels.site}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -97,16 +102,14 @@ function LabCaseRow({ caseStudy }: { caseStudy: CaseStudyContent }) {
|
||||
href={`/cases/${caseStudy.id}`}
|
||||
className={`${textLink} col-span-2 justify-self-start md:order-4 md:col-span-1`}
|
||||
>
|
||||
Ver o projeto<span className="sr-only"> {caseStudy.title}</span>
|
||||
{buttonLabels.case}
|
||||
<span className="sr-only"> {caseStudy.title}</span>
|
||||
</HoverPrefetchLink>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CasesPage() {
|
||||
// o mosaico mostra os outros clientes: o primeiro já aparece, com a capa, no primeiro cartão logo abaixo
|
||||
const posters = getMosaicPosters(clientCases.slice(1).map(({ id }) => id));
|
||||
const hasMosaic = posters.length > 0;
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
@@ -137,14 +140,11 @@ export default function CasesPage() {
|
||||
<main>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
{/* sem mosaico de pôsteres: os cartões logo abaixo já trazem a capa de cada cliente, e a mesma imagem não aparece duas vezes */}
|
||||
<section
|
||||
className={`ds-section ds-section-dark ${pageTop} ${
|
||||
hasMosaic ? "" : compactHero
|
||||
}`}
|
||||
className={`ds-section ds-section-dark ${pageTop} ${compactHero}`}
|
||||
>
|
||||
<div
|
||||
className={`ds-section-shell ${hasMosaic ? heroWithMediaGrid : ""}`}
|
||||
>
|
||||
<div className="ds-section-shell">
|
||||
<div className="max-w-[860px]">
|
||||
<Breadcrumb
|
||||
items={[
|
||||
@@ -157,10 +157,9 @@ export default function CasesPage() {
|
||||
Veja o que já fiz para empresas.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
{pageDescription}
|
||||
{pageLead}
|
||||
</p>
|
||||
</div>
|
||||
<PosterMosaic posters={posters} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -170,11 +169,11 @@ export default function CasesPage() {
|
||||
Projetos de clientes
|
||||
</h2>
|
||||
<div className="ds-grid-2 mt-sp-8 items-start">
|
||||
{clientCases.map((caseStudy, index) => (
|
||||
{clientCasesForListing.map((caseStudy, index) => (
|
||||
<ClientCaseCard
|
||||
key={caseStudy.id}
|
||||
caseStudy={caseStudy}
|
||||
priority={index === 0 && !hasMosaic}
|
||||
priority={index === 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -203,7 +202,7 @@ export default function CasesPage() {
|
||||
Quer um projeto parecido com algum destes?
|
||||
</h2>
|
||||
<p className="ds-copy max-w-[60ch]">
|
||||
Conte o que você precisa. A conversa começa direto no WhatsApp, sem
|
||||
Conte o que você precisa. A conversa começa pelo WhatsApp, sem
|
||||
formulário.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
|
||||
@@ -212,10 +211,10 @@ export default function CasesPage() {
|
||||
placement="cases_hub_footer"
|
||||
className={primaryButton}
|
||||
>
|
||||
Conversar sobre meu projeto
|
||||
{buttonLabels.contact}
|
||||
</WhatsAppLink>
|
||||
<HoverPrefetchLink href="/servicos" className={secondaryButtonDark}>
|
||||
Ver os serviços
|
||||
{buttonLabels.allServices}
|
||||
</HoverPrefetchLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user