feat(cases): cases de clientes, serviços ligados à prova e SEO por página

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.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 03:31:57 -03:00
1 parent 3756905a1b
commit 6e1022b58c
44 files changed
+2691 -1424

No files matched your search

@@ -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<React.ComponentProps<typeof OgCard>>(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();
});
});
+4 -2
View File
@@ -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(
<OgCard
eyebrow="Case de engenharia de software"
eyebrow={
isClientCase(caseStudy) ? "Case de cliente" : "Projeto de laboratório"
}
title={caseStudy.title}
description={caseStudy.summary}
backgroundImage={readProjectOgBackground(caseStudy.id)}
+137
View File
@@ -0,0 +1,137 @@
import { render, screen } from "@testing-library/react";
import { notFound } from "next/navigation";
import { caseStudies, getRelatedServices } from "@/lib/site-content";
import { getCaseMedia } from "../_components/case-media";
import CasePage, { generateMetadata } from "./page";
/**
* Testes de caminhos-base de CasePage e generateMetadata (complemento de page.test.tsx).
* Cobrem só o que o arquivo principal não exercita: slug desconhecido, mídia só no celular,
* demonstração sem captura, resultado vazio e case sem serviço relacionado.
*/
jest.mock("next/navigation", () => ({
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" });
});
});
+292 -163
View File
@@ -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<string, unknown>) => {
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<string, unknown>[] {
const scripts = container.querySelectorAll<HTMLScriptElement>(
'script[type="application/ld+json"]',
@@ -57,104 +49,141 @@ function readJsonLdEntities(container: HTMLElement): Record<string, unknown>[] {
});
}
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(<CasesPage />);
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,
+129 -196
View File
@@ -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) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<article>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/cases">
Cases
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/cases/${caseStudy.id}`}
>
{caseStudy.title}
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Case de engenharia de software</p>
<h1 className="ds-section-title text-white">
{caseStudy.title}: {caseStudy.subtitle}
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
{ label: caseStudy.title, href: `/cases/${caseStudy.id}` },
]}
/>
<p className="ds-eyebrow">
{isClient ? "Case de cliente" : "Projeto de laboratório"} ·{" "}
{caseStudy.title}
</p>
<h1 className="ds-section-title text-balance text-white">
{caseStudy.headline}
</h1>
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.detail?.answer.text ?? caseStudy.summary}
{caseStudy.summary}
</p>
{caseStudy.role ? (
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
<strong className="font-semibold text-white">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
{!caseStudy.detail ? (
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
</p>
) : null}
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl ? (
<a
className="ds-link ds-link-accent"
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir demonstração
</a>
) : null}
{caseStudy.githubUrl ? (
<a
className="ds-link ds-link-muted"
href={caseStudy.githubUrl}
target="_blank"
rel="noreferrer"
>
Ver código
</a>
) : null}
</div>
</section>
<section className="ds-section" aria-labelledby="case-context">
<div className="ds-section-shell max-w-[960px]">
<div className="ds-grid-2 items-start">
<div>
<h2 id="case-context" className="ds-card-title !mt-0">
{isClient
? "O cliente e o contexto"
: "O contexto do projeto"}
</h2>
<p className="ds-copy">{caseStudy.context}</p>
{caseStudy.role ? (
<p className="ds-copy">
<strong className="font-semibold text-[var(--ink)]">
Minha participação:
</strong>{" "}
{caseStudy.role}
</p>
) : null}
</div>
<div>
<h2 id="case-problem" className="ds-card-title !mt-0">
O problema
</h2>
<p className="ds-copy">{caseStudy.problem}</p>
</div>
</div>
</div>
</section>
{caseStudy.detail ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px] space-y-sp-10">
<section aria-labelledby="case-challenge">
<h2 id="case-challenge" className="ds-section-title">
Desafio verificável
</h2>
<p className="ds-section-description">
{caseStudy.detail.challenge.text}
</p>
</section>
<section aria-labelledby="case-implementation">
<h2 id="case-implementation" className="ds-section-title">
Decisões de implementação
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.implementation.map((statement) => (
<li key={statement.text}>{statement.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-boundaries">
<h2 id="case-boundaries" className="ds-section-title">
Limites desta evidência
</h2>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{caseStudy.detail.boundaries.map((boundary) => (
<li key={boundary.text}>{boundary.text}</li>
))}
</ul>
</section>
<section aria-labelledby="case-sources">
<h2 id="case-sources" className="ds-section-title">
Fontes
</h2>
<ul className="mt-sp-5 space-y-sp-4">
{caseStudy.detail.sources.map((source) => {
const sha = getSourceSha(source.url);
return (
<li key={source.id}>
<a
className="ds-link ds-link-muted"
href={source.url}
target="_blank"
rel="noreferrer"
>
Fonte: {source.label}
</a>
{sha ? (
<span className="mt-sp-2 block text-[12px] text-[var(--meta)]">
Commit: <code className="break-all">{sha}</code>
</span>
) : null}
</li>
);
})}
</ul>
</section>
</div>
</section>
) : null}
{relatedServices.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Serviços relacionados</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
{relatedServices.map((service) => (
<Link
key={service.slug}
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
</div>
</div>
</section>
) : null}
<section className="ds-section ds-section-dark pb-sp-6">
<section className="ds-section" aria-labelledby="case-done">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-card-title text-white">Próximos caminhos</h2>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
className="ds-link ds-link-accent"
href={intentCtas.project.href}
<h2 id="case-done" className="ds-card-title !mt-0">
O que foi feito
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
{caseStudy.deliveries.map((delivery) => (
<li key={delivery}>{delivery}</li>
))}
</ul>
</div>
</section>
<CaseMediaSection caseStudy={caseStudy} />
<CaseResultSection result={caseStudy.result} />
<section
className="ds-section ds-section-dark"
aria-labelledby="case-contact"
>
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-contact" className="ds-card-title !mt-0 text-white">
Quer um projeto parecido com este?
</h2>
<p className="ds-copy max-w-[60ch]">
Conte o que você precisa. A conversa começa direto no WhatsApp,
sem formulário.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service={`um projeto parecido com o da ${caseStudy.title}`}
placement="case_detail"
className={primaryButton}
>
{intentCtas.project.label}
</Link>
<Link
className="ds-link ds-link-muted"
href={intentCtas.consulting.href}
>
{intentCtas.consulting.label}
Conversar sobre um projeto parecido
</WhatsAppLink>
<Link href="/cases" className={secondaryButtonDark}>
Ver outros cases
</Link>
</div>
<CaseRelatedServices caseId={caseStudy.id} />
</div>
</section>
<section className="ds-section" aria-label="Detalhes técnicos">
<div className="ds-section-shell max-w-[960px]">
<details className="border border-[var(--border)] bg-white/70 p-sp-5 md:p-sp-6">
<summary className="cursor-pointer py-sp-2 text-base font-semibold leading-relaxed focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--ink)]">
Detalhes técnicos
</summary>
<div className="max-w-[80ch]">
<p className="ds-kicker mt-sp-5">Tecnologias</p>
<ul
className="ds-tags !mt-sp-3 !pt-0"
aria-label="Tecnologias do projeto"
>
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<p className="ds-kicker mt-sp-6">Decisões técnicas</p>
<ul className="ds-list !mt-sp-3 space-y-sp-2">
{caseStudy.decisions.map((decision) => (
<li key={decision}>{decision}</li>
))}
</ul>
</div>
</details>
</div>
</section>
</article>
@@ -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(<CaseCover {...props} />);
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(<CaseCover {...props} />);
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(<CaseCover {...props} />);
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(<CaseCover {...props} sizes="320px" priority />);
const image = screen.getByAltText(`Captura de tela do site ${props.title}`);
expect(image).toHaveAttribute("sizes", "320px");
expect(image).not.toHaveAttribute("loading", "lazy");
});
});
+40
View File
@@ -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 (
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<Image
src={media.poster}
alt={`Captura de tela do site ${title}`}
fill
sizes={sizes}
priority={priority}
className="object-cover object-top"
/>
</div>
</BrowserFrame>
);
}
@@ -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(<CaseMedia {...props} />);
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(<CaseMedia {...props} />);
expect(container).toBeEmptyDOMElement();
});
it("path: only the mobile capture -> phone frame without the browser frame", () => {
withCaptures(["mobile"]);
const { container } = render(<CaseMedia {...props} />);
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(<CaseMedia {...props} />);
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(
<CaseMedia id={props.id} title={props.title} demoUrl={null} />,
);
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
props.title,
);
});
});
+91
View File
@@ -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 (
<video
className={className}
controls
muted
loop
playsInline
preload="none"
poster={assets.poster}
width={assets.width}
height={assets.height}
aria-label={label}
disablePictureInPicture
>
{assets.sources.map((source) => (
<source key={source.src} src={source.src} type={source.type} />
))}
</video>
);
}
/**
* 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 (
<div className="mt-sp-8 grid items-end gap-sp-8 md:grid-cols-[minmax(0,1fr)_auto]">
{desktop ? (
<figure className="min-w-0">
<BrowserFrame url={displayUrl({ demoUrl, title })}>
<div className="relative aspect-[16/10] bg-[#0a0a0a]">
<CaseVideo
assets={desktop}
label={`Vídeo do site ${title} no computador`}
className="absolute inset-0 h-full w-full object-cover object-top"
/>
</div>
</BrowserFrame>
<figcaption className="mt-sp-3 font-[family-name:var(--font-body)] text-[15px] text-white/70">
O site {title} no computador.
</figcaption>
</figure>
) : null}
{mobile ? (
<figure className="mx-auto w-[min(62vw,220px)] md:mx-0">
<PhoneFrame poster={mobile.poster}>
<CaseVideo
assets={mobile}
label={`Vídeo do site ${title} no celular`}
className="absolute inset-0 h-full w-full object-cover object-top"
/>
</PhoneFrame>
<figcaption className="mt-sp-3 text-center font-[family-name:var(--font-body)] text-[15px] text-white/70">
No celular.
</figcaption>
</figure>
) : null}
</div>
);
}
@@ -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<CaseStudyContent, "id" | "demoUrl">) {
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<CaseStudyContent, "id" | "title" | "demoUrl">;
}) {
if (!hasCaseMedia(caseStudy)) return null;
return (
<section
className="ds-section ds-section-dark"
aria-labelledby="case-media"
>
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-media" className="ds-card-title !mt-0 text-white">
Como ficou
</h2>
<CaseMedia
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
/>
{caseStudy.demoUrl ? (
<div className="mt-sp-8">
<a
className={secondaryButtonDark}
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir o site {caseStudy.title}
</a>
</div>
) : null}
</div>
</section>
);
}
@@ -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 (
<div className="mt-sp-6 flex flex-wrap gap-x-sp-6">
{services.map((service) => (
<Link
key={service.slug}
className={textLink}
href={`/servicos/${service.slug}`}
>
Ver serviço relacionado: {service.navLabel}
</Link>
))}
</div>
);
}
@@ -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 (
<section className="ds-section" aria-labelledby="case-result">
<div className="ds-section-shell max-w-[960px]">
<h2 id="case-result" className="ds-card-title !mt-0">
Resultado
</h2>
<ul className="ds-list space-y-sp-3 pl-[1.15rem]">
{result.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
</div>
</section>
);
}
@@ -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<typeof import("node:fs")>("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);
});
});
+23
View File
@@ -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/<id>/, 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;
}
+4 -4
View File
@@ -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(
<OgCard
eyebrow="Trabalho verificável"
title="Cases de software, web e sistemas conectados"
description="Decisões, implementação, limites e fontes."
eyebrow="Trabalhos entregues"
title="Sites e sistemas feitos para empresas"
description="Veja como cada projeto ficou e converse sobre o seu."
/>,
OG_SIZE,
);
+65
View File
@@ -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(<CasesPage />);
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,
);
});
});
+138 -94
View File
@@ -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 (
<article className="ds-card !h-auto">
{withCover && getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<Link href={`/cases/${caseStudy.id}`} className={secondaryButtonLight}>
{ctaLabel}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site
</a>
) : null}
</div>
</article>
);
}
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() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/cases"
>
Cases
</Link>
</li>
</ol>
</nav>
<p className="ds-eyebrow">Selected work</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
Cases em software, Next.js, integrações e produtos conectados.
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Cases", href: "/cases" },
]}
/>
<p className="ds-eyebrow">Trabalhos entregues</p>
<h1 className="ds-section-title max-w-[18ch] text-balance text-white">
Veja o que já fiz para empresas.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
{pageDescription}
@@ -92,65 +136,65 @@ export default function CasesPage() {
</div>
</section>
<section className="ds-section">
<section className="ds-section" aria-labelledby="cases-clientes">
<div className="ds-section-shell">
<div className="ds-grid-2 items-start">
{caseStudies.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card !h-auto">
<div className="ds-kicker">{caseStudy.title}</div>
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
<p className="ds-copy">{caseStudy.summary}</p>
<p className="mt-sp-4 text-[13px] leading-7 text-[var(--meta)]">
{caseStudy.impact}
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case
</Link>
{caseStudy.demoUrl ? (
<MagneticButton
as="a"
href={caseStudy.demoUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
strength={0.25}
>
Abrir projeto
</MagneticButton>
) : null}
</div>
</article>
<h2 id="cases-clientes" className="ds-card-title !mt-0">
Projetos de clientes
</h2>
<div className="ds-grid-2 mt-sp-8 items-start">
{clientCases.map((caseStudy) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover
ctaLabel="Ver o case"
/>
))}
</div>
</div>
</section>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton
as="a"
href={intentCtas.project.href}
className="ds-link ds-link-accent"
strength={0.25}
<section className="ds-section" aria-labelledby="cases-laboratorio">
<div className="ds-section-shell">
<h2 id="cases-laboratorio" className="ds-card-title !mt-0">
Laboratório
</h2>
<p className="ds-copy max-w-[64ch]">
Projetos pessoais e de estudo, feitos para praticar e testar ideias.
</p>
<div className="ds-grid-2 mt-sp-8 items-start">
{labCases.map((caseStudy) => (
<CaseCard
key={caseStudy.id}
caseStudy={caseStudy}
withCover={false}
ctaLabel="Ver o projeto"
/>
))}
</div>
</div>
</section>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell">
<h2 className="ds-card-title !mt-0 text-white">
Quer um projeto parecido com algum destes?
</h2>
<p className="ds-copy max-w-[60ch]">
Conte o que você precisa. A conversa começa direto no WhatsApp, sem
formulário.
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<WhatsAppLink
service="um projeto parecido com os cases do portfólio"
placement="cases_hub_footer"
className={primaryButton}
>
{intentCtas.project.label}
</MagneticButton>
<MagneticButton
as="a"
href={intentCtas.consulting.href}
className="ds-link ds-link-muted"
strength={0.2}
>
{intentCtas.consulting.label}
</MagneticButton>
Conversar sobre meu projeto
</WhatsAppLink>
<Link href="/servicos" className={secondaryButtonDark}>
Ver os serviços
</Link>
</div>
</div>
</section>
+30
View File
@@ -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 (
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
{items.map(({ label, href }, index) => (
<li
key={href}
className={index > 0 ? "flex items-center gap-sp-2" : undefined}
>
{index > 0 ? <span aria-hidden="true">/</span> : null}
<Link
aria-current={index === items.length - 1 ? "page" : undefined}
className="ds-link ds-link-text"
href={href}
>
{label}
</Link>
</li>
))}
</ol>
</nav>
);
}
+25
View File
@@ -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 (
<a
className={className}
href={CV_URL}
download={CV_DOWNLOAD_NAME}
onClick={() => trackCvDownload({ placement: "curriculo" })}
>
{children}
</a>
);
}
+30 -1
View File
@@ -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" });
});
+24 -33
View File
@@ -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 (
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/curriculo"
>
Currículo
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Currículo", href: "/curriculo" },
]}
/>
<p className="ds-eyebrow">Currículo profissional</p>
<h1 className="ds-section-title text-white">
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.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<a className="ds-link ds-link-accent" href="/Profile.pdf" download>
Baixar currículo em PDF
</a>
<Link className="ds-link ds-link-muted" href="/#contact">
<Link className="ds-link ds-link-accent" href="/#contact">
Falar sobre uma oportunidade
</Link>
<Link className="ds-link ds-link-muted" href="/servicos">
<CvDownloadLink className="ds-link ds-link-muted">
Baixar currículo em PDF
</CvDownloadLink>
<Link className="ds-link ds-link-text" href="/servicos">
Procurando consultoria? Ver serviços
</Link>
</div>
@@ -131,7 +122,7 @@ export default function CurriculoPage() {
))}
</div>
<div className="mt-sp-8">
<Link className="ds-link ds-link-accent" href="/cases">
<Link className="ds-link ds-link-muted" href="/cases">
Ver projetos e cases
</Link>
</div>
+23 -11
View File
@@ -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 (
<div className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
<main className="min-h-screen bg-[#030712] flex items-center justify-center px-6">
<div className="max-w-2xl mx-auto text-center">
<div className="mb-8">
<Code2 className="w-24 h-24 text-blue-500 mx-auto mb-4 opacity-50" />
<h1 className="text-6xl md:text-8xl font-black text-white mb-4">
<Code2 className="w-24 h-24 text-[var(--slash)] mx-auto mb-4 opacity-60" />
<h1 className="font-[family-name:var(--font-display)] text-6xl md:text-8xl font-extrabold text-white mb-4">
404
</h1>
<h2 className="text-2xl md:text-3xl font-bold text-slate-400 mb-4">
<h2 className="font-[family-name:var(--font-display)] text-2xl md:text-3xl font-bold text-slate-300 mb-4">
Página não encontrada
</h2>
<p className="text-slate-500 mb-8">
<p className="font-[family-name:var(--font-body)] text-slate-400 mb-8">
A página que você está procurando não existe ou foi movida.
</p>
</div>
@@ -21,20 +33,20 @@ export default function NotFound() {
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link
href="/"
className="px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2"
className="px-6 py-3 border border-slate-600 hover:border-slate-400 bg-transparent text-white rounded-lg font-[family-name:var(--font-body)] text-base font-semibold transition-all flex items-center justify-center gap-2"
>
<Home className="w-5 h-5" />
Voltar para Home
Voltar ao início
</Link>
<Link
href="/#projects"
className="px-6 py-3 border border-slate-800 hover:border-slate-600 text-slate-400 hover:text-white rounded-lg font-semibold transition-all flex items-center justify-center gap-2"
href="/cases"
className="px-6 py-3 font-[family-name:var(--font-body)] text-base text-slate-300 hover:text-white underline underline-offset-4 rounded-lg font-medium transition-all flex items-center justify-center gap-2"
>
<ArrowLeft className="w-5 h-5" />
Ver Projetos
Ver os cases
</Link>
</div>
</div>
</div>
</main>
);
}
+16
View File
@@ -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(<PrivacyPage />);
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(<PrivacyPage />);
expect(container.querySelector(".ds-link-accent")).toBeNull();
});
it("uses privacy-specific canonical metadata", () => {
expect(metadata).toMatchObject({
title: expect.stringMatching(/privacidade/i),
+24 -38
View File
@@ -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() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell max-w-[960px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href="/privacidade"
>
Privacidade
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Privacidade", href: "/privacidade" },
]}
/>
<p className="ds-eyebrow">Privacidade e consentimento</p>
<h1 className="ds-section-title text-white">
Privacidade e consentimento
@@ -71,7 +57,7 @@ export default function PrivacyPage() {
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
<div className="ds-grid-2 items-start">
<article className="ds-card">
<p className="ds-kicker">Responsável e contato</p>
<h2 className="ds-card-title">Evandro Fonseca Junior</h2>
@@ -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{" "}
<a
className="ds-link ds-link-accent"
className="font-medium underline underline-offset-4"
href="mailto:[email protected]"
>
evandrofonsecajunior@gmail.com
@@ -93,8 +79,8 @@ export default function PrivacyPage() {
<h2 className="ds-card-title">Mensagem pelo WhatsApp</h2>
<p className="ds-copy">
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.
</p>
</article>
@@ -111,8 +97,8 @@ export default function PrivacyPage() {
Cookies de publicidade e personalização permanecem negados. Ao
conceder analytics, o Google pode criar cookies{" "}
<code>_ga*</code>. 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.
</p>
<p className="ds-copy mt-sp-4">
A tag compartilhada conecta Google Analytics e Google Ads. O
@@ -137,16 +123,16 @@ export default function PrivacyPage() {
<p className="ds-copy">
Para alterar a preferência, use o controle existente{" "}
<strong>“Preferências de analytics”</strong> 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.
</p>
<p className="ds-copy mt-sp-4">
Solicitações relacionadas aos seus direitos devem ser
encaminhadas ao e-mail publicado acima.
</p>
<Link
className="ds-link ds-link-accent mt-sp-5 inline-block"
className="ds-link ds-link-muted mt-sp-5 self-start"
href="/"
>
Voltar ao início
@@ -158,7 +144,7 @@ export default function PrivacyPage() {
<p className="ds-kicker">Vigência</p>
<p className="ds-copy mt-sp-2">
Esta versão está vigente desde{" "}
<time dateTime="2026-09-13">2026-09-13</time>.
<time dateTime="2026-09-13">13 de setembro de 2026</time>.
</p>
</div>
</div>
+98
View File
@@ -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" });
});
});
+148 -26
View File
@@ -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<string, unknown>) => {
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(<ServicesIndexPage />);
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);
}
});
+55 -157
View File
@@ -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) {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
<HeroBackground />
<div className="ds-section-shell relative z-10">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div
className={`ds-section-shell ${
heroCase
? "grid items-center gap-sp-10 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)] lg:gap-sp-16"
: ""
}`}
>
<div className="max-w-[860px]">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-2 text-sm">
<li>
<Link className="ds-link ds-link-muted" href="/">
Início
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link className="ds-link ds-link-muted" href="/servicos">
Serviços
</Link>
</li>
<li className="flex items-center gap-sp-2">
<span aria-hidden="true">/</span>
<Link
aria-current="page"
className="ds-link ds-link-muted"
href={`/servicos/${service.slug}`}
>
{service.navLabel}
</Link>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
{ label: service.navLabel, href: `/servicos/${service.slug}` },
]}
/>
<p className="ds-eyebrow">{service.eyebrow}</p>
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
{service.headline}
@@ -127,18 +119,15 @@ export default async function ServicePage({ params }: ServicePageProps) {
<WhatsAppLink
service={service.navLabel}
placement="service_detail_hero"
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver casos relacionados
</MagneticButton>
{relatedCases.length > 0 ? (
<a href="#casos-relacionados" className={secondaryButtonDark}>
Ver projetos parecidos
</a>
) : null}
</div>
<p className="mt-sp-4 text-sm leading-6 text-white/70">
Fale diretamente com Evandro. Abre uma conversa, sem precisar
@@ -146,9 +135,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
SC.
</p>
</div>
<ServiceHeroCover caseStudy={heroCase} />
</div>
</section>
<ServiceRelatedCases cases={relatedCases} />
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-grid-2">
@@ -192,8 +184,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
<div className="ds-kicker">Possibilidades de uso</div>
<h2 className="ds-card-title">Imagine na sua rotina</h2>
<p className="ds-copy">
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.
</p>
<ul className="ds-list">
{service.examples.map((example) => (
@@ -215,9 +207,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
))}
</ul>
<p className="ds-copy">
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.
</p>
<ul className="ds-tags" aria-label="Tecnologias do serviço">
{service.stack.map((item) => (
@@ -231,71 +222,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Casos relacionados</p>
<h2 className="ds-section-title">
Projetos para conhecer meu trabalho
</h2>
<p className="ds-section-description">
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.
</p>
</div>
</div>
<div className="ds-grid-2">
{relatedCases.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
<div className="ds-kicker">{caseStudy.title}</div>
<h3 className="ds-card-title">{caseStudy.subtitle}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<p className="mt-sp-4 text-[13px] leading-7 text-[var(--meta)]">
{caseStudy.impact}
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag !whitespace-normal">
{item}
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className="ds-link ds-link-accent"
target="_blank"
rel="noreferrer"
>
Abrir projeto: {caseStudy.title}
</a>
) : null}
{caseStudy.githubUrl ? (
<a
href={caseStudy.githubUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
>
Ver código: {caseStudy.title}
</a>
) : null}
</div>
</article>
))}
</div>
</div>
</section>
<ServiceOffer service={service} />
<section className="ds-section ds-section-dark pb-sp-6">
<div className="ds-section-shell">
@@ -314,48 +241,19 @@ export default async function ServicePage({ params }: ServicePageProps) {
<WhatsAppLink
service={service.navLabel}
placement="service_detail_footer"
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
>
{service.ctaLabel} no WhatsApp
</WhatsAppLink>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
<Link href="/servicos" className={secondaryButtonDark}>
Ver todos os serviços
</MagneticButton>
</Link>
</div>
</div>
</div>
</section>
{service.faq && service.faq.length > 0 ? (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de
cada projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{service.faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
) : null}
<ServiceFaq faq={service.faq} />
</main>
);
}
+32
View File
@@ -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 (
<section className="ds-section">
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Dúvidas comuns</p>
<h2 className="ds-section-title">Perguntas frequentes</h2>
<p className="ds-section-description">
Respostas diretas às perguntas que costumam aparecer antes de cada
projeto começar.
</p>
</div>
</div>
<div className="ds-grid-2">
{faq.map((item) => (
<article key={item.question} className="ds-card">
<h3 className="ds-card-title">{item.question}</h3>
<p className="ds-copy">{item.answer}</p>
</article>
))}
</div>
</div>
</section>
);
}
@@ -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 (
<Link href={`/cases/${caseStudy.id}`} className="block min-w-0">
<span className="sr-only">Ver o case {caseStudy.title}</span>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 520px, 100vw"
priority
/>
</Link>
);
}
+41
View File
@@ -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 (
<section className="ds-section" aria-labelledby="investimento-prazo">
<div className="ds-section-shell">
<h2 id="investimento-prazo" className="ds-card-title !mt-0">
Investimento e prazo
</h2>
<dl className="mt-sp-6 grid gap-sp-6 md:grid-cols-3">
{offer.map((item) => (
<div
key={item.label}
className="border-t border-[var(--border)] pt-sp-4"
>
<dt className="ds-kicker">{item.label}</dt>
<dd className="ds-copy !mt-sp-2">{item.value}</dd>
</div>
))}
</dl>
</div>
</section>
);
}
@@ -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 (
<section
id="casos-relacionados"
className="ds-section"
aria-labelledby="casos-relacionados-titulo"
>
<div className="ds-section-shell">
<div className="ds-section-header">
<div className="ds-section-meta">
<p className="ds-eyebrow">Trabalhos parecidos</p>
<h2 id="casos-relacionados-titulo" className="ds-section-title">
Veja projetos parecidos, já prontos
</h2>
<p className="ds-section-description">
Projetos reais nesta área. Abra o case para ver o que foi feito e
como ficou.
</p>
</div>
</div>
<div className="ds-grid-2">
{cases.map((caseStudy) => (
<article key={caseStudy.id} className="ds-card">
{getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<div className="ds-kicker">
{isClientCase(caseStudy)
? caseStudy.title
: `${caseStudy.title} · Protótipo de laboratório`}
</div>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className={secondaryButtonLight}
>
Ler case: {caseStudy.title}
</Link>
{caseStudy.demoUrl ? (
<a
href={caseStudy.demoUrl}
className={textLink}
target="_blank"
rel="noreferrer"
>
Abrir o site: {caseStudy.title}
</a>
) : null}
</div>
</article>
))}
</div>
</div>
</section>
);
}
+50 -53
View File
@@ -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() {
<main>
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
<section className="ds-section ds-section-dark">
<section className={`ds-section ds-section-dark ${pageTop}`}>
<div className="ds-section-shell">
<nav aria-label="Breadcrumb" className="mb-sp-6">
<ol className="flex flex-wrap items-center gap-sp-3 text-sm">
<li>
<Link
className="inline-flex min-h-11 items-center underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
href="/"
>
Início
</Link>
</li>
<li className="flex items-center gap-sp-3">
<span aria-hidden="true">/</span>
<span aria-current="page">Serviços</span>
</li>
</ol>
</nav>
<Breadcrumb
items={[
{ label: "Início", href: "/" },
{ label: "Serviços", href: "/servicos" },
]}
/>
<h1 className="ds-section-title max-w-[22ch] text-balance">
Sites, sistemas e aplicativos sob medida.
</h1>
@@ -124,7 +109,7 @@ export default function ServicesIndexPage() {
</p>
<div className="mt-sp-6 flex flex-wrap items-center gap-sp-4">
<WhatsAppLink
className="ds-link ds-link-accent min-h-12"
className={primaryButton}
service="Sites, sistemas e aplicativos"
placement="service_hub_hero"
>
@@ -132,7 +117,7 @@ export default function ServicesIndexPage() {
</WhatsAppLink>
<a
href="#projetos"
className="ds-link ds-link-muted min-h-12 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${secondaryButtonDark} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver projetos realizados
</a>
@@ -165,7 +150,7 @@ export default function ServicesIndexPage() {
<p className="ds-copy">{intent.description}</p>
<div className="mt-sp-4 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-2">
<WhatsAppLink
className="ds-link ds-link-accent min-h-11"
className={primaryButton}
service={intent.service}
placement="service_hub_intent"
>
@@ -173,7 +158,7 @@ export default function ServicesIndexPage() {
</WhatsAppLink>
<Link
href={intent.href}
className="inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${textLink} focus-visible:outline-2 focus-visible:outline-offset-4`}
>
{intent.linkLabel}
</Link>
@@ -194,24 +179,36 @@ export default function ServicesIndexPage() {
Veja o trabalho antes de conversar.
</h2>
<p className="ds-copy max-w-[64ch]">
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.
</p>
<div className="mt-sp-8 grid gap-sp-8 md:grid-cols-2">
{featuredCases.map((caseStudy) => (
{clientCases.map((caseStudy) => (
<article
key={caseStudy.id}
className="border-t border-[var(--border)] pt-sp-6"
>
<p className="mb-sp-3 text-sm text-[var(--meta)]">
Demonstração publicada
</p>
<h3 className="ds-card-title">{caseStudy.title}</h3>
{getCaseMedia(caseStudy.id, "desktop") ? (
<Link
href={`/cases/${caseStudy.id}`}
aria-hidden="true"
tabIndex={-1}
className="mb-sp-5 block"
>
<CaseCover
id={caseStudy.id}
title={caseStudy.title}
demoUrl={caseStudy.demoUrl}
sizes="(min-width: 1024px) 560px, 100vw"
/>
</Link>
) : null}
<p className="ds-kicker">{caseStudy.title}</p>
<h3 className="ds-card-title">{caseStudy.headline}</h3>
<p className="ds-copy">{caseStudy.summary}</p>
<Link
href={`/cases/${caseStudy.id}`}
className="mt-sp-4 inline-flex min-h-11 items-center text-sm underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4"
className={`${textLink} mt-sp-4 focus-visible:outline-2 focus-visible:outline-offset-4`}
>
Ver o case {caseStudy.title}
</Link>
@@ -286,7 +283,7 @@ export default function ServicesIndexPage() {
</h2>
<p className="ds-copy max-w-[64ch]">
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ê.
</p>
<ul className="mt-sp-8 divide-y divide-[var(--border)] border-y border-[var(--border)]">
{serviceHubLinks.map((service) => (
@@ -318,7 +315,7 @@ export default function ServicesIndexPage() {
formulário para começar.
</p>
<WhatsAppLink
className="ds-link ds-link-accent mt-sp-6 min-h-12"
className={`${primaryButton} mt-sp-6`}
service="Sites, sistemas e aplicativos"
placement="service_hub_footer"
>
+5 -1
View File
@@ -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 }));
}
-6
View File
@@ -1,6 +0,0 @@
{
"checkedAt": "2026-10-08T18:25:53Z",
"total": 68,
"public": 25,
"private": 43
}
+1
View File
@@ -68,6 +68,7 @@ export function OgCard({
/>
</>
)}
{/* biome-ignore lint/performance/noImgElement: o ImageResponse (satori) só desenha <img> */}
<img
src={brandMark}
alt="Evandro.dev.br"
+2 -1
View File
@@ -8,7 +8,8 @@ jest.mock("./project-health", () => ({
}));
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
+70
View File
@@ -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<ProjectHealthSnapshot | null> {
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<PortfolioProjectView[]> {
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);
}
}
+33 -2
View File
@@ -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);
});
});
+1 -63
View File
@@ -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<ProjectHealthSnapshot | null> {
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<PortfolioProjectView[]> {
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);
}
}
+49 -6
View File
@@ -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,
},
});
});
+38 -4
View File
@@ -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<Metadata, "alternates" | "openGraph" | "twitter"> {
return {
alternates: { canonical: url },
openGraph: {
locale: "pt_BR",
title,
description,
url,
siteName,
type,
},
twitter: {
card: "summary_large_image",
title,
description,
creator: twitterHandle,
},
};
}
+125 -74
View File
@@ -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:/);
}
});
+282 -423
View File
@@ -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[] {
+67 -66
View File
@@ -4,9 +4,8 @@
"title": "Engenheiro da Computação e Desenvolvedor de Software",
"location": "Garuva, Santa Catarina, Brasil",
"email": "[email protected]",
"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": "[email protected]",
"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:[email protected]",