feat: revamp portfolio with case studies, curriculo page, and local SEO
Adds dynamic case study and curriculo routes, project health monitoring for live demo status, and strengthens local SEO (structured data, service-area targeting for Garuva/Joinville/Curitiba, a dedicated "criação de sites" service page) so search results better match the professional title (Engenheiro da Computação) and experience (5 anos). Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
1 parent
9236fdd685
commit
d76bbf5974
41 files changed
+1359
-492
No files matched your search
@@ -0,0 +1,24 @@
|
||||
import { getLiveFeaturedProjects } from "./portfolio-data";
|
||||
import type { ProjectHealthSnapshot } from "./project-health";
|
||||
|
||||
describe("getLiveFeaturedProjects", () => {
|
||||
it("returns only featured projects validated in the current snapshot", () => {
|
||||
const snapshot: ProjectHealthSnapshot = {
|
||||
checkedAt: "2026-07-10T12:00:00.000Z",
|
||||
projects: {
|
||||
"sev-modaintima": {
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
checkedAt: "2026-07-10T12:00:00.000Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
getLiveFeaturedProjects(
|
||||
snapshot,
|
||||
new Date("2026-07-10T12:01:00.000Z"),
|
||||
).map((project) => project.id),
|
||||
).toEqual(["sev-modaintima"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import portfolioData from "@/public/portfolio-data.json";
|
||||
import {
|
||||
createProjectHealthSnapshot,
|
||||
filterLiveProjects,
|
||||
type ProjectHealthSnapshot,
|
||||
} from "./project-health";
|
||||
|
||||
export type PortfolioData = typeof portfolioData;
|
||||
export type PortfolioProject = PortfolioData["projects"][number];
|
||||
export type PortfolioExperience = PortfolioData["experiences"][number];
|
||||
export type PortfolioSkillCategory =
|
||||
PortfolioData["skills"]["technical"][number];
|
||||
|
||||
export const portfolio = portfolioData;
|
||||
|
||||
export function getLiveFeaturedProjects(
|
||||
snapshot: ProjectHealthSnapshot | null,
|
||||
now = new Date(),
|
||||
) {
|
||||
return filterLiveProjects<PortfolioProject>(
|
||||
portfolio.projects,
|
||||
snapshot,
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
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 loadLiveFeaturedProjects() {
|
||||
const fromSnapshot = getLiveFeaturedProjects(await loadProjectHealthSnapshot());
|
||||
if (fromSnapshot.length > 0) return fromSnapshot;
|
||||
|
||||
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;
|
||||
}, []);
|
||||
const freshSnapshot = await createProjectHealthSnapshot(projectDemos);
|
||||
return getLiveFeaturedProjects(freshSnapshot);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
createProjectHealthSnapshot,
|
||||
filterLiveProjects,
|
||||
isCurrentLiveCheck,
|
||||
type ProjectHealthSnapshot,
|
||||
} from "./project-health";
|
||||
|
||||
describe("project health snapshot", () => {
|
||||
const now = new Date("2026-07-10T12:00:00.000Z");
|
||||
const snapshot: ProjectHealthSnapshot = {
|
||||
checkedAt: "2026-07-10T11:55:00.000Z",
|
||||
projects: {
|
||||
online: {
|
||||
status: 200,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
checkedAt: "2026-07-10T11:55:00.000Z",
|
||||
},
|
||||
redirect: {
|
||||
status: 301,
|
||||
contentType: "text/html",
|
||||
checkedAt: "2026-07-10T11:55:00.000Z",
|
||||
},
|
||||
stale: {
|
||||
status: 200,
|
||||
contentType: "text/html",
|
||||
checkedAt: "2026-07-10T10:30:00.000Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
it("accepts only fresh final HTTP 200 HTML projects", () => {
|
||||
expect(isCurrentLiveCheck(snapshot.projects.online, now)).toBe(true);
|
||||
expect(isCurrentLiveCheck(snapshot.projects.redirect, now)).toBe(false);
|
||||
expect(isCurrentLiveCheck(snapshot.projects.stale, now)).toBe(false);
|
||||
|
||||
expect(
|
||||
filterLiveProjects(
|
||||
[
|
||||
{ id: "online", featured: true },
|
||||
{ id: "redirect", featured: true },
|
||||
{ id: "stale", featured: true },
|
||||
{ id: "hidden", featured: false },
|
||||
],
|
||||
snapshot,
|
||||
now,
|
||||
).map((project) => project.id),
|
||||
).toEqual(["online"]);
|
||||
});
|
||||
|
||||
it("fails closed when the snapshot is absent or expired", () => {
|
||||
expect(
|
||||
filterLiveProjects([{ id: "online", featured: true }], null, now),
|
||||
).toEqual([]);
|
||||
expect(
|
||||
filterLiveProjects(
|
||||
[{ id: "online", featured: true }],
|
||||
{ ...snapshot, checkedAt: "2026-07-10T10:00:00.000Z" },
|
||||
now,
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates a fresh health snapshot when the stored result is unavailable", async () => {
|
||||
const fetcher = jest.fn(async (url: string) => ({
|
||||
status: url.includes("online") ? 200 : 503,
|
||||
url,
|
||||
headers: new Headers({
|
||||
"content-type": url.includes("online") ? "text/html" : "text/plain",
|
||||
}),
|
||||
body: { cancel: jest.fn() },
|
||||
}));
|
||||
|
||||
const result = await createProjectHealthSnapshot(
|
||||
[
|
||||
{ id: "online", demoUrl: "https://online.example" },
|
||||
{ id: "offline", demoUrl: "https://offline.example" },
|
||||
],
|
||||
fetcher,
|
||||
new Date("2026-07-10T12:00:00.000Z"),
|
||||
);
|
||||
|
||||
expect(result.projects.online.status).toBe(200);
|
||||
expect(result.projects.online.contentType).toBe("text/html");
|
||||
expect(result.projects.offline.status).toBe(503);
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
export const PROJECT_HEALTH_MAX_AGE_MS = 15 * 60 * 1000;
|
||||
|
||||
export interface ProjectHealthCheck {
|
||||
status: number | null;
|
||||
contentType: string | null;
|
||||
checkedAt: string;
|
||||
finalUrl?: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ProjectHealthSnapshot {
|
||||
checkedAt: string;
|
||||
projects: Record<string, ProjectHealthCheck>;
|
||||
}
|
||||
|
||||
interface ProjectLike {
|
||||
id: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
function isFresh(checkedAt: string, now: Date) {
|
||||
const timestamp = Date.parse(checkedAt);
|
||||
return (
|
||||
Number.isFinite(timestamp) &&
|
||||
now.getTime() - timestamp <= PROJECT_HEALTH_MAX_AGE_MS
|
||||
);
|
||||
}
|
||||
|
||||
export function isCurrentLiveCheck(
|
||||
check: ProjectHealthCheck | undefined,
|
||||
now = new Date(),
|
||||
) {
|
||||
return Boolean(
|
||||
check &&
|
||||
check.status === 200 &&
|
||||
check.contentType?.toLowerCase().startsWith("text/html") &&
|
||||
isFresh(check.checkedAt, now),
|
||||
);
|
||||
}
|
||||
|
||||
export function filterLiveProjects<T extends ProjectLike>(
|
||||
projects: T[],
|
||||
snapshot: ProjectHealthSnapshot | null,
|
||||
now = new Date(),
|
||||
) {
|
||||
if (!snapshot || !isFresh(snapshot.checkedAt, now)) {
|
||||
return [] as T[];
|
||||
}
|
||||
|
||||
return projects.filter(
|
||||
(project) =>
|
||||
project.featured !== false &&
|
||||
isCurrentLiveCheck(snapshot.projects[project.id], now),
|
||||
);
|
||||
}
|
||||
|
||||
interface ProjectDemo {
|
||||
id: string;
|
||||
demoUrl: string;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
status: number;
|
||||
url: string;
|
||||
headers: { get(name: string): string | null };
|
||||
body?: { cancel?: () => unknown } | null;
|
||||
}
|
||||
|
||||
type HealthFetcher = (url: string, init: RequestInit) => Promise<HealthResponse>;
|
||||
|
||||
export async function createProjectHealthSnapshot(
|
||||
projects: ProjectDemo[],
|
||||
fetcher: HealthFetcher = (url, init) => fetch(url, init),
|
||||
now = new Date(),
|
||||
): Promise<ProjectHealthSnapshot> {
|
||||
const checkedAt = now.toISOString();
|
||||
const checks = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
try {
|
||||
const response = await fetcher(project.demoUrl, {
|
||||
redirect: "follow",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
headers: { "user-agent": "evandro-project-health/1.0" },
|
||||
});
|
||||
await response.body?.cancel?.();
|
||||
|
||||
return [
|
||||
project.id,
|
||||
{
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type"),
|
||||
checkedAt,
|
||||
finalUrl: response.url,
|
||||
},
|
||||
] as const;
|
||||
} catch (error) {
|
||||
return [
|
||||
project.id,
|
||||
{
|
||||
status: null,
|
||||
contentType: null,
|
||||
checkedAt,
|
||||
error: error instanceof Error ? error.message : "Health check failed",
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return { checkedAt, projects: Object.fromEntries(checks) };
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
export const siteUrl = "https://evandro.dev.br";
|
||||
export const siteName = "Evandro.dev.br";
|
||||
|
||||
export const regionCities = [
|
||||
"Garuva",
|
||||
"Joinville",
|
||||
"São Francisco do Sul",
|
||||
"Itapoá",
|
||||
"Balneário Barra do Sul",
|
||||
"Barra Velha",
|
||||
"Jaraguá do Sul",
|
||||
"Curitiba",
|
||||
];
|
||||
|
||||
export interface ServicePageContent {
|
||||
slug: string;
|
||||
navLabel: string;
|
||||
@@ -30,6 +41,40 @@ export interface CaseStudyContent {
|
||||
}
|
||||
|
||||
export const servicePages: ServicePageContent[] = [
|
||||
{
|
||||
slug: "criacao-de-sites",
|
||||
navLabel: "Criação de Sites",
|
||||
eyebrow: "Presença digital",
|
||||
seoTitle:
|
||||
"Criação de Sites em Garuva, Joinville e Curitiba | Evandro Fonseca Junior",
|
||||
metaDescription:
|
||||
"Criação de sites institucionais e sistemas web para empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul e Curitiba. Sites rápidos, responsivos e otimizados para SEO com Next.js.",
|
||||
headline:
|
||||
"Criação de sites institucionais e sistemas web para empresas de Garuva, Joinville e Curitiba.",
|
||||
intro:
|
||||
"Desenvolvo sites e sistemas web rápidos, responsivos e com SEO técnico desde a primeira entrega, para empresas de Garuva, Joinville, Norte de Santa Catarina e Curitiba que precisam aparecer bem nas buscas locais e converter visitantes em clientes.",
|
||||
audience: [
|
||||
"Empresas e profissionais em Garuva, Joinville e região que ainda não têm site ou precisam trocar um site lento e desatualizado.",
|
||||
"Negócios em Curitiba e no Norte de Santa Catarina que querem aparecer melhor no Google para buscas da própria cidade e região.",
|
||||
"Marcas que precisam de um site institucional rápido, com boa performance e pronto para escalar para um sistema web completo.",
|
||||
],
|
||||
outcomes: [
|
||||
"Site institucional rápido, responsivo e com boa nota em Core Web Vitals.",
|
||||
"Estrutura de SEO técnico (title, metadata, dados estruturados e sitemap) desde o lançamento.",
|
||||
"Presença digital pronta para captar clientes que pesquisam por serviços na sua cidade.",
|
||||
"Base de código organizada, fácil de evoluir para loja, sistema ou painel administrativo.",
|
||||
],
|
||||
process: [
|
||||
"Levantamento do objetivo do site, público local e páginas necessárias.",
|
||||
"Definição de estrutura, conteúdo e palavras-chave locais relevantes.",
|
||||
"Implementação em Next.js com foco em performance, acessibilidade e SEO.",
|
||||
"Publicação, configuração de domínio e orientação sobre indexação no Google.",
|
||||
],
|
||||
stack: ["Next.js", "TypeScript", "Tailwind CSS", "SEO técnico", "Vercel"],
|
||||
ctaLabel: "Quero um site para minha empresa",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["sc-plus", "nutriscan"],
|
||||
},
|
||||
{
|
||||
slug: "engenharia-de-software",
|
||||
navLabel: "Engenharia de Software",
|
||||
|
||||
Reference in new issue
Block a user