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
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user