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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 }));
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"checkedAt": "2026-10-08T18:25:53Z",
|
||||
"total": 68,
|
||||
"public": 25,
|
||||
"private": 43
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]",
|
||||
|
||||
Reference in new issue
Block a user