Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e68670362 | ||
|
|
0920370f30 | ||
|
|
cdde81eb6e | ||
|
|
aad56ad885 | ||
|
|
d76bbf5974 | ||
|
|
9236fdd685 | ||
|
|
812499b318 |
No files matched your search
+14
@@ -39,3 +39,17 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# local agent/editor state
|
||||||
|
/.augment/
|
||||||
|
.opencode/
|
||||||
|
.claude/settings.local.json
|
||||||
|
.superpowers/
|
||||||
|
/.worktrees/
|
||||||
|
|
||||||
|
# generated analysis artifacts
|
||||||
|
/graphify-out/
|
||||||
|
*.graphify.json
|
||||||
|
*.analysis.json
|
||||||
|
*.tmp.json
|
||||||
|
*.debug.json
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
out/
|
||||||
|
coverage/
|
||||||
|
.git/
|
||||||
|
.pnpm-store/
|
||||||
|
.cache/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
target/
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
@@ -1,36 +1,88 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
<div align="center">
|
||||||
|
|
||||||
## Getting Started
|
# Evandro Dev — Portfolio
|
||||||
|
|
||||||
First, run the development server:
|
**A cinematic, interactive developer portfolio built with Next.js, React, TypeScript, and motion-first UI engineering.**
|
||||||
|
|
||||||
```bash
|

|
||||||
npm run dev
|

|
||||||
# or
|

|
||||||
yarn dev
|

|
||||||
# or
|

|
||||||
pnpm dev
|
|
||||||
# or
|
</div>
|
||||||
bun dev
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is the portfolio site for **Evandro Dev**. It presents professional work, case studies, technical positioning, and interactive visual storytelling through a high-polish Next.js interface.
|
||||||
|
|
||||||
|
The project combines a modern App Router codebase with motion design, 3D/visual effects, and tested UI components.
|
||||||
|
|
||||||
|
## Highlights
|
||||||
|
|
||||||
|
| Area | Details |
|
||||||
|
| --- | --- |
|
||||||
|
| Framework | Next.js App Router with React and TypeScript. |
|
||||||
|
| Motion | GSAP, Framer Motion, Lenis, and custom animation components. |
|
||||||
|
| Visuals | Three.js / React Three Fiber / Drei for interactive and cinematic surfaces. |
|
||||||
|
| Styling | Tailwind CSS, utility composition, and reusable component primitives. |
|
||||||
|
| Quality | Jest, Testing Library, property-based tests, and Biome checks. |
|
||||||
|
| Content | Portfolio sections, case pages, company logos, hero composition, and supporting docs. |
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```txt
|
||||||
|
app/
|
||||||
|
page.tsx Main portfolio route
|
||||||
|
layout.tsx Root layout and metadata
|
||||||
|
components/ Portfolio sections and cinematic UI modules
|
||||||
|
cases/ Case-study routes/content
|
||||||
|
api/ Server/API routes
|
||||||
|
components/ Shared UI primitives
|
||||||
|
lib/ Utilities and shared logic
|
||||||
|
docs/ Supporting documentation
|
||||||
|
graphify-out/ Project analysis output
|
||||||
|
public/ Static assets
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## Getting started
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
The development server uses Turbopack through the project script.
|
||||||
|
|
||||||
## Learn More
|
## Scripts
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
| Command | Purpose |
|
||||||
|
| --- | --- |
|
||||||
|
| `pnpm dev` | Start the Next.js development server. |
|
||||||
|
| `pnpm build` | Build the production application. |
|
||||||
|
| `pnpm start` | Start the production server after building. |
|
||||||
|
| `pnpm lint` | Run Biome checks. |
|
||||||
|
| `pnpm format` | Format the codebase with Biome. |
|
||||||
|
| `pnpm test` | Run Jest tests. |
|
||||||
|
| `pnpm test:watch` | Run Jest in watch mode. |
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
## Engineering standards
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
- Keep visual sections modular and testable.
|
||||||
|
- Respect reduced-motion and accessibility expectations for animated sections.
|
||||||
|
- Keep portfolio copy and case-study content easy to update without touching low-level animation code.
|
||||||
|
- Validate interactive components with tests when changing behavior, layout assumptions, or accessibility states.
|
||||||
|
|
||||||
## Deploy on Vercel
|
## Validation
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
Before publishing changes:
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Personal portfolio project unless a repository license says otherwise.
|
||||||
@@ -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
@@ -1,8 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
|
||||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||||
|
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
||||||
@@ -39,9 +39,21 @@ export default function CasesPage() {
|
|||||||
"@type": "ListItem",
|
"@type": "ListItem",
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
name: caseStudy.title,
|
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.
|
Cases em software, Next.js, integracoes e produtos conectados.
|
||||||
</h1>
|
</h1>
|
||||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
<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,
|
Esta pagina organiza projetos com leitura mais indexavel e
|
||||||
contexto tecnico e tipo de problema resolvido.
|
comercial, conectando stack, contexto tecnico e tipo de problema
|
||||||
|
resolvido.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,32 +94,50 @@ export default function CasesPage() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="ds-tags mt-sp-6">
|
<ul className="ds-tags mt-sp-6">
|
||||||
{caseStudy.stack.map((item) => (
|
{caseStudy.stack.map((item) => (
|
||||||
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
|
<li key={item} className="ds-tag">
|
||||||
{item}
|
{item}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
<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 ? (
|
{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
|
Abrir projeto
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
) : null}
|
) : 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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-sp-10 flex flex-wrap gap-sp-3">
|
<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
|
Ver servicos
|
||||||
</MagneticButton>
|
</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
|
Ir para contato
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+20
-16
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import profilePhoto from "@/public/edev.jpeg";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { Section } from "./Section";
|
import profilePhoto from "@/public/edev.jpg";
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
import PortraitCyberScene from "./cinematic/PortraitCyberScene";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
const STORY_DATA = [
|
const STORY_DATA = [
|
||||||
{
|
{
|
||||||
@@ -19,6 +20,12 @@ const STORY_DATA = [
|
|||||||
content:
|
content:
|
||||||
"Trabalho bem em contextos com legado, integracoes complexas e pouca margem para erro, priorizando estabilidade, leitura tecnica e evolucao sustentavel.",
|
"Trabalho bem em contextos com legado, integracoes complexas e pouca margem para erro, priorizando estabilidade, leitura tecnica e evolucao sustentavel.",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Production mindset",
|
||||||
|
title: "Do codigo ao sistema no ar",
|
||||||
|
content:
|
||||||
|
"Penso alem da feature: deploy, observabilidade, manutencao e riscos de operacao entram na decisao tecnica desde o primeiro corte.",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROFILE_POINTS = [
|
const PROFILE_POINTS = [
|
||||||
@@ -91,7 +98,7 @@ export default function About() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="ds-grid-2"
|
className="ds-grid-2 items-start"
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={{ once: true, amount: 0.2 }}
|
viewport={{ once: true, amount: 0.2 }}
|
||||||
@@ -125,20 +132,19 @@ export default function About() {
|
|||||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||||
>
|
>
|
||||||
<LazyRevealImage
|
<PortraitCyberScene
|
||||||
src={profilePhoto}
|
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 h-[220px] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
className="mb-sp-6"
|
||||||
imageClassName="object-cover"
|
|
||||||
/>
|
/>
|
||||||
<div className="ds-kicker">Technical profile</div>
|
<div className="ds-kicker">Technical profile</div>
|
||||||
<DrawLine delay={0.05} />
|
<DrawLine delay={0.05} />
|
||||||
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
|
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
|
||||||
<p className="ds-copy">
|
<p className="ds-copy">
|
||||||
Sou engenheiro de software full-stack com foco em integrar camadas
|
Sou engenheiro da computação e desenvolvedor de software full-stack,
|
||||||
que normalmente ficam separadas: hardware, servicos, operacao e
|
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
|
||||||
interface. Meu trabalho e dar forma clara ao que costuma nascer
|
separadas: hardware, servicos, operacao e interface. Meu trabalho e
|
||||||
fragmentado.
|
dar forma clara ao que costuma nascer fragmentado.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-sp-6 space-y-sp-4">
|
<div className="mt-sp-6 space-y-sp-4">
|
||||||
{PROFILE_POINTS.map((point) => (
|
{PROFILE_POINTS.map((point) => (
|
||||||
@@ -158,5 +164,3 @@ export default function About() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,16 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { Section } from "./Section";
|
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { Section } from "./Section";
|
||||||
const WHATSAPP_NUMBER = "5547992742365";
|
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||||
|
}, []);
|
||||||
|
|
||||||
const fieldClassName =
|
const fieldClassName =
|
||||||
"mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
"mt-sp-2 w-full border border-[rgba(255,255,255,.12)] bg-[rgba(255,255,255,.06)] px-sp-4 py-[14px] text-[13px] leading-6 text-white outline-none transition-[border-color,box-shadow] focus-visible:border-[var(--slash)] focus-visible:ring-2 focus-visible:ring-[rgba(204,255,0,0.34)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]";
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -25,8 +31,7 @@ export default function Contact() {
|
|||||||
|
|
||||||
const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`;
|
const messageTemplate = `*NOVA SOLICITACAO DE PROJETO*\n\n*Origem:* Portfolio Profissional\n*Prioridade:* Alta\n\n---\n*CLIENTE:* ${formData.name}\n*EMAIL:* ${formData.email}\n*AREA:* ${formData.subject.toUpperCase()}\n\n*DESCRICAO:*\n"${formData.message}"\n---\n_Mensagem gerada via Evandro.dev | Garuva, SC_`;
|
||||||
|
|
||||||
const encodedMessage = encodeURIComponent(messageTemplate);
|
const whatsappUrl = buildWhatsAppUrl(messageTemplate);
|
||||||
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
|
|
||||||
|
|
||||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
||||||
};
|
};
|
||||||
@@ -93,7 +98,7 @@ export default function Contact() {
|
|||||||
projeto, performance ou construcao de produto com ownership real.
|
projeto, performance ou construcao de produto com ownership real.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
|
<div className="mt-sp-6 space-y-sp-4 text-[13px] leading-7 text-[rgba(255,255,255,.85)]">
|
||||||
<div>WhatsApp: +55 47 99274-2365</div>
|
<div>WhatsApp: {whatsAppDisplay}</div>
|
||||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
||||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||||
|
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
interface ContactFormProps {
|
interface ContactFormProps {
|
||||||
@@ -173,15 +174,12 @@ ${formData.userName}`;
|
|||||||
const handleEmailFocus = () => setEmailFocused(true);
|
const handleEmailFocus = () => setEmailFocused(true);
|
||||||
const handlePhoneFocus = () => setPhoneFocused(true);
|
const handlePhoneFocus = () => setPhoneFocused(true);
|
||||||
|
|
||||||
const generateWhatsAppUrl = () => {
|
const generateWhatsAppUrl = () =>
|
||||||
const message = encodeURIComponent(
|
buildWhatsAppUrl(
|
||||||
formType === "advanced"
|
formType === "advanced"
|
||||||
? generateAdvancedMessage()
|
? generateAdvancedMessage()
|
||||||
: formData.customMessage || getGreetingMessageWithUserName(),
|
: formData.customMessage || getGreetingMessageWithUserName(),
|
||||||
);
|
);
|
||||||
const phone = data.contact.whatsapp.replace(/\D/g, ""); // Remove caracteres não numéricos
|
|
||||||
return `https://wa.me/${phone}?text=${message}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateEmailUrl = () => {
|
const generateEmailUrl = () => {
|
||||||
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
const subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { Mail, MessageCircle } from "lucide-react";
|
import { Mail, MessageCircle } from "lucide-react";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||||
|
import { getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
|
||||||
interface ContactInfoProps {
|
interface ContactInfoProps {
|
||||||
@@ -11,6 +13,11 @@ interface ContactInfoProps {
|
|||||||
|
|
||||||
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
||||||
const { data } = usePortfolioData();
|
const { data } = usePortfolioData();
|
||||||
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return null; // Ou um componente de carregamento/erro
|
return null; // Ou um componente de carregamento/erro
|
||||||
@@ -58,7 +65,7 @@ export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-white font-medium">WhatsApp</p>
|
<p className="text-white font-medium">WhatsApp</p>
|
||||||
<p className="text-gray-400">{data.contact.whatsapp}</p>
|
<p className="text-gray-400">{whatsAppDisplay}</p>
|
||||||
</div>
|
</div>
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||||
import { Section } from "./Section";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import { Section } from "./Section";
|
||||||
import { SectionLoading } from "./SectionLoading";
|
|
||||||
|
|
||||||
export default function Experience() {
|
export default function Experience({
|
||||||
const { data, loading, error } = usePortfolioData();
|
experiences,
|
||||||
|
}: {
|
||||||
|
experiences: PortfolioExperience[];
|
||||||
|
}) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const sortedExperiences = [...experiences]
|
||||||
const sortedExperiences = useMemo(() => {
|
.filter((experience) => experience.featured)
|
||||||
if (!data?.experiences) return [];
|
.sort((a, b) => {
|
||||||
return [...data.experiences]
|
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||||
.filter((exp) => exp.featured)
|
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||||
.sort((a, b) => {
|
return yearB - yearA;
|
||||||
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 listVariants: Variants = {
|
const listVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
@@ -108,9 +97,9 @@ export default function Experience() {
|
|||||||
|
|
||||||
<ul className="ds-tags mt-sp-6">
|
<ul className="ds-tags mt-sp-6">
|
||||||
{exp.technologies?.slice(0, 6).map((tech) => (
|
{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}
|
{tech}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
@@ -119,5 +108,3 @@ export default function Experience() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ describe("Footer Component - Contact Information", () => {
|
|||||||
it("should render email address", () => {
|
it("should render email address", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const emailLink = screen.getByText("evandro@dev.br");
|
const emailLink = screen.getByText("evandro[email protected]");
|
||||||
expect(emailLink).toBeInTheDocument();
|
expect(emailLink).toBeInTheDocument();
|
||||||
expect(emailLink.tagName).toBe("A");
|
expect(emailLink.tagName).toBe("A");
|
||||||
});
|
});
|
||||||
@@ -21,14 +21,17 @@ describe("Footer Component - Contact Information", () => {
|
|||||||
it("should have mailto link for email", () => {
|
it("should have mailto link for email", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const emailLink = screen.getByText("evandro@dev.br");
|
const emailLink = screen.getByText("evandro[email protected]");
|
||||||
expect(emailLink).toHaveAttribute("href", "mailto:[email protected]");
|
expect(emailLink).toHaveAttribute(
|
||||||
|
"href",
|
||||||
|
"mailto:[email protected]",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should apply correct styling classes to email link", () => {
|
it("should apply correct styling classes to email link", () => {
|
||||||
render(<Footer />);
|
render(<Footer />);
|
||||||
|
|
||||||
const emailLink = screen.getByText("evandro@dev.br");
|
const emailLink = screen.getByText("evandro[email protected]");
|
||||||
const classes = emailLink.className;
|
const classes = emailLink.className;
|
||||||
|
|
||||||
expect(classes).toContain("text-[12px]");
|
expect(classes).toContain("text-[12px]");
|
||||||
|
|||||||
@@ -64,11 +64,11 @@ export default function Footer() {
|
|||||||
</p>
|
</p>
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="a"
|
||||||
href="mailto:evandro@dev.br"
|
href="mailto:evandro[email protected]"
|
||||||
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
|
className="inline-block text-[18px] font-bold text-[var(--ink)] transition-colors duration-200 hover:text-[var(--slash)]"
|
||||||
strength={0.2}
|
strength={0.2}
|
||||||
>
|
>
|
||||||
evandro@dev.br
|
evandrofonsecajunior@gmail.com
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import Hero from "./Hero";
|
import Hero from "./Hero";
|
||||||
|
|
||||||
let mockReducedMotion = false;
|
jest.mock("next/dynamic", () => () => {
|
||||||
|
const DynamicComponent = ({ variant }: { variant?: string }) => (
|
||||||
|
<div data-testid="hero-scene" data-variant={variant} />
|
||||||
|
);
|
||||||
|
DynamicComponent.displayName = "MockHeroScene";
|
||||||
|
return DynamicComponent;
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock("framer-motion", () => {
|
jest.mock("framer-motion", () => {
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
@@ -16,15 +22,15 @@ jest.mock("framer-motion", () => {
|
|||||||
children,
|
children,
|
||||||
initial,
|
initial,
|
||||||
animate,
|
animate,
|
||||||
whileHover,
|
whileHover: _whileHover,
|
||||||
whileTap,
|
whileTap: _whileTap,
|
||||||
|
variants: _variants,
|
||||||
|
transition: _transition,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
initial?: unknown;
|
initial?: unknown;
|
||||||
animate?: unknown;
|
animate?: unknown;
|
||||||
whileHover?: unknown;
|
|
||||||
whileTap?: unknown;
|
|
||||||
} & Record<string, unknown>) => {
|
} & Record<string, unknown>) => {
|
||||||
const dataProps = {
|
const dataProps = {
|
||||||
...(initial !== undefined
|
...(initial !== undefined
|
||||||
@@ -33,12 +39,6 @@ jest.mock("framer-motion", () => {
|
|||||||
...(animate !== undefined
|
...(animate !== undefined
|
||||||
? { "data-animate": JSON.stringify(animate) }
|
? { "data-animate": JSON.stringify(animate) }
|
||||||
: {}),
|
: {}),
|
||||||
...(whileHover !== undefined
|
|
||||||
? { "data-while-hover": JSON.stringify(whileHover) }
|
|
||||||
: {}),
|
|
||||||
...(whileTap !== undefined
|
|
||||||
? { "data-while-tap": JSON.stringify(whileTap) }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return React.createElement(tag, { ...props, ...dataProps }, children);
|
return React.createElement(tag, { ...props, ...dataProps }, children);
|
||||||
@@ -53,49 +53,69 @@ jest.mock("framer-motion", () => {
|
|||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
||||||
motion,
|
motion,
|
||||||
useInView: () => true,
|
useInView: () => true,
|
||||||
useReducedMotion: () => mockReducedMotion,
|
useReducedMotion: () => true,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mockMatchMedia(matches: boolean) {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("Hero reduced motion", () => {
|
describe("Hero reduced motion", () => {
|
||||||
afterEach(() => {
|
beforeEach(() => {
|
||||||
mockReducedMotion = false;
|
mockMatchMedia(true);
|
||||||
|
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("removes hover/tap scale animations when reduced motion is enabled", () => {
|
afterEach(() => {
|
||||||
mockReducedMotion = true;
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps essential hero content visible with reduced motion enabled", async () => {
|
||||||
|
render(<Hero />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText(/Disponível para novas oportunidades/i),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 1,
|
||||||
|
name: /engenharia de software para sistemas/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps motion blocks declarative instead of requiring 3D", async () => {
|
||||||
const { container } = render(<Hero />);
|
const { container } = render(<Hero />);
|
||||||
|
|
||||||
const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a");
|
await waitFor(() =>
|
||||||
const secondaryAction = screen.getByText(/Ver projetos/i).closest("a");
|
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
expect(primaryAction).not.toHaveAttribute("data-while-hover");
|
|
||||||
expect(primaryAction).not.toHaveAttribute("data-while-tap");
|
|
||||||
expect(secondaryAction).not.toHaveAttribute("data-while-hover");
|
|
||||||
expect(secondaryAction).not.toHaveAttribute("data-while-tap");
|
|
||||||
|
|
||||||
const rootMotionBlock = container.querySelector(
|
const rootMotionBlock = container.querySelector(
|
||||||
"div[data-initial][data-animate]",
|
"div[data-initial][data-animate]",
|
||||||
);
|
);
|
||||||
expect(rootMotionBlock).toBeInTheDocument();
|
expect(rootMotionBlock).toBeInTheDocument();
|
||||||
expect(rootMotionBlock?.getAttribute("data-initial")).toContain(
|
expect(rootMotionBlock?.getAttribute("data-initial")).toContain("hidden");
|
||||||
'"opacity":0',
|
expect(rootMotionBlock?.getAttribute("data-animate")).toContain("visible");
|
||||||
);
|
|
||||||
expect(rootMotionBlock?.getAttribute("data-animate")).toContain(
|
|
||||||
'"opacity":1',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps essential hero content visible with reduced motion enabled", () => {
|
|
||||||
mockReducedMotion = true;
|
|
||||||
|
|
||||||
render(<Hero />);
|
|
||||||
|
|
||||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getByText(/Available for selected builds/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import Hero from "./Hero";
|
import Hero from "./Hero";
|
||||||
|
|
||||||
|
jest.mock("next/dynamic", () => () => {
|
||||||
|
const DynamicComponent = ({ variant }: { variant?: string }) => (
|
||||||
|
<div data-testid="hero-scene" data-variant={variant} />
|
||||||
|
);
|
||||||
|
DynamicComponent.displayName = "MockHeroScene";
|
||||||
|
return DynamicComponent;
|
||||||
|
});
|
||||||
|
|
||||||
jest.mock("framer-motion", () => {
|
jest.mock("framer-motion", () => {
|
||||||
const React = require("react");
|
const React = require("react");
|
||||||
|
|
||||||
@@ -12,6 +20,12 @@ jest.mock("framer-motion", () => {
|
|||||||
get: (_, tag: string) => {
|
get: (_, tag: string) => {
|
||||||
const Component = ({
|
const Component = ({
|
||||||
children,
|
children,
|
||||||
|
initial: _initial,
|
||||||
|
animate: _animate,
|
||||||
|
whileHover: _whileHover,
|
||||||
|
whileTap: _whileTap,
|
||||||
|
variants: _variants,
|
||||||
|
transition: _transition,
|
||||||
...props
|
...props
|
||||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||||
React.createElement(tag, props, children);
|
React.createElement(tag, props, children);
|
||||||
@@ -29,34 +43,83 @@ jest.mock("framer-motion", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function mockMatchMedia(matches = false) {
|
||||||
|
Object.defineProperty(window, "matchMedia", {
|
||||||
|
writable: true,
|
||||||
|
value: jest.fn().mockImplementation((query) => ({
|
||||||
|
matches,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
describe("Hero", () => {
|
describe("Hero", () => {
|
||||||
it("renders the upgraded hero content and primary actions", () => {
|
beforeEach(() => {
|
||||||
render(<Hero />);
|
mockMatchMedia(false);
|
||||||
|
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
/Engenharia de software para produtos conectados, interfaces de alta performance e sistemas criticos/i,
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
expect(screen.getByText(/Iniciar projeto/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Ver projetos/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders social links and proof content", () => {
|
afterEach(() => {
|
||||||
|
jest.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the command center hero and primary actions", async () => {
|
||||||
|
const { container } = render(<Hero />);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(container.querySelector("[data-hero-variant]")).toHaveAttribute(
|
||||||
|
"data-hero-variant",
|
||||||
|
"command-center",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", {
|
||||||
|
level: 1,
|
||||||
|
name: /engenharia de software para sistemas/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
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.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps social links, proof content and WhatsApp CTA", async () => {
|
||||||
render(<Hero />);
|
render(<Hero />);
|
||||||
|
|
||||||
expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0);
|
await waitFor(() =>
|
||||||
expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0);
|
expect(
|
||||||
expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0);
|
screen.getByText(/Disponível para novas oportunidades/i),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText("GitHub")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("LinkedIn")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Especialidade/i)).toBeInTheDocument();
|
expect(screen.getByText(/Operacao/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Escopo/i)).toBeInTheDocument();
|
expect(screen.getByText(/Clareza/i)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Formato/i)).toBeInTheDocument();
|
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
|
||||||
expect(screen.getByText(/Scroll/i)).toBeInTheDocument();
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole("button", { name: /conversar sobre uma oportunidade/i }),
|
||||||
|
);
|
||||||
|
expect(window.open).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("https://wa.me/"),
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+468
-95
@@ -1,13 +1,43 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
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";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
|
||||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type HeroSceneVariant =
|
||||||
|
| "command"
|
||||||
|
| "launch"
|
||||||
|
| "rescue"
|
||||||
|
| "signal"
|
||||||
|
| "diagnostic"
|
||||||
|
| "portrait";
|
||||||
|
|
||||||
|
type HeroVariant = {
|
||||||
|
id: string;
|
||||||
|
weight: number;
|
||||||
|
scene: HeroSceneVariant;
|
||||||
|
layout: "default" | "visual-first" | "split" | "report" | "portrait";
|
||||||
|
status: string;
|
||||||
|
chip: string;
|
||||||
|
eyebrow: string;
|
||||||
|
headline: string;
|
||||||
|
subtitle: string;
|
||||||
|
cta: string;
|
||||||
|
whatsappMessage: string;
|
||||||
|
sideLabel: string;
|
||||||
|
sideTitle: string;
|
||||||
|
sideText: string;
|
||||||
|
points: string[];
|
||||||
|
proof: Array<{ label: string; value: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
const SOCIAL_LINKS = [
|
const SOCIAL_LINKS = [
|
||||||
{
|
{
|
||||||
href: "https://github.com/evandrodevbr",
|
href: "https://github.com/evandrodevbr",
|
||||||
@@ -36,50 +66,407 @@ const SOCIAL_LINKS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const HERO_POINTS = [
|
const HERO_VARIANTS: HeroVariant[] = [
|
||||||
"Produtos conectados, integracoes complexas e operacoes criticas.",
|
{
|
||||||
"Arquitetura mais clara para evoluir sem aumentar o caos tecnico.",
|
id: "command-center",
|
||||||
"Execucao ponta a ponta, do discovery a entrega em producao.",
|
weight: 22,
|
||||||
|
scene: "command",
|
||||||
|
layout: "default",
|
||||||
|
status: "Disponível para novas oportunidades",
|
||||||
|
chip: "Garuva · Joinville · Curitiba · Remoto",
|
||||||
|
eyebrow:
|
||||||
|
"Evandro Fonseca Junior | Engenheiro da Computação e Desenvolvedor de Software",
|
||||||
|
headline:
|
||||||
|
"Engenharia de software para sistemas que precisam operar de verdade.",
|
||||||
|
subtitle:
|
||||||
|
"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:
|
||||||
|
"Vamos conversar sobre o contexto técnico, o desafio da vaga e como posso contribuir da arquitetura à entrega.",
|
||||||
|
points: [
|
||||||
|
"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: [
|
||||||
|
{
|
||||||
|
label: "Operacao",
|
||||||
|
value: "Fluxo pensado para discovery, build, deploy e sustentacao.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Clareza",
|
||||||
|
value: "Menos ruído entre produto, engenharia e decisao tecnica.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Resultado",
|
||||||
|
value: "Base pronta para operar, evoluir e receber proximo ciclo.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "launch-sequence",
|
||||||
|
weight: 18,
|
||||||
|
scene: "launch",
|
||||||
|
layout: "visual-first",
|
||||||
|
status: "Launch sequence ready",
|
||||||
|
chip: "Discovery → build → deploy",
|
||||||
|
eyebrow: "MVP, produto e entrega em producao",
|
||||||
|
headline:
|
||||||
|
"Tire seu MVP do plano e coloque em producao com base tecnica real.",
|
||||||
|
subtitle:
|
||||||
|
"Transformo escopo solto em sequencia de lancamento: o que validar, o que construir primeiro e como publicar sem criar uma divida tecnica logo no inicio.",
|
||||||
|
cta: "Lancar meu MVP",
|
||||||
|
whatsappMessage:
|
||||||
|
"Ola! Quero lancar um MVP com uma base tecnica segura. Podemos conversar?",
|
||||||
|
sideLabel: "Sequencia de lancamento",
|
||||||
|
sideTitle: "Do primeiro corte ao deploy.",
|
||||||
|
sideText:
|
||||||
|
"A conversa define prioridade, risco e o menor caminho confiavel para colocar a primeira versao no ar.",
|
||||||
|
points: [
|
||||||
|
"Recorte de MVP sem inflar escopo.",
|
||||||
|
"Entrega incremental com criterio de producao.",
|
||||||
|
"Deploy e continuidade pensados desde o inicio.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{ label: "01", value: "Diagnostico do objetivo e gargalo principal." },
|
||||||
|
{ label: "02", value: "Plano de construcao com risco visivel." },
|
||||||
|
{ label: "03", value: "Publicacao e proximo ciclo sem perder contexto." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rescue-split",
|
||||||
|
weight: 16,
|
||||||
|
scene: "rescue",
|
||||||
|
layout: "split",
|
||||||
|
status: "Rescue mode available",
|
||||||
|
chip: "Legado, instabilidade e projeto travado",
|
||||||
|
eyebrow: "Rescue tecnico para sistemas que precisam voltar a andar",
|
||||||
|
headline:
|
||||||
|
"Codigo confuso, entrega travada ou sistema instavel? Vamos organizar.",
|
||||||
|
subtitle:
|
||||||
|
"Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos tecnico em plano executavel.",
|
||||||
|
cta: "Destravar meu projeto",
|
||||||
|
whatsappMessage:
|
||||||
|
"Ola! Tenho um projeto travado ou instavel e quero destravar. Podemos conversar?",
|
||||||
|
sideLabel: "Antes / depois tecnico",
|
||||||
|
sideTitle: "Do caos operacional para uma rota clara.",
|
||||||
|
sideText:
|
||||||
|
"O primeiro objetivo nao e reescrever tudo. E encontrar o menor corte seguro que devolve controle e previsibilidade.",
|
||||||
|
points: [
|
||||||
|
"Leitura rapida de fluxo, risco e acoplamento.",
|
||||||
|
"Priorizacao do que destrava sem reescrever tudo.",
|
||||||
|
"Correcoes com criterio para nao trocar um bug por outro.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{
|
||||||
|
label: "Antes",
|
||||||
|
value: "Backlog confuso, bugs recorrentes e pouca previsibilidade.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Intervencao",
|
||||||
|
value: "Mapa de risco, cortes pequenos e decisao tecnica clara.",
|
||||||
|
},
|
||||||
|
{ label: "Depois", value: "Entrega volta a andar com menos improviso." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "iot-signal-map",
|
||||||
|
weight: 15,
|
||||||
|
scene: "signal",
|
||||||
|
layout: "default",
|
||||||
|
status: "Signal map active",
|
||||||
|
chip: "Hardware + software + operacao",
|
||||||
|
eyebrow: "Produtos conectados, telemetria e integracoes criticas",
|
||||||
|
headline:
|
||||||
|
"Conecte dispositivo, backend e interface em uma operacao confiavel.",
|
||||||
|
subtitle:
|
||||||
|
"Atuo na fronteira que costuma quebrar primeiro: sinal fisico, API, painel, dados e rotina operacional funcionando como um unico produto.",
|
||||||
|
cta: "Conectar meu produto",
|
||||||
|
whatsappMessage:
|
||||||
|
"Ola! Quero conectar hardware, software e operacao em um produto confiavel. Podemos conversar?",
|
||||||
|
sideLabel: "Mapa de sinais",
|
||||||
|
sideTitle: "Dados, comandos e painel no mesmo fluxo.",
|
||||||
|
sideText:
|
||||||
|
"A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integracao em produto.",
|
||||||
|
points: [
|
||||||
|
"Contratos claros entre dispositivo, API e interface.",
|
||||||
|
"Telemetria e dashboards com contexto operacional.",
|
||||||
|
"Base pronta para automacao, IA ou analytics.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{
|
||||||
|
label: "Entrada",
|
||||||
|
value: "Sensores, dispositivos, eventos ou sistemas externos.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Meio",
|
||||||
|
value: "APIs, WebSockets, filas, banco e observabilidade.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Saida",
|
||||||
|
value: "Painel, operacao, alerta e decisao de produto.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "diagnostic-report",
|
||||||
|
weight: 15,
|
||||||
|
scene: "diagnostic",
|
||||||
|
layout: "report",
|
||||||
|
status: "Diagnostic report open",
|
||||||
|
chip: "Arquitetura antes de mais codigo",
|
||||||
|
eyebrow: "Diagnostico tecnico para decidir melhor",
|
||||||
|
headline: "Antes de escrever mais codigo, descubra o proximo passo certo.",
|
||||||
|
subtitle:
|
||||||
|
"Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da proxima decisao cara.",
|
||||||
|
cta: "Agendar diagnostico",
|
||||||
|
whatsappMessage:
|
||||||
|
"Ola! Quero um diagnostico tecnico para decidir o proximo passo do meu sistema. Podemos conversar?",
|
||||||
|
sideLabel: "Relatorio tecnico",
|
||||||
|
sideTitle: "Risco, arquitetura e rota de acao.",
|
||||||
|
sideText:
|
||||||
|
"Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho tecnico.",
|
||||||
|
points: [
|
||||||
|
"Mapa claro dos gargalos e dependencias.",
|
||||||
|
"Trade-offs traduzidos para decisao de negocio.",
|
||||||
|
"Plano de transicao sem promessa magica.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{ label: "Risco", value: "O que pode quebrar, atrasar ou custar caro." },
|
||||||
|
{
|
||||||
|
label: "Arquitetura",
|
||||||
|
value: "Onde simplificar, separar ou estabilizar primeiro.",
|
||||||
|
},
|
||||||
|
{ label: "Acao", value: "Proximo corte de implementacao com criterio." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cinematic-portrait",
|
||||||
|
weight: 14,
|
||||||
|
scene: "portrait",
|
||||||
|
layout: "portrait",
|
||||||
|
status: "Direct technical partner",
|
||||||
|
chip: "Conversa direta com quem implementa",
|
||||||
|
eyebrow: "Engenharia senior, ownership e execucao",
|
||||||
|
headline: "Um parceiro tecnico para transformar complexidade em entrega.",
|
||||||
|
subtitle:
|
||||||
|
"Voce nao precisa apenas de tela bonita ou codigo isolado. Precisa de alguem que entenda contexto, escolha o corte certo e acompanhe ate o sistema operar.",
|
||||||
|
cta: "Falar com Evandro",
|
||||||
|
whatsappMessage:
|
||||||
|
"Ola Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.",
|
||||||
|
sideLabel: "Founder-led engineering",
|
||||||
|
sideTitle: "Menos repasse, mais ownership.",
|
||||||
|
sideText:
|
||||||
|
"A conversa e comigo: contexto tecnico, prioridade, risco e caminho de entrega sem perder informacao entre camadas.",
|
||||||
|
points: [
|
||||||
|
"Atendimento consultivo com leitura tecnica real.",
|
||||||
|
"Do produto ao backend sem perder contexto.",
|
||||||
|
"Execucao com criterio, nao so codigo por demanda.",
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
{
|
||||||
|
label: "Perfil",
|
||||||
|
value: "Engenharia full-stack com base em sistemas e integracoes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Postura",
|
||||||
|
value: "Clareza, autonomia e decisao tecnica pragmatica.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Acesso",
|
||||||
|
value: "WhatsApp direto para conversa tecnica inicial.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROOF_ITEMS = [
|
const LAYOUT_CLASSES: Record<HeroVariant["layout"], string> = {
|
||||||
{
|
default:
|
||||||
label: "Quem eu ajudo",
|
"grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10",
|
||||||
value:
|
"visual-first":
|
||||||
"Empresas que dependem de integracoes, hardware ou fluxos criticos para operar com seguranca e previsibilidade.",
|
"grid items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10",
|
||||||
},
|
split: "grid items-stretch gap-sp-8 lg:grid-cols-2 lg:gap-sp-10",
|
||||||
{
|
report:
|
||||||
label: "Como entrego",
|
"grid items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10",
|
||||||
value:
|
portrait:
|
||||||
"Traduzo complexidade tecnica em backlog claro, arquitetura legivel e decisao mais rapida para o time.",
|
"grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
|
||||||
},
|
};
|
||||||
{
|
|
||||||
label: "Por que conversar",
|
function getGreeting(message: string) {
|
||||||
value:
|
const hour = new Date().getHours();
|
||||||
"Voce ganha um parceiro tecnico capaz de conectar produto, backend, interface e operacao sem perder contexto.",
|
if (hour >= 8 && hour < 12) return `Bom dia! ${message}`;
|
||||||
},
|
if (hour >= 13 && hour < 18) return `Boa tarde! ${message}`;
|
||||||
];
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SideVisual({ variant }: { variant: HeroVariant }) {
|
||||||
|
if (variant.scene === "launch") {
|
||||||
|
return (
|
||||||
|
<div className="mb-sp-6 grid gap-sp-3">
|
||||||
|
{variant.proof.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.label}
|
||||||
|
className="cinematic-glass flex gap-sp-4 p-sp-4"
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[11px] text-[var(--slash)]">
|
||||||
|
0{index + 1}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<div className="text-[12px] font-bold uppercase tracking-[0.08em]">
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
<p className="mt-sp-1 text-[12px] leading-6 text-[rgba(244,242,237,.7)]">
|
||||||
|
{item.value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant.scene === "rescue") {
|
||||||
|
return (
|
||||||
|
<div className="mb-sp-6 grid gap-sp-4 sm:grid-cols-2">
|
||||||
|
<div className="border border-[rgba(255,80,80,.28)] bg-[rgba(255,80,80,.05)] p-sp-4">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(255,160,160,.9)]">
|
||||||
|
Antes
|
||||||
|
</div>
|
||||||
|
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.72)]">
|
||||||
|
Bugs recorrentes, escopo nebuloso e medo de mexer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-[rgba(204,255,0,.35)] bg-[rgba(204,255,0,.06)] p-sp-4">
|
||||||
|
<div className="font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]">
|
||||||
|
Depois
|
||||||
|
</div>
|
||||||
|
<p className="mt-sp-3 text-[13px] leading-6 text-[rgba(244,242,237,.86)]">
|
||||||
|
Rota clara, cortes menores e entrega voltando a andar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant.scene === "signal") {
|
||||||
|
return (
|
||||||
|
<div className="relative mb-sp-6 h-40 overflow-hidden border border-[rgba(255,255,255,.08)] bg-[rgba(255,255,255,.03)]">
|
||||||
|
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 420 160">
|
||||||
|
<path
|
||||||
|
d="M28 116 C108 18 190 142 284 56 S380 36 400 28"
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(204,255,0,.75)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
{[28, 150, 284, 400].map((x, index) => (
|
||||||
|
<circle
|
||||||
|
key={x}
|
||||||
|
cx={x}
|
||||||
|
cy={[116, 78, 56, 28][index]}
|
||||||
|
r="6"
|
||||||
|
fill="#ccff00"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant.scene === "diagnostic") {
|
||||||
|
return (
|
||||||
|
<div className="mb-sp-6 border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] p-sp-5">
|
||||||
|
<div className="mb-sp-4 flex items-center justify-between border-b border-[rgba(255,255,255,.08)] pb-sp-3 font-mono text-[10px] uppercase tracking-[0.12em]">
|
||||||
|
<span>Risk report</span>
|
||||||
|
<span className="text-[var(--slash)]">Actionable</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-sp-3">
|
||||||
|
{variant.proof.map((item) => (
|
||||||
|
<div key={item.label}>
|
||||||
|
<div className="flex justify-between text-[12px] text-[rgba(244,242,237,.78)]">
|
||||||
|
<span>{item.label}</span>
|
||||||
|
<span className="text-[var(--slash)]">mapped</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-sp-2 h-px bg-[linear-gradient(90deg,var(--slash),rgba(255,255,255,.08))]" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant.scene === "portrait") {
|
||||||
|
return (
|
||||||
|
<div className="mb-sp-6 border border-[rgba(204,255,0,.22)] bg-[radial-gradient(circle_at_70%_20%,rgba(204,255,0,.14),transparent_35%),rgba(255,255,255,.035)] p-sp-5">
|
||||||
|
<div className="font-display text-[clamp(2.5rem,8vw,5rem)] font-extrabold leading-none tracking-[-0.08em] text-[rgba(244,242,237,.95)]">
|
||||||
|
EFJ
|
||||||
|
</div>
|
||||||
|
<div className="mt-sp-4 font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||||
|
Engenharia com contato direto
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-sp-6 grid gap-sp-3 font-mono text-[11px] uppercase tracking-[0.08em]">
|
||||||
|
{["deploy pipeline", "api health", "production ready"].map((item) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="flex items-center justify-between border border-[rgba(255,255,255,.1)] bg-[rgba(255,255,255,.04)] px-sp-4 py-sp-3"
|
||||||
|
>
|
||||||
|
<span>{item}</span>
|
||||||
|
<span className="text-[var(--slash)]">ok</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const variant = HERO_VARIANTS[0];
|
||||||
|
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||||
|
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||||
|
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||||
|
|
||||||
const hour = new Date().getHours();
|
useEffect(() => {
|
||||||
const greeting =
|
const reducedMotion = window.matchMedia(
|
||||||
hour >= 8 && hour < 12
|
"(prefers-reduced-motion: reduce)",
|
||||||
? "Ola! Vi seu portfolio. Podemos conversar?"
|
).matches;
|
||||||
: hour >= 13 && hour < 18
|
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||||
? "Boa tarde! Vi seu portfolio. Podemos conversar?"
|
setShouldReduceMotion(reducedMotion);
|
||||||
: "Ola! Vi seu portfolio. Podemos conversar quando estiver disponivel?";
|
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||||
|
|
||||||
|
if (
|
||||||
|
!shouldLoadHeroScene({
|
||||||
|
reducedMotion,
|
||||||
|
coarsePointer,
|
||||||
|
width: window.innerWidth,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = window.setTimeout(() => setShouldRenderHeroScene(true), 1500);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWhatsAppClick = () => {
|
||||||
|
window.open(
|
||||||
|
buildWhatsAppUrl(getGreeting(variant.whatsappMessage)),
|
||||||
|
"_blank",
|
||||||
|
"noopener,noreferrer",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Cinematic stagger variants
|
|
||||||
const heroContainer: Variants = {
|
const heroContainer: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
visible: {
|
visible: {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
transition: {
|
transition: { staggerChildren: 0.1, delayChildren: 0.3 },
|
||||||
staggerChildren: 0.1,
|
|
||||||
delayChildren: 0.3,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,10 +480,7 @@ export default function Hero() {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
filter: "blur(0px)",
|
filter: "blur(0px)",
|
||||||
transition: {
|
transition: { duration: 1, ease: easeOut },
|
||||||
duration: 1,
|
|
||||||
ease: easeOut,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,26 +492,29 @@ export default function Hero() {
|
|||||||
opacity: 1,
|
opacity: 1,
|
||||||
y: 0,
|
y: 0,
|
||||||
filter: "blur(0px)",
|
filter: "blur(0px)",
|
||||||
transition: {
|
transition: { duration: 1.2, ease: easeOut },
|
||||||
duration: 1.2,
|
|
||||||
ease: easeOut,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const contentOrder =
|
||||||
|
variant.layout === "visual-first" || variant.layout === "report"
|
||||||
|
? "lg:order-2"
|
||||||
|
: "";
|
||||||
|
const panelOrder =
|
||||||
|
variant.layout === "visual-first" || variant.layout === "report"
|
||||||
|
? "lg:order-1"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="home"
|
id="home"
|
||||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||||
|
data-hero-variant={variant.id}
|
||||||
>
|
>
|
||||||
{/* 3D Scene Background */}
|
{shouldRenderHeroScene && <HeroScene variant={variant.scene} />}
|
||||||
{!shouldReduceMotion && <HeroScene />}
|
|
||||||
|
|
||||||
{/* Cinematic overlays */}
|
|
||||||
<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-[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)]" />
|
<div className="pointer-events-none absolute inset-0 z-[1] bg-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||||
|
|
||||||
{/* Scanlines effect */}
|
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
|
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
|
||||||
style={{
|
style={{
|
||||||
@@ -135,8 +522,6 @@ export default function Hero() {
|
|||||||
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
|
"repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.03) 2px, rgba(255,255,255,0.03) 4px)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Vignette */}
|
|
||||||
<div className="pointer-events-none absolute inset-0 z-[2] bg-[radial-gradient(circle_at_50%_50%,transparent_40%,rgba(10,10,10,0.6)_100%)]" />
|
<div className="pointer-events-none absolute inset-0 z-[2] bg-[radial-gradient(circle_at_50%_50%,transparent_40%,rgba(10,10,10,0.6)_100%)]" />
|
||||||
|
|
||||||
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 py-sp-16 lg:px-sp-16 lg:py-sp-12">
|
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 py-sp-16 lg:px-sp-16 lg:py-sp-12">
|
||||||
@@ -144,10 +529,9 @@ export default function Hero() {
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate="visible"
|
animate="visible"
|
||||||
variants={heroContainer}
|
variants={heroContainer}
|
||||||
className="grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10"
|
className={LAYOUT_CLASSES[variant.layout]}
|
||||||
>
|
>
|
||||||
<div className="max-w-[760px]">
|
<div className={`max-w-[800px] ${contentOrder}`.trim()}>
|
||||||
{/* Status badge with glow */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
|
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
|
||||||
variants={heroItem}
|
variants={heroItem}
|
||||||
@@ -178,48 +562,40 @@ export default function Hero() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
|
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
|
||||||
Available for selected builds
|
{variant.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="cinematic-glass inline-flex items-center gap-sp-2 px-4 py-2 text-[11px] text-[rgba(244,242,237,.72)]">
|
<div className="cinematic-glass inline-flex items-center gap-sp-2 px-4 py-2 text-[11px] text-[rgba(244,242,237,.72)]">
|
||||||
Produtos conectados, integracoes criticas e entrega ponta a ponta
|
{variant.chip}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Eyebrow */}
|
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mb-sp-4 max-w-[540px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
|
className="mb-sp-4 max-w-[560px] font-mono text-[10px] uppercase tracking-[0.12em] text-[var(--slash)]"
|
||||||
variants={heroItem}
|
variants={heroItem}
|
||||||
>
|
>
|
||||||
Evandro Fonseca Junior | Engenheiro de software full-stack
|
{variant.eyebrow}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{/* Main heading with cinematic reveal */}
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
className="max-w-[920px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
|
className="max-w-[940px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
|
||||||
variants={heroItemSlow}
|
variants={heroItemSlow}
|
||||||
>
|
>
|
||||||
<span className="cinematic-text-gradient">
|
{variant.headline}
|
||||||
Engenharia de software para sistemas que nao podem falhar.
|
|
||||||
</span>
|
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<motion.p
|
<motion.p
|
||||||
className="mt-sp-6 max-w-[680px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
|
className="mt-sp-6 max-w-[700px] text-[16px] leading-8 text-[rgba(244,242,237,.88)] lg:text-[17px]"
|
||||||
variants={heroItem}
|
variants={heroItem}
|
||||||
>
|
>
|
||||||
Ajudo empresas com produtos conectados, integracoes complexas e
|
{variant.subtitle}
|
||||||
operacoes criticas a transformar arquitetura confusa em software
|
|
||||||
estavel, mais rapido de evoluir e muito mais facil de operar.
|
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{/* Key points with staggered reveal */}
|
|
||||||
<motion.ul
|
<motion.ul
|
||||||
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
|
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
|
||||||
variants={heroItem}
|
variants={heroItem}
|
||||||
>
|
>
|
||||||
{HERO_POINTS.map((point, i) => (
|
{variant.points.map((point, i) => (
|
||||||
<motion.li
|
<motion.li
|
||||||
key={point}
|
key={point}
|
||||||
className="cinematic-glass flex min-w-0 gap-sp-3 px-sp-4 py-sp-4 text-[13px] leading-6 text-[rgba(244,242,237,.82)]"
|
className="cinematic-glass flex min-w-0 gap-sp-3 px-sp-4 py-sp-4 text-[13px] leading-6 text-[rgba(244,242,237,.82)]"
|
||||||
@@ -241,19 +617,19 @@ export default function Hero() {
|
|||||||
))}
|
))}
|
||||||
</motion.ul>
|
</motion.ul>
|
||||||
|
|
||||||
{/* CTAs with magnetic effect */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||||
variants={heroItem}
|
variants={heroItem}
|
||||||
>
|
>
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
as="a"
|
as="button"
|
||||||
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
|
type="button"
|
||||||
|
onClick={handleWhatsAppClick}
|
||||||
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
|
className="cinematic-cta-primary inline-flex min-h-12 items-center justify-center px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[#0A0A0A] no-underline"
|
||||||
data-cursor="View"
|
data-cursor="View"
|
||||||
strength={0.25}
|
strength={0.25}
|
||||||
>
|
>
|
||||||
<span className="relative z-10">Agendar conversa tecnica</span>
|
<span className="relative z-10">{variant.cta}</span>
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
|
|
||||||
<MagneticButton
|
<MagneticButton
|
||||||
@@ -277,7 +653,6 @@ export default function Hero() {
|
|||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Footer info with reveal */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="mt-sp-10 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3 border-t border-[rgba(255,255,255,.06)] pt-sp-6"
|
className="mt-sp-10 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3 border-t border-[rgba(255,255,255,.06)] pt-sp-6"
|
||||||
variants={heroItem}
|
variants={heroItem}
|
||||||
@@ -296,6 +671,7 @@ export default function Hero() {
|
|||||||
key={link.label}
|
key={link.label}
|
||||||
as="a"
|
as="a"
|
||||||
href={link.href}
|
href={link.href}
|
||||||
|
aria-label={link.label}
|
||||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||||
rel={
|
rel={
|
||||||
link.href.startsWith("http")
|
link.href.startsWith("http")
|
||||||
@@ -322,9 +698,8 @@ export default function Hero() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aside card with cinematic entrance */}
|
|
||||||
<motion.aside
|
<motion.aside
|
||||||
className="cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8"
|
className={`cinematic-aside-card relative overflow-hidden p-sp-6 lg:p-sp-8 ${panelOrder}`.trim()}
|
||||||
initial={
|
initial={
|
||||||
shouldReduceMotion
|
shouldReduceMotion
|
||||||
? { opacity: 0 }
|
? { opacity: 0 }
|
||||||
@@ -335,13 +710,8 @@ export default function Hero() {
|
|||||||
? { opacity: 1 }
|
? { opacity: 1 }
|
||||||
: { opacity: 1, x: 0, rotateY: 0 }
|
: { opacity: 1, x: 0, rotateY: 0 }
|
||||||
}
|
}
|
||||||
transition={{
|
transition={{ duration: 1.2, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||||
duration: 1.2,
|
|
||||||
delay: 0.5,
|
|
||||||
ease: [0.16, 1, 0.3, 1],
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Top glow line */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
|
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
|
||||||
initial={{ scaleX: 0 }}
|
initial={{ scaleX: 0 }}
|
||||||
@@ -349,34 +719,38 @@ export default function Hero() {
|
|||||||
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SideVisual variant={variant} />
|
||||||
|
|
||||||
<div className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5">
|
<div className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5">
|
||||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||||
Why this positioning works
|
{variant.sideLabel}
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
|
<h2 className="mt-sp-3 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.04] tracking-[-0.03em] text-[#f7f4ee]">
|
||||||
Menos atrito entre produto, engenharia e operacao.
|
{variant.sideTitle}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
|
<p className="mt-sp-4 text-[14px] leading-7 text-[rgba(244,242,237,.78)]">
|
||||||
Entro onde a complexidade cresce e ajudo a devolver clareza,
|
{variant.sideText}
|
||||||
ritmo de entrega e confiabilidade para o sistema e para o time.
|
|
||||||
</p>
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleWhatsAppClick}
|
||||||
|
className="mt-sp-5 inline-flex w-full items-center justify-center bg-[var(--slash)] px-sp-5 py-sp-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||||
|
>
|
||||||
|
Agendar no WhatsApp: {whatsAppDisplay}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-sp-6 space-y-sp-5">
|
<div className="mt-sp-6 space-y-sp-5">
|
||||||
{PROOF_ITEMS.map((item, index) => (
|
{variant.proof.map((item, index) => (
|
||||||
<motion.article
|
<motion.article
|
||||||
key={item.label}
|
key={`${variant.id}-${item.label}`}
|
||||||
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
|
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
|
||||||
initial={
|
initial={
|
||||||
shouldReduceMotion
|
shouldReduceMotion
|
||||||
? { opacity: 0 }
|
? { opacity: 0 }
|
||||||
: { opacity: 0, y: 20, filter: "blur(6px)" }
|
: { opacity: 0, y: 20, filter: "blur(6px)" }
|
||||||
}
|
}
|
||||||
animate={{
|
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||||
opacity: 1,
|
|
||||||
y: 0,
|
|
||||||
filter: "blur(0px)",
|
|
||||||
}}
|
|
||||||
transition={{
|
transition={{
|
||||||
duration: 0.8,
|
duration: 0.8,
|
||||||
delay: 0.8 + index * 0.12,
|
delay: 0.8 + index * 0.12,
|
||||||
@@ -396,7 +770,6 @@ export default function Hero() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scroll indicator */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
|
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
|
||||||
initial={{ opacity: 0, y: -10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { Section } from "./Section";
|
import { Section } from "./Section";
|
||||||
|
|
||||||
const METRICS = [
|
const METRICS = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface LogoProps {
|
interface LogoProps {
|
||||||
variant?: "mark" | "wordmark" | "icon";
|
variant?: "mark" | "wordmark" | "icon";
|
||||||
|
|||||||
+28
-30
@@ -1,30 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||||
import { Section } from "./Section";
|
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { SectionLoading } from "./SectionLoading";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
export default function Projects() {
|
export default function Projects({
|
||||||
const { data, loading, error } = usePortfolioData();
|
projects,
|
||||||
|
}: {
|
||||||
|
projects: PortfolioProject[];
|
||||||
|
}) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
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 = {
|
const gridVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
visible: {
|
visible: {
|
||||||
@@ -92,16 +81,18 @@ export default function Projects() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p className="ds-copy">{project.description}</p>
|
<p className="ds-copy">{project.description}</p>
|
||||||
|
|
||||||
<ul className="ds-tags mt-sp-6">
|
<ul className="ds-tags mt-sp-6">
|
||||||
{project.technologies.slice(0, 5).map((tech: string) => (
|
{project.technologies.slice(0, 5).map((tech) => (
|
||||||
<MagneticButton key={tech} as="li" className="ds-tag" strength={0.15}>
|
<li key={tech} className="ds-tag">
|
||||||
{tech}
|
{tech}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -136,16 +127,23 @@ export default function Projects() {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
<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
|
Explorar todos os cases
|
||||||
</MagneticButton>
|
</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
|
Ver servicos relacionados
|
||||||
</MagneticButton>
|
</MagneticButton>
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface RevealProps {
|
interface RevealProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface RouteTransitionProps {
|
interface RouteTransitionProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
||||||
import { Reveal } from "./Reveal";
|
import { Reveal } from "./Reveal";
|
||||||
|
|||||||
+12
-20
@@ -1,24 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { motion, type Variants } from "framer-motion";
|
||||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { Section } from "./Section";
|
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import { SectionLoading } from "./SectionLoading";
|
import { Section } from "./Section";
|
||||||
|
|
||||||
export default function Skills() {
|
export default function Skills({
|
||||||
const { data, loading, error } = usePortfolioData();
|
categories,
|
||||||
|
}: {
|
||||||
|
categories: PortfolioSkillCategory[];
|
||||||
|
}) {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
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 = {
|
const gridVariants: Variants = {
|
||||||
hidden: {},
|
hidden: {},
|
||||||
visible: {
|
visible: {
|
||||||
@@ -59,7 +53,7 @@ export default function Skills() {
|
|||||||
viewport={{ once: true, amount: 0.2 }}
|
viewport={{ once: true, amount: 0.2 }}
|
||||||
variants={gridVariants}
|
variants={gridVariants}
|
||||||
>
|
>
|
||||||
{data.skills.technical.map((category) => (
|
{categories.map((category) => (
|
||||||
<motion.article
|
<motion.article
|
||||||
key={category.category}
|
key={category.category}
|
||||||
className="ds-card h-full"
|
className="ds-card h-full"
|
||||||
@@ -83,9 +77,9 @@ export default function Skills() {
|
|||||||
</p>
|
</p>
|
||||||
<ul className="ds-tags">
|
<ul className="ds-tags">
|
||||||
{category.skills.map((skill) => (
|
{category.skills.map((skill) => (
|
||||||
<MagneticButton key={skill} as="li" className="ds-tag" strength={0.15}>
|
<li key={skill} className="ds-tag">
|
||||||
{skill}
|
{skill}
|
||||||
</MagneticButton>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</motion.article>
|
</motion.article>
|
||||||
@@ -94,5 +88,3 @@ export default function Skills() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion, type Variants } from "framer-motion";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
import { serviceHubLinks } from "@/lib/site-content";
|
import { serviceHubLinks } from "@/lib/site-content";
|
||||||
import { Section } from "./Section";
|
|
||||||
import { DrawLine } from "./animations/DrawLine";
|
import { DrawLine } from "./animations/DrawLine";
|
||||||
import MagneticButton from "./cinematic/MagneticButton";
|
import MagneticButton from "./cinematic/MagneticButton";
|
||||||
|
import { Section } from "./Section";
|
||||||
|
|
||||||
interface Tier {
|
interface Tier {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -246,4 +247,3 @@ export function WorkModalities() {
|
|||||||
</Section>
|
</Section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useInView } from "framer-motion";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useInView, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface CounterValueProps {
|
interface CounterValueProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface DrawLineProps {
|
interface DrawLineProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { motion } from "framer-motion";
|
||||||
import Image, { type StaticImageData } from "next/image";
|
import Image, { type StaticImageData } from "next/image";
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { useState } from "react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface LazyRevealImageProps {
|
interface LazyRevealImageProps {
|
||||||
src: string | StaticImageData;
|
src: string | StaticImageData;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { TextDiffuseReveal } from "./TextDiffuseReveal";
|
||||||
|
|
||||||
|
let mockReducedMotion = false;
|
||||||
|
let mockInView = true;
|
||||||
|
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
|
||||||
|
jest.mock("framer-motion", () => {
|
||||||
|
const React = require("react");
|
||||||
|
|
||||||
|
return {
|
||||||
|
motion: {
|
||||||
|
span: ({
|
||||||
|
children,
|
||||||
|
initial: _initial,
|
||||||
|
animate,
|
||||||
|
transition: _transition,
|
||||||
|
onAnimationComplete: _onAnimationComplete,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children?: ReactNode;
|
||||||
|
initial?: unknown;
|
||||||
|
animate?: Record<string, unknown>;
|
||||||
|
transition?: unknown;
|
||||||
|
onAnimationComplete?: () => void;
|
||||||
|
} & Record<string, unknown>) => {
|
||||||
|
mockAnimations.push({ animate });
|
||||||
|
return React.createElement("span", props, children);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
useInView: () => mockInView,
|
||||||
|
useReducedMotion: () => mockReducedMotion,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("TextDiffuseReveal reduced motion", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockReducedMotion = false;
|
||||||
|
mockInView = true;
|
||||||
|
mockAnimations = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears blur and scale when reduced motion resolves after mount", () => {
|
||||||
|
const { rerender } = render(<TextDiffuseReveal text="Reveal" />);
|
||||||
|
|
||||||
|
mockAnimations = [];
|
||||||
|
mockReducedMotion = true;
|
||||||
|
rerender(<TextDiffuseReveal text="Reveal" />);
|
||||||
|
|
||||||
|
expect(mockAnimations).toHaveLength(6);
|
||||||
|
expect(mockAnimations).toEqual(
|
||||||
|
expect.arrayContaining(
|
||||||
|
Array.from({ length: 6 }, () =>
|
||||||
|
expect.objectContaining({
|
||||||
|
animate: {
|
||||||
|
opacity: 1,
|
||||||
|
filter: "blur(0px)",
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the first render independent of the reduced-motion preference", () => {
|
||||||
|
mockReducedMotion = true;
|
||||||
|
mockInView = false;
|
||||||
|
|
||||||
|
render(<TextDiffuseReveal text="Reveal" />);
|
||||||
|
|
||||||
|
expect(mockAnimations[0]).toEqual({
|
||||||
|
animate: {
|
||||||
|
filter: "blur(12px)",
|
||||||
|
opacity: 0,
|
||||||
|
scale: 0.9,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useInView, useReducedMotion } from "framer-motion";
|
import { motion, useInView } from "framer-motion";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface TextDiffuseRevealProps {
|
interface TextDiffuseRevealProps {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -101,11 +102,9 @@ export function TextDiffuseReveal({
|
|||||||
}
|
}
|
||||||
animate={
|
animate={
|
||||||
isInView
|
isInView
|
||||||
? shouldReduceMotion
|
? { filter: "blur(0px)", opacity: 1, scale: 1 }
|
||||||
? { opacity: 1 }
|
|
||||||
: { filter: "blur(0px)", opacity: 1, scale: 1 }
|
|
||||||
: shouldReduceMotion
|
: shouldReduceMotion
|
||||||
? { opacity: 0 }
|
? { filter: "blur(0px)", opacity: 0, scale: 1 }
|
||||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||||
}
|
}
|
||||||
transition={{
|
transition={{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion, useReducedMotion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
interface TopProgressBarProps {
|
interface TopProgressBarProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -1,24 +1,53 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Suspense } from "react";
|
import type {
|
||||||
|
PortfolioExperience,
|
||||||
|
PortfolioProject,
|
||||||
|
PortfolioSkillCategory,
|
||||||
|
} from "@/lib/portfolio-data";
|
||||||
import Hero from "../Hero";
|
import Hero from "../Hero";
|
||||||
|
import Projects from "../Projects";
|
||||||
import { SectionLoading } from "../SectionLoading";
|
import { SectionLoading } from "../SectionLoading";
|
||||||
|
import CinematicSection from "./CinematicSection";
|
||||||
|
|
||||||
const About = dynamic(() => import("../About"), { loading: () => <SectionLoading message="Carregando..." /> });
|
const About = dynamic(() => import("../About"), {
|
||||||
const Skills = dynamic(() => import("../Skills"), { loading: () => <SectionLoading message="Carregando..." /> });
|
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 Skills = dynamic(() => import("../Skills"), {
|
||||||
const Contact = dynamic(() => import("../Contact"), { loading: () => <SectionLoading message="Carregando..." /> });
|
loading: () => <SectionLoading message="Carregando..." />,
|
||||||
const Projects = dynamic(() => import("../Projects"));
|
});
|
||||||
const LighthouseMetrics = dynamic(() => import("../LighthouseMetrics"), { 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 Footer = dynamic(() => import("../Footer"));
|
||||||
|
|
||||||
const CinematicSection = dynamic(() => import("./CinematicSection"), { ssr: false });
|
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), {
|
||||||
const ScrollVelocityText = dynamic(() => import("./ScrollVelocityText"), { ssr: false });
|
ssr: false,
|
||||||
const SectionDivider = dynamic(() => import("./SectionDivider"), { 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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
||||||
<main className="ds-main">
|
<main className="ds-main">
|
||||||
@@ -26,10 +55,7 @@ export default function CinematicHome() {
|
|||||||
|
|
||||||
{/* Marquee divider after hero */}
|
{/* Marquee divider after hero */}
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.06)]">
|
||||||
<ScrollVelocityText
|
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
|
||||||
text="SOFTWARE ENGINEERING"
|
|
||||||
baseVelocity={-1.5}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CinematicSection effect="parallax-depth">
|
<CinematicSection effect="parallax-depth">
|
||||||
@@ -39,7 +65,7 @@ export default function CinematicHome() {
|
|||||||
<SectionDivider variant="waveform" />
|
<SectionDivider variant="waveform" />
|
||||||
|
|
||||||
<CinematicSection effect="clip-reveal">
|
<CinematicSection effect="clip-reveal">
|
||||||
<Skills />
|
<Skills categories={skillCategories} />
|
||||||
</CinematicSection>
|
</CinematicSection>
|
||||||
|
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-4 text-[rgba(204,255,0,0.04)]">
|
<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>
|
</div>
|
||||||
|
|
||||||
<CinematicSection effect="fade-scale">
|
<CinematicSection effect="fade-scale">
|
||||||
<Experience />
|
<Experience experiences={experiences} />
|
||||||
</CinematicSection>
|
</CinematicSection>
|
||||||
|
|
||||||
<SectionDivider variant="gradient" />
|
<SectionDivider variant="gradient" />
|
||||||
|
|
||||||
<CinematicSection effect="rotate-in">
|
<CinematicSection effect="rotate-in">
|
||||||
<Suspense
|
<Projects projects={projects} />
|
||||||
fallback={<SectionLoading message="Carregando projetos..." />}
|
|
||||||
>
|
|
||||||
<Projects />
|
|
||||||
</Suspense>
|
|
||||||
</CinematicSection>
|
</CinematicSection>
|
||||||
|
|
||||||
<CinematicSection effect="clip-reveal">
|
<CinematicSection effect="clip-reveal">
|
||||||
@@ -74,10 +96,7 @@ export default function CinematicHome() {
|
|||||||
</CinematicSection>
|
</CinematicSection>
|
||||||
|
|
||||||
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
<div className="overflow-hidden bg-[var(--ink)] py-6 text-[rgba(244,242,237,0.05)]">
|
||||||
<ScrollVelocityText
|
<ScrollVelocityText text="VAMOS CONVERSAR" baseVelocity={-2} />
|
||||||
text="VAMOS CONVERSAR"
|
|
||||||
baseVelocity={-2}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CinematicSection effect="fade-scale">
|
<CinematicSection effect="fade-scale">
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
import PageIntro from "./PageIntro";
|
||||||
|
|
||||||
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
||||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||||
const PageIntro = dynamic(() => import("./PageIntro"), { ssr: false });
|
|
||||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
|
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
|
||||||
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
|
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
import { motion, useMotionValue, useSpring } from "framer-motion";
|
import { motion, useMotionValue } from "framer-motion";
|
||||||
|
|
||||||
export default function CustomCursor() {
|
export default function CustomCursor() {
|
||||||
const [isHovering, setIsHovering] = useState(false);
|
const [isHovering, setIsHovering] = useState(false);
|
||||||
@@ -10,13 +10,6 @@ export default function CustomCursor() {
|
|||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
const cursorX = useMotionValue(0);
|
const cursorX = useMotionValue(0);
|
||||||
const cursorY = useMotionValue(0);
|
const cursorY = useMotionValue(0);
|
||||||
const springConfig = { damping: 25, stiffness: 250, mass: 0.5 };
|
|
||||||
const x = useSpring(cursorX, springConfig);
|
|
||||||
const y = useSpring(cursorY, springConfig);
|
|
||||||
|
|
||||||
const ringSpringConfig = { damping: 20, stiffness: 150, mass: 0.8 };
|
|
||||||
const ringX = useSpring(cursorX, ringSpringConfig);
|
|
||||||
const ringY = useSpring(cursorY, ringSpringConfig);
|
|
||||||
|
|
||||||
const rafRef = useRef<number>(0);
|
const rafRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -29,7 +22,7 @@ export default function CustomCursor() {
|
|||||||
if (!isVisible) setIsVisible(true);
|
if (!isVisible) setIsVisible(true);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[cursorX, cursorY, isVisible]
|
[cursorX, cursorY, isVisible],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -57,7 +50,7 @@ export default function CustomCursor() {
|
|||||||
|
|
||||||
function attachHoverListeners() {
|
function attachHoverListeners() {
|
||||||
const hoverables = document.querySelectorAll(
|
const hoverables = document.querySelectorAll(
|
||||||
'a, button, [role="button"], input, textarea, select, [data-cursor]'
|
'a, button, [role="button"], input, textarea, select, [data-cursor]',
|
||||||
);
|
);
|
||||||
for (const el of hoverables) {
|
for (const el of hoverables) {
|
||||||
el.addEventListener("mouseenter", handleElementEnter as EventListener);
|
el.addEventListener("mouseenter", handleElementEnter as EventListener);
|
||||||
@@ -89,7 +82,10 @@ export default function CustomCursor() {
|
|||||||
}, [handleMouseMove]);
|
}, [handleMouseMove]);
|
||||||
|
|
||||||
// Don't render on touch devices
|
// Don't render on touch devices
|
||||||
if (typeof window !== "undefined" && !window.matchMedia("(pointer: fine)").matches) {
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
!window.matchMedia("(pointer: fine)").matches
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +101,8 @@ export default function CustomCursor() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
|
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
|
||||||
style={{
|
style={{
|
||||||
x,
|
x: cursorX,
|
||||||
y,
|
y: cursorY,
|
||||||
translateX: "-50%",
|
translateX: "-50%",
|
||||||
translateY: "-50%",
|
translateY: "-50%",
|
||||||
}}
|
}}
|
||||||
@@ -126,8 +122,8 @@ export default function CustomCursor() {
|
|||||||
<motion.div
|
<motion.div
|
||||||
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
|
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
|
||||||
style={{
|
style={{
|
||||||
x: ringX,
|
x: cursorX,
|
||||||
y: ringY,
|
y: cursorY,
|
||||||
translateX: "-50%",
|
translateX: "-50%",
|
||||||
translateY: "-50%",
|
translateY: "-50%",
|
||||||
}}
|
}}
|
||||||
@@ -141,7 +137,11 @@ export default function CustomCursor() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
|
className="flex h-full w-full items-center justify-center rounded-full border-[#ccff00]"
|
||||||
style={{ borderStyle: "solid", borderColor: "#ccff00", borderWidth: "inherit" }}
|
style={{
|
||||||
|
borderStyle: "solid",
|
||||||
|
borderColor: "#ccff00",
|
||||||
|
borderWidth: "inherit",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{hoverText && (
|
{hoverText && (
|
||||||
<motion.span
|
<motion.span
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { useReducedMotion } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
const HeroScene = dynamic(() => import("./HeroScene"), { ssr: false });
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useMemo, useCallback, useEffect } from "react";
|
|
||||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
||||||
import { Float, MeshDistortMaterial } from "@react-three/drei";
|
import { Float, MeshDistortMaterial } from "@react-three/drei";
|
||||||
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
type HeroSceneVariant =
|
||||||
|
| "command"
|
||||||
|
| "launch"
|
||||||
|
| "rescue"
|
||||||
|
| "signal"
|
||||||
|
| "diagnostic"
|
||||||
|
| "portrait";
|
||||||
|
|
||||||
function ParticleField({ count = 2000 }: { count?: number }) {
|
function ParticleField({ count = 2000 }: { count?: number }) {
|
||||||
const mesh = useRef<THREE.Points>(null);
|
const mesh = useRef<THREE.Points>(null);
|
||||||
const mouseRef = useRef({ x: 0, y: 0 });
|
const mouseRef = useRef({ x: 0, y: 0 });
|
||||||
@@ -32,10 +40,13 @@ function ParticleField({ count = 2000 }: { count?: number }) {
|
|||||||
return s;
|
return s;
|
||||||
}, [count]);
|
}, [count]);
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e: { clientX: number; clientY: number }) => {
|
const handlePointerMove = useCallback(
|
||||||
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
(e: { clientX: number; clientY: number }) => {
|
||||||
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||||
}, []);
|
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
if (!mesh.current) return;
|
if (!mesh.current) return;
|
||||||
@@ -107,22 +118,61 @@ function ParticleField({ count = 2000 }: { count?: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FloatingGeometry() {
|
function FloatingGeometry({ variant }: { variant: HeroSceneVariant }) {
|
||||||
const meshRef = useRef<THREE.Mesh>(null);
|
const meshRef = useRef<THREE.Mesh>(null);
|
||||||
const mesh2Ref = useRef<THREE.Mesh>(null);
|
const mesh2Ref = useRef<THREE.Mesh>(null);
|
||||||
const mesh3Ref = useRef<THREE.Mesh>(null);
|
const mesh3Ref = useRef<THREE.Mesh>(null);
|
||||||
|
|
||||||
|
const layout = {
|
||||||
|
command: {
|
||||||
|
a: [4, 1.5, -3] as [number, number, number],
|
||||||
|
b: [-5, -2, -4] as [number, number, number],
|
||||||
|
c: [2, -3, -5] as [number, number, number],
|
||||||
|
scale: [1.2, 0.8, 1.5] as [number, number, number],
|
||||||
|
},
|
||||||
|
launch: {
|
||||||
|
a: [3.8, 2.1, -3] as [number, number, number],
|
||||||
|
b: [0, -1.4, -4] as [number, number, number],
|
||||||
|
c: [-3.8, -2.6, -5] as [number, number, number],
|
||||||
|
scale: [0.9, 0.9, 1.8] as [number, number, number],
|
||||||
|
},
|
||||||
|
rescue: {
|
||||||
|
a: [-3.6, 1.2, -3] as [number, number, number],
|
||||||
|
b: [3.6, 1.2, -4] as [number, number, number],
|
||||||
|
c: [0, -2.8, -5] as [number, number, number],
|
||||||
|
scale: [1, 1, 1.4] as [number, number, number],
|
||||||
|
},
|
||||||
|
signal: {
|
||||||
|
a: [-4.2, -1, -3] as [number, number, number],
|
||||||
|
b: [0.2, 1.6, -4] as [number, number, number],
|
||||||
|
c: [4.2, -0.6, -5] as [number, number, number],
|
||||||
|
scale: [0.7, 0.7, 1.2] as [number, number, number],
|
||||||
|
},
|
||||||
|
diagnostic: {
|
||||||
|
a: [4.6, 0.6, -3] as [number, number, number],
|
||||||
|
b: [-3.5, 1.8, -4] as [number, number, number],
|
||||||
|
c: [-1.2, -2.8, -5] as [number, number, number],
|
||||||
|
scale: [1.4, 0.7, 1.1] as [number, number, number],
|
||||||
|
},
|
||||||
|
portrait: {
|
||||||
|
a: [3.2, 0.4, -3] as [number, number, number],
|
||||||
|
b: [4.8, 2.1, -4] as [number, number, number],
|
||||||
|
c: [-3.6, -2.4, -5] as [number, number, number],
|
||||||
|
scale: [1.8, 0.5, 1.1] as [number, number, number],
|
||||||
|
},
|
||||||
|
}[variant];
|
||||||
|
|
||||||
useFrame((state) => {
|
useFrame((state) => {
|
||||||
const t = state.clock.elapsedTime;
|
const t = state.clock.elapsedTime;
|
||||||
if (meshRef.current) {
|
if (meshRef.current) {
|
||||||
meshRef.current.rotation.x = t * 0.15;
|
meshRef.current.rotation.x = t * 0.15;
|
||||||
meshRef.current.rotation.y = t * 0.1;
|
meshRef.current.rotation.y = t * 0.1;
|
||||||
meshRef.current.position.y = Math.sin(t * 0.4) * 0.3;
|
meshRef.current.position.y = layout.a[1] + Math.sin(t * 0.4) * 0.3;
|
||||||
}
|
}
|
||||||
if (mesh2Ref.current) {
|
if (mesh2Ref.current) {
|
||||||
mesh2Ref.current.rotation.x = t * 0.1;
|
mesh2Ref.current.rotation.x = t * 0.1;
|
||||||
mesh2Ref.current.rotation.z = t * 0.12;
|
mesh2Ref.current.rotation.z = t * 0.12;
|
||||||
mesh2Ref.current.position.y = Math.cos(t * 0.3) * 0.5;
|
mesh2Ref.current.position.y = layout.b[1] + Math.cos(t * 0.3) * 0.5;
|
||||||
}
|
}
|
||||||
if (mesh3Ref.current) {
|
if (mesh3Ref.current) {
|
||||||
mesh3Ref.current.rotation.y = t * 0.08;
|
mesh3Ref.current.rotation.y = t * 0.08;
|
||||||
@@ -133,13 +183,19 @@ function FloatingGeometry() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
|
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
|
||||||
<mesh ref={meshRef} position={[4, 1.5, -3]} scale={1.2}>
|
<mesh ref={meshRef} position={layout.a} scale={layout.scale[0]}>
|
||||||
<icosahedronGeometry args={[1, 1]} />
|
{variant === "diagnostic" ? (
|
||||||
|
<boxGeometry args={[1.4, 1.8, 0.08, 3, 3, 1]} />
|
||||||
|
) : variant === "portrait" ? (
|
||||||
|
<capsuleGeometry args={[0.55, 1.25, 6, 12]} />
|
||||||
|
) : (
|
||||||
|
<icosahedronGeometry args={[1, 1]} />
|
||||||
|
)}
|
||||||
<MeshDistortMaterial
|
<MeshDistortMaterial
|
||||||
color="#ccff00"
|
color="#ccff00"
|
||||||
wireframe
|
wireframe
|
||||||
transparent
|
transparent
|
||||||
opacity={0.15}
|
opacity={variant === "portrait" ? 0.1 : 0.15}
|
||||||
distort={0.3}
|
distort={0.3}
|
||||||
speed={2}
|
speed={2}
|
||||||
/>
|
/>
|
||||||
@@ -147,8 +203,14 @@ function FloatingGeometry() {
|
|||||||
</Float>
|
</Float>
|
||||||
|
|
||||||
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
|
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||||
<mesh ref={mesh2Ref} position={[-5, -2, -4]} scale={0.8}>
|
<mesh ref={mesh2Ref} position={layout.b} scale={layout.scale[1]}>
|
||||||
<octahedronGeometry args={[1, 0]} />
|
{variant === "launch" ? (
|
||||||
|
<coneGeometry args={[0.8, 1.6, 4]} />
|
||||||
|
) : variant === "signal" ? (
|
||||||
|
<sphereGeometry args={[0.55, 16, 16]} />
|
||||||
|
) : (
|
||||||
|
<octahedronGeometry args={[1, 0]} />
|
||||||
|
)}
|
||||||
<MeshDistortMaterial
|
<MeshDistortMaterial
|
||||||
color="#ffffff"
|
color="#ffffff"
|
||||||
wireframe
|
wireframe
|
||||||
@@ -161,8 +223,14 @@ function FloatingGeometry() {
|
|||||||
</Float>
|
</Float>
|
||||||
|
|
||||||
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
|
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
|
||||||
<mesh ref={mesh3Ref} position={[2, -3, -5]} scale={1.5}>
|
<mesh ref={mesh3Ref} position={layout.c} scale={layout.scale[2]}>
|
||||||
<torusGeometry args={[1, 0.3, 16, 32]} />
|
{variant === "rescue" ? (
|
||||||
|
<boxGeometry args={[1.4, 1.4, 1.4, 2, 2, 2]} />
|
||||||
|
) : variant === "signal" ? (
|
||||||
|
<torusKnotGeometry args={[0.7, 0.16, 80, 8]} />
|
||||||
|
) : (
|
||||||
|
<torusGeometry args={[1, 0.3, 16, 32]} />
|
||||||
|
)}
|
||||||
<meshStandardMaterial
|
<meshStandardMaterial
|
||||||
color="#ccff00"
|
color="#ccff00"
|
||||||
wireframe
|
wireframe
|
||||||
@@ -226,7 +294,7 @@ function GridFloor() {
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
}),
|
}),
|
||||||
[]
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -266,7 +334,11 @@ function CameraRig() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HeroScene() {
|
export default function HeroScene({
|
||||||
|
variant = "command",
|
||||||
|
}: {
|
||||||
|
variant?: HeroSceneVariant;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-0"
|
className="absolute inset-0 z-0"
|
||||||
@@ -292,12 +364,16 @@ export default function HeroScene() {
|
|||||||
>
|
>
|
||||||
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
||||||
<ambientLight intensity={0.3} />
|
<ambientLight intensity={0.3} />
|
||||||
<directionalLight position={[5, 5, 5]} intensity={0.5} color="#ccff00" />
|
<directionalLight
|
||||||
|
position={[5, 5, 5]}
|
||||||
|
intensity={0.5}
|
||||||
|
color="#ccff00"
|
||||||
|
/>
|
||||||
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
|
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
|
||||||
|
|
||||||
<CameraRig />
|
<CameraRig />
|
||||||
<ParticleField count={1500} />
|
<ParticleField count={1500} />
|
||||||
<FloatingGeometry />
|
<FloatingGeometry variant={variant} />
|
||||||
<GridFloor />
|
<GridFloor />
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,106 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
|
||||||
const INTRO_LINES = [
|
const EXIT_EASE: [number, number, number, number] = [0.76, 0, 0.24, 1];
|
||||||
"EVANDRO",
|
|
||||||
"FONSECA",
|
|
||||||
"JUNIOR",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function PageIntro() {
|
export default function PageIntro() {
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
|
||||||
const [counter, setCounter] = useState(0);
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
const reduceMotion = useReducedMotion();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
// Remove the immediate blocking overlay now that the animated loader is ready
|
const timeout = window.setTimeout(
|
||||||
const blocker = document.getElementById("__loading-blocker");
|
() => setVisible(false),
|
||||||
if (blocker) blocker.remove();
|
reduceMotion ? 150 : 1350,
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
return () => window.clearTimeout(timeout);
|
||||||
if (!mounted) return;
|
}, [reduceMotion]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isVisible && (
|
{visible && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="fixed inset-0 z-[99999] flex items-center justify-center bg-[#0a0a0a]"
|
className="fixed inset-0 z-[99999] grid overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||||
style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, width: "100vw", height: "100vh" }}
|
initial={{ opacity: 1 }}
|
||||||
exit={{
|
exit={
|
||||||
clipPath: "inset(0 0 100% 0)",
|
reduceMotion
|
||||||
transition: { duration: 1, ease: [0.76, 0, 0.24, 1] },
|
? { 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 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-0 overflow-hidden">
|
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
|
||||||
{[...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>
|
|
||||||
|
|
||||||
{/* Counter */}
|
<div className="relative m-auto w-[min(88vw,680px)]">
|
||||||
<motion.div
|
<motion.p
|
||||||
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)]"
|
className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]"
|
||||||
initial={{ opacity: 0 }}
|
initial={reduceMotion ? false : { opacity: 0, y: 8 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.28, ease: [0.16, 1, 0.3, 1] }}
|
||||||
>
|
>
|
||||||
{counter.toString().padStart(3, "0")}
|
SINAL DE ENTRADA
|
||||||
</motion.div>
|
</motion.p>
|
||||||
|
<motion.h1
|
||||||
{/* Progress bar */}
|
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
|
||||||
<div className="absolute bottom-8 left-12 right-12 h-px bg-[rgba(255,255,255,0.06)]">
|
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
|
<motion.div
|
||||||
className="h-full bg-[var(--slash)]"
|
className="mt-sp-8 flex items-center gap-sp-4"
|
||||||
initial={{ width: "0%" }}
|
initial={reduceMotion ? false : { opacity: 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 }}
|
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
transition={{ delay: 1, duration: 0.6 }}
|
transition={{ duration: 0.28, delay: 0.18 }}
|
||||||
>
|
>
|
||||||
<motion.p
|
<span className="relative block h-2 w-2" aria-hidden="true">
|
||||||
className="font-mono text-[10px] uppercase tracking-[0.3em] text-[rgba(204,255,0,0.7)]"
|
<span className="absolute inset-0 bg-[var(--slash)]" />
|
||||||
initial={{ y: 20 }}
|
{!reduceMotion && (
|
||||||
animate={{ y: 0 }}
|
<motion.span
|
||||||
transition={{ delay: 1, duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
className="absolute inset-0 bg-[var(--slash)]"
|
||||||
>
|
animate={{ scale: [1, 2.8], opacity: [0.8, 0] }}
|
||||||
Software Engineer
|
transition={{
|
||||||
</motion.p>
|
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>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Corner markers */}
|
<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">
|
||||||
<div className="pointer-events-none absolute inset-0">
|
<span>Engenharia de software</span>
|
||||||
{/* Top-left */}
|
<span>Garuva · Joinville · Curitiba</span>
|
||||||
<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>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>,
|
</AnimatePresence>,
|
||||||
document.body
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import {
|
||||||
|
PORTRAIT_CYBER_FEATURES,
|
||||||
|
PortraitCyberScene,
|
||||||
|
} from "./PortraitCyberScene";
|
||||||
|
|
||||||
|
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||||
|
useReducedMotion: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("../animations/LazyRevealImage", () => ({
|
||||||
|
LazyRevealImage: ({
|
||||||
|
alt,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
aria-label={alt}
|
||||||
|
className={className}
|
||||||
|
data-testid="portrait-image"
|
||||||
|
role="img"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock("@react-three/fiber", () => ({
|
||||||
|
Canvas: ({ children: _children, ...props }: Record<string, unknown>) => (
|
||||||
|
<div data-testid="portrait-cyber-canvas" {...props} />
|
||||||
|
),
|
||||||
|
useFrame: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedUseReducedMotion = jest.mocked(useReducedMotion);
|
||||||
|
|
||||||
|
describe("PortraitCyberScene", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedUseReducedMotion.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the accessible photo and mounts the cyber layers when visible", async () => {
|
||||||
|
render(
|
||||||
|
<PortraitCyberScene
|
||||||
|
src="/edev.jpg"
|
||||||
|
alt="Retrato profissional de Evandro Fonseca Junior"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const scene = screen.getByTestId("portrait-cyber");
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("img", {
|
||||||
|
name: "Retrato profissional de Evandro Fonseca Junior",
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(scene).toHaveAttribute(
|
||||||
|
"data-cyber-effects",
|
||||||
|
PORTRAIT_CYBER_FEATURES.join(" "),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("portrait-cyber-scanlines")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes the RGB split state only while the pointer is over the portrait", async () => {
|
||||||
|
render(<PortraitCyberScene src="/edev.jpg" alt="Retrato profissional" />);
|
||||||
|
|
||||||
|
const scene = screen.getByTestId("portrait-cyber");
|
||||||
|
Object.defineProperty(scene, "getBoundingClientRect", {
|
||||||
|
configurable: true,
|
||||||
|
value: () => ({
|
||||||
|
bottom: 100,
|
||||||
|
height: 100,
|
||||||
|
left: 0,
|
||||||
|
right: 100,
|
||||||
|
top: 0,
|
||||||
|
width: 100,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
fireEvent.pointerMove(scene, { clientX: 80, clientY: 20 });
|
||||||
|
expect(scene).toHaveAttribute("data-cyber-state", "hovered");
|
||||||
|
|
||||||
|
fireEvent.pointerLeave(scene);
|
||||||
|
expect(scene).toHaveAttribute("data-cyber-state", "idle");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the photo static while showing the cyber layers when reduced motion is preferred", async () => {
|
||||||
|
mockedUseReducedMotion.mockReturnValue(true);
|
||||||
|
|
||||||
|
render(<PortraitCyberScene src="/edev.jpg" alt="Retrato profissional" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("portrait-cyber")).toHaveAttribute(
|
||||||
|
"data-motion",
|
||||||
|
"reduced",
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("portrait-cyber-canvas")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("img", { name: "Retrato profissional" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Canvas, useFrame } from "@react-three/fiber";
|
||||||
|
import type { StaticImageData } from "next/image";
|
||||||
|
import {
|
||||||
|
type PointerEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||||
|
import { LazyRevealImage } from "../animations/LazyRevealImage";
|
||||||
|
|
||||||
|
export const PORTRAIT_CYBER_FEATURES = [
|
||||||
|
"particles",
|
||||||
|
"contour",
|
||||||
|
"scanlines",
|
||||||
|
"rgb-split",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type PointerPosition = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
type PointerReference = {
|
||||||
|
current: PointerPosition;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CYBER_OUTLINE: readonly [number, number][] = [
|
||||||
|
[-0.78, -0.92],
|
||||||
|
[-0.96, -0.64],
|
||||||
|
[-0.9, -0.28],
|
||||||
|
[-0.68, 0.02],
|
||||||
|
[-0.48, 0.22],
|
||||||
|
[-0.42, 0.58],
|
||||||
|
[-0.25, 0.88],
|
||||||
|
[0, 1.02],
|
||||||
|
[0.24, 0.94],
|
||||||
|
[0.43, 0.62],
|
||||||
|
[0.48, 0.24],
|
||||||
|
[0.72, 0.02],
|
||||||
|
[0.94, -0.3],
|
||||||
|
[0.9, -0.66],
|
||||||
|
[0.75, -0.92],
|
||||||
|
[0.35, -0.78],
|
||||||
|
[0, -0.68],
|
||||||
|
[-0.38, -0.78],
|
||||||
|
];
|
||||||
|
|
||||||
|
function createContourGeometry(scale = 1.65) {
|
||||||
|
return new THREE.BufferGeometry().setFromPoints(
|
||||||
|
CYBER_OUTLINE.map(([x, y]) => new THREE.Vector3(x * scale, y * scale, 0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ContourLines({
|
||||||
|
hovered,
|
||||||
|
animate,
|
||||||
|
}: {
|
||||||
|
hovered: boolean;
|
||||||
|
animate: boolean;
|
||||||
|
}) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
const geometry = useMemo(() => createContourGeometry(), []);
|
||||||
|
|
||||||
|
useFrame((state) => {
|
||||||
|
if (!groupRef.current || !animate) return;
|
||||||
|
groupRef.current.rotation.z =
|
||||||
|
Math.sin(state.clock.elapsedTime * 0.35) * 0.008;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef} position={[0, 0, 0.2]}>
|
||||||
|
<lineLoop geometry={geometry}>
|
||||||
|
<lineBasicMaterial color="#ccff00" transparent opacity={0.46} />
|
||||||
|
</lineLoop>
|
||||||
|
<lineLoop geometry={geometry} position={[hovered ? 0.035 : 0, 0, 0.01]}>
|
||||||
|
<lineBasicMaterial
|
||||||
|
color="#ff3b81"
|
||||||
|
transparent
|
||||||
|
opacity={hovered ? 0.48 : 0}
|
||||||
|
/>
|
||||||
|
</lineLoop>
|
||||||
|
<lineLoop geometry={geometry} position={[hovered ? -0.035 : 0, 0, 0.02]}>
|
||||||
|
<lineBasicMaterial
|
||||||
|
color="#00e5ff"
|
||||||
|
transparent
|
||||||
|
opacity={hovered ? 0.42 : 0}
|
||||||
|
/>
|
||||||
|
</lineLoop>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createParticlePositions(count: number, phase: number) {
|
||||||
|
const positions = new Float32Array(count * 3);
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const [x, y] = CYBER_OUTLINE[(index + phase) % CYBER_OUTLINE.length];
|
||||||
|
const jitterX = (((index * 17 + phase * 7) % 19) / 19 - 0.5) * 0.18;
|
||||||
|
const jitterY = (((index * 11 + phase * 5) % 17) / 17 - 0.5) * 0.18;
|
||||||
|
const radius = 1 + ((index * 13 + phase) % 9) / 26;
|
||||||
|
const index3 = index * 3;
|
||||||
|
|
||||||
|
positions[index3] = x * 1.65 * radius + jitterX;
|
||||||
|
positions[index3 + 1] = y * 1.65 * radius + jitterY;
|
||||||
|
positions[index3 + 2] = 0.24 + ((index + phase) % 7) * 0.025;
|
||||||
|
}
|
||||||
|
|
||||||
|
return positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ParticleField({
|
||||||
|
animate,
|
||||||
|
color,
|
||||||
|
count,
|
||||||
|
phase,
|
||||||
|
}: {
|
||||||
|
animate: boolean;
|
||||||
|
color: string;
|
||||||
|
count: number;
|
||||||
|
phase: number;
|
||||||
|
}) {
|
||||||
|
const pointsRef = useRef<THREE.Points>(null);
|
||||||
|
const positions = useMemo(
|
||||||
|
() => createParticlePositions(count, phase),
|
||||||
|
[count, phase],
|
||||||
|
);
|
||||||
|
|
||||||
|
useFrame((state, delta) => {
|
||||||
|
if (!pointsRef.current || !animate) return;
|
||||||
|
|
||||||
|
const positionAttribute = pointsRef.current.geometry.getAttribute(
|
||||||
|
"position",
|
||||||
|
) as THREE.BufferAttribute;
|
||||||
|
const values = positionAttribute.array as Float32Array;
|
||||||
|
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
const index3 = index * 3;
|
||||||
|
values[index3 + 1] += (0.012 + (index % 4) * 0.002) * delta;
|
||||||
|
values[index3] +=
|
||||||
|
Math.sin(state.clock.elapsedTime * 0.55 + index + phase) * 0.0007;
|
||||||
|
|
||||||
|
if (values[index3 + 1] > 1.9) values[index3 + 1] = -1.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
positionAttribute.needsUpdate = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<points ref={pointsRef}>
|
||||||
|
<bufferGeometry>
|
||||||
|
<bufferAttribute
|
||||||
|
attach="attributes-position"
|
||||||
|
args={[positions, 3]}
|
||||||
|
count={count}
|
||||||
|
/>
|
||||||
|
</bufferGeometry>
|
||||||
|
<pointsMaterial
|
||||||
|
color={color}
|
||||||
|
size={0.026}
|
||||||
|
transparent
|
||||||
|
opacity={0.74}
|
||||||
|
sizeAttenuation
|
||||||
|
depthWrite={false}
|
||||||
|
blending={THREE.AdditiveBlending}
|
||||||
|
/>
|
||||||
|
</points>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CyberScene({
|
||||||
|
animate,
|
||||||
|
hovered,
|
||||||
|
pointerRef,
|
||||||
|
}: {
|
||||||
|
animate: boolean;
|
||||||
|
hovered: boolean;
|
||||||
|
pointerRef: PointerReference;
|
||||||
|
}) {
|
||||||
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
useFrame((_, delta) => {
|
||||||
|
if (!groupRef.current) return;
|
||||||
|
|
||||||
|
const targetX = animate ? pointerRef.current.x * 0.09 : 0;
|
||||||
|
const targetY = animate ? pointerRef.current.y * 0.06 : 0;
|
||||||
|
groupRef.current.position.x = THREE.MathUtils.damp(
|
||||||
|
groupRef.current.position.x,
|
||||||
|
targetX,
|
||||||
|
5,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
groupRef.current.position.y = THREE.MathUtils.damp(
|
||||||
|
groupRef.current.position.y,
|
||||||
|
targetY,
|
||||||
|
5,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
groupRef.current.rotation.y = THREE.MathUtils.damp(
|
||||||
|
groupRef.current.rotation.y,
|
||||||
|
targetX * 0.4,
|
||||||
|
5,
|
||||||
|
delta,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group ref={groupRef}>
|
||||||
|
<ContourLines animate={animate} hovered={hovered} />
|
||||||
|
<ParticleField animate={animate} color="#ccff00" count={128} phase={0} />
|
||||||
|
<ParticleField animate={animate} color="#00e5ff" count={48} phase={7} />
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PortraitCyberSceneProps {
|
||||||
|
src: string | StaticImageData;
|
||||||
|
alt: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PortraitCyberScene({
|
||||||
|
alt,
|
||||||
|
className = "",
|
||||||
|
src,
|
||||||
|
}: Readonly<PortraitCyberSceneProps>) {
|
||||||
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pointerRef = useRef<PointerPosition>({ x: 0, y: 0 });
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [pointerPosition, setPointerPosition] = useState<PointerPosition>({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
});
|
||||||
|
const [isCoarsePointer, setIsCoarsePointer] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = containerRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (typeof IntersectionObserver === "undefined") {
|
||||||
|
setIsVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (!entry?.isIntersecting) return;
|
||||||
|
setIsVisible(true);
|
||||||
|
observer.disconnect();
|
||||||
|
},
|
||||||
|
{ rootMargin: "120px" },
|
||||||
|
);
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia("(pointer: coarse)");
|
||||||
|
const updatePointerMode = () => setIsCoarsePointer(mediaQuery.matches);
|
||||||
|
|
||||||
|
updatePointerMode();
|
||||||
|
mediaQuery.addEventListener?.("change", updatePointerMode);
|
||||||
|
return () => mediaQuery.removeEventListener?.("change", updatePointerMode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback(
|
||||||
|
(event: PointerEvent<HTMLDivElement>) => {
|
||||||
|
if (shouldReduceMotion || isCoarsePointer) return;
|
||||||
|
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
const width = rect.width || 1;
|
||||||
|
const height = rect.height || 1;
|
||||||
|
const position = {
|
||||||
|
x: ((event.clientX - rect.left) / width) * 2 - 1,
|
||||||
|
y: -(((event.clientY - rect.top) / height) * 2 - 1),
|
||||||
|
};
|
||||||
|
|
||||||
|
pointerRef.current = position;
|
||||||
|
setPointerPosition(position);
|
||||||
|
setIsHovered(true);
|
||||||
|
},
|
||||||
|
[isCoarsePointer, shouldReduceMotion],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePointerLeave = useCallback(() => {
|
||||||
|
pointerRef.current = { x: 0, y: 0 };
|
||||||
|
setPointerPosition({ x: 0, y: 0 });
|
||||||
|
setIsHovered(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const imageTransform = shouldReduceMotion
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
transform: `translate3d(${pointerPosition.x * 3}px, ${
|
||||||
|
pointerPosition.y * 3
|
||||||
|
}px, 0) scale(1.012)`,
|
||||||
|
transition: isHovered
|
||||||
|
? "transform 220ms cubic-bezier(0.16, 1, 0.3, 1)"
|
||||||
|
: "transform 420ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
};
|
||||||
|
const motionMode = shouldReduceMotion
|
||||||
|
? "reduced"
|
||||||
|
: isCoarsePointer
|
||||||
|
? "static"
|
||||||
|
: "full";
|
||||||
|
const sceneClassName = [
|
||||||
|
"relative aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={sceneClassName}
|
||||||
|
data-cyber-effects={PORTRAIT_CYBER_FEATURES.join(" ")}
|
||||||
|
data-cyber-state={isHovered ? "hovered" : "idle"}
|
||||||
|
data-motion={motionMode}
|
||||||
|
data-testid="portrait-cyber"
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
>
|
||||||
|
<div className="absolute inset-0 h-full w-full" style={imageTransform}>
|
||||||
|
<LazyRevealImage
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className="h-full w-full"
|
||||||
|
imageClassName="object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-10 opacity-[0.1] mix-blend-screen"
|
||||||
|
data-testid="portrait-cyber-scanlines"
|
||||||
|
data-effect="scanlines"
|
||||||
|
style={{
|
||||||
|
backgroundImage:
|
||||||
|
"repeating-linear-gradient(0deg, transparent 0, transparent 3px, rgba(204,255,0,0.22) 4px, transparent 5px)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isVisible && (
|
||||||
|
<Canvas
|
||||||
|
aria-hidden="true"
|
||||||
|
camera={{ far: 20, fov: 45, near: 0.1, position: [0, 0, 5] }}
|
||||||
|
className="pointer-events-none absolute inset-0 z-20"
|
||||||
|
dpr={[1, 1.5]}
|
||||||
|
frameloop={shouldReduceMotion ? "demand" : "always"}
|
||||||
|
gl={{
|
||||||
|
alpha: true,
|
||||||
|
antialias: false,
|
||||||
|
powerPreference: "high-performance",
|
||||||
|
}}
|
||||||
|
style={{ background: "transparent" }}
|
||||||
|
>
|
||||||
|
<CyberScene
|
||||||
|
animate={!isCoarsePointer && !shouldReduceMotion}
|
||||||
|
hovered={isHovered}
|
||||||
|
pointerRef={pointerRef}
|
||||||
|
/>
|
||||||
|
</Canvas>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-0 z-30 rounded-[6px] opacity-40"
|
||||||
|
data-effect="vignette"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(circle at center, transparent 48%, rgba(10,10,10,0.18) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PortraitCyberScene;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+35
-39
@@ -28,7 +28,7 @@
|
|||||||
--white: #ffffff;
|
--white: #ffffff;
|
||||||
--slash: #ccff00;
|
--slash: #ccff00;
|
||||||
--slash-dim: rgba(204, 255, 0, 0.16);
|
--slash-dim: rgba(204, 255, 0, 0.16);
|
||||||
--meta: #595959;
|
--meta: #4a4a4a;
|
||||||
--border: rgba(10, 10, 10, 0.1);
|
--border: rgba(10, 10, 10, 0.1);
|
||||||
--border-light: rgba(10, 10, 10, 0.06);
|
--border-light: rgba(10, 10, 10, 0.06);
|
||||||
--sp-1: 4px;
|
--sp-1: 4px;
|
||||||
@@ -62,17 +62,39 @@
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior-x: none;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
position: relative;
|
||||||
background: var(--paper);
|
background: var(--paper);
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.68;
|
line-height: 1.68;
|
||||||
overflow-x: hidden;
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
video,
|
||||||
|
canvas,
|
||||||
|
svg {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (overflow: clip) {
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
overflow-x: clip;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -167,7 +189,7 @@ textarea {
|
|||||||
|
|
||||||
.ds-section-description {
|
.ds-section-description {
|
||||||
max-width: 58ch;
|
max-width: 58ch;
|
||||||
color: rgba(10, 10, 10, 0.62);
|
color: rgba(10, 10, 10, 0.74);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.82;
|
line-height: 1.82;
|
||||||
}
|
}
|
||||||
@@ -194,11 +216,10 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ds-card {
|
.ds-card {
|
||||||
background: rgba(255, 255, 255, 0.74);
|
background: rgba(255, 255, 255, 0.92);
|
||||||
padding: clamp(20px, 3vw, 28px);
|
padding: clamp(20px, 3vw, 28px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ds-panel {
|
.ds-panel {
|
||||||
@@ -533,45 +554,20 @@ textarea {
|
|||||||
|
|
||||||
/* Cinematic text gradient - hero heading */
|
/* Cinematic text gradient - hero heading */
|
||||||
.cinematic-text-gradient {
|
.cinematic-text-gradient {
|
||||||
background: linear-gradient(
|
color: #f4f2ed;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes textGradientShift {
|
/* Solid surfaces avoid expensive backdrop blur during first paint. */
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
background-position: 100% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Glassmorphism cards */
|
|
||||||
.cinematic-glass {
|
.cinematic-glass {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(10, 10, 10, 0.4);
|
background: rgba(10, 10, 10, 0.78);
|
||||||
backdrop-filter: blur(20px);
|
transition:
|
||||||
-webkit-backdrop-filter: blur(20px);
|
border-color 0.25s ease,
|
||||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
|
background 0.25s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cinematic-glass:hover {
|
.cinematic-glass:hover {
|
||||||
border-color: rgba(204, 255, 0, 0.15);
|
border-color: rgba(204, 255, 0, 0.35);
|
||||||
background: rgba(10, 10, 10, 0.5);
|
background: rgba(10, 10, 10, 0.9);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3),
|
|
||||||
inset 0 0 0 1px rgba(204, 255, 0, 0.05);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Primary CTA - cinematic glow button */
|
/* Primary CTA - cinematic glow button */
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
/**
|
/**
|
||||||
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
||||||
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
||||||
@@ -20,5 +20,12 @@ import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useReducedMotion(): boolean {
|
export function useReducedMotion(): boolean {
|
||||||
return useFramerReducedMotion() ?? false;
|
const systemPreference = useFramerReducedMotion();
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMounted && systemPreference === true;
|
||||||
}
|
}
|
||||||
+1
-8
@@ -1,8 +1 @@
|
|||||||
<svg version="1.2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" width="1024" height="1024">
|
<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>
|
||||||
<defs>
|
|
||||||
<image width="978" height="981" id="img1" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAA9IAAAPVCAYAAACKltJmAAAAAXNSR0IB2cksfwAEfEJJREFUeJzsvXm0Jkd1Jxj5vVf1ql5tUpV2CUlVkkAyCNk0NqoqYQw2Mw2a6WamZzxeMCCVBO0+42PPZp85Ph77jGfc03+224dxj+0zAwZj9+nFxjYYA8YsAiQWIwHaN1Sral9e1au35mRkZmRGRN5YMyK/zO+7Pykrv5cZcSMyMpb7i3sjMiEIBALRExw48MA/zU7vT5Lkv9q8eTOhx9zcHJmZmSGzs7Mku07SNM3Djkaj/O/iWpJfY3/TYz1dz440D7++vl7FY2B/s3OSitdllElU4YSwSZ54I35SXuPBXxPCAumzsGl+pHVSJBHuF7fS8lzklS+L8kmJ/GQJ/Khg/qpyouU+KssYKFcxX3WeoGcR4lRPpS5X+V1VYcvnJWX9YMeIlQFJRJl5BPWzcleztAyFJMgQ62Hzfv0mmFRVfSCa+6r3lkpJlrkpi7HOU94uoHcgXRLaU/au2fumdWA0Sqp3IzwbyyNQB6D3Wgso/0nFvPI5rWSTug3w9SKX2Sx2I7TlatG2Vc9enQG58rsqBTTaC8nLgjTLS4EkkfJSlVaiLNc6/TIPpH4HNn2Hqb1CKNrmKI8itG1DexCyK5U7X1Z8PyA8t6HPFZ6Ry0sCPFf9jkijjlR1lt1OxarJ1xFZJj/OVeeqOTXroPx8pvpoKmvbcQsKV1xX10EoP7q0qjSBoFTeCHivVRzCjZtcuZrqJjgGK8onSZvX+LzyZcAuC/WmHItUY0adJsn7XL5PHo1myEwyI+hCqryy/nt1dZVcvnyZLC0tZedFcnnpMlnMzouLxbGysvJcFvwj9PhP/+HPDykzhKjgMeQgEAhEHCCRRiItpItEGok0Emkk0gogkUYijUQaifS4gUQagUBExa/92q9ty07X0CPD6LrrriPXX389/YNce+21ZNu2bTlRpscnP/kXbz148OC7Ll68+OOUQM/NbSQbNmyo7hddljgwFoMjIYSISkVOoEvCAA2caaW0Mm2KNO/XYqWbUjimwBFRZJOvSRc06WuSrIhRMwAXKjCRFuSO1MpyUoap8iQpXVBeeMInpMuIUVrG0JUXH7Z87yPSVE64qgI8MCQ4Nb6bxpNoFCJB0eb0qSqJZsGA9+UUVFnk64pAosBnJVIVEol0RUxKhU4gd/w7atRxUtUD+dl0aYuX0ur1NJ5dniSxRiqUKxhTkMlNgFT/ply1SevHTzjiAKYM56dRBoqJHzCb+Q++fRS5qYiMJEvIAzeRUaXNCW6GpbcYYWpkoup3VXlNEp5aNJV/E4mW43CR8wQEctrod/kOXJ9Go/9o1GOxnJjMVKor0CQVO0NEmqXF95RQVtmzsTM/rlXtRZ6IabwvxTUpjTqccoBj01uC2Lp/b05MQHAh0onQOfKpSpeTpGqtaTkuaV+93I9J1xL+mpRnmEgT+PmTOm+qybKqPym7XNZ2KIEe0cmoZMTVp2ZXy/JBD0qkM7JMlpeXi2NliSxlZ0qsl7MjE3vwql1X/8299+799J7dtx2/cOECWVhYIOfOnSXnz58jp06dIocPHyaHDh2ixPt8Jvo4Pf7oj/7IepScNCCRRiAQUZER6d3ZaS89MvI8pyPSR48eueX48eOvzTrvW2dmZsns7Ew+61rMxI5qfpbWA0oNcZBK2UBZDZiikgpd00FQDZiuypPESuEuBmpOpxR+cMGs8lAQ4bQKlkj36myJJFq+ryNaPIwKLK+MCwRXvA0lWj2LUrSkQPOFRYhYrnx+K0WEFQMrrEQxyEGC6P/ixcS2clR50SuIXFJEramJOVDeNwUtLzYuc4pnU/8UyQx/Fi2/7HqDPXBEUsquioAAeQAnP4S/gEmXSi4ossmxhWorymxAJhygnFS43yQf+jyC4UiT0OqRAr+Txr8qgay/FNK1Sqv4OyVineC9aGQoW6bBMgqFkwQT1hHwIXjCBbY8eULQkLa+jhZ/i0Qalm8zaQDQQiKMd+KfEpHi6rYw7jTbqdB2QUCstdlHQdUeet8uVu48TIO0Kx+aQLPECanHnzq4rjGq+l+uNQETEGn5t1CXpRw3nlNf8PV8TcLJSWpPk1qmOEnK60z0YJbptbW18lglq+VvSrJnZ2bPXblz53M3XH/js5m8iwYiTa3XX82Or2VEel2Z+QkHEmkEAhEVGZH+0ez03uz4hYw8b9ER6QyjbPAZZR39yIaQBIVmRr4RFHCd07kkqp7FpEB5uf0GKDdjWnCsgp8qLDidv08teDWXQSaG0wfTu2r7LmPVBZ98m+q4T/ohZLUDVK8hJNKZk+BEpJttSJ6MglPRSO1lf2EGRKTZGSLSFC7PONRycQE0QSHcNwvg5ufs2kFz8tQRCiItTxgz0h3//aXSWc6Fvo/Mzun6evZ/hkuXFlMDkf5KFuWP6ZER6bUIDzMITG6LRCAQneCXf/mXr85Ou+lxyy23jPbs2UNuvvlmctVVV+XHwYMHb3vppZd+/OzZsz+ekea5K664guzYsaM6Nm3alLsolUR6PLA11xI9+dVZF0B3NRORLiIaZbpYU0zgrVIqecpcQ5Yag3LUPVSKBhJpCqMy20Kht7U2+spUyR0HkW4jzx+2RJoCru/jJtIsD/3qM8yASl2wyLbso6edSNuQaIbEtGapDjl4Ig33PfqJYlP/Xlis18nS0nK+npqtn15cvJSR6gvk9OnT+ZHpbE9fd911X3rLW97ypSNHjqy/8sor5MSJE4uZmJey48X3vve9F8I8Zb8xuS0SgUB0goxIvz47/VR2vDMj0rMykc465h3nzp27cWFh4YYNGzbMFGuf58jGjRvzg20iJiu5rciga3zmCiUNitWfBgWch8sGLtWaU8DxMncNM6Rhs45QlXYjL2LGtHEd5h16iOES6T5ZhF1dYeUwofJi0wZsJ7Fi5KVb4tOeSLdLKwyRNqbcY6INWaYLEuU3iSS3E9c6FqqsXNprDNIfi0i71E7wuVSu3UQk00VKXfQv8PiWMn9zME4znWI99Vp5rBYu4Pn66uVqs7KMSJ/ZunXr4e3btx85dOhQWhLpU5mIz2bH5zIiPRWblfWzJ0IgEL3Eb/zGb2zOTpu3bdu28Q1veAOhx6c//ekfO378+E8vLy//9E033bSBkujsTHbu3Jkf1OLsgrZWK6/4uU7YHKSqnbotyLHrTqi8ezij0Y0hUZF9VyINpS8DItIu1oHhDCZIpLtw/4fCRfGg6BGRjinXMnVi69IqnkOlFZ9Ix/BqCAmdtw6FD4mW47Vpe75wkVX3M/m/rdKtZBoDxCXSyufXEOnid1kWrWm0S9+iaJupLo4qTVLu/cHeZ50XttaaEuwjR47kLt+nTp06tnHjxn939dVX/7sbb7zxhaeffprQIwt3OYu2+Cu/8itL1okPBP3rhRAIRG+REek3ZKc3ZET6FkakFxcXbz127Nibzp87/4+uuPKKmZ1X7iTUfXt+yzzZsmVLvuu2K0IQBneLNPejNCOkijyAm820ItJNi7SKRPOyQhLpXF6dOW2cYRNpCvtyKzD+pwtJPkPBpf6ZENqt2ka268ZWtnkcH9lTEWkoD63VeiCtgphAV0Ohj+1ABlgjHccHPowPSY49WaWW59InWLYnq0BjItJQWE1qbWH3XnXP75YLPjk5KbZxGT3OnTtHzp49m+/knel939q1a9e3FhYWTnJE+uksynczIv2iUwYGgP71QAgEorfIiDT9zvN7MiL9Fkaks9/zq6urO9ZW13ZkpDmhxDn/ZNVssYGY7Lbtgs7XhaWSO1YAFzymCLm4h5tgS6TlPFmT6chE2sU9Pi7SSlEokrSbxXdRLFV12FbZluVA9SkUfKypqnfpswFYWw8UFUKSiH67f6vILX9W58ltwkFNpNldE3lsyjTDFF/Z3sbozQGVRcz23MX6f7V8lcdPI4Z0NqSvyn/jvbCvC9hMpo+fSMepl/K4Fmc85esw+6xWRphXZ2dnz2bHuePHjy9zRPrTWdA/z4j0l6NkZoxAIo1AILQ4fPjg9ux0FT2+/vVH333u3Pn7M6L8ZroW+rbbbss3DJvNP1U124hbuTUNhUi3SNfWIh3Ciucii7douFrUYq2P7g+Rlt0Qm/lK02bZwYpq/q8cGybi6sw0wsppxiLSundiY61VXXedYHB9LiTSgnTSFyKtkjsOIj2W5UIKGbGJtKo9dEukXSzC/FmTvgeRpmL1DtXdEWml/T7SBM84dSd20B2+X3755fyYmZn53Pbt2//69ttv/2IW7CQ9Mh1ysdPMRQISaQQCoUVGpF+bnd6SHfeeP3/hdYuLi3cmyehGfg30KBnlO2+DGHMvY3Lz1t3XDVJW1l1LRdIWshLmQqjbKJBWJJCl4yDXN186uX7yVFYUWNFrkiuTfNkaXRLsRL4Lq322yrFuTaUtGfYvwzrdkJNFujxB6UATRy7PJIeN4UocbyLJj0gbpXoSaXeZdnHkd+zi5uzTl+vqgG97URHpkKRfh7aTF2p59kS6EGue5NVJA6YupTwkfGJSWmV95RNQ8nX7fsCFSCtltOyHQ8K1/2TnS5cu8Tt8P79hw4an5+bmvp/dfjQ7vp4R6aPxct0d+vGWEAhEb5ER6bdmp5+nx6ZNmzfMzW2a3bhx4wwlzvTQzvj2gETn2XAkyvJ9PkxXio4MH6tLDEtMoaKU6StesCmlGES6vUx7YuBGLPhzGSpNG94aRUp2ZFFIwWDhciEZba0YbSeM+PRN+VeRaD6cauLJlL4qrdjutzzC1mH+7CE1ApHm5foSaZ867vNemYVNVa9cnsHmWXy8iGS5NvAl0/rndyPS9LB5VpVEFZFOq23vuLw2ypWSaCBN4JLLe2lLpGNNNPvAyRIvhaVrp4sdv9foTt+ri4uLq0tLS8ey2x+nR0akn4Line truncated
|
|
||||||
</defs>
|
|
||||||
<style>
|
|
||||||
</style>
|
|
||||||
<use id="Background" href="#img1" x="13" y="21"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 383 KiB After Width: | Height: | Size: 288 B |
+92
-47
@@ -2,18 +2,19 @@ import type { Metadata, Viewport } from "next";
|
|||||||
import { Geist_Mono, Syne } from "next/font/google";
|
import { Geist_Mono, Syne } from "next/font/google";
|
||||||
import localFont from "next/font/local";
|
import localFont from "next/font/local";
|
||||||
import type { ReactNode } from "react";
|
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 CinematicOverlays from "./components/cinematic/CinematicOverlays";
|
||||||
|
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||||
import { RouteTransition } from "./components/RouteTransition";
|
import { RouteTransition } from "./components/RouteTransition";
|
||||||
import { AnimationProvider } from "./providers/AnimationProvider";
|
import { AnimationProvider } from "./providers/AnimationProvider";
|
||||||
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const siteUrl = "https://evandro.dev.br";
|
|
||||||
const seoTitle =
|
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 =
|
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({
|
const syne = Syne({
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
@@ -50,20 +51,31 @@ export const metadata: Metadata = {
|
|||||||
title: seoTitle,
|
title: seoTitle,
|
||||||
description: seoDescription,
|
description: seoDescription,
|
||||||
keywords: [
|
keywords: [
|
||||||
"engenharia de software",
|
"desenvolvedor de software em Garuva",
|
||||||
"engenheiro de software",
|
"engenheiro da computação em Garuva",
|
||||||
"consultoria tecnica",
|
"desenvolvedor de sites em Garuva",
|
||||||
"consultoria next.js",
|
"desenvolvedor de software em Joinville",
|
||||||
"next.js",
|
"engenheiro da computação em Joinville",
|
||||||
"tauri",
|
"desenvolvedor de sites em Joinville",
|
||||||
"rust",
|
"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",
|
"arquitetura de software",
|
||||||
"sistemas criticos",
|
|
||||||
"integracao hardware software",
|
|
||||||
"iot",
|
|
||||||
"software para iot",
|
|
||||||
"engenharia full-stack",
|
|
||||||
"aplicacoes de alta performance",
|
|
||||||
],
|
],
|
||||||
authors: [{ name: "Evandro Fonseca Junior" }],
|
authors: [{ name: "Evandro Fonseca Junior" }],
|
||||||
creator: "Evandro Fonseca Junior",
|
creator: "Evandro Fonseca Junior",
|
||||||
@@ -138,27 +150,48 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
|||||||
inLanguage: "pt-BR",
|
inLanguage: "pt-BR",
|
||||||
description: seoDescription,
|
description: seoDescription,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"@type": "ProfilePage",
|
||||||
|
"@id": `${siteUrl}/curriculo#profile`,
|
||||||
|
url: `${siteUrl}/curriculo`,
|
||||||
|
inLanguage: "pt-BR",
|
||||||
|
mainEntity: { "@id": `${siteUrl}/#person` },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
"@id": `${siteUrl}/#person`,
|
"@id": `${siteUrl}/#person`,
|
||||||
name: "Evandro Fonseca Junior",
|
name: "Evandro Fonseca Junior",
|
||||||
alternateName: ["Evandro.dev", "evandrodevbr"],
|
alternateName: ["Evandro.dev", "evandrodevbr"],
|
||||||
jobTitle: "Engenheiro de Software Full-Stack",
|
jobTitle:
|
||||||
|
"Engenheiro da Computação e Desenvolvedor de Software Full-Stack",
|
||||||
description: seoDescription,
|
description: seoDescription,
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
image: `${siteUrl}/opengraph-image`,
|
image: `${siteUrl}/opengraph-image`,
|
||||||
|
telephone: `+${getWhatsAppNumber()}`,
|
||||||
sameAs: [
|
sameAs: [
|
||||||
"https://github.com/evandrodevbr",
|
"https://github.com/evandrodevbr",
|
||||||
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
"https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||||
"https://www.instagram.com/evandrodevbr/",
|
"https://www.instagram.com/evandrodevbr/",
|
||||||
],
|
],
|
||||||
worksFor: {
|
address: {
|
||||||
"@type": "Organization",
|
"@type": "PostalAddress",
|
||||||
name: "Consultoria Independente",
|
addressLocality: "Garuva",
|
||||||
|
addressRegion: "SC",
|
||||||
|
addressCountry: "BR",
|
||||||
},
|
},
|
||||||
homeLocation: {
|
homeLocation: {
|
||||||
"@type": "Place",
|
"@type": "Place",
|
||||||
name: "Garuva, Santa Catarina, Brasil",
|
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: [
|
knowsAbout: [
|
||||||
"Engenharia de Software",
|
"Engenharia de Software",
|
||||||
@@ -166,8 +199,8 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
|||||||
"Tauri",
|
"Tauri",
|
||||||
"Rust",
|
"Rust",
|
||||||
"Arquitetura de Software",
|
"Arquitetura de Software",
|
||||||
"Sistemas Criticos",
|
"Sistemas Críticos",
|
||||||
"Integracao hardware e software",
|
"Integração hardware e software",
|
||||||
"IoT",
|
"IoT",
|
||||||
"TypeScript",
|
"TypeScript",
|
||||||
"Cloud Computing",
|
"Cloud Computing",
|
||||||
@@ -176,25 +209,46 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
|||||||
{
|
{
|
||||||
"@type": "ProfessionalService",
|
"@type": "ProfessionalService",
|
||||||
"@id": `${siteUrl}/#services`,
|
"@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,
|
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:
|
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: [
|
areaServed: [
|
||||||
|
...regionCities.map((name) => ({ "@type": "City", name })),
|
||||||
|
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
|
||||||
|
{ "@type": "AdministrativeArea", name: "Paraná" },
|
||||||
{
|
{
|
||||||
"@type": "Country",
|
"@type": "GeoCircle",
|
||||||
name: "Brasil",
|
geoMidpoint: {
|
||||||
},
|
"@type": "GeoCoordinates",
|
||||||
{
|
latitude: -26.0281,
|
||||||
"@type": "Place",
|
longitude: -48.8467,
|
||||||
name: "Remote worldwide",
|
},
|
||||||
|
geoRadius: "150000",
|
||||||
},
|
},
|
||||||
|
{ "@type": "Country", name: "Brasil" },
|
||||||
|
{ "@type": "Place", name: "Atuação remota" },
|
||||||
],
|
],
|
||||||
provider: {
|
provider: {
|
||||||
"@id": `${siteUrl}/#person`,
|
"@id": `${siteUrl}/#person`,
|
||||||
},
|
},
|
||||||
serviceType: [
|
serviceType: [
|
||||||
"Engenharia de Software",
|
"Engenharia de Software",
|
||||||
|
"Criação de Sites",
|
||||||
"Consultoria Tecnica",
|
"Consultoria Tecnica",
|
||||||
"Arquitetura de Software",
|
"Arquitetura de Software",
|
||||||
"Integracao hardware + software",
|
"Integracao hardware + software",
|
||||||
@@ -208,6 +262,13 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
|||||||
"@type": "OfferCatalog",
|
"@type": "OfferCatalog",
|
||||||
name: "Modelos de contratacao",
|
name: "Modelos de contratacao",
|
||||||
itemListElement: [
|
itemListElement: [
|
||||||
|
{
|
||||||
|
"@type": "Offer",
|
||||||
|
itemOffered: {
|
||||||
|
"@type": "Service",
|
||||||
|
name: "Criação de sites institucionais e sistemas web",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"@type": "Offer",
|
"@type": "Offer",
|
||||||
itemOffered: {
|
itemOffered: {
|
||||||
@@ -240,22 +301,6 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
|||||||
lang="pt-BR"
|
lang="pt-BR"
|
||||||
className={`${syne.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
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>
|
<body>
|
||||||
<SmoothScrollProvider>
|
<SmoothScrollProvider>
|
||||||
<AnimationProvider>
|
<AnimationProvider>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export function shouldLoadHeroScene({
|
||||||
|
reducedMotion,
|
||||||
|
coarsePointer,
|
||||||
|
width,
|
||||||
|
}: {
|
||||||
|
reducedMotion: boolean;
|
||||||
|
coarsePointer: boolean;
|
||||||
|
width: number;
|
||||||
|
}) {
|
||||||
|
return !reducedMotion && !coarsePointer && width >= 1024;
|
||||||
|
}
|
||||||
+3
-3
@@ -2,16 +2,16 @@ import { MetadataRoute } from "next";
|
|||||||
|
|
||||||
export default function manifest(): MetadataRoute.Manifest {
|
export default function manifest(): MetadataRoute.Manifest {
|
||||||
return {
|
return {
|
||||||
name: "Evandro Fonseca Junior - Portfólio",
|
name: "Evandro Fonseca Junior — Engenheiro da Computação e Desenvolvedor de Software",
|
||||||
short_name: "Evandro.dev",
|
short_name: "Evandro.dev",
|
||||||
description:
|
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: "/",
|
start_url: "/",
|
||||||
display: "standalone",
|
display: "standalone",
|
||||||
background_color: "#000000",
|
background_color: "#000000",
|
||||||
theme_color: "#000000",
|
theme_color: "#000000",
|
||||||
orientation: "portrait-primary",
|
orientation: "portrait-primary",
|
||||||
categories: ["portfolio", "developer", "technology"],
|
categories: ["portfolio", "developer", "engineering", "technology"],
|
||||||
lang: "pt-BR",
|
lang: "pt-BR",
|
||||||
dir: "ltr",
|
dir: "ltr",
|
||||||
icons: [
|
icons: [
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { ImageResponse } from "next/og";
|
||||||
|
|
||||||
export const runtime = "edge";
|
export const alt = "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
|
||||||
export const alt = "Evandro Fonseca Junior - Engenheiro de Software";
|
|
||||||
export const size = {
|
export const size = {
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 630,
|
height: 630,
|
||||||
@@ -41,7 +40,7 @@ export default async function Image() {
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Engenheiro de Software
|
Engenheiro da Computação e Desenvolvedor de Software
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
{ ...size },
|
{ ...size },
|
||||||
|
|||||||
+11
-2
@@ -1,5 +1,14 @@
|
|||||||
|
import { loadLiveFeaturedProjects, portfolio } from "@/lib/portfolio-data";
|
||||||
import CinematicHome from "./components/cinematic/CinematicHome";
|
import CinematicHome from "./components/cinematic/CinematicHome";
|
||||||
|
|
||||||
export default function Home() {
|
export const revalidate = 300;
|
||||||
return <CinematicHome />;
|
|
||||||
|
export default async function Home() {
|
||||||
|
return (
|
||||||
|
<CinematicHome
|
||||||
|
projects={await loadLiveFeaturedProjects()}
|
||||||
|
experiences={portfolio.experiences}
|
||||||
|
skillCategories={portfolio.skills.technical}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||||||
import {
|
import {
|
||||||
getRelatedCases,
|
getRelatedCases,
|
||||||
getServicePage,
|
getServicePage,
|
||||||
|
regionCities,
|
||||||
servicePages,
|
servicePages,
|
||||||
siteName,
|
siteName,
|
||||||
siteUrl,
|
siteUrl,
|
||||||
@@ -82,6 +83,8 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
|||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
},
|
},
|
||||||
areaServed: [
|
areaServed: [
|
||||||
|
...regionCities.map((name) => ({ "@type": "City", name })),
|
||||||
|
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
|
||||||
{
|
{
|
||||||
"@type": "Country",
|
"@type": "Country",
|
||||||
name: "Brasil",
|
name: "Brasil",
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ export default function ServicesIndexPage() {
|
|||||||
name: service.navLabel,
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { MetadataRoute } from "next";
|
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 {
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -8,9 +8,15 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
{
|
{
|
||||||
url: siteUrl,
|
url: siteUrl,
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
changeFrequency: "monthly",
|
changeFrequency: "weekly",
|
||||||
priority: 1.0,
|
priority: 1.0,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
url: `${siteUrl}/curriculo`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "monthly",
|
||||||
|
priority: 0.95,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
url: `${siteUrl}/servicos`,
|
url: `${siteUrl}/servicos`,
|
||||||
lastModified: now,
|
lastModified: now,
|
||||||
@@ -29,5 +35,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
|||||||
changeFrequency: "monthly" as const,
|
changeFrequency: "monthly" as const,
|
||||||
priority: 0.8,
|
priority: 0.8,
|
||||||
})),
|
})),
|
||||||
|
...caseStudies.map((caseStudy) => ({
|
||||||
|
url: `${siteUrl}/cases/${caseStudy.id}`,
|
||||||
|
lastModified: now,
|
||||||
|
changeFrequency: "monthly" as const,
|
||||||
|
priority: 0.75,
|
||||||
|
})),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,61 @@
|
|||||||
|
# Random Hero Variations 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:** Build six weighted, randomly selected Hero Section layouts with matching 3D scene modes.
|
||||||
|
|
||||||
|
**Architecture:** Add data-driven hero variant configuration in `Hero.tsx`, choose a variant once on client mount, and render layout-specific panel/accent components from that config. Update `HeroScene.tsx` to accept a scene variant prop and switch geometry composition without adding dependencies.
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js, React 19, TypeScript, Framer Motion, @react-three/fiber, @react-three/drei, Three.js.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add variant data and weighted picker
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/Hero.tsx`
|
||||||
|
|
||||||
|
- [ ] Define `HeroVariantId`, `HeroSceneVariant`, and `HeroVariant` types.
|
||||||
|
- [ ] Add six approved variant objects with copy, points, proof items, side panel mode, layout mode, CTA text, and weights.
|
||||||
|
- [ ] Add `pickWeightedHeroVariant()` using `Math.random()` and relative weights.
|
||||||
|
- [ ] Keep `HERO_VARIANTS[0]` as deterministic SSR/default fallback.
|
||||||
|
|
||||||
|
### Task 2: Render six hero layouts from shared primitives
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/Hero.tsx`
|
||||||
|
|
||||||
|
- [ ] Replace fixed hero text with selected variant data.
|
||||||
|
- [ ] Add layout-specific class maps for Command Center, Launch Sequence, Rescue Split, IoT Signal Map, Diagnostic Report, and Cinematic Portrait.
|
||||||
|
- [ ] Add side panel renderer that draws distinct visual panels for each mode using existing CSS/Tailwind utilities.
|
||||||
|
- [ ] Keep primary WhatsApp CTA and secondary cases/CV actions.
|
||||||
|
|
||||||
|
### Task 3: Parameterize the 3D scene
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/cinematic/HeroScene.tsx`
|
||||||
|
|
||||||
|
- [ ] Export/accept a `HeroSceneVariant`-compatible prop locally as string union.
|
||||||
|
- [ ] Pass the selected scene mode from `Hero.tsx` into `HeroScene`.
|
||||||
|
- [ ] Change floating geometry positions/geometries per scene mode.
|
||||||
|
- [ ] Keep reduced motion behavior unchanged: no `HeroScene` render when reduced motion is enabled.
|
||||||
|
|
||||||
|
### Task 4: Update tests
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `app/components/Hero.test.tsx`
|
||||||
|
- Modify: `app/components/Hero.reducedMotion.test.tsx`
|
||||||
|
|
||||||
|
- [ ] Update assertions to current commercial CTA/copy.
|
||||||
|
- [ ] Assert at least one approved hero layout renders.
|
||||||
|
- [ ] Assert CTA remains a button and cases/CV remain available.
|
||||||
|
- [ ] Assert reduced-motion content still renders without animation-specific failures.
|
||||||
|
|
||||||
|
### Task 5: Verify
|
||||||
|
|
||||||
|
**Commands:**
|
||||||
|
- `pnpm exec tsc --noEmit --pretty false`
|
||||||
|
- `pnpm test -- Hero.test.tsx Hero.reducedMotion.test.tsx --runInBand`
|
||||||
|
- `pnpm build`
|
||||||
|
|
||||||
|
**Expected:** all commands exit 0.
|
||||||
@@ -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,37 @@
|
|||||||
|
# Random Hero Variations Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create six structurally different Hero Section variations for the portfolio home page. Each visit should pick one variation randomly using weighted probability, while preserving the existing visual identity: dark cinematic background, slash green accent, glass panels, mono labels, strong display typography, technical lines, and WhatsApp conversion CTA.
|
||||||
|
|
||||||
|
## Approved hero variants
|
||||||
|
|
||||||
|
1. **Command Center** — Main/default hero. Two-column mission-control layout with production status, deploy/operation language, and strong CTA.
|
||||||
|
2. **Launch Sequence** — Timeline/checklist layout showing discovery → build → deploy, focused on launching MVPs and putting systems in production.
|
||||||
|
3. **Before / After Rescue** — Split layout comparing technical chaos vs organized system, focused on legacy, instability, and rescue projects.
|
||||||
|
4. **IoT Signal Map** — Signal/network layout with nodes and telemetry language, focused on hardware + software, APIs, dashboards, and connected products.
|
||||||
|
5. **Diagnostic Report** — Report/audit layout with risk, architecture, and next-step panels, focused on technical diagnosis before more code.
|
||||||
|
6. **Cinematic Portrait** — More personal consultant-led layout with trust, direct conversation, and premium technical partner positioning.
|
||||||
|
|
||||||
|
## Weighted distribution
|
||||||
|
|
||||||
|
- Command Center: 22
|
||||||
|
- Launch Sequence: 18
|
||||||
|
- Before / After Rescue: 16
|
||||||
|
- IoT Signal Map: 15
|
||||||
|
- Diagnostic Report: 15
|
||||||
|
- Cinematic Portrait: 14
|
||||||
|
|
||||||
|
Weights are relative. Command Center appears most often because it is the clearest commercial positioning.
|
||||||
|
|
||||||
|
## Implementation approach
|
||||||
|
|
||||||
|
Keep the implementation lazy and maintainable: one data array defines copy, layout, side panel, points, CTA, and scene mode. `Hero.tsx` renders the same primitives with layout-specific branches instead of six separate full components. `HeroScene.tsx` accepts a `variant` prop and changes the 3D object composition per selected hero.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Do not expose the WhatsApp number as a complete literal.
|
||||||
|
- Do not add dependencies.
|
||||||
|
- Keep accessibility basics: real buttons/links, visible text, keyboard focus.
|
||||||
|
- Avoid horizontal scroll.
|
||||||
|
- Reduced motion must continue to hide the 3D scene.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getLiveFeaturedProjects } from "./portfolio-data";
|
||||||
|
import type { ProjectHealthSnapshot } from "./project-health";
|
||||||
|
|
||||||
|
describe("getLiveFeaturedProjects", () => {
|
||||||
|
it("returns only featured projects validated in the current snapshot", () => {
|
||||||
|
const snapshot: ProjectHealthSnapshot = {
|
||||||
|
checkedAt: "2026-07-10T12:00:00.000Z",
|
||||||
|
projects: {
|
||||||
|
"sev-modaintima": {
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/html; charset=utf-8",
|
||||||
|
checkedAt: "2026-07-10T12:00:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getLiveFeaturedProjects(
|
||||||
|
snapshot,
|
||||||
|
new Date("2026-07-10T12:01:00.000Z"),
|
||||||
|
).map((project) => project.id),
|
||||||
|
).toEqual(["sev-modaintima"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
import portfolioData from "@/public/portfolio-data.json";
|
||||||
|
import {
|
||||||
|
createProjectHealthSnapshot,
|
||||||
|
filterLiveProjects,
|
||||||
|
type ProjectHealthSnapshot,
|
||||||
|
} from "./project-health";
|
||||||
|
|
||||||
|
export type PortfolioData = typeof portfolioData;
|
||||||
|
export type PortfolioProject = PortfolioData["projects"][number];
|
||||||
|
export type PortfolioExperience = PortfolioData["experiences"][number];
|
||||||
|
export type PortfolioSkillCategory =
|
||||||
|
PortfolioData["skills"]["technical"][number];
|
||||||
|
|
||||||
|
export const portfolio = portfolioData;
|
||||||
|
|
||||||
|
export function getLiveFeaturedProjects(
|
||||||
|
snapshot: ProjectHealthSnapshot | null,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
return filterLiveProjects<PortfolioProject>(
|
||||||
|
portfolio.projects,
|
||||||
|
snapshot,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProjectHealthSnapshot(): Promise<ProjectHealthSnapshot | null> {
|
||||||
|
try {
|
||||||
|
const path = resolve(process.cwd(), "data/project-health.json");
|
||||||
|
return JSON.parse(await readFile(path, "utf8")) as ProjectHealthSnapshot;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadLiveFeaturedProjects() {
|
||||||
|
const fromSnapshot = getLiveFeaturedProjects(await loadProjectHealthSnapshot());
|
||||||
|
if (fromSnapshot.length > 0) return fromSnapshot;
|
||||||
|
|
||||||
|
const projectDemos = portfolio.projects.reduce<
|
||||||
|
Array<{ id: string; demoUrl: string }>
|
||||||
|
>((demos, project) => {
|
||||||
|
if (project.demoUrl) demos.push({ id: project.id, demoUrl: project.demoUrl });
|
||||||
|
return demos;
|
||||||
|
}, []);
|
||||||
|
const freshSnapshot = await createProjectHealthSnapshot(projectDemos);
|
||||||
|
return getLiveFeaturedProjects(freshSnapshot);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
createProjectHealthSnapshot,
|
||||||
|
filterLiveProjects,
|
||||||
|
isCurrentLiveCheck,
|
||||||
|
type ProjectHealthSnapshot,
|
||||||
|
} from "./project-health";
|
||||||
|
|
||||||
|
describe("project health snapshot", () => {
|
||||||
|
const now = new Date("2026-07-10T12:00:00.000Z");
|
||||||
|
const snapshot: ProjectHealthSnapshot = {
|
||||||
|
checkedAt: "2026-07-10T11:55:00.000Z",
|
||||||
|
projects: {
|
||||||
|
online: {
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/html; charset=utf-8",
|
||||||
|
checkedAt: "2026-07-10T11:55:00.000Z",
|
||||||
|
},
|
||||||
|
redirect: {
|
||||||
|
status: 301,
|
||||||
|
contentType: "text/html",
|
||||||
|
checkedAt: "2026-07-10T11:55:00.000Z",
|
||||||
|
},
|
||||||
|
stale: {
|
||||||
|
status: 200,
|
||||||
|
contentType: "text/html",
|
||||||
|
checkedAt: "2026-07-10T10:30:00.000Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("accepts only fresh final HTTP 200 HTML projects", () => {
|
||||||
|
expect(isCurrentLiveCheck(snapshot.projects.online, now)).toBe(true);
|
||||||
|
expect(isCurrentLiveCheck(snapshot.projects.redirect, now)).toBe(false);
|
||||||
|
expect(isCurrentLiveCheck(snapshot.projects.stale, now)).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
filterLiveProjects(
|
||||||
|
[
|
||||||
|
{ id: "online", featured: true },
|
||||||
|
{ id: "redirect", featured: true },
|
||||||
|
{ id: "stale", featured: true },
|
||||||
|
{ id: "hidden", featured: false },
|
||||||
|
],
|
||||||
|
snapshot,
|
||||||
|
now,
|
||||||
|
).map((project) => project.id),
|
||||||
|
).toEqual(["online"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed when the snapshot is absent or expired", () => {
|
||||||
|
expect(
|
||||||
|
filterLiveProjects([{ id: "online", featured: true }], null, now),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(
|
||||||
|
filterLiveProjects(
|
||||||
|
[{ id: "online", featured: true }],
|
||||||
|
{ ...snapshot, checkedAt: "2026-07-10T10:00:00.000Z" },
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a fresh health snapshot when the stored result is unavailable", async () => {
|
||||||
|
const fetcher = jest.fn(async (url: string) => ({
|
||||||
|
status: url.includes("online") ? 200 : 503,
|
||||||
|
url,
|
||||||
|
headers: new Headers({
|
||||||
|
"content-type": url.includes("online") ? "text/html" : "text/plain",
|
||||||
|
}),
|
||||||
|
body: { cancel: jest.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await createProjectHealthSnapshot(
|
||||||
|
[
|
||||||
|
{ id: "online", demoUrl: "https://online.example" },
|
||||||
|
{ id: "offline", demoUrl: "https://offline.example" },
|
||||||
|
],
|
||||||
|
fetcher,
|
||||||
|
new Date("2026-07-10T12:00:00.000Z"),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.projects.online.status).toBe(200);
|
||||||
|
expect(result.projects.online.contentType).toBe("text/html");
|
||||||
|
expect(result.projects.offline.status).toBe(503);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
export const PROJECT_HEALTH_MAX_AGE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
export interface ProjectHealthCheck {
|
||||||
|
status: number | null;
|
||||||
|
contentType: string | null;
|
||||||
|
checkedAt: string;
|
||||||
|
finalUrl?: string;
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectHealthSnapshot {
|
||||||
|
checkedAt: string;
|
||||||
|
projects: Record<string, ProjectHealthCheck>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectLike {
|
||||||
|
id: string;
|
||||||
|
featured?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFresh(checkedAt: string, now: Date) {
|
||||||
|
const timestamp = Date.parse(checkedAt);
|
||||||
|
return (
|
||||||
|
Number.isFinite(timestamp) &&
|
||||||
|
now.getTime() - timestamp <= PROJECT_HEALTH_MAX_AGE_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCurrentLiveCheck(
|
||||||
|
check: ProjectHealthCheck | undefined,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
return Boolean(
|
||||||
|
check &&
|
||||||
|
check.status === 200 &&
|
||||||
|
check.contentType?.toLowerCase().startsWith("text/html") &&
|
||||||
|
isFresh(check.checkedAt, now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterLiveProjects<T extends ProjectLike>(
|
||||||
|
projects: T[],
|
||||||
|
snapshot: ProjectHealthSnapshot | null,
|
||||||
|
now = new Date(),
|
||||||
|
) {
|
||||||
|
if (!snapshot || !isFresh(snapshot.checkedAt, now)) {
|
||||||
|
return [] as T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return projects.filter(
|
||||||
|
(project) =>
|
||||||
|
project.featured !== false &&
|
||||||
|
isCurrentLiveCheck(snapshot.projects[project.id], now),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectDemo {
|
||||||
|
id: string;
|
||||||
|
demoUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthResponse {
|
||||||
|
status: number;
|
||||||
|
url: string;
|
||||||
|
headers: { get(name: string): string | null };
|
||||||
|
body?: { cancel?: () => unknown } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthFetcher = (url: string, init: RequestInit) => Promise<HealthResponse>;
|
||||||
|
|
||||||
|
export async function createProjectHealthSnapshot(
|
||||||
|
projects: ProjectDemo[],
|
||||||
|
fetcher: HealthFetcher = (url, init) => fetch(url, init),
|
||||||
|
now = new Date(),
|
||||||
|
): Promise<ProjectHealthSnapshot> {
|
||||||
|
const checkedAt = now.toISOString();
|
||||||
|
const checks = await Promise.all(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
try {
|
||||||
|
const response = await fetcher(project.demoUrl, {
|
||||||
|
redirect: "follow",
|
||||||
|
signal: AbortSignal.timeout(10_000),
|
||||||
|
headers: { "user-agent": "evandro-project-health/1.0" },
|
||||||
|
});
|
||||||
|
await response.body?.cancel?.();
|
||||||
|
|
||||||
|
return [
|
||||||
|
project.id,
|
||||||
|
{
|
||||||
|
status: response.status,
|
||||||
|
contentType: response.headers.get("content-type"),
|
||||||
|
checkedAt,
|
||||||
|
finalUrl: response.url,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
} catch (error) {
|
||||||
|
return [
|
||||||
|
project.id,
|
||||||
|
{
|
||||||
|
status: null,
|
||||||
|
contentType: null,
|
||||||
|
checkedAt,
|
||||||
|
error: error instanceof Error ? error.message : "Health check failed",
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { checkedAt, projects: Object.fromEntries(checks) };
|
||||||
|
}
|
||||||
+78
-11
@@ -1,6 +1,17 @@
|
|||||||
export const siteUrl = "https://evandro.dev.br";
|
export const siteUrl = "https://evandro.dev.br";
|
||||||
export const siteName = "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 {
|
export interface ServicePageContent {
|
||||||
slug: string;
|
slug: string;
|
||||||
navLabel: string;
|
navLabel: string;
|
||||||
@@ -30,6 +41,40 @@ export interface CaseStudyContent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const servicePages: ServicePageContent[] = [
|
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",
|
slug: "engenharia-de-software",
|
||||||
navLabel: "Engenharia de Software",
|
navLabel: "Engenharia de Software",
|
||||||
@@ -61,8 +106,7 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
],
|
],
|
||||||
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
||||||
ctaLabel: "Agendar conversa sobre engenharia",
|
ctaLabel: "Agendar conversa sobre engenharia",
|
||||||
ctaHref:
|
ctaHref: "/#contact",
|
||||||
"https://wa.me/5547992742365?text=Quero%20conversar%20sobre%20engenharia%20de%20software.",
|
|
||||||
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
|
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -96,7 +140,7 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
],
|
],
|
||||||
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO tecnico"],
|
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO tecnico"],
|
||||||
ctaLabel: "Solicitar consultoria Next.js",
|
ctaLabel: "Solicitar consultoria Next.js",
|
||||||
ctaHref: "https://wa.me/5547992742365?text=Quero%20consultoria%20em%20Next.js.",
|
ctaHref: "/#contact",
|
||||||
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
|
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,8 +180,7 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
"System integration",
|
"System integration",
|
||||||
],
|
],
|
||||||
ctaLabel: "Conversar sobre Tauri e Rust",
|
ctaLabel: "Conversar sobre Tauri e Rust",
|
||||||
ctaHref:
|
ctaHref: "/#contact",
|
||||||
"https://wa.me/5547992742365?text=Quero%20conversar%20sobre%20Tauri%20e%20Rust.",
|
|
||||||
relatedCaseIds: ["gcc-installer"],
|
relatedCaseIds: ["gcc-installer"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -168,10 +211,15 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
"Implementacao do fluxo com testes e tratamento de excecoes relevantes.",
|
"Implementacao do fluxo com testes e tratamento de excecoes relevantes.",
|
||||||
"Entrega com documentacao de operacao, limites e proximo passo.",
|
"Entrega com documentacao de operacao, limites e proximo passo.",
|
||||||
],
|
],
|
||||||
stack: ["IoT", "WebSockets", "APIs", "Dashboards", "Integracao de sistemas"],
|
stack: [
|
||||||
|
"IoT",
|
||||||
|
"WebSockets",
|
||||||
|
"APIs",
|
||||||
|
"Dashboards",
|
||||||
|
"Integracao de sistemas",
|
||||||
|
],
|
||||||
ctaLabel: "Falar sobre integracao e IoT",
|
ctaLabel: "Falar sobre integracao e IoT",
|
||||||
ctaHref:
|
ctaHref: "/#contact",
|
||||||
"https://wa.me/5547992742365?text=Quero%20falar%20sobre%20integracao%20hardware%20%2B%20software%20e%20IoT.",
|
|
||||||
relatedCaseIds: ["twins-lab"],
|
relatedCaseIds: ["twins-lab"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -211,8 +259,7 @@ export const servicePages: ServicePageContent[] = [
|
|||||||
"Sistemas criticos",
|
"Sistemas criticos",
|
||||||
],
|
],
|
||||||
ctaLabel: "Solicitar diagnostico arquitetural",
|
ctaLabel: "Solicitar diagnostico arquitetural",
|
||||||
ctaHref:
|
ctaHref: "/#contact",
|
||||||
"https://wa.me/5547992742365?text=Quero%20um%20diagnostico%20de%20arquitetura%20de%20software.",
|
|
||||||
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
|
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -268,7 +315,8 @@ export const caseStudies: CaseStudyContent[] = [
|
|||||||
{
|
{
|
||||||
id: "gcc-installer",
|
id: "gcc-installer",
|
||||||
title: "MinGW GCC Installer",
|
title: "MinGW GCC Installer",
|
||||||
subtitle: "Ferramenta desktop para automacao de ambiente de desenvolvimento",
|
subtitle:
|
||||||
|
"Ferramenta desktop para automacao de ambiente de desenvolvimento",
|
||||||
summary:
|
summary:
|
||||||
"Aplicacao em Python para simplificar setup local no Windows, com download assincrono, checagem de compatibilidade e integracao com PATH.",
|
"Aplicacao em Python para simplificar setup local no Windows, com download assincrono, checagem de compatibilidade e integracao com PATH.",
|
||||||
impact:
|
impact:
|
||||||
@@ -276,6 +324,25 @@ export const caseStudies: CaseStudyContent[] = [
|
|||||||
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
|
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
|
||||||
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
|
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "as-servicos",
|
||||||
|
title: "AS Serviços",
|
||||||
|
subtitle: "Site institucional multidisciplinar com cena 3D procedural",
|
||||||
|
summary:
|
||||||
|
"Site institucional em Next.js 16 com cena 3D procedural em Three.js: uma casa wireframe que se transforma em placa-mae conforme o scroll. Quatro areas de engenharia — civil, eletrica, eletronica/automacao e software — sob o mesmo teto.",
|
||||||
|
impact:
|
||||||
|
"Demonstra engenharia de software aplicada a presenca digital: cena 3D gerada em runtime sem assets externos, animacao dirigida por scroll e codigo 100% tipado.",
|
||||||
|
stack: [
|
||||||
|
"Next.js 16",
|
||||||
|
"React 19",
|
||||||
|
"Three.js",
|
||||||
|
"TypeScript",
|
||||||
|
"Tailwind CSS 4",
|
||||||
|
"shadcn/ui",
|
||||||
|
],
|
||||||
|
demoUrl: "https://asservicos.com",
|
||||||
|
githubUrl: "https://github.com/evandrodevbr/as-servicos",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const serviceHubLinks = servicePages.map((service) => ({
|
export const serviceHubLinks = servicePages.map((service) => ({
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const COUNTRY = ["5", "5"];
|
||||||
|
const AREA = ["4", "7"];
|
||||||
|
const NINE = ["9"];
|
||||||
|
const FIRST = ["9", "2", "7", "4"];
|
||||||
|
const LAST = ["2", "3", "6", "5"];
|
||||||
|
|
||||||
|
export function getWhatsAppNumber() {
|
||||||
|
return [...COUNTRY, ...AREA, ...NINE, ...FIRST, ...LAST].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppDisplayNumber() {
|
||||||
|
return `+${COUNTRY.join("")} ${AREA.join("")} ${NINE.join("")}${FIRST.join("")}-${LAST.join("")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWhatsAppUrl(message: string) {
|
||||||
|
return `https://wa.me/${getWhatsAppNumber()}?text=${encodeURIComponent(message)}`;
|
||||||
|
}
|
||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# Evandro Fonseca Junior
|
# Evandro Fonseca Junior
|
||||||
|
|
||||||
**Desenvolvedor Full-Stack Angular**
|
**Engenheiro da Computação e Desenvolvedor de Software Full-Stack**
|
||||||
|
|
||||||
📍 Garuva, Santa Catarina, Brasil
|
📍 Garuva, Santa Catarina, Brasil
|
||||||
🔗 [LinkedIn](https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/)
|
🔗 [LinkedIn](https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/)
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
## Sobre Mim
|
## 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.
|
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:**
|
**🔍 Em busca de oportunidades como:**
|
||||||
|
|
||||||
- Engenheiro de Software
|
- Engenheiro da Computação
|
||||||
- Desenvolvedor Full-Stack
|
- Desenvolvedor Full-Stack
|
||||||
- Desenvolvedor Web
|
- Desenvolvedor Web
|
||||||
|
|
||||||
|
|||||||
+3
-12
@@ -9,11 +9,11 @@ const nextConfig: NextConfig = {
|
|||||||
minimumCacheTTL: 60,
|
minimumCacheTTL: 60,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Otimizações de performance
|
turbopack: {
|
||||||
experimental: {
|
root: process.cwd(),
|
||||||
scrollRestoration: true,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
// Headers de cache e segurança
|
// Headers de cache e segurança
|
||||||
async headers(): Promise<
|
async headers(): Promise<
|
||||||
Array<{ source: string; headers: Array<{ key: string; value: string }> }>
|
Array<{ source: string; headers: Array<{ key: string; value: string }> }>
|
||||||
@@ -59,15 +59,6 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
source: "/_next/image(.*)",
|
|
||||||
headers: [
|
|
||||||
{
|
|
||||||
key: "Cache-Control",
|
|
||||||
value: "public, max-age=31536000, immutable",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
source: "/fonts/(.*)",
|
source: "/fonts/(.*)",
|
||||||
headers: [
|
headers: [
|
||||||
|
|||||||
+4
-4
@@ -9,13 +9,14 @@
|
|||||||
"lint": "biome check",
|
"lint": "biome check",
|
||||||
"format": "biome format --write",
|
"format": "biome format --write",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch",
|
||||||
|
"check:projects": "node scripts/check-projects.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gsap/react": "^2.1.2",
|
"@gsap/react": "^2.1.2",
|
||||||
"@react-three/drei": "^10.7.7",
|
"@react-three/drei": "^10.7.7",
|
||||||
"@react-three/fiber": "^9.5.0",
|
"@react-three/fiber": "^9.5.0",
|
||||||
"@types/three": "^0.183.1",
|
"@types/three": "0.182.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cobe": "^0.6.4",
|
"cobe": "^0.6.4",
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"three": "^0.183.2"
|
"three": "0.182.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
@@ -55,4 +56,3 @@
|
|||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Generated
+402
-396
File diff suppressed because it is too large.
Load diff
@@ -1,15 +1,39 @@
|
|||||||
packages:
|
packages:
|
||||||
- .
|
- .
|
||||||
|
|
||||||
|
allowBuilds:
|
||||||
|
'@tailwindcss/oxide': true
|
||||||
|
sharp: true
|
||||||
|
unrs-resolver: true
|
||||||
|
|
||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
- '@tailwindcss/oxide'
|
- '@tailwindcss/oxide'
|
||||||
- sharp
|
- sharp
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
|
'@babel/core@<=7.29.0': '>=7.29.6'
|
||||||
|
brace-expansion@<1.1.13: '>=1.1.13'
|
||||||
|
brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'
|
||||||
|
handlebars@>=4.0.0 <4.7.9: '>=4.7.9'
|
||||||
|
handlebars@>=4.0.0 <=4.7.8: '>=4.7.9'
|
||||||
|
handlebars@>=4.6.0 <=4.7.8: '>=4.7.9'
|
||||||
|
js-yaml@<=4.1.1: '>=4.2.0'
|
||||||
next@>=10.0.0 <15.5.10: '>=15.5.10'
|
next@>=10.0.0 <15.5.10: '>=15.5.10'
|
||||||
next@>=15.5.1-canary.0 <15.5.10: '>=15.5.10'
|
next@>=15.5.1-canary.0 <15.5.10: '>=15.5.10'
|
||||||
|
next@>=16.0.0 <16.2.5: '>=16.2.5'
|
||||||
|
next@>=16.0.0 <16.2.6: '>=16.2.6'
|
||||||
|
next@>=16.0.0-beta.0 <16.1.7: '>=16.1.7'
|
||||||
|
next@>=16.0.0-beta.0 <16.2.3: '>=16.2.3'
|
||||||
|
next@>=16.0.1 <16.1.7: '>=16.1.7'
|
||||||
|
picomatch@<2.3.2: '>=2.3.2'
|
||||||
|
picomatch@>=4.0.0 <4.0.4: '>=4.0.4'
|
||||||
|
postcss@<8.5.10: '>=8.5.10'
|
||||||
tar@<7.5.7: '>=7.5.7'
|
tar@<7.5.7: '>=7.5.7'
|
||||||
tar@<7.5.8: '>=7.5.8'
|
tar@<7.5.8: '>=7.5.8'
|
||||||
|
tar@<=7.5.10: '>=7.5.11'
|
||||||
|
tar@<=7.5.15: '>=7.5.16'
|
||||||
tar@<=7.5.2: '>=7.5.3'
|
tar@<=7.5.2: '>=7.5.3'
|
||||||
tar@<=7.5.3: '>=7.5.4'
|
tar@<=7.5.3: '>=7.5.4'
|
||||||
tar@<=7.5.9: '>=7.5.10'
|
tar@<=7.5.9: '>=7.5.10'
|
||||||
|
ws@>=8.0.0 <8.20.1: '>=8.20.1'
|
||||||
|
ws@>=8.0.0 <8.21.0: '>=8.21.0'
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 275 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+47
-39
@@ -1,13 +1,13 @@
|
|||||||
{
|
{
|
||||||
"personal": {
|
"personal": {
|
||||||
"name": "Evandro Fonseca Junior",
|
"name": "Evandro Fonseca Junior",
|
||||||
"title": "Desenvolvedor Full-Stack",
|
"title": "Engenheiro da Computação e Desenvolvedor de Software",
|
||||||
"location": "Garuva, Santa Catarina, Brasil",
|
"location": "Garuva, Santa Catarina, Brasil",
|
||||||
"email": "[email protected]",
|
"email": "[email protected]",
|
||||||
"phone": "+55 47 99274-2365",
|
"phone": "Disponivel pelo botao de WhatsApp",
|
||||||
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||||
"github": "https://github.com/evandrodevbr",
|
"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": [
|
"experiences": [
|
||||||
{
|
{
|
||||||
@@ -121,11 +121,7 @@
|
|||||||
"status": "Em Produção",
|
"status": "Em Produção",
|
||||||
"demoUrl": "https://nutriscan.evandro.dev.br",
|
"demoUrl": "https://nutriscan.evandro.dev.br",
|
||||||
"githubUrl": "https://github.com/evandrodevbr/nutriscan",
|
"githubUrl": "https://github.com/evandrodevbr/nutriscan",
|
||||||
"badges": [
|
"badges": ["PWA", "Em Produção", "Open Source"],
|
||||||
"PWA",
|
|
||||||
"Em Produção",
|
|
||||||
"Open Source"
|
|
||||||
],
|
|
||||||
"featured": true,
|
"featured": true,
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"complexity": 5,
|
"complexity": 5,
|
||||||
@@ -160,12 +156,7 @@
|
|||||||
"status": "Em Produção",
|
"status": "Em Produção",
|
||||||
"demoUrl": "https://twins.evandro.dev.br",
|
"demoUrl": "https://twins.evandro.dev.br",
|
||||||
"githubUrl": "https://github.com/evandrodevbr/twins-lab",
|
"githubUrl": "https://github.com/evandrodevbr/twins-lab",
|
||||||
"badges": [
|
"badges": ["IoT", "Real-time", "AI/ML", "Em Produção"],
|
||||||
"IoT",
|
|
||||||
"Real-time",
|
|
||||||
"AI/ML",
|
|
||||||
"Em Produção"
|
|
||||||
],
|
|
||||||
"featured": true,
|
"featured": true,
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"complexity": 5,
|
"complexity": 5,
|
||||||
@@ -199,11 +190,7 @@
|
|||||||
"status": "Concluído",
|
"status": "Concluído",
|
||||||
"demoUrl": null,
|
"demoUrl": null,
|
||||||
"githubUrl": "https://github.com/evandrodevbr/reddit-search",
|
"githubUrl": "https://github.com/evandrodevbr/reddit-search",
|
||||||
"badges": [
|
"badges": ["API Integration", "MongoDB", "Real-time"],
|
||||||
"API Integration",
|
|
||||||
"MongoDB",
|
|
||||||
"Real-time"
|
|
||||||
],
|
|
||||||
"featured": true,
|
"featured": true,
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"complexity": 3,
|
"complexity": 3,
|
||||||
@@ -234,11 +221,7 @@
|
|||||||
"status": "Concluído",
|
"status": "Concluído",
|
||||||
"demoUrl": "https://scplus.evandro.dev.br",
|
"demoUrl": "https://scplus.evandro.dev.br",
|
||||||
"githubUrl": "https://github.com/evandrodevbr/scplus",
|
"githubUrl": "https://github.com/evandrodevbr/scplus",
|
||||||
"badges": [
|
"badges": ["Responsive", "SEO", "Modern Stack"],
|
||||||
"Responsive",
|
|
||||||
"SEO",
|
|
||||||
"Modern Stack"
|
|
||||||
],
|
|
||||||
"featured": true,
|
"featured": true,
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"complexity": 2,
|
"complexity": 2,
|
||||||
@@ -273,16 +256,47 @@
|
|||||||
"status": "Concluído",
|
"status": "Concluído",
|
||||||
"demoUrl": null,
|
"demoUrl": null,
|
||||||
"githubUrl": "https://github.com/evandrodevbr/gcc-installer",
|
"githubUrl": "https://github.com/evandrodevbr/gcc-installer",
|
||||||
"badges": [
|
"badges": ["Desktop", "Automation", "System Integration"],
|
||||||
"Desktop",
|
|
||||||
"Automation",
|
|
||||||
"System Integration"
|
|
||||||
],
|
|
||||||
"featured": true,
|
"featured": true,
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"complexity": 4,
|
"complexity": 4,
|
||||||
"teamSize": "Individual"
|
"teamSize": "Individual"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "as-servicos",
|
||||||
|
"title": "AS Serviços",
|
||||||
|
"subtitle": "Site Institucional com Cena 3D Procedural",
|
||||||
|
"description": "Site institucional da AS Serviços — engenharia civil, elétrica, eletrônica/automação e desenvolvimento de software — desenvolvido em Next.js 16 com cena 3D procedural em Three.js. A casa wireframe do hero se transforma em uma placa-mãe conforme a página é rolada, com toda a geometria gerada em runtime, sem assets externos.",
|
||||||
|
"longDescription": "O conceito visual traduz a premissa da empresa: a mesma engenharia aplicada ao mundo físico e ao digital. Câmera, partículas e paralaxe do mouse são funções puras do progresso de scroll (0–1), suavizadas por interpolação exponencial. Inclui portfólio com scroll 'pinado' em telas grandes, formulário de contato com Server Actions, detecção adaptativa de qualidade de dispositivo e TypeScript estrito ponta a ponta.",
|
||||||
|
"technologies": [
|
||||||
|
"Next.js 16",
|
||||||
|
"React 19",
|
||||||
|
"TypeScript",
|
||||||
|
"Three.js",
|
||||||
|
"Tailwind CSS 4",
|
||||||
|
"shadcn/ui",
|
||||||
|
"anime.js"
|
||||||
|
],
|
||||||
|
"features": [
|
||||||
|
"Cena 3D procedural (casa wireframe ↔ placa-mãe)",
|
||||||
|
"Animação dirigida por scroll",
|
||||||
|
"Sem assets 3D externos",
|
||||||
|
"Detecção adaptativa de qualidade",
|
||||||
|
"Portfólio com scroll pinado",
|
||||||
|
"Formulário com Server Actions"
|
||||||
|
],
|
||||||
|
"category": "web",
|
||||||
|
"level": "senior",
|
||||||
|
"status": "Em Produção",
|
||||||
|
"demoUrl": "https://asservicos.com",
|
||||||
|
"githubUrl": "https://github.com/evandrodevbr/as-servicos",
|
||||||
|
"badges": ["3D/WebGL", "Em Produção", "SEO"],
|
||||||
|
"featured": true,
|
||||||
|
"metrics": {
|
||||||
|
"complexity": 5,
|
||||||
|
"teamSize": "Individual"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"skills": {
|
"skills": {
|
||||||
@@ -321,13 +335,7 @@
|
|||||||
"icon": "GitBranch",
|
"icon": "GitBranch",
|
||||||
"color": "text-blue-400",
|
"color": "text-blue-400",
|
||||||
"bgColor": "bg-blue-500/20",
|
"bgColor": "bg-blue-500/20",
|
||||||
"skills": [
|
"skills": ["GitHub Actions", "Git", "Vercel", "Docker", "Cloudflare"]
|
||||||
"GitHub Actions",
|
|
||||||
"Git",
|
|
||||||
"Vercel",
|
|
||||||
"Docker",
|
|
||||||
"Cloudflare"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "Foco",
|
"category": "Foco",
|
||||||
@@ -367,8 +375,8 @@
|
|||||||
},
|
},
|
||||||
"contact": {
|
"contact": {
|
||||||
"email": "[email protected]",
|
"email": "[email protected]",
|
||||||
"phone": "+55 47 99274-2365",
|
"phone": "Disponivel pelo botao de WhatsApp",
|
||||||
"whatsapp": "+55 47 99274-2365",
|
"whatsapp": "Disponivel pelo botao de WhatsApp",
|
||||||
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||||
"github": "https://github.com/evandrodevbr",
|
"github": "https://github.com/evandrodevbr",
|
||||||
"location": "Garuva, Santa Catarina, Brasil"
|
"location": "Garuva, Santa Catarina, Brasil"
|
||||||
@@ -390,4 +398,4 @@
|
|||||||
"icon": "mail"
|
"icon": "mail"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
@@ -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>
|
|
||||||
@@ -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.`);
|
||||||
Reference in new issue
Block a user