From de6af74a0c9b19d94c0c1f93a85c3d9654fcf918 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 17:01:35 -0300 Subject: [PATCH] =?UTF-8?q?feat(cases):=20prova=20certa=20por=20servi?= =?UTF-8?q?=C3=A7o,=20p=C3=A1ginas=20internas=20com=20trabalho=20no=20topo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnóstico sem case que não cabe; desktop com o GCC Installer como projeto próprio; IoT com o Twins Lab e a nota da Apolo. Mosaico de pôsteres em /servicos e /cases, faixa baixa em uma coluna no currículo, privacidade e laboratório; Laboratório como lista compacta; SEV começando por catálogo, pedido no WhatsApp e painel; Joinville e SC nos títulos; imagens do LCP com fetchPriority e sizes reais; pré-carregamento só no hover; 404 com Open Graph próprio. --- app/cases/[slug]/page.test.tsx | 44 +++++-- .../CaseContactSection.paths.test.tsx | 9 +- app/cases/_components/CaseContactSection.tsx | 17 +-- .../_components/CaseCover.paths.test.tsx | 20 ++- app/cases/_components/CaseCover.tsx | 35 +++++- .../_components/CaseCoverLink.paths.test.tsx | 42 ++++--- app/cases/_components/CaseCoverLink.tsx | 14 +-- app/cases/_components/CaseHero.paths.test.tsx | 22 ++++ app/cases/_components/CaseHero.tsx | 13 +- app/cases/_components/CasePlayer.tsx | 4 +- app/cases/_components/CaseRelatedServices.tsx | 6 +- app/cases/_components/PosterMosaic.test.tsx | 108 ++++++++++++++++ app/cases/_components/PosterMosaic.tsx | 68 ++++++++++ app/cases/_components/hero-styles.ts | 15 +++ app/cases/page.loading.test.tsx | 28 +++-- app/cases/page.paths.test.tsx | 50 ++++++++ app/cases/page.tsx | 95 ++++++++------ app/components/Breadcrumb.prefetch.test.tsx | 52 ++++++++ app/components/Breadcrumb.tsx | 11 +- app/curriculo/page.test.tsx | 16 +++ app/curriculo/page.tsx | 33 +++-- app/not-found.test.tsx | 23 ++++ app/not-found.tsx | 36 ++++-- app/privacidade/page.test.tsx | 8 ++ app/privacidade/page.tsx | 12 +- app/servicos/[slug]/page.test.tsx | 96 ++++++++++++++- app/servicos/[slug]/page.tsx | 44 ++++--- .../_components/ServiceHeroCover.test.tsx | 69 +++++++++++ app/servicos/_components/ServiceHeroCover.tsx | 13 +- .../_components/ServiceRelatedCases.tsx | 9 +- app/servicos/page.paths.test.tsx | 41 +++++++ app/servicos/page.tsx | 116 +++++++++++------- app/sitemap.test.ts | 33 +++++ lib/seo.test.ts | 1 + lib/seo.ts | 1 + lib/site-content.test.ts | 62 ++++++++-- lib/site-content.ts | 50 ++++---- 37 files changed, 1062 insertions(+), 254 deletions(-) create mode 100644 app/cases/_components/PosterMosaic.test.tsx create mode 100644 app/cases/_components/PosterMosaic.tsx create mode 100644 app/cases/_components/hero-styles.ts create mode 100644 app/components/Breadcrumb.prefetch.test.tsx create mode 100644 app/servicos/_components/ServiceHeroCover.test.tsx create mode 100644 app/servicos/page.paths.test.tsx create mode 100644 app/sitemap.test.ts diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index eb66b10..6c0da0e 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -51,7 +51,8 @@ function readJsonLdEntities(container: HTMLElement): Record[] { /** * Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case. - * Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido". + * Case de cliente: "projeto parecido"; projeto de laboratório: "problema parecido" no botão. A mensagem é a mesma + * frase nos dois, a do cabeçalho: "um projeto parecido com o {título}". */ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { const hrefs = screen @@ -69,9 +70,7 @@ function expectContactAndNoDeadLinks(caseStudy: { id: string; title: string }) { const destination = new URL(cta.getAttribute("href") ?? ""); expect(destination.origin).toBe("https://wa.me"); expect(destination.searchParams.get("text")).toContain( - isClient - ? `um projeto parecido com o da ${caseStudy.title}` - : `um problema parecido com o do projeto ${caseStudy.title}`, + `um projeto parecido com o ${caseStudy.title}`, ); expect(cta).toHaveAttribute("target", "_blank"); expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener")); @@ -590,16 +589,36 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc clientCaseIds.map((id) => `/cases/${id}`), ); - // laboratório: sem capa - const labLinks = screen.getAllByRole("link", { name: "Ver o projeto" }); + // laboratório: lista compacta, uma linha por projeto (nome, o que é, "Projeto próprio" e o link), sem capa nem cartão + const labLinks = screen.getAllByRole("link", { name: /^Ver o projeto / }); expect(labLinks.map((link) => link.getAttribute("href"))).toEqual( labCaseIds.map((id) => `/cases/${id}`), ); - for (const link of labLinks) { - const card = link.closest("article"); + const labList = labLinks[0].closest("ul"); + expect(labList?.querySelectorAll(":scope > li")).toHaveLength( + labCaseIds.length, + ); + expect(labList?.querySelector("article, .ds-card, .ds-bframe")).toBeNull(); + labLinks.forEach((link, index) => { + const row = link.closest("li"); + const caseStudy = getCase(labCaseIds[index]); expect(isBefore(labHeading, link)).toBe(true); - expect(card?.querySelector(".ds-bframe")).toBeNull(); - } + expect(link).toHaveAccessibleName(`Ver o projeto ${caseStudy.title}`); + expect( + within(row as HTMLElement).getByRole("heading", { + level: 3, + name: caseStudy.title, + }), + ).toBeVisible(); + expect( + within(row as HTMLElement).getByText(caseStudy.subtitle), + ).toBeVisible(); + expect( + within(row as HTMLElement).getByText("Projeto próprio"), + ).toBeVisible(); + // sem o resumo longo do cartão antigo + expect(row).not.toHaveTextContent(caseStudy.summary); + }); // contato pelo WhatsApp, sem /#contact nem GitHub const hrefs = screen @@ -639,10 +658,11 @@ it("lists client cases with a cover first, then the laboratory, and keeps the sc }; const visibleItems = [...clientLinks, ...labLinks].map((link, index) => { const href = link.getAttribute("href"); - const card = link.closest("article"); + // cartão do cliente ou linha da lista do laboratório + const card = link.closest("article, li"); if (!card) throw new Error(`Missing visible card for ${href}`); const expected = getCase((href ?? "").replace("/cases/", "")); - expect(card.querySelector(".ds-kicker")).toHaveTextContent(expected.title); + expect(card).toHaveTextContent(expected.title); return { "@type": "ListItem", position: index + 1, diff --git a/app/cases/_components/CaseContactSection.paths.test.tsx b/app/cases/_components/CaseContactSection.paths.test.tsx index dc73ded..4a0add8 100644 --- a/app/cases/_components/CaseContactSection.paths.test.tsx +++ b/app/cases/_components/CaseContactSection.paths.test.tsx @@ -4,7 +4,8 @@ import { CaseContactSection } from "./CaseContactSection"; /** * Testes de caminhos-base de CaseContactSection: o convite de um case de cliente ("projeto parecido") e o de - * um projeto de laboratório ("problema parecido"), o link para a lista de cases e os serviços ligados. + * um projeto de laboratório ("problema parecido" no botão), o link para a lista de cases e os serviços ligados. + * A mensagem do WhatsApp é a mesma frase nos dois: "um projeto parecido com o {título}". */ const sev = { id: "sev-modaintima", title: "SEV Moda Íntima" }; @@ -29,7 +30,7 @@ describe("CaseContactSection", () => { }); expect(cta).toHaveAttribute("target", "_blank"); expect(messageOf(cta)).toContain( - `Quero conversar sobre um projeto parecido com o da ${sev.title}.`, + `Quero conversar sobre um projeto parecido com o ${sev.title}.`, ); expect(getRelatedServices(sev.id).length).toBeGreaterThan(0); expect( @@ -49,9 +50,11 @@ describe("CaseContactSection", () => { const cta = screen.getByRole("link", { name: "Tem um problema parecido? Vamos conversar.", }); + // a mensagem usa a mesma frase do cabeçalho, sem "da" nem "do projeto" na frente do nome expect(messageOf(cta)).toContain( - `Quero conversar sobre um problema parecido com o do projeto ${nutri.title}.`, + `Quero conversar sobre um projeto parecido com o ${nutri.title}.`, ); + expect(messageOf(cta)).not.toMatch(/problema parecido|com o da |com o do /); expect( screen.queryByRole("link", { name: "Conversar sobre um projeto parecido", diff --git a/app/cases/_components/CaseContactSection.tsx b/app/cases/_components/CaseContactSection.tsx index 011cc40..3c908b3 100644 --- a/app/cases/_components/CaseContactSection.tsx +++ b/app/cases/_components/CaseContactSection.tsx @@ -1,24 +1,25 @@ -import Link from "next/link"; import { primaryButton, secondaryButtonDark, } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import type { CaseStudyContent } from "@/lib/site-content"; +import { similarProjectSubject } from "@/lib/whatsapp"; import { CaseRelatedServices } from "./CaseRelatedServices"; -/** Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido. */ +/** + * Textos do convite ao contato: o case de cliente fala de um projeto parecido; o de laboratório, de um problema parecido. + * O assunto da conversa de WhatsApp é o mesmo nos dois (e no botão do cabeçalho): `similarProjectSubject`. + */ const contactCopy = { client: { title: "Quer um projeto parecido com este?", action: "Conversar sobre um projeto parecido", - subject: (title: string) => `um projeto parecido com o da ${title}`, }, lab: { title: "Fale sobre o seu caso", action: "Tem um problema parecido? Vamos conversar.", - subject: (title: string) => - `um problema parecido com o do projeto ${title}`, }, } as const; @@ -47,15 +48,15 @@ export function CaseContactSection({

{copy.action} - + Ver outros cases - +
diff --git a/app/cases/_components/CaseCover.paths.test.tsx b/app/cases/_components/CaseCover.paths.test.tsx index 87ac2d0..f9d9495 100644 --- a/app/cases/_components/CaseCover.paths.test.tsx +++ b/app/cases/_components/CaseCover.paths.test.tsx @@ -1,11 +1,11 @@ import { render, screen } from "@testing-library/react"; import { getShowcaseAssets } from "@/lib/project-showcase"; -import { CaseCover } from "./CaseCover"; +import { CaseCover, coverSizes } from "./CaseCover"; import { getCaseMedia } from "./case-media"; /** * Testes de caminhos-base de CaseCover: sem captura (nada), com captura (moldura + pôster) - * e os valores padrão de `sizes` e `priority`. + * e os valores padrão de `sizes` e `priority` (prioridade = carga imediata e `fetchpriority="high"`). */ jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); @@ -49,16 +49,26 @@ describe("CaseCover", () => { render(); const image = screen.getByAltText(`Captura de tela do site ${props.title}`); - expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw"); + expect(image).toHaveAttribute("sizes", coverSizes.card); expect(image).toHaveAttribute("loading", "lazy"); + expect(image).not.toHaveAttribute("fetchpriority"); }); - it("path: sizes and priority given -> uses them", () => { + it("path: sizes and priority given -> uses them, with eager loading and high fetch priority", () => { mockedGetCaseMedia.mockImplementation(getShowcaseAssets); render(); const image = screen.getByAltText(`Captura de tela do site ${props.title}`); expect(image).toHaveAttribute("sizes", "320px"); - expect(image).not.toHaveAttribute("loading", "lazy"); + expect(image).toHaveAttribute("loading", "eager"); + expect(image).toHaveAttribute("fetchpriority", "high"); + }); + + it("path: no 100vw sizes -> every preset describes the width the cover really has", () => { + // 100vw baixava a imagem de 750 px para uma capa de 300 px no celular + for (const sizes of Object.values(coverSizes)) { + expect(sizes).not.toMatch(/(^|, )100vw$/); + expect(sizes).toMatch(/calc\(100vw - \d+px\)$/); + } }); }); diff --git a/app/cases/_components/CaseCover.tsx b/app/cases/_components/CaseCover.tsx index 2a7a6c3..6e88382 100644 --- a/app/cases/_components/CaseCover.tsx +++ b/app/cases/_components/CaseCover.tsx @@ -5,12 +5,40 @@ import { } from "@/app/components/showcase/BrowserFrame"; import { getCaseMedia } from "./case-media"; -/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */ +/** + * Largura real da capa em cada lugar da página, para o navegador baixar a imagem do tamanho certo + * (com 100vw, um celular de 390 px pedia a versão de 750 px para uma capa de 300 px). As contas seguem o + * padding das seções (20 px até 768 px; depois 6vw, no máximo 64 px), a coluna central de 1240 px e os cartões + * (borda e 20 a 28 px de padding). + */ +export const coverSizes = { + /** Topo do case e do serviço: coluna da direita a partir de 1024 px, e a largura da página abaixo disso. */ + hero: "(min-width: 1440px) 560px, (min-width: 1024px) 40vw, (min-width: 769px) 88vw, calc(100vw - 40px)", + /** Capa dentro de um cartão (`ds-card`) da grade de duas colunas, que vira uma só até 1024 px. */ + card: "(min-width: 1440px) 560px, (min-width: 1025px) calc(50vw - 134px), (min-width: 769px) calc(88vw - 56px), calc(100vw - 88px)", + /** Capa solta numa grade de duas colunas, sem cartão em volta (a partir de 768 px). */ + column: + "(min-width: 1440px) 604px, (min-width: 1068px) calc(50vw - 80px), (min-width: 768px) calc(44vw - 16px), calc(100vw - 40px)", +} as const; + +/** + * Props da imagem que abre a página (a maior acima da dobra): carrega na hora e com prioridade de rede alta. + * No Next 16 a prop `priority` do `Image` só gera o ``; a prioridade de rede vem do `fetchPriority`. + */ +export const priorityImageProps = { + loading: "eager", + fetchPriority: "high", +} as const; + +/** + * Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. + * `priority` é para a imagem que abre a página: aplica `priorityImageProps`. + */ export function CaseCover({ id, title, demoUrl, - sizes = "(min-width: 1024px) 560px, 100vw", + sizes = coverSizes.card, priority = false, }: { id: string; @@ -21,6 +49,7 @@ export function CaseCover({ }) { const media = getCaseMedia(id, "desktop"); if (!media) return null; + const priorityProps = priority ? priorityImageProps : {}; return ( @@ -30,7 +59,7 @@ export function CaseCover({ alt={`Captura de tela do site ${title}`} fill sizes={sizes} - priority={priority} + {...priorityProps} className="object-cover object-top" /> diff --git a/app/cases/_components/CaseCoverLink.paths.test.tsx b/app/cases/_components/CaseCoverLink.paths.test.tsx index ec5226a..ff55960 100644 --- a/app/cases/_components/CaseCoverLink.paths.test.tsx +++ b/app/cases/_components/CaseCoverLink.paths.test.tsx @@ -1,11 +1,13 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { getShowcaseAssets } from "@/lib/project-showcase"; +import { coverSizes } from "./CaseCover"; import { CaseCoverLink } from "./CaseCoverLink"; import { getCaseMedia } from "./case-media"; /** * Testes de caminhos-base de CaseCoverLink: sem captura (nada), com captura (link fora da leitura e do teclado, - * com a capa dentro), os valores padrão de `sizes`, `priority` e `prefetch`, e os valores dados. + * com a capa dentro), os valores padrão de `sizes` e `priority`, o pré-carregamento só ao chegar no link + * e os valores dados. */ jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); @@ -61,32 +63,34 @@ describe("CaseCoverLink", () => { ).toBeInTheDocument(); }); - it("path: sizes, priority and prefetch omitted -> default sizes, lazy image and the link's own prefetch", () => { + it("path: sizes and priority omitted -> default sizes and lazy image", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + render(); + + const image = screen.getByAltText(`Captura de tela do site ${props.title}`); + expect(image).toHaveAttribute("sizes", coverSizes.card); + expect(image).toHaveAttribute("loading", "lazy"); + expect(image).not.toHaveAttribute("fetchpriority"); + }); + + it("path: link -> pre-fetches the case only when the pointer or focus reaches it", () => { mockedGetCaseMedia.mockImplementation(getShowcaseAssets); const { container } = render(); - const image = screen.getByAltText(`Captura de tela do site ${props.title}`); - expect(image).toHaveAttribute("sizes", "(min-width: 1024px) 560px, 100vw"); - expect(image).toHaveAttribute("loading", "lazy"); - expect(container.querySelector("a")).toHaveAttribute( - "data-prefetch", - "undefined", - ); + const link = container.querySelector("a"); + expect(link).toHaveAttribute("data-prefetch", "false"); + fireEvent.mouseEnter(link as HTMLAnchorElement); + expect(link).toHaveAttribute("data-prefetch", "null"); }); - it("path: sizes, priority and prefetch given -> all of them are used", () => { + it("path: sizes and priority given -> both are used", () => { mockedGetCaseMedia.mockImplementation(getShowcaseAssets); - const { container } = render( - , - ); + render(); const image = screen.getByAltText(`Captura de tela do site ${props.title}`); expect(image).toHaveAttribute("sizes", "320px"); - expect(image).not.toHaveAttribute("loading", "lazy"); - expect(container.querySelector("a")).toHaveAttribute( - "data-prefetch", - "false", - ); + expect(image).toHaveAttribute("loading", "eager"); + expect(image).toHaveAttribute("fetchpriority", "high"); }); it("path: no demo address -> the browser bar falls back to the project title", () => { diff --git a/app/cases/_components/CaseCoverLink.tsx b/app/cases/_components/CaseCoverLink.tsx index 18cc825..e9fc933 100644 --- a/app/cases/_components/CaseCoverLink.tsx +++ b/app/cases/_components/CaseCoverLink.tsx @@ -1,34 +1,32 @@ -import Link from "next/link"; -import { CaseCover } from "./CaseCover"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; +import { CaseCover, coverSizes } from "./CaseCover"; import { getCaseMedia } from "./case-media"; /** * Capa do case dentro de um cartão: a imagem leva ao case, mas fica fora da leitura e da ordem de tabulação, * porque o link de texto do mesmo cartão já leva ao mesmo lugar. Sem captura gravada, não mostra nada. + * A rota do case só é pré-carregada quando a pessoa chega no link (HoverPrefetchLink). */ export function CaseCoverLink({ id, title, demoUrl, - sizes = "(min-width: 1024px) 560px, 100vw", + sizes = coverSizes.card, priority = false, - prefetch, }: { id: string; title: string; demoUrl?: string | null; sizes?: string; priority?: boolean; - prefetch?: boolean; }) { if (!getCaseMedia(id, "desktop")) return null; return ( - - + ); } diff --git a/app/cases/_components/CaseHero.paths.test.tsx b/app/cases/_components/CaseHero.paths.test.tsx index d6a690e..511d038 100644 --- a/app/cases/_components/CaseHero.paths.test.tsx +++ b/app/cases/_components/CaseHero.paths.test.tsx @@ -3,6 +3,7 @@ import { getShowcaseAssets } from "@/lib/project-showcase"; import { caseStudies } from "@/lib/site-content"; import { CaseHero } from "./CaseHero"; import { getCaseMedia } from "./case-media"; +import { compactHero } from "./hero-styles"; /** * Testes de caminhos-base de CaseHero e dos fatos do projeto: com e sem captura gravada, case de cliente e de @@ -77,6 +78,27 @@ describe("CaseHero: captura gravada e tipo de case", () => { expect(screen.getByText(lab.summary)).toBeVisible(); }); + it("path: with a capture -> the top strip keeps the default height", () => { + withCapture(true); + const { container } = render(); + + expect(container.querySelector("section")).not.toHaveClass(compactHero); + }); + + it("path: without a capture -> the top strip is the lower one-column strip, for client and laboratory cases", () => { + withCapture(false); + for (const [caseStudy, isClient] of [ + [client, true], + [lab, false], + ] as const) { + const { container, unmount } = render( + , + ); + expect(container.querySelector("section")).toHaveClass(compactHero); + unmount(); + } + }); + it("path: breadcrumb -> Início, Cases and the case itself as the current page", () => { withCapture(false); render(); diff --git a/app/cases/_components/CaseHero.tsx b/app/cases/_components/CaseHero.tsx index 3b56478..3a36c2e 100644 --- a/app/cases/_components/CaseHero.tsx +++ b/app/cases/_components/CaseHero.tsx @@ -1,8 +1,9 @@ import { Breadcrumb } from "@/app/components/Breadcrumb"; import { pageTop } from "@/app/components/button-styles"; import type { CaseStudyContent } from "@/lib/site-content"; -import { CaseCover } from "./CaseCover"; +import { CaseCover, coverSizes } from "./CaseCover"; import { getCaseMedia } from "./case-media"; +import { compactHero } from "./hero-styles"; /** Fatos verificáveis do projeto. Sem nenhum, a lista não aparece. */ function CaseFacts({ facts }: { facts?: string[] }) { @@ -28,7 +29,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) { id={caseStudy.id} title={caseStudy.title} demoUrl={caseStudy.demoUrl} - sizes="(min-width: 1024px) 560px, 100vw" + sizes={coverSizes.hero} priority /> @@ -37,7 +38,7 @@ function CaseHeroCover({ caseStudy }: { caseStudy: CaseStudyContent }) { /** * Topo do case: trilha, título, resumo e fatos. Com captura gravada, o texto fica à esquerda e o pôster à direita; - * no celular a mídia vem logo abaixo do título. + * no celular a mídia vem logo abaixo do título. Sem captura, o topo é uma coluna só e mais baixo. */ export function CaseHero({ caseStudy, @@ -49,7 +50,11 @@ export function CaseHero({ const hasMedia = Boolean(getCaseMedia(caseStudy.id, "desktop")); return ( -
+
= { * Pôster com um botão de play. O vídeo só é montado, e só baixa, quando a pessoa aperta o botão: * antes disso a página mostra a imagem do site, não um vídeo parado em 0:00 com controles do navegador. * Vai dentro de um quadro com `position: relative` (moldura de navegador ou de celular) que dá o tamanho. + * `sizes` segue a grade de CaseMedia: a coluna da esquerda de um bloco de até 1040 px (88 vw da página abaixo de 1182 px) + * com a moldura do celular ao lado (220 px mais 32 px de vão); abaixo de 768 px, a largura da página menos 40 px. */ export function CasePlayer({ assets, title, device, - sizes = "(min-width: 1280px) 900px, 100vw", + sizes = "(min-width: 1182px) 788px, (min-width: 768px) calc(88vw - 252px), calc(100vw - 40px)", }: { assets: ShowcaseAssets; title: string; diff --git a/app/cases/_components/CaseRelatedServices.tsx b/app/cases/_components/CaseRelatedServices.tsx index 49bf97e..8701516 100644 --- a/app/cases/_components/CaseRelatedServices.tsx +++ b/app/cases/_components/CaseRelatedServices.tsx @@ -1,5 +1,5 @@ -import Link from "next/link"; import { textLinkDark } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import { getRelatedServices } from "@/lib/site-content"; /** Quantos serviços aparecem no fim do case: dois cards bastam para levar a pessoa ao serviço certo sem virar um menu. */ @@ -19,12 +19,12 @@ export function CaseRelatedServices({ caseId }: { caseId: string }) {

{service.navLabel}

{service.intro}

- Ver o serviço: {service.navLabel} - +
))} diff --git a/app/cases/_components/PosterMosaic.test.tsx b/app/cases/_components/PosterMosaic.test.tsx new file mode 100644 index 0000000..ed2c1f5 --- /dev/null +++ b/app/cases/_components/PosterMosaic.test.tsx @@ -0,0 +1,108 @@ +import { render } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { coverSizes } from "./CaseCover"; +import { getCaseMedia } from "./case-media"; +import { getMosaicPosters, MOSAIC_SIZE, PosterMosaic } from "./PosterMosaic"; + +/** + * Mosaico de pôsteres do topo de /servicos e /cases: quais pôsteres entram (só os com captura, no máximo três), + * e como cada imagem carrega (a primeira, a maior, na hora e com prioridade alta; as outras só quando aparecem). + */ + +jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("getMosaicPosters", () => { + it("path: every id has a capture -> keeps the order and stops at three", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + + const posters = getMosaicPosters(["a", "b", "c", "d"]); + + expect(MOSAIC_SIZE).toBe(3); + expect(posters).toEqual([ + { id: "a", poster: "/videos/a/desktop.webp" }, + { id: "b", poster: "/videos/b/desktop.webp" }, + { id: "c", poster: "/videos/c/desktop.webp" }, + ]); + }); + + it("path: some ids have no capture -> skips them and fills with the next ones", () => { + mockedGetCaseMedia.mockImplementation((id, device) => + id === "b" ? undefined : getShowcaseAssets(id, device), + ); + + expect(getMosaicPosters(["a", "b", "c", "d"]).map(({ id }) => id)).toEqual([ + "a", + "c", + "d", + ]); + expect(mockedGetCaseMedia).toHaveBeenCalledWith("b", "desktop"); + }); + + it("path: no id has a capture -> an empty list", () => { + mockedGetCaseMedia.mockReturnValue(undefined); + + expect(getMosaicPosters(["a", "b"])).toEqual([]); + }); +}); + +describe("PosterMosaic", () => { + const three = ["a", "b", "c"].map((id) => ({ + id, + poster: `/videos/${id}/desktop.webp`, + })); + + it("path: no posters -> renders nothing", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("path: three posters -> decorative, the first one eager with high priority and the real column width", () => { + const { container } = render(); + + expect(container.firstElementChild).toHaveAttribute("aria-hidden", "true"); + const images = Array.from(container.querySelectorAll("img")); + expect(images).toHaveLength(3); + for (const image of images) { + expect(image).toHaveAttribute("alt", ""); + } + + const [main, ...side] = images; + expect(main).toHaveAttribute("loading", "eager"); + expect(main).toHaveAttribute("fetchpriority", "high"); + expect(main).toHaveAttribute("sizes", coverSizes.hero); + expect(main.closest("div")).toHaveClass("col-span-2"); + for (const image of side) { + expect(image).toHaveAttribute("loading", "lazy"); + expect(image).not.toHaveAttribute("fetchpriority"); + // metade da coluna, e nunca a largura toda da tela + expect(image.getAttribute("sizes")).toBe( + "(min-width: 1440px) 274px, 20vw", + ); + } + }); + + it("path: the smaller posters are hidden below 1024 px, so they are not downloaded on a phone", () => { + const { container } = render(); + + const [main, ...side] = Array.from(container.querySelectorAll("img")).map( + (image) => image.closest("div"), + ); + expect(main).not.toHaveClass("hidden"); + for (const frame of side) { + expect(frame).toHaveClass("hidden", "lg:block"); + } + }); + + it("path: a single poster -> only the main one, with no side posters", () => { + const { container } = render(); + + expect(container.querySelectorAll("img")).toHaveLength(1); + }); +}); diff --git a/app/cases/_components/PosterMosaic.tsx b/app/cases/_components/PosterMosaic.tsx new file mode 100644 index 0000000..563584b --- /dev/null +++ b/app/cases/_components/PosterMosaic.tsx @@ -0,0 +1,68 @@ +import Image from "next/image"; +import { coverSizes, priorityImageProps } from "./CaseCover"; +import { getCaseMedia } from "./case-media"; + +export interface MosaicPoster { + id: string; + poster: string; +} + +/** Quantos pôsteres entram no mosaico: um grande em cima e dois menores embaixo. */ +export const MOSAIC_SIZE = 3; + +/** + * Pôsteres gravados dos cases, na ordem dada, só dos que têm captura no disco (no máximo `MOSAIC_SIZE`). + * Roda no servidor, como `getCaseMedia`. + */ +export function getMosaicPosters(ids: readonly string[]): MosaicPoster[] { + return ids + .flatMap((id) => { + const media = getCaseMedia(id, "desktop"); + return media ? [{ id, poster: media.poster }] : []; + }) + .slice(0, MOSAIC_SIZE); +} + +const frame = + "relative aspect-[16/10] overflow-hidden border border-white/15 bg-[#0a0a0a]"; + +/** + * Mosaico de pôsteres dos sites no topo das páginas internas, na coluna que ficaria vazia ao lado do título. + * É decorativo (texto alternativo vazio): quem lê os cases tem os links logo abaixo. O primeiro pôster é o maior + * e o que abre a página, então carrega na hora e com prioridade alta; no celular só ele aparece, e os outros dois + * (que não são baixados enquanto estão ocultos) entram a partir de 1024 px. + */ +export function PosterMosaic({ + posters, +}: { + posters: readonly MosaicPoster[]; +}) { + const [main, ...others] = posters; + if (!main) return null; + + return ( + + ); +} diff --git a/app/cases/_components/hero-styles.ts b/app/cases/_components/hero-styles.ts new file mode 100644 index 0000000..ef1b900 --- /dev/null +++ b/app/cases/_components/hero-styles.ts @@ -0,0 +1,15 @@ +/** + * Faixa de topo de uma página interna que não tem imagem ao lado: uma coluna só e mais baixa que a padrão. + * O "!" vence o CSS sem camada de globals.css (.ds-section: 72 a 112 px de folga em cima e embaixo). + * Em cima fica a altura do cabeçalho fixo mais um respiro, para a trilha não ficar por baixo dele; embaixo, 48 px. + * No celular vale o padding de `pageTop` (button-styles.ts) e o padrão de baixo. + */ +export const compactHero = + "md:!pt-[calc(var(--site-header-height)+var(--sp-10))] md:!pb-sp-12"; + +/** + * Topo com imagem: texto à esquerda e a mídia à direita a partir de 1024 px (1,1 parte para 1), uma coluna abaixo disso. + * Os `sizes` das imagens (CaseCover e PosterMosaic) seguem esta grade. + */ +export const heroWithMediaGrid = + "grid items-center gap-y-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-x-sp-16"; diff --git a/app/cases/page.loading.test.tsx b/app/cases/page.loading.test.tsx index ed5cdbe..22edbf1 100644 --- a/app/cases/page.loading.test.tsx +++ b/app/cases/page.loading.test.tsx @@ -4,8 +4,8 @@ import { clientCases } from "@/lib/site-content"; import CasesPage from "./page"; /** - * Carregamento da lista de cases: a primeira capa (a maior imagem acima da dobra) tem prioridade, - * e os links das capas não pré-carregam as páginas dos cases sozinhos. + * Carregamento da lista de cases: o primeiro pôster do mosaico do topo (a maior imagem acima da dobra) carrega na hora + * e com prioridade alta, as capas dos cartões são preguiçosas, e os links não pré-carregam as páginas dos cases sozinhos. */ type LinkProps = ComponentProps; @@ -40,14 +40,28 @@ function coverImages(container: HTMLElement) { } describe("CasesPage: carregamento das capas", () => { - it("loads the first cover with priority and the others lazily", () => { + it("loads the first mosaic poster at once with high priority, and every other image lazily", () => { const { container } = render(); - const [first, ...others] = coverImages(container); - expect(first).not.toHaveAttribute("loading", "lazy"); - expect(others.length).toBeGreaterThan(0); - for (const image of others) { + const mosaic = Array.from( + container.querySelectorAll("section [aria-hidden] img"), + ).filter((image) => image.getAttribute("alt") === ""); + const [main, ...side] = mosaic; + expect(main).toHaveAttribute("loading", "eager"); + expect(main).toHaveAttribute("fetchpriority", "high"); + // a largura real do pôster, e não 100vw (que baixava 750 px para 330 px) + expect(main.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/); + expect(side.length).toBeGreaterThan(0); + for (const image of side) { expect(image).toHaveAttribute("loading", "lazy"); + expect(image).not.toHaveAttribute("fetchpriority"); + } + + const covers = coverImages(container); + expect(covers.length).toBeGreaterThan(0); + for (const image of covers) { + expect(image).toHaveAttribute("loading", "lazy"); + expect(image).not.toHaveAttribute("fetchpriority"); } }); diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx index f034f2c..e59567f 100644 --- a/app/cases/page.paths.test.tsx +++ b/app/cases/page.paths.test.tsx @@ -63,3 +63,53 @@ describe("CasesPage: cases de cliente", () => { ); }); }); + +describe("CasesPage: topo com mosaico de pôsteres", () => { + it("path: captures found -> two columns with three posters of the other clients, not the first card's", () => { + const { container } = render(); + + const hero = container.querySelector("section"); + expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid"); + expect(hero?.className).not.toContain("md:!pb-sp-12"); + const posters = Array.from(hero?.querySelectorAll("img") ?? []).map( + (image) => decodeURIComponent(image.getAttribute("src") ?? ""), + ); + expect(posters).toHaveLength(3); + expect(posters.join(" ")).not.toContain(clientCases[0].id); + for (const [index, { id }] of clientCases.slice(1).entries()) { + expect(posters[index]).toContain(`/videos/${id}/desktop.webp`); + } + }); + + it("path: no capture for the other clients -> no mosaic, a lower one-column strip, and the first card cover takes the priority", () => { + mockedGetCaseMedia.mockImplementation((id, device) => + id === clientCases[0].id ? actualGetCaseMedia(id, device) : undefined, + ); + + const { container } = render(); + + const hero = container.querySelector("section"); + expect(hero?.querySelector("img")).toBeNull(); + expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); + expect(hero?.className).toContain("md:!pb-sp-12"); + const firstCover = clientCard(container, clientCases[0].id).querySelector( + ".ds-bframe img", + ); + expect(firstCover).toHaveAttribute("loading", "eager"); + expect(firstCover).toHaveAttribute("fetchpriority", "high"); + }); +}); + +describe("CasesPage: prioridade da capa do primeiro cartão", () => { + it("path: mosaic at the top -> the first card cover stays lazy, the priority is the mosaic's first poster", () => { + const { container } = render(); + + const hero = container.querySelector("section"); + expect(hero?.querySelector("img")).toHaveAttribute("loading", "eager"); + for (const { id } of clientCases) { + const cover = clientCard(container, id).querySelector(".ds-bframe img"); + expect(cover).toHaveAttribute("loading", "lazy"); + expect(cover).not.toHaveAttribute("fetchpriority"); + } + }); +}); diff --git a/app/cases/page.tsx b/app/cases/page.tsx index b2377ef..dd54bc0 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -1,5 +1,4 @@ import type { Metadata } from "next"; -import Link from "next/link"; import { Breadcrumb } from "@/app/components/Breadcrumb"; import { pageTop, @@ -8,6 +7,7 @@ import { secondaryButtonLight, textLink, } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; @@ -17,7 +17,10 @@ import { labCases, siteUrl, } from "@/lib/site-content"; +import { CASES_SUBJECT } from "@/lib/whatsapp"; import { CaseCoverLink } from "./_components/CaseCoverLink"; +import { compactHero, heroWithMediaGrid } from "./_components/hero-styles"; +import { getMosaicPosters, PosterMosaic } from "./_components/PosterMosaic"; const pageUrl = `${siteUrl}/cases`; const pageTitle = "Cases: sites e sistemas para empresas"; @@ -36,40 +39,33 @@ export const metadata: Metadata = { }), }; -function CaseCard({ +/** Cartão de um case de cliente, com a capa do site (quando há captura gravada), o resumo e o botão do case. */ +function ClientCaseCard({ caseStudy, - withCover, - ctaLabel, priority = false, }: { caseStudy: CaseStudyContent; - withCover: boolean; - ctaLabel: string; - /** Capa que abre a página (a primeira, o maior elemento visível): carrega antes das demais. */ + /** Capa que abre a página: só quando não há mosaico no topo, a capa passa a ser a maior imagem visível. */ priority?: boolean; }) { return (
- {withCover ? ( - - ) : null} +
{caseStudy.title}

{caseStudy.headline}

{caseStudy.summary}

- - {ctaLabel} - + Ver o case + {caseStudy.demoUrl ? ( +

{caseStudy.title}

+

Projeto próprio

+

+ {caseStudy.subtitle} +

+ + Ver o projeto {caseStudy.title} + + + ); +} + export default function CasesPage() { + // o mosaico mostra os outros clientes: o primeiro já aparece, com a capa, no primeiro cartão logo abaixo + const posters = getMosaicPosters(clientCases.slice(1).map(({ id }) => id)); + const hasMosaic = posters.length > 0; const jsonLd = { "@context": "https://schema.org", "@graph": [ @@ -116,8 +137,14 @@ export default function CasesPage() {
-
-
+
+
+
@@ -143,12 +171,10 @@ export default function CasesPage() {
{clientCases.map((caseStudy, index) => ( - ))}
@@ -163,16 +189,11 @@ export default function CasesPage() {

Projetos pessoais e de estudo, feitos para praticar e testar ideias.

-
+
    {labCases.map((caseStudy) => ( - + ))} -
+
@@ -187,15 +208,15 @@ export default function CasesPage() {

Conversar sobre meu projeto - + Ver os serviços - +
diff --git a/app/components/Breadcrumb.prefetch.test.tsx b/app/components/Breadcrumb.prefetch.test.tsx new file mode 100644 index 0000000..faff452 --- /dev/null +++ b/app/components/Breadcrumb.prefetch.test.tsx @@ -0,0 +1,52 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { Breadcrumb } from "./Breadcrumb"; + +/** + * A trilha de navegação só pré-carrega a rota de um item quando a pessoa chega nele (mouse, teclado ou toque): + * o `prefetch` não aparece no HTML, então o Link é trocado por um que o expõe em `data-prefetch`. + */ + +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + children, + prefetch, + ...props + }: { children?: React.ReactNode; prefetch?: boolean | null } & Record< + string, + unknown + >) => ( + + {children} + + ), +})); + +const items = [ + { label: "Início", href: "/" }, + { label: "Cases", href: "/cases" }, +]; + +describe("Breadcrumb: pré-carregamento", () => { + it("path: opening the page -> no link pre-fetches", () => { + render(); + + for (const link of screen.getAllByRole("link")) { + expect(link).toHaveAttribute("data-prefetch", "false"); + } + }); + + it.each([ + ["mouse", fireEvent.mouseEnter], + ["keyboard focus", fireEvent.focus], + ["touch", fireEvent.touchStart], + ])("path: %s reaching one link -> only that link pre-fetches", (_, event) => { + render(); + + const [home, cases] = screen.getAllByRole("link"); + event(cases); + + expect(cases).toHaveAttribute("data-prefetch", "null"); + expect(home).toHaveAttribute("data-prefetch", "false"); + }); +}); diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx index 4c6f5e1..857f54d 100644 --- a/app/components/Breadcrumb.tsx +++ b/app/components/Breadcrumb.tsx @@ -1,6 +1,9 @@ -import Link from "next/link"; +import HoverPrefetchLink from "./HoverPrefetchLink"; -/** Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual. */ +/** + * Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual. + * Os links só pré-carregam a rota quando a pessoa chega neles (HoverPrefetchLink). + */ export function Breadcrumb({ items, }: { @@ -15,13 +18,13 @@ export function Breadcrumb({ className={index > 0 ? "flex items-center gap-sp-2" : undefined} > {index > 0 ? : null} - {label} - + ))} diff --git a/app/curriculo/page.test.tsx b/app/curriculo/page.test.tsx index cd5df2d..0e08d09 100644 --- a/app/curriculo/page.test.tsx +++ b/app/curriculo/page.test.tsx @@ -78,3 +78,19 @@ it("offers the résumé PDF as a secondary action and measures the download", () fireEvent.click(pdf); expect(trackCvDownload).toHaveBeenCalledWith({ placement: "curriculo" }); }); + +it("opens with a lower one-column strip, the location line and spaced achievement lists", () => { + const { container } = render(CurriculoPage()); + + const hero = container.querySelector("section"); + expect(hero?.className).toContain("md:!pb-sp-12"); + expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); + expect(hero).toHaveTextContent( + "Garuva e Joinville (SC) · atendimento remoto em todo o Brasil", + ); + const achievements = container.querySelectorAll("article.ds-card ul.ds-list"); + expect(achievements.length).toBeGreaterThan(0); + for (const list of achievements) { + expect(list).toHaveClass("space-y-sp-2"); + } +}); diff --git a/app/curriculo/page.tsx b/app/curriculo/page.tsx index b8ff95b..5d97f07 100644 --- a/app/curriculo/page.tsx +++ b/app/curriculo/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import { compactHero } from "@/app/cases/_components/hero-styles"; import { Breadcrumb } from "@/app/components/Breadcrumb"; import { pageTop } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import { safeJsonLd } from "@/lib/jsonld"; import { portfolio } from "@/lib/portfolio-data"; import { @@ -43,7 +44,9 @@ export default function CurriculoPage() { return (
-
+

- Base em Garuva, atendimento a projetos no Norte de Santa Catarina e - atuação remota. Este currículo reúne experiência profissional, - formação e competências técnicas para avaliação de contratação. + Garuva e Joinville (SC) · atendimento remoto em todo o Brasil. Este + currículo reúne experiência profissional, formação e competências + técnicas para avaliação de contratação.

- + Falar sobre uma oportunidade - + Baixar currículo em PDF - + Procurando consultoria? Ver serviços - +
@@ -87,7 +96,7 @@ export default function CurriculoPage() { {experience.title} — {experience.company}

{experience.description}

-
    +
      {experience.achievements.map((achievement) => (
    • {achievement}
    • ))} @@ -123,9 +132,9 @@ export default function CurriculoPage() { ))}
      - + Ver projetos e cases - +
diff --git a/app/not-found.test.tsx b/app/not-found.test.tsx index b723a72..3790a79 100644 --- a/app/not-found.test.tsx +++ b/app/not-found.test.tsx @@ -9,6 +9,23 @@ describe("not-found", () => { expect(metadata.robots).not.toHaveProperty("follow"); }); + it("has its own Open Graph and Twitter card, with no url (the home one does not apply to a page that does not exist)", () => { + expect(metadata.openGraph).toEqual({ + locale: "pt_BR", + title: "Página não encontrada", + description: "A página que você procura não existe ou foi movida.", + siteName: "Evandro.dev.br", + type: "website", + }); + expect(metadata.openGraph).not.toHaveProperty("url"); + expect(metadata.twitter).toMatchObject({ + card: "summary_large_image", + title: "Página não encontrada", + description: "A página que você procura não existe ou foi movida.", + }); + expect(metadata.twitter).not.toHaveProperty("url"); + }); + it("keeps Googlebot out, since the root layout lets it index", () => { expect(metadata.robots).toEqual({ googleBot: { index: false, follow: false }, @@ -30,4 +47,10 @@ describe("not-found", () => { "/cases", ); }); + + it("uses no lime: the lime is only for the contact action", () => { + const { container } = render(); + + expect(container.innerHTML).not.toMatch(/slash|ccff00/i); + }); }); diff --git a/app/not-found.tsx b/app/not-found.tsx index d7b48a7..695b3dc 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,14 +1,34 @@ import { ArrowLeft, Code2, Home } from "lucide-react"; import type { Metadata } from "next"; -import Link from "next/link"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; +import { twitterHandle } from "@/lib/seo"; +import { siteName } from "@/lib/site-content"; + +const title = "Página não encontrada"; +const description = "A página que você procura não existe ou foi movida."; // O Next já escreve em toda página 404; repetir `robots` aqui duplicaria a tag. // O layout raiz libera a indexação para o Googlebot, então só essa regra é trocada. A canônica da home também não vale num 404. +// O cartão de compartilhamento é o do próprio 404 e fica sem `url`: o endereço pedido não existe, e o `og:url` da home +// (herdado do layout, que é substituído por inteiro quando a página define `openGraph`) apontaria a prévia para outra página. export const metadata: Metadata = { - title: "Página não encontrada", - description: "A página que você procura não existe ou foi movida.", + title, + description, alternates: { canonical: null }, robots: { googleBot: { index: false, follow: false } }, + openGraph: { + locale: "pt_BR", + title, + description, + siteName, + type: "website", + }, + twitter: { + card: "summary_large_image", + title, + description, + creator: twitterHandle, + }, }; export default function NotFound() { @@ -16,7 +36,7 @@ export default function NotFound() {
- +

404

@@ -29,20 +49,20 @@ export default function NotFound() {
- Voltar ao início - - + Ver os cases - +
diff --git a/app/privacidade/page.test.tsx b/app/privacidade/page.test.tsx index 63534d7..9eb8ce7 100644 --- a/app/privacidade/page.test.tsx +++ b/app/privacidade/page.test.tsx @@ -64,6 +64,14 @@ it("uses the same shell as the home, with no narrow max-width", () => { expect(metadata.title).toBe("Privacidade e consentimento"); }); +it("opens with the same lower one-column strip as the other inner pages", () => { + const { container } = render(); + + const hero = container.querySelector("section"); + expect(hero?.className).toContain("md:!pb-sp-12"); + expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); +}); + it("keeps the lime fill for contact actions only", () => { const { container } = render(); diff --git a/app/privacidade/page.tsx b/app/privacidade/page.tsx index 8d61b5e..0e5b3fd 100644 --- a/app/privacidade/page.tsx +++ b/app/privacidade/page.tsx @@ -1,7 +1,8 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import { compactHero } from "@/app/cases/_components/hero-styles"; import { Breadcrumb } from "@/app/components/Breadcrumb"; import { pageTop } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import { safeJsonLd } from "@/lib/jsonld"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { siteUrl } from "@/lib/site-content"; @@ -37,7 +38,9 @@ export default function PrivacyPage() {
-
+
-

Privacidade e consentimento

Privacidade e consentimento

@@ -132,12 +134,12 @@ export default function PrivacyPage() { Solicitações relacionadas aos seus direitos devem ser encaminhadas ao e-mail publicado acima.

- Voltar ao início - +
diff --git a/app/servicos/[slug]/page.test.tsx b/app/servicos/[slug]/page.test.tsx index e7ce94e..8382bff 100644 --- a/app/servicos/[slug]/page.test.tsx +++ b/app/servicos/[slug]/page.test.tsx @@ -2,6 +2,7 @@ import { render, screen, within } from "@testing-library/react"; import { SEO_IDS } from "@/lib/seo"; import { caseStudies, + clientCases, getRelatedCases, getServicePage, isClientCase, @@ -207,6 +208,33 @@ it("renders answer-first consulting, reciprocal case links and minimal schema", ]); }); +it("fills the hub hero with a mosaic of three client posters and states the location in one line", () => { + const { container } = render(); + + const hero = container.querySelector("section"); + expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid"); + const posters = Array.from(hero?.querySelectorAll("img") ?? []); + expect(posters).toHaveLength(3); + for (const [index, poster] of posters.entries()) { + expect(decodeURIComponent(poster.getAttribute("src") ?? "")).toContain( + `/videos/${clientCases[index].id}/desktop.webp`, + ); + expect(poster).toHaveAttribute("alt", ""); + } + // só o primeiro, o maior, carrega na hora e com prioridade alta + expect(posters[0]).toHaveAttribute("fetchpriority", "high"); + expect(posters[1]).toHaveAttribute("loading", "lazy"); + expect(hero).toHaveTextContent( + "Garuva e Joinville (SC) · atendimento remoto em todo o Brasil", + ); + expect(container.textContent).not.toMatch(/perto de Garuva/); + + // as capas dos projetos, mais abaixo, têm a largura da coluna e carregam preguiçosas + const cover = container.querySelector("#projetos .ds-bframe img"); + expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/); + expect(cover).toHaveAttribute("loading", "lazy"); +}); + it("keeps hub cards and structured items aligned", () => { const { container } = render(); @@ -309,19 +337,75 @@ it("shows the client cases first, right after the hero, with the first cover in expect(hero?.querySelector("a[href='/cases/sev-modaintima']")).not.toBeNull(); }); -it("hides the related block and the hero frame when a service has no case", async () => { - const { container } = await renderService("tauri-rust"); +it("shows no case, no frame and no 'similar projects' button on the diagnosis service, only the Apolo note", async () => { + const { container } = await renderService("arquitetura-de-software"); + // nenhum case de cliente é um diagnóstico: só a experiência na Apolo, em texto expect( - screen.queryByRole("heading", { name: "Onde já apliquei isso" }), - ).toBeNull(); + screen.getByRole("heading", { name: "Onde já apliquei isso" }), + ).toBeVisible(); + expect(screen.getByText(/Apolo Energia/)).toBeVisible(); expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull(); expect( screen.queryByRole("link", { name: "Ver projetos parecidos" }), ).toBeNull(); + expect(screen.queryByText(/SEV Moda Íntima/)).toBeNull(); expect(container.querySelector(".ds-bframe")).toBeNull(); - // GCC Installer (Python e Tkinter) não pertence a um serviço de Tauri e Rust - expect(container.textContent).not.toMatch(/GCC|Tkinter/); +}); + +it("lists the Windows app as a 'Projeto próprio' on the desktop service, saying it is made in Python", async () => { + const { container } = await renderService("tauri-rust"); + + expect( + screen.getByRole("link", { name: "Ler case: MinGW GCC Installer" }), + ).toHaveAttribute("href", "/cases/gcc-installer"); + expect( + screen.getByText("MinGW GCC Installer · Projeto próprio"), + ).toBeVisible(); + expect(screen.getByText(/em Python e não em Tauri/)).toBeVisible(); + expect( + screen.getByRole("link", { name: "Ver projetos parecidos" }), + ).toHaveAttribute("href", "#casos-relacionados"); + // sem captura gravada, não há moldura nem imagem + expect(container.querySelector(".ds-bframe")).toBeNull(); +}); + +it("keeps the hero in one lower column when there is no media, and in two columns when there is", async () => { + for (const slug of [ + "tauri-rust", + "integracao-hardware-software-iot", + "arquitetura-de-software", + ]) { + const { container, unmount } = await renderService(slug); + const hero = container.querySelector("section"); + expect(hero?.className).toContain("md:!pb-sp-12"); + expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); + unmount(); + } + + const withMedia = await renderService("criacao-de-sites"); + const hero = withMedia.container.querySelector("section"); + expect(hero?.className).not.toContain("md:!pb-sp-12"); + expect(hero?.querySelector(".ds-section-shell")).toHaveClass("grid"); + const cover = hero?.querySelector(".ds-bframe img"); + // a largura real da coluna da direita, e não 100vw; é a imagem que abre a página + expect(cover?.getAttribute("sizes")).toMatch(/calc\(100vw - 40px\)$/); + expect(cover).toHaveAttribute("loading", "eager"); + expect(cover).toHaveAttribute("fetchpriority", "high"); +}); + +it("puts the location in one line, 'Garuva e Joinville (SC)', and spaces the list items", async () => { + const { container } = await renderService("consultoria-nextjs"); + + expect(container.textContent).toMatch( + /Garuva e Joinville \(SC\) · atendimento remoto em todo o Brasil/, + ); + expect(container.textContent).not.toMatch(/a partir de Garuva/); + const lists = Array.from(container.querySelectorAll("ul.ds-list")); + expect(lists).toHaveLength(4); + for (const list of lists) { + expect(list).toHaveClass("space-y-sp-2"); + } }); it("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => { diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx index 7387e22..89f1756 100644 --- a/app/servicos/[slug]/page.tsx +++ b/app/servicos/[slug]/page.tsx @@ -1,13 +1,17 @@ import type { Metadata } from "next"; -import Link from "next/link"; 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"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; import { @@ -92,13 +96,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
+
) : null}
-

+

Fale diretamente com Evandro. Abre uma conversa, sem precisar - preencher um formulário. Atendimento remoto a partir de Garuva, - SC. + preencher um formulário. Garuva e Joinville (SC) · atendimento + remoto em todo o Brasil.

@@ -150,7 +154,7 @@ export default async function ServicePage({ params }: ServicePageProps) {

Para quem faz sentido

-
    +
      {service.audience.map((item) => (
    • {item}
    • ))} @@ -159,7 +163,7 @@ export default async function ServicePage({ params }: ServicePageProps) {

      O que podemos entregar

      -
        +
          {service.outcomes.map((item) => (
        • {item}
        • ))} @@ -175,7 +179,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
          Da conversa à entrega

          Como trabalhamos

          -
            +
              {service.process.map((step) => (
            1. {step} @@ -191,7 +195,7 @@ export default async function ServicePage({ params }: ServicePageProps) { Situações comuns em que este serviço ajuda. A sua será combinada na conversa.

              -
                +
                  {service.examples.map((example) => (
                • {example}
                • ))} @@ -205,7 +209,7 @@ export default async function ServicePage({ params }: ServicePageProps) { Detalhes técnicos e tecnologias
                  -
                    +
                      {service.technicalDetails.map((detail) => (
                    • {detail}
                    • ))} @@ -240,8 +244,7 @@ export default async function ServicePage({ params }: ServicePageProps) {

                      Pode ser uma ideia, uma tarefa que toma tempo ou um sistema que precisa melhorar. Não é preciso chegar com uma solução técnica. - Atendo a partir de Garuva, no Norte de Santa Catarina, e - remotamente. + Atendo em Garuva e Joinville (SC) e remotamente, em todo o Brasil.

                      {service.ctaLabel} no WhatsApp - + Ver todos os serviços - +
diff --git a/app/servicos/_components/ServiceHeroCover.test.tsx b/app/servicos/_components/ServiceHeroCover.test.tsx new file mode 100644 index 0000000..29106b8 --- /dev/null +++ b/app/servicos/_components/ServiceHeroCover.test.tsx @@ -0,0 +1,69 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { coverSizes } from "@/app/cases/_components/CaseCover"; +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { caseStudies } from "@/lib/site-content"; +import { ServiceHeroCover } from "./ServiceHeroCover"; + +/** + * Capa de destaque do serviço: sem case não mostra nada; com case, é a imagem que abre a página (carga na hora, + * prioridade alta e a largura real da coluna) e o link só pré-carrega o case quando a pessoa chega nele. + */ + +jest.mock("@/app/cases/_components/case-media", () => ({ + getCaseMedia: jest.fn(), +})); +jest.mock("next/link", () => ({ + __esModule: true, + default: ({ + children, + prefetch, + ...props + }: { children?: React.ReactNode; prefetch?: boolean | null } & Record< + string, + unknown + >) => ( + + {children} + + ), +})); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); +const sev = caseStudies.filter(({ id }) => id === "sev-modaintima")[0]; + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("ServiceHeroCover", () => { + it("path: no case -> renders nothing", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("path: case with a capture -> a link to the case around a cover that loads first", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + render(); + + const link = screen.getByRole("link", { + name: new RegExp(`^Ver o case ${sev.title}`), + }); + expect(link).toHaveAttribute("href", `/cases/${sev.id}`); + const image = screen.getByAltText(`Captura de tela do site ${sev.title}`); + expect(image).toHaveAttribute("loading", "eager"); + expect(image).toHaveAttribute("fetchpriority", "high"); + expect(image).toHaveAttribute("sizes", coverSizes.hero); + }); + + it("path: link -> pre-fetches the case only after the pointer reaches it", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + render(); + + const link = screen.getByRole("link"); + expect(link).toHaveAttribute("data-prefetch", "false"); + fireEvent.mouseEnter(link); + expect(link).toHaveAttribute("data-prefetch", "null"); + }); +}); diff --git a/app/servicos/_components/ServiceHeroCover.tsx b/app/servicos/_components/ServiceHeroCover.tsx index a75ce67..6f82aee 100644 --- a/app/servicos/_components/ServiceHeroCover.tsx +++ b/app/servicos/_components/ServiceHeroCover.tsx @@ -1,5 +1,5 @@ -import Link from "next/link"; -import { CaseCover } from "@/app/cases/_components/CaseCover"; +import { CaseCover, coverSizes } from "@/app/cases/_components/CaseCover"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import type { CaseStudyContent } from "@/lib/site-content"; /** Capa do case de destaque ao lado do título do serviço. Sem case com captura, não mostra nada. */ @@ -11,15 +11,18 @@ export function ServiceHeroCover({ if (!caseStudy) return null; return ( - + Ver o case {caseStudy.title} - + ); } diff --git a/app/servicos/_components/ServiceRelatedCases.tsx b/app/servicos/_components/ServiceRelatedCases.tsx index 42739d2..7e51045 100644 --- a/app/servicos/_components/ServiceRelatedCases.tsx +++ b/app/servicos/_components/ServiceRelatedCases.tsx @@ -1,6 +1,6 @@ -import Link from "next/link"; import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink"; import { secondaryButtonLight, textLink } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import { type CaseStudyContent, isClientCase } from "@/lib/site-content"; function ServiceCaseCard({ @@ -27,9 +27,12 @@ function ServiceCaseCard({

Relação com este serviço

{relevance ?? caseStudy.summary}

- + Ler case: {caseStudy.title} - + {caseStudy.demoUrl ? ( ({ + getCaseMedia: jest.fn(), +})); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("ServicesIndexPage: topo sem captura gravada", () => { + it("path: no capture for any client -> no mosaic, a lower one-column strip, the text and buttons stay", () => { + mockedGetCaseMedia.mockReturnValue(undefined); + + const { container } = render(); + + const hero = container.querySelector("section"); + expect(hero).toHaveClass(compactHero); + expect(hero?.querySelector(".ds-section-shell")).not.toHaveClass("grid"); + expect(hero?.querySelector("img")).toBeNull(); + expect( + screen.getByRole("heading", { + level: 1, + name: "Sites, sistemas e aplicativos sob medida.", + }), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Ver projetos realizados" }), + ).toHaveAttribute("href", "#projetos"); + }); +}); diff --git a/app/servicos/page.tsx b/app/servicos/page.tsx index 6633734..66c779b 100644 --- a/app/servicos/page.tsx +++ b/app/servicos/page.tsx @@ -1,6 +1,14 @@ import type { Metadata } from "next"; -import Link from "next/link"; +import { coverSizes } from "@/app/cases/_components/CaseCover"; import { CaseCoverLink } from "@/app/cases/_components/CaseCoverLink"; +import { + compactHero, + heroWithMediaGrid, +} from "@/app/cases/_components/hero-styles"; +import { + getMosaicPosters, + PosterMosaic, +} from "@/app/cases/_components/PosterMosaic"; import { Breadcrumb } from "@/app/components/Breadcrumb"; import { pageTop, @@ -8,15 +16,17 @@ import { secondaryButtonDark, textLink, } from "@/app/components/button-styles"; +import HoverPrefetchLink from "@/app/components/HoverPrefetchLink"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content"; +import { SERVICES_SUBJECT } from "@/lib/whatsapp"; const pageUrl = `${siteUrl}/servicos`; 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. Atendimento de Garuva-SC para todo o Brasil."; + "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."; const businessIntents = [ { @@ -60,6 +70,9 @@ export const metadata: Metadata = { }; export default function ServicesIndexPage() { + // três pôsteres dos clientes na coluna ao lado do título, que ficaria vazia + const posters = getMosaicPosters(clientCases.map(({ id }) => id)); + const hasMosaic = posters.length > 0; const jsonLd = { "@context": "https://schema.org", "@graph": [ @@ -90,41 +103,53 @@ export default function ServicesIndexPage() {
-
-
- -

- Sites, sistemas e aplicativos sob medida. -

-

- Apresente seu negócio, organize a operação ou conecte ferramentas - que hoje dão trabalho. Desenvolvo a solução a partir do que você - precisa resolver, não de uma lista de tecnologias. -

-
- - Falar com Evandro no WhatsApp - - - Ver projetos realizados - +
+
+
+ +

+ Sites, sistemas e aplicativos sob medida. +

+

+ Apresente seu negócio, organize a operação ou conecte ferramentas + que hoje dão trabalho. Desenvolvo a solução a partir do que você + precisa resolver, não de uma lista de tecnologias. +

+
+ + Falar com Evandro no WhatsApp + + + Ver projetos realizados + +
+

+ Sou Evandro, desenvolvedor de software, e a conversa é direta + comigo. +

+

+ Garuva e Joinville (SC) · atendimento remoto em todo o Brasil +

-

- Sou Evandro, desenvolvedor em Garuva, Santa Catarina. Atendimento - remoto para todo o Brasil, com conversa direta comigo. -

+
@@ -155,12 +180,12 @@ export default function ServicesIndexPage() { > {intent.ctaLabel} - {intent.linkLabel} - +
))} @@ -191,16 +216,17 @@ export default function ServicesIndexPage() { id={caseStudy.id} title={caseStudy.title} demoUrl={caseStudy.demoUrl} + sizes={coverSizes.column} />

{caseStudy.title}

{caseStudy.headline}

{caseStudy.summary}

- Ver o case {caseStudy.title} - + ))}
@@ -230,7 +256,7 @@ export default function ServicesIndexPage() {

Antes de contratar

-
+
Quanto custa e quanto tempo leva? @@ -243,7 +269,7 @@ export default function ServicesIndexPage() {
- Preciso estar perto de Garuva? + Preciso estar em Joinville ou em Garuva?
Não. O atendimento é remoto, com conversas, demonstrações e @@ -280,12 +306,12 @@ export default function ServicesIndexPage() { key={service.href} className="grid items-start gap-sp-3 py-sp-5 md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] md:gap-sp-8" > - {service.label} - +

{service.description}

))} @@ -305,7 +331,7 @@ export default function ServicesIndexPage() {

Conversar com Evandro no WhatsApp diff --git a/app/sitemap.test.ts b/app/sitemap.test.ts new file mode 100644 index 0000000..c31d567 --- /dev/null +++ b/app/sitemap.test.ts @@ -0,0 +1,33 @@ +import { caseStudies, servicePages, siteUrl } from "@/lib/site-content"; +import sitemap from "./sitemap"; + +/** O mapa do site lista a home, as páginas fixas, cada serviço e cada case, sem repetir endereço. */ +describe("sitemap", () => { + const urls = sitemap().map((entry) => entry.url); + + it("lists the fixed pages, every service and every case, once each", () => { + expect(urls).toEqual( + expect.arrayContaining([ + siteUrl, + `${siteUrl}/curriculo`, + `${siteUrl}/privacidade`, + `${siteUrl}/servicos`, + `${siteUrl}/cases`, + `${siteUrl}/ia`, + ...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`), + ...caseStudies.map(({ id }) => `${siteUrl}/cases/${id}`), + ]), + ); + expect(new Set(urls).size).toBe(urls.length); + expect(urls).toHaveLength(6 + servicePages.length + caseStudies.length); + }); + + it("never lists the 404 page", () => { + expect(urls.some((url) => /not-found|nada/.test(url))).toBe(false); + }); + + it("gives every entry the same fixed date, so the sitemap is equal on every build", () => { + const dates = sitemap().map((entry) => String(entry.lastModified)); + expect(new Set(dates).size).toBe(1); + }); +}); diff --git a/lib/seo.test.ts b/lib/seo.test.ts index d1028dc..5ed0039 100644 --- a/lib/seo.test.ts +++ b/lib/seo.test.ts @@ -92,6 +92,7 @@ it("builds a canonical service with only the verified service area", () => { provider: { "@id": SEO_IDS.person }, areaServed: [ { "@type": "City", name: "Garuva" }, + { "@type": "City", name: "Joinville" }, { "@type": "AdministrativeArea", name: "Norte de Santa Catarina" }, ], }); diff --git a/lib/seo.ts b/lib/seo.ts index 3752278..9c3a37f 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -76,6 +76,7 @@ export function buildServiceJsonLd(service: ServicePageContent) { provider: { "@id": SEO_IDS.person }, areaServed: [ { "@type": "City", name: "Garuva" }, + { "@type": "City", name: "Joinville" }, { "@type": "AdministrativeArea", name: "Norte de Santa Catarina", diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index af2d0fe..a7b6938 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -100,16 +100,57 @@ it("maps each service to its client cases", () => { "lavacao-047", "sc-plus", ]); - expect(getService("arquitetura-de-software").relatedCaseIds).toEqual([ - "sev-modaintima", - ]); - // GCC Installer é Python/Tkinter: não combina com um serviço de Tauri e Rust - expect(getService("tauri-rust").relatedCaseIds).toEqual([]); + // 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({}); + // o GCC Installer é um app para Windows (em Python): entra no serviço de desktop como projeto próprio + expect(getService("tauri-rust").relatedCaseIds).toEqual(["gcc-installer"]); expect(getService("integracao-hardware-software-iot").relatedCaseIds).toEqual( ["twins-lab"], ); }); +it("says what the Next.js service cases really prove: sites built by me, not a diagnosis", () => { + const { caseRelevance } = getService("consultoria-nextjs"); + for (const id of ["sev-modaintima", "lavacao-047"]) { + expect(caseRelevance[id]).toMatch(/construí em Next\.js 16/); + expect(caseRelevance[id]).not.toMatch(/diagn|conheço por dentro/i); + } +}); + +it("is honest that the desktop case is a Windows app made in Python, not in Tauri", () => { + expect(getService("tauri-rust").caseRelevance["gcc-installer"]).toMatch( + /Windows.*Python/, + ); +}); + +it("places the services in Joinville and SC instead of only 'em Garuva'", () => { + expect(getService("criacao-de-sites").metaTitle).toBe( + "Criação de sites em Joinville e SC", + ); + for (const service of servicePages) { + expect(service.metaTitle).not.toMatch(/em Garuva/); + expect(service.metaDescription).toMatch(/Garuva e Joinville \(SC\)/); + } +}); + +it("opens the case lists with what sells: the catalog, the order by WhatsApp and the panel", () => { + const deliveries = + caseStudies.find(({ id }) => id === "sev-modaintima")?.deliveries ?? []; + const [catalog, whatsapp, panel] = deliveries; + expect(catalog).toMatch(/^Catálogo por tamanho/); + expect(whatsapp).toMatch(/^Pedido pelo WhatsApp/); + expect(panel).toMatch(/^Painel para a loja cadastrar as peças/); + // a vitrine com animações, que era a primeira, vai para o fim + expect(deliveries[deliveries.length - 1]).toMatch( + /^Uma vitrine com animações/, + ); + // a animação da Lavação também não abre a lista + expect( + caseStudies.find(({ id }) => id === "lavacao-047")?.deliveries[0], + ).toMatch(/^Tabela de preços/); +}); + it("puts client cases before laboratory cases when listing related work", () => { expect( getRelatedCases(["twins-lab", "lavacao-047", "sev-modaintima"]).map( @@ -128,14 +169,11 @@ it("derives the exact related service set from the existing relationship", () => .map(({ slug }) => slug) .sort(), ).toEqual( - [ - "criacao-de-sites", - "engenharia-de-software", - "consultoria-nextjs", - "arquitetura-de-software", - ].sort(), + ["criacao-de-sites", "engenharia-de-software", "consultoria-nextjs"].sort(), ); - expect(getRelatedServices("gcc-installer")).toEqual([]); + expect(getRelatedServices("gcc-installer").map(({ slug }) => slug)).toEqual([ + "tauri-rust", + ]); }); it("resolves every declared related case ID", () => { diff --git a/lib/site-content.ts b/lib/site-content.ts index 60d9873..af92fb5 100644 --- a/lib/site-content.ts +++ b/lib/site-content.ts @@ -8,7 +8,7 @@ export interface ServicePageContent { slug: string; navLabel: string; eyebrow: string; - /** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */ + /** 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; /** Descrição para busca, com até 155 caracteres. */ metaDescription: string; @@ -41,7 +41,7 @@ export interface CaseStudyContent { title: string; /** Tipo do projeto, em poucas palavras. */ subtitle: string; - /** Título para busca, separado do h1: até 44 caracteres, e 60 com o sufixo " | Evandro.dev.br" (17) que o layout acrescenta. */ + /** 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; /** Descrição para busca, com até 155 caracteres. */ metaDescription: string; @@ -74,9 +74,9 @@ export const servicePages: ServicePageContent[] = [ slug: "criacao-de-sites", navLabel: "Sites profissionais", eyebrow: "Presença digital", - metaTitle: "Criação de sites para empresas em Garuva", + metaTitle: "Criação de sites em Joinville e SC", metaDescription: - "Site profissional para apresentar sua empresa e levar o cliente ao WhatsApp. Feito em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", + "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: "Um site que explica seu negócio.", answer: "Crio sites para apresentar sua empresa, mostrar o que você oferece e facilitar o contato. O projeto considera leitura no celular e no computador, conteúdo, páginas e funcionalidades combinadas antes do desenvolvimento.", @@ -145,7 +145,7 @@ export const servicePages: ServicePageContent[] = [ eyebrow: "Engenharia de software", metaTitle: "Sistemas web e aplicativos sob medida", metaDescription: - "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas. Desenvolvedor em Garuva-SC, atende Joinville e remoto no Brasil.", + "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: "Sistemas e apps para o seu trabalho.", answer: "Desenvolvo sistemas web, usados no navegador, e aplicativos mobile para celular. Também conecto ferramentas e evoluo sistemas existentes. Definimos juntos os usuários, as funções, as plataformas atendidas e a primeira entrega útil para o negócio.", @@ -210,7 +210,7 @@ export const servicePages: ServicePageContent[] = [ eyebrow: "Consultoria Next.js", metaTitle: "Consultoria Next.js para site ou sistema", metaDescription: - "Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Garuva-SC, Joinville e remoto no Brasil.", + "Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção. Garuva e Joinville (SC), remoto em todo o Brasil.", headline: "Seu projeto Next.js pode funcionar melhor.", answer: "Analiso sites e sistemas feitos em Next.js para entender lentidão, falhas ou dificuldades de manutenção. Você recebe prioridades explicadas, um plano de correção e, quando contratado, a implementação das melhorias.", @@ -247,9 +247,9 @@ export const servicePages: ServicePageContent[] = [ relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"], caseRelevance: { "sev-modaintima": - "Um projeto em Next.js 16 com catálogo, busca, estoque e painel, que conheço por dentro.", + "Uma loja que construí em Next.js 16, com catálogo, busca, estoque e painel.", "lavacao-047": - "Um projeto em Next.js 16 em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.", + "Um site que construí em Next.js 16, em que o conteúdo do painel alimenta a página, o sitemap e os dados para os buscadores.", "sc-plus": "Uma página em Next.js 15 com título, descrição, endereço canônico e dados estruturados para os buscadores.", }, @@ -277,7 +277,7 @@ export const servicePages: ServicePageContent[] = [ eyebrow: "Aplicativos desktop", metaTitle: "Aplicativos desktop para Windows e Linux", metaDescription: - "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Desenvolvedor em Garuva-SC, atendimento remoto no Brasil.", + "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da empresa. Garuva e Joinville (SC), atendimento remoto em todo o Brasil.", headline: "Aplicativos para Windows e Linux.", answer: "Desenvolvo aplicativos desktop para trabalhar no computador, lidar com arquivos e integrar recursos locais. Windows e Linux são as plataformas atendidas; as versões e distribuições compatíveis são combinadas em cada projeto.", @@ -316,8 +316,11 @@ export const servicePages: ServicePageContent[] = [ "Integração com o sistema", ], ctaLabel: "Conversar sobre meu aplicativo desktop", - relatedCaseIds: [], - caseRelevance: {}, + relatedCaseIds: ["gcc-installer"], + caseRelevance: { + "gcc-installer": + "Um aplicativo para Windows feito por mim, em Python e não em Tauri: lista as versões do compilador, instala e ajusta o PATH em uma única janela.", + }, faq: [ { question: "O mesmo aplicativo pode funcionar em Windows e Linux?", @@ -342,7 +345,7 @@ export const servicePages: ServicePageContent[] = [ eyebrow: "Hardware, software e IoT", metaTitle: "Integração de equipamentos e sistemas (IoT)", metaDescription: - "Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva-SC, Joinville e remoto no Brasil.", + "Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Garuva e Joinville (SC), remoto em todo o Brasil.", headline: "Dados dos equipamentos no seu sistema.", answer: "Conecto equipamentos e software para transformar leituras e eventos em informação útil para a operação. Primeiro vemos o que cada dispositivo permite acessar; a integração segue essas interfaces e os requisitos de segurança e funcionamento.", @@ -407,7 +410,7 @@ export const servicePages: ServicePageContent[] = [ eyebrow: "Diagnóstico e decisão", metaTitle: "Diagnóstico e arquitetura de software", metaDescription: - "Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano, de Garuva-SC para todo o Brasil.", + "Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Garuva e Joinville (SC), remoto em todo o Brasil.", headline: "Entenda o problema antes de mudar tudo.", answer: "Analiso como um sistema está organizado e por que ficou difícil de manter ou evoluir. Você recebe um diagnóstico explicado, prioridades e um plano de mudança para decidir o que corrigir, preservar ou substituir.", @@ -447,11 +450,8 @@ export const servicePages: ServicePageContent[] = [ "Sistemas críticos", ], ctaLabel: "Conversar sobre um diagnóstico", - relatedCaseIds: ["sev-modaintima"], - caseRelevance: { - "sev-modaintima": - "Uma loja completa, com catálogo, pedidos, estoque e painel, em que as decisões técnicas estão explicadas no case.", - }, + relatedCaseIds: [], + caseRelevance: {}, experienceNote: "Desde 2022 desenvolvo sistemas internos, aplicativos e APIs na Apolo Energia e participo do planejamento e da arquitetura de novas soluções. Por serem sistemas de uso interno, não há link nem detalhes públicos.", faq: [ @@ -492,12 +492,12 @@ export const caseStudies: CaseStudyContent[] = [ problem: "A loja vende moda íntima em várias peças e tamanhos. A cliente precisa achar o que procura entre as opções e escolher a medida certa, e o pedido segue pelo WhatsApp, com o frete calculado pela distância. A loja, por sua vez, precisa saber o que ainda tem de cada tamanho.", deliveries: [ - "Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.", - "Filtros e busca inteligente: a cliente encontra o que procura e os itens mais relevantes aparecem primeiro.", + "Catálogo por tamanho, com filtros e busca: a cliente encontra a peça, escolhe a medida e vê primeiro os itens mais relevantes.", "Pedido pelo WhatsApp: a cliente escolhe os produtos no site e envia o pedido para a loja.", - "Frete calculado pela distância até o endereço da cliente.", - "Painel administrativo para a loja cadastrar produtos, acompanhar pedidos e controlar o estoque.", + "Painel para a loja cadastrar as peças, acompanhar os pedidos e controlar o estoque.", "Estoque controlado por tamanho, para cada medida ter a sua quantidade.", + "Frete calculado pela distância até o endereço da cliente.", + "Uma vitrine com animações que dão destaque aos produtos e deixam a navegação agradável.", ], stack: [ "Next.js 16", @@ -538,8 +538,8 @@ export const caseStudies: CaseStudyContent[] = [ "Painel do dono: ele altera preços, textos, fotos e o mapa sozinho, e a mudança aparece no site sem nova publicação.", "Galeria de trabalhos por serviço, para o cliente ver o resultado antes de chamar.", "Mapa de localização, para o cliente achar a lavação.", - "Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.", "Informações organizadas para o Google e para assistentes de IA entenderem o que a lavação faz e onde fica.", + "Uma animação de rolagem que leva o carro do sujo ao brilho e perguntas frequentes montadas com os dados reais do negócio.", ], stack: [ "Next.js 16", @@ -667,8 +667,8 @@ export const caseStudies: CaseStudyContent[] = [ "A instituição tem várias áreas de atuação e projetos. Quem chega pela primeira vez precisa entender o que ela faz e saber como entrar em contato. Sem um endereço próprio, isso não fica reunido em um só lugar.", deliveries: [ "Uma página própria que apresenta a instituição, as áreas de atuação e os projetos.", - "Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.", "Contato direto na própria página.", + "Uma seção de apoiadores e de participação, para quem quer apoiar ou participar.", "Título, descrição e imagem de compartilhamento próprios, para o link aparecer bem nos buscadores e nas redes.", "Dados estruturados que ajudam o Google a entender de quem é a página.", ], @@ -726,8 +726,8 @@ export const caseStudies: CaseStudyContent[] = [ problem: "A empresa reúne duas frentes bem diferentes: a engenharia física, de um lado, e o software, do outro. Em um site comum, as duas podem virar uma lista de serviços sem relação entre si. O desafio era mostrar as duas na mesma história, de um jeito que prenda a atenção de quem rola a página e que continue leve em celulares e conexões mais simples.", deliveries: [ - "Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.", "As duas frentes da empresa, a física e a digital, aparecem na mesma história visual.", + "Uma cena 3D que acompanha a rolagem: casa, instalação elétrica e placa-mãe aparecem desenhadas em linhas.", "A qualidade da cena se ajusta ao aparelho: celulares, conexões mais simples e quem prefere menos movimento recebem uma versão mais leve.", "Um painel para controle de projetos, desenvolvido junto com a primeira página do site.", ],