From 6e1022b58c5f90cb2167d4adab43e5ea7cb82806 Mon Sep 17 00:00:00 2001 From: Evandro Fonseca Junior Date: Wed, 14 Oct 2026 03:31:57 -0300 Subject: [PATCH] =?UTF-8?q?feat(cases):=20cases=20de=20clientes,=20servi?= =?UTF-8?q?=C3=A7os=20ligados=20=C3=A0=20prova=20e=20SEO=20por=20p=C3=A1gi?= =?UTF-8?q?na?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cases da SEV Moda Íntima e da Lavação 047; case no formato cliente, problema, entrega, mídia e contato, com detalhes técnicos recolhidos; sem GitHub, ressalvas e fontes de commit. /cases separa clientes e Laboratório. Serviços mostram o case de cliente certo e o bloco de investimento e prazo só aparece preenchido. Metadata social própria por página, not-found com noindex, sitemap com /ia. --- .../[slug]/opengraph-image.paths.test.tsx | 94 +++ app/cases/[slug]/opengraph-image.tsx | 6 +- app/cases/[slug]/page.paths.test.tsx | 137 ++++ app/cases/[slug]/page.test.tsx | 455 +++++++---- app/cases/[slug]/page.tsx | 325 ++++---- .../_components/CaseCover.paths.test.tsx | 64 ++ app/cases/_components/CaseCover.tsx | 40 + .../_components/CaseMedia.paths.test.tsx | 94 +++ app/cases/_components/CaseMedia.tsx | 91 +++ app/cases/_components/CaseMediaSection.tsx | 55 ++ app/cases/_components/CaseRelatedServices.tsx | 23 + app/cases/_components/CaseResultSection.tsx | 19 + .../_components/case-media.paths.test.ts | 41 + app/cases/_components/case-media.ts | 23 + app/cases/opengraph-image.tsx | 8 +- app/cases/page.paths.test.tsx | 65 ++ app/cases/page.tsx | 232 +++--- app/components/Breadcrumb.tsx | 30 + app/curriculo/CvDownloadLink.tsx | 25 + app/curriculo/page.test.tsx | 31 +- app/curriculo/page.tsx | 57 +- app/not-found.tsx | 34 +- app/privacidade/page.test.tsx | 16 + app/privacidade/page.tsx | 62 +- app/servicos/[slug]/page.paths.test.tsx | 98 +++ app/servicos/[slug]/page.test.tsx | 174 ++++- app/servicos/[slug]/page.tsx | 212 ++---- app/servicos/_components/ServiceFaq.tsx | 32 + app/servicos/_components/ServiceHeroCover.tsx | 25 + app/servicos/_components/ServiceOffer.tsx | 41 + .../_components/ServiceRelatedCases.tsx | 84 +++ app/servicos/page.tsx | 103 ++- app/sitemap.ts | 6 +- data/github-stats.json | 6 - lib/og-card.tsx | 1 + lib/portfolio-data.load.test.ts | 3 +- lib/portfolio-data.server.ts | 70 ++ lib/portfolio-data.test.ts | 35 +- lib/portfolio-data.ts | 64 +- lib/seo.test.ts | 55 +- lib/seo.ts | 42 +- lib/site-content.test.ts | 199 +++-- lib/site-content.ts | 705 +++++++----------- public/portfolio-data.json | 133 ++-- 44 files changed, 2691 insertions(+), 1424 deletions(-) create mode 100644 app/cases/[slug]/opengraph-image.paths.test.tsx create mode 100644 app/cases/[slug]/page.paths.test.tsx create mode 100644 app/cases/_components/CaseCover.paths.test.tsx create mode 100644 app/cases/_components/CaseCover.tsx create mode 100644 app/cases/_components/CaseMedia.paths.test.tsx create mode 100644 app/cases/_components/CaseMedia.tsx create mode 100644 app/cases/_components/CaseMediaSection.tsx create mode 100644 app/cases/_components/CaseRelatedServices.tsx create mode 100644 app/cases/_components/CaseResultSection.tsx create mode 100644 app/cases/_components/case-media.paths.test.ts create mode 100644 app/cases/_components/case-media.ts create mode 100644 app/cases/page.paths.test.tsx create mode 100644 app/components/Breadcrumb.tsx create mode 100644 app/curriculo/CvDownloadLink.tsx create mode 100644 app/servicos/[slug]/page.paths.test.tsx create mode 100644 app/servicos/_components/ServiceFaq.tsx create mode 100644 app/servicos/_components/ServiceHeroCover.tsx create mode 100644 app/servicos/_components/ServiceOffer.tsx create mode 100644 app/servicos/_components/ServiceRelatedCases.tsx delete mode 100644 data/github-stats.json create mode 100644 lib/portfolio-data.server.ts diff --git a/app/cases/[slug]/opengraph-image.paths.test.tsx b/app/cases/[slug]/opengraph-image.paths.test.tsx new file mode 100644 index 0000000..138e1f9 --- /dev/null +++ b/app/cases/[slug]/opengraph-image.paths.test.tsx @@ -0,0 +1,94 @@ +import { notFound } from "next/navigation"; +import { ImageResponse } from "next/og"; +import { isValidElement } from "react"; +import { OG_SIZE, OgCard } from "@/lib/og-card"; +import { caseStudies } from "@/lib/site-content"; +import Image from "./opengraph-image"; + +/** + * Testes de caminhos-base de Image (imagem de compartilhamento do case): + * slug desconhecido, id da imagem diferente do case, case de cliente e projeto de laboratório. + */ + +jest.mock("next/navigation", () => ({ + notFound: jest.fn(() => { + throw new Error("NEXT_NOT_FOUND"); + }), +})); + +jest.mock("next/og", () => ({ ImageResponse: jest.fn() })); + +const mockedImageResponse = jest.mocked(ImageResponse); + +beforeEach(() => { + jest.mocked(notFound).mockClear(); + mockedImageResponse.mockClear(); +}); + +function getCase(id: string) { + const caseStudy = caseStudies.find((item) => item.id === id); + if (!caseStudy) throw new Error(`Missing case ${id}`); + return caseStudy; +} + +function renderImage(slug: string, imageId: string) { + return Image({ + params: Promise.resolve({ slug }), + id: Promise.resolve(imageId), + }); +} + +function sentCard() { + const [element, options] = mockedImageResponse.mock.calls[0]; + if (!isValidElement>(element)) { + throw new Error("ImageResponse did not receive an element"); + } + return { card: element, options }; +} + +describe("opengraph-image Image", () => { + it("path: client case with the matching image id -> card labelled as a client case", async () => { + const caseStudy = getCase("sev-modaintima"); + await renderImage(caseStudy.id, caseStudy.id); + + expect(notFound).not.toHaveBeenCalled(); + expect(mockedImageResponse).toHaveBeenCalledTimes(1); + const { card, options } = sentCard(); + expect(card.type).toBe(OgCard); + expect(card.props).toMatchObject({ + eyebrow: "Case de cliente", + title: caseStudy.title, + description: caseStudy.summary, + }); + expect(card.props.backgroundImage).toMatch(/^data:image\/jpeg;base64,/); + expect(options).toEqual(OG_SIZE); + }); + + it("path: unknown slug -> notFound() and no image", async () => { + await expect(renderImage("nao-existe", "nao-existe")).rejects.toThrow( + "NEXT_NOT_FOUND", + ); + expect(notFound).toHaveBeenCalledTimes(1); + expect(mockedImageResponse).not.toHaveBeenCalled(); + }); + + it("path: known slug but another case's image id -> notFound() and no image", async () => { + await expect(renderImage("sev-modaintima", "lavacao-047")).rejects.toThrow( + "NEXT_NOT_FOUND", + ); + expect(notFound).toHaveBeenCalledTimes(1); + expect(mockedImageResponse).not.toHaveBeenCalled(); + }); + + it("path: laboratory case -> card labelled as a laboratory project, no cover when none is recorded", async () => { + const caseStudy = getCase("twins-lab"); + await renderImage(caseStudy.id, caseStudy.id); + + const { card } = sentCard(); + expect(card.props).toMatchObject({ + eyebrow: "Projeto de laboratório", + title: caseStudy.title, + }); + expect(card.props.backgroundImage).toBeUndefined(); + }); +}); diff --git a/app/cases/[slug]/opengraph-image.tsx b/app/cases/[slug]/opengraph-image.tsx index 6a24f83..b1d83b8 100644 --- a/app/cases/[slug]/opengraph-image.tsx +++ b/app/cases/[slug]/opengraph-image.tsx @@ -6,7 +6,7 @@ import { OgCard, readProjectOgBackground, } from "@/lib/og-card"; -import { caseStudies } from "@/lib/site-content"; +import { caseStudies, isClientCase } from "@/lib/site-content"; function getCaseStudy(slug: string) { return caseStudies.find(({ id }) => id === slug); @@ -48,7 +48,9 @@ export default async function Image({ return new ImageResponse( ({ + notFound: jest.fn(() => { + throw new Error("NEXT_NOT_FOUND"); + }), +})); + +jest.mock("../_components/case-media", () => { + const actual = jest.requireActual("../_components/case-media"); + return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) }; +}); + +const actualGetCaseMedia = jest.requireActual< + typeof import("../_components/case-media") +>("../_components/case-media").getCaseMedia; +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +beforeEach(() => { + jest.mocked(notFound).mockClear(); +}); + +afterEach(() => { + mockedGetCaseMedia.mockImplementation(actualGetCaseMedia); +}); + +function getCase(id: string) { + const caseStudy = caseStudies.find((item) => item.id === id); + if (!caseStudy) throw new Error(`Missing case ${id}`); + return caseStudy; +} + +async function renderCase(id: string) { + const ui = await CasePage({ params: Promise.resolve({ slug: id }) }); + return render(ui); +} + +describe("CasePage: caminhos que o teste principal não cobre", () => { + it("path: unknown slug -> calls notFound() and renders nothing", async () => { + await expect( + CasePage({ params: Promise.resolve({ slug: "nao-existe" }) }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(notFound).toHaveBeenCalledTimes(1); + }); + + it("path: no demo and no desktop capture, but a mobile capture -> shows only the phone", async () => { + const caseStudy = getCase("as-servicos"); + expect(caseStudy.demoUrl).toBeNull(); + mockedGetCaseMedia.mockImplementation((id, device) => + device === "mobile" ? actualGetCaseMedia(id, device) : undefined, + ); + + const { container } = await renderCase(caseStudy.id); + + expect( + screen.getByRole("heading", { level: 2, name: "Como ficou" }), + ).toBeVisible(); + expect( + screen.getByLabelText(`Vídeo do site ${caseStudy.title} no celular`), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(`Vídeo do site ${caseStudy.title} no computador`), + ).toBeNull(); + expect(container.querySelector(".ds-bframe")).toBeNull(); + expect(container.querySelector(".ds-phone")).not.toBeNull(); + expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull(); + }); + + it("path: demo published but no capture on disk -> keeps the block with the site button and no video", async () => { + const caseStudy = getCase("sev-modaintima"); + expect(caseStudy.demoUrl).toBeTruthy(); + mockedGetCaseMedia.mockImplementation(() => undefined); + + const { container } = await renderCase(caseStudy.id); + + expect( + screen.getByRole("heading", { level: 2, name: "Como ficou" }), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), + ).toHaveAttribute("href", caseStudy.demoUrl); + expect(container.querySelector("video")).toBeNull(); + expect(container.querySelector(".ds-bframe")).toBeNull(); + expect(container.querySelector(".ds-phone")).toBeNull(); + }); + + it("path: result is an empty list -> no result section", async () => { + const caseStudy = getCase("lavacao-047"); + caseStudy.result = []; + try { + await renderCase(caseStudy.id); + expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull(); + expect( + screen.getByRole("heading", { level: 2, name: "Como ficou" }), + ).toBeVisible(); + } finally { + delete caseStudy.result; + } + }); + + it("path: case that no service points to -> no related service links, contact stays", async () => { + const caseStudy = getCase("nutriscan"); + expect(getRelatedServices(caseStudy.id)).toHaveLength(0); + + await renderCase(caseStudy.id); + + expect(screen.getByText(/Projeto de laboratório/)).toBeVisible(); + expect( + screen.queryByRole("link", { name: /^Ver serviço relacionado:/ }), + ).toBeNull(); + expect( + screen.getByRole("link", { name: "Conversar sobre um projeto parecido" }), + ).toBeVisible(); + expect( + screen.getByRole("link", { name: "Ver outros cases" }), + ).toHaveAttribute("href", "/cases"); + }); +}); + +describe("generateMetadata: caminho do slug desconhecido", () => { + it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => { + const metadata = await generateMetadata({ + params: Promise.resolve({ slug: "nao-existe" }), + }); + expect(metadata).toEqual({ title: "Case não encontrado" }); + }); +}); diff --git a/app/cases/[slug]/page.test.tsx b/app/cases/[slug]/page.test.tsx index be3a76f..f4f04c9 100644 --- a/app/cases/[slug]/page.test.tsx +++ b/app/cases/[slug]/page.test.tsx @@ -1,49 +1,41 @@ import { render, screen, within } from "@testing-library/react"; -import type { ReactNode } from "react"; -import { caseStudies, servicePages, siteUrl } from "@/lib/site-content"; +import { caseStudies, getRelatedServices, siteUrl } from "@/lib/site-content"; import CasesPage from "../page"; import CasePage, { generateMetadata, generateStaticParams } from "./page"; jest.mock("next/navigation", () => ({ notFound: jest.fn() })); -jest.mock("@/app/components/cinematic/HeroBackground", () => ({ - __esModule: true, - default: () => null, -})); +const clientCaseIds = [ + "sev-modaintima", + "lavacao-047", + "sc-plus", + "as-servicos", +]; +const labCaseIds = [ + "nutriscan", + "twins-lab", + "reddit-explorer", + "gcc-installer", +]; +const publicCaseIds = [...clientCaseIds, ...labCaseIds]; -jest.mock("@/app/components/cinematic/MagneticButton", () => ({ - __esModule: true, - default: ({ - as = "button", - strength: _strength, - children, - ...props - }: { - as?: string; - strength?: number; - children?: ReactNode; - } & Record) => { - const React = require("react"); - return React.createElement(as, props, children); - }, -})); - -const publicCases = [ - { id: "nutriscan", title: "NutriScan" }, - { id: "twins-lab", title: "Twins Lab" }, - { id: "reddit-explorer", title: "Reddit Posts Explorer" }, - { id: "sc-plus", title: "SC Plus Aquicultura" }, - { id: "gcc-installer", title: "MinGW GCC Installer" }, - { id: "as-servicos", title: "AS Serviços" }, -] as const; -const publicCaseIds = publicCases.map(({ id }) => id); - -function getCase(id: (typeof publicCases)[number]["id"]) { +function getCase(id: string) { const caseStudy = caseStudies.find((item) => item.id === id); if (!caseStudy) throw new Error(`Missing case ${id}`); return caseStudy; } +async function renderCase(id: string) { + const ui = await CasePage({ params: Promise.resolve({ slug: id }) }); + return render(ui); +} + +function isBefore(first: Element, second: Element) { + return Boolean( + first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING, + ); +} + function readJsonLdEntities(container: HTMLElement): Record[] { const scripts = container.querySelectorAll( 'script[type="application/ld+json"]', @@ -57,104 +49,141 @@ function readJsonLdEntities(container: HTMLElement): Record[] { }); } -function expectCaseCtas() { - const destinations = screen +/** Nenhum link para /#contact nem para o GitHub; o contato é o WhatsApp com o contexto do case. */ +function expectContactAndNoDeadLinks(caseStudy: { title: string }) { + const hrefs = screen .getAllByRole("link") - .map((link) => link.getAttribute("href")); - expect(destinations).toContain("/#contact"); - expect(destinations).toContain("/servicos"); - expect(destinations).not.toContain("/curriculo"); + .map((link) => link.getAttribute("href") ?? ""); + expect(hrefs).not.toContain("/#contact"); + expect(hrefs.some((href) => /github/i.test(href))).toBe(false); + + const cta = screen.getByRole("link", { + name: "Conversar sobre um projeto parecido", + }); + const destination = new URL(cta.getAttribute("href") ?? ""); + expect(destination.origin).toBe("https://wa.me"); + expect(destination.searchParams.get("text")).toContain( + `um projeto parecido com o da ${caseStudy.title}`, + ); + expect(cta).toHaveAttribute("target", "_blank"); + expect(cta).toHaveAttribute("rel", expect.stringContaining("noopener")); } -it("keeps the six public params and factual case metadata", async () => { +it("exposes the eight public cases, the two new client cases among them", async () => { const staticSlugs = generateStaticParams().map(({ slug }) => slug); - expect(staticSlugs).toHaveLength(6); - expect(new Set(staticSlugs).size).toBe(6); + expect(staticSlugs).toHaveLength(8); + expect(new Set(staticSlugs).size).toBe(8); expect([...staticSlugs].sort()).toEqual([...publicCaseIds].sort()); + expect(staticSlugs).toContain("sev-modaintima"); + expect(staticSlugs).toContain("lavacao-047"); - const caseStudy = getCase("as-servicos"); - const url = `${siteUrl}/cases/${caseStudy.id}`; - const metadata = await generateMetadata({ - params: Promise.resolve({ slug: caseStudy.id }), - }); - expect(metadata).toMatchObject({ - title: expect.stringContaining(caseStudy.title), - description: caseStudy.summary, - alternates: { canonical: url }, - openGraph: { - title: caseStudy.title, + for (const id of ["sev-modaintima", "lavacao-047", "as-servicos"]) { + const caseStudy = getCase(id); + const url = `${siteUrl}/cases/${caseStudy.id}`; + const title = `${caseStudy.title}: ${caseStudy.subtitle}`; + const metadata = await generateMetadata({ + params: Promise.resolve({ slug: caseStudy.id }), + }); + expect(metadata).toMatchObject({ + title, description: caseStudy.summary, - url, - }, - twitter: { - title: caseStudy.title, - description: caseStudy.summary, - }, - }); - expect(metadata).not.toHaveProperty("keywords"); + alternates: { canonical: url }, + openGraph: { title, description: caseStudy.summary, url }, + twitter: { title, description: caseStudy.summary }, + }); + expect(metadata).not.toHaveProperty("keywords"); + } }); -it("renders the authorized detailed case in order with traceable sources", async () => { - const caseStudy = getCase("as-servicos"); - if (!caseStudy.detail) throw new Error("Missing detailed case content"); - const ui = await CasePage({ - params: Promise.resolve({ slug: caseStudy.id }), - }); - const { container } = render(ui); +it("renders a client case in the sales order, with media, contact and collapsed technical details", async () => { + const caseStudy = getCase("sev-modaintima"); + const { container } = await renderCase(caseStudy.id); const heading = screen.getByRole("heading", { level: 1 }); - const answer = screen.getByText(caseStudy.detail.answer.text); - expect(heading.nextElementSibling).toBe(answer); + expect(heading).toHaveTextContent(caseStudy.headline); + expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary); + expect(screen.getByText(/Case de cliente/)).toBeVisible(); const sectionNames = [ - "Desafio verificável", - "Decisões de implementação", - "Limites desta evidência", - "Fontes", + "O cliente e o contexto", + "O problema", + "O que foi feito", + "Como ficou", + "Quer um projeto parecido com este?", ]; const sectionHeadings = sectionNames.map((name) => screen.getByRole("heading", { level: 2, name }), ); for (let index = 1; index < sectionHeadings.length; index += 1) { - expect( - sectionHeadings[index - 1].compareDocumentPosition( - sectionHeadings[index], - ) & Node.DOCUMENT_POSITION_FOLLOWING, - ).toBeTruthy(); + expect(isBefore(sectionHeadings[index - 1], sectionHeadings[index])).toBe( + true, + ); + } + expect(isBefore(heading, sectionHeadings[0])).toBe(true); + + expect(screen.getByText(caseStudy.context)).toBeVisible(); + expect(screen.getByText(caseStudy.problem)).toBeVisible(); + for (const delivery of caseStudy.deliveries) { + expect(screen.getByText(delivery)).toBeVisible(); } - const challengeSection = screen - .getByRole("heading", { level: 2, name: "Desafio verificável" }) - .closest("section"); - if (!challengeSection) throw new Error("Missing challenge section"); - const challengeText = within(challengeSection).getByText( - caseStudy.detail.challenge.text, + // sem dado real, não há seção de resultado + expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull(); + + // mídia: pôster e vídeo do computador e do celular + const desktopVideo = screen.getByLabelText( + `Vídeo do site ${caseStudy.title} no computador`, ); - expect(challengeText).toBeVisible(); - expect(screen.getAllByText(caseStudy.impact)).toEqual([challengeText]); - for (const statement of caseStudy.detail.implementation) { - expect(screen.getByText(statement.text)).toBeVisible(); - } - for (const boundary of caseStudy.detail.boundaries) { - const boundaryText = screen.getByText(boundary.text); - expect(boundaryText).toBeVisible(); - expect(boundaryText.closest("a")).toBeNull(); - } + const mobileVideo = screen.getByLabelText( + `Vídeo do site ${caseStudy.title} no celular`, + ); + expect(desktopVideo).toHaveAttribute( + "poster", + `/videos/${caseStudy.id}/desktop.webp`, + ); + expect(mobileVideo).toHaveAttribute( + "poster", + `/videos/${caseStudy.id}/mobile.webp`, + ); + expect( + desktopVideo.querySelectorAll("source[src^='/videos/sev-modaintima/']"), + ).toHaveLength(2); + expect(container.querySelector(".ds-bframe")).toContainElement(desktopVideo); + expect(container.querySelector(".ds-phone")).toContainElement(mobileVideo); + expect( + screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), + ).toHaveAttribute("href", caseStudy.demoUrl); - const sourceLinks = screen.getAllByRole("link", { name: /^Fonte:/ }); - expect(sourceLinks).toHaveLength(caseStudy.detail.sources.length); - for (const source of caseStudy.detail.sources) { - const link = screen.getByRole("link", { name: `Fonte: ${source.label}` }); - expect(link).toHaveAttribute("href", source.url); - expect(link).toHaveAttribute("target", "_blank"); - expect(link).toHaveAttribute("rel", expect.stringMatching(/noreferrer/)); - expect(source.url).toMatch(/\/blob\/[0-9a-f]{40}\//); - const sha = source.url.match(/\/blob\/([0-9a-f]{40})\//)?.[1]; - if (!sha) throw new Error(`Missing source SHA for ${source.id}`); - expect(within(link.closest("li") ?? link).getByText(sha)).toBeVisible(); - } + expectContactAndNoDeadLinks(caseStudy); - expectCaseCtas(); + // detalhes técnicos: recolhidos, no fim da página + const details = container.querySelector("details"); + if (!details) throw new Error("Missing technical details"); + expect(details).not.toHaveAttribute("open"); + expect(within(details).getByText("Detalhes técnicos")).toBeVisible(); + for (const technology of caseStudy.stack) { + expect(within(details).getByText(technology)).not.toBeVisible(); + } + for (const decision of caseStudy.decisions) { + expect(within(details).getByText(decision)).not.toBeVisible(); + } + const contactHeading = sectionHeadings[sectionHeadings.length - 1]; + expect(isBefore(contactHeading, details)).toBe(true); + expect(container.querySelectorAll("details")).toHaveLength(1); + const lastSection = container.querySelector("article")?.lastElementChild; + expect(lastSection).toContainElement(details); + + // o que saiu: desafio verificável, limites da evidência, fontes e commits + for (const name of [ + "Desafio verificável", + "Decisões de implementação", + "Limites desta evidência", + "Fontes", + ]) { + expect(screen.queryByRole("heading", { name })).toBeNull(); + } + expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull(); + expect(container.textContent).not.toMatch(/Commit:|[0-9a-f]{40}/); const graph = readJsonLdEntities(container); expect(graph.map((node) => node["@type"])).toEqual(["BreadcrumbList"]); @@ -208,75 +237,176 @@ it("renders the authorized detailed case in order with traceable sources", async ]); }); -it("keeps the fallback concise and links its declared services", async () => { - const caseStudy = getCase("sc-plus"); - expect(caseStudy.detail).toBeUndefined(); - const ui = await CasePage({ - params: Promise.resolve({ slug: caseStudy.id }), - }); - const { container } = render(ui); +it("renders the two new client cases from the portfolio data", async () => { + for (const id of ["sev-modaintima", "lavacao-047"]) { + const caseStudy = getCase(id); + const { unmount } = await renderCase(id); - const heading = screen.getByRole("heading", { level: 1 }); - expect(heading.nextElementSibling).toHaveTextContent(caseStudy.summary); - expect(screen.getByText(caseStudy.impact)).toBeVisible(); - for (const technology of caseStudy.stack) { - expect(screen.getByText(technology)).toBeVisible(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent( + caseStudy.headline, + ); + expect( + screen.getByRole("link", { name: `Abrir o site ${caseStudy.title}` }), + ).toHaveAttribute("href", caseStudy.demoUrl); + expectContactAndNoDeadLinks(caseStudy); + unmount(); } +}); + +it("labels a laboratory case, skips the media block and links its service", async () => { + const caseStudy = getCase("twins-lab"); + const { container } = await renderCase(caseStudy.id); + + expect(screen.getByText(/Projeto de laboratório/)).toBeVisible(); expect( - screen.getByRole("link", { name: "Abrir demonstração" }), - ).toHaveAttribute("href", caseStudy.demoUrl); - expect(screen.getByRole("link", { name: "Ver código" })).toHaveAttribute( - "href", - caseStudy.githubUrl, + screen.getByRole("heading", { level: 2, name: "O contexto do projeto" }), + ).toBeVisible(); + expect(screen.queryByRole("heading", { name: "Como ficou" })).toBeNull(); + expect(container.querySelector("video")).toBeNull(); + expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull(); + expect(screen.queryByText(/indispon/i)).toBeNull(); + + const relatedLinks = screen.getAllByRole("link", { + name: /^Ver serviço relacionado:/, + }); + expect(relatedLinks.map((link) => link.getAttribute("href"))).toEqual( + getRelatedServices(caseStudy.id).map(({ slug }) => `/servicos/${slug}`), ); - for (const name of [ - "Desafio verificável", - "Decisões de implementação", - "Limites desta evidência", - "Fontes", - ]) { - expect(screen.queryByRole("heading", { name })).toBeNull(); + expect(relatedLinks).toHaveLength(1); + expectContactAndNoDeadLinks(caseStudy); +}); + +it("shows the author's role and no site button for a client project without a public site", async () => { + const caseStudy = getCase("as-servicos"); + expect(caseStudy.demoUrl).toBeNull(); + await renderCase(caseStudy.id); + + expect(screen.getByText("Minha participação:")).toBeVisible(); + expect( + screen.getByText(caseStudy.role ?? "", { exact: false }), + ).toBeVisible(); + expect(screen.queryByRole("link", { name: /^Abrir o site/ })).toBeNull(); + expect(screen.queryByText(/indispon/i)).toBeNull(); + // a capa continua nas molduras + expect( + screen.getByLabelText(`Vídeo do site ${caseStudy.title} no computador`), + ).toBeInTheDocument(); +}); + +it("shows the result section only when the case has real data", async () => { + const caseStudy = getCase("lavacao-047"); + const { unmount } = await renderCase(caseStudy.id); + expect(screen.queryByRole("heading", { name: "Resultado" })).toBeNull(); + unmount(); + + caseStudy.result = ["Dado real informado pelo cliente."]; + try { + await renderCase(caseStudy.id); + const heading = screen.getByRole("heading", { + level: 2, + name: "Resultado", + }); + expect(screen.getByText("Dado real informado pelo cliente.")).toBeVisible(); + expect( + isBefore( + screen.getByRole("heading", { level: 2, name: "Como ficou" }), + heading, + ), + ).toBe(true); + expect( + isBefore( + heading, + screen.getByRole("heading", { + level: 2, + name: "Quer um projeto parecido com este?", + }), + ), + ).toBe(true); + } finally { + delete caseStudy.result; } - expect(screen.queryByRole("link", { name: /^Fonte:/ })).toBeNull(); +}); + +it("links every service related to a client case", async () => { + const caseStudy = getCase("sc-plus"); + const { container } = await renderCase(caseStudy.id); - const expectedServices = servicePages - .filter(({ relatedCaseIds }) => relatedCaseIds.includes(caseStudy.id)) - .map(({ slug, navLabel }) => ({ - name: `Ver serviço relacionado: ${navLabel}`, - href: `/servicos/${slug}`, - })) - .sort((a, b) => a.href.localeCompare(b.href)); const relatedLinks = screen.getAllByRole("link", { name: /^Ver serviço relacionado:/, }); expect( relatedLinks - .map((link) => ({ - name: link.textContent, - href: link.getAttribute("href"), - })) - .sort((a, b) => (a.href ?? "").localeCompare(b.href ?? "")), - ).toEqual(expectedServices); - expectCaseCtas(); - + .map((link) => link.getAttribute("href")) + .sort((a, b) => (a ?? "").localeCompare(b ?? "")), + ).toEqual( + getRelatedServices(caseStudy.id) + .map(({ slug }) => `/servicos/${slug}`) + .sort(), + ); + expectContactAndNoDeadLinks(caseStudy); expect(readJsonLdEntities(container).map((node) => node["@type"])).toEqual([ "BreadcrumbList", ]); }); -it("keeps the hub cards, schema and distinct intents aligned", () => { +it("lists client cases with a cover first, then the laboratory, and keeps the schema aligned", () => { const { container } = render(); const heading = screen.getByRole("heading", { level: 1 }); - expect(heading.nextElementSibling).toHaveTextContent(/seis projetos/i); - - const caseLinks = screen.getAllByRole("link", { name: "Ler case" }); - const caseHrefs = caseLinks.map((link) => link.getAttribute("href")); - expect(caseHrefs).toHaveLength(6); - expect(new Set(caseHrefs).size).toBe(6); - expect([...caseHrefs].sort()).toEqual( - publicCaseIds.map((id) => `/cases/${id}`).sort(), + expect(heading.nextElementSibling).toHaveTextContent( + /foi feito para empresas/i, ); - expectCaseCtas(); + expect(container.textContent).not.toMatch(/Selected work/i); + + const clientHeading = screen.getByRole("heading", { + level: 2, + name: "Projetos de clientes", + }); + const labHeading = screen.getByRole("heading", { + level: 2, + name: "Laboratório", + }); + expect(isBefore(heading, clientHeading)).toBe(true); + expect(isBefore(clientHeading, labHeading)).toBe(true); + expect(screen.getByText(/Projetos pessoais e de estudo/)).toBeVisible(); + + // cases de cliente: capa (pôster) em moldura de navegador e botão do case em contorno + const clientLinks = clientCaseIds.map((id) => { + const card = container + .querySelector(`a[href="/cases/${id}"]`) + ?.closest("article"); + if (!card) throw new Error(`Missing client card ${id}`); + expect(isBefore(clientHeading, card)).toBe(true); + expect(isBefore(card, labHeading)).toBe(true); + expect(card.querySelector(".ds-bframe img")).toHaveAttribute( + "alt", + `Captura de tela do site ${getCase(id).title}`, + ); + return within(card).getByRole("link", { name: "Ver o case" }); + }); + expect(clientLinks.map((link) => link.getAttribute("href"))).toEqual( + clientCaseIds.map((id) => `/cases/${id}`), + ); + + // laboratório: sem capa + 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"); + expect(isBefore(labHeading, link)).toBe(true); + expect(card?.querySelector(".ds-bframe")).toBeNull(); + } + + // contato pelo WhatsApp, sem /#contact nem GitHub + const hrefs = screen + .getAllByRole("link") + .map((link) => link.getAttribute("href") ?? ""); + expect(hrefs).not.toContain("/#contact"); + expect(hrefs.some((href) => /github/i.test(href))).toBe(false); + expect(hrefs).toContain("/servicos"); + const cta = screen.getByRole("link", { name: "Conversar sobre meu projeto" }); + expect(new URL(cta.getAttribute("href") ?? "").origin).toBe("https://wa.me"); const visibleBreadcrumb = screen.getByRole("navigation", { name: "Breadcrumb", @@ -304,13 +434,12 @@ it("keeps the hub cards, schema and distinct intents aligned", () => { url: string; }>; }; - const visibleItems = caseLinks.map((link, index) => { + const visibleItems = [...clientLinks, ...labLinks].map((link, index) => { const href = link.getAttribute("href"); - const expected = publicCases.find(({ id }) => href === `/cases/${id}`); - if (!expected) throw new Error(`Unexpected visible case link ${href}`); const card = link.closest("article"); if (!card) throw new Error(`Missing visible card for ${href}`); - expect(within(card).getByText(expected.title)).toBeVisible(); + const expected = getCase((href ?? "").replace("/cases/", "")); + expect(card.querySelector(".ds-kicker")).toHaveTextContent(expected.title); return { "@type": "ListItem", position: index + 1, diff --git a/app/cases/[slug]/page.tsx b/app/cases/[slug]/page.tsx index 16a28c0..0b2c88b 100644 --- a/app/cases/[slug]/page.tsx +++ b/app/cases/[slug]/page.tsx @@ -1,26 +1,24 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { safeJsonLd } from "@/lib/jsonld"; -import { buildBreadcrumbList } from "@/lib/seo"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; import { - caseStudies, - getRelatedServices, - intentCtas, - siteName, - siteUrl, -} from "@/lib/site-content"; + pageTop, + primaryButton, + secondaryButtonDark, +} from "@/app/components/button-styles"; +import WhatsAppLink from "@/app/components/WhatsAppLink"; +import { safeJsonLd } from "@/lib/jsonld"; +import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; +import { caseStudies, isClientCase, siteUrl } from "@/lib/site-content"; +import { CaseMediaSection } from "../_components/CaseMediaSection"; +import { CaseRelatedServices } from "../_components/CaseRelatedServices"; +import { CaseResultSection } from "../_components/CaseResultSection"; interface CasePageProps { params: Promise<{ slug: string }>; } -const sourceShaPattern = /\/blob\/([0-9a-f]{40})\//; - -function getSourceSha(url: string) { - return sourceShaPattern.exec(url)?.[1]; -} - export function generateStaticParams() { return caseStudies.map((caseStudy) => ({ slug: caseStudy.id })); } @@ -34,23 +32,16 @@ export async function generateMetadata({ if (!caseStudy) return { title: "Case não encontrado" }; const url = `${siteUrl}/cases/${caseStudy.id}`; + const title = `${caseStudy.title}: ${caseStudy.subtitle}`; return { - title: `${caseStudy.title} | Case de Engenharia`, + title, description: caseStudy.summary, - alternates: { canonical: url }, - openGraph: { - locale: "pt_BR", - title: caseStudy.title, - description: caseStudy.summary, + ...buildPageSocialMetadata({ url, - siteName, - type: "article", - }, - twitter: { - card: "summary_large_image", - title: caseStudy.title, + title, description: caseStudy.summary, - }, + type: "article", + }), }; } @@ -60,7 +51,7 @@ export default async function CasePage({ params }: CasePageProps) { if (!caseStudy) notFound(); const pageUrl = `${siteUrl}/cases/${caseStudy.id}`; - const relatedServices = getRelatedServices(caseStudy.id); + const isClient = isClientCase(caseStudy); const jsonLd = { "@context": "https://schema.org", "@graph": [ @@ -76,186 +67,128 @@ export default async function CasePage({ params }: CasePageProps) {
-
+
- -

Case de engenharia de software

-

- {caseStudy.title}: {caseStudy.subtitle} + +

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

+

+ {caseStudy.headline}

- {caseStudy.detail?.answer.text ?? caseStudy.summary} + {caseStudy.summary}

- {caseStudy.role ? ( -

- - Minha participação: - {" "} - {caseStudy.role} -

- ) : null} - {!caseStudy.detail ? ( -

- {caseStudy.impact} -

- ) : null} -
    - {caseStudy.stack.map((item) => ( -
  • - {item} -
  • - ))} -
-
- {caseStudy.demoUrl ? ( - - Abrir demonstração - - ) : null} - {caseStudy.githubUrl ? ( - - Ver código - - ) : null} +
+

+ +
+
+
+
+

+ {isClient + ? "O cliente e o contexto" + : "O contexto do projeto"} +

+

{caseStudy.context}

+ {caseStudy.role ? ( +

+ + Minha participação: + {" "} + {caseStudy.role} +

+ ) : null} +
+
+

+ O problema +

+

{caseStudy.problem}

+
- {caseStudy.detail ? ( -
-
-
-

- Desafio verificável -

-

- {caseStudy.detail.challenge.text} -

-
- -
-

- Decisões de implementação -

-
    - {caseStudy.detail.implementation.map((statement) => ( -
  • {statement.text}
  • - ))} -
-
- -
-

- Limites desta evidência -

-
    - {caseStudy.detail.boundaries.map((boundary) => ( -
  • {boundary.text}
  • - ))} -
-
- -
-

- Fontes -

-
    - {caseStudy.detail.sources.map((source) => { - const sha = getSourceSha(source.url); - return ( -
  • - - Fonte: {source.label} - - {sha ? ( - - Commit: {sha} - - ) : null} -
  • - ); - })} -
-
-
-
- ) : null} - - {relatedServices.length > 0 ? ( -
-
-

Serviços relacionados

-
- {relatedServices.map((service) => ( - - Ver serviço relacionado: {service.navLabel} - - ))} -
-
-
- ) : null} - -
+
-

Próximos caminhos

-
- + O que foi feito + +
    + {caseStudy.deliveries.map((delivery) => ( +
  • {delivery}
  • + ))} +
+
+
+ + + + + +
+
+

+ Quer um projeto parecido com este? +

+

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

+
+ - {intentCtas.project.label} - - - {intentCtas.consulting.label} + Conversar sobre um projeto parecido + + + Ver outros cases
+ +
+
+ +
+
+
+ + Detalhes técnicos + +
+

Tecnologias

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

Decisões técnicas

+
    + {caseStudy.decisions.map((decision) => ( +
  • {decision}
  • + ))} +
+
+
diff --git a/app/cases/_components/CaseCover.paths.test.tsx b/app/cases/_components/CaseCover.paths.test.tsx new file mode 100644 index 0000000..87ac2d0 --- /dev/null +++ b/app/cases/_components/CaseCover.paths.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { CaseCover } 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`. + */ + +jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); +const props = { + id: "sev-modaintima", + title: "SEV Moda Íntima", + demoUrl: "https://sevmodaintima.com.br", +}; + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("CaseCover", () => { + it("path: no desktop capture -> renders nothing", () => { + mockedGetCaseMedia.mockReturnValue(undefined); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop"); + }); + + it("path: capture found -> poster inside the browser frame, with the real domain in the bar", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + const { container } = render(); + + const image = screen.getByAltText(`Captura de tela do site ${props.title}`); + expect(container.querySelector(".ds-bframe")).toContainElement(image); + expect(image.getAttribute("src")).toContain( + encodeURIComponent(`/videos/${props.id}/desktop.webp`), + ); + expect(container.querySelector(".ds-bframe-url")).toHaveTextContent( + "sevmodaintima.com.br", + ); + }); + + it("path: sizes and priority omitted -> default sizes and lazy loading", () => { + mockedGetCaseMedia.mockImplementation(getShowcaseAssets); + 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"); + }); + + it("path: sizes and priority given -> uses them", () => { + 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"); + }); +}); diff --git a/app/cases/_components/CaseCover.tsx b/app/cases/_components/CaseCover.tsx new file mode 100644 index 0000000..d63ef7a --- /dev/null +++ b/app/cases/_components/CaseCover.tsx @@ -0,0 +1,40 @@ +import Image from "next/image"; +import { + BrowserFrame, + displayUrl, +} from "@/app/components/showcase/BrowserFrame"; +import { getCaseMedia } from "./case-media"; +import "../../components/showcase/showcase.css"; + +/** Capa do case: o pôster do site dentro de uma moldura de navegador. Sem captura gravada, não mostra nada. */ +export function CaseCover({ + id, + title, + demoUrl, + sizes = "(min-width: 1024px) 560px, 100vw", + priority = false, +}: { + id: string; + title: string; + demoUrl?: string | null; + sizes?: string; + priority?: boolean; +}) { + const media = getCaseMedia(id, "desktop"); + if (!media) return null; + + return ( + +
+ {`Captura +
+
+ ); +} diff --git a/app/cases/_components/CaseMedia.paths.test.tsx b/app/cases/_components/CaseMedia.paths.test.tsx new file mode 100644 index 0000000..972cae2 --- /dev/null +++ b/app/cases/_components/CaseMedia.paths.test.tsx @@ -0,0 +1,94 @@ +import { render, screen } from "@testing-library/react"; +import { getShowcaseAssets } from "@/lib/project-showcase"; +import { CaseMedia } from "./CaseMedia"; +import { getCaseMedia } from "./case-media"; + +/** + * Testes de caminhos-base de CaseMedia: com computador e celular, só um dos dois, ou nenhum. + * A existência dos arquivos em disco é simulada em getCaseMedia, para o teste não depender de public/. + */ + +jest.mock("./case-media", () => ({ getCaseMedia: jest.fn() })); + +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +function withCaptures(devices: ReadonlyArray<"desktop" | "mobile">) { + mockedGetCaseMedia.mockImplementation((id, device) => + devices.includes(device) ? getShowcaseAssets(id, device) : undefined, + ); +} + +const props = { + id: "sev-modaintima", + title: "SEV Moda Íntima", + demoUrl: "https://sevmodaintima.com.br", +}; +const desktopLabel = `Vídeo do site ${props.title} no computador`; +const mobileLabel = `Vídeo do site ${props.title} no celular`; + +afterEach(() => { + mockedGetCaseMedia.mockReset(); +}); + +describe("CaseMedia", () => { + it("path: desktop and mobile captures -> both frames, the browser bar shows the real domain", () => { + withCaptures(["desktop", "mobile"]); + const { container } = render(); + + expect(container.querySelector(".ds-bframe")).toContainElement( + screen.getByLabelText(desktopLabel), + ); + expect(container.querySelector(".ds-phone")).toContainElement( + screen.getByLabelText(mobileLabel), + ); + expect(container.querySelector(".ds-bframe-url")).toHaveTextContent( + "sevmodaintima.com.br", + ); + expect( + screen.getByText(`O site ${props.title} no computador.`), + ).toBeVisible(); + expect(screen.getByText("No celular.")).toBeVisible(); + expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "desktop"); + expect(mockedGetCaseMedia).toHaveBeenCalledWith(props.id, "mobile"); + }); + + it("path: no capture at all -> renders nothing", () => { + withCaptures([]); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("path: only the mobile capture -> phone frame without the browser frame", () => { + withCaptures(["mobile"]); + const { container } = render(); + + expect(screen.getByLabelText(mobileLabel)).toBeInTheDocument(); + expect(screen.queryByLabelText(desktopLabel)).toBeNull(); + expect(container.querySelector(".ds-bframe")).toBeNull(); + expect(container.querySelector(".ds-phone")).not.toBeNull(); + expect(screen.queryByText(/no computador\./)).toBeNull(); + }); + + it("path: only the desktop capture -> browser frame without the phone frame", () => { + withCaptures(["desktop"]); + const { container } = render(); + + expect(screen.getByLabelText(desktopLabel)).toBeInTheDocument(); + expect(screen.queryByLabelText(mobileLabel)).toBeNull(); + expect(container.querySelector(".ds-phone")).toBeNull(); + expect(container.querySelector(".ds-bframe")).not.toBeNull(); + expect(screen.queryByText("No celular.")).toBeNull(); + }); + + it("path: no demo address -> the browser bar falls back to the project title", () => { + withCaptures(["desktop"]); + const { container } = render( + , + ); + + expect(container.querySelector(".ds-bframe-url")).toHaveTextContent( + props.title, + ); + }); +}); diff --git a/app/cases/_components/CaseMedia.tsx b/app/cases/_components/CaseMedia.tsx new file mode 100644 index 0000000..b7b62aa --- /dev/null +++ b/app/cases/_components/CaseMedia.tsx @@ -0,0 +1,91 @@ +import { + BrowserFrame, + displayUrl, +} from "@/app/components/showcase/BrowserFrame"; +import { PhoneFrame } from "@/app/components/showcase/PhoneFrame"; +import type { ShowcaseAssets } from "@/lib/project-showcase"; +import { getCaseMedia } from "./case-media"; +import "../../components/showcase/showcase.css"; + +function CaseVideo({ + assets, + label, + className, +}: { + assets: ShowcaseAssets; + label: string; + className: string; +}) { + return ( + + ); +} + +/** + * Pôster e vídeo do projeto: o site no computador (moldura de navegador) e no celular (moldura de celular). + * O vídeo só baixa quando a pessoa aperta o play. Sem captura gravada, não mostra nada. + */ +export function CaseMedia({ + id, + title, + demoUrl, +}: { + id: string; + title: string; + demoUrl?: string | null; +}) { + const desktop = getCaseMedia(id, "desktop"); + const mobile = getCaseMedia(id, "mobile"); + if (!desktop && !mobile) return null; + + return ( +
+ {desktop ? ( +
+ +
+ +
+
+
+ O site {title} no computador. +
+
+ ) : null} + {mobile ? ( +
+ + + +
+ No celular. +
+
+ ) : null} +
+ ); +} diff --git a/app/cases/_components/CaseMediaSection.tsx b/app/cases/_components/CaseMediaSection.tsx new file mode 100644 index 0000000..1614a87 --- /dev/null +++ b/app/cases/_components/CaseMediaSection.tsx @@ -0,0 +1,55 @@ +import { secondaryButtonDark } from "@/app/components/button-styles"; +import type { CaseStudyContent } from "@/lib/site-content"; +import { CaseMedia } from "./CaseMedia"; +import { getCaseMedia } from "./case-media"; + +/** O bloco "Como ficou" existe quando há site no ar ou captura gravada, no computador ou no celular. */ +export function hasCaseMedia({ + id, + demoUrl, +}: Pick) { + return ( + Boolean(demoUrl) || + Boolean(getCaseMedia(id, "desktop")) || + Boolean(getCaseMedia(id, "mobile")) + ); +} + +/** "Como ficou": pôster e vídeo do projeto e o botão para abrir o site. Sem site nem captura, não mostra nada. */ +export function CaseMediaSection({ + caseStudy, +}: { + caseStudy: Pick; +}) { + if (!hasCaseMedia(caseStudy)) return null; + + return ( +
+
+

+ Como ficou +

+ + {caseStudy.demoUrl ? ( + + ) : null} +
+
+ ); +} diff --git a/app/cases/_components/CaseRelatedServices.tsx b/app/cases/_components/CaseRelatedServices.tsx new file mode 100644 index 0000000..f21c999 --- /dev/null +++ b/app/cases/_components/CaseRelatedServices.tsx @@ -0,0 +1,23 @@ +import Link from "next/link"; +import { textLink } from "@/app/components/button-styles"; +import { getRelatedServices } from "@/lib/site-content"; + +/** Links para os serviços que citam este case. Sem serviço relacionado, não mostra nada. */ +export function CaseRelatedServices({ caseId }: { caseId: string }) { + const services = getRelatedServices(caseId); + if (services.length === 0) return null; + + return ( +
+ {services.map((service) => ( + + Ver serviço relacionado: {service.navLabel} + + ))} +
+ ); +} diff --git a/app/cases/_components/CaseResultSection.tsx b/app/cases/_components/CaseResultSection.tsx new file mode 100644 index 0000000..e23f704 --- /dev/null +++ b/app/cases/_components/CaseResultSection.tsx @@ -0,0 +1,19 @@ +/** Resultado do case, só com dado real e autorizado. Sem dado, a seção não aparece. */ +export function CaseResultSection({ result }: { result?: string[] }) { + if (!result || result.length === 0) return null; + + return ( +
+
+

+ Resultado +

+
    + {result.map((item) => ( +
  • {item}
  • + ))} +
+
+
+ ); +} diff --git a/app/cases/_components/case-media.paths.test.ts b/app/cases/_components/case-media.paths.test.ts new file mode 100644 index 0000000..2d2de65 --- /dev/null +++ b/app/cases/_components/case-media.paths.test.ts @@ -0,0 +1,41 @@ +import { existsSync } from "node:fs"; +import { getCaseMedia } from "./case-media"; + +/** Testes de caminhos-base de getCaseMedia: id inválido, pôster ausente e pôster presente em disco. */ + +jest.mock("node:fs", () => { + const actual = jest.requireActual("node:fs"); + return { ...actual, existsSync: jest.fn(actual.existsSync) }; +}); + +const mockedExistsSync = jest.mocked(existsSync); + +beforeEach(() => { + mockedExistsSync.mockClear(); +}); + +describe("getCaseMedia", () => { + it.each([ + ["with path traversal", "../../etc/passwd"], + ["with upper case", "Sev-Modaintima"], + ["with a slash", "sev/modaintima"], + ["empty", ""], + ])("path: id %s -> undefined without touching the disk", (_label, id) => { + expect(getCaseMedia(id, "desktop")).toBeUndefined(); + expect(mockedExistsSync).not.toHaveBeenCalled(); + }); + + it("path: valid id with no poster on disk -> undefined", () => { + expect(getCaseMedia("twins-lab", "desktop")).toBeUndefined(); + expect(mockedExistsSync).toHaveBeenCalledTimes(1); + }); + + it("path: valid id with the poster on disk -> the showcase assets of that device", () => { + const desktop = getCaseMedia("sev-modaintima", "desktop"); + const mobile = getCaseMedia("sev-modaintima", "mobile"); + + expect(desktop?.poster).toBe("/videos/sev-modaintima/desktop.webp"); + expect(mobile?.poster).toBe("/videos/sev-modaintima/mobile.webp"); + expect(desktop?.sources.length).toBeGreaterThan(0); + }); +}); diff --git a/app/cases/_components/case-media.ts b/app/cases/_components/case-media.ts new file mode 100644 index 0000000..7bfa1f6 --- /dev/null +++ b/app/cases/_components/case-media.ts @@ -0,0 +1,23 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { + type Device, + getShowcaseAssets, + type ShowcaseAssets, +} from "@/lib/project-showcase"; + +const idPattern = /^[a-z0-9-]+$/; + +/** + * Pôster e vídeo gravados em public/videos//, ou `undefined` quando o projeto não tem captura. + * Roda no servidor (na geração das páginas): olha se o pôster existe no disco. + */ +export function getCaseMedia( + id: string, + device: Device, +): ShowcaseAssets | undefined { + if (!idPattern.test(id)) return undefined; + const assets = getShowcaseAssets(id, device); + const file = join(process.cwd(), "public", assets.poster); + return existsSync(file) ? assets : undefined; +} diff --git a/app/cases/opengraph-image.tsx b/app/cases/opengraph-image.tsx index d3105a9..372c1f4 100644 --- a/app/cases/opengraph-image.tsx +++ b/app/cases/opengraph-image.tsx @@ -2,16 +2,16 @@ import { ImageResponse } from "next/og"; import { OG_CONTENT_TYPE, OG_SIZE, OgCard } from "@/lib/og-card"; export const alt = - "Cases de software, web e sistemas conectados — Evandro Fonseca Junior"; + "Sites e sistemas feitos para empresas — Evandro Fonseca Junior"; export const size = OG_SIZE; export const contentType = OG_CONTENT_TYPE; export default async function Image() { return new ImageResponse( , OG_SIZE, ); diff --git a/app/cases/page.paths.test.tsx b/app/cases/page.paths.test.tsx new file mode 100644 index 0000000..f034f2c --- /dev/null +++ b/app/cases/page.paths.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, within } from "@testing-library/react"; +import { clientCases } from "@/lib/site-content"; +import { getCaseMedia } from "./_components/case-media"; +import CasesPage from "./page"; + +/** + * Teste de caminhos-base da lista de cases de cliente em CasesPage: + * o caminho "case de cliente sem captura gravada" (a página principal só vê cases com captura). + */ + +jest.mock("./_components/case-media", () => { + const actual = jest.requireActual("./_components/case-media"); + return { ...actual, getCaseMedia: jest.fn(actual.getCaseMedia) }; +}); + +const actualGetCaseMedia = jest.requireActual< + typeof import("./_components/case-media") +>("./_components/case-media").getCaseMedia; +const mockedGetCaseMedia = jest.mocked(getCaseMedia); + +afterEach(() => { + mockedGetCaseMedia.mockImplementation(actualGetCaseMedia); +}); + +function clientCard(container: HTMLElement, id: string) { + const card = container + .querySelector(`a[href="/cases/${id}"]`) + ?.closest("article"); + if (!card) throw new Error(`Missing client card ${id}`); + return card; +} + +describe("CasesPage: cases de cliente", () => { + it("path: client case without a desktop capture -> card keeps text and links but no cover", () => { + const [withoutCapture, withCapture] = clientCases; + mockedGetCaseMedia.mockImplementation((id, device) => + id === withoutCapture.id ? undefined : actualGetCaseMedia(id, device), + ); + + const { container } = render(); + + const bare = clientCard(container, withoutCapture.id); + expect(bare.querySelector(".ds-bframe")).toBeNull(); + expect(bare.querySelector("img")).toBeNull(); + expect( + within(bare).queryByRole("link", { + name: `Ver o case ${withoutCapture.title}`, + }), + ).toBeNull(); + expect(within(bare).getByText(withoutCapture.headline)).toBeVisible(); + expect( + within(bare).getByRole("link", { name: "Ver o case" }), + ).toHaveAttribute("href", `/cases/${withoutCapture.id}`); + + // os outros cases de cliente continuam com a capa + const covered = clientCard(container, withCapture.id); + expect(covered.querySelector(".ds-bframe img")).toHaveAttribute( + "alt", + `Captura de tela do site ${withCapture.title}`, + ); + expect(screen.getAllByRole("link", { name: "Ver o case" })).toHaveLength( + clientCases.length, + ); + }); +}); diff --git a/app/cases/page.tsx b/app/cases/page.tsx index 5fadb4a..c2f54de 100644 --- a/app/cases/page.tsx +++ b/app/cases/page.tsx @@ -1,32 +1,90 @@ import type { Metadata } from "next"; import Link from "next/link"; -import HeroBackground from "@/app/components/cinematic/HeroBackground"; -import MagneticButton from "@/app/components/cinematic/MagneticButton"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { + pageTop, + primaryButton, + secondaryButtonDark, + secondaryButtonLight, + textLink, +} from "@/app/components/button-styles"; +import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; -import { buildBreadcrumbList } from "@/lib/seo"; -import { caseStudies, intentCtas, siteName, siteUrl } from "@/lib/site-content"; +import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; +import { + type CaseStudyContent, + clientCases, + labCases, + siteUrl, +} from "@/lib/site-content"; +import { CaseCover } from "./_components/CaseCover"; +import { getCaseMedia } from "./_components/case-media"; const pageUrl = `${siteUrl}/cases`; -const pageTitle = "Cases de Engenharia de Software, Next.js e IoT"; +const pageTitle = "Cases: Sites e Sistemas Feitos para Empresas"; const pageDescription = - "Seis projetos mostram aplicações de engenharia de software em web, desktop, IoT, integrações e experiências orientadas a dados."; + "Veja o que já foi feito para empresas: loja virtual com pedido pelo WhatsApp, site com painel para o dono, páginas institucionais e mais. Confira cada projeto e converse sobre o seu."; +const allCases = [...clientCases, ...labCases]; export const metadata: Metadata = { title: pageTitle, description: pageDescription, - alternates: { - canonical: pageUrl, - }, - openGraph: { - locale: "pt_BR", + ...buildPageSocialMetadata({ + url: pageUrl, title: pageTitle, description: pageDescription, - url: pageUrl, - siteName, type: "website", - }, + }), }; +function CaseCard({ + caseStudy, + withCover, + ctaLabel, +}: { + caseStudy: CaseStudyContent; + withCover: boolean; + ctaLabel: string; +}) { + return ( +
+ {withCover && getCaseMedia(caseStudy.id, "desktop") ? ( + + + + ) : null} +
{caseStudy.title}
+

{caseStudy.headline}

+

{caseStudy.summary}

+
+ + {ctaLabel} + + {caseStudy.demoUrl ? ( + + Abrir o site + + ) : null} +
+
+ ); +} + export default function CasesPage() { const jsonLd = { "@context": "https://schema.org", @@ -40,7 +98,7 @@ export default function CasesPage() { }, { "@type": "ItemList", - itemListElement: caseStudies.map((caseStudy, index) => ({ + itemListElement: allCases.map((caseStudy, index) => ({ "@type": "ListItem", position: index + 1, name: caseStudy.title, @@ -58,32 +116,18 @@ export default function CasesPage() {
-
- -
+
+
- -

Selected work

-

- Cases em software, Next.js, integrações e produtos conectados. + +

Trabalhos entregues

+

+ Veja o que já fiz para empresas.

{pageDescription} @@ -92,65 +136,65 @@ export default function CasesPage() {

-
+
-
- {caseStudies.map((caseStudy) => ( -
-
{caseStudy.title}
-

{caseStudy.subtitle}

-

{caseStudy.summary}

-

- {caseStudy.impact} -

-
    - {caseStudy.stack.map((item) => ( -
  • - {item} -
  • - ))} -
-
- - Ler case - - {caseStudy.demoUrl ? ( - - Abrir projeto - - ) : null} -
-
+

+ Projetos de clientes +

+
+ {clientCases.map((caseStudy) => ( + ))}
+
+
-
- +
+

+ Laboratório +

+

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

+
+ {labCases.map((caseStudy) => ( + + ))} +
+
+
+ +
+
+

+ Quer um projeto parecido com algum destes? +

+

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

+
+ - {intentCtas.project.label} - - - {intentCtas.consulting.label} - + Conversar sobre meu projeto + + + Ver os serviços +
diff --git a/app/components/Breadcrumb.tsx b/app/components/Breadcrumb.tsx new file mode 100644 index 0000000..d4e8aeb --- /dev/null +++ b/app/components/Breadcrumb.tsx @@ -0,0 +1,30 @@ +import Link from "next/link"; + +/** Trilha de navegação sobre fundo escuro, no topo das páginas internas. O último item é a página atual. */ +export function Breadcrumb({ + items, +}: { + items: readonly { label: string; href: string }[]; +}) { + return ( + + ); +} diff --git a/app/curriculo/CvDownloadLink.tsx b/app/curriculo/CvDownloadLink.tsx new file mode 100644 index 0000000..dd20c32 --- /dev/null +++ b/app/curriculo/CvDownloadLink.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { ReactNode } from "react"; +import { trackCvDownload } from "@/lib/analytics"; +import { CV_DOWNLOAD_NAME, CV_URL } from "@/lib/site-navigation"; + +/** Link do PDF do currículo; mede o clique (só com consentimento de analytics). */ +export default function CvDownloadLink({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( + trackCvDownload({ placement: "curriculo" })} + > + {children} + + ); +} diff --git a/app/curriculo/page.test.tsx b/app/curriculo/page.test.tsx index c4ecdc0..3e8894c 100644 --- a/app/curriculo/page.test.tsx +++ b/app/curriculo/page.test.tsx @@ -1,6 +1,9 @@ -import { render, screen, within } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { trackCvDownload } from "@/lib/analytics"; import CurriculoPage from "./page"; +jest.mock("@/lib/analytics", () => ({ trackCvDownload: jest.fn() })); + it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => { render(CurriculoPage()); @@ -23,3 +26,29 @@ it("shows a matching breadcrumb and keeps consulting secondary to hiring", () => Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); }); + +it("links to no GitHub profile", () => { + render(CurriculoPage()); + + // "GitHub Actions" continua como competência técnica; o que não pode haver é endereço do GitHub + expect(document.body.textContent).not.toMatch(/github\.com/i); + for (const link of screen.getAllByRole("link")) { + expect(link.getAttribute("href")).not.toMatch(/github/i); + } +}); + +it("offers the résumé PDF as a secondary action and measures the download", () => { + render(CurriculoPage()); + + const pdf = screen.getByRole("link", { name: "Baixar currículo em PDF" }); + expect(pdf).toHaveAttribute("href", "/Profile.pdf"); + expect(pdf).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf"); + // limão só para o contato + expect(pdf).not.toHaveClass("ds-link-accent"); + expect(screen.getByRole("link", { name: /oportunidade/i })).toHaveClass( + "ds-link-accent", + ); + + fireEvent.click(pdf); + expect(trackCvDownload).toHaveBeenCalledWith({ placement: "curriculo" }); +}); diff --git a/app/curriculo/page.tsx b/app/curriculo/page.tsx index 7ad4370..0e81ffd 100644 --- a/app/curriculo/page.tsx +++ b/app/curriculo/page.tsx @@ -1,9 +1,16 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { pageTop } from "@/app/components/button-styles"; import { safeJsonLd } from "@/lib/jsonld"; import { portfolio } from "@/lib/portfolio-data"; -import { buildBreadcrumbList, buildProfilePageJsonLd } from "@/lib/seo"; +import { + buildBreadcrumbList, + buildPageSocialMetadata, + buildProfilePageJsonLd, +} from "@/lib/seo"; import { siteName, siteUrl } from "@/lib/site-content"; +import CvDownloadLink from "./CvDownloadLink"; const pageUrl = `${siteUrl}/curriculo`; @@ -11,16 +18,13 @@ export const metadata: Metadata = { title: { absolute: `Currículo de Evandro Fonseca Junior | ${siteName}` }, description: "Currículo profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software, com experiência, formação e competências técnicas.", - alternates: { canonical: pageUrl }, - openGraph: { - locale: "pt_BR", + ...buildPageSocialMetadata({ + url: pageUrl, title: `Currículo de Evandro Fonseca Junior | ${siteName}`, description: "Experiência profissional, formação e competências técnicas de Evandro Fonseca Junior para avaliação de contratação.", - url: pageUrl, - siteName, type: "profile", - }, + }), }; export default function CurriculoPage() { @@ -38,27 +42,14 @@ export default function CurriculoPage() { return (
-
+
- +

Currículo profissional

Engenheiro da Computação e Desenvolvedor de Software. @@ -69,13 +60,13 @@ export default function CurriculoPage() { formação e competências técnicas para avaliação de contratação.

- - Baixar currículo em PDF - - + Falar sobre uma oportunidade - + + Baixar currículo em PDF + + Procurando consultoria? Ver serviços
@@ -131,7 +122,7 @@ export default function CurriculoPage() { ))}

- + Ver projetos e cases
diff --git a/app/not-found.tsx b/app/not-found.tsx index d710931..7b75dba 100644 --- a/app/not-found.tsx +++ b/app/not-found.tsx @@ -1,19 +1,31 @@ import { ArrowLeft, Code2, Home } from "lucide-react"; +import type { Metadata } from "next"; import Link from "next/link"; +// O layout raiz libera a indexação (inclusive para o Googlebot); aqui a regra inteira é trocada por noindex. +export const metadata: Metadata = { + title: "Página não encontrada", + description: "A página que você procura não existe ou foi movida.", + robots: { + index: false, + follow: false, + googleBot: { index: false, follow: false }, + }, +}; + export default function NotFound() { return ( -
+
- -

+ +

404

-

+

Página não encontrada

-

+

A página que você está procurando não existe ou foi movida.

@@ -21,20 +33,20 @@ export default function NotFound() {
- Voltar para Home + Voltar ao início - Ver Projetos + Ver os cases
-
+
); } diff --git a/app/privacidade/page.test.tsx b/app/privacidade/page.test.tsx index add5966..5550831 100644 --- a/app/privacidade/page.test.tsx +++ b/app/privacidade/page.test.tsx @@ -40,6 +40,22 @@ it("preserves breadcrumb navigation and a breadcrumb-only JSON-LD graph", () => ]); }); +it("mentions no GitHub link and no English jargon in the visible text", () => { + const { container } = render(); + + expect(container.textContent).not.toMatch(/github/i); + expect(container.textContent).not.toMatch(/\b(loader|endpoint)\b/i); + for (const link of screen.getAllByRole("link")) { + expect(link.getAttribute("href")).not.toMatch(/github/i); + } +}); + +it("keeps the lime fill for contact actions only", () => { + const { container } = render(); + + expect(container.querySelector(".ds-link-accent")).toBeNull(); +}); + it("uses privacy-specific canonical metadata", () => { expect(metadata).toMatchObject({ title: expect.stringMatching(/privacidade/i), diff --git a/app/privacidade/page.tsx b/app/privacidade/page.tsx index 5eda1d2..ab8596b 100644 --- a/app/privacidade/page.tsx +++ b/app/privacidade/page.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { pageTop } from "@/app/components/button-styles"; import { safeJsonLd } from "@/lib/jsonld"; -import { buildBreadcrumbList } from "@/lib/seo"; +import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; import { siteName, siteUrl } from "@/lib/site-content"; const pageUrl = `${siteUrl}/privacidade`; @@ -10,16 +12,13 @@ export const metadata: Metadata = { title: "Privacidade e consentimento", description: "Como o portfólio trata dados de contato, consentimento de analytics, links externos e solicitações de privacidade.", - alternates: { canonical: pageUrl }, - openGraph: { - locale: "pt_BR", + ...buildPageSocialMetadata({ + url: pageUrl, title: `Privacidade e consentimento | ${siteName}`, description: "Informações factuais sobre contato, analytics e preferências de privacidade no portfólio.", - url: pageUrl, - siteName, type: "website", - }, + }), }; export default function PrivacyPage() { @@ -37,27 +36,14 @@ export default function PrivacyPage() {
-
+
- +

Privacidade e consentimento

Privacidade e consentimento @@ -71,7 +57,7 @@ export default function PrivacyPage() {
-
+

Responsável e contato

Evandro Fonseca Junior

@@ -79,7 +65,7 @@ export default function PrivacyPage() { O responsável pelo site é Evandro Fonseca Junior. Para contato ou solicitações relacionadas a esta página, escreva para{" "} evandrofonsecajunior@gmail.com @@ -93,8 +79,8 @@ export default function PrivacyPage() {

Mensagem pelo WhatsApp

Os dados digitados no formulário são transformados em uma - mensagem e abrem o WhatsApp. O site não possui endpoint próprio - de armazenamento desse formulário. + mensagem e abrem o WhatsApp. O site não guarda esse formulário + em nenhum servidor próprio.

@@ -111,8 +97,8 @@ export default function PrivacyPage() { Cookies de publicidade e personalização permanecem negados. Ao conceder analytics, o Google pode criar cookies{" "} _ga*. Ao revogar, o consentimento é atualizado para - negado, o loader é removido e os cookies de analytics acessíveis - são expirados. + negado, o carregador da tag é removido e os cookies de analytics + acessíveis são expirados.

A tag compartilhada conecta Google Analytics e Google Ads. O @@ -137,16 +123,16 @@ export default function PrivacyPage() {

Para alterar a preferência, use o controle existente{" "} “Preferências de analytics” para acessar suas - preferências de privacidade. Links externos para GitHub, - LinkedIn, Instagram, demos e WhatsApp seguem as políticas - próprias de cada serviço. + preferências de privacidade. Links externos para LinkedIn, + Instagram, demonstrações e WhatsApp seguem as políticas próprias + de cada serviço.

Solicitações relacionadas aos seus direitos devem ser encaminhadas ao e-mail publicado acima.

Voltar ao início @@ -158,7 +144,7 @@ export default function PrivacyPage() {

Vigência

Esta versão está vigente desde{" "} - . + .

diff --git a/app/servicos/[slug]/page.paths.test.tsx b/app/servicos/[slug]/page.paths.test.tsx new file mode 100644 index 0000000..cf58cf9 --- /dev/null +++ b/app/servicos/[slug]/page.paths.test.tsx @@ -0,0 +1,98 @@ +import { render, screen } from "@testing-library/react"; +import { notFound } from "next/navigation"; +import { getServicePage } from "@/lib/site-content"; +import ServicePage, { generateMetadata } from "./page"; + +/** + * Testes de caminhos-base de ServicePage e generateMetadata (complemento de page.test.tsx). + * Cobrem só o que o arquivo principal não exercita: slug desconhecido e perguntas frequentes ausentes ou vazias. + */ + +jest.mock("next/navigation", () => ({ + notFound: jest.fn(() => { + throw new Error("NEXT_NOT_FOUND"); + }), +})); + +beforeEach(() => { + jest.mocked(notFound).mockClear(); +}); + +async function renderService(slug: string) { + const ui = await ServicePage({ params: Promise.resolve({ slug }) }); + return render(ui); +} + +const FAQ_HEADING = "Perguntas frequentes"; + +describe("ServicePage: caminhos que o teste principal não cobre", () => { + it("path: unknown slug -> calls notFound() and renders nothing", async () => { + await expect( + ServicePage({ params: Promise.resolve({ slug: "nao-existe" }) }), + ).rejects.toThrow("NEXT_NOT_FOUND"); + expect(notFound).toHaveBeenCalledTimes(1); + }); + + it("path: service with a FAQ (main path) -> shows every question", async () => { + const service = getServicePage("criacao-de-sites"); + if (!service?.faq?.length) throw new Error("Missing service FAQ"); + + await renderService(service.slug); + + expect( + screen.getByRole("heading", { level: 2, name: FAQ_HEADING }), + ).toBeVisible(); + for (const item of service.faq) { + expect( + screen.getByRole("heading", { level: 3, name: item.question }), + ).toBeVisible(); + } + }); + + it("path: service without the faq field -> no FAQ section, the rest of the page stays", async () => { + const service = getServicePage("criacao-de-sites"); + if (!service?.faq) throw new Error("Missing service FAQ"); + const originalFaq = service.faq; + delete service.faq; + try { + await renderService(service.slug); + expect(screen.queryByRole("heading", { name: FAQ_HEADING })).toBeNull(); + expect( + screen.getByRole("heading", { + level: 2, + name: "Conte o que você precisa resolver.", + }), + ).toBeVisible(); + } finally { + service.faq = originalFaq; + } + }); + + it("path: service with an empty FAQ list -> no FAQ section", async () => { + const service = getServicePage("criacao-de-sites"); + if (!service?.faq) throw new Error("Missing service FAQ"); + const originalFaq = service.faq; + service.faq = []; + try { + await renderService(service.slug); + expect(screen.queryByRole("heading", { name: FAQ_HEADING })).toBeNull(); + expect( + screen.getByRole("heading", { + level: 2, + name: "Conte o que você precisa resolver.", + }), + ).toBeVisible(); + } finally { + service.faq = originalFaq; + } + }); +}); + +describe("generateMetadata: caminho do slug desconhecido", () => { + it("path: unknown slug -> only a 'not found' title, no canonical or social tags", async () => { + const metadata = await generateMetadata({ + params: Promise.resolve({ slug: "nao-existe" }), + }); + expect(metadata).toEqual({ title: "Serviço não encontrado" }); + }); +}); diff --git a/app/servicos/[slug]/page.test.tsx b/app/servicos/[slug]/page.test.tsx index 1908952..da45f01 100644 --- a/app/servicos/[slug]/page.test.tsx +++ b/app/servicos/[slug]/page.test.tsx @@ -1,33 +1,26 @@ import { render, screen, within } from "@testing-library/react"; -import type { ReactNode } from "react"; import { SEO_IDS } from "@/lib/seo"; -import { caseStudies, getServicePage, siteUrl } from "@/lib/site-content"; +import { + caseStudies, + getRelatedCases, + getServicePage, + siteUrl, +} from "@/lib/site-content"; import ServicesIndexPage from "../page"; import ServicePage, { generateMetadata, generateStaticParams } from "./page"; jest.mock("next/navigation", () => ({ notFound: jest.fn() })); -jest.mock("@/app/components/cinematic/HeroBackground", () => ({ - __esModule: true, - default: () => null, -})); +async function renderService(slug: string) { + const ui = await ServicePage({ params: Promise.resolve({ slug }) }); + return render(ui); +} -jest.mock("@/app/components/cinematic/MagneticButton", () => ({ - __esModule: true, - default: ({ - as = "button", - strength: _strength, - children, - ...props - }: { - as?: string; - strength?: number; - children?: ReactNode; - } & Record) => { - const React = require("react"); - return React.createElement(as, props, children); - }, -})); +function isBefore(first: Element, second: Element) { + return Boolean( + first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING, + ); +} const service = getServicePage("consultoria-nextjs"); if (!service) throw new Error("Missing representative service"); @@ -86,10 +79,7 @@ it("keeps static params and route metadata canonical", async () => { }); it("renders answer-first consulting, reciprocal case links and minimal schema", async () => { - const ui = await ServicePage({ - params: Promise.resolve({ slug: service.slug }), - }); - const { container } = render(ui); + const { container } = await renderService(service.slug); const answer = screen.getByText(service.answer); expect(answer).toBeVisible(); @@ -153,7 +143,15 @@ it("renders answer-first consulting, reciprocal case links and minimal schema", "rel", expect.stringMatching(/noreferrer/), ); + expect(externalLink.getAttribute("href")).not.toMatch(/github/i); } + const hrefs = screen + .getAllByRole("link") + .map((link) => link.getAttribute("href") ?? ""); + expect(hrefs).not.toContain("/#contact"); + expect(container.textContent).not.toMatch( + /Exemplos ilustrativos|não projetos já entregues|Sem promessas|indispon|Ver código/i, + ); const graph = readJsonLdEntities(container); expect(graph.map((node) => node["@type"])).toEqual([ @@ -243,3 +241,127 @@ it("keeps hub cards and structured items aligned", () => { ], }); }); + +it("shows the client projects with covers on the hub, without disclaimers", () => { + const { container } = render(); + + for (const id of [ + "sev-modaintima", + "lavacao-047", + "sc-plus", + "as-servicos", + ]) { + const link = container.querySelector(`a[href="/cases/${id}"]`); + expect( + link?.closest("article")?.querySelector(".ds-bframe img"), + ).not.toBeNull(); + } + expect(container.textContent).not.toMatch( + /sem promessas|Demonstração publicada|limites|indispon/i, + ); + const hrefs = screen + .getAllByRole("link") + .map((link) => link.getAttribute("href") ?? ""); + expect(hrefs).not.toContain("/#contact"); + expect(hrefs.some((href) => /github/i.test(href))).toBe(false); +}); + +it("shows the client cases first, right after the hero, with the first cover in a frame", async () => { + const { container } = await renderService("criacao-de-sites"); + const siteService = getServicePage("criacao-de-sites"); + if (!siteService) throw new Error("Missing service"); + + const casesHeading = screen.getByRole("heading", { + level: 2, + name: "Veja projetos parecidos, já prontos", + }); + expect( + isBefore(screen.getByRole("heading", { level: 1 }), casesHeading), + ).toBe(true); + expect( + isBefore( + casesHeading, + screen.getByRole("heading", { level: 2, name: "Para quem faz sentido" }), + ), + ).toBe(true); + + const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ }); + expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual( + getRelatedCases(siteService.relatedCaseIds).map(({ id }) => `/cases/${id}`), + ); + expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([ + "/cases/sev-modaintima", + "/cases/lavacao-047", + "/cases/sc-plus", + "/cases/as-servicos", + ]); + + // pôster do primeiro case numa moldura no topo, no lugar do fundo verde + const hero = container.querySelector("section"); + const heroFrame = hero?.querySelector(".ds-bframe"); + expect(heroFrame).not.toBeNull(); + expect(heroFrame?.querySelector("img")).toHaveAttribute( + "alt", + "Captura de tela do site SEV Moda Íntima", + ); + 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"); + + expect( + screen.queryByRole("heading", { + name: "Veja projetos parecidos, já prontos", + }), + ).toBeNull(); + expect(screen.queryByRole("link", { name: /^Ler case:/ })).toBeNull(); + expect( + screen.queryByRole("link", { name: "Ver projetos parecidos" }), + ).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("labels the Twins Lab prototype as a laboratory project on the IoT service", async () => { + const { container } = await renderService("integracao-hardware-software-iot"); + + const caseLinks = screen.getAllByRole("link", { name: /^Ler case:/ }); + expect(caseLinks.map((link) => link.getAttribute("href"))).toEqual([ + "/cases/twins-lab", + ]); + expect(screen.getByText(/Protótipo de laboratório/)).toBeVisible(); + // sem captura gravada, não há moldura nem imagem + expect(container.querySelector(".ds-bframe")).toBeNull(); +}); + +it("renders the offer block only when some field is filled in", async () => { + const empty = await renderService("criacao-de-sites"); + expect( + screen.queryByRole("heading", { name: "Investimento e prazo" }), + ).toBeNull(); + empty.unmount(); + + const offerService = getServicePage("criacao-de-sites"); + if (!offerService) throw new Error("Missing service"); + offerService.typicalTimeline = "Texto de prazo de teste"; + try { + await renderService("criacao-de-sites"); + expect( + screen.getByRole("heading", { level: 2, name: "Investimento e prazo" }), + ).toBeVisible(); + expect(screen.getByText("Prazo típico")).toBeVisible(); + expect(screen.getByText("Texto de prazo de teste")).toBeVisible(); + expect(screen.queryByText("A partir de")).toBeNull(); + expect(screen.queryByText("Garantia")).toBeNull(); + } finally { + delete offerService.typicalTimeline; + } +}); + +it("keeps related case ids resolving to real cases", () => { + for (const id of service.relatedCaseIds) { + expect(caseStudies.some((item) => item.id === id)).toBe(true); + } +}); diff --git a/app/servicos/[slug]/page.tsx b/app/servicos/[slug]/page.tsx index 559bcdb..37416bf 100644 --- a/app/servicos/[slug]/page.tsx +++ b/app/servicos/[slug]/page.tsx @@ -1,18 +1,30 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; -import HeroBackground from "@/app/components/cinematic/HeroBackground"; -import MagneticButton from "@/app/components/cinematic/MagneticButton"; +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { + pageTop, + primaryButton, + secondaryButtonDark, +} from "@/app/components/button-styles"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; -import { buildBreadcrumbList, buildServiceJsonLd } from "@/lib/seo"; +import { + buildBreadcrumbList, + buildPageSocialMetadata, + buildServiceJsonLd, +} from "@/lib/seo"; import { getRelatedCases, getServicePage, servicePages, - siteName, siteUrl, } from "@/lib/site-content"; +import { ServiceFaq } from "../_components/ServiceFaq"; +import { ServiceHeroCover } from "../_components/ServiceHeroCover"; +import { ServiceOffer } from "../_components/ServiceOffer"; +import { ServiceRelatedCases } from "../_components/ServiceRelatedCases"; interface ServicePageProps { params: Promise<{ @@ -41,22 +53,12 @@ export async function generateMetadata({ return { title: service.seoTitle, description: service.metaDescription, - alternates: { - canonical: url, - }, - openGraph: { - locale: "pt_BR", - title: service.seoTitle, - description: service.metaDescription, + ...buildPageSocialMetadata({ url, - siteName, - type: "article", - }, - twitter: { - card: "summary_large_image", title: service.seoTitle, description: service.metaDescription, - }, + type: "article", + }), }; } @@ -69,6 +71,9 @@ export default async function ServicePage({ params }: ServicePageProps) { } const relatedCases = getRelatedCases(service.relatedCaseIds); + const heroCase = relatedCases.find((caseStudy) => + getCaseMedia(caseStudy.id, "desktop"), + ); const pageUrl = `${siteUrl}/servicos/${service.slug}`; const jsonLd = { @@ -87,35 +92,22 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
- -
+ +
@@ -192,8 +184,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
Possibilidades de uso

Imagine na sua rotina

- Exemplos ilustrativos para ajudar a explicar o serviço, não - projetos já entregues. O seu escopo será definido na conversa. + Situações comuns em que este serviço ajuda. A sua será combinada + na conversa.

    {service.examples.map((example) => ( @@ -215,9 +207,8 @@ export default async function ServicePage({ params }: ServicePageProps) { ))}

- Tecnologias consideradas neste serviço. A seleção depende do - problema e do ambiente; não é uma lista obrigatória para todo - projeto. + Tecnologias que costumo usar neste serviço. A escolha depende do + problema e do ambiente.

    {service.stack.map((item) => ( @@ -231,71 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
-
-
-
-
-

Casos relacionados

-

- Projetos para conhecer meu trabalho -

-

- Referências de problemas e soluções relacionados. Cada projeto - usa as tecnologias indicadas no próprio case; não representa - todas as plataformas ou tecnologias oferecidas neste serviço. -

-
-
- -
- {relatedCases.map((caseStudy) => ( - - ))} -
-
-
+
@@ -314,48 +241,19 @@ export default async function ServicePage({ params }: ServicePageProps) { {service.ctaLabel} no WhatsApp - + Ver todos os serviços - +

- {service.faq && service.faq.length > 0 ? ( -
-
-
-
-

Dúvidas comuns

-

Perguntas frequentes

-

- Respostas diretas às perguntas que costumam aparecer antes de - cada projeto começar. -

-
-
- -
- {service.faq.map((item) => ( -
-

{item.question}

-

{item.answer}

-
- ))} -
-
-
- ) : null} +
); } diff --git a/app/servicos/_components/ServiceFaq.tsx b/app/servicos/_components/ServiceFaq.tsx new file mode 100644 index 0000000..cf94dda --- /dev/null +++ b/app/servicos/_components/ServiceFaq.tsx @@ -0,0 +1,32 @@ +import type { ServicePageContent } from "@/lib/site-content"; + +/** "Perguntas frequentes" do serviço. Sem perguntas, a seção não aparece. */ +export function ServiceFaq({ faq }: { faq: ServicePageContent["faq"] }) { + if (!faq || faq.length === 0) return null; + + return ( +
+
+
+
+

Dúvidas comuns

+

Perguntas frequentes

+

+ Respostas diretas às perguntas que costumam aparecer antes de cada + projeto começar. +

+
+
+ +
+ {faq.map((item) => ( +
+

{item.question}

+

{item.answer}

+
+ ))} +
+
+
+ ); +} diff --git a/app/servicos/_components/ServiceHeroCover.tsx b/app/servicos/_components/ServiceHeroCover.tsx new file mode 100644 index 0000000..a75ce67 --- /dev/null +++ b/app/servicos/_components/ServiceHeroCover.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; +import { CaseCover } from "@/app/cases/_components/CaseCover"; +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. */ +export function ServiceHeroCover({ + caseStudy, +}: { + caseStudy?: CaseStudyContent; +}) { + if (!caseStudy) return null; + + return ( + + Ver o case {caseStudy.title} + + + ); +} diff --git a/app/servicos/_components/ServiceOffer.tsx b/app/servicos/_components/ServiceOffer.tsx new file mode 100644 index 0000000..1268fdf --- /dev/null +++ b/app/servicos/_components/ServiceOffer.tsx @@ -0,0 +1,41 @@ +import type { ServicePageContent } from "@/lib/site-content"; + +/** "Investimento e prazo": só mostra os campos preenchidos com valor real; sem nenhum, a seção não aparece. */ +export function ServiceOffer({ + service, +}: { + service: Pick< + ServicePageContent, + "startingPrice" | "typicalTimeline" | "guarantee" + >; +}) { + const offer = [ + { label: "A partir de", value: service.startingPrice }, + { label: "Prazo típico", value: service.typicalTimeline }, + { label: "Garantia", value: service.guarantee }, + ].filter((item): item is { label: string; value: string } => + Boolean(item.value), + ); + if (offer.length === 0) return null; + + return ( +
+
+

+ Investimento e prazo +

+
+ {offer.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+
+ ); +} diff --git a/app/servicos/_components/ServiceRelatedCases.tsx b/app/servicos/_components/ServiceRelatedCases.tsx new file mode 100644 index 0000000..f2feeeb --- /dev/null +++ b/app/servicos/_components/ServiceRelatedCases.tsx @@ -0,0 +1,84 @@ +import Link from "next/link"; +import { CaseCover } from "@/app/cases/_components/CaseCover"; +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { secondaryButtonLight, textLink } from "@/app/components/button-styles"; +import { type CaseStudyContent, isClientCase } from "@/lib/site-content"; + +/** "Veja projetos parecidos": os cases ligados ao serviço. Sem case relacionado, não mostra nada. */ +export function ServiceRelatedCases({ + cases, +}: { + cases: readonly CaseStudyContent[]; +}) { + if (cases.length === 0) return null; + + return ( +
+
+
+
+

Trabalhos parecidos

+

+ Veja projetos parecidos, já prontos +

+

+ Projetos reais nesta área. Abra o case para ver o que foi feito e + como ficou. +

+
+
+ +
+ {cases.map((caseStudy) => ( +
+ {getCaseMedia(caseStudy.id, "desktop") ? ( + + + + ) : null} +
+ {isClientCase(caseStudy) + ? caseStudy.title + : `${caseStudy.title} · Protótipo de laboratório`} +
+

{caseStudy.headline}

+

{caseStudy.summary}

+
+ + Ler case: {caseStudy.title} + + {caseStudy.demoUrl ? ( + + Abrir o site: {caseStudy.title} + + ) : null} +
+
+ ))} +
+
+
+ ); +} diff --git a/app/servicos/page.tsx b/app/servicos/page.tsx index 5e172a0..4d11e34 100644 --- a/app/servicos/page.tsx +++ b/app/servicos/page.tsx @@ -1,19 +1,23 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { CaseCover } from "@/app/cases/_components/CaseCover"; +import { getCaseMedia } from "@/app/cases/_components/case-media"; +import { Breadcrumb } from "@/app/components/Breadcrumb"; +import { + pageTop, + primaryButton, + secondaryButtonDark, + textLink, +} from "@/app/components/button-styles"; import WhatsAppLink from "@/app/components/WhatsAppLink"; import { safeJsonLd } from "@/lib/jsonld"; -import { buildBreadcrumbList } from "@/lib/seo"; -import { - caseStudies, - serviceHubLinks, - siteName, - siteUrl, -} from "@/lib/site-content"; +import { buildBreadcrumbList, buildPageSocialMetadata } from "@/lib/seo"; +import { clientCases, serviceHubLinks, siteUrl } from "@/lib/site-content"; const pageUrl = `${siteUrl}/servicos`; -const pageTitle = "Sites, Sistemas e Aplicativos sob Medida"; +const pageTitle = "Sites, Sistemas e Aplicativos sob Medida para Empresas"; const pageDescription = - "Sites, sistemas web e aplicativos sob medida com Evandro, desenvolvedor em Garuva-SC e atendimento remoto no Brasil. Conheça projetos e converse pelo WhatsApp."; + "Sites, sistemas web e aplicativos sob medida para sua empresa apresentar o negócio e organizar a operação. Evandro, desenvolvedor em Garuva-SC, atende Joinville, região e todo o Brasil de forma remota."; const businessIntents = [ { @@ -45,24 +49,15 @@ const businessIntents = [ }, ]; -const featuredCases = caseStudies.filter((caseStudy) => - ["sc-plus", "nutriscan"].includes(caseStudy.id), -); - export const metadata: Metadata = { title: pageTitle, description: pageDescription, - alternates: { - canonical: pageUrl, - }, - openGraph: { - locale: "pt_BR", + ...buildPageSocialMetadata({ + url: pageUrl, title: pageTitle, description: pageDescription, - url: pageUrl, - siteName, type: "website", - }, + }), }; export default function ServicesIndexPage() { @@ -96,24 +91,14 @@ export default function ServicesIndexPage() {
-
+
- +

Sites, sistemas e aplicativos sob medida.

@@ -124,7 +109,7 @@ export default function ServicesIndexPage() {

@@ -132,7 +117,7 @@ export default function ServicesIndexPage() { Ver projetos realizados @@ -165,7 +150,7 @@ export default function ServicesIndexPage() {

{intent.description}

@@ -173,7 +158,7 @@ export default function ServicesIndexPage() { {intent.linkLabel} @@ -194,24 +179,36 @@ export default function ServicesIndexPage() { Veja o trabalho antes de conversar.

- Dois projetos com demonstrações publicadas. Os cases mostram o que - foi implementado e as tecnologias usadas, sem promessas de resultado - comercial. + Projetos feitos para empresas. Abra o case para ver o que foi feito + e como o site ficou.

- {featuredCases.map((caseStudy) => ( + {clientCases.map((caseStudy) => (
-

- Demonstração publicada -

-

{caseStudy.title}

+ {getCaseMedia(caseStudy.id, "desktop") ? ( + + + + ) : null} +

{caseStudy.title}

+

{caseStudy.headline}

{caseStudy.summary}

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

Consulte as áreas de atuação, incluindo desktop, integrações, - consultoria e IoT. Cada página explica possibilidades e limites. + consultoria e IoT. Cada página explica o que posso fazer por você.

    {serviceHubLinks.map((service) => ( @@ -318,7 +315,7 @@ export default function ServicesIndexPage() { formulário para começar.

    diff --git a/app/sitemap.ts b/app/sitemap.ts index f9547d4..c9b6e4b 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,9 @@ import type { MetadataRoute } from "next"; import { caseStudies, servicePages, siteUrl } from "@/lib/site-content"; +/** Data fixa da última mudança de conteúdo; o sitemap precisa ser igual a cada build. Atualize quando as páginas mudarem. */ +const lastModified = new Date("2026-10-14T00:00:00Z"); + export default function sitemap(): MetadataRoute.Sitemap { return [ { url: siteUrl }, @@ -8,11 +11,12 @@ export default function sitemap(): MetadataRoute.Sitemap { { url: `${siteUrl}/privacidade` }, { url: `${siteUrl}/servicos` }, { url: `${siteUrl}/cases` }, + { url: `${siteUrl}/ia` }, ...servicePages.map(({ slug }) => ({ url: `${siteUrl}/servicos/${slug}`, })), ...caseStudies.map(({ id }) => ({ url: `${siteUrl}/cases/${id}`, })), - ]; + ].map((entry) => ({ ...entry, lastModified })); } diff --git a/data/github-stats.json b/data/github-stats.json deleted file mode 100644 index 4cd5eab..0000000 --- a/data/github-stats.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "checkedAt": "2026-10-08T18:25:53Z", - "total": 68, - "public": 25, - "private": 43 -} diff --git a/lib/og-card.tsx b/lib/og-card.tsx index 7b1821d..e1bb1ab 100644 --- a/lib/og-card.tsx +++ b/lib/og-card.tsx @@ -68,6 +68,7 @@ export function OgCard({ /> )} + {/* biome-ignore lint/performance/noImgElement: o ImageResponse (satori) só desenha */} Evandro.dev.br ({ })); import { readFile } from "node:fs/promises"; -import { loadFeaturedProjects, portfolio } from "./portfolio-data"; +import { portfolio } from "./portfolio-data"; +import { loadFeaturedProjects } from "./portfolio-data.server"; import { createProjectHealthSnapshot } from "./project-health"; const featuredIds = portfolio.projects diff --git a/lib/portfolio-data.server.ts b/lib/portfolio-data.server.ts new file mode 100644 index 0000000..36fa0b7 --- /dev/null +++ b/lib/portfolio-data.server.ts @@ -0,0 +1,70 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + getFeaturedProjects, + type PortfolioProjectView, + portfolio, +} from "./portfolio-data"; +import { + createProjectHealthSnapshot, + PROJECT_HEALTH_MAX_AGE_MS, + type ProjectHealthSnapshot, +} from "./project-health"; + +// Só no servidor: lê o arquivo de saúde das demonstrações (node:fs). lib/portfolio-data.ts fica sem Node, +// porque lib/site-content.ts e lib/proof.ts o importam e componentes de cliente chegam a eles. + +function isSnapshotFresh( + snapshot: ProjectHealthSnapshot | null, + now = new Date(), +) { + if (!snapshot) return false; + const checkedAt = Date.parse(snapshot.checkedAt); + return ( + Number.isFinite(checkedAt) && + now.getTime() - checkedAt <= PROJECT_HEALTH_MAX_AGE_MS + ); +} + +export async function loadProjectHealthSnapshot(): Promise { + try { + const path = resolve(process.cwd(), "data/project-health.json"); + return JSON.parse(await readFile(path, "utf8")) as ProjectHealthSnapshot; + } catch { + return null; + } +} + +export async function loadFeaturedProjects(): Promise { + const snapshot = await loadProjectHealthSnapshot(); + if (process.env.NODE_ENV !== "development" || isSnapshotFresh(snapshot)) { + return getFeaturedProjects(snapshot); + } + + const projectDemos = portfolio.projects.reduce< + Array<{ id: string; demoUrl: string }> + >((demos, project) => { + if (project.demoUrl) + demos.push({ id: project.id, demoUrl: project.demoUrl }); + return demos; + }, []); + + try { + const refreshed = await createProjectHealthSnapshot(projectDemos); + const projects = { ...refreshed.projects }; + + for (const [id, check] of Object.entries(projects)) { + if (!check.error && check.status !== null) continue; + const previous = snapshot?.projects[id]; + if (previous && !previous.error && previous.status !== null) { + projects[id] = previous; + } else { + delete projects[id]; + } + } + + return getFeaturedProjects({ ...refreshed, projects }); + } catch { + return getFeaturedProjects(snapshot); + } +} diff --git a/lib/portfolio-data.test.ts b/lib/portfolio-data.test.ts index bae3977..c872e3d 100644 --- a/lib/portfolio-data.test.ts +++ b/lib/portfolio-data.test.ts @@ -63,7 +63,9 @@ describe("factual portfolio records", () => { const project = portfolio.projects.find((item) => item.id === id); expect(project).toBeDefined(); if (!project) throw new Error(`Missing project ${id}`); - return JSON.stringify(project); + // "kind" (cliente ou laboratório) é classificação do portfólio, não afirmação sobre o projeto + const { kind: _kind, ...claims } = project; + return JSON.stringify(claims); } it("keeps every public field of the three cases within verified claims", () => { @@ -73,7 +75,7 @@ describe("factual portfolio records", () => { expect(nutriscan).toContain("Open Food Facts"); expect(nutriscan).not.toMatch( - /Next\.js\s*\d|PWA|Service Worker|MongoDB|históric|scanner|offline|NutriScore|produç|24\/7|2M\+|disponibilidade|volume de produtos|resultado de saúde/i, + /Next\.js\s*\d|PWA|Service Worker|MongoDB|históric|scanner|offline|NutriScore|24\/7|2M\+|disponibilidade|volume de produtos|resultado de saúde/i, ); expect(twins).toContain("SSE"); @@ -112,4 +114,33 @@ describe("factual portfolio records", () => { portfolio.projects.find((item) => item.id === "twins-lab")?.demoUrl, ).toBeUndefined(); }); + + it("leaves no GitHub link in the portfolio data", () => { + expect(JSON.stringify(portfolio)).not.toMatch(/github\.com/i); + for (const project of portfolio.projects) { + expect(project.githubUrl).toBe(""); + } + }); + + it("does not call a project public code while no repository link is published", () => { + expect(JSON.stringify(portfolio)).not.toMatch(/código público/i); + const status = (id: string) => + portfolio.projects.find((item) => item.id === id)?.status; + // o NutriScan tem demonstração no ar; o site da AS Serviços está concluído, mas não tem endereço público + expect(status("nutriscan")).toBe("Em Produção"); + expect(status("as-servicos")).toBe("Concluído"); + }); + + it("keeps a single LinkedIn profile", () => { + const linkedinUrls = JSON.stringify(portfolio).match( + /https:\/\/www\.linkedin\.com\/in\/[^"]+/g, + ); + expect(new Set(linkedinUrls)).toEqual( + new Set(["https://www.linkedin.com/in/evandrodevbr/"]), + ); + }); + + it("states no limitation about the Reddit Posts Explorer in its public text", () => { + expect(serializeProject("reddit-explorer")).not.toMatch(/nem CI|não tem/i); + }); }); diff --git a/lib/portfolio-data.ts b/lib/portfolio-data.ts index 260a889..b47fb8d 100644 --- a/lib/portfolio-data.ts +++ b/lib/portfolio-data.ts @@ -1,19 +1,12 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; import portfolioData from "@/public/portfolio-data.json"; import { - createProjectHealthSnapshot, type DemoAvailability, getDemoAvailability, - PROJECT_HEALTH_MAX_AGE_MS, type ProjectHealthSnapshot, } from "./project-health"; -export type PortfolioData = typeof portfolioData; +type PortfolioData = typeof portfolioData; export type PortfolioProject = PortfolioData["projects"][number]; -export type PortfolioExperience = PortfolioData["experiences"][number]; -export type PortfolioSkillCategory = - PortfolioData["skills"]["technical"][number]; export type PortfolioProjectView = PortfolioProject & { demoAvailability: DemoAvailability; @@ -36,58 +29,3 @@ export function getFeaturedProjects( }; }); } - -function isSnapshotFresh( - snapshot: ProjectHealthSnapshot | null, - now = new Date(), -) { - if (!snapshot) return false; - const checkedAt = Date.parse(snapshot.checkedAt); - return ( - Number.isFinite(checkedAt) && - now.getTime() - checkedAt <= PROJECT_HEALTH_MAX_AGE_MS - ); -} - -export async function loadProjectHealthSnapshot(): Promise { - try { - const path = resolve(process.cwd(), "data/project-health.json"); - return JSON.parse(await readFile(path, "utf8")) as ProjectHealthSnapshot; - } catch { - return null; - } -} - -export async function loadFeaturedProjects(): Promise { - const snapshot = await loadProjectHealthSnapshot(); - if (process.env.NODE_ENV !== "development" || isSnapshotFresh(snapshot)) { - return getFeaturedProjects(snapshot); - } - - const projectDemos = portfolio.projects.reduce< - Array<{ id: string; demoUrl: string }> - >((demos, project) => { - if (project.demoUrl) - demos.push({ id: project.id, demoUrl: project.demoUrl }); - return demos; - }, []); - - try { - const refreshed = await createProjectHealthSnapshot(projectDemos); - const projects = { ...refreshed.projects }; - - for (const [id, check] of Object.entries(projects)) { - if (!check.error && check.status !== null) continue; - const previous = snapshot?.projects[id]; - if (previous && !previous.error && previous.status !== null) { - projects[id] = previous; - } else { - delete projects[id]; - } - } - - return getFeaturedProjects({ ...refreshed, projects }); - } catch { - return getFeaturedProjects(snapshot); - } -} diff --git a/lib/seo.test.ts b/lib/seo.test.ts index 4f07b75..404ad55 100644 --- a/lib/seo.test.ts +++ b/lib/seo.test.ts @@ -1,11 +1,13 @@ import sitemap from "../app/sitemap"; import { buildBreadcrumbList, + buildPageSocialMetadata, buildPersonJsonLd, buildProfilePageJsonLd, buildServiceJsonLd, buildWebSiteJsonLd, SEO_IDS, + twitterHandle, } from "./seo"; import { caseStudies, servicePages, siteName, siteUrl } from "./site-content"; @@ -61,10 +63,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" }, - sameAs: [ - "https://github.com/evandrodevbr", - "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", - ], + sameAs: ["https://www.linkedin.com/in/evandrodevbr/"], }); expect(JSON.stringify([website, person])).not.toMatch( @@ -121,16 +120,60 @@ it("keeps profile data timeless and sitemap output deterministic", () => { `${siteUrl}/privacidade`, `${siteUrl}/servicos`, `${siteUrl}/cases`, + `${siteUrl}/ia`, ...servicePages.map(({ slug }) => `${siteUrl}/servicos/${slug}`), ...caseStudies.map(({ id }) => `${siteUrl}/cases/${id}`), ]; const first = sitemap(); const second = sitemap(); - expect(first).toEqual(expectedUrls.map((url) => ({ url }))); + expect(first.map(({ url }) => url)).toEqual(expectedUrls); expect(second).toEqual(first); - expect(first).toHaveLength(17); + expect(first).toHaveLength(20); expect( first.filter(({ url }) => url === `${siteUrl}/privacidade`), ).toHaveLength(1); }); + +it("lists the client cases and the AI page, each with a fixed modification date", () => { + const entries = sitemap(); + const urls = entries.map(({ url }) => url); + + expect(urls).toContain(`${siteUrl}/cases/sev-modaintima`); + expect(urls).toContain(`${siteUrl}/cases/lavacao-047`); + expect(urls).toContain(`${siteUrl}/ia`); + for (const entry of entries) { + expect(entry.lastModified).toBeInstanceOf(Date); + expect(Number.isNaN(new Date(entry.lastModified ?? "").getTime())).toBe( + false, + ); + } +}); + +it("gives every page a complete social card, so it never inherits the home one", () => { + const url = `${siteUrl}/cases`; + expect( + buildPageSocialMetadata({ + url, + title: "Título", + description: "Descrição", + type: "website", + }), + ).toEqual({ + alternates: { canonical: url }, + openGraph: { + locale: "pt_BR", + title: "Título", + description: "Descrição", + url, + siteName, + type: "website", + }, + twitter: { + card: "summary_large_image", + title: "Título", + description: "Descrição", + creator: twitterHandle, + }, + }); +}); diff --git a/lib/seo.ts b/lib/seo.ts index 2e232ef..18e73c8 100644 --- a/lib/seo.ts +++ b/lib/seo.ts @@ -1,5 +1,8 @@ +import type { Metadata } from "next"; import { type ServicePageContent, siteName, siteUrl } from "./site-content"; +export const twitterHandle = "@evandrodevbr"; + export const SEO_IDS = { website: `${siteUrl}/#website`, person: `${siteUrl}/#person`, @@ -40,10 +43,7 @@ export function buildPersonJsonLd() { "@type": "Place", name: "Garuva, Santa Catarina, Brasil", }, - sameAs: [ - "https://github.com/evandrodevbr", - "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", - ], + sameAs: ["https://www.linkedin.com/in/evandrodevbr/"], } as const; } @@ -93,3 +93,37 @@ export function buildBreadcrumbList( })), } as const; } + +/** + * Canonical, Open Graph e Twitter de uma página. Quem define `openGraph` ou `twitter` numa página substitui o do layout inteiro, + * então o cartão precisa vir completo daqui; a imagem vem do `opengraph-image` da própria rota. + */ +export function buildPageSocialMetadata({ + url, + title, + description, + type, +}: { + url: string; + title: string; + description: string; + type: "website" | "article" | "profile"; +}): Pick { + return { + alternates: { canonical: url }, + openGraph: { + locale: "pt_BR", + title, + description, + url, + siteName, + type, + }, + twitter: { + card: "summary_large_image", + title, + description, + creator: twitterHandle, + }, + }; +} diff --git a/lib/site-content.test.ts b/lib/site-content.test.ts index f7e93f8..ee49ef7 100644 --- a/lib/site-content.test.ts +++ b/lib/site-content.test.ts @@ -1,98 +1,140 @@ -import { caseStudies, getRelatedServices, servicePages } from "./site-content"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + caseStudies, + clientCases, + getRelatedCases, + getRelatedServices, + isClientCase, + labCases, + servicePages, +} from "./site-content"; -const detailedIds = [ - "as-servicos", - "gcc-installer", - "nutriscan", - "reddit-explorer", - "twins-lab", -] as const; -const immutableGitHubBlobUrl = - /^https:\/\/github\.com\/[^/]+\/[^/]+\/blob\/[0-9a-f]{40}\/.+$/; +const clientIds = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"]; +const labIds = ["nutriscan", "twins-lab", "reddit-explorer", "gcc-installer"]; -function getDetail(id: (typeof detailedIds)[number]) { - const detail = caseStudies.find((item) => item.id === id)?.detail; - expect(detail).toBeDefined(); - if (!detail) throw new Error(`Missing detail for ${id}`); - return detail; +function getService(slug: string) { + const service = servicePages.find((item) => item.slug === slug); + if (!service) throw new Error(`Missing service ${slug}`); + return service; } -it("limits detailed content to the evidenced cases (public repositories pinned to a commit)", () => { +it("separates client work from the laboratory using the kind in the portfolio data", () => { + expect(clientCases.map(({ id }) => id)).toEqual(clientIds); + expect(labCases.map(({ id }) => id)).toEqual(labIds); + expect(caseStudies).toHaveLength(8); expect( - caseStudies - .filter(({ detail }) => detail !== undefined) - .map(({ id }) => id) - .sort(), - ).toEqual([...detailedIds].sort()); + caseStudies.every( + (item) => isClientCase(item) === clientIds.includes(item.id), + ), + ).toBe(true); + expect(isClientCase({ id: "projeto-que-nao-existe" })).toBe(false); }); -it("uses unique source IDs and immutable GitHub commit URLs", () => { - for (const id of detailedIds) { - const { sources } = getDetail(id); - const sourceIds = sources.map(({ id }) => id); - expect(new Set(sourceIds).size).toBe(sourceIds.length); - - for (const { url } of sources) { - expect(url).toMatch(immutableGitHubBlobUrl); - } +it("gives every case the sections of the business structure", () => { + for (const caseStudy of caseStudies) { + expect(caseStudy.headline.length).toBeGreaterThan(10); + expect(caseStudy.context.length).toBeGreaterThan(20); + expect(caseStudy.problem.length).toBeGreaterThan(20); + expect(caseStudy.deliveries.length).toBeGreaterThanOrEqual(3); + expect(caseStudy.stack.length).toBeGreaterThan(0); + expect(caseStudy.decisions.length).toBeGreaterThan(0); + // sem dado real e autorizado, não há seção de resultado + expect(caseStudy.result).toBeUndefined(); } }); -it("requires sources for factual statements and resolves every source ID", () => { - for (const id of detailedIds) { - const detail = getDetail(id); - const sourceIds = new Set(detail.sources.map(({ id }) => id)); - const factualStatements = [ - detail.answer, - detail.challenge, - ...detail.implementation, - ]; - - for (const statement of [...factualStatements, ...detail.boundaries]) { - expect( - statement.sourceIds.every((sourceId) => sourceIds.has(sourceId)), - ).toBe(true); - } - for (const statement of factualStatements) { - expect(statement.sourceIds.length).toBeGreaterThan(0); - } - } - - expect( - getDetail("as-servicos").boundaries.map(({ sourceIds }) => sourceIds), - ).toEqual([[]]); - - for (const id of ["nutriscan", "twins-lab"] as const) { - expect( - getDetail(id).boundaries.every(({ sourceIds }) => sourceIds.length > 0), - ).toBe(true); - } - expect(getDetail("nutriscan").boundaries[0]?.sourceIds).toContain( - "nutri-readme", +it("writes the NutriScan case in business language", () => { + expect(caseStudies.find(({ id }) => id === "nutriscan")?.headline).toBe( + "Consulta de alimentos por nome ou código de barras", ); +}); - // reddit-explorer e gcc-installer: o 1º limite vem da documentação do repositório; o 2º é o escopo editorial (sem fonte) - for (const id of ["reddit-explorer", "gcc-installer"] as const) { - const { boundaries } = getDetail(id); - expect(boundaries[0]?.sourceIds.length).toBeGreaterThan(0); - expect( - boundaries.slice(1).every(({ sourceIds }) => sourceIds.length === 0), - ).toBe(true); +it("has no GitHub link, source list or disqualifying caveat in the public text", () => { + const text = JSON.stringify({ caseStudies, servicePages }); + expect(text).not.toMatch(/github\.com/i); + expect(text).not.toMatch(/\/#contact/); + expect(text).not.toMatch( + /Limites desta evidência|Desafio verificável|Exemplos ilustrativos|não projetos já entregues|Sem promessas|nem CI|não há CI|escopo definido|não comprova|não representa/i, + ); + for (const caseStudy of caseStudies) { + expect(caseStudy).not.toHaveProperty("githubUrl"); + expect(caseStudy).not.toHaveProperty("detail"); } }); +it("never mentions an unavailable demo in the case text", () => { + expect(JSON.stringify(caseStudies)).not.toMatch(/indispon/i); +}); + +it("leaves the commercial offer of every service empty for the owner to fill in", () => { + for (const service of servicePages) { + expect(service.startingPrice).toBeUndefined(); + expect(service.typicalTimeline).toBeUndefined(); + expect(service.guarantee).toBeUndefined(); + } +}); + +it("mentions the region and a benefit in the title and description of every service", () => { + for (const service of servicePages) { + expect(`${service.seoTitle} ${service.metaDescription}`).toMatch( + /Garuva|Joinville|Santa Catarina/, + ); + expect(service.metaDescription).toMatch(/remoto|Brasil/); + } + const descriptions = servicePages.map( + ({ metaDescription }) => metaDescription, + ); + expect(new Set(descriptions).size).toBe(descriptions.length); +}); + +it("maps each service to its client cases", () => { + expect(getService("criacao-de-sites").relatedCaseIds).toEqual(clientIds); + expect(getService("engenharia-de-software").relatedCaseIds).toEqual([ + "sev-modaintima", + "lavacao-047", + ]); + expect(getService("consultoria-nextjs").relatedCaseIds).toEqual([ + "sev-modaintima", + "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([]); + expect(getService("integracao-hardware-software-iot").relatedCaseIds).toEqual( + ["twins-lab"], + ); +}); + +it("puts client cases before laboratory cases when listing related work", () => { + expect( + getRelatedCases(["twins-lab", "lavacao-047", "sev-modaintima"]).map( + ({ id }) => id, + ), + ).toEqual(["lavacao-047", "sev-modaintima", "twins-lab"]); + expect(getRelatedCases(["nao-existe"])).toEqual([]); +}); + it("derives the exact related service set from the existing relationship", () => { + expect(getRelatedServices("twins-lab").map(({ slug }) => slug)).toEqual([ + "integracao-hardware-software-iot", + ]); expect( - getRelatedServices("twins-lab") + getRelatedServices("sev-modaintima") .map(({ slug }) => slug) .sort(), ).toEqual( [ + "criacao-de-sites", + "engenharia-de-software", "consultoria-nextjs", - "integracao-hardware-software-iot", "arquitetura-de-software", ].sort(), ); + expect(getRelatedServices("gcc-installer")).toEqual([]); }); it("resolves every declared related case ID", () => { @@ -112,13 +154,22 @@ it("does not publish the unavailable Twins Lab demo", () => { ).toBeUndefined(); }); -it("keeps AS Serviços as a project (code only) and states the author's participation", () => { +it("keeps AS Serviços without a public site and states the author's participation", () => { const asServicos = caseStudies.find(({ id }) => id === "as-servicos"); expect(asServicos?.demoUrl).toBeNull(); - expect(asServicos?.githubUrl).toBe( - "https://github.com/evandrodevbr/as-servicos", - ); expect(asServicos?.role).toMatch(/primeira landing page/i); expect(asServicos?.role).toMatch(/animações 3D/i); expect(asServicos?.role).toMatch(/painel para controle de projetos/i); }); + +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", + "portfolio-data", + "proof", + "project-health", + ]) { + const source = readFileSync(join(__dirname, `${file}.ts`), "utf8"); + expect(source).not.toMatch(/from "node:/); + } +}); diff --git a/lib/site-content.ts b/lib/site-content.ts index e678477..51cd23c 100644 --- a/lib/site-content.ts +++ b/lib/site-content.ts @@ -1,49 +1,9 @@ +import { portfolio } from "./portfolio-data"; +import { isClientProject } from "./proof"; + export const siteUrl = "https://evandro.dev.br"; export const siteName = "Evandro.dev.br"; -export const intentCtas = { - project: { - label: "Conversar sobre meu projeto", - href: "/#contact", - }, - consulting: { - label: "Conhecer as soluções", - href: "/servicos", - }, -} as const; - -export type CtaIntent = keyof typeof intentCtas; - -export const regionCities = [ - "Garuva", - "Joinville", - "São Francisco do Sul", - "Itapoá", - "Balneário Barra do Sul", - "Barra Velha", - "Jaraguá do Sul", - "Curitiba", -]; - -export interface EvidenceSource { - id: string; - label: string; - url: string; -} - -export interface SourcedStatement { - text: string; - sourceIds: string[]; -} - -export interface CaseStudyDetail { - answer: SourcedStatement; - challenge: SourcedStatement; - implementation: SourcedStatement[]; - boundaries: SourcedStatement[]; - sources: EvidenceSource[]; -} - export interface ServicePageContent { slug: string; navLabel: string; @@ -59,25 +19,42 @@ export interface ServicePageContent { examples: string[]; technicalDetails: string[]; stack: string[]; - ctaIntent: "consulting"; ctaLabel: string; - ctaHref: string; + /** Ids dos cases relacionados; os de cliente aparecem primeiro na página. */ relatedCaseIds: string[]; + /** Oferta: preencha só com valores reais. O bloco "Investimento e prazo" só aparece quando algum campo existe. */ + startingPrice?: string; + typicalTimeline?: string; + guarantee?: string; faq?: { question: string; answer: string }[]; } export interface CaseStudyContent { id: string; + /** Nome do projeto ou do cliente. */ title: string; + /** Tipo do projeto, em poucas palavras. */ subtitle: string; + /** Título da página, em linguagem de negócio. */ + headline: string; + /** Resumo de uma ou duas frases (cards, descrição de busca e abertura da página). */ summary: string; + /** O que muda para quem usa, em uma frase. */ impact: string; + /** Quem é o cliente e em que situação o projeto nasceu. */ + context: string; + /** O problema que o projeto resolve. */ + problem: string; + /** O que foi feito, escrito como benefício. */ + deliveries: string[]; + /** Resultado com dado real e autorizado. Sem dado, o campo fica de fora e a seção não aparece. */ + result?: string[]; + /** Detalhes técnicos: tecnologias e decisões de projeto (ficam recolhidos no fim da página). */ stack: string[]; + decisions: string[]; demoUrl?: string | null; - githubUrl?: string | null; - /** Papel do autor quando o projeto não é só dele (texto editorial, sem fonte pública). */ + /** Papel do autor quando o projeto não é só dele. */ role?: string; - detail?: CaseStudyDetail; } export const servicePages: ServicePageContent[] = [ @@ -85,9 +62,9 @@ export const servicePages: ServicePageContent[] = [ slug: "criacao-de-sites", navLabel: "Sites profissionais", eyebrow: "Presença digital", - seoTitle: "Criação de Sites Profissionais e Institucionais", + seoTitle: "Criação de Sites para Empresas em Garuva e Joinville", metaDescription: - "Sites profissionais para apresentar serviços, produtos e canais de contato. Desenvolvimento responsivo com acessibilidade e SEO técnico, em escopo definido.", + "Site profissional para apresentar sua empresa e levar o cliente ao seu WhatsApp. Desenvolvido em Garuva-SC, com atendimento em Joinville, região e remoto no Brasil.", 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.", @@ -101,7 +78,7 @@ export const servicePages: ServicePageContent[] = [ outcomes: [ "Páginas com uma sequência clara: o que você faz, para quem e como entrar em contato.", "Layout adaptado a celular e computador, com atenção à leitura e à navegação.", - "Publicação e orientação de uso dentro do escopo; domínio, hospedagem, produção de conteúdo e manutenção são combinados na proposta.", + "Publicação do site e orientação de uso. Domínio, hospedagem, produção de conteúdo e manutenção são combinados à parte, na proposta.", ], examples: [ "Um site institucional para uma prestadora de serviços, com apresentação, áreas de atuação e contato.", @@ -117,13 +94,11 @@ export const servicePages: ServicePageContent[] = [ technicalDetails: [ "Implementação com Next.js e TypeScript, escolhendo renderização e carregamento conforme o conteúdo e as integrações.", "HTML semântico, navegação por teclado, imagens dimensionadas e avaliação de carregamento orientam acessibilidade e performance.", - "Metadados, URLs canônicas, sitemap e dados estruturados quando aplicáveis ajudam a interpretação do site pelos buscadores; não garantem posição no Google.", + "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"], - ctaIntent: "consulting", ctaLabel: "Conversar sobre meu site", - ctaHref: "/#contact", - relatedCaseIds: ["sc-plus", "nutriscan"], + relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"], faq: [ { question: "Quanto custa e o que preciso ter pronto?", @@ -133,7 +108,7 @@ export const servicePages: ServicePageContent[] = [ { question: "O site também funciona no celular?", answer: - "Sim, a proposta considera um site responsivo, acessado pelo navegador no celular e no computador. Isso não equivale a um aplicativo instalado; se você precisa de um app, avaliamos esse escopo separadamente.", + "Sim. O site se adapta à tela e abre pelo navegador, no celular e no computador. Se você precisa de um aplicativo instalado no aparelho, conversamos sobre isso em um projeto à parte.", }, { question: "Posso contratar melhorias depois da publicação?", @@ -146,9 +121,9 @@ export const servicePages: ServicePageContent[] = [ slug: "engenharia-de-software", navLabel: "Sistemas web e aplicativos mobile", eyebrow: "Engenharia de software", - seoTitle: "Sistemas Web e Aplicativos Mobile sob Medida", + seoTitle: "Sistemas Web e Aplicativos sob Medida em Santa Catarina", metaDescription: - "Sistemas web e aplicativos mobile sob medida, integração entre ferramentas e evolução de software existente. Escopo, plataformas e entregas definidos por projeto.", + "Sistema web ou aplicativo sob medida para organizar pedidos, clientes e rotinas em um só lugar. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", 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.", @@ -162,7 +137,7 @@ export const servicePages: ServicePageContent[] = [ outcomes: [ "Uma primeira versão com as funções prioritárias, em vez de tentar resolver toda a operação de uma vez.", "Fluxos para consultar, registrar e compartilhar informações, com acessos definidos por perfil.", - "Escopo explícito para web ou mobile: dispositivos, sistemas operacionais, uso sem internet e distribuição são avaliados na proposta, não presumidos.", + "Web ou mobile, conforme o seu caso: dispositivos, sistemas operacionais, uso sem internet e distribuição são combinados na proposta.", ], examples: [ "Um sistema web para acompanhar pedidos e atendimentos em uma única tela.", @@ -178,13 +153,11 @@ export const servicePages: ServicePageContent[] = [ technicalDetails: [ "A arquitetura parte das regras do negócio, dos limites entre módulos e dos contratos de API. Migrações e integrações consideram os dados e o sistema já em uso.", "Autorização por perfil, validação no servidor, tratamento de falhas e testes dos fluxos críticos entram conforme os riscos do projeto.", - "A experiência profissional declarada inclui Web e Mobile. A tecnologia mobile é definida após avaliar dispositivos e recursos necessários; os cases abaixo não comprovam um framework mobile específico.", + "Em aplicativos mobile, a tecnologia é escolhida depois de avaliar os dispositivos e os recursos necessários, como câmera, notificações e uso sem internet.", ], stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"], - ctaIntent: "consulting", ctaLabel: "Conversar sobre meu sistema ou app", - ctaHref: "/#contact", - relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"], + relatedCaseIds: ["sev-modaintima", "lavacao-047"], faq: [ { question: "Preciso saber qual tecnologia usar?", @@ -199,7 +172,7 @@ export const servicePages: ServicePageContent[] = [ { question: "O aplicativo inclui publicação em lojas?", answer: - "A forma de distribuição precisa ser avaliada no projeto. Plataformas, contas, requisitos de terceiros e eventual publicação são definidos antes da contratação; não fazem parte de uma promessa genérica de entrega.", + "A forma de distribuição é definida no projeto. Plataformas, contas de desenvolvedor, requisitos das lojas e a publicação são combinados antes da contratação.", }, ], }, @@ -207,9 +180,9 @@ export const servicePages: ServicePageContent[] = [ slug: "consultoria-nextjs", navLabel: "Melhorias em sites e sistemas Next.js", eyebrow: "Consultoria Next.js", - seoTitle: "Consultoria Next.js para Sites e Sistemas", + seoTitle: "Consultoria Next.js para Melhorar Seu Site ou Sistema", metaDescription: - "Diagnóstico e melhorias em sites e sistemas Next.js: carregamento, navegação, SEO técnico e manutenção. Prioridades explicadas e implementação em escopo definido.", + "Descubra por que seu site ou sistema Next.js está lento ou com falhas e receba um plano de correção claro. Atendimento a partir de Garuva-SC, em Joinville e remoto no Brasil.", 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.", @@ -223,7 +196,7 @@ export const servicePages: ServicePageContent[] = [ outcomes: [ "Diagnóstico com problemas observados, impacto para o usuário e prioridades.", "Plano de correção que distingue ajustes pontuais de mudanças maiores.", - "Implementação ou acompanhamento do time, conforme a contratação; metas de performance dependem da medição e do contexto, sem promessa de ranking.", + "Implementação das correções ou acompanhamento do seu time, conforme a contratação. As metas de desempenho partem de medições do seu site.", ], examples: [ "Investigar por que uma página demora a abrir no celular.", @@ -242,10 +215,8 @@ export const servicePages: ServicePageContent[] = [ "Auditoria de metadados, canônicas, sitemap, dados estruturados e links internos, preservando URLs e requisitos de indexação nas mudanças.", ], stack: ["Next.js", "React", "TypeScript", "Tailwind", "SEO técnico"], - ctaIntent: "consulting", ctaLabel: "Conversar sobre meu projeto Next.js", - ctaHref: "/#contact", - relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"], + relatedCaseIds: ["sev-modaintima", "lavacao-047", "sc-plus"], faq: [ { question: "Não sei se meu site usa Next.js. Posso conversar?", @@ -255,7 +226,7 @@ export const servicePages: ServicePageContent[] = [ { question: "A consultoria inclui corrigir o código?", answer: - "Pode incluir. Um diagnóstico pontual, a implementação das correções e o acompanhamento de um time são formatos diferentes. A proposta deixa claro quais deles entram e como a entrega será avaliada.", + "Pode incluir. Um diagnóstico pontual, a implementação das correções e o acompanhamento de um time são formatos diferentes. A proposta informa quais deles entram e como a entrega será avaliada.", }, { question: "Precisa substituir o site inteiro?", @@ -268,12 +239,12 @@ export const servicePages: ServicePageContent[] = [ slug: "tauri-rust", navLabel: "Aplicativos desktop para Windows e Linux", eyebrow: "Aplicativos desktop", - seoTitle: "Aplicativos Desktop com Tauri e Rust", + seoTitle: "Aplicativos Desktop para Windows e Linux com Tauri e Rust", metaDescription: - "Desenvolvimento de aplicativos desktop para Windows e Linux com Tauri e Rust. Ferramentas locais, arquivos e integrações com compatibilidade definida por projeto.", + "Aplicativo desktop para Windows e Linux que automatiza tarefas no computador da sua empresa. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", 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 plataformas oferecidas; as versões, distribuições e funções compatíveis são definidas e validadas no escopo de cada projeto.", + "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.", intro: "Crie uma ferramenta para Windows ou Linux: organize arquivos, execute rotinas locais ou conecte o computador aos sistemas da empresa.", audience: [ @@ -283,8 +254,8 @@ export const servicePages: ServicePageContent[] = [ ], outcomes: [ "Uma interface para executar as tarefas locais prioritárias e entender o resultado de cada operação.", - "Compatibilidade acordada para Windows e/ou Linux, incluindo versões, distribuições e dependências necessárias.", - "Instalação, atualizações, permissões e uso sem internet tratados no escopo; suportar uma plataforma não significa compatibilidade automática com todas as outras.", + "Compatibilidade combinada para Windows e/ou Linux, incluindo versões, distribuições e dependências necessárias.", + "Instalação, atualizações, permissões e uso sem internet combinados no projeto. Cada plataforma é testada separadamente.", ], examples: [ "Um aplicativo para conferir e organizar arquivos antes de enviá-los a outro sistema.", @@ -299,35 +270,32 @@ export const servicePages: ServicePageContent[] = [ ], technicalDetails: [ "Tauri conecta uma interface web a comandos nativos em Rust. Permissões e acesso a arquivos devem ser limitados ao necessário, com validação na fronteira entre interface e sistema.", - "Empacotamento e dependências do WebView variam por sistema. Integrações nativas precisam ser verificadas por alvo; o mesmo código não elimina trabalho específico de Windows ou Linux.", - "O case GCC Installer abaixo é uma referência de automação desktop em Python/Tkinter e Win32 API no Windows. Não é uma implementação em Tauri/Rust nem comprovação de implantação em Linux.", + "Empacotamento e dependências variam por sistema, então cada integração nativa é testada no Windows e no Linux.", ], stack: [ "Tauri", "Rust", "TypeScript", - "Desktop Apps", - "System Integration", + "Aplicativos desktop", + "Integração com o sistema", ], - ctaIntent: "consulting", ctaLabel: "Conversar sobre meu aplicativo desktop", - ctaHref: "/#contact", - relatedCaseIds: ["gcc-installer"], + relatedCaseIds: [], faq: [ { question: "O mesmo aplicativo pode funcionar em Windows e Linux?", answer: - "Pode ser projetado para os dois, mas isso precisa fazer parte do escopo. Versões, distribuições Linux, recursos locais e dependências são definidos e testados separadamente. Não presumo que uma entrega para Windows já esteja pronta para Linux.", + "Sim, desde que os dois entrem no projeto. Versões do Windows, distribuições Linux, recursos locais e dependências são definidos e testados separadamente.", }, { question: "O aplicativo funciona sem internet?", answer: - "Funções locais podem ser projetadas para isso. Login remoto, sincronização e serviços externos podem continuar dependendo de conexão. Identificamos cada necessidade antes de prometer operação sem internet.", + "As funções locais podem funcionar sem internet. Login remoto, sincronização e serviços externos continuam precisando de conexão. Vemos cada caso na conversa inicial.", }, { - question: "Você já tem um case desktop público?", + question: "Como o aplicativo é instalado e atualizado?", answer: - "Sim: GCC Installer, uma ferramenta Windows feita em Python, Tkinter e Win32 API. Ela demonstra trabalho com rotinas locais e instalação, não uma entrega anterior em Tauri, Rust ou Linux.", + "A forma de instalação e de atualização é combinada no projeto, conforme o ambiente de uso: Windows, Linux ou os dois.", }, ], }, @@ -335,12 +303,12 @@ export const servicePages: ServicePageContent[] = [ slug: "integracao-hardware-software-iot", navLabel: "Integração de equipamentos e sistemas", eyebrow: "Hardware, software e IoT", - seoTitle: "Integração de Equipamentos, Sistemas e IoT", + seoTitle: "Integração de Equipamentos e Sistemas (IoT) em Santa Catarina", metaDescription: - "Integração entre equipamentos e software para consultar dados e acompanhar operações. Viabilidade, interfaces e tratamento de falhas definidos por projeto.", + "Leve os dados dos seus equipamentos para um painel e acompanhe a operação sem anotações manuais. Desenvolvedor em Garuva-SC, com atendimento em Joinville e remoto no Brasil.", 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 avaliamos o que cada dispositivo permite acessar; a integração depende dessas interfaces e dos requisitos de segurança e funcionamento.", + "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.", intro: "Leve informações de equipamentos para um sistema ou painel. Acompanhe leituras e eventos sem depender apenas de registros manuais.", audience: [ @@ -351,7 +319,7 @@ export const servicePages: ServicePageContent[] = [ outcomes: [ "Um caminho definido entre a informação do equipamento e a tela de quem precisa usá-la.", "Tratamento combinado para falta de conexão, leituras inválidas e outras falhas relevantes.", - "Escopo condicionado a protocolos, documentação e acesso ao equipamento; compra de hardware, instalação física e certificações não são presumidas.", + "O trabalho parte dos protocolos, da documentação e do acesso ao equipamento. Compra de hardware, instalação física e certificações são tratadas à parte.", ], examples: [ "Um painel para consultar leituras de sensores compatíveis.", @@ -367,24 +335,16 @@ export const servicePages: ServicePageContent[] = [ technicalDetails: [ "A descoberta identifica APIs, arquivos, portas seriais ou protocolos expostos pelo equipamento; a escolha depende da interface realmente disponível.", "Contratos de dados, validação, persistência, reconexão e rastreamento de falhas organizam o fluxo entre dispositivo, backend e painel. WebSockets entram apenas quando há necessidade de atualização contínua.", - "Twins Lab é um simulador/protótipo documentado. Ele ilustra uma interface para processo físico, mas não comprova integração com equipamento real, WebSockets ou resultado industrial.", + "O Twins Lab, protótipo de laboratório, simula um reator e envia o estado dele a um painel a cada dois segundos, por eventos do servidor.", ], - stack: [ - "IoT", - "WebSockets", - "APIs", - "Dashboards", - "Integração de sistemas", - ], - ctaIntent: "consulting", + stack: ["IoT", "WebSockets", "APIs", "Painéis", "Integração de sistemas"], ctaLabel: "Avaliar minha integração", - ctaHref: "/#contact", relatedCaseIds: ["twins-lab"], faq: [ { question: "Qualquer equipamento pode ser conectado?", answer: - "Não é possível garantir sem avaliar. Precisamos saber quais dados e interfaces ele disponibiliza, se há documentação e quais restrições existem no ambiente. Essa avaliação pode concluir que a integração não é viável no escopo pretendido.", + "Depende do equipamento. Precisamos saber quais dados e interfaces ele disponibiliza, se há documentação e quais restrições existem no ambiente. Fazemos essa avaliação antes de fechar o projeto.", }, { question: "É necessário trocar os sistemas existentes?", @@ -394,7 +354,7 @@ export const servicePages: ServicePageContent[] = [ { question: "É possível começar pequeno?", answer: - "Sim. Um equipamento, uma leitura ou um fluxo pode servir para avaliar viabilidade antes de ampliar o projeto. Os critérios dessa avaliação são combinados para não confundir um protótipo com uma solução pronta para toda a operação.", + "Sim. Um equipamento, uma leitura ou um fluxo já permite validar a ideia antes de ampliar o projeto. Os critérios dessa primeira etapa são combinados antes de começar.", }, ], }, @@ -402,9 +362,10 @@ export const servicePages: ServicePageContent[] = [ slug: "arquitetura-de-software", navLabel: "Diagnóstico e arquitetura de software", eyebrow: "Diagnóstico e decisão", - seoTitle: "Diagnóstico e Arquitetura de Software", + seoTitle: + "Diagnóstico e Arquitetura de Software para Decidir com Segurança", metaDescription: - "Diagnóstico de software para entender falhas, organizar prioridades e planejar evolução. Arquitetura com riscos, alternativas e plano de mudança explicado.", + "Entenda por que seu sistema ficou difícil de mudar e decida o que corrigir, manter ou trocar. Diagnóstico com plano de ação, de Garuva-SC para Joinville e todo o Brasil.", 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.", @@ -434,19 +395,17 @@ export const servicePages: ServicePageContent[] = [ technicalDetails: [ "Análise de dependências, fronteiras entre módulos, contratos de API, fluxos de dados e pontos de falha para localizar acoplamento e riscos.", "A arquitetura proposta considera carga, consistência de dados, operação, segurança e capacidade real do time, evitando distribuir um sistema sem necessidade.", - "Migrações são divididas em etapas verificáveis, com estratégia de transição e reversão quando aplicável. As decisões registram os trade-offs, não apenas um diagrama final.", + "Migrações são divididas em etapas verificáveis, com estratégia de transição e reversão quando aplicável. As decisões ficam registradas com seus prós e contras, não apenas em um diagrama final.", ], stack: [ "Arquitetura de software", "APIs", - "Performance", + "Desempenho", "Integrações", "Sistemas críticos", ], - ctaIntent: "consulting", ctaLabel: "Conversar sobre um diagnóstico", - ctaHref: "/#contact", - relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"], + relatedCaseIds: ["sev-modaintima"], faq: [ { question: "Preciso ter uma equipe técnica para contratar?", @@ -468,368 +427,260 @@ export const servicePages: ServicePageContent[] = [ ]; export const caseStudies: CaseStudyContent[] = [ + { + id: "sev-modaintima", + title: "SEV Moda Íntima", + subtitle: "Catálogo digital e loja virtual", + headline: "Loja virtual de moda íntima com pedido direto pelo WhatsApp", + summary: + "Catálogo digital e loja virtual de moda íntima. A cliente encontra o produto, escolhe o tamanho e envia o pedido pelo WhatsApp. A loja cuida de tudo em um painel próprio.", + impact: + "A cliente encontra o produto e envia o pedido pelo WhatsApp; a loja controla produtos, estoque e pedidos em um painel.", + context: + "A SEV Moda Íntima vende moda íntima pela internet. O site funciona como vitrine e loja virtual: apresenta os produtos, recebe os pedidos pelo WhatsApp e dá à loja um painel para cuidar do catálogo.", + problem: + "Uma loja de moda íntima tem muitos produtos, em vários modelos e tamanhos. A cliente precisa achar o que procura com facilidade e fechar o pedido sem complicação. A loja precisa manter catálogo, estoque e pedidos organizados.", + 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.", + "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.", + "Estoque controlado por tamanho, para cada medida ter a sua quantidade.", + ], + stack: [ + "Next.js 16", + "TypeScript", + "PostgreSQL", + "Prisma", + "Tailwind CSS 4", + "Framer Motion", + ], + decisions: [ + "A busca usa ranking de relevância e vários filtros combinados.", + "O cálculo de frete usa geocodificação híbrida para chegar à distância entre loja e cliente.", + "A vitrine tem modo escuro e animações com Framer Motion.", + "A área administrativa cobre produtos, estoque e pedidos.", + ], + demoUrl: "https://sevmodaintima.com.br", + }, + { + id: "lavacao-047", + title: "Lavação 047", + subtitle: "Site e painel para lavação automotiva", + headline: + "Site com tabela de preços e pedido pelo WhatsApp para uma lavação em Garuva", + summary: + "Site da Lavação 047, em Garuva-SC. O cliente vê o preço pelo porte do veículo e pede o serviço pelo WhatsApp com a mensagem já pronta. O dono edita preços, textos e fotos sozinho.", + impact: + "O cliente descobre o preço e pede pelo WhatsApp; o dono atualiza o site sem depender de um desenvolvedor.", + context: + "A Lavação 047 é uma lavação automotiva em Garuva, Santa Catarina. O site apresenta os serviços, a tabela de preços e a localização, e leva o cliente direto para o WhatsApp.", + problem: + "Quem procura uma lavação quer saber quanto custa para o seu veículo e como chegar. O dono, por sua vez, precisa manter preços, textos e fotos em dia sem depender de alguém a cada mudança.", + deliveries: [ + "Tabela de preços por porte do veículo: o cliente vê quanto paga pelo seu carro.", + "Pedido pelo WhatsApp com mensagem pronta: um toque abre a conversa já com o serviço escolhido.", + "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.", + ], + stack: [ + "Next.js 16", + "React 19", + "TypeScript", + "Tailwind CSS 4", + "Motion", + "Upstash Redis", + "Vercel Blob", + ], + decisions: [ + "O conteúdo salvo no painel alimenta a página, os dados estruturados, o sitemap e o cartão de compartilhamento.", + "Conteúdo e imagens ficam guardados em Redis e em armazenamento privado, com arquivo local como alternativa.", + "A animação respeita a preferência do sistema por menos movimento.", + "O mapa de localização usa OpenStreetMap.", + ], + demoUrl: "https://landing-page-lavacao047.vercel.app", + }, { id: "nutriscan", title: "NutriScan", subtitle: "Consulta de produtos por nome e código de barras", + headline: "Consulta de alimentos por nome ou código de barras", summary: - "Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando cache JSON com a API Open Food Facts.", + "Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando um cache próprio com a base pública Open Food Facts.", impact: - "Reduzir a dependência da API externa e manter um caminho explícito para buscas e consulta por código.", + "A consulta encontra o produto por nome ou código e só vai à base externa quando precisa.", + context: + "Projeto próprio, criado para estudar a integração com uma base pública de alimentos. Está no ar para quem quiser testar.", + problem: + "As informações dos produtos vêm de uma base pública externa. O desafio era não depender só dela a cada consulta e deixar claros dois caminhos: buscar pelo nome e consultar pelo código de barras.", + deliveries: [ + "Busca por nome ou por código de barras.", + "As consultas passam primeiro pelo cache próprio e só vão à base externa quando faltam resultados, o que reduz a dependência dela.", + "O código de barras é validado (de 8 a 13 dígitos) antes da consulta, para evitar buscas inválidas.", + "Se a base externa demora mais de oito segundos, a consulta é cancelada e a pessoa não fica esperando.", + ], stack: ["Next.js", "TypeScript", "Cache JSON", "Open Food Facts"], + decisions: [ + "A busca consulta o cache primeiro, tem um modo somente-cache e recorre ao Open Food Facts quando faltam resultados.", + "A consulta por código remove caracteres não numéricos, aceita de 8 a 13 dígitos, consulta o cache e usa a base externa se não achar.", + "A camada de acesso à base externa cancela requisições após oito segundos e separa a busca por nome da busca por código.", + ], demoUrl: "https://nutriscan.evandro.dev.br", - githubUrl: "https://github.com/evandrodevbr/nutriscan", - detail: { - answer: { - text: "Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando cache JSON com a API Open Food Facts.", - sourceIds: ["nutri-search", "nutri-barcode", "nutri-wrapper"], - }, - challenge: { - text: "Reduzir a dependência da API externa e manter um caminho explícito para buscas e consulta por código.", - sourceIds: ["nutri-search", "nutri-barcode"], - }, - implementation: [ - { - text: "A busca consulta o cache primeiro, permite modo somente-cache e recorre ao Open Food Facts quando faltam resultados.", - sourceIds: ["nutri-search"], - }, - { - text: "A consulta por código remove caracteres não numéricos, aceita de 8 a 13 dígitos, consulta cache e usa a API externa como fallback.", - sourceIds: ["nutri-barcode"], - }, - { - text: "O wrapper cancela requisições após oito segundos e diferencia busca por nome de busca por código.", - sourceIds: ["nutri-wrapper"], - }, - ], - boundaries: [ - { - text: "A página não afirma PWA, MongoDB, histórico de usuários, disponibilidade, volume de produtos ou resultado de saúde, porque as fontes atuais não sustentam esse conjunto de forma consistente.", - sourceIds: [ - "nutri-search", - "nutri-barcode", - "nutri-wrapper", - "nutri-package", - "nutri-prisma", - "nutri-readme", - ], - }, - ], - sources: [ - { - id: "nutri-search", - label: "Rota de busca", - url: "https://github.com/evandrodevbr/nutriscan/blob/ef0e6d4eca1f8588bb6981c1cfa58e4b7db1302b/app/api/search/route.ts", - }, - { - id: "nutri-barcode", - label: "Rota de consulta por código", - url: "https://github.com/evandrodevbr/nutriscan/blob/ef0e6d4eca1f8588bb6981c1cfa58e4b7db1302b/app/api/products/%5Bbarcode%5D/route.ts", - }, - { - id: "nutri-wrapper", - label: "Wrapper Open Food Facts", - url: "https://github.com/evandrodevbr/nutriscan/blob/ef0e6d4eca1f8588bb6981c1cfa58e4b7db1302b/lib/openFoodFactsApi.ts", - }, - { - id: "nutri-package", - label: "Manifesto do projeto", - url: "https://github.com/evandrodevbr/nutriscan/blob/ef0e6d4eca1f8588bb6981c1cfa58e4b7db1302b/package.json", - }, - { - id: "nutri-prisma", - label: "Schema Prisma", - url: "https://github.com/evandrodevbr/nutriscan/blob/ef0e6d4eca1f8588bb6981c1cfa58e4b7db1302b/prisma/schema.prisma", - }, - { - id: "nutri-readme", - label: "README do projeto", - url: "https://github.com/evandrodevbr/nutriscan/blob/ef0e6d4eca1f8588bb6981c1cfa58e4b7db1302b/README.md", - }, - ], - }, }, { id: "twins-lab", title: "Twins Lab", - subtitle: "Protótipo de gêmeo digital industrial com SSE", + subtitle: "Protótipo de laboratório de gêmeo digital industrial", + headline: + "Painel ao vivo para acompanhar a simulação de um reator industrial", summary: - "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e streaming de eventos do servidor.", + "Protótipo de gêmeo digital industrial: um simulador de reator envia seu estado a um painel com acesso por login, atualizado a cada dois segundos.", impact: - "Expor estados simulados do reator a uma interface autenticada e manter histórico/status do simulador.", + "O estado do reator simulado chega ao painel sozinho, só para quem tem acesso, e o histórico fica guardado.", + context: + "Projeto próprio, um protótipo de laboratório para explorar como os dados de um processo físico chegam a uma tela. O reator é simulado.", + problem: + "Como levar o estado de um processo físico para uma tela, mostrar só a quem tem acesso e manter o histórico do que aconteceu?", + deliveries: [ + "Acesso por login: só quem está autenticado abre o painel.", + "O estado do reator chega à tela sozinho, a cada dois segundos, sem recarregar a página.", + "Dados e estado do simulador ficam guardados no banco, o que mantém o histórico.", + "Quando a execução ou o cenário muda, todos os painéis abertos são avisados.", + ], stack: ["Next.js", "TypeScript", "NextAuth", "MongoDB", "SSE"], - githubUrl: "https://github.com/evandrodevbr/twins-lab", - detail: { - answer: { - text: "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e streaming de eventos do servidor.", - sourceIds: ["twins-package", "twins-stream", "twins-simulator"], - }, - challenge: { - text: "Expor estados simulados do reator a uma interface autenticada e manter histórico/status do simulador.", - sourceIds: ["twins-stream", "twins-simulator"], - }, - implementation: [ - { - text: "A rota exige sessão do NextAuth antes de abrir o stream.", - sourceIds: ["twins-stream"], - }, - { - text: "A resposta usa `text/event-stream`, envia estados/dados/erros em intervalos de dois segundos e limita a conexão a dez minutos.", - sourceIds: ["twins-stream"], - }, - { - text: "O simulador salva dados e estado no MongoDB e notifica clientes quando execução ou cenário mudam.", - sourceIds: ["twins-simulator"], - }, - ], - boundaries: [ - { - text: "O case não usa as alegações de economia, ROI, precisão, manutenção preditiva futura, equipamento real, WebSocket, OpenRouter ou atualização sub-segundo.", - sourceIds: [ - "twins-package", - "twins-stream", - "twins-simulator", - "twins-readme", - ], - }, - ], - sources: [ - { - id: "twins-package", - label: "Manifesto do projeto", - url: "https://github.com/evandrodevbr/twins-lab/blob/f83f99a7417f16ece0cd7fe4832ba122e7152d0c/package.json", - }, - { - id: "twins-stream", - label: "Rota de streaming do reator", - url: "https://github.com/evandrodevbr/twins-lab/blob/f83f99a7417f16ece0cd7fe4832ba122e7152d0c/app/api/reactor/stream/route.ts", - }, - { - id: "twins-simulator", - label: "Simulador do reator", - url: "https://github.com/evandrodevbr/twins-lab/blob/f83f99a7417f16ece0cd7fe4832ba122e7152d0c/simulators/reactor/reactor-simulator.ts", - }, - { - id: "twins-readme", - label: "README do projeto", - url: "https://github.com/evandrodevbr/twins-lab/blob/f83f99a7417f16ece0cd7fe4832ba122e7152d0c/README.md", - }, - ], - }, + decisions: [ + "A rota exige sessão do NextAuth antes de abrir a transmissão de eventos.", + "A resposta usa text/event-stream (SSE), envia estados, dados e erros em intervalos de dois segundos e limita a conexão a dez minutos.", + "O simulador salva dados e estado no MongoDB e notifica os clientes quando a execução ou o cenário mudam.", + ], }, { id: "reddit-explorer", title: "Reddit Posts Explorer", subtitle: "Posts do r/artificial em MongoDB, filtrados por período", + headline: + "Posts de um fórum de inteligência artificial, coletados e filtrados por período", summary: - "Aplicação Astro SSR que coleta os posts em alta do r/artificial, grava no MongoDB sem duplicar e filtra por período, upvotes ou comentários.", + "Aplicação que coleta os posts em alta do r/artificial, guarda tudo no MongoDB sem duplicar e permite filtrar por período e ordenar por votos ou comentários.", impact: - "Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.", - stack: ["Astro", "MongoDB", "Mongoose", "TypeScript", "Reddit API"], - githubUrl: "https://github.com/evandrodevbr/reddit-search", - detail: { - answer: { - text: "Aplicação Astro em modo SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.", - sourceIds: ["rd-readme", "rd-service", "rd-astro"], - }, - challenge: { - text: "Atualizar a coleta sem duplicar posts e consultar o histórico por intervalo de datas e ordenação.", - sourceIds: ["rd-service", "rd-filter"], - }, - implementation: [ - { - text: "O serviço consulta r/artificial/hot.json com User-Agent identificado e limite de 15 segundos, e grava os posts em lote com upsert pelo id; rodar a coleta de novo atualiza os existentes.", - sourceIds: ["rd-service"], - }, - { - text: "O endpoint de filtro valida as datas e o campo de ordenação (ups ou num_comments) e responde 400 quando faltam parâmetros ou eles são inválidos.", - sourceIds: ["rd-filter"], - }, - { - text: "O Astro roda com output server e o adapter Node em modo standalone.", - sourceIds: ["rd-astro", "rd-package"], - }, - ], - boundaries: [ - { - text: "O subreddit é fixo, e o projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.", - sourceIds: ["rd-readme"], - }, - { - text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, volume de dados ou resultado comercial.", - sourceIds: [], - }, - ], - sources: [ - { - id: "rd-readme", - label: "README do projeto", - url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/README.md", - }, - { - id: "rd-service", - label: "Serviço de coleta e consulta", - url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/lib/services/redditService.ts", - }, - { - id: "rd-filter", - label: "Endpoint de filtro", - url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/src/pages/api/posts/filter.ts", - }, - { - id: "rd-astro", - label: "Configuração do Astro", - url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/astro.config.mjs", - }, - { - id: "rd-package", - label: "Manifesto do projeto", - url: "https://github.com/evandrodevbr/reddit-search/blob/ff76e08379a51fc02c3b8e4d6c415d627d8c90de/package.json", - }, - ], - }, + "Rodar a coleta de novo atualiza os posts que já existem, sem duplicar, e o histórico pode ser consultado por datas.", + context: + "Projeto próprio, feito para estudar a coleta de dados de uma API pública e a consulta por período.", + problem: + "Os posts em alta mudam o tempo todo. O desafio era coletar de novo sem duplicar o que já estava guardado e consultar o histórico por datas e por popularidade.", + deliveries: [ + "Coleta dos posts em alta do r/artificial pela API pública do Reddit.", + "Cada post é guardado uma vez só: coletar de novo atualiza o que já existe em vez de duplicar.", + "Filtro por período e ordenação por votos ou comentários.", + "A consulta confere as datas e a ordenação e avisa quando os parâmetros estão errados.", + ], + stack: ["Astro", "MongoDB", "Mongoose", "TypeScript", "API do Reddit"], + decisions: [ + "O serviço consulta r/artificial/hot.json com identificação própria e limite de 15 segundos, e grava os posts em lote com atualização pelo id.", + "O endpoint de filtro valida as datas e o campo de ordenação (votos ou comentários) e responde com erro 400 quando faltam parâmetros ou eles são inválidos.", + "O Astro roda com saída de servidor e o adaptador Node em modo standalone.", + ], }, { id: "sc-plus", title: "SC Plus Aquicultura", - subtitle: "Landing page institucional em Next.js 15", + subtitle: "Página institucional", + headline: + "Página institucional para a SC Plus Aquicultura ser encontrada na internet", summary: - "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.", + "Página institucional da SC Plus Aquicultura, com apresentação da instituição, áreas e projetos, apoiadores e contato, preparada para os buscadores e para o compartilhamento.", impact: - "Dar à instituição uma página própria, com base técnica para ser encontrada em buscadores.", + "A instituição ganhou uma página própria, preparada para ser encontrada nos buscadores.", + context: + "A SC Plus Aquicultura é uma instituição do setor de aquicultura. Ela precisava de uma página própria para se apresentar, mostrar suas áreas de atuação e seus projetos e receber contato.", + problem: + "Sem uma página própria, quem procura a SC Plus na internet não encontra uma apresentação clara do que a instituição faz. Faltava um endereço próprio, com as áreas, os projetos, os apoiadores e o contato.", + 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.", + "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.", + ], stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"], + decisions: [ + "A página publica título, descrição, endereço canônico e imagem de compartilhamento.", + "Os dados estruturados descrevem a instituição para os buscadores.", + ], demoUrl: "https://scplus.evandro.dev.br", - githubUrl: "https://github.com/evandrodevbr/scplus", }, { id: "gcc-installer", title: "MinGW GCC Installer", subtitle: "Instalador gráfico de MinGW-w64/GCC para Windows", + headline: "Instalador do compilador GCC para Windows em uma única janela", summary: - "Aplicativo Python com interface tkinter que lista as builds do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e adiciona o GCC ao PATH do usuário.", + "Aplicativo para Windows que lista as versões do MinGW-w64, baixa a escolhida, instala em C:\\mingw64 e deixa o GCC pronto para uso, tudo em uma janela.", impact: - "Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.", + "Escolher a versão, baixar, extrair e ajustar o PATH deixam de ser passos manuais: tudo acontece em uma janela.", + context: + "Projeto próprio, criado para resolver uma tarefa repetitiva de quem programa no Windows: instalar o compilador GCC.", + problem: + "Instalar o GCC no Windows exige vários passos manuais: escolher a versão, baixar, extrair os arquivos e configurar o PATH do sistema.", + deliveries: [ + "Lista as versões publicadas do MinGW-w64 e destaca as mais usadas (ucrt e posix).", + "Baixa o pacote escolhido e instala na pasta C:\\mingw64.", + "Configura o PATH do usuário e avisa o Windows da mudança, para o GCC funcionar sem ajustes manuais.", + "Deixa o comando make disponível, renomeando o mingw32-make.exe.", + ], stack: ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"], - githubUrl: "https://github.com/evandrodevbr/gcc-installer", - detail: { - answer: { - text: "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.", - sourceIds: ["gcc-readme", "gcc-main"], - }, - challenge: { - text: "Reduzir a configuração manual do GCC no Windows (escolher a build, baixar, extrair e ajustar o PATH) a uma única janela.", - sourceIds: ["gcc-readme"], - }, - implementation: [ - { - text: "A lista de versões vem da API de releases do repositório niXman/mingw-builds-binaries, e as builds com ucrt e thread model posix são destacadas na tabela.", - sourceIds: ["gcc-main"], - }, - { - text: "A instalação extrai o pacote com o 7-Zip (caminho padrão fixo) em uma pasta temporária, move o resultado para C:\\mingw64 e renomeia mingw32-make.exe para make.exe.", - sourceIds: ["gcc-main"], - }, - { - text: "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE; fora do Windows o programa encerra com um aviso.", - sourceIds: ["gcc-main"], - }, - ], - boundaries: [ - { - text: "Funciona só no Windows, o caminho do 7-Zip é fixo e o fluxo da interface não foi exercitado de ponta a ponta no Windows nesta revisão; os 23 testes cobrem a lógica independente de plataforma, e não há CI.", - sourceIds: ["gcc-readme", "gcc-tests"], - }, - { - text: "O case demonstra implementação e decisões técnicas; não atribui ganho de performance, número de usuários ou resultado comercial.", - sourceIds: [], - }, - ], - sources: [ - { - id: "gcc-readme", - label: "README do projeto", - url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/README.md", - }, - { - id: "gcc-main", - label: "Aplicativo (main.py)", - url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/main.py", - }, - { - id: "gcc-tests", - label: "Testes do núcleo", - url: "https://github.com/evandrodevbr/gcc-installer/blob/bab64fc5b5c20128e7b06e1afc448c5efe71b747/tests/test_core.py", - }, - ], - }, + decisions: [ + "A lista de versões vem da API de releases do repositório niXman/mingw-builds-binaries, e as versões com ucrt e posix aparecem em destaque na tabela.", + "A instalação extrai o pacote com o 7-Zip (no caminho padrão) em uma pasta temporária e move o resultado para C:\\mingw64.", + "Adicionar ao PATH grava em HKCU\\Environment e avisa o Windows com WM_SETTINGCHANGE.", + "Os 23 testes automatizados cobrem a lógica do núcleo, que não depende da plataforma.", + ], }, { id: "as-servicos", title: "AS Serviços", - subtitle: "Site institucional com cena 3D procedural", + subtitle: "Site institucional com cena 3D", + headline: "Site institucional com uma cena 3D que acompanha a rolagem", summary: - "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.", + "Site institucional da AS Serviços com uma cena 3D criada em código: uma casa e uma placa-mãe desenhadas em linhas que reagem à rolagem da página.", impact: - "Representar engenharia física e software na mesma narrativa visual sem depender de um modelo 3D pronto.", + "Engenharia física e software contados na mesma história visual, sem depender de um modelo 3D pronto.", + context: + "A AS Serviços reúne engenharia física e software. O site precisava mostrar as duas frentes na mesma história.", + problem: + "Como contar, em uma única página, duas frentes tão diferentes quanto a engenharia física e o software, de um jeito que prenda a atenção de quem rola a página?", + 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.", + "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.", + ], stack: ["Next.js 16", "Three.js", "TypeScript"], + decisions: [ + "A cena é gerada em código, sem arquivo 3D pronto: o motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js.", + "O canvas converte a rolagem em um progresso de 0 a 1 e o entrega ao motor da cena.", + "A qualidade tem quatro níveis (estático, baixo, médio e alto), escolhidos conforme o WebGL, a preferência por menos movimento, a economia de dados, a conexão e a capacidade do dispositivo.", + ], demoUrl: null, - githubUrl: "https://github.com/evandrodevbr/as-servicos", role: "Desenvolvimento da primeira landing page do site, com animações 3D, e de um painel para controle de projetos.", - detail: { - answer: { - text: "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.", - sourceIds: ["as-readme", "as-package", "as-engine"], - }, - challenge: { - text: "Representar engenharia física e software na mesma narrativa visual sem depender de um modelo 3D pronto.", - sourceIds: ["as-readme", "as-engine"], - }, - implementation: [ - { - text: "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js.", - sourceIds: ["as-engine"], - }, - { - text: "O canvas converte a rolagem em progresso de 0 a 1 e o entrega ao motor da cena.", - sourceIds: ["as-canvas"], - }, - { - text: "A qualidade pode cair para `static`, `low`, `medium` ou `high` conforme WebGL, reduced motion, economia de dados, conexão e capacidade do dispositivo.", - sourceIds: ["as-engine"], - }, - ], - boundaries: [ - { - text: "O case demonstra implementação e decisões técnicas; não atribui geração de leads, ranking ou ganho de performance.", - sourceIds: [], - }, - ], - sources: [ - { - id: "as-readme", - label: "README do projeto", - url: "https://github.com/evandrodevbr/as-servicos/blob/abeb9946a704139336942bfd41ad163ccfe1e685/README.md", - }, - { - id: "as-package", - label: "Manifesto do projeto", - url: "https://github.com/evandrodevbr/as-servicos/blob/abeb9946a704139336942bfd41ad163ccfe1e685/package.json", - }, - { - id: "as-engine", - label: "Motor da cena Three.js", - url: "https://github.com/evandrodevbr/as-servicos/blob/abeb9946a704139336942bfd41ad163ccfe1e685/lib/three/engine.ts", - }, - { - id: "as-canvas", - label: "Canvas e progresso da cena", - url: "https://github.com/evandrodevbr/as-servicos/blob/abeb9946a704139336942bfd41ad163ccfe1e685/components/scene/scene-canvas.tsx", - }, - ], - }, }, ]; +/** Projetos feitos para clientes ficam à frente; os pessoais e de estudo vão para o Laboratório. */ +export function isClientCase(caseStudy: { id: string }) { + const project = portfolio.projects.find(({ id }) => id === caseStudy.id); + return project ? isClientProject(project) : false; +} + +export const clientCases = caseStudies.filter(isClientCase); +export const labCases = caseStudies.filter( + (caseStudy) => !isClientCase(caseStudy), +); + export const serviceHubLinks = servicePages.map((service) => ({ href: `/servicos/${service.slug}`, label: service.navLabel, @@ -840,8 +691,16 @@ export function getServicePage(slug: string) { return servicePages.find((service) => service.slug === slug); } +/** Cases na ordem de `caseIds`, com os de cliente à frente dos de laboratório. */ export function getRelatedCases(caseIds: string[]) { - return caseStudies.filter((caseStudy) => caseIds.includes(caseStudy.id)); + const cases = caseIds.flatMap((id) => { + const caseStudy = caseStudies.find((item) => item.id === id); + return caseStudy ? [caseStudy] : []; + }); + return [ + ...cases.filter(isClientCase), + ...cases.filter((caseStudy) => !isClientCase(caseStudy)), + ]; } export function getRelatedServices(caseId: string): ServicePageContent[] { diff --git a/public/portfolio-data.json b/public/portfolio-data.json index ab26c2b..55cfd3a 100644 --- a/public/portfolio-data.json +++ b/public/portfolio-data.json @@ -4,9 +4,8 @@ "title": "Engenheiro da Computação e Desenvolvedor de Software", "location": "Garuva, Santa Catarina, Brasil", "email": "evandrofonsecajunior@gmail.com", - "phone": "Disponivel pelo botao de WhatsApp", - "linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", - "github": "https://github.com/evandrodevbr", + "phone": "Disponível pelo botão de WhatsApp", + "linkedin": "https://www.linkedin.com/in/evandrodevbr/", "summary": "Engenheiro da Computação e desenvolvedor de software com 5 anos de experiência, especializado em soluções web modernas e inovadoras. Formado em Engenharia da Computação, com conhecimentos sólidos em computação em nuvem e inteligência artificial. Busco constantemente novos desafios onde possa aplicar minha paixão por tecnologia e resolver problemas de forma criativa." }, "experiences": [ @@ -62,10 +61,11 @@ "projects": [ { "id": "sev-modaintima", + "kind": "cliente", "title": "SEV Moda Íntima", - "subtitle": "Catálogo Digital & E-commerce", - "description": "Plataforma de e-commerce completa desenvolvida com Next.js App Router, TypeScript, Prisma e Tailwind CSS. Inclui vitrine pública com experiência cinematográfica, sistema de pedidos via WhatsApp, área administrativa e busca inteligente com ranking de relevância.", - "longDescription": "Catálogo digital avançado com multi-filtros, busca inteligente com ranking de relevância e integração híbrida de geocoding. Inclui dark mode, animações fluidas com Framer Motion e área administrativa completa para produtos, estoque e pedidos.", + "subtitle": "Catálogo digital e loja virtual", + "description": "Loja virtual completa desenvolvida com Next.js App Router, TypeScript, Prisma e Tailwind CSS. Inclui vitrine pública com experiência cinematográfica, sistema de pedidos via WhatsApp, área administrativa e busca inteligente com ranking de relevância.", + "longDescription": "Catálogo digital avançado com multi-filtros, busca inteligente com ranking de relevância e integração híbrida de geocodificação. Inclui modo escuro, animações fluidas com Framer Motion e área administrativa completa para produtos, estoque e pedidos.", "technologies": [ "Next.js 16", "TypeScript", @@ -82,12 +82,12 @@ "Painel Administrativo completo", "Gestão de estoque por tamanho" ], - "category": "e-commerce", + "category": "loja virtual", "level": "senior", "status": "Em Produção", "demoUrl": "https://sevmodaintima.com.br", - "githubUrl": null, - "badges": ["E-commerce", "Em Produção"], + "githubUrl": "", + "badges": ["Loja virtual", "Em Produção"], "featured": true, "metrics": { "complexity": 5, @@ -96,10 +96,11 @@ }, { "id": "lavacao-047", + "kind": "cliente", "title": "Lavação 047", "subtitle": "Site e painel para lavação automotiva", - "description": "Site da Lavação 047, em Garuva-SC, com tabela de preços por porte do veículo, pedido direto pelo WhatsApp, mapa de localização e painel do dono para editar preços, textos e fotos sem novo deploy.", - "longDescription": "Landing page em Next.js 16 com cena de rolagem do sujo ao brilho, galeria de trabalhos por serviço e FAQ montado a partir dos dados reais do negócio. O conteúdo salvo no painel alimenta a página, os dados estruturados, o sitemap e o cartão de compartilhamento. Respeita prefers-reduced-motion e guarda conteúdo e imagens em Redis e Blob privado, com arquivo local como alternativa.", + "description": "Site da Lavação 047, em Garuva-SC, com tabela de preços por porte do veículo, pedido direto pelo WhatsApp, mapa de localização e painel do dono para editar preços, textos e fotos sem nova publicação.", + "longDescription": "Página em Next.js 16 com cena de rolagem do sujo ao brilho, galeria de trabalhos por serviço e perguntas frequentes montadas a partir dos dados reais do negócio. O conteúdo salvo no painel alimenta a página, os dados estruturados, o sitemap e o cartão de compartilhamento. Respeita a preferência do sistema por menos movimento e guarda conteúdo e imagens em Redis e Blob privado, com arquivo local como alternativa.", "technologies": [ "Next.js 16", "React 19", @@ -117,11 +118,11 @@ "SEO com JSON-LD, sitemap e llms.txt", "Mapa de localização com OpenStreetMap" ], - "category": "web", + "category": "site", "level": "senior", "status": "Em Produção", "demoUrl": "https://landing-page-lavacao047.vercel.app", - "githubUrl": null, + "githubUrl": "", "badges": ["Site e painel", "Em Produção"], "featured": true, "metrics": { @@ -131,10 +132,11 @@ }, { "id": "nutriscan", + "kind": "laboratorio", "title": "NutriScan", "subtitle": "Consulta de produtos por nome e código de barras", "description": "Aplicação web para consultar produtos alimentícios por nome ou código de barras, combinando cache JSON com a API Open Food Facts.", - "longDescription": "A busca consulta o cache JSON primeiro e recorre ao Open Food Facts quando faltam resultados. A consulta por código aceita de 8 a 13 dígitos, e o wrapper diferencia buscas por nome e código com timeout de oito segundos.", + "longDescription": "A busca consulta o cache JSON primeiro e recorre ao Open Food Facts quando faltam resultados. A consulta por código aceita de 8 a 13 dígitos, e a camada de acesso à base diferencia buscas por nome e código, com limite de oito segundos por consulta.", "technologies": [ "Next.js", "TypeScript", @@ -144,16 +146,16 @@ "features": [ "Busca por nome ou código", "Cache JSON consultado antes da API externa", - "Fallback para Open Food Facts", + "Consulta ao Open Food Facts quando faltam resultados", "Validação de códigos com 8 a 13 dígitos", - "Timeout de oito segundos" + "Limite de oito segundos por consulta" ], - "category": "full-stack", + "category": "aplicação web", "level": "senior", - "status": "Código público", + "status": "Em Produção", "demoUrl": "https://nutriscan.evandro.dev.br", - "githubUrl": "https://github.com/evandrodevbr/nutriscan", - "badges": ["Open Food Facts", "Código público"], + "githubUrl": "", + "badges": ["Open Food Facts", "Em Produção"], "featured": true, "metrics": { "complexity": 5, @@ -162,22 +164,23 @@ }, { "id": "twins-lab", + "kind": "laboratorio", "title": "Twins Lab", "subtitle": "Protótipo de Gêmeo Digital Industrial", - "description": "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e streaming SSE de eventos do servidor a cada dois segundos.", - "longDescription": "A rota exige sessão do NextAuth antes de abrir o stream SSE. O simulador salva dados e estado no MongoDB e notifica clientes quando execução ou cenário mudam.", + "description": "Protótipo de gêmeo digital industrial com autenticação, simulador de reator, persistência MongoDB e transmissão de eventos do servidor (SSE) a cada dois segundos.", + "longDescription": "A rota exige sessão do NextAuth antes de abrir a transmissão SSE. O simulador salva dados e estado no MongoDB e notifica clientes quando execução ou cenário mudam.", "technologies": ["Next.js", "TypeScript", "NextAuth", "MongoDB", "SSE"], "features": [ "Autenticação com NextAuth", "Simulador de reator", "Persistência no MongoDB", - "Stream SSE a cada dois segundos" + "Transmissão SSE a cada dois segundos" ], - "category": "iot", + "category": "IoT", "level": "senior", "status": "Protótipo", - "githubUrl": "https://github.com/evandrodevbr/twins-lab", - "badges": ["IoT", "SSE", "Código público"], + "githubUrl": "", + "badges": ["IoT", "SSE", "Protótipo"], "featured": true, "metrics": { "complexity": 5, @@ -186,30 +189,31 @@ }, { "id": "reddit-explorer", + "kind": "laboratorio", "title": "Reddit Posts Explorer", "subtitle": "Posts do r/artificial em MongoDB, filtrados por período", - "description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB com upsert por id e permite filtrar por período e ordenar por upvotes ou comentários.", - "longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação. O projeto não tem paginação, busca textual, autenticação, testes automatizados nem CI.", + "description": "Aplicação Astro SSR que coleta os posts em alta do r/artificial pela API pública do Reddit, grava no MongoDB sem duplicar, pelo id, e permite filtrar por período e ordenar por votos ou comentários.", + "longDescription": "O serviço consulta r/artificial/hot.json e grava os posts em lote, atualizando os existentes em vez de duplicar. O endpoint de filtro valida as datas e o campo de ordenação.", "technologies": [ "Astro", "MongoDB", "Mongoose", "TypeScript", - "Reddit API" + "API do Reddit" ], "features": [ "Coleta dos posts em alta do r/artificial", - "Upsert por id no MongoDB", + "Gravação por id no MongoDB, sem duplicar", "Filtro por período", - "Ordenação por upvotes ou comentários", + "Ordenação por votos ou comentários", "API própria com validação de parâmetros" ], - "category": "api", + "category": "integração com API", "level": "pleno", "status": "Concluído", "demoUrl": null, - "githubUrl": "https://github.com/evandrodevbr/reddit-search", - "badges": ["API Integration", "MongoDB", "Código público"], + "githubUrl": "", + "badges": ["Integração com API", "MongoDB", "Concluído"], "featured": true, "metrics": { "complexity": 3, @@ -218,9 +222,10 @@ }, { "id": "sc-plus", + "kind": "cliente", "title": "SC Plus Aquicultura", - "subtitle": "Landing page institucional em Next.js 15", - "description": "Landing page institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.", + "subtitle": "Página institucional em Next.js 15", + "description": "Página institucional em Next.js 15, React 19 e Tailwind CSS 4, com metadados e dados estruturados para indexação.", "longDescription": "Site institucional em Next.js 15 com React 19 e Tailwind CSS 4. A página publica título, descrição, URL canônica, imagem social e dados estruturados.", "technologies": [ "Next.js 15", @@ -229,16 +234,16 @@ "TypeScript" ], "features": [ - "Landing page institucional", + "Página institucional", "Metadados e URL canônica", "Dados estruturados" ], - "category": "web", + "category": "site", "level": "pleno", "status": "Concluído", "demoUrl": "https://scplus.evandro.dev.br", - "githubUrl": "https://github.com/evandrodevbr/scplus", - "badges": ["Landing page", "Next.js 15"], + "githubUrl": "", + "badges": ["Página institucional", "Next.js 15"], "featured": true, "metrics": { "complexity": 2, @@ -247,24 +252,25 @@ }, { "id": "gcc-installer", + "kind": "laboratorio", "title": "MinGW GCC Installer", "subtitle": "Instalador gráfico de MinGW-w64/GCC para Windows", - "description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o toolchain ao PATH do usuário no registro do Windows.", - "longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Funciona só no Windows e o caminho do 7-Zip é fixo.", + "description": "Aplicativo Python com interface tkinter que lê as builds publicadas do MinGW-w64, baixa o pacote .7z escolhido, instala em C:\\mingw64 e adiciona o compilador ao PATH do usuário no registro do Windows.", + "longDescription": "A lista de versões vem da API de releases do niXman/mingw-builds-binaries, com as builds ucrt e posix destacadas. A instalação extrai o pacote com o 7-Zip, move para C:\\mingw64 e renomeia mingw32-make.exe para make.exe. Feito para Windows, com o 7-Zip no caminho padrão.", "technologies": ["Python", "Tkinter", "Win32 API", "GitHub API", "7-Zip"], "features": [ "Lista as builds publicadas do MinGW-w64", "Destaca builds ucrt e posix", "Baixa e instala em C:\\mingw64", - "Adiciona o toolchain ao PATH do usuário", + "Adiciona o compilador ao PATH do usuário", "Testes do núcleo (23)" ], - "category": "desktop", + "category": "aplicativo desktop", "level": "pleno", "status": "Concluído", "demoUrl": null, - "githubUrl": "https://github.com/evandrodevbr/gcc-installer", - "badges": ["Desktop", "Windows", "Código público"], + "githubUrl": "", + "badges": ["Desktop", "Windows", "Concluído"], "featured": true, "metrics": { "complexity": 4, @@ -273,22 +279,23 @@ }, { "id": "as-servicos", + "kind": "cliente", "title": "AS Serviços", - "subtitle": "Site Institucional com Cena 3D Procedural", - "description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa wireframe e uma placa-mãe ligadas ao progresso da rolagem.", - "longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, reduced motion, economia de dados, conexão e capacidade do dispositivo.", + "subtitle": "Site institucional com cena 3D gerada em código", + "description": "Site institucional em Next.js 16 com cena Three.js gerada em código: uma casa desenhada em linhas e uma placa-mãe ligadas ao progresso da rolagem.", + "longDescription": "O motor monta casa, instalação elétrica e placa-mãe com geometrias e linhas do Three.js. O canvas converte a rolagem em progresso de 0 a 1, e a qualidade se adapta conforme WebGL, preferência por menos movimento, economia de dados, conexão e capacidade do dispositivo.", "technologies": ["Next.js 16", "TypeScript", "Three.js"], "features": [ "Casa, instalação elétrica e placa-mãe geradas com Three.js", "Rolagem convertida em progresso de 0 a 1", - "Qualidade static, low, medium ou high" + "Qualidade em quatro níveis: estático, baixo, médio ou alto" ], - "category": "web", + "category": "site", "level": "senior", - "status": "Código público", + "status": "Concluído", "demoUrl": null, - "githubUrl": "https://github.com/evandrodevbr/as-servicos", - "badges": ["Three.js", "WebGL", "Código público"], + "githubUrl": "", + "badges": ["Three.js", "WebGL", "Concluído"], "featured": true, "metrics": { "complexity": 5, @@ -328,7 +335,7 @@ ] }, { - "category": "DevOps & Tools", + "category": "DevOps e ferramentas", "icon": "GitBranch", "color": "text-blue-400", "bgColor": "bg-blue-500/20", @@ -340,7 +347,7 @@ "color": "text-blue-400", "bgColor": "bg-blue-500/20", "skills": [ - "Cloud Computing", + "Computação em nuvem", "Inteligência Artificial", "IoT", "Energia Renovável" @@ -372,23 +379,17 @@ }, "contact": { "email": "evandrofonsecajunior@gmail.com", - "phone": "Disponivel pelo botao de WhatsApp", - "whatsapp": "Disponivel pelo botao de WhatsApp", - "linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", - "github": "https://github.com/evandrodevbr", + "phone": "Disponível pelo botão de WhatsApp", + "whatsapp": "Disponível pelo botão de WhatsApp", + "linkedin": "https://www.linkedin.com/in/evandrodevbr/", "location": "Garuva, Santa Catarina, Brasil" }, "social": [ { "name": "LinkedIn", - "url": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/", + "url": "https://www.linkedin.com/in/evandrodevbr/", "icon": "linkedin" }, - { - "name": "GitHub", - "url": "https://github.com/evandrodevbr", - "icon": "github" - }, { "name": "Email", "url": "mailto:evandrofonsecajunior@gmail.com",