feat: add randomized portfolio hero variants
This commit is contained in:
1 parent
812499b318
commit
9236fdd685
25 files changed
+1438
-658
No files matched your search
+13
@@ -39,3 +39,16 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# local agent/editor state
|
||||
/.augment/
|
||||
.opencode/
|
||||
.claude/settings.local.json
|
||||
.superpowers/
|
||||
|
||||
# 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
|
||||
bun dev
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## Engineering standards
|
||||
|
||||
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.
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import profilePhoto from "@/public/edev.jpeg";
|
||||
import profilePhoto from "@/public/edev.jpg";
|
||||
import { Section } from "./Section";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||
@@ -19,6 +19,12 @@ const STORY_DATA = [
|
||||
content:
|
||||
"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 = [
|
||||
@@ -91,7 +97,7 @@ export default function About() {
|
||||
}
|
||||
>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
className="ds-grid-2 items-start"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
@@ -128,8 +134,8 @@ export default function About() {
|
||||
<LazyRevealImage
|
||||
src={profilePhoto}
|
||||
alt="Retrato de Evandro"
|
||||
className="mb-sp-6 h-[220px] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||
imageClassName="object-cover"
|
||||
className="mb-sp-6 aspect-square w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||
imageClassName="object-contain"
|
||||
/>
|
||||
<div className="ds-kicker">Technical profile</div>
|
||||
<DrawLine delay={0.05} />
|
||||
@@ -158,5 +164,3 @@ export default function About() {
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { Section } from "./Section";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
|
||||
const WHATSAPP_NUMBER = "5547992742365";
|
||||
|
||||
export default function Contact() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
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)]";
|
||||
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 encodedMessage = encodeURIComponent(messageTemplate);
|
||||
const whatsappUrl = `https://wa.me/${WHATSAPP_NUMBER}?text=${encodedMessage}`;
|
||||
const whatsappUrl = buildWhatsAppUrl(messageTemplate);
|
||||
|
||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
@@ -93,7 +98,7 @@ export default function Contact() {
|
||||
projeto, performance ou construcao de produto com ownership real.
|
||||
</p>
|
||||
<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>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface ContactFormProps {
|
||||
@@ -173,15 +174,12 @@ ${formData.userName}`;
|
||||
const handleEmailFocus = () => setEmailFocused(true);
|
||||
const handlePhoneFocus = () => setPhoneFocused(true);
|
||||
|
||||
const generateWhatsAppUrl = () => {
|
||||
const message = encodeURIComponent(
|
||||
const generateWhatsAppUrl = () =>
|
||||
buildWhatsAppUrl(
|
||||
formType === "advanced"
|
||||
? generateAdvancedMessage()
|
||||
: 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 subject = encodeURIComponent("Proposta de Projeto - Portfólio");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Mail, MessageCircle } from "lucide-react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
|
||||
interface ContactInfoProps {
|
||||
@@ -11,6 +13,11 @@ interface ContactInfoProps {
|
||||
|
||||
export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
||||
const { data } = usePortfolioData();
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
useEffect(() => {
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
if (!data) {
|
||||
return null; // Ou um componente de carregamento/erro
|
||||
@@ -58,7 +65,7 @@ export function ContactInfo({ onWhatsAppClick }: ContactInfoProps) {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</MagneticButton>
|
||||
|
||||
|
||||
@@ -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 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", () => {
|
||||
const React = require("react");
|
||||
@@ -16,15 +22,15 @@ jest.mock("framer-motion", () => {
|
||||
children,
|
||||
initial,
|
||||
animate,
|
||||
whileHover,
|
||||
whileTap,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
variants: _variants,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
} & Record<string, unknown>) => {
|
||||
const dataProps = {
|
||||
...(initial !== undefined
|
||||
@@ -33,12 +39,6 @@ jest.mock("framer-motion", () => {
|
||||
...(animate !== undefined
|
||||
? { "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);
|
||||
@@ -53,49 +53,67 @@ jest.mock("framer-motion", () => {
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
||||
motion,
|
||||
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", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(true);
|
||||
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||
});
|
||||
|
||||
it("removes hover/tap scale animations when reduced motion is enabled", () => {
|
||||
mockReducedMotion = true;
|
||||
afterEach(() => {
|
||||
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(/Command center online/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
level: 1,
|
||||
name: /coloque seu sistema no ar/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /botar meu sistema no ar/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps motion blocks declarative instead of requiring 3D", async () => {
|
||||
const { container } = render(<Hero />);
|
||||
|
||||
const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a");
|
||||
const secondaryAction = screen.getByText(/Ver projetos/i).closest("a");
|
||||
|
||||
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");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
const rootMotionBlock = container.querySelector(
|
||||
"div[data-initial][data-animate]",
|
||||
);
|
||||
expect(rootMotionBlock).toBeInTheDocument();
|
||||
expect(rootMotionBlock?.getAttribute("data-initial")).toContain(
|
||||
'"opacity":0',
|
||||
);
|
||||
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();
|
||||
expect(rootMotionBlock?.getAttribute("data-initial")).toContain("hidden");
|
||||
expect(rootMotionBlock?.getAttribute("data-animate")).toContain("visible");
|
||||
});
|
||||
});
|
||||
@@ -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 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", () => {
|
||||
const React = require("react");
|
||||
|
||||
@@ -12,6 +20,12 @@ jest.mock("framer-motion", () => {
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
initial: _initial,
|
||||
animate: _animate,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
variants: _variants,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
@@ -29,34 +43,84 @@ 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", () => {
|
||||
it("renders the upgraded hero content and primary actions", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
||||
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();
|
||||
beforeEach(() => {
|
||||
mockMatchMedia(false);
|
||||
jest.spyOn(Math, "random").mockReturnValue(0);
|
||||
jest.spyOn(window, "open").mockImplementation(() => null);
|
||||
});
|
||||
|
||||
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: /coloque seu sistema no ar/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: /botar meu sistema no ar/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /ver casos selecionados/i }),
|
||||
).toHaveAttribute("href", "/cases");
|
||||
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("hero-scene")).toHaveAttribute(
|
||||
"data-variant",
|
||||
"command",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps social links, proof content and WhatsApp CTA", async () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Command center online/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(/Especialidade/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Escopo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Formato/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Scroll/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Operacao/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clareza/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /botar meu sistema no ar/i }),
|
||||
);
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
expect.stringContaining("https://wa.me/"),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
});
|
||||
});
|
||||
+462
-93
@@ -1,13 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import dynamic from "next/dynamic";
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
|
||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||
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 = [
|
||||
{
|
||||
href: "https://github.com/evandrodevbr",
|
||||
@@ -36,50 +65,402 @@ const SOCIAL_LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
const HERO_POINTS = [
|
||||
"Produtos conectados, integracoes complexas e operacoes criticas.",
|
||||
"Arquitetura mais clara para evoluir sem aumentar o caos tecnico.",
|
||||
"Execucao ponta a ponta, do discovery a entrega em producao.",
|
||||
const HERO_VARIANTS: HeroVariant[] = [
|
||||
{
|
||||
id: "command-center",
|
||||
weight: 22,
|
||||
scene: "command",
|
||||
layout: "default",
|
||||
status: "Command center online",
|
||||
chip: "Sistemas em producao, sem improviso",
|
||||
eyebrow:
|
||||
"Evandro Fonseca Junior | Engenharia para produtos que precisam operar",
|
||||
headline: "Coloque seu sistema no ar com engenharia que nao improvisa.",
|
||||
subtitle:
|
||||
"Se voce tem um projeto travado, um MVP para lancar ou um sistema critico precisando operar melhor, agende uma conversa tecnica e saia com o proximo passo claro para producao.",
|
||||
cta: "Botar meu sistema no ar",
|
||||
whatsappMessage: "Ola! Quero colocar meu sistema no ar. Podemos conversar?",
|
||||
sideLabel: "Conversa tecnica pelo WhatsApp",
|
||||
sideTitle: "Seu projeto precisa virar sistema em producao?",
|
||||
sideText:
|
||||
"Me conte objetivo, prazo e contexto. Eu respondo com uma conversa direta para destravar arquitetura, entrega e deploy.",
|
||||
points: [
|
||||
"Conversa tecnica para tirar o projeto do papel sem improviso.",
|
||||
"Arquitetura, codigo e deploy conectados ao resultado do negocio.",
|
||||
"Plano claro para colocar o sistema no ar com seguranca.",
|
||||
],
|
||||
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 = [
|
||||
{
|
||||
label: "Quem eu ajudo",
|
||||
value:
|
||||
"Empresas que dependem de integracoes, hardware ou fluxos criticos para operar com seguranca e previsibilidade.",
|
||||
},
|
||||
{
|
||||
label: "Como entrego",
|
||||
value:
|
||||
"Traduzo complexidade tecnica em backlog claro, arquitetura legivel e decisao mais rapida para o time.",
|
||||
},
|
||||
{
|
||||
label: "Por que conversar",
|
||||
value:
|
||||
"Voce ganha um parceiro tecnico capaz de conectar produto, backend, interface e operacao sem perder contexto.",
|
||||
},
|
||||
];
|
||||
const LAYOUT_CLASSES: Record<HeroVariant["layout"], string> = {
|
||||
default:
|
||||
"grid items-start gap-sp-8 lg:grid-cols-[minmax(0,1.1fr)_minmax(340px,0.9fr)] lg:gap-sp-10",
|
||||
"visual-first":
|
||||
"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:
|
||||
"grid items-start gap-sp-8 lg:grid-cols-[minmax(360px,0.85fr)_minmax(0,1.15fr)] lg:gap-sp-10",
|
||||
portrait:
|
||||
"grid items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
|
||||
};
|
||||
|
||||
function pickWeightedHeroVariant() {
|
||||
const total = HERO_VARIANTS.reduce((sum, variant) => sum + variant.weight, 0);
|
||||
let ticket = Math.random() * total;
|
||||
|
||||
for (const variant of HERO_VARIANTS) {
|
||||
ticket -= variant.weight;
|
||||
if (ticket <= 0) return variant;
|
||||
}
|
||||
|
||||
return HERO_VARIANTS[0];
|
||||
}
|
||||
|
||||
function getGreeting(message: string) {
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 8 && hour < 12) return `Bom dia! ${message}`;
|
||||
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() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [variant, setVariant] = useState<HeroVariant>(HERO_VARIANTS[0]);
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const greeting =
|
||||
hour >= 8 && hour < 12
|
||||
? "Ola! Vi seu portfolio. Podemos conversar?"
|
||||
: hour >= 13 && hour < 18
|
||||
? "Boa tarde! Vi seu portfolio. Podemos conversar?"
|
||||
: "Ola! Vi seu portfolio. Podemos conversar quando estiver disponivel?";
|
||||
useEffect(() => {
|
||||
setVariant(pickWeightedHeroVariant());
|
||||
setShouldReduceMotion(
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
);
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
}, []);
|
||||
|
||||
const handleWhatsAppClick = () => {
|
||||
window.open(
|
||||
buildWhatsAppUrl(getGreeting(variant.whatsappMessage)),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
};
|
||||
|
||||
// Cinematic stagger variants
|
||||
const heroContainer: Variants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.3,
|
||||
},
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.3 },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -93,10 +474,7 @@ export default function Hero() {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 1,
|
||||
ease: easeOut,
|
||||
},
|
||||
transition: { duration: 1, ease: easeOut },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -108,26 +486,29 @@ export default function Hero() {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: {
|
||||
duration: 1.2,
|
||||
ease: easeOut,
|
||||
},
|
||||
transition: { 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 (
|
||||
<section
|
||||
id="home"
|
||||
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 */}
|
||||
{!shouldReduceMotion && <HeroScene />}
|
||||
{!shouldReduceMotion && <HeroScene variant={variant.scene} />}
|
||||
|
||||
{/* 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-gradient-to-b from-transparent via-transparent to-[var(--ink)]" />
|
||||
|
||||
{/* Scanlines effect */}
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-[2] opacity-[0.015]"
|
||||
style={{
|
||||
@@ -135,8 +516,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)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 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="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 +523,9 @@ export default function Hero() {
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
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]">
|
||||
{/* Status badge with glow */}
|
||||
<div className={`max-w-[800px] ${contentOrder}`.trim()}>
|
||||
<motion.div
|
||||
className="mb-sp-5 flex flex-wrap items-center gap-sp-3"
|
||||
variants={heroItem}
|
||||
@@ -178,48 +556,42 @@ export default function Hero() {
|
||||
}
|
||||
/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.82)]">
|
||||
Available for selected builds
|
||||
{variant.status}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</motion.div>
|
||||
|
||||
{/* Eyebrow */}
|
||||
<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}
|
||||
>
|
||||
Evandro Fonseca Junior | Engenheiro de software full-stack
|
||||
{variant.eyebrow}
|
||||
</motion.p>
|
||||
|
||||
{/* Main heading with cinematic reveal */}
|
||||
<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}
|
||||
>
|
||||
<span className="cinematic-text-gradient">
|
||||
Engenharia de software para sistemas que nao podem falhar.
|
||||
{variant.headline}
|
||||
</span>
|
||||
</motion.h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<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}
|
||||
>
|
||||
Ajudo empresas com produtos conectados, integracoes complexas e
|
||||
operacoes criticas a transformar arquitetura confusa em software
|
||||
estavel, mais rapido de evoluir e muito mais facil de operar.
|
||||
{variant.subtitle}
|
||||
</motion.p>
|
||||
|
||||
{/* Key points with staggered reveal */}
|
||||
<motion.ul
|
||||
className="mt-sp-8 grid gap-sp-3 sm:grid-cols-3"
|
||||
variants={heroItem}
|
||||
>
|
||||
{HERO_POINTS.map((point, i) => (
|
||||
{variant.points.map((point, i) => (
|
||||
<motion.li
|
||||
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)]"
|
||||
@@ -241,19 +613,19 @@ export default function Hero() {
|
||||
))}
|
||||
</motion.ul>
|
||||
|
||||
{/* CTAs with magnetic effect */}
|
||||
<motion.div
|
||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||
variants={heroItem}
|
||||
>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
|
||||
as="button"
|
||||
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"
|
||||
data-cursor="View"
|
||||
strength={0.25}
|
||||
>
|
||||
<span className="relative z-10">Agendar conversa tecnica</span>
|
||||
<span className="relative z-10">{variant.cta}</span>
|
||||
</MagneticButton>
|
||||
|
||||
<MagneticButton
|
||||
@@ -277,7 +649,6 @@ export default function Hero() {
|
||||
</MagneticButton>
|
||||
</motion.div>
|
||||
|
||||
{/* Footer info with reveal */}
|
||||
<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"
|
||||
variants={heroItem}
|
||||
@@ -296,6 +667,7 @@ export default function Hero() {
|
||||
key={link.label}
|
||||
as="a"
|
||||
href={link.href}
|
||||
aria-label={link.label}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
@@ -322,9 +694,8 @@ export default function Hero() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Aside card with cinematic entrance */}
|
||||
<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={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
@@ -335,13 +706,8 @@ export default function Hero() {
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, x: 0, rotateY: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
delay: 0.5,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
transition={{ duration: 1.2, delay: 0.5, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
{/* Top glow line */}
|
||||
<motion.div
|
||||
className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.8),transparent)]"
|
||||
initial={{ scaleX: 0 }}
|
||||
@@ -349,34 +715,38 @@ export default function Hero() {
|
||||
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="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
Why this positioning works
|
||||
{variant.sideLabel}
|
||||
</div>
|
||||
<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>
|
||||
<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,
|
||||
ritmo de entrega e confiabilidade para o sistema e para o time.
|
||||
{variant.sideText}
|
||||
</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 className="mt-sp-6 space-y-sp-5">
|
||||
{PROOF_ITEMS.map((item, index) => (
|
||||
{variant.proof.map((item, index) => (
|
||||
<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"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, filter: "blur(6px)" }
|
||||
}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
}}
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.8 + index * 0.12,
|
||||
@@ -396,7 +766,6 @@ export default function Hero() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<motion.div
|
||||
className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { motion, useMotionValue, useSpring } from "framer-motion";
|
||||
import { motion, useMotionValue } from "framer-motion";
|
||||
|
||||
export default function CustomCursor() {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
@@ -10,13 +10,6 @@ export default function CustomCursor() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const cursorX = 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);
|
||||
|
||||
@@ -29,7 +22,7 @@ export default function CustomCursor() {
|
||||
if (!isVisible) setIsVisible(true);
|
||||
});
|
||||
},
|
||||
[cursorX, cursorY, isVisible]
|
||||
[cursorX, cursorY, isVisible],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -57,7 +50,7 @@ export default function CustomCursor() {
|
||||
|
||||
function attachHoverListeners() {
|
||||
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) {
|
||||
el.addEventListener("mouseenter", handleElementEnter as EventListener);
|
||||
@@ -89,7 +82,10 @@ export default function CustomCursor() {
|
||||
}, [handleMouseMove]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -105,8 +101,8 @@ export default function CustomCursor() {
|
||||
<motion.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[9999] mix-blend-difference"
|
||||
style={{
|
||||
x,
|
||||
y,
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
}}
|
||||
@@ -126,8 +122,8 @@ export default function CustomCursor() {
|
||||
<motion.div
|
||||
className="pointer-events-none fixed top-0 left-0 z-[9998] mix-blend-difference"
|
||||
style={{
|
||||
x: ringX,
|
||||
y: ringY,
|
||||
x: cursorX,
|
||||
y: cursorY,
|
||||
translateX: "-50%",
|
||||
translateY: "-50%",
|
||||
}}
|
||||
@@ -141,7 +137,11 @@ export default function CustomCursor() {
|
||||
>
|
||||
<div
|
||||
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 && (
|
||||
<motion.span
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
"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 { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
type HeroSceneVariant =
|
||||
| "command"
|
||||
| "launch"
|
||||
| "rescue"
|
||||
| "signal"
|
||||
| "diagnostic"
|
||||
| "portrait";
|
||||
|
||||
function ParticleField({ count = 2000 }: { count?: number }) {
|
||||
const mesh = useRef<THREE.Points>(null);
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
@@ -32,10 +40,13 @@ function ParticleField({ count = 2000 }: { count?: number }) {
|
||||
return s;
|
||||
}, [count]);
|
||||
|
||||
const handlePointerMove = useCallback((e: { clientX: number; clientY: number }) => {
|
||||
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
}, []);
|
||||
const handlePointerMove = useCallback(
|
||||
(e: { clientX: number; clientY: number }) => {
|
||||
mouseRef.current.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
mouseRef.current.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useFrame((state) => {
|
||||
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 mesh2Ref = 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) => {
|
||||
const t = state.clock.elapsedTime;
|
||||
if (meshRef.current) {
|
||||
meshRef.current.rotation.x = t * 0.15;
|
||||
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) {
|
||||
mesh2Ref.current.rotation.x = t * 0.1;
|
||||
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) {
|
||||
mesh3Ref.current.rotation.y = t * 0.08;
|
||||
@@ -133,13 +183,19 @@ function FloatingGeometry() {
|
||||
return (
|
||||
<>
|
||||
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
|
||||
<mesh ref={meshRef} position={[4, 1.5, -3]} scale={1.2}>
|
||||
<icosahedronGeometry args={[1, 1]} />
|
||||
<mesh ref={meshRef} position={layout.a} scale={layout.scale[0]}>
|
||||
{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
|
||||
color="#ccff00"
|
||||
wireframe
|
||||
transparent
|
||||
opacity={0.15}
|
||||
opacity={variant === "portrait" ? 0.1 : 0.15}
|
||||
distort={0.3}
|
||||
speed={2}
|
||||
/>
|
||||
@@ -147,8 +203,14 @@ function FloatingGeometry() {
|
||||
</Float>
|
||||
|
||||
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||
<mesh ref={mesh2Ref} position={[-5, -2, -4]} scale={0.8}>
|
||||
<octahedronGeometry args={[1, 0]} />
|
||||
<mesh ref={mesh2Ref} position={layout.b} scale={layout.scale[1]}>
|
||||
{variant === "launch" ? (
|
||||
<coneGeometry args={[0.8, 1.6, 4]} />
|
||||
) : variant === "signal" ? (
|
||||
<sphereGeometry args={[0.55, 16, 16]} />
|
||||
) : (
|
||||
<octahedronGeometry args={[1, 0]} />
|
||||
)}
|
||||
<MeshDistortMaterial
|
||||
color="#ffffff"
|
||||
wireframe
|
||||
@@ -161,8 +223,14 @@ function FloatingGeometry() {
|
||||
</Float>
|
||||
|
||||
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
|
||||
<mesh ref={mesh3Ref} position={[2, -3, -5]} scale={1.5}>
|
||||
<torusGeometry args={[1, 0.3, 16, 32]} />
|
||||
<mesh ref={mesh3Ref} position={layout.c} scale={layout.scale[2]}>
|
||||
{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
|
||||
color="#ccff00"
|
||||
wireframe
|
||||
@@ -226,7 +294,7 @@ function GridFloor() {
|
||||
}
|
||||
`,
|
||||
}),
|
||||
[]
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -266,7 +334,11 @@ function CameraRig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function HeroScene() {
|
||||
export default function HeroScene({
|
||||
variant = "command",
|
||||
}: {
|
||||
variant?: HeroSceneVariant;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
@@ -292,12 +364,16 @@ export default function HeroScene() {
|
||||
>
|
||||
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
||||
<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" />
|
||||
|
||||
<CameraRig />
|
||||
<ParticleField count={1500} />
|
||||
<FloatingGeometry />
|
||||
<FloatingGeometry variant={variant} />
|
||||
<GridFloor />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
+23
-1
@@ -62,17 +62,39 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
overflow-x: hidden;
|
||||
overscroll-behavior-x: none;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
position: relative;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.68;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
canvas,
|
||||
svg {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@supports (overflow: clip) {
|
||||
html,
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const runtime = "edge";
|
||||
export const alt = "Evandro Fonseca Junior - Engenheiro de Software";
|
||||
export const size = {
|
||||
width: 1200,
|
||||
|
||||
@@ -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,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.
|
||||
+5
-9
@@ -61,8 +61,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
||||
ctaLabel: "Agendar conversa sobre engenharia",
|
||||
ctaHref:
|
||||
"https://wa.me/5547992742365?text=Quero%20conversar%20sobre%20engenharia%20de%20software.",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
|
||||
},
|
||||
{
|
||||
@@ -96,7 +95,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO tecnico"],
|
||||
ctaLabel: "Solicitar consultoria Next.js",
|
||||
ctaHref: "https://wa.me/5547992742365?text=Quero%20consultoria%20em%20Next.js.",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
|
||||
},
|
||||
{
|
||||
@@ -136,8 +135,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
"System integration",
|
||||
],
|
||||
ctaLabel: "Conversar sobre Tauri e Rust",
|
||||
ctaHref:
|
||||
"https://wa.me/5547992742365?text=Quero%20conversar%20sobre%20Tauri%20e%20Rust.",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["gcc-installer"],
|
||||
},
|
||||
{
|
||||
@@ -170,8 +168,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
],
|
||||
stack: ["IoT", "WebSockets", "APIs", "Dashboards", "Integracao de sistemas"],
|
||||
ctaLabel: "Falar sobre integracao e IoT",
|
||||
ctaHref:
|
||||
"https://wa.me/5547992742365?text=Quero%20falar%20sobre%20integracao%20hardware%20%2B%20software%20e%20IoT.",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["twins-lab"],
|
||||
},
|
||||
{
|
||||
@@ -211,8 +208,7 @@ export const servicePages: ServicePageContent[] = [
|
||||
"Sistemas criticos",
|
||||
],
|
||||
ctaLabel: "Solicitar diagnostico arquitetural",
|
||||
ctaHref:
|
||||
"https://wa.me/5547992742365?text=Quero%20um%20diagnostico%20de%20arquitetura%20de%20software.",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
-12
@@ -9,11 +9,11 @@ const nextConfig: NextConfig = {
|
||||
minimumCacheTTL: 60,
|
||||
},
|
||||
|
||||
// Otimizações de performance
|
||||
experimental: {
|
||||
scrollRestoration: true,
|
||||
turbopack: {
|
||||
root: process.cwd(),
|
||||
},
|
||||
|
||||
|
||||
// Headers de cache e segurança
|
||||
async headers(): Promise<
|
||||
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/(.*)",
|
||||
headers: [
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@
|
||||
"@gsap/react": "^2.1.2",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
"@react-three/fiber": "^9.5.0",
|
||||
"@types/three": "^0.183.1",
|
||||
"@types/three": "0.182.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^0.6.4",
|
||||
@@ -30,7 +30,7 @@
|
||||
"react-dom": "19.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"three": "^0.183.2"
|
||||
"three": "0.182.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.0",
|
||||
|
||||
Generated
+402
-396
File diff suppressed because it is too large.
Load diff
@@ -1,15 +1,39 @@
|
||||
packages:
|
||||
- .
|
||||
|
||||
allowBuilds:
|
||||
'@tailwindcss/oxide': true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@tailwindcss/oxide'
|
||||
- sharp
|
||||
|
||||
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@>=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.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.3: '>=7.5.4'
|
||||
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 |
@@ -4,7 +4,7 @@
|
||||
"title": "Desenvolvedor Full-Stack",
|
||||
"location": "Garuva, Santa Catarina, Brasil",
|
||||
"email": "[email protected]",
|
||||
"phone": "+55 47 99274-2365",
|
||||
"phone": "Disponivel pelo botao de WhatsApp",
|
||||
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||
"github": "https://github.com/evandrodevbr",
|
||||
"summary": "Desenvolvedor Full-Stack com 3+ anos de experiência, especializado em soluções web modernas e inovadoras. Formado em Engenharia da Computação, com conhecimentos sólidos em computação em nuvem e inteligência artificial. Busco constantemente novos desafios onde possa aplicar minha paixão por tecnologia e resolver problemas de forma criativa."
|
||||
@@ -367,8 +367,8 @@
|
||||
},
|
||||
"contact": {
|
||||
"email": "[email protected]",
|
||||
"phone": "+55 47 99274-2365",
|
||||
"whatsapp": "+55 47 99274-2365",
|
||||
"phone": "Disponivel pelo botao de WhatsApp",
|
||||
"whatsapp": "Disponivel pelo botao de WhatsApp",
|
||||
"linkedin": "https://www.linkedin.com/in/evandro-fonseca-junior-0969aa1b0/",
|
||||
"github": "https://github.com/evandrodevbr",
|
||||
"location": "Garuva, Santa Catarina, Brasil"
|
||||
|
||||
Reference in new issue
Block a user