From fc44a69fb35d5ae1c9a1aa4bc916a5463dbc9f17 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 19:14:46 -0300 Subject: [PATCH] feat(servicos): ofertas no topo de /servicos, capa por assunto e diagramas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- app/cases/[slug]/page.facts.paths.test.tsx | 6 +- app/cases/[slug]/page.paths.test.tsx | 13 +- app/cases/[slug]/page.test.tsx | 176 ++++++--- app/cases/[slug]/page.tsx | 3 + .../CaseCapabilitiesSection.paths.test.tsx | 138 +++++++ .../_components/CaseCapabilitiesSection.tsx | 61 +++ .../CaseContactSection.paths.test.tsx | 19 +- app/cases/_components/CaseContactSection.tsx | 21 +- app/cases/_components/CaseMediaSection.tsx | 5 +- app/cases/page.loading.test.tsx | 32 +- app/cases/page.paths.test.tsx | 111 ++++-- app/cases/page.tsx | 49 ++- app/components/Breadcrumb.paths.test.tsx | 22 +- app/components/Breadcrumb.tsx | 13 +- app/components/Faq.test.tsx | 90 +++++ app/components/Faq.tsx | 73 ++++ app/curriculo/page.test.tsx | 5 +- app/curriculo/page.tsx | 4 +- app/servicos/[slug]/page.cover.paths.test.tsx | 162 ++++++++ app/servicos/[slug]/page.paths.test.tsx | 19 +- app/servicos/[slug]/page.test.tsx | 365 +++++++++++++++--- app/servicos/[slug]/page.tsx | 50 ++- .../_components/ServiceDiagram.test.tsx | 70 ++++ app/servicos/_components/ServiceDiagram.tsx | 163 ++++++++ app/servicos/_components/ServiceFaq.tsx | 32 -- .../_components/ServiceHeroCover.test.tsx | 64 ++- app/servicos/_components/ServiceHeroCover.tsx | 76 +++- .../ServiceOfferCards.paths.test.tsx | 111 ++++++ .../_components/ServiceOfferCards.tsx | 71 ++++ .../ServiceRelatedCases.paths.test.tsx | 56 ++- .../_components/ServiceRelatedCases.test.tsx | 4 +- .../_components/ServiceRelatedCases.tsx | 36 +- app/servicos/page.paths.test.tsx | 23 +- app/servicos/page.tsx | 245 ++++-------- app/sitemap.test.ts | 47 ++- app/sitemap.ts | 46 ++- lib/seo.test.ts | 48 +++ lib/seo.ts | 38 ++ lib/site-content.listing.paths.test.ts | 77 ++++ lib/site-content.test.ts | 184 ++++++++- lib/site-content.ts | 208 ++++++++-- 41 files changed, 2486 insertions(+), 550 deletions(-) create mode 100644 app/cases/_components/CaseCapabilitiesSection.paths.test.tsx create mode 100644 app/cases/_components/CaseCapabilitiesSection.tsx create mode 100644 app/components/Faq.test.tsx create mode 100644 app/components/Faq.tsx create mode 100644 app/servicos/[slug]/page.cover.paths.test.tsx create mode 100644 app/servicos/_components/ServiceDiagram.test.tsx create mode 100644 app/servicos/_components/ServiceDiagram.tsx delete mode 100644 app/servicos/_components/ServiceFaq.tsx create mode 100644 app/servicos/_components/ServiceOfferCards.paths.test.tsx create mode 100644 app/servicos/_components/ServiceOfferCards.tsx create mode 100644 lib/site-content.listing.paths.test.ts diff --git a/app/cases/[slug]/page.facts.paths.test.tsx b/app/cases/[slug]/page.facts.paths.test.tsx index f52664d..93966fe 100644 --- a/app/cases/[slug]/page.facts.paths.test.tsx +++ b/app/cases/[slug]/page.facts.paths.test.tsx @@ -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); diff --git a/app/cases/[slug]/page.paths.test.tsx b/app/cases/[slug]/page.paths.test.tsx index 1c68c45..809e526 100644 --- a/app/cases/[slug]/page.paths.test.tsx +++ b/app/cases/[slug]/page.paths.test.tsx @@ -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"); diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index 6c0da0e..f09feb1 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -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[] { } /** - * 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(); 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", { diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index 4185605..21d4acf 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -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) { + + diff --git a/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx b/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx new file mode 100644 index 0000000..21d42a8 --- /dev/null +++ b/app/cases/_components/CaseCapabilitiesSection.paths.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + expect(screen.getByText(capabilities[0])).toBeVisible(); + expect(screen.queryByRole("link")).toBeNull(); + }); + + it("path: no items and no 'Como ficou' -> renders nothing", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/cases/_components/CaseCapabilitiesSection.tsx b/app/cases/_components/CaseCapabilitiesSection.tsx new file mode 100644 index 0000000..9adf24a --- /dev/null +++ b/app/cases/_components/CaseCapabilitiesSection.tsx @@ -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 ( +
+
+ {capabilities.length > 0 ? ( + <> +

+ {isClient + ? "O que o cliente passou a poder fazer" + : "O que dá para fazer com ele"} +

+
    + {capabilities.map((item) => ( +
  • {item}
  • + ))} +
+ + ) : ( +

+ Fale sobre o seu projeto +

+ )} + {showContact ? ( +
+ + {buttonLabels.contact} + +
+ ) : null} +
+
+ ); +} diff --git a/app/cases/_components/CaseContactSection.paths.test.tsx b/app/cases/_components/CaseContactSection.paths.test.tsx index 4a0add8..0e239e3 100644 --- a/app/cases/_components/CaseContactSection.paths.test.tsx +++ b/app/cases/_components/CaseContactSection.paths.test.tsx @@ -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 diff --git a/app/cases/_components/CaseContactSection.tsx b/app/cases/_components/CaseContactSection.tsx index 3c908b3..55b564a 100644 --- a/app/cases/_components/CaseContactSection.tsx +++ b/app/cases/_components/CaseContactSection.tsx @@ -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}

