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:
Evandro Fonseca JuniorandClaude Sonnet 5 committed 2026-07-15 01:31:46 -03:00
1 parent 9236fdd685
commit d76bbf5974
41 files changed
+1359 -492

No files matched your search

+146
View File
@@ -0,0 +1,146 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
interface CasePageProps {
params: Promise<{ slug: string }>;
}
export function generateStaticParams() {
return caseStudies.map((caseStudy) => ({ slug: caseStudy.id }));
}
export async function generateMetadata({
params,
}: CasePageProps): Promise<Metadata> {
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) return { title: `Case não encontrado | ${siteName}` };
const url = `${siteUrl}/cases/${caseStudy.id}`;
return {
title: `${caseStudy.title}: ${caseStudy.subtitle} | ${siteName}`,
description: caseStudy.summary,
alternates: { canonical: url },
openGraph: {
title: caseStudy.title,
description: caseStudy.summary,
url,
siteName,
type: "article",
},
};
}
export default async function CasePage({ params }: CasePageProps) {
const { slug } = await params;
const caseStudy = caseStudies.find((item) => item.id === slug);
if (!caseStudy) notFound();
const url = `${siteUrl}/cases/${caseStudy.id}`;
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Article",
headline: caseStudy.title,
description: caseStudy.summary,
mainEntityOfPage: url,
author: { "@id": `${siteUrl}/#person` },
about: caseStudy.stack,
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
{
"@type": "ListItem",
position: 3,
name: caseStudy.title,
item: url,
},
],
},
],
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<p className="ds-eyebrow">Case de engenharia de software</p>
<h1 className="ds-section-title text-white">
{caseStudy.title}: {caseStudy.subtitle}
</h1>
<p className="ds-copy max-w-[68ch] !text-[rgba(255,255,255,.78)]">
{caseStudy.summary}
</p>
<p className="mt-sp-6 text-[15px] leading-8 text-white">
{caseStudy.impact}
</p>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
{caseStudy.demoUrl && (
<a
className="ds-link ds-link-accent"
href={caseStudy.demoUrl}
target="_blank"
rel="noreferrer"
>
Abrir demonstração
</a>
)}
{caseStudy.githubUrl && (
<a
className="ds-link ds-link-muted"
href={caseStudy.githubUrl}
target="_blank"
rel="noreferrer"
>
Ver código
</a>
)}
</div>
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell max-w-[960px]">
<h2 className="ds-section-title">Tecnologias e contexto técnico</h2>
<p className="ds-section-description">
Este case demonstra como aplico engenharia de software a produtos
com requisitos de performance, integração e operação, para
empresas do Norte de Santa Catarina, Curitiba e atuação remota.
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<li key={item} className="ds-tag">
{item}
</li>
))}
</ul>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<Link className="ds-link ds-link-accent" href="/curriculo">
Ver currículo profissional
</Link>
<Link className="ds-link ds-link-muted" href="/servicos">
Ver serviços de engenharia
</Link>
</div>
</div>
</section>
</article>
</main>
);
}
+45 -14
View File
@@ -1,8 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
import HeroBackground from "@/app/components/cinematic/HeroBackground";
import MagneticButton from "@/app/components/cinematic/MagneticButton";
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
export const metadata: Metadata = {
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
@@ -39,9 +39,21 @@ export default function CasesPage() {
"@type": "ListItem",
position: index + 1,
name: caseStudy.title,
url: caseStudy.demoUrl ?? caseStudy.githubUrl ?? `${siteUrl}/cases`,
url: `${siteUrl}/cases/${caseStudy.id}`,
})),
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Cases",
item: `${siteUrl}/cases`,
},
],
},
],
};
@@ -61,8 +73,9 @@ export default function CasesPage() {
Cases em software, Next.js, integracoes e produtos conectados.
</h1>
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
Esta pagina organiza projetos com leitura mais indexavel e comercial, conectando stack,
contexto tecnico e tipo de problema resolvido.
Esta pagina organiza projetos com leitura mais indexavel e
comercial, conectando stack, contexto tecnico e tipo de problema
resolvido.
</p>
</div>
</div>
@@ -81,32 +94,50 @@ export default function CasesPage() {
</p>
<ul className="ds-tags mt-sp-6">
{caseStudy.stack.map((item) => (
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
<li key={item} className="ds-tag">
{item}
</MagneticButton>
</li>
))}
</ul>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<Link
href={`/cases/${caseStudy.id}`}
className="ds-link ds-link-accent"
>
Ler case
</Link>
{caseStudy.demoUrl ? (
<MagneticButton as="a" href={caseStudy.demoUrl} className="ds-link ds-link-accent" target="_blank" rel="noreferrer" strength={0.25}>
<MagneticButton
as="a"
href={caseStudy.demoUrl}
className="ds-link ds-link-muted"
target="_blank"
rel="noreferrer"
strength={0.25}
>
Abrir projeto
</MagneticButton>
) : null}
{caseStudy.githubUrl ? (
<MagneticButton as="a" href={caseStudy.githubUrl} className="ds-link ds-link-muted" target="_blank" rel="noreferrer" strength={0.2}>
Ver codigo
</MagneticButton>
) : null}
</div>
</article>
))}
</div>
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.25}>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-accent"
strength={0.25}
>
Ver servicos
</MagneticButton>
<MagneticButton as="a" href="/#contact" className="ds-link ds-link-muted" strength={0.2}>
<MagneticButton
as="a"
href="/#contact"
className="ds-link ds-link-muted"
strength={0.2}
>
Ir para contato
</MagneticButton>
</div>
+7 -7
View File
@@ -1,10 +1,10 @@
"use client";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import profilePhoto from "@/public/edev.jpg";
import { Section } from "./Section";
import { DrawLine } from "./animations/DrawLine";
import { LazyRevealImage } from "./animations/LazyRevealImage";
import { Section } from "./Section";
const STORY_DATA = [
{
@@ -133,7 +133,7 @@ export default function About() {
>
<LazyRevealImage
src={profilePhoto}
alt="Retrato de Evandro"
alt="Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, SC"
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
imageClassName="object-contain"
/>
@@ -141,10 +141,10 @@ export default function About() {
<DrawLine delay={0.05} />
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
<p className="ds-copy">
Sou engenheiro de software full-stack com foco em integrar camadas
que normalmente ficam separadas: hardware, servicos, operacao e
interface. Meu trabalho e dar forma clara ao que costuma nascer
fragmentado.
Sou engenheiro da computação e desenvolvedor de software full-stack,
atuando ha 5 anos com foco em integrar camadas que normalmente
ficam separadas: hardware, servicos, operacao e interface. Meu
trabalho e dar forma clara ao que costuma nascer fragmentado.
</p>
<div className="mt-sp-6 space-y-sp-4">
{PROFILE_POINTS.map((point) => (
+15 -29
View File
@@ -1,35 +1,23 @@
"use client";
import { useMemo } from "react";
import type { PortfolioExperience } from "@/lib/portfolio-data";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { Section } from "./Section";
import { DrawLine } from "./animations/DrawLine";
import MagneticButton from "./cinematic/MagneticButton";
import { SectionLoading } from "./SectionLoading";
export default function Experience() {
const { data, loading, error } = usePortfolioData();
export default function Experience({
experiences,
}: {
experiences: PortfolioExperience[];
}) {
const shouldReduceMotion = useReducedMotion();
const sortedExperiences = useMemo(() => {
if (!data?.experiences) return [];
return [...data.experiences]
.filter((exp) => exp.featured)
.sort((a, b) => {
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
return yearB - yearA;
});
}, [data]);
if (loading) {
return <SectionLoading message="Loading selected experience" />;
}
if (error || !data) {
return <div className="ds-error">Error loading selected experience</div>;
}
const sortedExperiences = [...experiences]
.filter((experience) => experience.featured)
.sort((a, b) => {
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
return yearB - yearA;
});
const listVariants: Variants = {
hidden: {},
@@ -108,9 +96,9 @@ export default function Experience() {
<ul className="ds-tags mt-sp-6">
{exp.technologies?.slice(0, 6).map((tech) => (
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
<li key={tech} className="ds-tag">
{tech}
</MagneticButton>
</li>
))}
</ul>
</motion.article>
@@ -119,5 +107,3 @@ export default function Experience() {
</Section>
);
}
+5 -3
View File
@@ -90,15 +90,17 @@ describe("Hero reduced motion", () => {
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
);
expect(screen.getByText(/Command center online/i)).toBeInTheDocument();
expect(
screen.getByText(/Disponível para novas oportunidades/i),
).toBeInTheDocument();
expect(
screen.getByRole("heading", {
level: 1,
name: /coloque seu sistema no ar/i,
name: /engenharia de software para sistemas/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /botar meu sistema no ar/i }),
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
).toBeInTheDocument();
});
+7 -8
View File
@@ -83,27 +83,26 @@ describe("Hero", () => {
expect(
screen.getByRole("heading", {
level: 1,
name: /coloque seu sistema no ar/i,
name: /engenharia de software para sistemas/i,
}),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /botar meu sistema no ar/i }),
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /ver casos selecionados/i }),
).toHaveAttribute("href", "/cases");
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
expect(screen.getByTestId("hero-scene")).toHaveAttribute(
"data-variant",
"command",
);
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
});
it("keeps social links, proof content and WhatsApp CTA", async () => {
render(<Hero />);
await waitFor(() =>
expect(screen.getByText(/Command center online/i)).toBeInTheDocument(),
expect(
screen.getByText(/Disponível para novas oportunidades/i),
).toBeInTheDocument(),
);
expect(screen.getByLabelText("GitHub")).toBeInTheDocument();
@@ -115,7 +114,7 @@ describe("Hero", () => {
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
fireEvent.click(
screen.getByRole("button", { name: /botar meu sistema no ar/i }),
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
);
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining("https://wa.me/"),
+38 -34
View File
@@ -3,6 +3,7 @@
import { motion, type Variants } from "framer-motion";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
import MagneticButton from "./cinematic/MagneticButton";
@@ -71,23 +72,25 @@ const HERO_VARIANTS: HeroVariant[] = [
weight: 22,
scene: "command",
layout: "default",
status: "Command center online",
chip: "Sistemas em producao, sem improviso",
status: "Disponível para novas oportunidades",
chip: "Garuva · Joinville · Curitiba · Remoto",
eyebrow:
"Evandro Fonseca Junior | Engenharia para produtos que precisam operar",
headline: "Coloque seu sistema no ar com engenharia que nao improvisa.",
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
headline:
"Engenharia de software para sistemas que precisam operar de verdade.",
subtitle:
"Se voce tem um projeto travado, um MVP para lancar ou um sistema critico precisando operar melhor, agende uma conversa tecnica e saia com o proximo passo claro para producao.",
cta: "Botar meu sistema no ar",
whatsappMessage: "Ola! Quero colocar meu sistema no ar. Podemos conversar?",
sideLabel: "Conversa tecnica pelo WhatsApp",
sideTitle: "Seu projeto precisa virar sistema em producao?",
"Profissional disponível para oportunidades CLT e PJ em Garuva, Joinville, Norte de Santa Catarina e Curitiba; também atuo em consultoria para produtos, integrações e sistemas críticos.",
cta: "Conversar sobre uma oportunidade",
whatsappMessage:
"Olá! Quero conversar sobre uma oportunidade profissional em engenharia de software.",
sideLabel: "Contato profissional direto",
sideTitle: "Procurando um engenheiro da computação para o seu time?",
sideText:
"Me conte objetivo, prazo e contexto. Eu respondo com uma conversa direta para destravar arquitetura, entrega e deploy.",
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
points: [
"Conversa tecnica para tirar o projeto do papel sem improviso.",
"Arquitetura, codigo e deploy conectados ao resultado do negocio.",
"Plano claro para colocar o sistema no ar com seguranca.",
"Engenharia full-stack com base em sistemas e integrações.",
"Next.js, TypeScript, Rust, IoT e arquitetura de software.",
"Disponível para times do Norte de SC, Curitiba e atuação remota.",
],
proof: [
{
@@ -293,18 +296,6 @@ const LAYOUT_CLASSES: Record<HeroVariant["layout"], string> = {
"grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
};
function pickWeightedHeroVariant() {
const total = HERO_VARIANTS.reduce((sum, variant) => sum + variant.weight, 0);
let ticket = Math.random() * total;
for (const variant of HERO_VARIANTS) {
ticket -= variant.weight;
if (ticket <= 0) return variant;
}
return HERO_VARIANTS[0];
}
function getGreeting(message: string) {
const hour = new Date().getHours();
if (hour >= 8 && hour < 12) return `Bom dia! ${message}`;
@@ -436,16 +427,31 @@ function SideVisual({ variant }: { variant: HeroVariant }) {
}
export default function Hero() {
const [variant, setVariant] = useState<HeroVariant>(HERO_VARIANTS[0]);
const variant = HERO_VARIANTS[0];
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
useEffect(() => {
setVariant(pickWeightedHeroVariant());
setShouldReduceMotion(
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
);
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
setShouldReduceMotion(reducedMotion);
setWhatsAppDisplay(getWhatsAppDisplayNumber());
if (
!shouldLoadHeroScene({
reducedMotion,
coarsePointer,
width: window.innerWidth,
})
) {
return;
}
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
return () => window.clearTimeout(timer);
}, []);
const handleWhatsAppClick = () => {
@@ -505,7 +511,7 @@ export default function Hero() {
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
data-hero-variant={variant.id}
>
{!shouldReduceMotion && <HeroScene variant={variant.scene} />}
{shouldRenderHeroScene && <HeroScene variant={variant.scene} />}
<div className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(ellipse_80%_60%_at_50%_40%,transparent_0%,rgba(10,10,10,0.4)_50%,var(--ink)_100%)]" />
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
@@ -575,9 +581,7 @@ export default function Hero() {
className="max-w-[940px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
variants={heroItemSlow}
>
<span className="cinematic-text-gradient">
{variant.headline}
</span>
{variant.headline}
</motion.h1>
<motion.p
+24 -27
View File
@@ -1,30 +1,18 @@
"use client";
import { useMemo } from "react";
import type { PortfolioProject } from "@/lib/portfolio-data";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { Section } from "./Section";
import MagneticButton from "./cinematic/MagneticButton";
import { DrawLine } from "./animations/DrawLine";
import { SectionLoading } from "./SectionLoading";
export default function Projects() {
const { data, loading, error } = usePortfolioData();
export default function Projects({
projects,
}: {
projects: PortfolioProject[];
}) {
const shouldReduceMotion = useReducedMotion();
const projects = useMemo(
() => data?.projects.filter((p) => p.featured) || [],
[data],
);
if (loading) {
return <SectionLoading dark message="Loading selected work" />;
}
if (error) {
return <div className="ds-error">Error loading selected work</div>;
}
const gridVariants: Variants = {
hidden: {},
visible: {
@@ -92,16 +80,18 @@ export default function Projects() {
</p>
)}
</div>
<MagneticButton as="span" className="ds-tag" strength={0.15}>{project.status}</MagneticButton>
<MagneticButton as="span" className="ds-tag" strength={0.15}>
{project.status}
</MagneticButton>
</div>
<p className="ds-copy">{project.description}</p>
<ul className="ds-tags mt-sp-6">
{project.technologies.slice(0, 5).map((tech: string) => (
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
{project.technologies.slice(0, 5).map((tech) => (
<li key={tech} className="ds-tag">
{tech}
</MagneticButton>
</li>
))}
</ul>
@@ -136,16 +126,23 @@ export default function Projects() {
</motion.div>
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
<MagneticButton as="a" href="/cases" className="ds-link ds-link-accent" strength={0.2}>
<MagneticButton
as="a"
href="/cases"
className="ds-link ds-link-accent"
strength={0.2}
>
Explorar todos os cases
</MagneticButton>
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-muted" strength={0.2}>
<MagneticButton
as="a"
href="/servicos"
className="ds-link ds-link-muted"
strength={0.2}
>
Ver servicos relacionados
</MagneticButton>
</div>
</Section>
);
}
+9 -18
View File
@@ -1,24 +1,17 @@
"use client";
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
import { motion, type Variants, useReducedMotion } from "framer-motion";
import { usePortfolioData } from "@/lib/usePortfolioData";
import { Section } from "./Section";
import MagneticButton from "./cinematic/MagneticButton";
import { DrawLine } from "./animations/DrawLine";
import { SectionLoading } from "./SectionLoading";
export default function Skills() {
const { data, loading, error } = usePortfolioData();
export default function Skills({
categories,
}: {
categories: PortfolioSkillCategory[];
}) {
const shouldReduceMotion = useReducedMotion();
if (loading) {
return <SectionLoading dark message="Loading core stack" />;
}
if (error || !data) {
return <div className="ds-error">Error loading core stack</div>;
}
const gridVariants: Variants = {
hidden: {},
visible: {
@@ -59,7 +52,7 @@ export default function Skills() {
viewport={{ once: true, amount: 0.2 }}
variants={gridVariants}
>
{data.skills.technical.map((category) => (
{categories.map((category) => (
<motion.article
key={category.category}
className="ds-card h-full"
@@ -83,9 +76,9 @@ export default function Skills() {
</p>
<ul className="ds-tags">
{category.skills.map((skill) => (
<MagneticButton key={skill} as="li" className="ds-tag" strength={0.15}>
<li key={skill} className="ds-tag">
{skill}
</MagneticButton>
</li>
))}
</ul>
</motion.article>
@@ -94,5 +87,3 @@ export default function Skills() {
</Section>
);
}
+46 -27
View File
@@ -1,24 +1,53 @@
"use client";
import dynamic from "next/dynamic";
import { Suspense } from "react";
import type {
PortfolioExperience,
PortfolioProject,
PortfolioSkillCategory,
} from "@/lib/portfolio-data";
import Hero from "../Hero";
import Projects from "../Projects";
import { SectionLoading } from "../SectionLoading";
import CinematicSection from "./CinematicSection";
const About = dynamic(() => import("../About"), { loading: () => <SectionLoading message="Carregando..." /> });
const Skills = dynamic(() => import("../Skills"), { loading: () => <SectionLoading message="Carregando..." /> });
const Experience = dynamic(() => import("../Experience"), { loading: () => <SectionLoading message="Carregando..." /> });
const WorkModalities = dynamic(() => import("../WorkModalities").then(mod => mod.WorkModalities), { loading: () => <SectionLoading message="Carregando..." /> });
const Contact = dynamic(() => import("../Contact"), { loading: () => <SectionLoading message="Carregando..." /> });
const Projects = dynamic(() => import("../Projects"));
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), { loading: () => <SectionLoading message="Carregando..." /> });
const About = dynamic(() => import("../About"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Skills = dynamic(() => import("../Skills"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Experience = dynamic(() => import("../Experience"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const WorkModalities = dynamic(
() => import("../WorkModalities").then((mod) => mod.WorkModalities),
{ loading: () => <SectionLoading message="Carregando..." /> },
);
const Contact = dynamic(() => import("../Contact"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), {
loading: () => <SectionLoading message="Carregando..." />,
});
const Footer = dynamic(() => import("../Footer"));
const CinematicSection = dynamic(() => import("./CinematicSection"), { ssr: false });
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), { ssr: false });
const SectionDivider = dynamic(() => import("./SectionDivider"), { ssr: false });
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), {
ssr: false,
});
const SectionDivider = dynamic(() => import("./SectionDivider"), {
ssr: false,
});
export default function CinematicHome() {
export default function CinematicHome({
projects,
experiences,
skillCategories,
}: {
projects: PortfolioProject[];
experiences: PortfolioExperience[];
skillCategories: PortfolioSkillCategory[];
}) {
return (
<div className="flex min-h-screen flex-col overflow-x-hidden">
<main className="ds-main">
@@ -26,10 +55,7 @@ export default function CinematicHome() {
{/* Marquee divider after hero */}
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
<ScrollVelocityText
text="SOFTWARE ENGINEERING"
baseVelocity={-1.5}
/>
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
</div>
<CinematicSection effect="parallax-depth">
@@ -39,7 +65,7 @@ export default function CinematicHome() {
<SectionDivider variant="waveform" />
<CinematicSection effect="clip-reveal">
<Skills />
<Skills categories={skillCategories} />
</CinematicSection>
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
@@ -50,17 +76,13 @@ export default function CinematicHome() {
</div>
<CinematicSection effect="fade-scale">
<Experience />
<Experience experiences={experiences} />
</CinematicSection>
<SectionDivider variant="gradient" />
<CinematicSection effect="rotate-in">
<Suspense
fallback={<SectionLoading message="Carregando projetos..." />}
>
<Projects />
</Suspense>
<Projects projects={projects} />
</CinematicSection>
<CinematicSection effect="clip-reveal">
@@ -74,10 +96,7 @@ export default function CinematicHome() {
</CinematicSection>
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
<ScrollVelocityText
text="VAMOS CONVERSAR"
baseVelocity={-2}
/>
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
</div>
<CinematicSection effect="fade-scale">
@@ -1,10 +1,10 @@
"use client";
import dynamic from "next/dynamic";
import PageIntro from "./PageIntro";
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
const PageIntro = dynamic(() => import("./PageIntro"), { ssr: false });
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
@@ -0,0 +1,53 @@
import { act, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import PageIntro from "./PageIntro";
jest.mock("react-dom", () => ({
...jest.requireActual("react-dom"),
createPortal: (children: ReactNode) => children,
}));
jest.mock("framer-motion", () => {
const React = require("react");
const motion = new Proxy(
{},
{
get:
(_target, tag: string) =>
({ children, ...props }: { children?: ReactNode }) =>
React.createElement(tag, props, children),
},
);
return {
AnimatePresence: ({ children }: { children: ReactNode }) => children,
motion,
useReducedMotion: () => false,
};
});
describe("PageIntro", () => {
it("renders the compact signal loading state without a fake percentage", () => {
render(<PageIntro />);
expect(
screen.getByRole("heading", { name: /EVANDRO.*DEV/ }),
).toBeInTheDocument();
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
expect(screen.queryByText("000")).not.toBeInTheDocument();
});
it("stays visible long enough to be perceived on a fast page load", () => {
jest.useFakeTimers();
render(<PageIntro />);
act(() => jest.advanceTimersByTime(900));
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
act(() => jest.runAllTimers());
expect(screen.queryByText("Preparando o portfólio")).not.toBeInTheDocument();
jest.useRealTimers();
});
});
+74 -146
View File
@@ -1,177 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { motion, AnimatePresence } from "framer-motion";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const INTRO_LINES = [
"EVANDRO",
"FONSECA",
"JUNIOR",
];
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
export default function PageIntro() {
const [isVisible, setIsVisible] = useState(true);
const [counter, setCounter] = useState(0);
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(true);
const reduceMotion = useReducedMotion();
useEffect(() => {
setMounted(true);
// Remove the immediate blocking overlay now that the animated loader is ready
const blocker = document.getElementById("__loading-blocker");
if (blocker) blocker.remove();
}, []);
const timeout = window.setTimeout(
() => setVisible(false),
reduceMotion ? 150 : 1350,
);
useEffect(() => {
if (!mounted) return;
// Animate counter from 0 to 100
const duration = 2000;
const start = performance.now();
const tick = (now: number) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// Eased progress
const eased = 1 - Math.pow(1 - progress, 3);
setCounter(Math.floor(eased * 100));
if (progress < 1) {
requestAnimationFrame(tick);
} else {
// Wait a beat then hide
setTimeout(() => setIsVisible(false), 400);
}
};
requestAnimationFrame(tick);
}, [mounted]);
return () => window.clearTimeout(timeout);
}, [reduceMotion]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{isVisible && (
{visible && (
<motion.div
className="fixed inset-0 z-[99999] flex items-center justify-center bg-[#0a0a0a]"
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, width: "100vw", height: "100vh" }}
exit={{
clipPath: "inset(0 0 100% 0)",
transition: { duration: 1, ease: [0.76, 0, 0.24, 1] },
}}
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
initial={{ opacity: 1 }}
exit={
reduceMotion
? { opacity: 0, transition: { duration: 0.15 } }
: {
clipPath: "inset(0 0 100% 0)",
transition: { duration: 0.65, ease: EXIT_EASE },
}
}
aria-live="polite"
aria-label="Preparando o portfólio"
>
{/* Horizontal lines decoration */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
className="absolute left-0 h-px w-full bg-[rgba(204,255,0,0.06)]"
style={{ top: `${20 + i * 15}%` }}
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{
duration: 1.5,
delay: 0.1 * i,
ease: [0.76, 0, 0.24, 1],
}}
/>
))}
</div>
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
{/* Counter */}
<motion.div
className="absolute bottom-12 right-12 font-mono text-[clamp(48px,8vw,96px)] font-bold leading-none tracking-tighter text-[rgba(204,255,0,0.15)]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
{counter.toString().padStart(3, "0")}
</motion.div>
{/* Progress bar */}
<div className="absolute bottom-8 left-12 right-12 h-px bg-[rgba(255,255,255,0.06)]">
<div className="relative m-auto w-[min(88vw,680px)]">
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
>
SINAL DE ENTRADA
</motion.p>
<motion.h1
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.48,
delay: 0.06,
ease: [0.16, 1, 0.3, 1],
}}
>
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
</motion.h1>
<motion.div
className="h-full bg-[var(--slash)]"
initial={{ width: "0%" }}
animate={{ width: `${counter}%` }}
transition={{ duration: 0.1 }}
/>
</div>
{/* Name reveal */}
<div className="relative flex flex-col items-center gap-0">
{INTRO_LINES.map((line, i) => (
<div key={line} className="overflow-hidden">
<motion.div
className="font-display text-[clamp(2.5rem,10vw,7rem)] font-extrabold leading-[0.9] tracking-[-0.06em] text-[#f4f2ed]"
initial={{ y: "100%", rotateX: -40 }}
animate={{ y: "0%", rotateX: 0 }}
transition={{
duration: 0.8,
delay: 0.3 + i * 0.12,
ease: [0.16, 1, 0.3, 1],
}}
>
{line}
</motion.div>
</div>
))}
{/* Subtitle */}
<motion.div
className="mt-6 overflow-hidden"
initial={{ opacity: 0 }}
className="mt-sp-8 flex items-center gap-sp-4"
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1, duration: 0.6 }}
transition={{ duration: 0.28, delay: 0.18 }}
>
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.3em] text-[rgba(204,255,0,0.7)]"
initial={{ y: 20 }}
animate={{ y: 0 }}
transition={{ delay: 1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
>
Software Engineer
</motion.p>
<span className="relative block h-2 w-2" aria-hidden="true">
<span className="absolute inset-0 bg-[var(--slash)]" />
{!reduceMotion && (
<motion.span
className="absolute inset-0 bg-[var(--slash)]"
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
transition={{
duration: 0.8,
repeat: Number.POSITIVE_INFINITY,
}}
/>
)}
</span>
<p className="font-mono text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
Preparando o portfólio
</p>
<span
className="h-px flex-1 bg-[rgba(244,242,237,.22)]"
aria-hidden="true"
/>
</motion.div>
</div>
{/* Corner markers */}
<div className="pointer-events-none absolute inset-0">
{/* Top-left */}
<motion.div
className="absolute left-8 top-8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{ delay: 0.5 }}
>
<div className="h-6 w-px bg-[var(--slash)]" />
<div className="absolute left-0 top-0 h-px w-6 bg-[var(--slash)]" />
</motion.div>
{/* Top-right */}
<motion.div
className="absolute right-8 top-8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{ delay: 0.6 }}
>
<div className="absolute right-0 top-0 h-6 w-px bg-[var(--slash)]" />
<div className="absolute right-0 top-0 h-px w-6 bg-[var(--slash)]" />
</motion.div>
{/* Bottom-left */}
<motion.div
className="absolute bottom-8 left-8"
initial={{ opacity: 0 }}
animate={{ opacity: 0.3 }}
transition={{ delay: 0.7 }}
>
<div className="h-6 w-px bg-[var(--slash)]" />
<div className="absolute bottom-0 left-0 h-px w-6 bg-[var(--slash)]" />
</motion.div>
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[9px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.45)] lg:bottom-10 lg:left-12 lg:right-12">
<span>Engenharia de software</span>
<span>Garuva · Joinville · Curitiba</span>
</div>
</motion.div>
)}
</AnimatePresence>,
document.body
document.body,
);
}
+133
View File
@@ -0,0 +1,133 @@
import type { Metadata } from "next";
import Link from "next/link";
import { portfolio } from "@/lib/portfolio-data";
import { siteName, siteUrl } from "@/lib/site-content";
const pageUrl = `${siteUrl}/curriculo`;
export const metadata: Metadata = {
title: `Currículo | Engenheiro da Computação e Desenvolvedor de Software em Garuva, Joinville e Curitiba | ${siteName}`,
description:
"Currículo de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba. Experiência em Next.js, TypeScript, Node.js, Rust, IoT e sistemas críticos.",
alternates: { canonical: pageUrl },
openGraph: {
title: `Currículo de Evandro Fonseca Junior | ${siteName}`,
description:
"Engenheiro da computação e desenvolvedor de software disponível para oportunidades CLT, PJ e projetos estratégicos no Norte de SC, Curitiba e remoto.",
url: pageUrl,
siteName,
type: "profile",
},
};
export default function CurriculoPage() {
const jsonLd = {
"@context": "https://schema.org",
"@type": "ProfilePage",
dateModified: new Date().toISOString(),
mainEntity: {
"@type": "Person",
"@id": `${siteUrl}/#person`,
name: portfolio.personal.name,
jobTitle:
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
description: portfolio.personal.summary,
url: siteUrl,
sameAs: [portfolio.personal.github, portfolio.personal.linkedin],
homeLocation: {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
},
knowsAbout: portfolio.skills.technical.flatMap(
(category) => category.skills,
),
},
};
return (
<main>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<section className="ds-section ds-section-dark">
<div className="ds-section-shell max-w-[960px]">
<p className="ds-eyebrow">Currículo profissional</p>
<h1 className="ds-section-title text-white">
Engenheiro da Computação e Desenvolvedor de Software para Garuva,
Joinville, Curitiba e remoto.
</h1>
<p className="ds-copy max-w-[70ch] !text-[rgba(255,255,255,.78)]">
{portfolio.personal.summary} Estou disponível para oportunidades CLT
e PJ, com prioridade para times e empresas do Norte de Santa
Catarina e Curitiba que precisam de execução técnica consistente.
</p>
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
<a className="ds-link ds-link-accent" href="/Profile.pdf" download>
Baixar currículo em PDF
</a>
<Link className="ds-link ds-link-muted" href="/#contact">
Falar sobre uma oportunidade
</Link>
</div>
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<h2 className="ds-section-title">Experiência profissional</h2>
<div className="mt-sp-8 space-y-sp-6">
{portfolio.experiences.map((experience) => (
<article key={experience.id} className="ds-card">
<p className="ds-kicker">
{experience.period} · {experience.location}
</p>
<h3 className="ds-card-title">
{experience.title} — {experience.company}
</h3>
<p className="ds-copy">{experience.description}</p>
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
{experience.achievements.map((achievement) => (
<li key={achievement}>{achievement}</li>
))}
</ul>
<ul className="ds-tags mt-sp-6">
{experience.technologies.map((technology) => (
<li key={technology} className="ds-tag">
{technology}
</li>
))}
</ul>
</article>
))}
</div>
</div>
</section>
<section className="ds-section">
<div className="ds-section-shell">
<h2 className="ds-section-title">Competências técnicas</h2>
<div className="ds-grid-2 mt-sp-8">
{portfolio.skills.technical.map((category) => (
<article key={category.category} className="ds-card">
<h3 className="ds-card-title">{category.category}</h3>
<ul className="ds-tags mt-sp-5">
{category.skills.map((skill) => (
<li key={skill} className="ds-tag">
{skill}
</li>
))}
</ul>
</article>
))}
</div>
<div className="mt-sp-8">
<Link className="ds-link ds-link-accent" href="/cases">
Ver projetos e cases
</Link>
</div>
</div>
</section>
</main>
);
}
+12 -38
View File
@@ -28,7 +28,7 @@
--white: #ffffff;
--slash: #ccff00;
--slash-dim: rgba(204, 255, 0, 0.16);
--meta: #595959;
--meta: #4a4a4a;
--border: rgba(10, 10, 10, 0.1);
--border-light: rgba(10, 10, 10, 0.06);
--sp-1: 4px;
@@ -189,7 +189,7 @@ textarea {
.ds-section-description {
max-width: 58ch;
color: rgba(10, 10, 10, 0.62);
color: rgba(10, 10, 10, 0.74);
font-size: 14px;
line-height: 1.82;
}
@@ -216,11 +216,10 @@ textarea {
}
.ds-card {
background: rgba(255, 255, 255, 0.74);
background: rgba(255, 255, 255, 0.92);
padding: clamp(20px, 3vw, 28px);
display: flex;
flex-direction: column;
backdrop-filter: blur(10px);
}
.ds-panel {
@@ -555,45 +554,20 @@ textarea {
/* Cinematic text gradient - hero heading */
.cinematic-text-gradient {
background: linear-gradient(
135deg,
#ffffff 0%,
#ffffff 25%,
#ccff00 50%,
#ffffff 75%,
#a7a7a7 100%
);
background-size: 300% 300%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: textGradientShift 8s ease-in-out infinite;
color: #f4f2ed;
}
@keyframes textGradientShift {
0%,
100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* Glassmorphism cards */
/* Solid surfaces avoid expensive backdrop blur during first paint. */
.cinematic-glass {
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(10, 10, 10, 0.4);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(10, 10, 10, 0.78);
transition:
border-color 0.25s ease,
background 0.25s ease;
}
.cinematic-glass:hover {
border-color: rgba(204, 255, 0, 0.15);
background: rgba(10, 10, 10, 0.5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
inset 0 0 0 1px rgba(204, 255, 0, 0.05);
border-color: rgba(204, 255, 0, 0.35);
background: rgba(10, 10, 10, 0.9);
}
/* Primary CTA - cinematic glow button */
+1 -8
View File
@@ -1,8 +1 @@
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
<defs>
<image width="978" height="981" id="img1" href="data:image/png;base64,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 truncated
</defs>
<style>
</style>
<use id="Background" href="#img1" x="13" y="21"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-labelledby="title"><title>Evandro Fonseca Junior</title><rect width="64" height="64" fill="#0a0a0a"/><path fill="#ccff00" d="M12 12h40v8H21v8h25v8H21v8h31v8H12z"/><path fill="#f4f2ed" d="M22 24h22v8H22z"/></svg>

Before

Width:  |  Height:  |  Size: 383 KiB

After

Width:  |  Height:  |  Size: 288 B

+92 -47
View File
@@ -2,18 +2,19 @@ import type { Metadata, Viewport } from "next";
import { Geist_Mono, Syne } from "next/font/google";
import localFont from "next/font/local";
import type { ReactNode } from "react";
import GoogleAnalytics from "./components/GoogleAnalytics";
import { regionCities, siteUrl } from "@/lib/site-content";
import { getWhatsAppNumber } from "@/lib/whatsapp";
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
import GoogleAnalytics from "./components/GoogleAnalytics";
import { RouteTransition } from "./components/RouteTransition";
import { AnimationProvider } from "./providers/AnimationProvider";
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
import "./globals.css";
const siteUrl = "https://evandro.dev.br";
const seoTitle =
"Engenharia de Software, Next.js, Tauri, Rust e Consultoria Tecnica | Evandro Fonseca Junior";
"Engenheiro da Computação e Desenvolvedor de Software em Garuva, Joinville e Curitiba | Evandro Fonseca Junior";
const seoDescription =
"Consultoria e engenharia de software para Next.js, Tauri, Rust, IoT e sistemas criticos. Arquitetura de software e integracoes hardware + software para empresas que precisam de estabilidade e escala.";
"Evandro Fonseca Junior é engenheiro da computação, desenvolvedor de software e criação de sites para empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul, Norte de Santa Catarina e Curitiba. Atua com Next.js, TypeScript, Rust, IoT, integração de sistemas e produtos críticos.";
const syne = Syne({
subsets: ["latin"],
@@ -50,20 +51,31 @@ export const metadata: Metadata = {
title: seoTitle,
description: seoDescription,
keywords: [
"engenharia de software",
"engenheiro de software",
"consultoria tecnica",
"consultoria next.js",
"next.js",
"tauri",
"rust",
"desenvolvedor de software em Garuva",
"engenheiro da computação em Garuva",
"desenvolvedor de sites em Garuva",
"desenvolvedor de software em Joinville",
"engenheiro da computação em Joinville",
"desenvolvedor de sites em Joinville",
"criação de sites em Joinville",
"programador em Joinville",
"desenvolvedor de software em Curitiba",
"engenheiro da computação em Curitiba",
"criação de sites em Curitiba",
"desenvolvedor de sites em Curitiba",
"programador em Curitiba",
"desenvolvedor de software em São Francisco do Sul",
"desenvolvedor de software em Itapoá",
"desenvolvedor de software em Jaraguá do Sul",
"desenvolvedor full stack Santa Catarina",
"engenharia de software Norte de Santa Catarina",
"criação de sites Norte de Santa Catarina",
"desenvolvimento de sistemas web Santa Catarina",
"Next.js",
"TypeScript",
"Rust",
"IoT",
"arquitetura de software",
"sistemas criticos",
"integracao hardware software",
"iot",
"software para iot",
"engenharia full-stack",
"aplicacoes de alta performance",
],
authors: [{ name: "Evandro Fonseca Junior" }],
creator: "Evandro Fonseca Junior",
@@ -138,27 +150,48 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
inLanguage: "pt-BR",
description: seoDescription,
},
{
"@type": "ProfilePage",
"@id": `${siteUrl}/curriculo#profile`,
url: `${siteUrl}/curriculo`,
inLanguage: "pt-BR",
mainEntity: { "@id": `${siteUrl}/#person` },
},
{
"@type": "Person",
"@id": `${siteUrl}/#person`,
name: "Evandro Fonseca Junior",
alternateName: ["Evandro.dev", "evandrodevbr"],
jobTitle: "Engenheiro de Software Full-Stack",
jobTitle:
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
description: seoDescription,
url: siteUrl,
image: `${siteUrl}/opengraph-image`,
telephone: `+${getWhatsAppNumber()}`,
sameAs: [
"https://github.com/evandrodevbr",
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
"https://www.instagram.com/evandrodevbr/",
],
worksFor: {
"@type": "Organization",
name: "Consultoria Independente",
address: {
"@type": "PostalAddress",
addressLocality: "Garuva",
addressRegion: "SC",
addressCountry: "BR",
},
homeLocation: {
"@type": "Place",
name: "Garuva, Santa Catarina, Brasil",
geo: {
"@type": "GeoCoordinates",
latitude: -26.0281,
longitude: -48.8467,
},
},
hasOccupation: {
"@type": "Occupation",
name: "Engenheiro da Computação e Desenvolvedor de Software",
occupationalCategory: "15-1252.00 Software Developers",
},
knowsAbout: [
"Engenharia de Software",
@@ -166,8 +199,8 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
"Tauri",
"Rust",
"Arquitetura de Software",
"Sistemas Criticos",
"Integracao hardware e software",
"Sistemas Críticos",
"Integração hardware e software",
"IoT",
"TypeScript",
"Cloud Computing",
@@ -176,25 +209,46 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
{
"@type": "ProfessionalService",
"@id": `${siteUrl}/#services`,
name: "Evandro Fonseca Junior | Engenharia de Software e Consultoria Tecnica",
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Tecnica",
url: siteUrl,
image: `${siteUrl}/opengraph-image`,
telephone: `+${getWhatsAppNumber()}`,
priceRange: "$$",
address: {
"@type": "PostalAddress",
addressLocality: "Garuva",
addressRegion: "SC",
addressCountry: "BR",
},
geo: {
"@type": "GeoCoordinates",
latitude: -26.0281,
longitude: -48.8467,
},
description:
"Servico de engenharia de software, arquitetura de software, integracoes criticas e consultoria tecnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
"Servico de engenharia de software, criacao de sites, arquitetura de software, integracoes criticas e consultoria tecnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
areaServed: [
...regionCities.map((name) => ({ "@type": "City", name })),
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
{ "@type": "AdministrativeArea", name: "Paraná" },
{
"@type": "Country",
name: "Brasil",
},
{
"@type": "Place",
name: "Remote worldwide",
"@type": "GeoCircle",
geoMidpoint: {
"@type": "GeoCoordinates",
latitude: -26.0281,
longitude: -48.8467,
},
geoRadius: "150000",
},
{ "@type": "Country", name: "Brasil" },
{ "@type": "Place", name: "Atuação remota" },
],
provider: {
"@id": `${siteUrl}/#person`,
},
serviceType: [
"Engenharia de Software",
"Criação de Sites",
"Consultoria Tecnica",
"Arquitetura de Software",
"Integracao hardware + software",
@@ -208,6 +262,13 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
"@type": "OfferCatalog",
name: "Modelos de contratacao",
itemListElement: [
{
"@type": "Offer",
itemOffered: {
"@type": "Service",
name: "Criação de sites institucionais e sistemas web",
},
},
{
"@type": "Offer",
itemOffered: {
@@ -240,22 +301,6 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
lang="pt-BR"
className={`${syne.variable} ${geistMono.variable} ${monumentExtended.variable}`}
>
<head>
<link rel="canonical" href={siteUrl} />
{/* Blocking script: creates immediate black overlay BEFORE any paint.
This prevents the flash of real content before React hydrates
and PageIntro mounts. PageIntro removes the #__loading-blocker div. */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){
var el=document.createElement('div');
el.id='__loading-blocker';
el.style.cssText='position:fixed;inset:0;background:#0a0a0a;z-index:99998;pointer-events:none;';
document.documentElement.appendChild(el);
})();`,
}}
/>
</head>
<body>
<SmoothScrollProvider>
<AnimationProvider>
+37
View File
@@ -0,0 +1,37 @@
import { shouldLoadHeroScene } from "./should-load-hero-scene";
describe("shouldLoadHeroScene", () => {
it("keeps WebGL off for reduced motion, coarse pointers, and narrow screens", () => {
expect(
shouldLoadHeroScene({
reducedMotion: true,
coarsePointer: false,
width: 1440,
}),
).toBe(false);
expect(
shouldLoadHeroScene({
reducedMotion: false,
coarsePointer: true,
width: 1440,
}),
).toBe(false);
expect(
shouldLoadHeroScene({
reducedMotion: false,
coarsePointer: false,
width: 767,
}),
).toBe(false);
});
it("allows WebGL only on capable desktop screens", () => {
expect(
shouldLoadHeroScene({
reducedMotion: false,
coarsePointer: false,
width: 1024,
}),
).toBe(true);
});
});
+11
View File
@@ -0,0 +1,11 @@
export function shouldLoadHeroScene({
reducedMotion,
coarsePointer,
width,
}: {
reducedMotion: boolean;
coarsePointer: boolean;
width: number;
}) {
return !reducedMotion && !coarsePointer && width >= 1024;
}
+3 -3
View File
@@ -2,16 +2,16 @@ import { MetadataRoute } from "next";
export default function manifest(): MetadataRoute.Manifest {
return {
name: "Evandro Fonseca Junior - Portfólio",
name: "Evandro Fonseca Junior — Engenheiro da Computação e Desenvolvedor de Software",
short_name: "Evandro.dev",
description:
"Portfólio profissional de Evandro Fonseca Junior, Desenvolvedor Full-Stack",
"Portfólio profissional de Evandro Fonseca Junior, engenheiro da computação e desenvolvedor de software em Garuva, Joinville, Norte de Santa Catarina e Curitiba.",
start_url: "/",
display: "standalone",
background_color: "#000000",
theme_color: "#000000",
orientation: "portrait-primary",
categories: ["portfolio", "developer", "technology"],
categories: ["portfolio", "developer", "engineering", "technology"],
lang: "pt-BR",
dir: "ltr",
icons: [
+2 -2
View File
@@ -1,6 +1,6 @@
import { ImageResponse } from "next/og";
export const alt = "Evandro Fonseca Junior - Engenheiro de Software";
export const alt = "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
export const size = {
width: 1200,
height: 630,
@@ -40,7 +40,7 @@ export default async function Image() {
textAlign: "center",
}}
>
Engenheiro de Software
Engenheiro da Computação e Desenvolvedor de Software
</div>
</div>,
{ ...size },
+11 -2
View File
@@ -1,5 +1,14 @@
import { loadLiveFeaturedProjects, portfolio } from "@/lib/portfolio-data";
import CinematicHome from "./components/cinematic/CinematicHome";
export default function Home() {
return <CinematicHome />;
export const revalidate = 300;
export default async function Home() {
return (
<CinematicHome
projects={await loadLiveFeaturedProjects()}
experiences={portfolio.experiences}
skillCategories={portfolio.skills.technical}
/>
);
}
+3
View File
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import {
getRelatedCases,
getServicePage,
regionCities,
servicePages,
siteName,
siteUrl,
@@ -82,6 +83,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
url: siteUrl,
},
areaServed: [
...regionCities.map((name) => ({ "@type": "City", name })),
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
{
"@type": "Country",
name: "Brasil",
+12
View File
@@ -42,6 +42,18 @@ export default function ServicesIndexPage() {
name: service.navLabel,
})),
},
{
"@type": "BreadcrumbList",
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
{
"@type": "ListItem",
position: 2,
name: "Servicos",
item: `${siteUrl}/servicos`,
},
],
},
],
};
+14 -2
View File
@@ -1,5 +1,5 @@
import { MetadataRoute } from "next";
import { servicePages, siteUrl } from "@/lib/site-content";
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
export default function sitemap(): MetadataRoute.Sitemap {
const now = new Date();
@@ -8,9 +8,15 @@ export default function sitemap(): MetadataRoute.Sitemap {
{
url: siteUrl,
lastModified: now,
changeFrequency: "monthly",
changeFrequency: "weekly",
priority: 1.0,
},
{
url: `${siteUrl}/curriculo`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.95,
},
{
url: `${siteUrl}/servicos`,
lastModified: now,
@@ -29,5 +35,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly" as const,
priority: 0.8,
})),
...caseStudies.map((caseStudy) => ({
url: `${siteUrl}/cases/${caseStudy.id}`,
lastModified: now,
changeFrequency: "monthly" as const,
priority: 0.75,
})),
];
}
+33
View File
@@ -0,0 +1,33 @@
{
"checkedAt": "2026-07-11T05:31:16.931Z",
"projects": {
"sev-modaintima": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"checkedAt": "2026-07-11T05:31:16.931Z",
"finalUrl": "https://sevmodaintima.com.br/",
"latencyMs": 248
},
"nutriscan": {
"status": null,
"contentType": null,
"checkedAt": "2026-07-11T05:31:16.931Z",
"latencyMs": 55,
"error": "fetch failed"
},
"twins-lab": {
"status": 404,
"contentType": "text/plain; charset=utf-8",
"checkedAt": "2026-07-11T05:31:16.931Z",
"finalUrl": "https://twins.evandro.dev.br/",
"latencyMs": 75
},
"sc-plus": {
"status": 502,
"contentType": "text/html; charset=UTF-8",
"checkedAt": "2026-07-11T05:31:16.931Z",
"finalUrl": "https://scplus.evandro.dev.br/",
"latencyMs": 79
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# Verificação automática de projetos na VPS
A lista pública de projetos só exibe demos cujo snapshot atual tenha resposta final HTTP `200` e `Content-Type: text/html`. O snapshot vence após 15 minutos; se não houver uma verificação atual, nenhum projeto é exibido.
## Pré-requisito
O cron deve executar no mesmo diretório de release que contém `data/project-health.json` consumido pelo processo Next.js. Em deploys com diretório versionado, aponte o cron para o symlink `current`.
```cron
*/10 * * * * cd /var/www/evandro.dev.br/current && /usr/bin/node scripts/check-projects.mjs >> /var/log/evandro-project-health.log 2>&1
```
Para validar manualmente:
```bash
cd /var/www/evandro.dev.br/current
npm run check:projects
cat data/project-health.json
```
O script segue redirecionamentos, limita cada requisição a 10 segundos, não aceita `3xx`, `4xx`, `5xx`, DNS/TLS inválido nem conteúdo não HTML. Ele grava em arquivo temporário e renomeia ao final, evitando que o Next.js leia JSON parcial.
Após a primeira instalação, rode `npm run check:projects` antes de iniciar a aplicação. O ISR da home é de 5 minutos; portanto, uma alteração de disponibilidade pode levar até esse intervalo adicional para aparecer no HTML público.
@@ -0,0 +1,82 @@
# Portfolio Growth Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make the portfolio fast, indexable, regionally relevant, and limited to live project demos.
**Architecture:** Project metadata remains static and is read on the server. A VPS cron runs a small Node script that writes an atomic health snapshot; server rendering filters featured projects by final HTTP 200 HTML results. Interactive motion is isolated from semantic server-rendered content, and SEO is represented by Next metadata plus visible resume/case content and matching schema.org.
**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Jest, Node.js fetch, cron.
---
### Task 1: Project-health domain
**Files:**
- Create: `lib/project-health.ts`
- Create: `lib/project-health.test.ts`
- Create: `scripts/check-projects.mjs`
- Create: `data/project-health.json`
- [ ] Write a failing test: only current, final-200 HTML project IDs are accepted.
- [ ] Run `npm test -- lib/project-health.test.ts --runInBand`; expect failure because the module is absent.
- [ ] Implement pure snapshot parsing/filtering and seed a safe empty snapshot.
- [ ] Run the focused test; expect pass.
- [ ] Implement the cron script with bounded concurrent GET checks, 10-second timeout, HTML content-type test, atomic JSON rename and nonzero exit only for configuration failures.
### Task 2: Render data on the server
**Files:**
- Create: `lib/portfolio-data.ts`
- Modify: `app/components/Projects.tsx`
- Modify: `app/components/Experience.tsx`
- Modify: `app/components/Skills.tsx`
- Modify: `app/components/cinematic/CinematicHome.tsx`
- [ ] Write failing tests for project filtering and server data adapters.
- [ ] Move static portfolio JSON parsing into a typed server module.
- [ ] Pass filtered projects, experiences and skills as props; retain client animation only within components.
- [ ] Run focused tests and build.
### Task 3: Indexable professional content and regional SEO
**Files:**
- Modify: `app/layout.tsx`
- Modify: `app/page.tsx`
- Modify: `lib/site-content.ts`
- Modify: `app/robots.ts`
- Modify: `app/sitemap.ts`
- Create: `app/curriculo/page.tsx`
- Create: `app/cases/[slug]/page.tsx`
- [ ] Add profile/curriculum and case-study routes with visible text, internal links and static params.
- [ ] Add regional titles/descriptions and ProfilePage/Person schema that matches visible text.
- [ ] Add only canonical generated by Next, remove stale static metadata files that conflict with generated routes.
- [ ] Test static generation and inspect route output.
### Task 4: Measured front-end fixes
**Files:**
- Modify: `app/layout.tsx`
- Modify: `app/components/Hero.tsx`
- Modify: `app/components/cinematic/HeroScene.tsx`
- Modify: `app/components/cinematic/CinematicHome.tsx`
- Modify: `app/globals.css`
- Modify: `app/icon.svg`
- [ ] Write or update tests for semantics where behavior changes.
- [ ] Remove hydration blocker; reserve hero layout to stop CLS.
- [ ] Defer the 3D canvas until browser idle and omit it with reduced motion or narrow/coarse pointers.
- [ ] Replace gradient text/glass defaults, repair contrast, list structure and accessible labels.
- [ ] Run Lighthouse again and compare baseline.
### Task 5: Operations and verification
**Files:**
- Create: `docs/project-health-cron.md`
- Modify: relevant failing component tests only when the implementation intentionally changed their contractual UI.
- [ ] Document the exact cron command and expected snapshot behavior.
- [ ] Run `npm test -- --runInBand`, `npm run lint`, and `npm run build`.
- [ ] Run the health script against actual project URLs and confirm rendered project IDs match final 200 responses.
- [ ] Run a fresh Lighthouse audit and report measured deltas.
@@ -0,0 +1,27 @@
# Portfolio Growth Design
## Objetivo
Posicionar Evandro Fonseca Junior para contratação em engenharia de software no Norte de Santa Catarina e Curitiba, mantendo consultoria como conversão secundária. O portfólio deve servir conteúdo profissional indexável, rápido e verificável.
## Decisões
- A validação de projetos usa um snapshot gerado na VPS. Um script executado pelo cron testa as demos com `GET`, redirecionamentos, timeout e limite de corpo; somente resposta final HTTP 200 com HTML é aprovada.
- O snapshot é gravado de forma atômica. Se estiver ausente ou vencido, nenhum projeto é mostrado em vez de exibir links potencialmente quebrados.
- Dados profissionais e projetos passam a ser renderizados no servidor, deixando animações como ilhas client-side.
- O posicionamento textual prioriza Garuva, Joinville, Norte de Santa Catarina, Curitiba e região, com contratação como CTA principal.
- SEO para mecanismos de IA segue SEO convencional: conteúdo textual rastreável, links internos, dados estruturados fiéis ao conteúdo e boa experiência de página. Não haverá markup ou arquivo específico prometendo ranking em IA.
## Escopo
1. Snapshot de saúde, script de validação, testes e instrução de cron.
2. Projetos e currículo visíveis no HTML inicial.
3. Metadados e JSON-LD regionais, ProfilePage, cases e currículo.
4. Correções mensuráveis de Lighthouse: cena 3D diferida, remoção do bloqueador de hidratação, ícone enxuto, contraste e listas semânticas.
5. Correção dos testes afetados pela arquitetura atual.
## Fora do escopo
- Garantia de posição em buscadores.
- Integração com APIs externas de Google Jobs ou Google Business Profile.
- Verificação de repositórios GitHub; a regra solicitada cobre demos públicas.
+24
View File
@@ -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"]);
});
});
+50
View File
@@ -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);
}
+87
View File
@@ -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);
});
});
+112
View File
@@ -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) };
}
+45
View File
@@ -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",
+3 -3
View File
@@ -1,6 +1,6 @@
# Evandro Fonseca Junior
**Desenvolvedor Full-Stack Angular**
**Engenheiro da Computação e Desenvolvedor de Software Full-Stack**
📍 Garuva, Santa Catarina, Brasil
🔗 [LinkedIn](https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/)
@@ -9,7 +9,7 @@
## Sobre Mim
Desenvolvedor Full-Stack com 3+ anos de experiência, especializado em soluções web modernas e inovadoras. Formado em Engenharia da Computação, com conhecimentos sólidos em computação em nuvem e inteligência artificial. Busco constantemente novos desafios onde possa aplicar minha paixão por tecnologia e resolver problemas de forma criativa.
Engenheiro da Computação e desenvolvedor de software com 5 anos de experiência, especializado em soluções web modernas e inovadoras. Formado em Engenharia da Computação, com conhecimentos sólidos em computação em nuvem e inteligência artificial. Busco constantemente novos desafios onde possa aplicar minha paixão por tecnologia e resolver problemas de forma criativa.
Meu objetivo é entregar soluções de alta qualidade que superem expectativas, sempre priorizando colaboração, ética e inovação. Acredito no poder da tecnologia para fazer a diferença e estou comprometido com o aprendizado contínuo e a excelência técnica.
@@ -162,7 +162,7 @@ _Aplicativo Python para automação de instalação de ambiente de desenvolvimen
**🔍 Em busca de oportunidades como:**
- Engenheiro de Software
- Engenheiro da Computação
- Desenvolvedor Full-Stack
- Desenvolvedor Web
+2 -2
View File
@@ -9,7 +9,8 @@
"lint": "biome check",
"format": "biome format --write",
"test": "jest",
"test:watch": "jest --watch"
"test:watch": "jest --watch",
"check:projects": "node scripts/check-projects.mjs"
},
"dependencies": {
"@gsap/react": "^2.1.2",
@@ -55,4 +56,3 @@
"typescript": "^5"
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"name": "Evandro Fonseca Junior - Portfólio",
"short_name": "Evandro.dev",
"description": "Portfólio profissional de Evandro Fonseca Junior, Desenvolvedor Full-Stack",
"start_url": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"orientation": "portrait-primary",
"categories": ["portfolio", "developer", "technology"],
"lang": "pt-BR",
"dir": "ltr",
"icons": [
{
"src": "/favicon.ico",
"sizes": "any",
"type": "image/x-icon"
}
]
}
+2 -2
View File
@@ -1,13 +1,13 @@
{
"personal": {
"name": "Evandro Fonseca Junior",
"title": "Desenvolvedor Full-Stack",
"title": "Engenheiro da Computação e Desenvolvedor de Software",
"location": "Garuva, Santa Catarina, Brasil",
"email": "[email protected]",
"phone": "Disponivel pelo botao de WhatsApp",
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
"github": "https://github.com/evandrodevbr",
"summary": "Desenvolvedor Full-Stack com 3+ anos de experiência, especializado em soluções web modernas e inovadoras. Formado em Engenharia da Computação, com conhecimentos sólidos em computação em nuvem e inteligência artificial. Busco constantemente novos desafios onde possa aplicar minha paixão por tecnologia e resolver problemas de forma criativa."
"summary": "Engenheiro da Computação e desenvolvedor de software com 5 anos de experiência, especializado em soluções web modernas e inovadoras. Formado em Engenharia da Computação, com conhecimentos sólidos em computação em nuvem e inteligência artificial. Busco constantemente novos desafios onde possa aplicar minha paixão por tecnologia e resolver problemas de forma criativa."
},
"experiences": [
{
-10
View File
@@ -1,10 +0,0 @@
User-agent: *
Allow: /
# Block access to sensitive files
Disallow: /api/
Disallow: /_next/
Disallow: /admin/
# Sitemap
Sitemap: https://evandrofonsecajunior.dev/sitemap.xml
-39
View File
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://evandrofonsecajunior.dev</loc>
<lastmod>2025-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://evandrofonsecajunior.dev/#about</loc>
<lastmod>2025-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://evandrofonsecajunior.dev/#skills</loc>
<lastmod>2025-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://evandrofonsecajunior.dev/#experience</loc>
<lastmod>2025-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://evandrofonsecajunior.dev/#projects</loc>
<lastmod>2025-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://evandrofonsecajunior.dev/#contact</loc>
<lastmod>2025-01-09</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
+68
View File
@@ -0,0 +1,68 @@
import { readFile, rename, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
const root = process.cwd();
const sourcePath = resolve(root, "public/portfolio-data.json");
const outputPath = resolve(root, "data/project-health.json");
const timeoutMs = 10_000;
const data = JSON.parse(await readFile(sourcePath, "utf8"));
const projects = data.projects.filter((project) => project.demoUrl);
const checkedAt = new Date().toISOString();
const results = await Promise.all(
projects.map(async ({ id, demoUrl }) => {
const startedAt = Date.now();
try {
const response = await fetch(demoUrl, {
redirect: "follow",
signal: AbortSignal.timeout(timeoutMs),
headers: { "user-agent": "evandro-project-health/1.0" },
});
const contentType = response.headers.get("content-type");
await response.body?.cancel();
return [
id,
{
status: response.status,
contentType,
checkedAt,
finalUrl: response.url,
latencyMs: Date.now() - startedAt,
},
];
} catch (error) {
return [
id,
{
status: null,
contentType: null,
checkedAt,
latencyMs: Date.now() - startedAt,
error:
error instanceof Error
? error.message
: "Unknown health-check error",
},
];
}
}),
);
const snapshot = { checkedAt, projects: Object.fromEntries(results) };
const temporaryPath = `${outputPath}.${process.pid}.tmp`;
await writeFile(
temporaryPath,
`${JSON.stringify(snapshot, null, 2)}\n`,
"utf8",
);
await rename(temporaryPath, outputPath);
const live = Object.values(snapshot.projects).filter(
(check) =>
check.status === 200 &&
check.contentType?.toLowerCase().startsWith("text/html"),
).length;
console.log(`Checked ${projects.length} project demos; ${live} are live.`);