diff --git a/app/cases/[slug]/opengraph-image.paths.test.tsx b/app/cases/[slug]/opengraph-image.paths.test.tsx index 138e1f9..cb5d339 100644 --- a/app/cases/[slug]/opengraph-image.paths.test.tsx +++ b/app/cases/[slug]/opengraph-image.paths.test.tsx @@ -1,15 +1,50 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { notFound } from "next/navigation"; import { ImageResponse } from "next/og"; import { isValidElement } from "react"; -import { OG_SIZE, OgCard } from "@/lib/og-card"; +import { + OG_IMAGE_MAX_BYTES, + OG_SIZE, + OgCard, + readProjectOgImage, +} from "@/lib/og-card"; import { caseStudies } from "@/lib/site-content"; -import Image from "./opengraph-image"; +import Image, { generateImageMetadata } from "./opengraph-image"; /** * Testes de caminhos-base de Image (imagem de compartilhamento do case): - * slug desconhecido, id da imagem diferente do case, case de cliente e projeto de laboratório. + * slug desconhecido, id da imagem diferente do case, case com capa gravada (entregue como está, em JPEG, + * abaixo de 150 KB), capa pesada demais (cartão simples) e projeto de laboratório. */ +/** O jsdom não tem `Response`; a rota roda no Node e devolve uma. Aqui basta guardar o corpo e os cabeçalhos. */ +class FakeResponse { + constructor( + readonly body: Uint8Array, + readonly init: { headers: Record }, + ) {} +} + +beforeAll(() => { + Object.assign(globalThis, { Response: FakeResponse }); +}); + +jest.mock("@/lib/og-card", () => { + const actual = jest.requireActual("@/lib/og-card"); + return { ...actual, readProjectOgImage: jest.fn(actual.readProjectOgImage) }; +}); + +const mockedReadProjectOgImage = jest.mocked(readProjectOgImage); +const actualReadProjectOgImage = + jest.requireActual( + "@/lib/og-card", + ).readProjectOgImage; + +afterEach(() => { + mockedReadProjectOgImage.mockImplementation(actualReadProjectOgImage); +}); + jest.mock("next/navigation", () => ({ notFound: jest.fn(() => { throw new Error("NEXT_NOT_FOUND"); @@ -47,11 +82,32 @@ function sentCard() { } describe("opengraph-image Image", () => { - it("path: client case with the matching image id -> card labelled as a client case", async () => { + it("path: case with a recorded cover -> the cover itself, in JPEG, under 150 KB, and no generated card", async () => { const caseStudy = getCase("sev-modaintima"); - await renderImage(caseStudy.id, caseStudy.id); + const response = (await renderImage( + caseStudy.id, + caseStudy.id, + )) as unknown as FakeResponse; expect(notFound).not.toHaveBeenCalled(); + expect(mockedImageResponse).not.toHaveBeenCalled(); + expect(response).toBeInstanceOf(FakeResponse); + expect(response.init.headers["Content-Type"]).toBe("image/jpeg"); + const cover = readFileSync( + join(process.cwd(), "public/videos/sev-modaintima/og.jpg"), + ); + expect(Buffer.from(response.body).equals(cover)).toBe(true); + expect(response.body.length).toBeLessThan(150_000); + }); + + it("path: cover that weighs 150 KB or more -> the plain generated card, with no background", async () => { + const caseStudy = getCase("sev-modaintima"); + mockedReadProjectOgImage.mockReturnValue(undefined); + expect(OG_IMAGE_MAX_BYTES).toBe(150_000); + + const response = await renderImage(caseStudy.id, caseStudy.id); + + expect(response).not.toBeInstanceOf(FakeResponse); expect(mockedImageResponse).toHaveBeenCalledTimes(1); const { card, options } = sentCard(); expect(card.type).toBe(OgCard); @@ -60,10 +116,31 @@ describe("opengraph-image Image", () => { title: caseStudy.title, description: caseStudy.summary, }); - expect(card.props.backgroundImage).toMatch(/^data:image\/jpeg;base64,/); + expect(card.props.backgroundImage).toBeUndefined(); expect(options).toEqual(OG_SIZE); }); + it("path: image metadata -> JPEG for covers, PNG for generated cards, and every case listed without a slug", () => { + const [sev] = generateImageMetadata({ params: { slug: "sev-modaintima" } }); + const [twins] = generateImageMetadata({ params: { slug: "twins-lab" } }); + expect(sev).toMatchObject({ + id: "sev-modaintima", + contentType: "image/jpeg", + size: OG_SIZE, + }); + expect(twins).toMatchObject({ + id: "twins-lab", + contentType: "image/png", + size: OG_SIZE, + }); + + const all = generateImageMetadata({ params: {} }); + expect(all.map(({ id }) => id)).toEqual(caseStudies.map(({ id }) => id)); + expect( + all.filter(({ contentType }) => contentType === "image/jpeg"), + ).toHaveLength(5); + }); + it("path: unknown slug -> notFound() and no image", async () => { await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow( "NEXT_NOT_FOUND", diff --git a/app/cases/[slug]/opengraph-image.tsx b/app/cases/[slug]/opengraph-image.tsx index b1d83b8..203c0e9 100644 --- a/app/cases/[slug]/opengraph-image.tsx +++ b/app/cases/[slug]/opengraph-image.tsx @@ -2,9 +2,10 @@ import { notFound } from "next/navigation"; import { ImageResponse } from "next/og"; import { OG_CONTENT_TYPE, + OG_PHOTO_CONTENT_TYPE, OG_SIZE, OgCard, - readProjectOgBackground, + readProjectOgImage, } from "@/lib/og-card"; import { caseStudies, isClientCase } from "@/lib/site-content"; @@ -12,12 +13,15 @@ function getCaseStudy(slug: string) { return caseStudies.find(({ id }) => id === slug); } +// Quem tem a capa gravada (public/videos//og.jpg, abaixo de 150 KB) a entrega como está, em JPEG: o cartão em PNG com a capa de fundo passava de 270 KB. function getImageMetadata(caseStudy: (typeof caseStudies)[number]) { return { id: caseStudy.id, alt: `Case ${caseStudy.title} — Evandro Fonseca Junior`, size: OG_SIZE, - contentType: OG_CONTENT_TYPE, + contentType: readProjectOgImage(caseStudy.id) + ? OG_PHOTO_CONTENT_TYPE + : OG_CONTENT_TYPE, }; } @@ -46,6 +50,16 @@ export default async function Image({ const caseStudy = getCaseStudy(slug); if (!caseStudy || imageId !== caseStudy.id) notFound(); + const photo = readProjectOgImage(caseStudy.id); + if (photo) { + return new Response(new Uint8Array(photo), { + headers: { + "Content-Type": OG_PHOTO_CONTENT_TYPE, + "Cache-Control": "public, max-age=86400", + }, + }); + } + return new ImageResponse( , OG_SIZE, ); diff --git a/app/cases/[slug]/page.facts.paths.test.tsx b/app/cases/[slug]/page.facts.paths.test.tsx new file mode 100644 index 0000000..f52664d --- /dev/null +++ b/app/cases/[slug]/page.facts.paths.test.tsx @@ -0,0 +1,36 @@ +import { render, screen } from "@testing-library/react"; +import { caseStudies } from "@/lib/site-content"; +import CasePage from "./page"; + +/** + * Teste de caminhos-base de CasePage: os "fatos do projeto" no topo. + * O teste principal cobre a lista ausente (`undefined`) e a lista preenchida; aqui, a lista vazia (`[]`). + */ + +jest.mock("next/navigation", () => ({ notFound: jest.fn() })); + +function getCase(id: string) { + const caseStudy = caseStudies.find((item) => item.id === id); + if (!caseStudy) throw new Error(`Missing case ${id}`); + return caseStudy; +} + +describe("CasePage: fatos do projeto", () => { + it("path: empty facts list -> no list in the hero, summary stays", async () => { + const caseStudy = getCase("lavacao-047"); + caseStudy.facts = []; + try { + const ui = await CasePage({ + params: Promise.resolve({ slug: caseStudy.id }), + }); + render(ui); + + expect( + screen.queryByRole("list", { name: "Fatos do projeto" }), + ).toBeNull(); + expect(screen.getByText(caseStudy.summary)).toBeVisible(); + } finally { + delete caseStudy.facts; + } + }); +}); diff --git a/app/cases/[slug]/page.paths.test.tsx b/app/cases/[slug]/page.paths.test.tsx index 259bdb3..1c68c45 100644 --- a/app/cases/[slug]/page.paths.test.tsx +++ b/app/cases/[slug]/page.paths.test.tsx @@ -66,12 +66,20 @@ describe("CasePage: caminhos que o teste principal não cobre", () => { screen.getByRole("heading", { level: 2, name: "Como ficou" }), ).toBeVisible(); expect( - screen.getByLabelText(`Vídeo do site ${caseStudy.title} no celular`), + screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`, + }), ).toBeInTheDocument(); expect( - screen.queryByLabelText(`Vídeo do site ${caseStudy.title} no computador`), + screen.queryByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`, + }), ).toBeNull(); + // sem captura do computador, o topo fica em uma coluna, sem moldura de navegador expect(container.querySelector(".ds-bframe")).toBeNull(); + expect( + container.querySelector("section")?.querySelector(".grid"), + ).toBeNull(); expect(container.querySelector(".ds-phone")).not.toBeNull(); expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull(); }); @@ -115,11 +123,12 @@ describe("CasePage: caminhos que o teste principal não cobre", () => { await renderCase(caseStudy.id); 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.queryByRole("link", { name: /^Ver serviço relacionado:/ }), - ).toBeNull(); - expect( - screen.getByRole("link", { name: "Conversar sobre um projeto parecido" }), + screen.getByRole("link", { + name: "Tem um problema parecido? Vamos conversar.", + }), ).toBeVisible(); expect( screen.getByRole("link", { name: "Ver outros cases" }), diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index f4f04c9..eb66b10 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content"; import CasesPage from "../page"; import CasePage, { generateMetadata, generateStaticParams } from "./page"; @@ -49,24 +49,34 @@ function readJsonLdEntities(container: HTMLElement): Record[] { }); } -/** Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case. */ -function expectContactAndNoDeadLinks(caseStudy: { title: string }) { +/** + * 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". + */ +function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { 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: "Conversar sobre um projeto parecido", + 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 da ${caseStudy.title}`, + isClient + ? `um projeto parecido com o da ${caseStudy.title}` + : `um problema parecido com o do projeto ${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 () => { @@ -80,16 +90,16 @@ it("exposes the eight public cases, the two new client cases among them", async for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) { const caseStudy = getCase(id); const url = `${siteUrl}/cases/${caseStudy.id}`; - const title = `${caseStudy.title}: ${caseStudy.subtitle}`; + const { metaTitle: title, metaDescription: description } = caseStudy; const metadata = await generateMetadata({ params: Promise.resolve({ slug: caseStudy.id }), }); expect(metadata).toMatchObject({ title, - description: caseStudy.summary, + description, alternates: { canonical: url }, - openGraph: { title, description: caseStudy.summary, url }, - twitter: { title, description: caseStudy.summary }, + openGraph: { title, description, url }, + twitter: { title, description }, }); expect(metadata).not.toHaveProperty("keywords"); } @@ -101,7 +111,7 @@ it("renders a client case in the sales order, with media, contact and collapsed const heading = screen.getByRole("heading", { level: 1 }); expect(heading).toHaveTextContent(caseStudy.headline); - expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary); + expect(isBefore(heading, screen.getByText(caseStudy.summary))).toBe(true); expect(screen.getByText(/Case de cliente/)).toBeVisible(); const sectionNames = [ @@ -130,33 +140,28 @@ it("renders a client case in the sales order, with media, contact and collapsed // sem dado real, não há seção de resultado expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull(); - // mídia: pôster e vídeo do computador e do celular - const desktopVideo = screen.getByLabelText( - `Vídeo do site ${caseStudy.title} no computador`, + // mídia: o pôster do computador e do celular com um botão de play; nenhum vídeo parado com controles + expect(container.querySelector("video")).toBeNull(); + const desktopPlay = screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`, + }); + const mobilePlay = screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`, + }); + const mediaSection = screen + .getByRole("heading", { level: 2, name: "Como ficou" }) + .closest("section"); + expect(mediaSection?.querySelector(".ds-bframe")).toContainElement( + desktopPlay, ); - const mobileVideo = screen.getByLabelText( - `Vídeo do site ${caseStudy.title} no celular`, - ); - expect(desktopVideo).toHaveAttribute( - "poster", - `/videos/${caseStudy.id}/desktop.webp`, - ); - expect(mobileVideo).toHaveAttribute( - "poster", - `/videos/${caseStudy.id}/mobile.webp`, - ); - expect( - desktopVideo.querySelectorAll("source[src^='/videos/sev-modaintima/']"), - ).toHaveLength(2); - expect(container.querySelector(".ds-bframe")).toContainElement(desktopVideo); - expect(container.querySelector(".ds-phone")).toContainElement(mobileVideo); + expect(mediaSection?.querySelector(".ds-phone")).toContainElement(mobilePlay); expect( screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), ).toHaveAttribute("href", caseStudy.demoUrl); expectContactAndNoDeadLinks(caseStudy); - // detalhes técnicos: recolhidos, no fim da página + // detalhes técnicos: recolhidos, junto do conteúdo (logo depois do que foi feito), não numa faixa à parte const details = container.querySelector("details"); if (!details) throw new Error("Missing technical details"); expect(details).not.toHaveAttribute("open"); @@ -167,11 +172,16 @@ it("renders a client case in the sales order, with media, contact and collapsed for (const decision of caseStudy.decisions) { expect(within(details).getByText(decision)).not.toBeVisible(); } - const contactHeading = sectionHeadings[sectionHeadings.length - 1]; - expect(isBefore(contactHeading, details)).toBe(true); + const doneSection = sectionHeadings[2].closest("section"); + expect(doneSection).toContainElement(details); + expect(isBefore(screen.getByText(caseStudy.deliveries[0]), details)).toBe( + true, + ); + expect(isBefore(details, sectionHeadings[3])).toBe(true); expect(container.querySelectorAll("details")).toHaveLength(1); - const lastSection = container.querySelector("article")?.lastElementChild; - expect(lastSection).toContainElement(details); + expect( + container.querySelector("section[aria-label='Detalhes técnicos']"), + ).toBeNull(); // o que saiu: desafio verificável, limites da evidência, fontes e commits for (const name of [ @@ -186,11 +196,14 @@ it("renders a client case in the sales order, with media, contact and collapsed expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/); const graph = readJsonLdEntities(container); - expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]); + expect(graph.map((node) => node["@type"])).toEqual([ + "CreativeWork", + "BreadcrumbList", + ]); expect(JSON.stringify(graph)).not.toMatch( /Article|FAQPage|ProfessionalService|LocalBusiness/, ); - const breadcrumb = graph[0] as { + const breadcrumb = graph[1] as { itemListElement: Array<{ "@type": string; position: number; @@ -216,7 +229,7 @@ it("renders a client case in the sales order, with media, contact and collapsed ]); const visibleBreadcrumb = screen.getByRole("navigation", { - name: "Breadcrumb", + name: "Trilha de navegação", }); expect( within(visibleBreadcrumb) @@ -253,7 +266,7 @@ it("renders the two new client cases from the portfolio data", async () => { } }); -it("labels a laboratory case, skips the media block and links its service", async () => { +it("labels a laboratory case, skips the media block and shows its service as a card", async () => { const caseStudy = getCase("twins-lab"); const { container } = await renderCase(caseStudy.id); @@ -263,11 +276,12 @@ it("labels a laboratory case, skips the media block and links its service", asyn ).toBeVisible(); expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull(); expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector(".ds-bframe")).toBeNull(); expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull(); expect(screen.queryByText(/indispon/i)).toBeNull(); const relatedLinks = screen.getAllByRole("link", { - name: /^Ver serviço relacionado:/, + name: /^Ver o serviço:/, }); expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual( getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`), @@ -276,6 +290,24 @@ it("labels a laboratory case, skips the media block and links its service", asyn expectContactAndNoDeadLinks(caseStudy); }); +it("turns the contact of a laboratory case into the 'problema parecido' invitation", 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", + }), + ).toBeNull(); + expect( + screen.getByRole("heading", { level: 2, name: "Fale sobre o seu caso" }), + ).toBeVisible(); + expectContactAndNoDeadLinks(getCase(id)); + unmount(); + } +}); + 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(); @@ -289,7 +321,9 @@ it("shows the author's role and no site button for a client project without a pu expect(screen.queryByText(/indispon/i)).toBeNull(); // a capa continua nas molduras expect( - screen.getByLabelText(`Vídeo do site ${caseStudy.title} no computador`), + screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`, + }), ).toBeInTheDocument(); }); @@ -327,26 +361,195 @@ it("shows the result section only when the case has real data", async () => { } }); -it("links every service related to a client case", async () => { - const caseStudy = getCase("sc-plus"); +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"); + const all = getRelatedServices(sev.id); + expect(all.length).toBeGreaterThan(2); + const { unmount } = await renderCase(sev.id); + + const cards = screen.getAllByRole("link", { name: /^Ver o serviço:/ }); + expect(cards).toHaveLength(2); + expect(cards.map((link) => link.getAttribute("href"))).toEqual( + all.slice(0, 2).map(({ slug }) => `/servicos/${slug}`), + ); + for (const link of cards) { + const card = link.closest("article"); + expect(card).toHaveClass("ds-card"); + expect( + within(card as HTMLElement).getByRole("heading", { level: 3 }), + ).toBeVisible(); + } + 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); + unmount(); + + // sc-plus tem dois; as-servicos, um + for (const [id, expected] of [ + ["sc-plus", 2], + ["as-servicos", 1], + ] as const) { + const { unmount: unmountCase } = await renderCase(id); + const links = screen.getAllByRole("link", { name: /^Ver o serviço:/ }); + expect(links).toHaveLength(expected); + expect(links.map((link) => link.getAttribute("href"))).toEqual( + getRelatedServices(id) + .slice(0, 2) + .map(({ slug }) => `/servicos/${slug}`), + ); + expectContactAndNoDeadLinks(getCase(id)); + unmountCase(); + } +}); + +it("writes the CreativeWork of a client case with creator, client and keywords", async () => { + const caseStudy = getCase("lavacao-047"); const { container } = await renderCase(caseStudy.id); - const relatedLinks = screen.getAllByRole("link", { - name: /^Ver serviço relacionado:/, - }); - expect( - relatedLinks - .map((link) => link.getAttribute("href")) - .sort((a, b) => (a ?? "").localeCompare(b ?? "")), - ).toEqual( - getRelatedServices(caseStudy.id) - .map(({ slug }) => `/servicos/${slug}`) - .sort(), + const work = readJsonLdEntities(container).find( + (node) => node["@type"] === "CreativeWork", ); - expectContactAndNoDeadLinks(caseStudy); - expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([ - "BreadcrumbList", - ]); + expect(work).toEqual({ + "@type": "CreativeWork", + "@id": `${siteUrl}/cases/lavacao-047#creative-work`, + name: "Lavação 047: Site e painel para lavação automotiva", + description: caseStudy.metaDescription, + url: `${siteUrl}/cases/lavacao-047`, + image: `${siteUrl}/cases/lavacao-047/opengraph-image/lavacao-047`, + inLanguage: "pt-BR", + creator: { "@id": `${siteUrl}/#person` }, + sourceOrganization: { + "@type": "Organization", + name: "Lavação 047", + url: "https://landing-page-lavacao047.vercel.app", + }, + keywords: caseStudy.stack.join(", "), + }); +}); + +it("writes the CreativeWork of a laboratory case without a client", async () => { + const caseStudy = getCase("twins-lab"); + const { container } = await renderCase(caseStudy.id); + + const work = readJsonLdEntities(container).find( + (node) => node["@type"] === "CreativeWork", + ); + expect(work).toMatchObject({ + name: "Twins Lab: Painel ao vivo de um reator simulado", + url: `${siteUrl}/cases/twins-lab`, + creator: { "@id": `${siteUrl}/#person` }, + keywords: "Next.js, TypeScript, NextAuth, MongoDB, SSE", + }); + expect(work).not.toHaveProperty("sourceOrganization"); +}); + +it("puts the project cover next to the title, and below it on a phone, in a browser frame", async () => { + const caseStudy = getCase("sev-modaintima"); + const { container } = await renderCase(caseStudy.id); + + const hero = container.querySelector("section"); + if (!hero) throw new Error("Missing hero"); + const frame = hero.querySelector(".ds-bframe"); + if (!frame) throw new Error("Missing hero frame"); + expect(frame.querySelector("img")).toHaveAttribute( + "alt", + `Captura de tela do site ${caseStudy.title}`, + ); + expect(frame.querySelector(".ds-bframe-url")).toHaveTextContent( + "sevmodaintima.com.br", + ); + // a capa abre a página: carrega antes das demais imagens + expect(frame.querySelector("img")).not.toHaveAttribute("loading", "lazy"); + + // ordem do documento (a do celular): título, mídia, resumo + const heading = screen.getByRole("heading", { level: 1 }); + const summary = screen.getByText(caseStudy.summary); + expect(hero).toContainElement(summary); + expect(isBefore(heading, frame)).toBe(true); + expect(isBefore(frame, summary)).toBe(true); + // no computador: duas colunas, texto à esquerda e mídia à direita + const grid = frame.closest(".grid"); + expect(grid?.className).toContain("lg:grid-cols-["); + expect(frame.parentElement?.className).toContain("lg:col-start-2"); + expect(heading.parentElement?.className).toContain("lg:col-start-1"); +}); + +it("keeps the hero in one column when the case has no capture", async () => { + const { container } = await renderCase("twins-lab"); + + const hero = container.querySelector("section"); + expect(hero?.querySelector(".ds-bframe")).toBeNull(); + expect(hero?.querySelector(".grid")).toBeNull(); +}); + +it("shows the optional facts only when they are filled in", async () => { + const caseStudy = getCase("lavacao-047"); + const empty = await renderCase(caseStudy.id); + expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull(); + empty.unmount(); + + caseStudy.facts = ["Fato de teste um", "Fato de teste dois"]; + try { + await renderCase(caseStudy.id); + const list = screen.getByRole("list", { name: "Fatos do projeto" }); + expect(within(list).getAllByRole("listitem")).toHaveLength(2); + expect(within(list).getByText("Fato de teste um")).toBeVisible(); + expect(isBefore(screen.getByText(caseStudy.summary), list)).toBe(true); + } finally { + delete caseStudy.facts; + } +}); + +it("starts the video only when the visitor presses play, and moves focus to it", async () => { + const caseStudy = getCase("sev-modaintima"); + const { container } = await renderCase(caseStudy.id); + + const play = screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`, + }); + expect(play).toBeVisible(); + expect(play.className).not.toContain("--slash"); + expect(container.querySelector("video")).toBeNull(); + + fireEvent.click(play); + + const video = screen.getByLabelText( + `Vídeo do site ${caseStudy.title} no computador`, + ); + expect(video.tagName).toBe("VIDEO"); + expect(video).toHaveAttribute( + "poster", + `/videos/${caseStudy.id}/desktop.webp`, + ); + expect(video).toHaveAttribute("controls"); + expect(video).toHaveFocus(); + expect( + video.querySelectorAll("source[src^='/videos/sev-modaintima/']"), + ).toHaveLength(2); + expect(video.closest(".ds-bframe")).not.toBeNull(); + expect( + screen.queryByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no computador`, + }), + ).toBeNull(); + // o celular continua com o pôster até a pessoa tocar nele + expect( + screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${caseStudy.title} no celular`, + }), + ).toBeVisible(); +}); + +it("gives the case page the same shell as the home, with no narrow max-width", async () => { + const { container } = await renderCase("sev-modaintima"); + for (const shell of container.querySelectorAll(".ds-section-shell")) { + expect(shell.className).not.toMatch(/max-w-/); + } }); it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => { @@ -409,7 +612,7 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me"); const visibleBreadcrumb = screen.getByRole("navigation", { - name: "Breadcrumb", + name: "Trilha de navegação", }); expect( within(visibleBreadcrumb) diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index 0b2c88b..4185605 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -1,18 +1,15 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { notFound } from "next/navigation"; -import { Breadcrumb } from "@/app/components/Breadcrumb"; -import { - pageTop, - primaryButton, - secondaryButtonDark, -} from "@/app/components/button-styles"; -import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; -import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; +import { + buildBreadcrumbList, + buildCaseJsonLd, + buildPageSocialMetadata, +} from "@/lib/seo"; import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content"; +import { CaseContactSection } from "../_components/CaseContactSection"; +import { CaseHero } from "../_components/CaseHero"; import { CaseMediaSection } from "../_components/CaseMediaSection"; -import { CaseRelatedServices } from "../_components/CaseRelatedServices"; import { CaseResultSection } from "../_components/CaseResultSection"; interface CasePageProps { @@ -32,14 +29,13 @@ export async function generateMetadata({ if (!caseStudy) return { title: "Case não encontrado" }; const url = `${siteUrl}/cases/${caseStudy.id}`; - const title = `${caseStudy.title}: ${caseStudy.subtitle}`; return { - title, - description: caseStudy.summary, + title: caseStudy.metaTitle, + description: caseStudy.metaDescription, ...buildPageSocialMetadata({ url, - title, - description: caseStudy.summary, + title: caseStudy.metaTitle, + description: caseStudy.metaDescription, type: "article", }), }; @@ -55,6 +51,7 @@ export default async function CasePage({ params }: CasePageProps) { const jsonLd = { "@context": "https://schema.org", "@graph": [ + buildCaseJsonLd(caseStudy), buildBreadcrumbList(pageUrl, [ ["Início", siteUrl], ["Cases", `${siteUrl}/cases`], @@ -67,32 +64,12 @@ export default async function CasePage({ params }: CasePageProps) {
-
-
- -

- {isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "} - {caseStudy.title} -

-

- {caseStudy.headline} -

-

- {caseStudy.summary} -

-
-
+
-
+
-
+

{isClient ? "O cliente e o contexto" @@ -108,7 +85,7 @@ export default async function CasePage({ params }: CasePageProps) {

) : null}

-
+

O problema

@@ -119,15 +96,41 @@ export default async function CasePage({ params }: CasePageProps) {
-
-

- O que foi feito -

-
    - {caseStudy.deliveries.map((delivery) => ( -
  • {delivery}
  • - ))} -
+
+
+

+ O que foi feito +

+
    + {caseStudy.deliveries.map((delivery) => ( +
  • {delivery}
  • + ))} +
+
+ + Detalhes técnicos + +
+

Tecnologias

+
    + {caseStudy.stack.map((item) => ( +
  • + {item} +
  • + ))} +
+

Decisões técnicas

+
    + {caseStudy.decisions.map((decision) => ( +
  • {decision}
  • + ))} +
+
+
+
@@ -135,62 +138,7 @@ export default async function CasePage({ params }: CasePageProps) { -
-
-

- Quer um projeto parecido com este? -

-

- Conte o que você precisa. A conversa começa direto no WhatsApp, - sem formulário. -

-
- - Conversar sobre um projeto parecido - - - Ver outros cases - -
- -
-
- -
-
-
- - Detalhes técnicos - -
-

Tecnologias

-
    - {caseStudy.stack.map((item) => ( -
  • - {item} -
  • - ))} -
-

Decisões técnicas

-
    - {caseStudy.decisions.map((decision) => ( -
  • {decision}
  • - ))} -
-
-
-
-
+
); diff --git a/app/cases/_components/CaseContactSection.paths.test.tsx b/app/cases/_components/CaseContactSection.paths.test.tsx new file mode 100644 index 0000000..dc73ded --- /dev/null +++ b/app/cases/_components/CaseContactSection.paths.test.tsx @@ -0,0 +1,67 @@ +import { render, screen } from "@testing-library/react"; +import { getRelatedServices } from "@/lib/site-content"; +import { CaseContactSection } from "./CaseContactSection"; + +/** + * Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de + * um projeto de laboratório ("problema parecido"), o link para a lista de cases e os serviços ligados. + */ + +const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" }; +const nutri = { id: "nutriscan", title: "NutriScan" }; + +function messageOf(link: HTMLElement) { + return new URL(link.getAttribute("href") ?? "").searchParams.get("text"); +} + +describe("CaseContactSection", () => { + it("path: client case -> 'projeto parecido' invitation, WhatsApp with the case title, services listed", () => { + render(); + + expect( + screen.getByRole("heading", { + level: 2, + name: "Quer um projeto parecido com este?", + }), + ).toBeVisible(); + const cta = screen.getByRole("link", { + name: "Conversar sobre um projeto parecido", + }); + expect(cta).toHaveAttribute("target", "_blank"); + expect(messageOf(cta)).toContain( + `Quero conversar sobre um projeto parecido com o da ${sev.title}.`, + ); + expect(getRelatedServices(sev.id).length).toBeGreaterThan(0); + expect( + screen.getAllByRole("link", { name: /^Ver o serviço:/ }).length, + ).toBeGreaterThan(0); + expect( + screen.getByRole("link", { name: "Ver outros cases" }), + ).toHaveAttribute("href", "/cases"); + }); + + it("path: laboratory case -> 'problema parecido' invitation and no mention of a similar project", () => { + render(); + + 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.", + }); + expect(messageOf(cta)).toContain( + `Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`, + ); + expect( + screen.queryByRole("link", { + name: "Conversar sobre um projeto parecido", + }), + ).toBeNull(); + // o NutriScan não é citado por nenhum serviço: sem cartões, o convite continua + expect(getRelatedServices(nutri.id)).toHaveLength(0); + expect(screen.queryByRole("link", { name: /^Ver o serviço:/ })).toBeNull(); + expect( + screen.getByRole("link", { name: "Ver outros cases" }), + ).toBeVisible(); + }); +}); diff --git a/app/cases/_components/CaseContactSection.tsx b/app/cases/_components/CaseContactSection.tsx new file mode 100644 index 0000000..011cc40 --- /dev/null +++ b/app/cases/_components/CaseContactSection.tsx @@ -0,0 +1,64 @@ +import Link from "next/link"; +import { + primaryButton, + secondaryButtonDark, +} from "@/app/components/button-styles"; +import WhatsAppLink from "@/app/components/WhatsAppLink"; +import type { CaseStudyContent } from "@/lib/site-content"; +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. */ +const contactCopy = { + client: { + title: "Quer um projeto parecido com este?", + action: "Conversar sobre um projeto parecido", + subject: (title: string) => `um projeto parecido com o da ${title}`, + }, + lab: { + title: "Fale sobre o seu caso", + action: "Tem um problema parecido? Vamos conversar.", + subject: (title: string) => + `um problema parecido com o do projeto ${title}`, + }, +} as const; + +/** Fecho do case: convite ao WhatsApp, volta para a lista de cases e os serviços ligados ao projeto. */ +export function CaseContactSection({ + caseStudy, + isClient, +}: { + caseStudy: Pick; + isClient: boolean; +}) { + const copy = contactCopy[isClient ? "client" : "lab"]; + + return ( +
+
+

+ {copy.title} +

+

+ Conte o que você precisa. A conversa começa direto no WhatsApp, sem + formulário. +

+
+ + {copy.action} + + + Ver outros cases + +
+ +
+
+ ); +} diff --git a/app/cases/_components/CaseCover.tsx b/app/cases/_components/CaseCover.tsx index d63ef7a..2a7a6c3 100644 --- a/app/cases/_components/CaseCover.tsx +++ b/app/cases/_components/CaseCover.tsx @@ -4,7 +4,6 @@ import { displayUrl, } from "@/app/components/showcase/BrowserFrame"; import { getCaseMedia } from "./case-media"; -import "../../components/showcase/showcase.css"; /** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */ export function CaseCover({ diff --git a/app/cases/_components/CaseCoverLink.paths.test.tsx b/app/cases/_components/CaseCoverLink.paths.test.tsx new file mode 100644 index 0000000..ec5226a --- /dev/null +++ b/app/cases/_components/CaseCoverLink.paths.test.tsx @@ -0,0 +1,102 @@ +import { render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { CaseCoverLink } from "./CaseCoverLink"; +import { getCaseMedia } from "./case-media"; + +/** + * Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado, + * com a capa dentro), os valores padrão de `sizes`, `priority` e `prefetch`, e os valores dados. + */ + +jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + children, + prefetch, + ...props + }: { children?: React.ReactNode; prefetch?: boolean | null } & Record< + string, + unknown + >) => ( + + {children} + + ), +})); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); +const props = { + id: "sev-modaintima", + title: "SEV Moda Íntima", + demoUrl: "https://sevmodaintima.com.br", +}; + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("CaseCoverLink", () => { + it("path: no desktop capture -> renders nothing, not even the link", () => { + mockedGetCaseMedia.mockReturnValue(undefined); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop"); + }); + + it("path: capture found -> a link to the case, out of reading order and tab order, around the cover", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + const { container } = render(); + + const link = container.querySelector("a"); + expect(link).toHaveAttribute("href", `/cases/${props.id}`); + expect(link).toHaveAttribute("aria-hidden", "true"); + expect(link).toHaveAttribute("tabindex", "-1"); + expect(link?.querySelector(".ds-bframe-url")).toHaveTextContent( + "sevmodaintima.com.br", + ); + expect( + screen.getByAltText(`Captura de tela do site ${props.title}`), + ).toBeInTheDocument(); + }); + + it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + const { container } = render(); + + const image = screen.getByAltText(`Captura de tela do site ${props.title}`); + expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw"); + expect(image).toHaveAttribute("loading", "lazy"); + expect(container.querySelector("a")).toHaveAttribute( + "data-prefetch", + "undefined", + ); + }); + + it("path: sizes, priority and prefetch given -> all of them are used", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + const { container } = render( + , + ); + + const image = screen.getByAltText(`Captura de tela do site ${props.title}`); + expect(image).toHaveAttribute("sizes", "320px"); + expect(image).not.toHaveAttribute("loading", "lazy"); + expect(container.querySelector("a")).toHaveAttribute( + "data-prefetch", + "false", + ); + }); + + it("path: no demo address -> the browser bar falls back to the project title", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + const { container } = render( + , + ); + + expect(container.querySelector(".ds-bframe-url")).toHaveTextContent( + props.title, + ); + }); +}); diff --git a/app/cases/_components/CaseCoverLink.tsx b/app/cases/_components/CaseCoverLink.tsx new file mode 100644 index 0000000..18cc825 --- /dev/null +++ b/app/cases/_components/CaseCoverLink.tsx @@ -0,0 +1,43 @@ +import Link from "next/link"; +import { CaseCover } from "./CaseCover"; +import { getCaseMedia } from "./case-media"; + +/** + * Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação, + * porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada. + */ +export function CaseCoverLink({ + id, + title, + demoUrl, + sizes = "(min-width: 1024px) 560px, 100vw", + priority = false, + prefetch, +}: { + id: string; + title: string; + demoUrl?: string | null; + sizes?: string; + priority?: boolean; + prefetch?: boolean; +}) { + if (!getCaseMedia(id, "desktop")) return null; + + return ( + + + + ); +} diff --git a/app/cases/_components/CaseHero.paths.test.tsx b/app/cases/_components/CaseHero.paths.test.tsx new file mode 100644 index 0000000..d6a690e --- /dev/null +++ b/app/cases/_components/CaseHero.paths.test.tsx @@ -0,0 +1,130 @@ +import { render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { caseStudies } from "@/lib/site-content"; +import { CaseHero } from "./CaseHero"; +import { getCaseMedia } from "./case-media"; + +/** + * Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de + * laboratório, e a lista de fatos ausente, vazia ou preenchida. A existência da captura é simulada em getCaseMedia. + */ + +jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +function getCase(id: string) { + const caseStudy = caseStudies.find((item) => item.id === id); + if (!caseStudy) throw new Error(`Missing case ${id}`); + return caseStudy; +} + +const client = getCase("sev-modaintima"); +const lab = getCase("twins-lab"); + +function withCapture(has: boolean) { + mockedGetCaseMedia.mockImplementation((id, device) => + has ? getShowcaseAssets(id, device) : undefined, + ); +} + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("CaseHero: captura gravada e tipo de case", () => { + it("path: client case with a capture -> two columns, the cover on the right, the client eyebrow", () => { + withCapture(true); + const { container } = render(); + + expect(container.querySelector(".ds-section-shell")).toHaveClass("grid"); + expect(container.querySelector(".ds-bframe")).toBeInTheDocument(); + expect(screen.getByText(/Case de cliente/)).toBeVisible(); + expect( + screen.getByRole("heading", { level: 1, name: client.headline }), + ).toBeVisible(); + }); + + it("path: client case without a capture -> one column and no cover", () => { + withCapture(false); + const { container } = render(); + + expect(container.querySelector(".ds-section-shell")).not.toHaveClass( + "grid", + ); + expect(container.querySelector(".ds-bframe")).toBeNull(); + expect(screen.getByText(client.summary)).toBeVisible(); + }); + + it("path: laboratory case with a capture -> the cover shows, with the laboratory eyebrow", () => { + withCapture(true); + const { container } = render(); + + expect(container.querySelector(".ds-bframe")).toBeInTheDocument(); + expect(screen.getByText(/Projeto de laboratório/)).toBeVisible(); + expect(screen.queryByText(/Case de cliente/)).toBeNull(); + }); + + it("path: laboratory case without a capture -> one column, laboratory eyebrow, title and summary", () => { + withCapture(false); + const { container } = render(); + + expect(container.querySelector(".ds-section-shell")).not.toHaveClass( + "grid", + ); + expect(container.querySelector(".ds-bframe")).toBeNull(); + expect(screen.getByText(/Projeto de laboratório/)).toBeVisible(); + expect(screen.getByText(lab.summary)).toBeVisible(); + }); + + it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => { + withCapture(false); + render(); + + const links = screen.getAllByRole("link"); + expect(links.map((link) => link.getAttribute("href"))).toEqual([ + "/", + "/cases", + `/cases/${client.id}`, + ]); + expect(links[2]).toHaveAttribute("aria-current", "page"); + }); +}); + +describe("CaseHero: fatos do projeto", () => { + const withFacts = (facts: string[] | undefined) => ({ + ...client, + facts, + }); + + it("path: no facts field -> no list", () => { + withCapture(false); + render(); + + expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull(); + }); + + it("path: empty facts list -> no list", () => { + withCapture(false); + render(); + + expect(screen.queryByRole("list", { name: "Fatos do projeto" })).toBeNull(); + }); + + it("path: filled facts -> one item each, after the summary", () => { + withCapture(false); + render( + , + ); + + const list = screen.getByRole("list", { name: "Fatos do projeto" }); + expect(list.querySelectorAll("li")).toHaveLength(2); + expect( + screen.getByText(client.summary).compareDocumentPosition(list) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); +}); diff --git a/app/cases/_components/CaseHero.tsx b/app/cases/_components/CaseHero.tsx new file mode 100644 index 0000000..3b56478 --- /dev/null +++ b/app/cases/_components/CaseHero.tsx @@ -0,0 +1,86 @@ +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { pageTop } from "@/app/components/button-styles"; +import type { CaseStudyContent } from "@/lib/site-content"; +import { CaseCover } from "./CaseCover"; +import { getCaseMedia } from "./case-media"; + +/** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */ +function CaseFacts({ facts }: { facts?: string[] }) { + if (!facts?.length) return null; + + return ( +
    + {facts.map((fact) => ( +
  • {fact}
  • + ))} +
+ ); +} + +/** Pôster do site na coluna da direita (no celular, logo abaixo do título). */ +function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) { + return ( +
+ +
+ ); +} + +/** + * Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita; + * no celular a mídia vem logo abaixo do título. + */ +export function CaseHero({ + caseStudy, + isClient, +}: { + caseStudy: CaseStudyContent; + isClient: boolean; +}) { + const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop")); + + return ( +
+
+
+ +

+ {isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "} + {caseStudy.title} +

+

+ {caseStudy.headline} +

+
+ {hasMedia ? : null} +
+

+ {caseStudy.summary} +

+ +
+
+
+ ); +} diff --git a/app/cases/_components/CaseMedia.paths.test.tsx b/app/cases/_components/CaseMedia.paths.test.tsx index 972cae2..8e3e646 100644 --- a/app/cases/_components/CaseMedia.paths.test.tsx +++ b/app/cases/_components/CaseMedia.paths.test.tsx @@ -1,10 +1,11 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { getShowcaseAssets } from "@/lib/project-showcase"; import { CaseMedia } from "./CaseMedia"; import { getCaseMedia } from "./case-media"; /** * Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum. + * Cada quadro mostra o pôster com um botão de play; o vídeo só aparece depois do clique. * A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/. */ @@ -23,8 +24,10 @@ const props = { title: "SEV Moda Íntima", demoUrl: "https://sevmodaintima.com.br", }; -const desktopLabel = `Vídeo do site ${props.title} no computador`; -const mobileLabel = `Vídeo do site ${props.title} no celular`; +const desktopPlay = `Reproduzir o vídeo do site ${props.title} no computador`; +const mobilePlay = `Reproduzir o vídeo do site ${props.title} no celular`; +const desktopVideo = `Vídeo do site ${props.title} no computador`; +const mobileVideo = `Vídeo do site ${props.title} no celular`; afterEach(() => { mockedGetCaseMedia.mockReset(); @@ -36,10 +39,10 @@ describe("CaseMedia", () => { const { container } = render(); expect(container.querySelector(".ds-bframe")).toContainElement( - screen.getByLabelText(desktopLabel), + screen.getByRole("button", { name: desktopPlay }), ); expect(container.querySelector(".ds-phone")).toContainElement( - screen.getByLabelText(mobileLabel), + screen.getByRole("button", { name: mobilePlay }), ); expect(container.querySelector(".ds-bframe-url")).toHaveTextContent( "sevmodaintima.com.br", @@ -63,8 +66,10 @@ describe("CaseMedia", () => { withCaptures(["mobile"]); const { container } = render(); - expect(screen.getByLabelText(mobileLabel)).toBeInTheDocument(); - expect(screen.queryByLabelText(desktopLabel)).toBeNull(); + expect( + screen.getByRole("button", { name: mobilePlay }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: desktopPlay })).toBeNull(); expect(container.querySelector(".ds-bframe")).toBeNull(); expect(container.querySelector(".ds-phone")).not.toBeNull(); expect(screen.queryByText(/no computador\./)).toBeNull(); @@ -74,13 +79,47 @@ describe("CaseMedia", () => { withCaptures(["desktop"]); const { container } = render(); - expect(screen.getByLabelText(desktopLabel)).toBeInTheDocument(); - expect(screen.queryByLabelText(mobileLabel)).toBeNull(); + expect( + screen.getByRole("button", { name: desktopPlay }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: mobilePlay })).toBeNull(); expect(container.querySelector(".ds-phone")).toBeNull(); expect(container.querySelector(".ds-bframe")).not.toBeNull(); expect(screen.queryByText("No celular.")).toBeNull(); }); + it("path: both captures -> no video until play, then only the pressed frame plays", () => { + withCaptures(["desktop", "mobile"]); + const { container } = render(); + + expect(container.querySelector("video")).toBeNull(); + // o pôster do computador vem com texto alternativo vazio: o botão já descreve o quadro + expect(container.querySelector(".ds-bframe img")).toHaveAttribute( + "alt", + "", + ); + + fireEvent.click(screen.getByRole("button", { name: mobilePlay })); + + const phone = screen.getByLabelText(mobileVideo); + expect(container.querySelector(".ds-phone")).toContainElement(phone); + expect(phone).toHaveAttribute("poster", `/videos/${props.id}/mobile.webp`); + expect(phone.querySelectorAll("source")).toHaveLength(2); + expect(screen.queryByLabelText(desktopVideo)).toBeNull(); + expect( + screen.getByRole("button", { name: desktopPlay }), + ).toBeInTheDocument(); + expect(container.querySelectorAll("video")).toHaveLength(1); + + fireEvent.click(screen.getByRole("button", { name: desktopPlay })); + expect(screen.getByLabelText(desktopVideo)).toHaveAttribute( + "poster", + `/videos/${props.id}/desktop.webp`, + ); + expect(container.querySelectorAll("video")).toHaveLength(2); + expect(screen.queryByRole("button")).toBeNull(); + }); + it("path: no demo address -> the browser bar falls back to the project title", () => { withCaptures(["desktop"]); const { container } = render( diff --git a/app/cases/_components/CaseMedia.tsx b/app/cases/_components/CaseMedia.tsx index b7b62aa..13ec69d 100644 --- a/app/cases/_components/CaseMedia.tsx +++ b/app/cases/_components/CaseMedia.tsx @@ -3,43 +3,13 @@ import { displayUrl, } from "@/app/components/showcase/BrowserFrame"; import { PhoneFrame } from "@/app/components/showcase/PhoneFrame"; -import type { ShowcaseAssets } from "@/lib/project-showcase"; +import { CasePlayer } from "./CasePlayer"; import { getCaseMedia } from "./case-media"; -import "../../components/showcase/showcase.css"; - -function CaseVideo({ - assets, - label, - className, -}: { - assets: ShowcaseAssets; - label: string; - className: string; -}) { - return ( - - ); -} /** * Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular). - * O vídeo só baixa quando a pessoa aperta o play. Sem captura gravada, não mostra nada. + * Cada quadro mostra o pôster com um botão de play; o vídeo só baixa quando a pessoa aperta o botão. + * Sem captura gravada, não mostra nada. */ export function CaseMedia({ id, @@ -55,16 +25,12 @@ export function CaseMedia({ if (!desktop && !mobile) return null; return ( -
+
{desktop ? (
- +
@@ -75,11 +41,7 @@ export function CaseMedia({ {mobile ? (
- +
No celular. diff --git a/app/cases/_components/CaseMediaSection.tsx b/app/cases/_components/CaseMediaSection.tsx index 1614a87..7c945be 100644 --- a/app/cases/_components/CaseMediaSection.tsx +++ b/app/cases/_components/CaseMediaSection.tsx @@ -28,7 +28,7 @@ export function CaseMediaSection({ className="ds-section ds-section-dark" aria-labelledby="case-media" > -
+

Como ficou

diff --git a/app/cases/_components/CasePlayer.test.tsx b/app/cases/_components/CasePlayer.test.tsx new file mode 100644 index 0000000..d3b3456 --- /dev/null +++ b/app/cases/_components/CasePlayer.test.tsx @@ -0,0 +1,85 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { CasePlayer } from "./CasePlayer"; + +const title = "SEV Moda Íntima"; + +describe("CasePlayer", () => { + it("shows the poster and an accessible play button instead of a paused video", () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector("video")).toBeNull(); + // a imagem é decorativa: o botão já diz o que o quadro mostra + const poster = container.querySelector("img"); + expect(poster).toHaveAttribute("alt", ""); + expect(poster?.getAttribute("src")).toContain( + encodeURIComponent("/videos/sev-modaintima/desktop.webp"), + ); + const button = screen.getByRole("button", { + name: `Reproduzir o vídeo do site ${title} no computador`, + }); + expect(button).toHaveAttribute("type", "button"); + // só botão de contato é limão: o de play é escuro, com contorno branco + expect(button.innerHTML).not.toContain("--slash"); + expect(button.querySelector("[aria-hidden='true']")).not.toBeNull(); + }); + + it("mounts the video with its sources only after the click, muted and looping, with focus on it", () => { + const assets = getShowcaseAssets("lavacao-047", "desktop"); + render( +
+ +
, + ); + + fireEvent.click(screen.getByRole("button")); + + const video = screen.getByLabelText( + "Vídeo do site Lavação 047 no computador", + ); + expect(video).toHaveFocus(); + expect(video).toHaveAttribute("controls"); + expect(video).toHaveAttribute("poster", assets.poster); + expect(video).toHaveAttribute("width", String(assets.width)); + expect(video).toHaveAttribute("height", String(assets.height)); + expect((video as HTMLVideoElement).muted).toBe(true); + expect((video as HTMLVideoElement).loop).toBe(true); + expect( + [...video.querySelectorAll("source")].map((source) => [ + source.getAttribute("src"), + source.getAttribute("type"), + ]), + ).toEqual(assets.sources.map(({ src, type }) => [src, type])); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("leaves the poster to the phone frame on mobile and names the device in the labels", () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector("img")).toBeNull(); + fireEvent.click( + screen.getByRole("button", { + name: "Reproduzir o vídeo do site SC Plus Aquicultura no celular", + }), + ); + expect( + screen.getByLabelText("Vídeo do site SC Plus Aquicultura no celular"), + ).toHaveAttribute("poster", "/videos/sc-plus/mobile.webp"); + }); +}); diff --git a/app/cases/_components/CasePlayer.tsx b/app/cases/_components/CasePlayer.tsx new file mode 100644 index 0000000..79e61ce --- /dev/null +++ b/app/cases/_components/CasePlayer.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Play } from "lucide-react"; +import Image from "next/image"; +import { useEffect, useRef, useState } from "react"; +import type { Device, ShowcaseAssets } from "@/lib/project-showcase"; + +const deviceLabel: Record = { + desktop: "no computador", + mobile: "no celular", +}; + +/** + * Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão: + * antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador. + * Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho. + */ +export function CasePlayer({ + assets, + title, + device, + sizes = "(min-width: 1280px) 900px, 100vw", +}: { + assets: ShowcaseAssets; + title: string; + device: Device; + sizes?: string; +}) { + const [playing, setPlaying] = useState(false); + const videoRef = useRef(null); + + // o botão sai de cena ao tocar: o foco vai para o vídeo, para quem usa o teclado não o perder + useEffect(() => { + if (playing) videoRef.current?.focus(); + }, [playing]); + + if (playing) { + return ( + + ); + } + + return ( + <> + {/* o quadro do celular já mostra o pôster como fundo; no computador a imagem entra aqui */} + {device === "desktop" ? ( + + ) : null} + + + ); +} diff --git a/app/cases/_components/CaseRelatedServices.tsx b/app/cases/_components/CaseRelatedServices.tsx index f21c999..49bf97e 100644 --- a/app/cases/_components/CaseRelatedServices.tsx +++ b/app/cases/_components/CaseRelatedServices.tsx @@ -1,23 +1,34 @@ import Link from "next/link"; -import { textLink } from "@/app/components/button-styles"; +import { textLinkDark } from "@/app/components/button-styles"; import { getRelatedServices } from "@/lib/site-content"; -/** Links para os serviços que citam este case. Sem serviço relacionado, não mostra nada. */ +/** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */ +export const MAX_RELATED_SERVICES = 2; + +/** Cards dos serviços que citam este case (no máximo dois), sobre fundo escuro. Sem serviço relacionado, não mostra nada. */ export function CaseRelatedServices({ caseId }: { caseId: string }) { - const services = getRelatedServices(caseId); + const services = getRelatedServices(caseId).slice(0, MAX_RELATED_SERVICES); if (services.length === 0) return null; return ( -
- {services.map((service) => ( - - Ver serviço relacionado: {service.navLabel} - - ))} +
+

Serviços ligados a este projeto

+
    + {services.map((service) => ( +
  • +
    +

    {service.navLabel}

    +

    {service.intro}

    + + Ver o serviço: {service.navLabel} + +
    +
  • + ))} +
); } diff --git a/app/cases/_components/CaseResultSection.tsx b/app/cases/_components/CaseResultSection.tsx index e23f704..f5f23f1 100644 --- a/app/cases/_components/CaseResultSection.tsx +++ b/app/cases/_components/CaseResultSection.tsx @@ -4,11 +4,11 @@ export function CaseResultSection({ result }: { result?: string[] }) { return (
-
+

Resultado

-
    +
      {result.map((item) => (
    • {item}
    • ))} diff --git a/app/cases/_components/case-media.ts b/app/cases/_components/case-media.ts index 7bfa1f6..a1f9dbb 100644 --- a/app/cases/_components/case-media.ts +++ b/app/cases/_components/case-media.ts @@ -1,3 +1,4 @@ +import "server-only"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { diff --git a/app/cases/page.loading.test.tsx b/app/cases/page.loading.test.tsx new file mode 100644 index 0000000..ed5cdbe --- /dev/null +++ b/app/cases/page.loading.test.tsx @@ -0,0 +1,66 @@ +import { render } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { clientCases } from "@/lib/site-content"; +import CasesPage from "./page"; + +/** + * Carregamento da lista de cases: a primeira capa (a maior imagem acima da dobra) tem prioridade, + * e os links das capas não pré-carregam as páginas dos cases sozinhos. + */ + +type LinkProps = ComponentProps; + +/** Propriedades de cada `Link` renderizado: o `prefetch` não aparece no HTML, então a rota é espiada aqui. */ +const mockLinkProps: LinkProps[] = []; + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ children, href, prefetch, ...anchor }: LinkProps) => { + mockLinkProps.push({ children, href, prefetch, ...anchor }); + return ( + )}> + {children} + + ); + }, +})); + +beforeEach(() => { + mockLinkProps.length = 0; +}); + +function coverImages(container: HTMLElement) { + return clientCases.map((caseStudy) => { + const image = container + .querySelector(`a[href="/cases/${caseStudy.id}"][aria-hidden="true"]`) + ?.querySelector("img"); + if (!image) throw new Error(`Missing cover of ${caseStudy.id}`); + return image; + }); +} + +describe("CasesPage: carregamento das capas", () => { + it("loads the first cover with priority and the others lazily", () => { + const { container } = render(); + + const [first, ...others] = coverImages(container); + expect(first).not.toHaveAttribute("loading", "lazy"); + expect(others.length).toBeGreaterThan(0); + for (const image of others) { + expect(image).toHaveAttribute("loading", "lazy"); + } + }); + + it("does not prefetch the case pages from the cover links, nor from the card buttons", () => { + render(); + + const toCases = mockLinkProps.filter(({ href }) => + String(href).startsWith("/cases/"), + ); + // uma capa e um botão por case de cliente, e um botão por projeto do laboratório + expect(toCases.length).toBeGreaterThanOrEqual(clientCases.length * 2); + for (const props of toCases) { + expect(props.prefetch).toBe(false); + } + }); +}); diff --git a/app/cases/page.tsx b/app/cases/page.tsx index c2f54de..b2377ef 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -17,13 +17,12 @@ import { labCases, siteUrl, } from "@/lib/site-content"; -import { CaseCover } from "./_components/CaseCover"; -import { getCaseMedia } from "./_components/case-media"; +import { CaseCoverLink } from "./_components/CaseCoverLink"; const pageUrl = `${siteUrl}/cases`; -const pageTitle = "Cases: Sites e Sistemas Feitos para Empresas"; +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, páginas institucionais e mais. Confira cada projeto e converse sobre o seu."; + "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]; export const metadata: Metadata = { @@ -41,33 +40,34 @@ function CaseCard({ caseStudy, withCover, ctaLabel, + priority = false, }: { caseStudy: CaseStudyContent; withCover: boolean; ctaLabel: string; + /** Capa que abre a página (a primeira, o maior elemento visível): carrega antes das demais. */ + priority?: boolean; }) { return (
      - {withCover && getCaseMedia(caseStudy.id, "desktop") ? ( - - - + {withCover ? ( + ) : null}
      {caseStudy.title}

      {caseStudy.headline}

      {caseStudy.summary}

      - + {ctaLabel} {caseStudy.demoUrl ? ( @@ -142,12 +142,13 @@ export default function CasesPage() { Projetos de clientes
      - {clientCases.map((caseStudy) => ( + {clientCases.map((caseStudy, index) => ( ))}
      diff --git a/app/components/Breadcrumb.paths.test.tsx b/app/components/Breadcrumb.paths.test.tsx new file mode 100644 index 0000000..511be47 --- /dev/null +++ b/app/components/Breadcrumb.paths.test.tsx @@ -0,0 +1,53 @@ +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). + */ + +describe("Breadcrumb", () => { + it("path: several items -> a separator before every item but the first", () => { + 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[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"); + }); + + it("path: a single item -> no separator, and it is the current page", () => { + const { container } = render( + , + ); + + expect(container.querySelector("span[aria-hidden='true']")).toBeNull(); + expect(screen.getByRole("link", { name: "Início" })).toHaveAttribute( + "aria-current", + "page", + ); + }); + + it("path: no items -> an empty list inside the navigation", () => { + render(); + + const nav = screen.getByRole("navigation"); + expect(nav.querySelectorAll("li")).toHaveLength(0); + expect(screen.queryByRole("link")).toBeNull(); + }); +}); diff --git a/app/components/Breadcrumb.test.tsx b/app/components/Breadcrumb.test.tsx new file mode 100644 index 0000000..7291099 --- /dev/null +++ b/app/components/Breadcrumb.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, within } from "@testing-library/react"; +import { Breadcrumb } from "./Breadcrumb"; + +describe("Breadcrumb", () => { + const items = [ + { label: "Início", href: "/" }, + { label: "Cases", href: "/cases" }, + { label: "SEV Moda Íntima", href: "/cases/sev-modaintima" }, + ]; + + it("marks only the last item as the current page", () => { + render(); + + const links = within( + screen.getByRole("navigation", { name: "Trilha de navegação" }), + ).getAllByRole("link"); + expect(links.map((link) => link.getAttribute("aria-current"))).toEqual([ + null, + null, + "page", + ]); + }); + + it("keeps the text at 15 px or more, as the body text of the inner pages", () => { + const { container } = render(); + + const list = container.querySelector("ol"); + expect(list).toHaveClass("text-[15px]"); + expect(list?.className).not.toMatch(/text-(xs|sm)\b|text-\[1[0-4]px\]/); + }); +}); diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx index d4e8aeb..4c6f5e1 100644 --- a/app/components/Breadcrumb.tsx +++ b/app/components/Breadcrumb.tsx @@ -7,8 +7,8 @@ export function Breadcrumb({ items: readonly { label: string; href: string }[]; }) { return ( -
-
- {cases.map((caseStudy) => ( -
- {getCaseMedia(caseStudy.id, "desktop") ? ( - - - - ) : null} -
- {isClientCase(caseStudy) - ? caseStudy.title - : `${caseStudy.title} · Protótipo de laboratório`} -
-

{caseStudy.headline}

-

{caseStudy.summary}

-
- - Ler case: {caseStudy.title} - - {caseStudy.demoUrl ? ( - - Abrir o site: {caseStudy.title} - - ) : null} -
-
- ))} -
+ {experienceNote ? ( +

+ + Na prática: + {" "} + {experienceNote} +

+ ) : null} + + {hasCases ? ( +
+ {cases.map((caseStudy) => ( + + ))} +
+ ) : null}
); diff --git a/app/servicos/page.tsx b/app/servicos/page.tsx index 4d11e34..6633734 100644 --- a/app/servicos/page.tsx +++ b/app/servicos/page.tsx @@ -1,7 +1,6 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { CaseCover } from "@/app/cases/_components/CaseCover"; -import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink"; import { Breadcrumb } from "@/app/components/Breadcrumb"; import { pageTop, @@ -15,9 +14,9 @@ import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content"; const pageUrl = `${siteUrl}/servicos`; -const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas"; +const pageTitle = "Sites, sistemas e aplicativos sob medida"; const pageDescription = - "Sites, sistemas web e aplicativos sob medida para sua empresa apresentar o negócio e organizar a operação. Evandro, desenvolvedor em Garuva-SC, atende Joinville, região e todo o Brasil de forma remota."; + "Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Atendimento de Garuva-SC para todo o Brasil."; const businessIntents = [ { @@ -188,21 +187,11 @@ export default function ServicesIndexPage() { key={caseStudy.id} className="border-t border-[var(--border)] pt-sp-6" > - {getCaseMedia(caseStudy.id, "desktop") ? ( - - - - ) : null} +

{caseStudy.title}

{caseStudy.headline}

{caseStudy.summary}

@@ -241,7 +230,7 @@ export default function ServicesIndexPage() {

Antes de contratar

-
+
Quanto custa e quanto tempo leva? diff --git a/lib/__mocks__/server-only.ts b/lib/__mocks__/server-only.ts new file mode 100644 index 0000000..d9f8712 --- /dev/null +++ b/lib/__mocks__/server-only.ts @@ -0,0 +1,2 @@ +// "server-only" lança fora do servidor do Next; nos testes (jsdom) o módulo não faz nada. +export {}; diff --git a/lib/og-card.test.tsx b/lib/og-card.test.tsx index 8874c33..7c87ed6 100644 --- a/lib/og-card.test.tsx +++ b/lib/og-card.test.tsx @@ -1,16 +1,82 @@ +import { statSync } from "node:fs"; import { render } from "@testing-library/react"; -import { OgCard, readProjectOgBackground } from "./og-card"; +import { OG_IMAGE_MAX_BYTES, OgCard, readProjectOgImage } from "./og-card"; +import { PROJECT_VIDEO_IDS } from "./project-showcase"; -describe("readProjectOgBackground", () => { - it("returns the recorded cover of a showcase project as a JPEG data URI", () => { - const uri = readProjectOgBackground("nutriscan"); - expect(uri).toMatch(/^data:image\/jpeg;base64,/); - expect((uri ?? "").length).toBeGreaterThan(5_000); +jest.mock("node:fs", () => { + const actual = jest.requireActual("node:fs"); + return { ...actual, statSync: jest.fn(actual.statSync) }; +}); + +const mockedStatSync = jest.mocked(statSync); +const actualStatSync = + jest.requireActual("node:fs").statSync; + +afterEach(() => { + mockedStatSync.mockImplementation(actualStatSync); +}); + +/** Largura e altura de um JPEG, lidas do primeiro marcador de quadro (SOF). */ +function jpegSize(image: Buffer) { + let offset = 2; + while (offset < image.length) { + const marker = image[offset + 1]; + const length = image.readUInt16BE(offset + 2); + if ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 + ) { + return { + height: image.readUInt16BE(offset + 5), + width: image.readUInt16BE(offset + 7), + }; + } + offset += 2 + length; + } + throw new Error("JPEG sem marcador de quadro"); +} + +describe("readProjectOgImage", () => { + it("returns the recorded cover of a showcase project as it is, in JPEG", () => { + const image = readProjectOgImage("nutriscan"); + expect(image?.subarray(0, 2).toString("hex")).toBe("ffd8"); + expect(image?.length).toBeGreaterThan(5_000); + }); + + it("serves every recorded cover below 150 KB and in the 1200x630 card size", () => { + expect(OG_IMAGE_MAX_BYTES).toBe(150_000); + for (const id of PROJECT_VIDEO_IDS) { + const image = readProjectOgImage(id); + expect(image).toBeDefined(); + expect(image?.length ?? Infinity).toBeLessThan(150_000); + expect(jpegSize(image as Buffer)).toEqual({ width: 1200, height: 630 }); + } + }); + + it("returns undefined for a cover that weighs the limit or more, so the plain card is used", () => { + mockedStatSync.mockReturnValue({ + size: OG_IMAGE_MAX_BYTES, + } as ReturnType); + expect(readProjectOgImage("nutriscan")).toBeUndefined(); + + mockedStatSync.mockReturnValue({ + size: OG_IMAGE_MAX_BYTES - 1, + } as ReturnType); + expect(readProjectOgImage("nutriscan")).toBeDefined(); }); it("returns undefined for projects without a recorded cover", () => { - expect(readProjectOgBackground("twins-lab")).toBeUndefined(); - expect(readProjectOgBackground("../../etc/passwd")).toBeUndefined(); + expect(readProjectOgImage("twins-lab")).toBeUndefined(); + expect(readProjectOgImage("../../etc/passwd")).toBeUndefined(); + }); + + it("returns undefined when the file is missing on disk", () => { + mockedStatSync.mockImplementation(() => { + throw new Error("ENOENT"); + }); + expect(readProjectOgImage("nutriscan")).toBeUndefined(); }); }); diff --git a/lib/og-card.tsx b/lib/og-card.tsx index e1bb1ab..b4ab0ef 100644 --- a/lib/og-card.tsx +++ b/lib/og-card.tsx @@ -1,4 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; +import "server-only"; +import { readFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { getOgImagePath, PROJECT_VIDEO_IDS } from "./project-showcase"; @@ -8,13 +9,24 @@ const brandMark = `data:image/png;base64,${readFileSync( export const OG_SIZE = { width: 1200, height: 630 } as const; export const OG_CONTENT_TYPE = "image/png"; +export const OG_PHOTO_CONTENT_TYPE = "image/jpeg"; -/** Capa 1200x630 gravada da página do projeto (public/videos//og.jpg), como data URI; `undefined` se não existir. */ -export function readProjectOgBackground(id: string): string | undefined { +/** Teto de uma imagem de compartilhamento: quem recebe o link baixa a imagem a cada prévia, então ela precisa ser leve. */ +export const OG_IMAGE_MAX_BYTES = 150_000; + +/** + * Capa 1200x630 gravada da página do projeto (public/videos//og.jpg), usada como está na imagem de compartilhamento. + * `undefined` se não existir ou se pesar o teto ou mais: nesse caso quem chama gera o cartão simples, sem imagem de fundo. + */ +export function readProjectOgImage(id: string): Buffer | undefined { if (!PROJECT_VIDEO_IDS.includes(id)) return undefined; const path = join(process.cwd(), "public", getOgImagePath(id)); - if (!existsSync(path)) return undefined; - return `data:image/jpeg;base64,${readFileSync(path).toString("base64")}`; + try { + if (statSync(path).size >= OG_IMAGE_MAX_BYTES) return undefined; + return readFileSync(path); + } catch { + return undefined; + } } export function OgCard({ diff --git a/lib/portfolio-data.content.test.ts b/lib/portfolio-data.content.test.ts new file mode 100644 index 0000000..f7c6c93 --- /dev/null +++ b/lib/portfolio-data.content.test.ts @@ -0,0 +1,41 @@ +import { portfolio } from "./portfolio-data"; + +describe("public/portfolio-data.json", () => { + it("writes the Innovo company name with the correct accents and case", () => { + const innovo = portfolio.experiences.find( + ({ id }) => id === "innovo-tecnologia", + ); + expect(innovo?.company).toBe("Innovo Tecnologia da Informação Ltda."); + expect(JSON.stringify(portfolio)).not.toMatch(/Informacao|Da Informa/); + }); + + it("keeps the kind of every project, which separates client work from the laboratory", () => { + expect( + portfolio.projects.map(({ id, kind }) => [id, kind] as const), + ).toEqual([ + ["sev-modaintima", "cliente"], + ["lavacao-047", "cliente"], + ["nutriscan", "laboratorio"], + ["twins-lab", "laboratorio"], + ["reddit-explorer", "laboratorio"], + ["sc-plus", "cliente"], + ["gcc-installer", "laboratorio"], + ["as-servicos", "cliente"], + ]); + }); + + it("describes each project in a subtitle a business owner understands", () => { + const subtitles = Object.fromEntries( + portfolio.projects.map(({ id, subtitle }) => [id, subtitle]), + ); + expect(subtitles["sc-plus"]).toBe( + "Site institucional com áreas, projetos e contato", + ); + expect(subtitles["gcc-installer"]).toBe( + "Instalador do compilador GCC para Windows", + ); + expect(Object.values(subtitles).join(" ")).not.toMatch( + /Next\.js|MongoDB|MinGW|Gêmeo Digital|gerada em código/, + ); + }); +}); diff --git a/lib/portfolio-data.server.ts b/lib/portfolio-data.server.ts index 36fa0b7..51b8f74 100644 --- a/lib/portfolio-data.server.ts +++ b/lib/portfolio-data.server.ts @@ -1,3 +1,4 @@ +import "server-only"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { diff --git a/lib/seo.test.ts b/lib/seo.test.ts index 404ad55..d1028dc 100644 --- a/lib/seo.test.ts +++ b/lib/seo.test.ts @@ -1,15 +1,23 @@ import sitemap from "../app/sitemap"; import { buildBreadcrumbList, + buildCaseJsonLd, buildPageSocialMetadata, buildPersonJsonLd, buildProfilePageJsonLd, buildServiceJsonLd, buildWebSiteJsonLd, + getCaseImageUrl, SEO_IDS, twitterHandle, } from "./seo"; -import { caseStudies, servicePages, siteName, siteUrl } from "./site-content"; +import { + caseStudies, + isClientCase, + servicePages, + siteName, + siteUrl, +} from "./site-content"; const representativeService = servicePages[0]; if (!representativeService) throw new Error("Missing representative service"); @@ -89,6 +97,63 @@ it("builds a canonical service with only the verified service area", () => { }); }); +function getCase(id: string) { + const caseStudy = caseStudies.find((item) => item.id === id); + if (!caseStudy) throw new Error(`Missing case ${id}`); + return caseStudy; +} + +it("describes a client case as a CreativeWork made by the person for the client", () => { + const caseStudy = getCase("sev-modaintima"); + const pageUrl = `${siteUrl}/cases/${caseStudy.id}`; + + expect(buildCaseJsonLd(caseStudy)).toEqual({ + "@type": "CreativeWork", + "@id": `${pageUrl}#creative-work`, + name: "SEV Moda Íntima: Catálogo digital e loja virtual", + description: caseStudy.metaDescription, + url: pageUrl, + image: `${pageUrl}/opengraph-image/${caseStudy.id}`, + inLanguage: "pt-BR", + creator: { "@id": SEO_IDS.person }, + sourceOrganization: { + "@type": "Organization", + name: "SEV Moda Íntima", + url: "https://sevmodaintima.com.br", + }, + keywords: + "Next.js 16, TypeScript, PostgreSQL, Prisma, Tailwind CSS 4, Framer Motion", + }); +}); + +it("builds a CreativeWork for every case, with the client only on client work", () => { + for (const caseStudy of caseStudies) { + const work = buildCaseJsonLd(caseStudy); + expect(work).toMatchObject({ + "@type": "CreativeWork", + url: `${siteUrl}/cases/${caseStudy.id}`, + image: getCaseImageUrl(caseStudy.id), + creator: { "@id": SEO_IDS.person }, + }); + expect(work.name).toContain(caseStudy.title); + expect(work.description.length).toBeGreaterThan(40); + expect(work.keywords).toBe(caseStudy.stack.join(", ")); + expect("sourceOrganization" in work).toBe(isClientCase(caseStudy)); + } +}); + +it("omits the client address when a client case has no public site", () => { + const work = buildCaseJsonLd(getCase("as-servicos")) as { + url: string; + sourceOrganization?: Record; + }; + expect(work.sourceOrganization).toStrictEqual({ + "@type": "Organization", + name: "AS Serviços", + }); + expect(work.url).toBe(`${siteUrl}/cases/as-servicos`); +}); + it("builds contiguous breadcrumbs from readonly tuples", () => { const pageUrl = `${siteUrl}/servicos/x`; const items = [ diff --git a/lib/seo.ts b/lib/seo.ts index 18e73c8..3752278 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -1,5 +1,11 @@ import type { Metadata } from "next"; -import { type ServicePageContent, siteName, siteUrl } from "./site-content"; +import { + type CaseStudyContent, + isClientCase, + type ServicePageContent, + siteName, + siteUrl, +} from "./site-content"; export const twitterHandle = "@evandrodevbr"; @@ -78,6 +84,37 @@ export function buildServiceJsonLd(service: ServicePageContent) { } as const; } +/** Imagem de compartilhamento do case: a mesma que as redes recebem em `og:image` (rota opengraph-image do case). */ +export function getCaseImageUrl(id: string) { + return `${siteUrl}/cases/${id}/opengraph-image/${id}`; +} + +/** Trabalho do case como `CreativeWork`: autor ligado à pessoa e, nos projetos de cliente, o cliente em nome de quem foi feito. */ +export function buildCaseJsonLd(caseStudy: CaseStudyContent) { + const pageUrl = `${siteUrl}/cases/${caseStudy.id}`; + + return { + "@type": "CreativeWork", + "@id": `${pageUrl}#creative-work`, + name: `${caseStudy.title}: ${caseStudy.subtitle}`, + description: caseStudy.metaDescription, + url: pageUrl, + image: getCaseImageUrl(caseStudy.id), + inLanguage: "pt-BR", + creator: { "@id": SEO_IDS.person }, + ...(isClientCase(caseStudy) + ? { + sourceOrganization: { + "@type": "Organization", + name: caseStudy.title, + ...(caseStudy.demoUrl ? { url: caseStudy.demoUrl } : {}), + }, + } + : {}), + keywords: caseStudy.stack.join(", "), + }; +} + export function buildBreadcrumbList( pageUrl: string, items: readonly (readonly [name: string, url: string])[], diff --git a/lib/server-only.test.ts b/lib/server-only.test.ts new file mode 100644 index 0000000..a772251 --- /dev/null +++ b/lib/server-only.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const root = join(__dirname, ".."); + +describe("fronteira entre servidor e cliente", () => { + it.each([ + "app/cases/_components/case-media.ts", + "lib/og-card.tsx", + "lib/portfolio-data.server.ts", + ])("%s só pode ser importado no servidor", (file) => { + const source = readFileSync(join(root, file), "utf8"); + expect(source).toMatch(/^import "server-only";$/m); + }); + + it("declara server-only como dependência", () => { + const manifest = JSON.parse( + readFileSync(join(root, "package.json"), "utf8"), + ) as { dependencies: Record }; + expect(manifest.dependencies["server-only"]).toBeDefined(); + }); + + it("não deixa nenhum componente de cliente importar um módulo só de servidor", () => { + const serverOnly = + /from "(?:@\/lib\/og-card|@\/lib\/portfolio-data\.server|(?:\.{1,2}\/)+(?:[\w-]+\/)*case-media)"/; + const clientFiles = [ + "app/cases/_components/CasePlayer.tsx", + "app/curriculo/CvDownloadLink.tsx", + ]; + for (const file of clientFiles) { + const source = readFileSync(join(root, file), "utf8"); + expect(source).toMatch(/^"use client";/); + expect(source).not.toMatch(serverOnly); + } + }); +}); diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index ee49ef7..af2d0fe 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; +import portfolioData from "@/public/portfolio-data.json"; import { caseStudies, clientCases, @@ -77,7 +78,7 @@ it("leaves the commercial offer of every service empty for the owner to fill in" it("mentions the region and a benefit in the title and description of every service", () => { for (const service of servicePages) { - expect(`${service.seoTitle} ${service.metaDescription}`).toMatch( + expect(`${service.metaTitle} ${service.metaDescription}`).toMatch( /Garuva|Joinville|Santa Catarina/, ); expect(service.metaDescription).toMatch(/remoto|Brasil/); @@ -148,6 +149,89 @@ it("resolves every declared related case ID", () => { expect(unresolved).toEqual([]); }); +it("only cites project ids that exist in public/portfolio-data.json", () => { + const projectIds = new Set(portfolioData.projects.map(({ id }) => id)); + const cited = [ + ...caseStudies.map(({ id }) => id), + ...servicePages.flatMap(({ relatedCaseIds }) => relatedCaseIds), + ...servicePages.flatMap(({ caseRelevance }) => Object.keys(caseRelevance)), + ]; + + expect(cited.length).toBeGreaterThan(0); + expect(cited.filter((id) => !projectIds.has(id))).toEqual([]); +}); + +it("writes one sentence on why each related case matters to its service", () => { + for (const service of servicePages) { + expect(Object.keys(service.caseRelevance).sort()).toEqual( + [...service.relatedCaseIds].sort(), + ); + for (const sentence of Object.values(service.caseRelevance)) { + expect(sentence.length).toBeGreaterThan(30); + expect(sentence).toMatch(/[.]$/); + } + } +}); + +it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => { + const apolo = portfolioData.experiences.find( + ({ id }) => id === "apolo-energia", + ); + expect(apolo?.period).toMatch(/2022/); + + const withNote = servicePages + .filter(({ experienceNote }) => experienceNote) + .map(({ slug }) => slug); + expect(withNote).toEqual([ + "integracao-hardware-software-iot", + "arquitetura-de-software", + ]); + for (const slug of withNote) { + const note = getService(slug).experienceNote ?? ""; + expect(note).toMatch(/Apolo Energia/); + expect(note).toMatch(/desde 2022/i); + expect(note).toMatch(/sistemas internos/i); + expect(note).toMatch(/aplicativos/i); + expect(note).toMatch(/APIs/); + // sem link e sem detalhe de operação do cliente + expect(note).not.toMatch(/https?:|www\.|\.com/i); + } +}); + +it("keeps the problem of every client case concrete, with no invented figure or quote", () => { + for (const caseStudy of clientCases) { + expect(caseStudy.problem.length).toBeGreaterThan(200); + expect(caseStudy.problem).not.toMatch(/["“”]/); + expect(caseStudy.problem).not.toMatch(/\d+\s?%|R\$\s?\d/); + } + expect( + caseStudies.find(({ id }) => id === "sev-modaintima")?.problem, + ).toMatch(/tamanho/); + expect( + caseStudies.find(({ id }) => id === "sev-modaintima")?.problem, + ).toMatch(/WhatsApp/); +}); + +it("leaves the optional facts of every case empty until there is real data", () => { + for (const caseStudy of caseStudies) { + expect(caseStudy.facts ?? []).toEqual([]); + } +}); + +it("writes project subtitles without technical jargon", () => { + const jargon = + /Next\.js|React|MongoDB|MinGW|\/GCC|tkinter|\bSSE\b|Three\.js|em código|r\/artificial/i; + for (const caseStudy of caseStudies) { + expect(caseStudy.subtitle).not.toMatch(jargon); + } + for (const project of portfolioData.projects) { + expect(project.subtitle).not.toMatch(jargon); + } + expect( + portfolioData.projects.find(({ id }) => id === "sc-plus")?.subtitle, + ).toBe("Site institucional com áreas, projetos e contato"); +}); + it("does not publish the unavailable Twins Lab demo", () => { expect( caseStudies.find((item) => item.id === "twins-lab")?.demoUrl, diff --git a/lib/site-content.ts b/lib/site-content.ts index 51cd23c..60d9873 100644 --- a/lib/site-content.ts +++ b/lib/site-content.ts @@ -8,7 +8,9 @@ export interface ServicePageContent { slug: string; navLabel: string; eyebrow: string; - seoTitle: string; + /** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */ + metaTitle: string; + /** Descrição para busca, com até 155 caracteres. */ metaDescription: string; headline: string; intro: string; @@ -22,6 +24,10 @@ export interface ServicePageContent { ctaLabel: string; /** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */ relatedCaseIds: string[]; + /** Por que cada case relacionado interessa a este serviço, em uma frase (chave: id do case). */ + caseRelevance: Record; + /** Experiência profissional citada junto dos cases, só em texto, sem link nem detalhe interno. */ + experienceNote?: string; /** Oferta: preencha só com valores reais. O bloco "Investimento e prazo" só aparece quando algum campo existe. */ startingPrice?: string; typicalTimeline?: string; @@ -35,9 +41,13 @@ export interface CaseStudyContent { title: string; /** Tipo do projeto, em poucas palavras. */ subtitle: string; + /** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */ + metaTitle: string; + /** Descrição para busca, com até 155 caracteres. */ + metaDescription: string; /** Título da página, em linguagem de negócio. */ headline: string; - /** Resumo de uma ou duas frases (cards, descrição de busca e abertura da página). */ + /** Resumo de uma ou duas frases (cards e abertura da página). */ summary: string; /** O que muda para quem usa, em uma frase. */ impact: string; @@ -45,11 +55,13 @@ export interface CaseStudyContent { context: string; /** O problema que o projeto resolve. */ problem: string; + /** Fatos verificáveis do projeto, como "No ar desde ...". Só aparece na página quando há algum; sem dado real, fica de fora. */ + facts?: string[]; /** O que foi feito, escrito como benefício. */ deliveries: string[]; /** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */ result?: string[]; - /** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos no fim da página). */ + /** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos, logo depois do que foi feito). */ stack: string[]; decisions: string[]; demoUrl?: string | null; @@ -62,9 +74,9 @@ export const servicePages: ServicePageContent[] = [ slug: "criacao-de-sites", navLabel: "Sites profissionais", eyebrow: "Presença digital", - seoTitle: "Criação de Sites para Empresas em Garuva e Joinville", + metaTitle: "Criação de sites para empresas em Garuva", metaDescription: - "Site profissional para apresentar sua empresa e levar o cliente ao seu WhatsApp. Desenvolvido em Garuva-SC, com atendimento em Joinville, região e remoto no Brasil.", + "Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", headline: "Um site que explica seu negócio.", answer: "Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.", @@ -99,6 +111,16 @@ export const servicePages: ServicePageContent[] = [ stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"], ctaLabel: "Conversar sobre meu site", relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"], + caseRelevance: { + "sev-modaintima": + "Um catálogo com busca e filtros que leva o pedido da cliente direto ao WhatsApp da loja.", + "lavacao-047": + "Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.", + "sc-plus": + "Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.", + "as-servicos": + "Um site institucional que conta duas frentes da empresa em uma só história, com cena 3D na rolagem.", + }, faq: [ { question: "Quanto custa e o que preciso ter pronto?", @@ -121,9 +143,9 @@ export const servicePages: ServicePageContent[] = [ slug: "engenharia-de-software", navLabel: "Sistemas web e aplicativos mobile", eyebrow: "Engenharia de software", - seoTitle: "Sistemas Web e Aplicativos sob Medida em Santa Catarina", + metaTitle: "Sistemas web e aplicativos sob medida", metaDescription: - "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas em um só lugar. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", + "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.", headline: "Sistemas e apps para o seu trabalho.", answer: "Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.", @@ -158,6 +180,12 @@ export const servicePages: ServicePageContent[] = [ stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"], ctaLabel: "Conversar sobre meu sistema ou app", relatedCaseIds: ["sev-modaintima", "lavacao-047"], + caseRelevance: { + "sev-modaintima": + "Um sistema com painel para cadastrar produtos, controlar o estoque por tamanho e acompanhar pedidos.", + "lavacao-047": + "Um painel em que o dono altera preços, textos e fotos, e a mudança aparece no site sem nova publicação.", + }, faq: [ { question: "Preciso saber qual tecnologia usar?", @@ -180,9 +208,9 @@ export const servicePages: ServicePageContent[] = [ slug: "consultoria-nextjs", navLabel: "Melhorias em sites e sistemas Next.js", eyebrow: "Consultoria Next.js", - seoTitle: "Consultoria Next.js para Melhorar Seu Site ou Sistema", + metaTitle: "Consultoria Next.js para site ou sistema", metaDescription: - "Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Atendimento a partir de Garuva-SC, em Joinville e remoto no Brasil.", + "Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Garuva-SC, Joinville e remoto no Brasil.", headline: "Seu projeto Next.js pode funcionar melhor.", answer: "Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.", @@ -217,6 +245,14 @@ export const servicePages: ServicePageContent[] = [ stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"], ctaLabel: "Conversar sobre meu projeto Next.js", relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"], + caseRelevance: { + "sev-modaintima": + "Um projeto em Next.js 16 com catálogo, busca, estoque e painel, que conheço por dentro.", + "lavacao-047": + "Um projeto em Next.js 16 em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.", + "sc-plus": + "Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.", + }, faq: [ { question: "Não sei se meu site usa Next.js. Posso conversar?", @@ -239,9 +275,9 @@ export const servicePages: ServicePageContent[] = [ slug: "tauri-rust", navLabel: "Aplicativos desktop para Windows e Linux", eyebrow: "Aplicativos desktop", - seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust", + metaTitle: "Aplicativos desktop para Windows e Linux", metaDescription: - "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da sua empresa. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", + "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.", headline: "Aplicativos para Windows e Linux.", answer: "Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são as plataformas atendidas; as versões e distribuições compatíveis são combinadas em cada projeto.", @@ -281,6 +317,7 @@ export const servicePages: ServicePageContent[] = [ ], ctaLabel: "Conversar sobre meu aplicativo desktop", relatedCaseIds: [], + caseRelevance: {}, faq: [ { question: "O mesmo aplicativo pode funcionar em Windows e Linux?", @@ -303,9 +340,9 @@ export const servicePages: ServicePageContent[] = [ slug: "integracao-hardware-software-iot", navLabel: "Integração de equipamentos e sistemas", eyebrow: "Hardware, software e IoT", - seoTitle: "Integração de Equipamentos e Sistemas (IoT) em Santa Catarina", + metaTitle: "Integração de equipamentos e sistemas (IoT)", metaDescription: - "Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", + "Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva-SC, Joinville e remoto no Brasil.", headline: "Dados dos equipamentos no seu sistema.", answer: "Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro vemos o que cada dispositivo permite acessar; a integração segue essas interfaces e os requisitos de segurança e funcionamento.", @@ -340,6 +377,12 @@ export const servicePages: ServicePageContent[] = [ stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"], ctaLabel: "Avaliar minha integração", relatedCaseIds: ["twins-lab"], + caseRelevance: { + "twins-lab": + "Um reator simulado envia seu estado a um painel a cada dois segundos, com o histórico guardado.", + }, + experienceNote: + "Desde 2022 trabalho com isso na Apolo Energia: sistemas internos, aplicativos, APIs e integração de dispositivos IoT para a troca de dados entre serviços e parceiros. Por serem sistemas de uso interno, não há link nem detalhes públicos.", faq: [ { question: "Qualquer equipamento pode ser conectado?", @@ -362,10 +405,9 @@ export const servicePages: ServicePageContent[] = [ slug: "arquitetura-de-software", navLabel: "Diagnóstico e arquitetura de software", eyebrow: "Diagnóstico e decisão", - seoTitle: - "Diagnóstico e Arquitetura de Software para Decidir com Segurança", + metaTitle: "Diagnóstico e arquitetura de software", metaDescription: - "Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano de ação, de Garuva-SC para Joinville e todo o Brasil.", + "Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano, de Garuva-SC para todo o Brasil.", headline: "Entenda o problema antes de mudar tudo.", answer: "Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.", @@ -406,6 +448,12 @@ export const servicePages: ServicePageContent[] = [ ], ctaLabel: "Conversar sobre um diagnóstico", relatedCaseIds: ["sev-modaintima"], + caseRelevance: { + "sev-modaintima": + "Uma loja completa, com catálogo, pedidos, estoque e painel, em que as decisões técnicas estão explicadas no case.", + }, + experienceNote: + "Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.", faq: [ { question: "Preciso ter uma equipe técnica para contratar?", @@ -431,6 +479,9 @@ export const caseStudies: CaseStudyContent[] = [ id: "sev-modaintima", title: "SEV Moda Íntima", subtitle: "Catálogo digital e loja virtual", + metaTitle: "SEV Moda Íntima: loja virtual com WhatsApp", + metaDescription: + "Loja virtual de moda íntima: a cliente escolhe o tamanho e envia o pedido pelo WhatsApp, e a loja controla produtos e estoque em um painel.", headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp", summary: "Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.", @@ -439,7 +490,7 @@ export const caseStudies: CaseStudyContent[] = [ context: "A SEV Moda Íntima vende moda íntima pela internet. O site funciona como vitrine e loja virtual: apresenta os produtos, recebe os pedidos pelo WhatsApp e dá à loja um painel para cuidar do catálogo.", problem: - "Uma loja de moda íntima tem muitos produtos, em vários modelos e tamanhos. A cliente precisa achar o que procura com facilidade e fechar o pedido sem complicação. A loja precisa manter catálogo, estoque e pedidos organizados.", + "A loja vende moda íntima em várias peças e tamanhos. A cliente precisa achar o que procura entre as opções e escolher a medida certa, e o pedido segue pelo WhatsApp, com o frete calculado pela distância. A loja, por sua vez, precisa saber o que ainda tem de cada tamanho.", deliveries: [ "Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.", "Filtros e busca inteligente: a cliente encontra o que procura e os itens mais relevantes aparecem primeiro.", @@ -468,6 +519,9 @@ export const caseStudies: CaseStudyContent[] = [ id: "lavacao-047", title: "Lavação 047", subtitle: "Site e painel para lavação automotiva", + metaTitle: "Lavação 047: site com preços e WhatsApp", + metaDescription: + "Site da Lavação 047, em Garuva-SC: preço pelo porte do veículo, pedido pelo WhatsApp e painel para o dono editar tudo sozinho.", headline: "Site com tabela de preços e pedido pelo WhatsApp para uma lavação em Garuva", summary: @@ -477,7 +531,7 @@ export const caseStudies: CaseStudyContent[] = [ context: "A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.", problem: - "Quem procura uma lavação quer saber quanto custa para o seu veículo e como chegar. O dono, por sua vez, precisa manter preços, textos e fotos em dia sem depender de alguém a cada mudança.", + "O cliente chega com perguntas diretas: quanto custa para o meu carro, onde fica e como pedir. O preço muda com o porte do veículo, e o contato é pelo WhatsApp. O dono, por sua vez, precisa mudar preços, textos e fotos quando quiser, sem esperar por um desenvolvedor.", deliveries: [ "Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.", "Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.", @@ -508,6 +562,9 @@ export const caseStudies: CaseStudyContent[] = [ id: "nutriscan", title: "NutriScan", subtitle: "Consulta de produtos por nome e código de barras", + metaTitle: "NutriScan: consulta de alimentos", + metaDescription: + "Consulta de alimentos por nome ou código de barras, com cache próprio e a base pública Open Food Facts. Projeto próprio, no ar para teste.", headline: "Consulta de alimentos por nome ou código de barras", summary: "Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando um cache próprio com a base pública Open Food Facts.", @@ -534,7 +591,10 @@ export const caseStudies: CaseStudyContent[] = [ { id: "twins-lab", title: "Twins Lab", - subtitle: "Protótipo de laboratório de gêmeo digital industrial", + subtitle: "Painel ao vivo de um reator simulado", + metaTitle: "Twins Lab: painel de reator simulado", + metaDescription: + "Protótipo de painel ao vivo que mostra a simulação de um reator industrial, com acesso por login e histórico guardado. Projeto próprio.", headline: "Painel ao vivo para acompanhar a simulação de um reator industrial", summary: @@ -561,7 +621,10 @@ export const caseStudies: CaseStudyContent[] = [ { id: "reddit-explorer", title: "Reddit Posts Explorer", - subtitle: "Posts do r/artificial em MongoDB, filtrados por período", + subtitle: "Posts de um fórum de IA, filtrados por período", + metaTitle: "Reddit Posts Explorer: coleta e filtros", + metaDescription: + "Coleta os posts em alta do r/artificial sem duplicar e permite filtrar por período e ordenar por votos ou comentários. Projeto próprio.", headline: "Posts de um fórum de inteligência artificial, coletados e filtrados por período", summary: @@ -588,7 +651,10 @@ export const caseStudies: CaseStudyContent[] = [ { id: "sc-plus", title: "SC Plus Aquicultura", - subtitle: "Página institucional", + subtitle: "Site institucional com áreas, projetos e contato", + metaTitle: "SC Plus Aquicultura: página institucional", + metaDescription: + "Página institucional da SC Plus Aquicultura, com áreas de atuação, projetos, apoiadores e contato, preparada para os buscadores.", headline: "Página institucional para a SC Plus Aquicultura ser encontrada na internet", summary: @@ -596,9 +662,9 @@ export const caseStudies: CaseStudyContent[] = [ impact: "A instituição ganhou uma página própria, preparada para ser encontrada nos buscadores.", context: - "A SC Plus Aquicultura é uma instituição do setor de aquicultura. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.", + "A SC Plus Aquicultura é uma instituição de Garuva, em Santa Catarina, que atua com educação, ciência e tecnologia sustentável. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.", problem: - "Sem uma página própria, quem procura a SC Plus na internet não encontra uma apresentação clara do que a instituição faz. Faltava um endereço próprio, com as áreas, os projetos, os apoiadores e o contato.", + "A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.", deliveries: [ "Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.", "Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.", @@ -616,7 +682,10 @@ export const caseStudies: CaseStudyContent[] = [ { id: "gcc-installer", title: "MinGW GCC Installer", - subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows", + subtitle: "Instalador do compilador GCC para Windows", + metaTitle: "MinGW GCC Installer: instalador no Windows", + metaDescription: + "Aplicativo para Windows que lista as versões do MinGW-w64, instala o compilador GCC e ajusta o PATH, tudo em uma janela. Projeto próprio.", headline: "Instalador do compilador GCC para Windows em uma única janela", summary: "Aplicativo para Windows que lista as versões do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e deixa o GCC pronto para uso, tudo em uma janela.", @@ -644,6 +713,9 @@ export const caseStudies: CaseStudyContent[] = [ id: "as-servicos", title: "AS Serviços", subtitle: "Site institucional com cena 3D", + metaTitle: "AS Serviços: site com cena 3D", + metaDescription: + "Site institucional da AS Serviços com uma cena 3D que acompanha a rolagem e conta a engenharia física e o software numa só história.", headline: "Site institucional com uma cena 3D que acompanha a rolagem", summary: "Site institucional da AS Serviços com uma cena 3D criada em código: uma casa e uma placa-mãe desenhadas em linhas que reagem à rolagem da página.", @@ -652,7 +724,7 @@ export const caseStudies: CaseStudyContent[] = [ context: "A AS Serviços reúne engenharia física e software. O site precisava mostrar as duas frentes na mesma história.", problem: - "Como contar, em uma única página, duas frentes tão diferentes quanto a engenharia física e o software, de um jeito que prenda a atenção de quem rola a página?", + "A empresa reúne duas frentes bem diferentes: a engenharia física, de um lado, e o software, do outro. Em um site comum, as duas podem virar uma lista de serviços sem relação entre si. O desafio era mostrar as duas na mesma história, de um jeito que prenda a atenção de quem rola a página e que continue leve em celulares e conexões mais simples.", deliveries: [ "Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.", "As duas frentes da empresa, a física e a digital, aparecem na mesma história visual.", @@ -670,10 +742,13 @@ export const caseStudies: CaseStudyContent[] = [ }, ]; +const clientProjectIds = new Set( + portfolio.projects.filter(isClientProject).map(({ id }) => id), +); + /** Projetos feitos para clientes ficam à frente; os pessoais e de estudo vão para o Laboratório. */ export function isClientCase(caseStudy: { id: string }) { - const project = portfolio.projects.find(({ id }) => id === caseStudy.id); - return project ? isClientProject(project) : false; + return clientProjectIds.has(caseStudy.id); } export const clientCases = caseStudies.filter(isClientCase); diff --git a/public/portfolio-data.json b/public/portfolio-data.json index 55cfd3a..7667dce 100644 --- a/public/portfolio-data.json +++ b/public/portfolio-data.json @@ -12,7 +12,7 @@ { "id": "innovo-tecnologia", "title": "Desenvolvedor Fullstack", - "company": "Innovo Tecnologia Da Informacao Ltda.", + "company": "Innovo Tecnologia da Informação Ltda.", "period": "Dezembro 2024 - Fevereiro 2025", "location": "Joinville, Santa Catarina, Brasil", "type": "Tempo Integral · Híbrido", @@ -166,7 +166,7 @@ "id": "twins-lab", "kind": "laboratorio", "title": "Twins Lab", - "subtitle": "Protótipo de Gêmeo Digital Industrial", + "subtitle": "Painel ao vivo de um reator simulado", "description": "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e transmissão de eventos do servidor (SSE) a cada dois segundos.", "longDescription": "A rota exige sessão do NextAuth antes de abrir a transmissão SSE. O simulador salva dados e estado no MongoDB e notifica clientes quando execução ou cenário mudam.", "technologies": ["Next.js", "TypeScript", "NextAuth", "MongoDB", "SSE"], @@ -191,7 +191,7 @@ "id": "reddit-explorer", "kind": "laboratorio", "title": "Reddit Posts Explorer", - "subtitle": "Posts do r/artificial em MongoDB, filtrados por período", + "subtitle": "Posts de um fórum de IA, filtrados por período", "description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB sem duplicar, pelo id, e permite filtrar por período e ordenar por votos ou comentários.", "longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação.", "technologies": [ @@ -224,7 +224,7 @@ "id": "sc-plus", "kind": "cliente", "title": "SC Plus Aquicultura", - "subtitle": "Página institucional em Next.js 15", + "subtitle": "Site institucional com áreas, projetos e contato", "description": "Página institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.", "longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.", "technologies": [ @@ -254,7 +254,7 @@ "id": "gcc-installer", "kind": "laboratorio", "title": "MinGW GCC Installer", - "subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows", + "subtitle": "Instalador do compilador GCC para Windows", "description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o compilador ao PATH do usuário no registro do Windows.", "longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Feito para Windows, com o 7-Zip no caminho padrão.", "technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"], @@ -281,7 +281,7 @@ "id": "as-servicos", "kind": "cliente", "title": "AS Serviços", - "subtitle": "Site institucional com cena 3D gerada em código", + "subtitle": "Site institucional com cena 3D na rolagem", "description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa desenhada em linhas e uma placa-mãe ligadas ao progresso da rolagem.", "longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, preferência por menos movimento, economia de dados, conexão e capacidade do dispositivo.", "technologies": ["Next.js 16", "TypeScript", "Three.js"],