- 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.

@@ -52,7 +47,7 @@ export function CaseContactSection({ placement="case_detail" className={primaryButton} > - {copy.action} + {buttonLabels.contact} Ver outros cases diff --git a/app/cases/_components/CaseMediaSection.tsx b/app/cases/_components/CaseMediaSection.tsx index 7c945be..fa6c199 100644 --- a/app/cases/_components/CaseMediaSection.tsx +++ b/app/cases/_components/CaseMediaSection.tsx @@ -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} + {caseStudy.title}
) : null} diff --git a/app/cases/page.loading.test.tsx b/app/cases/page.loading.test.tsx index 22edbf1..09db163 100644 --- a/app/cases/page.loading.test.tsx +++ b/app/cases/page.loading.test.tsx @@ -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; @@ -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(); - const mosaic = Array.from( - container.querySelectorAll("section [aria-hidden] img"), + // sem mosaico: nenhuma imagem decorativa (alt vazio) no topo da página + const decorative = Array.from( + container.querySelectorAll("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"); } diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx index e59567f..3b76fa6 100644 --- a/app/cases/page.paths.test.tsx +++ b/app/cases/page.paths.test.tsx @@ -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(); + + 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(); + + 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(); - - 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(); 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(); + + 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(); - 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"); diff --git a/app/cases/page.tsx b/app/cases/page.tsx index dd54bc0..c25fd1c 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -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} + {caseStudy.title} {caseStudy.demoUrl ? ( - Abrir o site + {buttonLabels.site} + {caseStudy.title} ) : null} @@ -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 {caseStudy.title} + {buttonLabels.case} + {caseStudy.title} ); } 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() {
+ {/* 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 */}
-
+

- {pageDescription} + {pageLead}

-
@@ -170,11 +169,11 @@ export default function CasesPage() { Projetos de clientes
- {clientCases.map((caseStudy, index) => ( + {clientCasesForListing.map((caseStudy, index) => ( ))}
@@ -203,7 +202,7 @@ export default function CasesPage() { Quer um projeto parecido com algum destes?

- 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.

@@ -212,10 +211,10 @@ export default function CasesPage() { placement="cases_hub_footer" className={primaryButton} > - Conversar sobre meu projeto + {buttonLabels.contact} - Ver os serviços + {buttonLabels.allServices}
diff --git a/app/components/Breadcrumb.paths.test.tsx b/app/components/Breadcrumb.paths.test.tsx index 511be47..bcdfc36 100644 --- a/app/components/Breadcrumb.paths.test.tsx +++ b/app/components/Breadcrumb.paths.test.tsx @@ -2,12 +2,12 @@ import { render, screen } from "@testing-library/react"; import { Breadcrumb } from "./Breadcrumb"; /** - * Testes de caminhos-base de Breadcrumb: o primeiro item não tem separador, - * os seguintes têm, e só o último é a página atual (inclusive quando é o único). + * Testes de caminhos-base de Breadcrumb: a barra de separação vem depois de cada item menos do último (a quebra de linha + * no celular nunca deixa uma linha começando por "/"), e só o último é a página atual (inclusive quando é o único). */ describe("Breadcrumb", () => { - it("path: several items -> a separator before every item but the first", () => { + it("path: several items -> a separator after every item but the last, so a wrapped line starts with a name", () => { const { container } = render( { const entries = Array.from(container.querySelectorAll("li")); expect(entries).toHaveLength(3); - expect(entries[0].querySelector("span[aria-hidden='true']")).toBeNull(); + expect( + entries[0].querySelector("span[aria-hidden='true']"), + ).toHaveTextContent("/"); expect( entries[1].querySelector("span[aria-hidden='true']"), ).toHaveTextContent("/"); - expect( - entries[2].querySelector("span[aria-hidden='true']"), - ).toHaveTextContent("/"); - expect(entries[0].className).toBe(""); - expect(entries[1].className).toContain("flex"); + expect(entries[2].querySelector("span[aria-hidden='true']")).toBeNull(); + // o nome vem antes da barra em todos os itens, e nenhum item abre por "/" + for (const entry of entries) { + expect(entry.firstElementChild?.tagName).toBe("A"); + expect(entry.textContent?.trimStart().startsWith("/")).toBe(false); + } + expect(entries[0].className).toContain("flex"); }); it("path: a single item -> no separator, and it is the current page", () => { diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx index 857f54d..0438602 100644 --- a/app/components/Breadcrumb.tsx +++ b/app/components/Breadcrumb.tsx @@ -3,6 +3,8 @@ import HoverPrefetchLink from "./HoverPrefetchLink"; /** * Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual. * Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink). + * A barra vem depois de cada item, menos do último: quando a trilha não cabe numa linha (celular), a quebra cai + * depois da barra e a linha de baixo começa pelo nome, nunca por "/". */ export function Breadcrumb({ items, @@ -11,13 +13,9 @@ export function Breadcrumb({ }) { return (