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
@@ -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[] {
|
||||
|
||||
Reference in new issue
Block a user