diff --git a/app/ia/opengraph-image.tsx b/app/ia/opengraph-image.tsx index 62786a1..f5873b9 100644 --- a/app/ia/opengraph-image.tsx +++ b/app/ia/opengraph-image.tsx @@ -1,7 +1,7 @@ import { ImageResponse } from "next/og"; import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card"; -export const alt = "Revisão de app feito com IA"; +export const alt = "Revisão de sistema e de app feito com IA"; export const size = OG_SIZE; export const contentType = OG_CONTENT_TYPE; @@ -9,7 +9,7 @@ export default function Image() { return new ImageResponse( , OG_SIZE, diff --git a/app/ia/page.test.tsx b/app/ia/page.test.tsx index 9444a49..16ffbda 100644 --- a/app/ia/page.test.tsx +++ b/app/ia/page.test.tsx @@ -1,18 +1,24 @@ import { render, screen, within } from "@testing-library/react"; +import { offerLabels } from "@/lib/offer-labels"; import AiPage, { metadata } from "./page"; -it("uses a short search title: with the layout suffix it fits in 44 characters", () => { +it("uses a short search title: up to 44 characters, and 60 with the layout suffix", () => { // o template de app/layout.tsx acrescenta " | Evandro.dev.br" const suffix = " | Evandro.dev.br"; - expect(metadata.title).toBe("Revisão de app feito com IA"); - expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(44); + expect(metadata.title).toBe("Revisão de sistema e de app feito com IA"); + expect(String(metadata.title).length).toBeLessThanOrEqual(44); + expect(`${metadata.title}${suffix}`.length).toBeLessThanOrEqual(60); expect(metadata).toMatchObject({ description: expect.any(String), alternates: { canonical: "https://evandro.dev.br/ia" }, }); // como nas outras páginas, o título da prévia de compartilhamento é o mesmo da busca, sem o sufixo - expect(metadata.openGraph?.title).toBe("Revisão de app feito com IA"); - expect(metadata.twitter?.title).toBe("Revisão de app feito com IA"); + expect(metadata.openGraph?.title).toBe( + "Revisão de sistema e de app feito com IA", + ); + expect(metadata.twitter?.title).toBe( + "Revisão de sistema e de app feito com IA", + ); }); it("uses no lime outside the contact actions (the quote on the paper background is ink)", () => { @@ -75,7 +81,7 @@ it("has breadcrumb navigation back to the home", () => { within(breadcrumb).getByRole("link", { name: "Início" }), ).toHaveAttribute("href", "/"); expect( - within(breadcrumb).getByText("Revisão de app feito com IA"), + within(breadcrumb).getByText(offerLabels.revisao.name), ).toHaveAttribute("aria-current", "page"); }); @@ -232,3 +238,17 @@ it("keeps the incident news collapsed in a
, closed by default", () => .closest("details"), ).toBeNull(); }); + +it("names the review like the offer on the home: the trail and the structured data use the offer name, not the search title", () => { + const { container } = render(); + + const script = container.querySelector('script[type="application/ld+json"]'); + const data = JSON.parse(script?.textContent ?? "{}"); + const breadcrumb = data["@graph"].find( + (node: { "@type": string }) => node["@type"] === "BreadcrumbList", + ); + expect( + breadcrumb.itemListElement.map(({ name }: { name: string }) => name), + ).toEqual(["Início", offerLabels.revisao.name]); + expect(offerLabels.revisao.name).toBe("Revisão de sistema que já existe"); +}); diff --git a/app/ia/page.tsx b/app/ia/page.tsx index 5e10659..2c124e1 100644 --- a/app/ia/page.tsx +++ b/app/ia/page.tsx @@ -8,13 +8,17 @@ import { import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; +import { offerLabels } from "@/lib/offer-labels"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { siteUrl } from "@/lib/site-content"; import { AI_REVIEW_SUBJECT } from "@/lib/whatsapp"; const pageUrl = `${siteUrl}/ia`; -/** Curto de propósito: o template de app/layout.tsx acrescenta " | Evandro.dev.br" e o conjunto cabe em 44 caracteres. */ -const pageTitle = "Revisão de app feito com IA"; +/** + * Título para busca: até 44 caracteres, e 60 com o " | Evandro.dev.br" que o template de app/layout.tsx acrescenta. Cobre a oferta + * (revisão de sistema) e a busca por app feito com IA. O nome da oferta, o mesmo da home, é o da trilha e dos dados estruturados. + */ +const pageTitle = "Revisão de sistema e de app feito com IA"; const pageDescription = "Fez um app com Lovable, Bolt ou Replit? Eu reviso as falhas de acesso a dados, separo o que aproveitar do que refazer e faço o orçamento para a produção."; /** Entra na frase "Quero conversar sobre {assunto}." da mensagem do WhatsApp (a mesma do botão flutuante em /ia). */ @@ -167,7 +171,7 @@ export default function AiPage() { "@graph": [ buildBreadcrumbList(pageUrl, [ ["Início", siteUrl], - ["Revisão de app feito com IA", pageUrl], + [offerLabels.revisao.name, pageUrl], ] as const), ], }; @@ -181,7 +185,7 @@ export default function AiPage() {
diff --git a/app/layout.test.tsx b/app/layout.test.tsx index 1e6295d..fcc8860 100644 --- a/app/layout.test.tsx +++ b/app/layout.test.tsx @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; +import { Geist_Mono, Space_Grotesk } from "next/font/google"; +import localFont from "next/font/local"; import { renderToStaticMarkup } from "react-dom/server"; import { caseStudies, serviceHubLinks, servicePages } from "@/lib/site-content"; import RootLayout, { metadata } from "./layout"; @@ -14,9 +14,10 @@ const mockFloatingProps = jest.fn(); const mockFooterProps = jest.fn(); jest.mock("next/font/google", () => ({ - Geist_Mono: () => ({ variable: "font-mono-var" }), - Space_Grotesk: () => ({ variable: "font-display-var" }), + Geist_Mono: jest.fn(() => ({ variable: "font-mono-var" })), + Space_Grotesk: jest.fn(() => ({ variable: "font-display-var" })), })); +jest.mock("next/font/local", () => ({ __esModule: true, default: jest.fn() })); jest.mock("./globals.css", () => ({})); jest.mock("./components/cinematic/CinematicOverlays", () => ({ __esModule: true, @@ -47,6 +48,12 @@ jest.mock("./components/FloatingWhatsApp", () => ({ }, })); +// as fontes são pedidas ao carregar o layout (uma vez, na importação): lê-se o que foi pedido antes de qualquer +// jest.clearAllMocks +const monoFontOptions = jest.mocked(Geist_Mono).mock.calls[0]?.[0]; +const displayFontOptions = jest.mocked(Space_Grotesk).mock.calls[0]?.[0]; +const localFontCalls = jest.mocked(localFont).mock.calls.length; + beforeEach(() => { jest.clearAllMocks(); }); @@ -119,12 +126,12 @@ describe("what the layout mounts", () => { expect(html.match(/id="site-footer"/g)).toHaveLength(1); }); - it("gives the header and the button the service and case names, and only names", () => { + it("gives the header and the button the whatsappSubject of each service and the case names, and only those", () => { markup(); const expected = { services: Object.fromEntries( - servicePages.map((service) => [service.slug, service.navLabel]), + servicePages.map((service) => [service.slug, service.whatsappSubject]), ), cases: Object.fromEntries( caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]), @@ -135,9 +142,19 @@ describe("what the layout mounts", () => { whatsAppContexts: expected, }); expect(Object.keys(expected.services).length).toBeGreaterThan(0); + // a mensagem lê "Quero conversar sobre {assunto}.": nenhum assunto de serviço começa em maiúscula + for (const subject of Object.values(expected.services)) { + expect(subject).toMatch(/^[a-zà-ú]/); + } expect(Object.keys(expected.cases).length).toBeGreaterThan(0); }); + it("keeps the smooth anchors but opens each new page at the top: the html carries data-scroll-behavior, which makes Next turn the smooth scroll off only during the route change", () => { + // o atributo é o que o Next lê para desligar a rolagem suave só durante a troca de rota (o e2e navigation.spec.ts + // confere no navegador que a página nova abre em scrollY 0, sem deslizar) + expect(markup()).toMatch(/]* data-scroll-behavior="smooth"/); + }); + it("hides the JavaScript-only parts of the page when scripts are off", () => { expect(markup()).toContain(".js-only{display:none!important}"); }); @@ -145,18 +162,17 @@ describe("what the layout mounts", () => { describe("fonts", () => { it("does not preload Geist Mono (only small labels use it), while the display font keeps its preload", () => { - const source = readFileSync(join(__dirname, "layout.tsx"), "utf8"); - - const mono = source.match(/Geist_Mono\(\{[^}]*\}\)/)?.[0] ?? ""; - expect(mono).toMatch(/preload:\s*false/); - const display = source.match(/Space_Grotesk\(\{[^}]*\}\)/)?.[0] ?? ""; - expect(display).not.toMatch(/preload:\s*false/); + expect(monoFontOptions).toEqual( + expect.objectContaining({ preload: false }), + ); + expect(displayFontOptions).toBeDefined(); + expect(displayFontOptions).not.toEqual( + expect.objectContaining({ preload: false }), + ); }); it("no longer loads the unused Monument Extended font", () => { - const source = readFileSync(join(__dirname, "layout.tsx"), "utf8"); - - expect(source).not.toMatch(/Monument|next\/font\/local/i); + expect(localFontCalls).toBe(0); expect(markupClassName()).not.toMatch(/monument/i); }); diff --git a/app/layout.tsx b/app/layout.tsx index 2e7ed43..4205b6e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -28,10 +28,14 @@ const defaultTitle = `Sites e sistemas em Joinville e SC${titleSuffix}`; const seoDescription = "Sites com pedido pelo WhatsApp e sistemas que tiram sua operação da planilha. Base em Garuva, SC; atendimento em Joinville e remoto em todo o Brasil."; -/** Para o WhatsApp falar do serviço ou do case da página aberta: só nomes, sem o conteúdo pesado das páginas. */ +/** + * Para o WhatsApp falar do serviço ou do case da página aberta: só os assuntos e nomes, sem o conteúdo pesado das + * páginas. O serviço entra pelo `whatsappSubject` (uma frase em minúsculas, que cabe em "Quero conversar sobre + * {assunto}."), e não pelo nome: o nome daria "sobre Sites profissionais.". + */ const whatsAppContexts: WhatsAppContexts = { services: Object.fromEntries( - servicePages.map((service) => [service.slug, service.navLabel]), + servicePages.map((service) => [service.slug, service.whatsappSubject]), ), cases: Object.fromEntries( caseStudies.map((caseStudy) => [caseStudy.id, caseStudy.title]), @@ -130,8 +134,11 @@ export default function RootLayout({ children }: Readonly) { }; return ( + // data-scroll-behavior: com `scroll-behavior: smooth` no CSS global, o Next desliga a suavização só durante a troca + // de página (a página nova abre no topo, sem deslizar) e a restaura depois; as âncoras da mesma página seguem suaves. diff --git a/app/metadata-limits.test.ts b/app/metadata-limits.test.ts index 2936a3c..cdb0061 100644 --- a/app/metadata-limits.test.ts +++ b/app/metadata-limits.test.ts @@ -6,7 +6,6 @@ import { metadata as layoutMetadata } from "./layout"; import { metadata as notFoundMetadata } from "./not-found"; import sitemap from "./sitemap"; -jest.mock("next/navigation", () => ({ notFound: jest.fn() })); // o layout importa a fonte do Google e o CSS global; fora do Next nenhum dos dois existe jest.mock("next/font/google", () => ({ Geist_Mono: () => ({ variable: "font-mono-var" }), diff --git a/app/servicos/[slug]/page.cover.paths.test.tsx b/app/servicos/[slug]/page.cover.paths.test.tsx index 8834d92..b0e9413 100644 --- a/app/servicos/[slug]/page.cover.paths.test.tsx +++ b/app/servicos/[slug]/page.cover.paths.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { getCaseMedia } from "@/app/cases/_components/case-media"; import { type Device, getShowcaseAssets } from "@/lib/project-showcase"; import { getServicePage } from "@/lib/site-content"; @@ -15,12 +16,9 @@ import ServicePage from "./page"; * Quando a capa vale, a legenda dela cita o case e o case não ganha cartão em "Trabalhos parecidos". */ -jest.mock("next/navigation", () => ({ - notFound: jest.fn(() => { - throw new Error("NEXT_NOT_FOUND"); - }), - usePathname: () => "/servicos", -})); +beforeEach(() => { + setNavigation({ pathname: "/servicos" }); +}); jest.mock("@/app/cases/_components/case-media", () => ({ ...jest.requireActual("@/app/cases/_components/case-media"), getCaseMedia: jest.fn(), @@ -74,7 +72,8 @@ describe("ServicePage: escolha da capa do topo", () => { expect(view.compact).toBe(false); expect(view.caption).toHaveTextContent("Lavação 047"); expect(view.cards).not.toContain("Lavação 047"); - expect(view.cards).toEqual(["SC Plus Aquicultura", "AS Serviços"]); + // a SEV é o segundo trabalho; SC Plus e AS Serviços ficam no bloco menor, fora de "Onde já apliquei isso" + expect(view.cards).toEqual(["SEV Moda Íntima"]); }); it("path: mobile cover with its capture on disk -> phone frame in the hero, no browser frame", async () => { @@ -98,11 +97,7 @@ describe("ServicePage: escolha da capa do topo", () => { expect(view.twoColumns).toBe(false); expect(view.compact).toBe(true); expect(screen.queryByRole("img")).toBeNull(); - expect(view.cards).toEqual([ - "Lavação 047", - "SC Plus Aquicultura", - "AS Serviços", - ]); + expect(view.cards).toEqual(["Lavação 047", "SEV Moda Íntima"]); }); it("path: only the OTHER device was captured (mobile cover, desktop capture) -> no cover, the check follows the device the service asks for", async () => { diff --git a/app/servicos/[slug]/page.paths.test.tsx b/app/servicos/[slug]/page.paths.test.tsx index d804062..b2d95ac 100644 --- a/app/servicos/[slug]/page.paths.test.tsx +++ b/app/servicos/[slug]/page.paths.test.tsx @@ -1,5 +1,6 @@ import { render, screen } from "@testing-library/react"; import { notFound } from "next/navigation"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { getServicePage } from "@/lib/site-content"; import ServicePage, { generateMetadata } from "./page"; @@ -8,13 +9,9 @@ import ServicePage, { generateMetadata } from "./page"; * Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias. */ -// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home -jest.mock("next/navigation", () => ({ - notFound: jest.fn(() => { - throw new Error("NEXT_NOT_FOUND"); - }), - usePathname: () => "/servicos", -})); +beforeEach(() => { + setNavigation({ pathname: "/servicos" }); +}); beforeEach(() => { jest.mocked(notFound).mockClear(); diff --git a/app/servicos/[slug]/page.sections.paths.test.tsx b/app/servicos/[slug]/page.sections.paths.test.tsx new file mode 100644 index 0000000..889d6f1 --- /dev/null +++ b/app/servicos/[slug]/page.sections.paths.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; +import { getServiceOffer, getServicePage } from "@/lib/site-content"; +import ServicePage from "./page"; + +/** + * Caminhos-base de ServicePage (CC 4, contado à mão: serviço que não existe | bloco menor `alsoCases` | oferta com + * "O que você recebe") nas combinações do bloco menor e da oferta, que page.test.tsx só mostra juntas ou separadas com + * dados reais: + * - nem bloco menor nem oferta (a base: tauri-rust); + * - bloco menor SEM oferta (nenhum serviço real é assim: montado trocando `alsoCases` de consultoria-nextjs); + * - bloco menor E oferta (criacao-de-sites): a ordem na página é cases, bloco menor, "O que você recebe". + * O slug desconhecido está em page.paths.test.tsx. + */ + +beforeEach(() => { + setNavigation({ pathname: "/servicos" }); +}); + +async function renderService(slug: string) { + const ui = await ServicePage({ params: Promise.resolve({ slug }) }); + return render(ui); +} + +const ALSO_TITLE = "Também faço sites institucionais"; +const ALSO_HEADING = { level: 2, name: ALSO_TITLE } as const; +const DELIVERABLES_HEADING = { + level: 2, + name: "O que você recebe", +} as const; + +describe("ServicePage: bloco menor e oferta", () => { + it("path: no smaller block and no offer -> neither 'Também faço' nor 'O que você recebe', and the rest of the page stays", async () => { + expect(getServicePage("tauri-rust")?.alsoCases).toBeUndefined(); + expect(getServiceOffer("tauri-rust")).toBeUndefined(); + + await renderService("tauri-rust"); + + expect(screen.queryByRole("heading", ALSO_HEADING)).toBeNull(); + expect(screen.queryByRole("heading", DELIVERABLES_HEADING)).toBeNull(); + expect( + screen.getByRole("heading", { level: 2, name: "Como trabalhamos" }), + ).toBeVisible(); + }); + + it("path: a smaller block on a service that is not an offer -> the block shows, 'O que você recebe' does not", async () => { + const service = getServicePage("consultoria-nextjs"); + if (!service) throw new Error("Missing service consultoria-nextjs"); + expect(getServiceOffer(service.slug)).toBeUndefined(); + const original = service.alsoCases; + service.alsoCases = { title: "Também faço isto", caseIds: ["sc-plus"] }; + try { + await renderService(service.slug); + + const block = screen + .getByRole("heading", { level: 2, name: "Também faço isto" }) + .closest("section") as HTMLElement; + expect(block).toHaveTextContent("SC Plus"); + expect(screen.queryByRole("heading", DELIVERABLES_HEADING)).toBeNull(); + } finally { + service.alsoCases = original; + } + }); + + it("path: a smaller block on an offer -> the cases come first, then the block, then 'O que você recebe'", async () => { + await renderService("criacao-de-sites"); + + const cases = document.getElementById("casos-relacionados"); + const also = screen + .getByRole("heading", ALSO_HEADING) + .closest("section") as HTMLElement; + const deliverables = screen + .getByRole("heading", DELIVERABLES_HEADING) + .closest("section") as HTMLElement; + expect(cases).not.toBeNull(); + const follows = Node.DOCUMENT_POSITION_FOLLOWING; + expect( + (cases as HTMLElement).compareDocumentPosition(also) & follows, + ).toBeTruthy(); + expect(also.compareDocumentPosition(deliverables) & follows).toBeTruthy(); + }); +}); diff --git a/app/servicos/[slug]/page.test.tsx b/app/servicos/[slug]/page.test.tsx index 5cb5ea3..b699689 100644 --- a/app/servicos/[slug]/page.test.tsx +++ b/app/servicos/[slug]/page.test.tsx @@ -1,4 +1,5 @@ import { render, screen, within } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { SEO_IDS } from "@/lib/seo"; import { buttonLabels, @@ -14,11 +15,9 @@ import { import ServicesIndexPage from "../page"; import ServicePage, { generateMetadata, generateStaticParams } from "./page"; -// usePathname: o HoverPrefetchLink (trilha, cartões) lê o caminho para saber se está na home -jest.mock("next/navigation", () => ({ - notFound: jest.fn(), - usePathname: () => "/servicos", -})); +beforeEach(() => { + setNavigation({ pathname: "/servicos" }); +}); async function renderService(slug: string) { const ui = await ServicePage({ params: Promise.resolve({ slug }) }); @@ -117,7 +116,13 @@ it("renders answer-first consulting, reciprocal case links and minimal schema", for (const cta of primaryCtas) { const destination = new URL(cta.getAttribute("href") ?? ""); expect(destination.origin).toBe("https://wa.me"); - expect(destination.searchParams.get("text")).toContain(service.navLabel); + // o assunto da mensagem é o `whatsappSubject` do serviço, em minúsculas, e não o nome com maiúscula + expect(destination.searchParams.get("text")).toContain( + `Quero conversar sobre ${service.whatsappSubject}.`, + ); + expect(destination.searchParams.get("text")).toContain( + "uma consultoria em Next.js", + ); expect(cta).toHaveAttribute("target", "_blank"); expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener")); } @@ -236,9 +241,13 @@ it("fills the hub hero with a mosaic of three client posters, never opening with expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid"); const posters = Array.from(hero?.querySelectorAll("img") ?? []); expect(posters).toHaveLength(3); + // a ordem é a de /cases (a vitrine), sem a SEV, que lidera a lista mas não o mosaico + const mosaicCases = clientCasesForListing.filter( + ({ id }) => id !== "sev-modaintima", + ); for (const [index, poster] of posters.entries()) { expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain( - `/videos/${clientCasesForListing[index].id}/desktop.webp`, + `/videos/${mosaicCases[index].id}/desktop.webp`, ); expect(poster).toHaveAttribute("alt", ""); } @@ -406,19 +415,33 @@ it("shows the client cases first, right after the hero, with the first cover in ), ).toBe(true); - // o case da capa (Lavação 047) é citado pela legenda dela; os cartões trazem os demais, com os de cliente à frente + // o case da capa (Lavação 047) é citado pela legenda dela; "Onde já apliquei isso" traz a SEV como segundo trabalho const caseLinks = caseCardLinks(container); - expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual( - getRelatedCases(siteService.relatedCaseIds) - .filter(({ id }) => id !== siteService.heroCase?.id) - .map(({ id }) => `/cases/${id}`), - ); expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([ - "/cases/sc-plus", - "/cases/as-servicos", + "/cases/sev-modaintima", ]); + // SC Plus e AS Serviços saem dali e vão para o bloco menor "Também faço sites institucionais" + const alsoHeading = screen.getByRole("heading", { + level: 2, + name: "Também faço sites institucionais", + }); + expect(isBefore(casesHeading, alsoHeading)).toBe(true); + const alsoBlock = alsoHeading.closest("section") as HTMLElement; + expect( + within(alsoBlock) + .getAllByRole("link") + .map((link) => link.getAttribute("href")), + ).toEqual(["/cases/sc-plus", "/cases/as-servicos"]); + expect( + within(alsoBlock).getByText(siteService.caseRelevance["sc-plus"]), + ).toBeVisible(); + // sem capa no bloco menor + expect(alsoBlock.querySelector("img")).toBeNull(); + expect( + getRelatedCases(siteService.relatedCaseIds).map(({ id }) => id), + ).toEqual(["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"]); - // pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde, e não a SEV + // pôster do case de destaque (Lavação 047) numa moldura no topo, no lugar do fundo verde const hero = container.querySelector("section"); const heroFrame = hero?.querySelector(".ds-bframe"); expect(heroFrame).not.toBeNull(); @@ -427,7 +450,11 @@ it("shows the client cases first, right after the hero, with the first cover in "Captura de tela do site Lavação 047", ); expect(hero?.querySelector("a[href='/cases/lavacao-047']")).not.toBeNull(); - expect(container.textContent).not.toMatch(/SEV Moda Íntima/); + // a SEV tem cartão (segundo trabalho), mas não vira a capa do topo + expect(hero?.textContent).not.toMatch(/SEV Moda Íntima/); + expect(caseCardLinks(container)[0].closest("article")).toHaveTextContent( + siteService.caseRelevance["sev-modaintima"], + ); // a legenda da capa cita o case com a frase de relação e o botão "Ver case" const caption = hero?.querySelector("figcaption"); expect(caption).toHaveTextContent("Lavação 047"); @@ -437,7 +464,7 @@ it("shows the client cases first, right after the hero, with the first cover in name: "Ver case Lavação 047", }), ).toHaveAttribute("href", "/cases/lavacao-047"); - // a mesma capa não aparece duas vezes: o case de destaque não ganha cartão, e os outros mantêm a capa deles + // a mesma capa não aparece duas vezes: o case de destaque não ganha cartão; a SEV mantém a dela e os do bloco menor não têm capa expect( container.querySelectorAll( "img[alt='Captura de tela do site Lavação 047']", @@ -447,7 +474,150 @@ it("shows the client cases first, right after the hero, with the first cover in container.querySelectorAll( "img[alt='Captura de tela do site SC Plus Aquicultura']", ), - ).toHaveLength(1); + ).toHaveLength(0); +}); + +it("repeats on the two offer pages the 'O que você recebe' and the 'Primeiro passo' of the offer, from the same source as the home", async () => { + for (const [slug, offer] of [ + ["criacao-de-sites", serviceOffers[0]], + ["engenharia-de-software", serviceOffers[1]], + ] as const) { + const { container, unmount } = await renderService(slug); + + const receives = screen.getByRole("heading", { + level: 2, + name: "O que você recebe", + }); + const block = receives.closest("section") as HTMLElement; + expect( + within(block) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual(offer.deliverables); + const firstStep = within(block).getByRole("heading", { + level: 2, + name: "Primeiro passo", + }); + expect(isBefore(receives, firstStep)).toBe(true); + expect(within(block).getByText(offer.firstStep)).toBeVisible(); + // vem depois da prova e antes de "Para quem faz sentido" + expect( + isBefore( + screen.getByRole("heading", { + level: 2, + name: "Onde já apliquei isso", + }), + receives, + ), + ).toBe(true); + expect( + isBefore( + receives, + screen.getByRole("heading", { + level: 2, + name: "Para quem faz sentido", + }), + ), + ).toBe(true); + // o bloco não traz botão de contato próprio: o do topo e o do fim bastam + expect(within(block).queryByRole("link")).toBeNull(); + expect(container.textContent).not.toMatch(/\d+\s?dias|R\$/); + unmount(); + } +}); + +it("keeps a single list of deliverables per page: the offer pages drop 'O que podemos entregar', the others keep it", async () => { + for (const slug of ["criacao-de-sites", "engenharia-de-software"]) { + const { unmount } = await renderService(slug); + expect( + screen.getByRole("heading", { level: 2, name: "O que você recebe" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("heading", { name: "O que podemos entregar" }), + ).toBeNull(); + // "Para quem faz sentido" continua, sozinho + expect( + screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }), + ).toBeInTheDocument(); + unmount(); + } + for (const slug of [ + "consultoria-nextjs", + "tauri-rust", + "integracao-hardware-software-iot", + "arquitetura-de-software", + ]) { + const { unmount } = await renderService(slug); + expect( + screen.getByRole("heading", { level: 2, name: "O que podemos entregar" }), + ).toBeInTheDocument(); + unmount(); + } +}); + +it("leaves 'O que você recebe' and 'Primeiro passo' out of the services that are not an offer", async () => { + for (const slug of [ + "consultoria-nextjs", + "tauri-rust", + "integracao-hardware-software-iot", + "arquitetura-de-software", + ]) { + const { unmount } = await renderService(slug); + expect( + screen.queryByRole("heading", { name: "O que você recebe" }), + ).toBeNull(); + expect( + screen.queryByRole("heading", { name: "Primeiro passo" }), + ).toBeNull(); + expect( + screen.queryByRole("heading", { + name: "Também faço sites institucionais", + }), + ).toBeNull(); + unmount(); + } +}); + +it("opens the systems proof with the Apolo Energia block, as the résumé says it, and a subtitle that starts from the pain", async () => { + const { container } = await renderService("engenharia-de-software"); + + const proof = container.querySelector("#casos-relacionados"); + if (!proof) throw new Error("Missing proof block"); + // o bloco da Apolo vem no topo da prova, antes dos cartões de case + const note = within(proof).getByText(/Apolo Energia/); + expect(note).toHaveTextContent( + "aplicações web e mobile para rotinas internas, APIs e painéis de operação", + ); + expect(note.textContent).toMatch(/uso interno/); + expect(isBefore(note, caseCardLinks(container)[0])).toBe(true); + // o subtítulo parte da dor: planilha e retrabalho + const subtitle = screen.getByText(/planilhas, mensagens e retrabalho/); + expect(isBefore(screen.getByRole("heading", { level: 1 }), subtitle)).toBe( + true, + ); +}); + +it("says the offer name, not the old label, in the breadcrumb, the title and the WhatsApp message of every service", async () => { + for (const { slug, navLabel, whatsappSubject } of servicePages) { + const { unmount } = await renderService(slug); + + const trail = within( + screen.getByRole("navigation", { name: "Trilha de navegação" }), + ).getAllByRole("link"); + expect(trail[trail.length - 1]).toHaveTextContent(navLabel); + for (const cta of screen.getAllByRole("link", { + name: buttonLabels.contact, + })) { + const text = new URL(cta.getAttribute("href") ?? "").searchParams.get( + "text", + ); + expect(text).toContain(`Quero conversar sobre ${whatsappSubject}.`); + expect(text).not.toMatch( + /Sites profissionais|Sites, sistemas e aplicativos/, + ); + } + unmount(); + } }); it("opens the two offer services with the offer name as the h1, and leaves the search title alone", async () => { @@ -674,7 +844,8 @@ it("says in one sentence, per case, why it matters to the service", async () => container.querySelectorAll(`article a[href="/cases/${id}"]`), ).toHaveLength(0); } else { - const card = screen.getByText(sentence).closest("article"); + // o cartão de "Onde já apliquei isso" ou a linha do bloco menor "Também faço sites institucionais" + const card = screen.getByText(sentence).closest("article, li"); expect(card).toContainElement(link); } } @@ -715,13 +886,8 @@ it("cites the Apolo Energia experience in text on the IoT and architecture pages } }); -it("does not mention Apolo Energia on the other service pages", async () => { - for (const slug of [ - "criacao-de-sites", - "engenharia-de-software", - "consultoria-nextjs", - "tauri-rust", - ]) { +it("does not mention Apolo Energia on the other service pages (the systems page cites it, tested above)", async () => { + for (const slug of ["criacao-de-sites", "consultoria-nextjs", "tauri-rust"]) { const { container, unmount } = await renderService(slug); expect(container.textContent).not.toMatch(/Apolo/); unmount(); diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx index 59b08fd..098c5c8 100644 --- a/app/servicos/[slug]/page.tsx +++ b/app/servicos/[slug]/page.tsx @@ -1,13 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getCaseMedia } from "@/app/cases/_components/case-media"; import { - compactHero, - heroWithMediaGrid, -} from "@/app/cases/_components/hero-styles"; -import { Breadcrumb } from "@/app/components/Breadcrumb"; -import { - pageTop, primaryButton, secondaryButtonDark, } from "@/app/components/button-styles"; @@ -22,16 +15,20 @@ import { } from "@/lib/seo"; import { buttonLabels, - caseStudies, - getRelatedCases, + getServiceOffer, getServicePage, servicePages, siteUrl, } from "@/lib/site-content"; -import { ServiceDiagram } from "../_components/ServiceDiagram"; -import { ServiceHeroCover } from "../_components/ServiceHeroCover"; +import { ServiceAlsoCases } from "../_components/ServiceAlsoCases"; +import { ServiceDeliverables } from "../_components/ServiceDeliverables"; +import { ServiceHero } from "../_components/ServiceHero"; import { ServiceOffer } from "../_components/ServiceOffer"; import { ServiceRelatedCases } from "../_components/ServiceRelatedCases"; +import { + resolveServiceHero, + splitServiceCases, +} from "../_components/service-page"; interface ServicePageProps { params: Promise<{ @@ -77,18 +74,21 @@ export default async function ServicePage({ params }: ServicePageProps) { notFound(); } - const relatedCases = getRelatedCases(service.relatedCaseIds); - // capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo - const heroDevice = service.heroCase?.device ?? "desktop"; - const heroCase = caseStudies.find( - ({ id }) => - id === service.heroCase?.id && Boolean(getCaseMedia(id, heroDevice)), - ); - const diagram = heroCase ? undefined : service.diagram; - const hasHeroSide = Boolean(heroCase || diagram); - // o case da capa é citado pela legenda dela, e não ganha cartão em "Onde já apliquei isso" - const hasCaseCards = relatedCases.some(({ id }) => id !== heroCase?.id); + const hero = resolveServiceHero(service); + const { cards, also } = splitServiceCases(service); + // as duas primeiras ofertas têm página aqui; "O que você recebe" e "Primeiro passo" vêm de serviceOffers, como na home + const offer = getServiceOffer(service.slug); const pageUrl = `${siteUrl}/servicos/${service.slug}`; + const audienceCard = ( +
+

Para quem faz sentido

+
    + {service.audience.map((item) => ( +
  • {item}
  • + ))} +
+
+ ); const jsonLd = { "@context": "https://schema.org", @@ -106,90 +106,44 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
-
-
- -

{service.eyebrow}

-

- {service.headline} -

-

- {service.answer} -

-
- - {buttonLabels.contact} - - {hasCaseCards ? ( - - Ver projetos parecidos - - ) : null} -
-

- O botão abre uma conversa no WhatsApp, sem formulário. Garuva e - Joinville (SC) · atendimento remoto em todo o Brasil. -

-
- {diagram ? ( - - ) : ( - - )} -
-
+ + {also ? ( + + ) : null} + + {offer ? : null} +
-
-
-

Para quem faz sentido

-
    - {service.audience.map((item) => ( -
  • {item}
  • - ))} -
-
+ {offer ? ( + // a página da oferta já tem "O que você recebe": uma lista de entregáveis só, sem "O que podemos entregar" + audienceCard + ) : ( +
+ {audienceCard} -
-

O que podemos entregar

-
    - {service.outcomes.map((item) => ( -
  • {item}
  • - ))} -
-
-
+
+

O que podemos entregar

+
    + {service.outcomes.map((item) => ( +
  • {item}
  • + ))} +
+
+
+ )}
@@ -223,7 +177,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
+
Detalhes técnicos e tecnologias @@ -268,7 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {

diff --git a/app/servicos/_components/ServiceAlsoCases.test.tsx b/app/servicos/_components/ServiceAlsoCases.test.tsx new file mode 100644 index 0000000..3ac4f08 --- /dev/null +++ b/app/servicos/_components/ServiceAlsoCases.test.tsx @@ -0,0 +1,75 @@ +import { render, screen, within } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; +import { type CaseStudyContent, caseStudies } from "@/lib/site-content"; +import { ServiceAlsoCases } from "./ServiceAlsoCases"; + +/** + * ServiceAlsoCases (CC 3): sem case (nada) | com case; frase de relação própria | só o resumo do case. + */ + +beforeEach(() => { + setNavigation({ pathname: "/servicos" }); +}); + +function getCase(id: string): CaseStudyContent { + const found = caseStudies.find((item) => item.id === id); + if (!found) throw new Error(`Missing case ${id}`); + return found; +} + +describe("ServiceAlsoCases", () => { + it("path: cases with a relation sentence -> one line per case, with the title, the sentence and a 'Ver case' link, and no cover", () => { + render( + , + ); + + expect( + screen.getByRole("heading", { + level: 2, + name: "Também faço sites institucionais", + }), + ).toBeVisible(); + const rows = screen.getAllByRole("listitem"); + expect(rows).toHaveLength(2); + expect( + within(rows[0]).getByRole("heading", { + level: 3, + name: "SC Plus Aquicultura", + }), + ).toBeVisible(); + expect(within(rows[0]).getByText("Frase do SC Plus.")).toBeVisible(); + expect( + within(rows[0]).getByRole("link", { + name: "Ver case SC Plus Aquicultura", + }), + ).toHaveAttribute("href", "/cases/sc-plus"); + expect( + within(rows[1]).getByRole("link", { name: "Ver case AS Serviços" }), + ).toHaveAttribute("href", "/cases/as-servicos"); + expect(screen.queryByRole("img")).toBeNull(); + }); + + it("path: no relation sentence for a case -> the summary of the case stands in", () => { + const caseStudy = getCase("sc-plus"); + render( + , + ); + + expect(screen.getByText(caseStudy.summary)).toBeVisible(); + }); + + it("path: no case -> renders nothing", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/servicos/_components/ServiceAlsoCases.tsx b/app/servicos/_components/ServiceAlsoCases.tsx new file mode 100644 index 0000000..401c970 --- /dev/null +++ b/app/servicos/_components/ServiceAlsoCases.tsx @@ -0,0 +1,52 @@ +import { textLink } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; +import { buttonLabels, type CaseStudyContent } from "@/lib/site-content"; + +/** + * Bloco menor depois de "Onde já apliquei isso": trabalhos parecidos que não são a prova principal do serviço, em uma linha + * cada (nome, por que interessam e o link do case), sem capa. Sem case, não mostra nada. + */ +export function ServiceAlsoCases({ + title, + cases, + relevance, +}: { + title: string; + cases: readonly CaseStudyContent[]; + /** Por que cada case interessa ao serviço (chave: id do case); sem frase própria, vale o resumo do case. */ + relevance: Readonly>; +}) { + if (cases.length === 0) return null; + + return ( +
+
+

+ {title} +

+
    + {cases.map((caseStudy) => ( +
  • +

    + {caseStudy.title} +

    +

    + {relevance[caseStudy.id] ?? caseStudy.summary} +

    + + {buttonLabels.case} + {caseStudy.title} + +
  • + ))} +
+
+
+ ); +} diff --git a/app/servicos/_components/ServiceDeliverables.test.tsx b/app/servicos/_components/ServiceDeliverables.test.tsx new file mode 100644 index 0000000..5d2611a --- /dev/null +++ b/app/servicos/_components/ServiceDeliverables.test.tsx @@ -0,0 +1,44 @@ +import { render, screen, within } from "@testing-library/react"; +import { serviceOffers } from "@/lib/site-content"; +import { ServiceDeliverables } from "./ServiceDeliverables"; + +describe("ServiceDeliverables", () => { + it("shows 'O que você recebe' with the deliverables of the offer, then 'Primeiro passo' with its first step", () => { + const [offer] = serviceOffers; + render(); + + const receives = screen.getByRole("heading", { + level: 2, + name: "O que você recebe", + }); + const list = within(receives.closest("article") as HTMLElement).getByRole( + "list", + ); + expect( + within(list) + .getAllByRole("listitem") + .map((item) => item.textContent), + ).toEqual(offer.deliverables); + + const firstStep = screen.getByRole("heading", { + level: 2, + name: "Primeiro passo", + }); + expect( + receives.compareDocumentPosition(firstStep) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(screen.getByText(offer.firstStep)).toBeVisible(); + }); + + it("names the section by its heading and has no contact button or link of its own", () => { + const { container } = render( + , + ); + + expect( + screen.getByRole("region", { name: "O que você recebe" }), + ).toBeVisible(); + expect(container.querySelectorAll("a")).toHaveLength(0); + }); +}); diff --git a/app/servicos/_components/ServiceDeliverables.tsx b/app/servicos/_components/ServiceDeliverables.tsx new file mode 100644 index 0000000..0084cf5 --- /dev/null +++ b/app/servicos/_components/ServiceDeliverables.tsx @@ -0,0 +1,37 @@ +import type { ServiceOffer } from "@/lib/site-content"; + +/** + * "O que você recebe" e "Primeiro passo" da oferta. A lista é a mesma do cartão da home (`serviceOffers` em + * lib/site-content.ts): a página repete o que o cartão prometeu. O primeiro passo é o da oferta, que termina como a frase + * geral da home (`generalFirstStep`): uma conversa e, depois dela, escopo e proposta por escrito. Só as páginas que + * explicam uma oferta mostram este bloco. + */ +export function ServiceDeliverables({ + offer, +}: { + offer: Pick; +}) { + return ( +
+
+
+
+

+ O que você recebe +

+
    + {offer.deliverables.map((item) => ( +
  • {item}
  • + ))} +
+
+ +
+

Primeiro passo

+

{offer.firstStep}

+
+
+
+
+ ); +} diff --git a/app/servicos/_components/ServiceHero.paths.test.tsx b/app/servicos/_components/ServiceHero.paths.test.tsx new file mode 100644 index 0000000..91a930c --- /dev/null +++ b/app/servicos/_components/ServiceHero.paths.test.tsx @@ -0,0 +1,187 @@ +import { render, screen } from "@testing-library/react"; +import { + compactHero, + heroWithMediaGrid, +} from "@/app/cases/_components/hero-styles"; +import { + caseStudies, + getServicePage, + type ServicePageContent, +} from "@/lib/site-content"; +import { ServiceHero } from "./ServiceHero"; +import type { ServiceHeroData } from "./service-page"; + +/** + * Testes de caminhos-base de ServiceHero (CC 6, contado à mão: as cinco escolhas do JSX): + * hasHeroSide (classe do topo: compacto ou não), hasHeroSide (grade de duas colunas), hasCaseCards (botão "Ver projetos + * parecidos"), diagram (esquema ou capa) e heroCase (a frase da capa). + * Os dois filhos que desenham a coluna ao lado (esquema e capa) são trocados por dublês que mostram o que receberam. + */ + +jest.mock("./ServiceDiagram", () => ({ + ServiceDiagram: ({ kind }: { kind: string }) => ( +
{kind}
+ ), +})); +jest.mock("./ServiceHeroCover", () => ({ + ServiceHeroCover: (props: { + caseStudy?: { id: string }; + device?: string; + relevance?: string; + }) => ( +
+ ), +})); + +function service(slug = "criacao-de-sites"): ServicePageContent { + const found = getServicePage(slug); + if (!found) throw new Error(`Missing service ${slug}`); + return found; +} + +function caseStudy(id: string) { + const found = caseStudies.find((item) => item.id === id); + if (!found) throw new Error(`Missing case ${id}`); + return found; +} + +const noSide: ServiceHeroData = { + heroCase: undefined, + heroDevice: "desktop", + diagram: undefined, + hasHeroSide: false, + hasCaseCards: false, +}; + +const shell = (container: HTMLElement) => + container.querySelector(".ds-section-shell") as HTMLElement; +const section = (container: HTMLElement) => + container.querySelector("section") as HTMLElement; +const projectsLink = () => + screen.queryByRole("link", { name: "Ver projetos parecidos" }); + +describe("ServiceHero", () => { + it("path: no cover, no diagram and no card -> one low column, no 'Ver projetos parecidos', and an empty slot where the cover would be", () => { + const { container } = render( + , + ); + + for (const token of compactHero.split(/\s+/).filter(Boolean)) + expect(section(container)).toHaveClass(token); + for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean)) + expect(shell(container)).not.toHaveClass(token); + expect(projectsLink()).toBeNull(); + expect(screen.queryByTestId("diagram")).toBeNull(); + // sem esquema, quem ocupa a coluna é a capa, que por conta própria não desenha nada sem case + expect(screen.getByTestId("cover")).toHaveAttribute("data-case", ""); + expect(screen.getByTestId("cover")).toHaveAttribute( + "data-has-relevance", + "false", + ); + }); + + it("path: a side column -> the compact class goes away and the two-column grid comes in", () => { + const { container } = render( + , + ); + + for (const token of compactHero.split(/\s+/).filter(Boolean)) + expect(section(container)).not.toHaveClass(token); + for (const token of heroWithMediaGrid.split(/\s+/).filter(Boolean)) + expect(shell(container)).toHaveClass(token); + }); + + it("path: cards below -> 'Ver projetos parecidos' points at the related cases", () => { + render( + , + ); + + expect(projectsLink()).toHaveAttribute("href", "#casos-relacionados"); + }); + + it("path: a diagram -> the diagram fills the side column and the cover is not mounted", () => { + render( + , + ); + + expect(screen.getByTestId("diagram")).toHaveTextContent("iot"); + expect(screen.queryByTestId("cover")).toBeNull(); + }); + + it("path: a cover case -> the cover gets the case, the device and the service's sentence about that case", () => { + const lavacao = service(); + render( + , + ); + + const cover = screen.getByTestId("cover"); + expect(cover).toHaveAttribute("data-case", "lavacao-047"); + expect(cover).toHaveAttribute("data-device", "desktop"); + expect(cover).toHaveAttribute( + "data-relevance", + lavacao.caseRelevance["lavacao-047"], + ); + expect(screen.queryByTestId("diagram")).toBeNull(); + }); + + it("path: a cover case the service has no sentence for -> the cover gets no sentence (it falls back on its own)", () => { + // o serviço de esquema não tem frase para a SEV + const withoutSentence = service("tauri-rust"); + expect(withoutSentence.caseRelevance["sev-modaintima"]).toBeUndefined(); + + render( + , + ); + + const cover = screen.getByTestId("cover"); + expect(cover).toHaveAttribute("data-case", "sev-modaintima"); + expect(cover).toHaveAttribute("data-device", "mobile"); + expect(cover).toHaveAttribute("data-has-relevance", "false"); + }); + + it("path: the contact button speaks of the service by its whatsappSubject, in lower case, with the hero placement", () => { + const sistemas = service("engenharia-de-software"); + render(); + + const button = screen.getByRole("link", { + name: /falar sobre meu projeto/i, + }); + const message = new URL(button.getAttribute("href") ?? "").searchParams.get( + "text", + ); + expect(message).toContain( + `Quero conversar sobre ${sistemas.whatsappSubject}.`, + ); + expect(sistemas.whatsappSubject).toMatch(/^[a-zà-ú]/); + }); +}); diff --git a/app/servicos/_components/ServiceHero.tsx b/app/servicos/_components/ServiceHero.tsx new file mode 100644 index 0000000..b1eabd5 --- /dev/null +++ b/app/servicos/_components/ServiceHero.tsx @@ -0,0 +1,87 @@ +import { + compactHero, + heroWithMediaGrid, +} from "@/app/cases/_components/hero-styles"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { + pageTop, + primaryButton, + secondaryButtonDark, +} from "@/app/components/button-styles"; +import WhatsAppLink from "@/app/components/WhatsAppLink"; +import { buttonLabels, type ServicePageContent } from "@/lib/site-content"; +import { ServiceDiagram } from "./ServiceDiagram"; +import { ServiceHeroCover } from "./ServiceHeroCover"; +import type { ServiceHeroData } from "./service-page"; + +/** + * Topo da página do serviço: trilha, título, resposta, botão de contato e, na coluna ao lado, a capa do case escolhido + * ou o esquema de fluxo. Sem nenhum dos dois, o topo é uma coluna só e mais baixo. + */ +export function ServiceHero({ + service, + hero, +}: { + service: ServicePageContent; + hero: ServiceHeroData; +}) { + const { heroCase, heroDevice, diagram, hasHeroSide, hasCaseCards } = hero; + + return ( +
+
+
+ +

{service.eyebrow}

+

+ {service.headline} +

+

+ {service.answer} +

+
+ + {buttonLabels.contact} + + {hasCaseCards ? ( + + Ver projetos parecidos + + ) : null} +
+

+ O botão abre uma conversa no WhatsApp, sem formulário. Garuva e + Joinville (SC) · atendimento remoto em todo o Brasil. +

+
+ {diagram ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/app/servicos/_components/ServiceOfferCards.paths.test.tsx b/app/servicos/_components/ServiceOfferCards.paths.test.tsx index f7d9234..533f670 100644 --- a/app/servicos/_components/ServiceOfferCards.paths.test.tsx +++ b/app/servicos/_components/ServiceOfferCards.paths.test.tsx @@ -1,4 +1,5 @@ import { render, screen, within } from "@testing-library/react"; +import { setNavigation } from "@/__mocks__/next/navigation"; import { caseStudies, type ServiceOffer } from "@/lib/site-content"; import { ServiceOfferCards } from "./ServiceOfferCards"; @@ -7,15 +8,19 @@ import { ServiceOfferCards } from "./ServiceOfferCards"; * com cases | sem cases (a lista de links some); com observação | sem observação. */ -jest.mock("next/navigation", () => ({ usePathname: () => "/servicos" })); +beforeEach(() => { + setNavigation({ pathname: "/servicos" }); +}); function offer(overrides: Partial): ServiceOffer { return { id: "site", name: "Oferta de teste", + whatsappSubject: "uma oferta de teste", summary: "Resumo.", href: "/servicos/criacao-de-sites", deliverables: ["Um", "Dois", "Três"], + firstStep: "Primeira conversa. Depois, escopo e proposta por escrito.", proof: { label: "Veja funcionando", caseIds: [], ...overrides }, }; } diff --git a/app/servicos/_components/service-page.paths.test.ts b/app/servicos/_components/service-page.paths.test.ts new file mode 100644 index 0000000..5e37de1 --- /dev/null +++ b/app/servicos/_components/service-page.paths.test.ts @@ -0,0 +1,140 @@ +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { getServicePage, type ServicePageContent } from "@/lib/site-content"; +import { resolveServiceHero, splitServiceCases } from "./service-page"; + +/** + * Caminhos-base de resolveServiceHero (CC 6) e splitServiceCases (CC 3) que service-page.test.ts não separa, todos com um + * serviço montado à mão, porque nenhum serviço real reúne essas combinações: + * - capa e esquema ao mesmo tempo: a capa vale e o esquema some; sem captura da capa, o esquema entra no lugar; + * - os cases do bloco menor não contam para o botão "Ver projetos parecidos" (ele leva a "Onde já apliquei isso"); + * - todos os cases no bloco menor: nenhum cartão, e o bloco leva todos, na ordem de `relatedCaseIds`. + * A existência das capturas em disco é simulada em getCaseMedia. + */ + +jest.mock("@/app/cases/_components/case-media", () => ({ + getCaseMedia: jest.fn(), +})); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +function service(overrides: Partial): ServicePageContent { + const base = getServicePage("consultoria-nextjs"); + if (!base) throw new Error("Missing service consultoria-nextjs"); + return { + ...base, + heroCase: undefined, + diagram: undefined, + alsoCases: undefined, + ...overrides, + }; +} + +/** Só as capturas listadas existem no disco. */ +function capturesOnDisk(captures: readonly (readonly [string, string])[]) { + mockedGetCaseMedia.mockImplementation((id, device) => + captures.some( + ([knownId, knownDevice]) => knownId === id && knownDevice === device, + ) + ? getShowcaseAssets(id, device) + : undefined, + ); +} + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("resolveServiceHero: combinações que os serviços reais não têm", () => { + it("path: a cover with its capture AND a diagram -> the cover wins and the diagram is dropped", () => { + capturesOnDisk([["lavacao-047", "desktop"]]); + + const hero = resolveServiceHero( + service({ + relatedCaseIds: ["lavacao-047"], + heroCase: { id: "lavacao-047", device: "desktop" }, + diagram: "iot", + }), + ); + + expect(hero.heroCase?.id).toBe("lavacao-047"); + expect(hero.diagram).toBeUndefined(); + expect(hero.hasHeroSide).toBe(true); + }); + + it("path: a cover WITHOUT its capture and a diagram -> the diagram takes the side column", () => { + capturesOnDisk([]); + + const hero = resolveServiceHero( + service({ + relatedCaseIds: ["lavacao-047"], + heroCase: { id: "lavacao-047", device: "desktop" }, + diagram: "iot", + }), + ); + + expect(hero.heroCase).toBeUndefined(); + expect(hero.diagram).toBe("iot"); + expect(hero.hasHeroSide).toBe(true); + // a capa não valeu, então o case não está citado em legenda nenhuma: ele ganha o cartão + expect(hero.hasCaseCards).toBe(true); + }); + + it("path: the only card left after the smaller block is the cover -> no 'Ver projetos parecidos' target, even with related cases in the block", () => { + capturesOnDisk([["lavacao-047", "desktop"]]); + + const hero = resolveServiceHero( + service({ + relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"], + heroCase: { id: "lavacao-047", device: "desktop" }, + alsoCases: { title: "Também", caseIds: ["sc-plus", "as-servicos"] }, + }), + ); + + expect(hero.heroCase?.id).toBe("lavacao-047"); + expect(hero.hasCaseCards).toBe(false); + }); + + it("path: a cover in the smaller block as well -> it is not a card, and the cover stays", () => { + capturesOnDisk([["lavacao-047", "desktop"]]); + + const hero = resolveServiceHero( + service({ + relatedCaseIds: ["lavacao-047"], + heroCase: { id: "lavacao-047", device: "desktop" }, + alsoCases: { title: "Também", caseIds: ["lavacao-047"] }, + }), + ); + + expect(hero.heroCase?.id).toBe("lavacao-047"); + expect(hero.hasCaseCards).toBe(false); + }); +}); + +describe("splitServiceCases: o bloco leva todos os cases", () => { + it("path: every related case in the smaller block -> no card at all, and the block keeps the order of relatedCaseIds", () => { + const { cards, also } = splitServiceCases( + service({ + relatedCaseIds: ["sc-plus", "as-servicos"], + // fora de ordem de propósito: quem manda é a ordem de `relatedCaseIds` + alsoCases: { title: "Também", caseIds: ["as-servicos", "sc-plus"] }, + }), + ); + + expect(cards).toEqual([]); + expect(also?.title).toBe("Também"); + expect(also?.cases.map(({ id }) => id)).toEqual(["sc-plus", "as-servicos"]); + }); + + it("path: a smaller block with an empty list of ids -> null, and every related case stays a card", () => { + const { cards, also } = splitServiceCases( + service({ + relatedCaseIds: ["sc-plus", "lavacao-047"], + alsoCases: { title: "Também", caseIds: [] }, + }), + ); + + expect(also).toBeNull(); + expect(cards.map(({ id }) => id)).toEqual(["sc-plus", "lavacao-047"]); + }); +}); diff --git a/app/servicos/_components/service-page.test.ts b/app/servicos/_components/service-page.test.ts new file mode 100644 index 0000000..c185aa5 --- /dev/null +++ b/app/servicos/_components/service-page.test.ts @@ -0,0 +1,148 @@ +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { getServicePage, type ServicePageContent } from "@/lib/site-content"; +import { resolveServiceHero, splitServiceCases } from "./service-page"; + +/** + * resolveServiceHero (CC 6): capa com captura | sem captura do aparelho pedido; esquema | sem esquema; o aparelho padrão + * (computador) | o do serviço (celular); algum case com cartão | só o da capa | nenhum. + * splitServiceCases (CC 3): serviço com bloco menor | sem ele; ids do bloco que o serviço relaciona | que não relaciona. + * A existência das capturas em disco é simulada em getCaseMedia. + */ + +jest.mock("@/app/cases/_components/case-media", () => ({ + getCaseMedia: jest.fn(), +})); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +function service(slug: string): ServicePageContent { + const found = getServicePage(slug); + if (!found) throw new Error(`Missing service ${slug}`); + return found; +} + +/** Só as capturas listadas existem no disco. */ +function capturesOnDisk(captures: readonly (readonly [string, string])[]) { + mockedGetCaseMedia.mockImplementation((id, device) => + captures.some( + ([knownId, knownDevice]) => knownId === id && knownDevice === device, + ) + ? getShowcaseAssets(id, device) + : undefined, + ); +} + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("resolveServiceHero", () => { + it("path: cover with its capture on disk -> the case is the cover, no diagram, two columns", () => { + capturesOnDisk([["lavacao-047", "desktop"]]); + + const hero = resolveServiceHero(service("criacao-de-sites")); + + expect(hero.heroCase?.id).toBe("lavacao-047"); + expect(hero.heroDevice).toBe("desktop"); + expect(hero.diagram).toBeUndefined(); + expect(hero.hasHeroSide).toBe(true); + // a SEV, que não é a capa, ganha cartão + expect(hero.hasCaseCards).toBe(true); + }); + + it("path: a service that asks for the phone -> the capture is checked for the phone", () => { + capturesOnDisk([["sev-modaintima", "mobile"]]); + + const hero = resolveServiceHero(service("engenharia-de-software")); + + expect(hero.heroDevice).toBe("mobile"); + expect(hero.heroCase?.id).toBe("sev-modaintima"); + expect(mockedGetCaseMedia).toHaveBeenCalledWith("sev-modaintima", "mobile"); + }); + + it("path: cover without a capture and no diagram -> no side column, and the case that would be the cover gets its card back", () => { + capturesOnDisk([]); + + const hero = resolveServiceHero(service("criacao-de-sites")); + + expect(hero.heroCase).toBeUndefined(); + expect(hero.diagram).toBeUndefined(); + expect(hero.hasHeroSide).toBe(false); + expect(hero.hasCaseCards).toBe(true); + }); + + it("path: service with a diagram -> the diagram fills the side column, desktop by default", () => { + capturesOnDisk([]); + + const hero = resolveServiceHero(service("tauri-rust")); + + expect(hero.heroCase).toBeUndefined(); + expect(hero.heroDevice).toBe("desktop"); + expect(hero.diagram).toBe("desktop"); + expect(hero.hasHeroSide).toBe(true); + expect(hero.hasCaseCards).toBe(true); + }); + + it("path: the only related case is the cover -> no card, so no 'Ver projetos parecidos' target", () => { + capturesOnDisk([["sc-plus", "desktop"]]); + const onlyCover: ServicePageContent = { + ...service("consultoria-nextjs"), + relatedCaseIds: ["sc-plus"], + }; + + const hero = resolveServiceHero(onlyCover); + + expect(hero.heroCase?.id).toBe("sc-plus"); + expect(hero.hasCaseCards).toBe(false); + }); + + it("path: no related case at all -> no cards", () => { + capturesOnDisk([]); + + expect(resolveServiceHero(service("arquitetura-de-software"))).toEqual({ + heroCase: undefined, + heroDevice: "desktop", + diagram: "arquitetura", + hasHeroSide: true, + hasCaseCards: false, + }); + }); +}); + +describe("splitServiceCases", () => { + it("path: service with a smaller block -> the sites institucionais leave the cards and go to the block", () => { + const { cards, also } = splitServiceCases(service("criacao-de-sites")); + + expect(cards.map(({ id }) => id)).toEqual([ + "lavacao-047", + "sev-modaintima", + ]); + expect(also?.title).toBe("Também faço sites institucionais"); + expect(also?.cases.map(({ id }) => id)).toEqual(["sc-plus", "as-servicos"]); + }); + + it("path: service without a smaller block -> every related case is a card and `also` is null", () => { + const { cards, also } = splitServiceCases( + service("engenharia-de-software"), + ); + + expect(cards.map(({ id }) => id)).toEqual([ + "sev-modaintima", + "lavacao-047", + ]); + expect(also).toBeNull(); + }); + + it("path: block ids that the service does not relate -> ignored, and with none left the block is null", () => { + const stray: ServicePageContent = { + ...service("consultoria-nextjs"), + alsoCases: { title: "Também", caseIds: ["as-servicos", "nao-existe"] }, + }; + + const { cards, also } = splitServiceCases(stray); + + expect(cards.map(({ id }) => id)).toEqual(["sc-plus", "lavacao-047"]); + expect(also).toBeNull(); + }); +}); diff --git a/app/servicos/_components/service-page.ts b/app/servicos/_components/service-page.ts new file mode 100644 index 0000000..2e3e59a --- /dev/null +++ b/app/servicos/_components/service-page.ts @@ -0,0 +1,64 @@ +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { + type CaseStudyContent, + caseStudies, + getRelatedCases, + type HeroDevice, + type ServiceDiagramKind, + type ServicePageContent, +} from "@/lib/site-content"; + +/** O que ocupa a coluna ao lado do título do serviço, e se há "Onde já apliquei isso" para o botão do topo levar até lá. */ +export interface ServiceHeroData { + /** Case da capa: o escolhido para o serviço, quando tem captura gravada do aparelho pedido. */ + heroCase?: CaseStudyContent; + heroDevice: HeroDevice; + /** Esquema de fluxo no lugar da capa; só existe quando não há case com captura. */ + diagram?: ServiceDiagramKind; + /** Há coluna ao lado do título (capa ou esquema): sem ela, o topo é uma coluna só e mais baixo. */ + hasHeroSide: boolean; + /** Algum case ganha cartão em "Onde já apliquei isso" (o da capa não ganha: a legenda dela o cita). */ + hasCaseCards: boolean; +} + +/** + * Os cases do serviço, em dois grupos: os cartões de "Onde já apliquei isso" e os do bloco menor `alsoCases` + * (sem capa), que o serviço pode declarar. Sem `alsoCases`, `also` é nulo. + */ +export function splitServiceCases(service: ServicePageContent) { + const related = getRelatedCases(service.relatedCaseIds); + const alsoIds = new Set(service.alsoCases?.caseIds); + const alsoCases = related.filter(({ id }) => alsoIds.has(id)); + + return { + cards: related.filter(({ id }) => !alsoIds.has(id)), + also: + service.alsoCases && alsoCases.length > 0 + ? { title: service.alsoCases.title, cases: alsoCases } + : null, + }; +} + +/** + * Capa do topo: o case escolhido para o serviço, se tiver captura gravada do aparelho pedido; senão, o esquema de fluxo. + * O case da capa é citado pela legenda dela e não ganha cartão em "Onde já apliquei isso". + */ +export function resolveServiceHero( + service: ServicePageContent, +): ServiceHeroData { + const heroDevice = service.heroCase?.device ?? "desktop"; + const heroCase = caseStudies.find( + ({ id }) => + id === service.heroCase?.id && Boolean(getCaseMedia(id, heroDevice)), + ); + const diagram = heroCase ? undefined : service.diagram; + const { cards } = splitServiceCases(service); + + return { + heroCase, + heroDevice, + diagram, + hasHeroSide: Boolean(heroCase || diagram), + hasCaseCards: cards.some(({ id }) => id !== heroCase?.id), + }; +} diff --git a/app/servicos/page.tsx b/app/servicos/page.tsx index 9f8f841..7e43a94 100644 --- a/app/servicos/page.tsx +++ b/app/servicos/page.tsx @@ -33,6 +33,9 @@ const pageTitle = "Sites, sistemas e aplicativos sob medida"; const pageDescription = "Sites, sistemas web e aplicativos sob medida para apresentar seu negócio e organizar a operação. Garuva e Joinville (SC), remoto em todo o Brasil."; +/** Cliente que não entra no mosaico do topo: a lista de /cases abre com a SEV (ordem da vitrine), mas o hub mostra os outros três. */ +const MOSAIC_EXCLUDED_ID = "sev-modaintima"; + /** Páginas que as três ofertas já abrem; os demais serviços ficam na lista "Outros serviços". */ const offerHrefs = new Set(serviceOffers.map(({ href }) => href)); const otherServices = serviceHubLinks.filter( @@ -69,9 +72,13 @@ export const metadata: Metadata = { }; export default function ServicesIndexPage() { - // três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (ela vai por último) + // três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia; a SEV fica de fora (o hub não abre com ela) // e nenhum cliente se repete mais abaixo, onde as ofertas citam os cases só por link - const posters = getMosaicPosters(clientCasesForListing.map(({ id }) => id)); + const posters = getMosaicPosters( + clientCasesForListing + .map(({ id }) => id) + .filter((id) => id !== MOSAIC_EXCLUDED_ID), + ); const hasMosaic = posters.length > 0; const jsonLd = { "@context": "https://schema.org", diff --git a/docs/seo-geo-operations.md b/docs/seo-geo-operations.md index b32ff8a..c8e4926 100644 --- a/docs/seo-geo-operations.md +++ b/docs/seo-geo-operations.md @@ -108,7 +108,7 @@ A matriz do design fecha a cobertura em seis grupos. Os números abaixo são rec | --- | --- | ---: | --- | | Google | G1–G13 | 13 | Conteúdo útil e original, uma canônica por intenção, schema visível, frescor sem datas fabricadas e medição no Search Console. | | Bing + Web Vitals | B1–B3, W1 | 4 | Resposta factual cedo, páginas focadas, evidência e AI Performance; sem IndexNow prematuro e sem score alegado. | -| Schema.org | S1–S6 | 6 | `Person`, `Service`, `ProfilePage` e `BreadcrumbList` mínimos, conectados e coerentes com o conteúdo visível. | +| Schema.org | S1–S6 | 6 | `Person`, `Service`, `ProfilePage` e `BreadcrumbList` mínimos, conectados e coerentes com o conteúdo visível. A home descreve as três ofertas como nós `Service` (com `provider` ligado à `Person` e `areaServed`); a revisão aponta para `/ia`. O `areaServed` é um valor só em todo nó `Service`, home e páginas de serviço: Garuva, Joinville, Santa Catarina e Brasil, como no texto visível. Sem `ProfessionalService`, `LocalBusiness` nem catálogo de ofertas. | | Next.js | N1–N7 | 7 | Metadata por rota, `generateMetadata`, imagens por segmento e build estático, sem pacote novo. | | GEO/crawlers | A1–A2, C1–C4, L1 | 7 | Fontes visíveis, medição como experimento e política de bots apenas documentada; sem causalidade de citação. | | Mercado observado | — | 20 | Clareza de intenção, links, projetos e prova de primeira mão; sem stuffing, testemunho, métrica ou autoridade inferida. | diff --git a/lib/offer-labels.ts b/lib/offer-labels.ts new file mode 100644 index 0000000..b8a41de --- /dev/null +++ b/lib/offer-labels.ts @@ -0,0 +1,20 @@ +/** + * Nome de cada uma das três ofertas e a frase que a mensagem de WhatsApp usa como assunto ("Quero conversar sobre {assunto}."). + * É a única fonte desses textos: a home, a página da oferta, os dados estruturados, o formulário de contato e as mensagens de + * WhatsApp importam daqui. Fica à parte de lib/site-content.ts, que reúne o texto de todas as páginas e não deve ir inteiro para + * o navegador: este módulo é pequeno, não tem dependência e não usa nada do Node, então componentes de cliente podem importá-lo. + */ +export const offerLabels = { + site: { + name: "Site que traz pedidos pelo WhatsApp", + whatsappSubject: "um site que traz pedidos pelo WhatsApp", + }, + sistema: { + name: "Sistema para tirar a operação da planilha", + whatsappSubject: "um sistema para tirar a operação da planilha", + }, + revisao: { + name: "Revisão de sistema que já existe", + whatsappSubject: "a revisão de um sistema que já existe", + }, +} as const; diff --git a/lib/seo.test.ts b/lib/seo.test.ts index b96704c..1895cea 100644 --- a/lib/seo.test.ts +++ b/lib/seo.test.ts @@ -2,9 +2,9 @@ import sitemap from "../app/sitemap"; import { buildBreadcrumbList, buildCaseJsonLd, + buildOfferServicesJsonLd, buildPageSocialMetadata, buildPersonJsonLd, - buildProfessionalServiceJsonLd, buildProfilePageJsonLd, buildServiceJsonLd, buildWebSiteJsonLd, @@ -29,7 +29,6 @@ it("uses one canonical identity across website, profile and service", () => { website: `${siteUrl}/#website`, person: `${siteUrl}/#person`, profilePage: `${siteUrl}/curriculo#profile-page`, - professionalService: `${siteUrl}/#professional-service`, }); expect(buildWebSiteJsonLd().publisher).toEqual({ "@id": SEO_IDS.person }); expect(buildProfilePageJsonLd()).toMatchObject({ @@ -74,6 +73,7 @@ it("keeps the root graph limited to verified website and person fields", () => { image: `${siteUrl}/edev.jpg`, jobTitle: "Engenheiro da Computação e Desenvolvedor de Software", homeLocation: { "@type": "Place", name: "Garuva, Santa Catarina, Brasil" }, + // só o LinkedIn: a conta do GitHub foi suspensa (404) e o Instagram fica fora por política sameAs: ["https://www.linkedin.com/in/evandrodevbr/"], }); @@ -82,7 +82,7 @@ it("keeps the root graph limited to verified website and person fields", () => { ); }); -it("builds a canonical service with only the verified service area", () => { +it("builds a canonical service whose service area is the visible one: Garuva, Joinville, Santa Catarina and Brazil", () => { const service = representativeService; const pageUrl = `${siteUrl}/servicos/${service.slug}`; @@ -96,49 +96,56 @@ it("builds a canonical service with only the verified service area", () => { areaServed: [ { "@type": "City", name: "Garuva" }, { "@type": "City", name: "Joinville" }, - { "@type": "AdministrativeArea", name: "Norte de Santa Catarina" }, + { "@type": "AdministrativeArea", name: "Santa Catarina" }, + { "@type": "Country", name: "Brasil" }, ], }); + // a mesma área das três ofertas da home: um valor só em todo o JSON-LD + for (const offer of buildOfferServicesJsonLd()) { + expect(offer.areaServed).toEqual(buildServiceJsonLd(service).areaServed); + } }); -it("describes the business as a ProfessionalService with the service area and the three offers as a catalog", () => { - const business = buildProfessionalServiceJsonLd(); +it("describes the three offers as Service nodes tied to the person, with the service area and the page of each offer", () => { + const services = buildOfferServicesJsonLd(); - expect(business).toMatchObject({ - "@type": "ProfessionalService", - "@id": SEO_IDS.professionalService, - name: siteName, - url: siteUrl, - founder: { "@id": SEO_IDS.person }, - }); - expect(business.areaServed.map(({ name }) => name)).toEqual([ - "Garuva", - "Joinville", - "Santa Catarina", - "Brasil", - ]); - - const catalog = business.hasOfferCatalog; - expect(catalog["@type"]).toBe("OfferCatalog"); - // as mesmas três ofertas da home, com o mesmo nome e a página de cada uma - expect( - catalog.itemListElement.map(({ itemOffered }) => itemOffered.name), - ).toEqual([ + expect(services).toHaveLength(3); + // as mesmas três ofertas da home, com o mesmo nome e a página de cada uma (a revisão aponta para /ia) + expect(services.map(({ name }) => name)).toEqual([ "Site que traz pedidos pelo WhatsApp", "Sistema para tirar a operação da planilha", - "Revisão de sistema que já existe, inclusive feito com IA", + "Revisão de sistema que já existe", ]); - expect( - catalog.itemListElement.map(({ itemOffered }) => itemOffered.url), - ).toEqual(serviceOffers.map(({ href }) => `${siteUrl}${href}`)); - for (const offer of catalog.itemListElement) { - expect(offer["@type"]).toBe("Offer"); - expect(offer.itemOffered["@type"]).toBe("Service"); - expect(offer.itemOffered.description.length).toBeGreaterThan(30); + expect(services.map(({ url }) => url)).toEqual([ + `${siteUrl}/servicos/criacao-de-sites`, + `${siteUrl}/servicos/engenharia-de-software`, + `${siteUrl}/ia`, + ]); + expect(services.map(({ url }) => url)).toEqual( + serviceOffers.map(({ href }) => `${siteUrl}${href}`), + ); + for (const service of services) { + expect(service["@type"]).toBe("Service"); + expect(service.provider).toEqual({ "@id": SEO_IDS.person }); + expect(service.areaServed.map(({ name }) => name)).toEqual([ + "Garuva", + "Joinville", + "Santa Catarina", + "Brasil", + ]); + expect(service.description.length).toBeGreaterThan(30); } - // nada que o site não publique: sem preço, prazo, endereço de rua, telefone ou e-mail - expect(JSON.stringify(business)).not.toMatch( - /price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i, + // ids próprios e estáveis, que não repetem o `#service` que a página de cada serviço emite + const ids = services.map((service) => service["@id"]); + expect(new Set(ids).size).toBe(3); + expect(ids).toEqual([ + `${siteUrl}/#service-site`, + `${siteUrl}/#service-sistema`, + `${siteUrl}/#service-revisao`, + ]); + // política de docs/seo-geo-operations.md: nenhum tipo de negócio local e nada que o site não publique + expect(JSON.stringify(services)).not.toMatch( + /ProfessionalService|LocalBusiness|OfferCatalog|price|priceCurrency|availability|telephone|email|streetAddress|PostalAddress|geo|aggregateRating|review/i, ); }); diff --git a/lib/seo.ts b/lib/seo.ts index 5a07d1a..c680ceb 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -14,7 +14,6 @@ export const SEO_IDS = { website: `${siteUrl}/#website`, person: `${siteUrl}/#person`, profilePage: `${siteUrl}/curriculo#profile-page`, - professionalService: `${siteUrl}/#professional-service`, } as const; export function buildWebSiteJsonLd() { @@ -51,6 +50,7 @@ export function buildPersonJsonLd() { "@type": "Place", name: "Garuva, Santa Catarina, Brasil", }, + // perfis profissionais públicos e no ar; a conta do GitHub foi suspensa (404) e o Instagram fica fora por política sameAs: ["https://www.linkedin.com/in/evandrodevbr/"], } as const; } @@ -67,39 +67,34 @@ export function buildProfilePageJsonLd() { } /** - * O negócio como `ProfessionalService`: onde atende (Garuva, Joinville, Santa Catarina e o Brasil, de forma remota) e as três ofertas - * da home como catálogo. Sem endereço de rua, preço, prazo, telefone ou e-mail: o que não está publicado no site não entra nos - * dados estruturados. Só o construtor: quem o coloca na página é a home. + * Área de atendimento de todo nó `Service` (as ofertas da home e as páginas de serviço): a mesma do texto visível, "Garuva e + * Joinville (SC), atendimento remoto em todo o Brasil". Um valor só, para os dados estruturados não divergirem entre páginas. */ -export function buildProfessionalServiceJsonLd() { - return { - "@type": "ProfessionalService", - "@id": SEO_IDS.professionalService, - name: siteName, - url: siteUrl, - description: - "Sites, sistemas web e aplicativos sob medida, e revisão de sistemas que já existem, inclusive feitos com IA. Garuva e Joinville (SC), remoto em todo o Brasil.", - founder: { "@id": SEO_IDS.person }, - areaServed: [ - { "@type": "City", name: "Garuva" }, - { "@type": "City", name: "Joinville" }, - { "@type": "AdministrativeArea", name: "Santa Catarina" }, - { "@type": "Country", name: "Brasil" }, - ], - hasOfferCatalog: { - "@type": "OfferCatalog", - name: "Serviços de desenvolvimento de software", - itemListElement: serviceOffers.map((offer) => ({ - "@type": "Offer", - itemOffered: { - "@type": "Service", - name: offer.name, - description: offer.summary, - url: `${siteUrl}${offer.href}`, - }, - })), - }, - } as const; +const serviceAreaServed = [ + { "@type": "City", name: "Garuva" }, + { "@type": "City", name: "Joinville" }, + { "@type": "AdministrativeArea", name: "Santa Catarina" }, + { "@type": "Country", name: "Brasil" }, +] as const; + +/** + * As três ofertas da home como nós `Service`, ligados à pessoa (`provider`) e com a área de atendimento (`serviceAreaServed`). + * Nada de `ProfessionalService`, `LocalBusiness` ou catálogo de ofertas: a política + * de docs/seo-geo-operations.md os exclui sem entidade ou local comprovado. Sem endereço, preço, prazo, telefone ou e-mail: o que + * não está publicado no site não entra nos dados estruturados. Só o construtor: quem o coloca na página é a home. + * O `url` é a página da oferta (a revisão abre /ia). O `@id` é da home, para não repetir o do nó que a página do serviço emite + * com outra descrição. + */ +export function buildOfferServicesJsonLd() { + return serviceOffers.map((offer) => ({ + "@type": "Service", + "@id": `${siteUrl}/#service-${offer.id}`, + name: offer.name, + description: offer.summary, + url: `${siteUrl}${offer.href}`, + provider: { "@id": SEO_IDS.person }, + areaServed: serviceAreaServed, + })); } export function buildServiceJsonLd(service: ServicePageContent) { @@ -112,14 +107,7 @@ export function buildServiceJsonLd(service: ServicePageContent) { name: service.navLabel, description: service.metaDescription, provider: { "@id": SEO_IDS.person }, - areaServed: [ - { "@type": "City", name: "Garuva" }, - { "@type": "City", name: "Joinville" }, - { - "@type": "AdministrativeArea", - name: "Norte de Santa Catarina", - }, - ], + areaServed: serviceAreaServed, } as const; } diff --git a/lib/site-content.listing.paths.test.ts b/lib/site-content.listing.paths.test.ts index 82f3f85..ce4b5f4 100644 --- a/lib/site-content.listing.paths.test.ts +++ b/lib/site-content.listing.paths.test.ts @@ -7,7 +7,7 @@ * Os dados reais só têm os quatro clientes da lista; o caminho do cliente fora da lista só existe aqui. */ -const LISTED = ["lavacao-047", "sc-plus", "as-servicos", "sev-modaintima"]; +const LISTED = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"]; function loadWithClients(clientIds: readonly string[] | "all") { let loaded: typeof import("./site-content") | undefined; @@ -25,14 +25,14 @@ function loadWithClients(clientIds: readonly string[] | "all") { } describe("clientCasesForListing: ordem", () => { - it("path: only listed clients -> the fixed order, the SEV last, whatever order the cases come in", () => { + it("path: only listed clients -> the fixed order, the SEV first, whatever order the cases come in", () => { const { clientCasesForListing, clientCases } = loadWithClients(LISTED); expect(clientCasesForListing.map(({ id }) => id)).toEqual(LISTED); expect(clientCasesForListing).toHaveLength(clientCases.length); }); - it("path: a client that is not in the order list -> goes after all the listed ones, the SEV included", () => { + it("path: a client that is not in the order list -> goes after all the listed ones", () => { const { clientCasesForListing, caseStudies } = loadWithClients([ ...LISTED, "gcc-installer", @@ -67,11 +67,11 @@ describe("clientCasesForListing: ordem", () => { expect(clientCasesForListing).toEqual([]); }); - it("path: the real data -> the SEV is the last client, as the mosaic and /cases expect", () => { + it("path: the real data -> the SEV is the first client, as in the home showcase", () => { jest.resetModules(); const { clientCasesForListing } = jest.requireActual("./site-content"); - expect(clientCasesForListing.at(-1)?.id).toBe("sev-modaintima"); + expect(clientCasesForListing[0]?.id).toBe("sev-modaintima"); }); }); diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index b013509..ea04858 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -6,8 +6,10 @@ import { caseStudies, clientCases, clientCasesForListing, + generalFirstStep, getRelatedCases, getRelatedServices, + getServiceOffer, isClientCase, labCases, pageUpdatedAt, @@ -93,12 +95,18 @@ it("mentions the region and a benefit in the title and description of every serv expect(new Set(descriptions).size).toBe(descriptions.length); }); -it("maps each service to its client cases, with the SEV only where it is the proof: the systems service", () => { +it("maps each service to its client cases, with the SEV as proof of both offers: the site and the system", () => { + // a Lavação abre a página, a SEV é o segundo trabalho e os dois sites institucionais vão para o bloco menor expect(getService("criacao-de-sites").relatedCaseIds).toEqual([ "lavacao-047", + "sev-modaintima", "sc-plus", "as-servicos", ]); + expect(getService("criacao-de-sites").alsoCases).toEqual({ + title: "Também faço sites institucionais", + caseIds: ["sc-plus", "as-servicos"], + }); expect(getService("engenharia-de-software").relatedCaseIds).toEqual([ "sev-modaintima", "lavacao-047", @@ -111,7 +119,12 @@ it("maps each service to its client cases, with the SEV only where it is the pro servicePages .filter(({ relatedCaseIds }) => relatedCaseIds.includes("sev-modaintima")) .map(({ slug }) => slug), - ).toEqual(["engenharia-de-software"]); + ).toEqual(["criacao-de-sites", "engenharia-de-software"]); + // o bloco menor só cita cases que o serviço já relaciona + for (const { relatedCaseIds, alsoCases } of servicePages) { + for (const id of alsoCases?.caseIds ?? []) + expect(relatedCaseIds).toContain(id); + } // nenhum case de cliente é um diagnóstico de sistema: o serviço fica só com a experiência na Apolo expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([]); expect(getService("arquitetura-de-software").caseRelevance).toEqual({}); @@ -195,12 +208,12 @@ it("gives each service without a client cover a flow diagram instead, and never } }); -it("lists the client cases for the mosaic and /cases with the SEV last, never first", () => { +it("lists the client cases for the mosaic and /cases in the order of the home showcase, the SEV first", () => { expect(clientCasesForListing.map(({ id }) => id)).toEqual([ + "sev-modaintima", "lavacao-047", "sc-plus", "as-servicos", - "sev-modaintima", ]); expect(clientCasesForListing).toHaveLength(clientCases.length); }); @@ -222,7 +235,12 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl expect(serviceOffers.map(({ name }) => name)).toEqual([ "Site que traz pedidos pelo WhatsApp", "Sistema para tirar a operação da planilha", - "Revisão de sistema que já existe, inclusive feito com IA", + "Revisão de sistema que já existe", + ]); + expect(serviceOffers.map(({ whatsappSubject }) => whatsappSubject)).toEqual([ + "um site que traz pedidos pelo WhatsApp", + "um sistema para tirar a operação da planilha", + "a revisão de um sistema que já existe", ]); expect(serviceOffers.map(({ href }) => href)).toEqual([ "/servicos/criacao-de-sites", @@ -232,6 +250,13 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl for (const offer of serviceOffers) { expect(offer.deliverables.length).toBeGreaterThanOrEqual(3); expect(offer.deliverables.length).toBeLessThanOrEqual(4); + expect(offer.firstStep).toMatch( + /^Primeira conversa .* proposta por escrito\.$/, + ); + // a frase geral da home não contradiz a da oferta: as duas terminam igual + expect(offer.firstStep.endsWith(generalFirstStep.split(". ")[1])).toBe( + true, + ); expect( offer.proof.caseIds.every((id) => caseStudies.some((c) => c.id === id)), ).toBe(true); @@ -240,6 +265,7 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl offer.name, offer.summary, ...offer.deliverables, + offer.firstStep, offer.proof.note ?? "", ].join(" "); expect(text).not.toMatch(/R\$|\d+\s?%|\d+\s?dias|["“”]/); @@ -254,6 +280,48 @@ it("explains the three offers of the home with the same names, 3 or 4 deliverabl expect(serviceOffers[2].proof.caseIds).toEqual([]); }); +it("uses one name per offer: the service page, the breadcrumb label and the WhatsApp subject match the offer", () => { + for (const offer of serviceOffers) { + const service = servicePages.find(({ slug }) => offer.href.endsWith(slug)); + // a revisão abre /ia, que não é uma página de serviço + if (!service) { + expect(offer.id).toBe("revisao"); + continue; + } + expect(service.navLabel).toBe(offer.name); + expect(service.headline).toBe(offer.name); + expect(service.whatsappSubject).toBe(offer.whatsappSubject); + expect(getServiceOffer(service.slug)).toBe(offer); + } + expect(getServiceOffer("consultoria-nextjs")).toBeUndefined(); +}); + +it("gives every service a short name and a lowercase WhatsApp subject with its article, and no two names alike", () => { + expect(servicePages.map(({ navLabel }) => navLabel)).toEqual([ + "Site que traz pedidos pelo WhatsApp", + "Sistema para tirar a operação da planilha", + "Consultoria em Next.js", + "Aplicativos desktop", + "Integração de equipamentos", + "Diagnóstico de arquitetura", + ]); + for (const service of servicePages) { + expect(service.whatsappSubject).toMatch(/^(um|uma|a) [a-zà-ú]/); + expect(service.whatsappSubject).not.toMatch(/[.!?]$/); + } + expect(getService("consultoria-nextjs").whatsappSubject).toBe( + "uma consultoria em Next.js", + ); + const names = servicePages.map(({ navLabel }) => navLabel); + expect(new Set(names).size).toBe(names.length); +}); + +it("says the systems service in terms of the pain: spreadsheets and rework", () => { + expect(getService("engenharia-de-software").answer).toMatch( + /planilhas.*retrabalho/, + ); +}); + it("writes 2 or 3 capabilities for every client case, without numbers, and none for the laboratory", () => { for (const caseStudy of clientCases) { expect(caseStudy.capabilities?.length).toBeGreaterThanOrEqual(2); @@ -323,7 +391,9 @@ it("derives the exact related service set from the existing relationship", () => expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([ "integracao-hardware-software-iot", ]); + // a SEV liga à oferta de site e à de sistema, nesta ordem expect(getRelatedServices("sev-modaintima").map(({ slug }) => slug)).toEqual([ + "criacao-de-sites", "engenharia-de-software", ]); expect( @@ -373,7 +443,7 @@ it("writes one sentence on why each related case matters to its service", () => } }); -it("cites the Apolo Energia experience, from 2022, only on the IoT and architecture services", () => { +it("cites the Apolo Energia experience, from 2022, only on the systems, IoT and architecture services", () => { const apolo = portfolioData.experiences.find( ({ id }) => id === "apolo-energia", ); @@ -383,6 +453,7 @@ it("cites the Apolo Energia experience, from 2022, only on the IoT and architect .filter(({ experienceNote }) => experienceNote) .map(({ slug }) => slug); expect(withNote).toEqual([ + "engenharia-de-software", "integracao-hardware-software-iot", "arquitetura-de-software", ]); @@ -390,12 +461,24 @@ it("cites the Apolo Energia experience, from 2022, only on the IoT and architect 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); } + for (const slug of [ + "integracao-hardware-software-iot", + "arquitetura-de-software", + ]) { + const note = getService(slug).experienceNote ?? ""; + expect(note).toMatch(/sistemas internos/i); + expect(note).toMatch(/aplicativos/i); + } + // o sistema diz o que o currículo diz da Apolo: aplicações web e mobile para rotinas internas, APIs e painéis de operação + const systemsNote = getService("engenharia-de-software").experienceNote ?? ""; + expect(systemsNote).toMatch(/aplicações web e mobile para rotinas internas/); + expect(systemsNote).toMatch(/painéis de operação/); + expect(apolo?.description).toMatch(/aplicações Web e Mobile/); + expect(apolo?.description).toMatch(/rotinas internas/); }); it("keeps the problem of every client case concrete, with no invented figure or quote", () => { @@ -449,6 +532,7 @@ it("keeps AS Serviços without a public site and states the author's participati it("keeps site-content and the modules it imports free of Node built-ins, so client components can import it", () => { for (const file of [ "site-content", + "offer-labels", "portfolio-data", "proof", "project-health", diff --git a/lib/site-content.ts b/lib/site-content.ts index ca85232..dfd7be6 100644 --- a/lib/site-content.ts +++ b/lib/site-content.ts @@ -1,3 +1,4 @@ +import { offerLabels } from "./offer-labels"; import { portfolio } from "./portfolio-data"; import { isClientProject } from "./proof"; @@ -25,7 +26,10 @@ export const buttonLabels = { export interface ServicePageContent { slug: string; + /** Nome curto do serviço: o mesmo na trilha, nos títulos, nos links e nos dados estruturados. Nas ofertas da home é o nome da oferta. */ navLabel: string; + /** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */ + whatsappSubject: string; eyebrow: string; /** Título para busca, separado do h1: até 43 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */ metaTitle: string; @@ -36,6 +40,7 @@ export interface ServicePageContent { intro: string; answer: string; audience: string[]; + /** "O que podemos entregar". Nas páginas que explicam uma oferta da home a seção não aparece: vale o "O que você recebe" da oferta (`serviceOffers`), uma lista de entregáveis só. */ outcomes: string[]; process: string[]; examples: string[]; @@ -43,6 +48,8 @@ export interface ServicePageContent { stack: string[]; /** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */ relatedCaseIds: string[]; + /** Cases relacionados que saem de "Onde já apliquei isso" e vão para um bloco menor com este título, sem capa (ids de `relatedCaseIds`). */ + alsoCases?: { title: string; caseIds: string[] }; /** Capa do topo: o case (um dos relacionados) e o aparelho da captura. Sem ela, o topo mostra o esquema `diagram`. */ heroCase?: { id: string; device: HeroDevice }; /** Esquema de fluxo do topo, para o serviço sem case com captura: nunca leva dado real nem logo de cliente. */ @@ -101,12 +108,13 @@ export interface CaseStudyContent { export const servicePages: ServicePageContent[] = [ { slug: "criacao-de-sites", - navLabel: "Sites profissionais", + navLabel: offerLabels.site.name, + whatsappSubject: offerLabels.site.whatsappSubject, eyebrow: "Presença digital", metaTitle: "Criação de sites em Joinville e SC", metaDescription: "Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.", - headline: "Site que traz pedidos pelo WhatsApp", + headline: offerLabels.site.name, 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.", intro: @@ -138,12 +146,19 @@ export const servicePages: ServicePageContent[] = [ "Metadados, URLs canônicas, sitemap e dados estruturados ajudam os buscadores a entender o site.", ], stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"], - relatedCaseIds: ["lavacao-047", "sc-plus", "as-servicos"], + // a Lavação abre a página (capa), a SEV é o segundo trabalho e os dois sites institucionais ficam no bloco menor + relatedCaseIds: ["lavacao-047", "sev-modaintima", "sc-plus", "as-servicos"], heroCase: { id: "lavacao-047", device: "desktop" }, + alsoCases: { + title: "Também faço sites institucionais", + caseIds: ["sc-plus", "as-servicos"], + }, updatedAt: "2026-10-14", caseRelevance: { "lavacao-047": "Um site local com preço por porte do veículo e pedido pelo WhatsApp, que o dono atualiza sozinho.", + "sev-modaintima": + "Um catálogo por tamanho com pedido pelo WhatsApp, e um painel para a loja cuidar de produtos e estoque.", "sc-plus": "Uma página institucional que reúne áreas, projetos, apoiadores e contato em um endereço próprio.", "as-servicos": @@ -169,14 +184,15 @@ export const servicePages: ServicePageContent[] = [ }, { slug: "engenharia-de-software", - navLabel: "Sistemas web e aplicativos mobile", + navLabel: offerLabels.sistema.name, + whatsappSubject: offerLabels.sistema.whatsappSubject, eyebrow: "Engenharia de software", metaTitle: "Sistemas web e aplicativos sob medida", metaDescription: "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Atendimento em Garuva e Joinville (SC) e remoto em todo o Brasil.", - headline: "Sistema para tirar a operação da planilha", + headline: offerLabels.sistema.name, 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.", + "Quando a operação vive em planilhas, mensagens e retrabalho, desenvolvo um sistema web ou um aplicativo mobile feito para a sua equipe. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções e a primeira entrega útil.", intro: "Organize a operação com um sistema web ou aplicativo mobile sob medida. Conecte ferramentas, reduza tarefas repetidas ou melhore um sistema que já existe.", audience: [ @@ -215,6 +231,8 @@ export const servicePages: ServicePageContent[] = [ "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.", }, + experienceNote: + "Desde 2022 trabalho com isso na Apolo Energia: aplicações web e mobile para rotinas internas, APIs e painéis de operação. Por serem sistemas de uso interno, não há link nem detalhes públicos.", faq: [ { question: "Preciso saber qual tecnologia usar?", @@ -235,7 +253,8 @@ export const servicePages: ServicePageContent[] = [ }, { slug: "consultoria-nextjs", - navLabel: "Melhorias em sites e sistemas Next.js", + navLabel: "Consultoria em Next.js", + whatsappSubject: "uma consultoria em Next.js", eyebrow: "Consultoria Next.js", metaTitle: "Consultoria Next.js para site ou sistema", metaDescription: @@ -301,7 +320,8 @@ export const servicePages: ServicePageContent[] = [ }, { slug: "tauri-rust", - navLabel: "Aplicativos desktop para Windows e Linux", + navLabel: "Aplicativos desktop", + whatsappSubject: "um aplicativo desktop", eyebrow: "Aplicativos desktop", metaTitle: "Aplicativos desktop para Windows e Linux", metaDescription: @@ -370,7 +390,8 @@ export const servicePages: ServicePageContent[] = [ }, { slug: "integracao-hardware-software-iot", - navLabel: "Integração de equipamentos e sistemas", + navLabel: "Integração de equipamentos", + whatsappSubject: "uma integração de equipamentos", eyebrow: "Hardware, software e IoT", metaTitle: "Integração de equipamentos e sistemas (IoT)", metaDescription: @@ -436,7 +457,8 @@ export const servicePages: ServicePageContent[] = [ }, { slug: "arquitetura-de-software", - navLabel: "Diagnóstico e arquitetura de software", + navLabel: "Diagnóstico de arquitetura", + whatsappSubject: "um diagnóstico de arquitetura", eyebrow: "Diagnóstico e decisão", metaTitle: "Diagnóstico e arquitetura de software", metaDescription: @@ -816,14 +838,14 @@ export const labCases = caseStudies.filter( ); /** - * Ordem dos clientes na lista de /cases e no mosaico de /servicos: a SEV Moda Íntima, que já abre a página de sistemas - * e é a prova da oferta de sistema, vem por último e fica fora das três capas do mosaico. Cliente fora desta lista vai para o fim. + * Ordem dos clientes na lista de /cases e no mosaico de /servicos: a mesma da vitrine da home (SHOWCASE em + * lib/project-showcase.ts), com a SEV Moda Íntima primeiro. Cliente fora desta lista vai para o fim. */ const clientListingOrder = [ + "sev-modaintima", "lavacao-047", "sc-plus", "as-servicos", - "sev-modaintima", ]; function listingRank({ id }: { id: string }) { @@ -848,18 +870,28 @@ export const pageUpdatedAt = { export interface ServiceOffer { id: "site" | "sistema" | "revisao"; - /** Nome da oferta, o mesmo da home. */ + /** Nome da oferta, o mesmo da home, da página da oferta, dos dados estruturados e do WhatsApp. */ name: string; + /** Assunto da conversa de WhatsApp, em minúsculas e com artigo, para a frase "Quero conversar sobre {assunto}.". */ + whatsappSubject: string; /** Uma frase sobre a oferta (descrição dos dados estruturados). */ summary: string; /** Página que explica a oferta. */ href: string; - /** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. */ + /** O que a pessoa recebe, em 3 ou 4 itens, só com o que os cases ou a página da oferta já sustentam. A home e a página da oferta mostram esta lista. */ deliverables: string[]; + /** Primeiro passo: uma conversa e, depois dela, o escopo e a proposta por escrito (sem prazo nem preço). A página da oferta mostra esta frase; a home, que serve às três, mostra `generalFirstStep`. */ + firstStep: string; /** Onde ver a oferta funcionando: o texto que abre a linha, os cases (links) e uma observação opcional. */ proof: { label: string; caseIds: string[]; note?: string }; } +/** O que vem depois da primeira conversa: o mesmo em cada oferta e na frase geral da home. */ +const FIRST_STEP_NEXT = "Depois, escopo e proposta por escrito."; + +/** O primeiro passo da home, que vale para as três ofertas: igual ao de cada uma, só sem dizer o que se entende na conversa. */ +export const generalFirstStep = `Primeira conversa para entender o seu caso. ${FIRST_STEP_NEXT}`; + /** * As três ofertas da home, na ordem do H1, com o mesmo nome. Cada texto só diz o que os cases citados, a experiência * profissional ou a página /ia já sustentam; sem prazo nem preço. @@ -867,7 +899,7 @@ export interface ServiceOffer { export const serviceOffers: ServiceOffer[] = [ { id: "site", - name: "Site que traz pedidos pelo WhatsApp", + ...offerLabels.site, summary: "Catálogo ou tabela de preços, mensagem pronta no WhatsApp e um painel para você editar preços, textos e fotos sozinho.", href: "/servicos/criacao-de-sites", @@ -877,6 +909,7 @@ export const serviceOffers: ServiceOffer[] = [ "Painel para você mudar preços, textos e fotos sem pedir a ninguém.", "Leitura boa no celular e informações organizadas para o Google.", ], + firstStep: `Primeira conversa para entender o problema. ${FIRST_STEP_NEXT}`, proof: { label: "Veja funcionando", caseIds: ["lavacao-047", "sev-modaintima"], @@ -884,7 +917,7 @@ export const serviceOffers: ServiceOffer[] = [ }, { id: "sistema", - name: "Sistema para tirar a operação da planilha", + ...offerLabels.sistema, summary: "Pedidos, estoque e rotinas que hoje vivem em planilhas e mensagens passam para um painel próprio, feito para a sua equipe.", href: "/servicos/engenharia-de-software", @@ -894,6 +927,7 @@ export const serviceOffers: ServiceOffer[] = [ "Acesso por perfil: cada pessoa vê só o que precisa.", "Uma primeira versão com as funções prioritárias, ampliada em etapas.", ], + firstStep: `Primeira conversa para entender a operação. ${FIRST_STEP_NEXT}`, proof: { label: "Veja funcionando", caseIds: ["sev-modaintima"], @@ -902,9 +936,9 @@ export const serviceOffers: ServiceOffer[] = [ }, { id: "revisao", - name: "Revisão de sistema que já existe, inclusive feito com IA", + ...offerLabels.revisao, summary: - "Avaliação do que já existe, teste das falhas de acesso a dados e separação do que dá para aproveitar do que é melhor refazer.", + "Avaliação de um sistema que já existe, inclusive feito com IA: teste das falhas de acesso a dados e separação do que dá para aproveitar do que é melhor refazer.", href: "/ia", deliverables: [ "As falhas de acesso a dados: quem consegue ver ou alterar o dado de quem.", @@ -912,6 +946,7 @@ export const serviceOffers: ServiceOffer[] = [ "O que é melhor refazer, com o motivo de cada parte.", "Um orçamento por escrito para pôr o sistema em produção.", ], + firstStep: `Primeira conversa para entender o app e o que ele precisa fazer. ${FIRST_STEP_NEXT}`, proof: { label: "Veja como funciona", caseIds: [], @@ -930,6 +965,11 @@ export function getServicePage(slug: string) { return servicePages.find((service) => service.slug === slug); } +/** A oferta que a página de serviço explica; os serviços que não são oferta (e a revisão, que abre /ia) não têm. */ +export function getServiceOffer(slug: string) { + return serviceOffers.find(({ href }) => href === `/servicos/${slug}`); +} + /** Cases na ordem de `caseIds`, com os de cliente à frente dos de laboratório. */ export function getRelatedCases(caseIds: string[]) { const cases = caseIds.flatMap((id) => { diff --git a/lib/whatsapp.paths.test.ts b/lib/whatsapp.paths.test.ts index 15236d2..4f1199f 100644 --- a/lib/whatsapp.paths.test.ts +++ b/lib/whatsapp.paths.test.ts @@ -16,7 +16,7 @@ import { */ const contexts: WhatsAppContexts = { - services: { "criacao-de-sites": "Sites profissionais" }, + services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" }, cases: { "sev-modaintima": "SEV Moda Íntima", vazio: "" }, }; diff --git a/lib/whatsapp.test.ts b/lib/whatsapp.test.ts index ec95bdf..61e8f66 100644 --- a/lib/whatsapp.test.ts +++ b/lib/whatsapp.test.ts @@ -1,3 +1,4 @@ +import { servicePages } from "./site-content"; import { AI_REVIEW_SUBJECT, buildWhatsAppUrl, @@ -5,6 +6,7 @@ import { HOME_SUBJECT, headerOpensWhatsApp, isServiceOrCasePath, + OFFER_SUBJECTS, RESUME_SUBJECT, SERVICES_SUBJECT, similarProjectSubject, @@ -13,15 +15,39 @@ import { } from "./whatsapp"; const contexts: WhatsAppContexts = { - services: { "criacao-de-sites": "Sites profissionais" }, + // o whatsappSubject do serviço, como app/layout.tsx o monta: uma frase em minúsculas, e não o nome do serviço + services: { "criacao-de-sites": "um site que traz pedidos pelo WhatsApp" }, cases: { "sev-modaintima": "SEV Moda Íntima" }, }; describe("whatsAppSubjectForPath", () => { - it("cites the service on /servicos/", () => { - expect(whatsAppSubjectForPath("/servicos/criacao-de-sites", contexts)).toBe( - "Sites profissionais", + it("cites the service on /servicos/ with its whatsappSubject, a lowercase phrase, so the message reads as a sentence", () => { + const subject = whatsAppSubjectForPath( + "/servicos/criacao-de-sites", + contexts, ); + expect(subject).toBe("um site que traz pedidos pelo WhatsApp"); + expect(`Quero conversar sobre ${subject}.`).not.toMatch( + /sobre Sites profissionais/, + ); + }); + + it("has a whatsappSubject, in lowercase and with no final period, for every service page and offer of site-content", () => { + expect(servicePages.length).toBeGreaterThan(0); + for (const { slug, navLabel, whatsappSubject } of servicePages) { + // não é o nome do serviço (que tem inicial maiúscula): é uma frase que entra depois de "sobre" + expect(whatsappSubject).toMatch(/^[a-zà-ú].*[^.]$/); + expect(whatsappSubject).not.toBe(navLabel); + expect( + whatsAppSubjectForPath(`/servicos/${slug}`, { + services: { [slug]: whatsappSubject }, + cases: {}, + }), + ).toBe(whatsappSubject); + } + for (const subject of Object.values(OFFER_SUBJECTS)) { + expect(subject).toMatch(/^[a-zà-ú].*[^.]$/); + } }); it("cites the case on /cases/, with the phrase every page uses for a similar project", () => { @@ -40,7 +66,17 @@ describe("whatsAppSubjectForPath", () => { expect(whatsAppSubjectForPath("/servicos", contexts)).toBe( SERVICES_SUBJECT, ); - expect(SERVICES_SUBJECT).toBe("Sites, sistemas e aplicativos"); + expect(SERVICES_SUBJECT).toBe("sites, sistemas e aplicativos"); + // nenhum assunto fixo começa em maiúscula: "Quero conversar sobre Sites, sistemas..." era o erro + for (const subject of [ + AI_REVIEW_SUBJECT, + HOME_SUBJECT, + CASES_SUBJECT, + SERVICES_SUBJECT, + RESUME_SUBJECT, + ]) { + expect(subject).toMatch(/^[a-zà-ú]/); + } expect(whatsAppSubjectForPath("/curriculo", contexts)).toBe(RESUME_SUBJECT); // sem contextos, a página-índice continua falando de si expect(whatsAppSubjectForPath("/cases/")).toBe(CASES_SUBJECT); @@ -55,7 +91,7 @@ describe("whatsAppSubjectForPath", () => { it("ignores a trailing slash", () => { expect( whatsAppSubjectForPath("/servicos/criacao-de-sites/", contexts), - ).toBe("Sites profissionais"); + ).toBe("um site que traz pedidos pelo WhatsApp"); }); it.each([ diff --git a/lib/whatsapp.ts b/lib/whatsapp.ts index b2612bc..57fabac 100644 --- a/lib/whatsapp.ts +++ b/lib/whatsapp.ts @@ -1,3 +1,5 @@ +import { offerLabels } from "./offer-labels"; + const COUNTRY = ["5", "5"]; const AREA = ["4", "7"]; const NINE = ["9"]; @@ -17,11 +19,14 @@ export function buildWhatsAppUrl(message: string) { } /** - * Nomes dos serviços e dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts: + * Assuntos dos serviços e nomes dos cases, montados no servidor (app/layout.tsx) a partir de lib/site-content.ts: * esse arquivo reúne o texto de todas as páginas e não deve ir inteiro para o navegador. */ export interface WhatsAppContexts { - /** slug do serviço -> nome do serviço */ + /** + * slug do serviço -> `whatsappSubject` dele: uma frase em minúsculas que entra em "Quero conversar sobre {assunto}." + * (e não o nome do serviço, que daria "sobre Sites profissionais."). + */ services: Record; /** id do case -> nome do projeto ou do cliente */ cases: Record; @@ -36,9 +41,20 @@ export const AI_REVIEW_SUBJECT = "a revisão de um aplicativo feito com IA"; */ export const HOME_SUBJECT = "um site ou sistema para a minha empresa"; export const CASES_SUBJECT = "um projeto parecido com os cases do portfólio"; -export const SERVICES_SUBJECT = "Sites, sistemas e aplicativos"; +export const SERVICES_SUBJECT = "sites, sistemas e aplicativos"; export const RESUME_SUBJECT = "um projeto, depois de ver o seu currículo"; +/** + * Assunto de cada oferta da home, para o contato direto de WhatsApp do formulário quando o tipo de projeto já está + * escolhido ("Quero conversar sobre um site que traz pedidos pelo WhatsApp."). Os nomes das ofertas, em minúsculas e com artigo, + * que vêm de `offerLabels` (lib/offer-labels.ts), a mesma fonte do nome da oferta e da página dela. + */ +export const OFFER_SUBJECTS = { + site: offerLabels.site.whatsappSubject, + sistema: offerLabels.sistema.whatsappSubject, + revisao: offerLabels.revisao.whatsappSubject, +} as const; + /** Frase padrão para quem quer algo como um projeto publicado (case de cliente ou de laboratório). "O case" concorda com qualquer título: sem ele, saía "o Lavação 047" e "o SEV Moda Íntima". */ export function similarProjectSubject(title: string) { return `um projeto parecido com o case ${title}`; diff --git a/scripts/seo-audit.mjs b/scripts/seo-audit.mjs index 530e10a..799d2a5 100644 --- a/scripts/seo-audit.mjs +++ b/scripts/seo-audit.mjs @@ -403,7 +403,8 @@ function checkStructuredData(scope, p) { (n) => n["@type"] === "Person" && Array.isArray(n.sameAs) && - n.sameAs.length >= 2, + // ao menos um perfil profissional no ar (o GitHub do autor foi suspenso; sobrou o LinkedIn) + n.sameAs.length >= 1, ), ); return ld;