feat: control room hero, SEO/AEO optimization and design system fixes
Hero: replace particle field with cobe globe (Capricorn view, multi-axis drag, reduced-motion safe), telemetry HUD with pointer parallax, particle text build effect, Space Grotesk display font. SEO: short titles with template, PT-BR accents, twitter cards on dynamic cases, escaped JSON-LD via lib/jsonld.ts, CSP report-only, 25 service FAQs answer-first. Design: fix TW4 layered reset killing spacing utilities, literal @theme tokens, mobile hero overflow, header overlap, empty case cards. Remove framer-motion (unified on motion/react), header version badge, phone number from hero CTA. Production build verified (24 static pages).
This commit is contained in:
1 parent
0920370f30
commit
b791b7de9e
94 files changed
+2621
-2889
No files matched your search
@@ -53,3 +53,6 @@ next-env.d.ts
|
||||
*.analysis.json
|
||||
*.tmp.json
|
||||
*.debug.json
|
||||
|
||||
.omo/
|
||||
.playwright-mcp/
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
||||
|
||||
interface CasePageProps {
|
||||
@@ -17,11 +18,11 @@ export async function generateMetadata({
|
||||
const { slug } = await params;
|
||||
const caseStudy = caseStudies.find((item) => item.id === slug);
|
||||
|
||||
if (!caseStudy) return { title: `Case não encontrado | ${siteName}` };
|
||||
if (!caseStudy) return { title: "Case não encontrado" };
|
||||
|
||||
const url = `${siteUrl}/cases/${caseStudy.id}`;
|
||||
return {
|
||||
title: `${caseStudy.title}: ${caseStudy.subtitle} | ${siteName}`,
|
||||
title: `${caseStudy.title} | Case de Engenharia de Software`,
|
||||
description: caseStudy.summary,
|
||||
alternates: { canonical: url },
|
||||
openGraph: {
|
||||
@@ -31,6 +32,11 @@ export async function generateMetadata({
|
||||
siteName,
|
||||
type: "article",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: caseStudy.title,
|
||||
description: caseStudy.summary,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,7 +60,7 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
@@ -74,10 +80,7 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<article>
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
@@ -117,7 +120,9 @@ export default async function CasePage({ params }: CasePageProps) {
|
||||
</section>
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<h2 className="ds-section-title">Tecnologias e contexto técnico</h2>
|
||||
<h2 className="ds-section-title !leading-snug">
|
||||
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
|
||||
|
||||
+14
-16
@@ -2,19 +2,20 @@ import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { caseStudies, siteName, siteUrl } from "@/lib/site-content";
|
||||
|
||||
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",
|
||||
description:
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos. Portfolio tecnico com foco em clareza, performance e execucao.",
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos. Portfolio técnico com foco em clareza, performance e execução.",
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/cases`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `Cases de Engenharia de Software, Next.js e IoT | ${siteName}`,
|
||||
title: "Cases de Engenharia de Software, Next.js e IoT",
|
||||
description:
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integracoes e software para contextos criticos.",
|
||||
"Cases selecionados em engenharia de software, Next.js, IoT, integrações e software para contextos críticos.",
|
||||
url: `${siteUrl}/cases`,
|
||||
siteName,
|
||||
type: "website",
|
||||
@@ -31,7 +32,7 @@ export default function CasesPage() {
|
||||
name: "Cases de engenharia de software",
|
||||
url: `${siteUrl}/cases`,
|
||||
description:
|
||||
"Colecao de cases em Next.js, IoT, integracoes e software para operacoes criticas.",
|
||||
"Coleção de cases em Next.js, IoT, integrações e software para operações críticas.",
|
||||
},
|
||||
{
|
||||
"@type": "ItemList",
|
||||
@@ -45,7 +46,7 @@ export default function CasesPage() {
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
@@ -59,10 +60,7 @@ export default function CasesPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
|
||||
<HeroBackground />
|
||||
@@ -70,11 +68,11 @@ export default function CasesPage() {
|
||||
<div className="max-w-[860px]">
|
||||
<p className="ds-eyebrow">Selected work</p>
|
||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
||||
Cases em software, Next.js, integracoes e produtos conectados.
|
||||
Cases em software, Next.js, integrações e produtos conectados.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
Esta pagina organiza projetos com leitura mais indexavel e
|
||||
comercial, conectando stack, contexto tecnico e tipo de problema
|
||||
Esta página organiza projetos com leitura mais indexável e
|
||||
comercial, conectando stack, contexto técnico e tipo de problema
|
||||
resolvido.
|
||||
</p>
|
||||
</div>
|
||||
@@ -83,9 +81,9 @@ export default function CasesPage() {
|
||||
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-grid-2">
|
||||
<div className="ds-grid-2 items-start">
|
||||
{caseStudies.map((caseStudy) => (
|
||||
<article key={caseStudy.id} className="ds-card">
|
||||
<article key={caseStudy.id} className="ds-card !h-auto">
|
||||
<div className="ds-kicker">{caseStudy.title}</div>
|
||||
<h2 className="ds-card-title">{caseStudy.subtitle}</h2>
|
||||
<p className="ds-copy">{caseStudy.summary}</p>
|
||||
@@ -130,7 +128,7 @@ export default function CasesPage() {
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Ver servicos
|
||||
Ver serviços
|
||||
</MagneticButton>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
|
||||
+17
-17
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import profilePhoto from "@/public/edev.jpg";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
@@ -10,39 +10,39 @@ import { Section } from "./Section";
|
||||
const STORY_DATA = [
|
||||
{
|
||||
label: "Hardware + software",
|
||||
title: "Do sinal fisico ao produto digital",
|
||||
title: "Do sinal físico ao produto digital",
|
||||
content:
|
||||
"Construo a ponte entre dispositivos, protocolos de baixo nivel, backend e interface para que o sistema inteiro pareca uma unica experiencia coerente.",
|
||||
"Construo a ponte entre dispositivos, protocolos de baixo nível, backend e interface para que o sistema inteiro pareça uma única experiência coerente.",
|
||||
},
|
||||
{
|
||||
label: "Execution",
|
||||
title: "Entrega clara mesmo sob restricao",
|
||||
title: "Entrega clara mesmo sob restrição",
|
||||
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, integrações complexas e pouca margem para erro, priorizando estabilidade, leitura técnica e evolução sustentável.",
|
||||
},
|
||||
{
|
||||
label: "Production mindset",
|
||||
title: "Do codigo ao sistema no ar",
|
||||
title: "Do código ao sistema no ar",
|
||||
content:
|
||||
"Penso alem da feature: deploy, observabilidade, manutencao e riscos de operacao entram na decisao tecnica desde o primeiro corte.",
|
||||
"Penso além da feature: deploy, observabilidade, manutenção e riscos de operação entram na decisão técnica desde o primeiro corte.",
|
||||
},
|
||||
];
|
||||
|
||||
const PROFILE_POINTS = [
|
||||
{
|
||||
title: "Arquitetura pragmatica",
|
||||
title: "Arquitetura pragmática",
|
||||
description:
|
||||
"Escolhas guiadas por contexto, operabilidade e impacto real no produto.",
|
||||
},
|
||||
{
|
||||
title: "Integracao profunda",
|
||||
title: "Integração profunda",
|
||||
description:
|
||||
"Capacidade de atravessar camada fisica, servicos e interface sem perder consistencia.",
|
||||
"Capacidade de atravessar camada física, serviços e interface sem perder consistência.",
|
||||
},
|
||||
{
|
||||
title: "Ownership ponta a ponta",
|
||||
description:
|
||||
"Responsabilidade que cobre discovery, implementacao, qualidade e operacao.",
|
||||
"Responsabilidade que cobre discovery, implementação, qualidade e operação.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -87,8 +87,8 @@ export default function About() {
|
||||
<Section
|
||||
id="about"
|
||||
eyebrow="Operating context"
|
||||
title="Engenharia de software, arquitetura e integracoes complexas"
|
||||
description="Atuo na intersecao entre arquitetura de software, integracoes de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
|
||||
title="Engenharia de software, arquitetura e integrações complexas"
|
||||
description="Atuo na interseção entre arquitetura de software, integrações de sistemas, hardware + software e entrega full-stack para produtos que precisam funcionar com confiabilidade."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric">
|
||||
@@ -140,12 +140,12 @@ export default function About() {
|
||||
/>
|
||||
<div className="ds-kicker">Technical profile</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
|
||||
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
|
||||
<p className="ds-copy">
|
||||
Sou engenheiro da computação e desenvolvedor de software full-stack,
|
||||
atuando ha 5 anos com foco em integrar camadas que normalmente
|
||||
ficam separadas: hardware, servicos, operacao e interface. Meu
|
||||
trabalho e dar forma clara ao que costuma nascer fragmentado.
|
||||
atuando ha 5 anos com foco em integrar camadas que normalmente ficam
|
||||
separadas: hardware, servicos, operacao e interface. Meu trabalho e
|
||||
dar forma clara ao que costuma nascer fragmentado.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4">
|
||||
{PROFILE_POINTS.map((point) => (
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
import fc from "fast-check";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock framer-motion with configurable useReducedMotion
|
||||
// Mock motion/react with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
jest.mock("motion/react", () => {
|
||||
const actual = jest.requireActual("motion/react");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
import fc from "fast-check";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock framer-motion with configurable useReducedMotion
|
||||
// Mock motion/react with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
jest.mock("motion/react", () => {
|
||||
const actual = jest.requireActual("motion/react");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock framer-motion with configurable useReducedMotion
|
||||
// Mock motion/react with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
jest.mock("motion/react", () => {
|
||||
const actual = jest.requireActual("motion/react");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
|
||||
*/
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import Image from "next/image";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
@@ -97,6 +97,8 @@ export default function CompanyLogos() {
|
||||
<div
|
||||
key={`${logo.name}-${index}`}
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
// Second half is a visual duplicate for seamless looping; hide from AT
|
||||
aria-hidden={index >= companies.length ? true : undefined}
|
||||
>
|
||||
<Image
|
||||
src={logo.imagePath}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
@@ -64,8 +64,8 @@ export default function Contact() {
|
||||
<Section
|
||||
id="contact"
|
||||
eyebrow="Contact"
|
||||
title="Consultoria tecnica para software, arquitetura e integracoes criticas"
|
||||
description="Fale comigo se voce precisa de engenharia de software, consultoria tecnica, arquitetura de software ou apoio em projetos com Next.js, Tauri, Rust e IoT."
|
||||
title="Consultoria técnica para software, arquitetura e integrações críticas"
|
||||
description="Fale comigo se você precisa de engenharia de software, consultoria técnica, arquitetura de software ou apoio em projetos com Next.js, Tauri, Rust e IoT."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
@@ -182,7 +182,7 @@ export default function Contact() {
|
||||
<option value="full-stack">Desenvolvimento Full-Stack</option>
|
||||
<option value="cloud">Cloud & Infrastructure</option>
|
||||
<option value="iot">Internet of Things (IoT)</option>
|
||||
<option value="consultancy">Consultoria Tecnica Senior</option>
|
||||
<option value="consultancy">Consultoria Técnica Sênior</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, ChangeEvent, FormEvent, useEffect } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { type ChangeEvent, type FormEvent, useEffect, useState } from "react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
interface ContactFormProps {
|
||||
formType: "simple" | "advanced";
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "motion/react";
|
||||
import { Mail, MessageCircle } from "lucide-react";
|
||||
import { motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
|
||||
@@ -1,556 +1,127 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => false,
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
article: ({ children, ...props }: any) => (
|
||||
<article {...props}>{children}</article>
|
||||
),
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div data-testid="draw-line" />,
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
jest.mock("./animations/CounterValue", () => ({
|
||||
CounterValue: ({ value, suffix }: any) => (
|
||||
<span>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
function makeExperience(index: number, featured: boolean): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period: `${2020 + index} - Present`,
|
||||
location: "Remote",
|
||||
type: index % 2 === 0 ? "Software" : "Hardware",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock SectionLoading
|
||||
jest.mock("./SectionLoading", () => ({
|
||||
SectionLoading: ({ message }: any) => <div>{message}</div>,
|
||||
}));
|
||||
|
||||
describe("Property 5: Experience Categorization", () => {
|
||||
// Counter for unique IDs
|
||||
let idCounter = 0;
|
||||
|
||||
// Generator for experience entries
|
||||
const experienceArbitrary = (type: "Software" | "Hardware") =>
|
||||
fc.record({
|
||||
id: fc
|
||||
.string({ minLength: 10, maxLength: 20 })
|
||||
.map((s) => `exp-${type.toLowerCase()}-${idCounter++}-${s}`),
|
||||
title: fc.string({ minLength: 5, maxLength: 50 }),
|
||||
company: fc.string({ minLength: 3, maxLength: 30 }),
|
||||
period: fc.string({ minLength: 10, maxLength: 30 }),
|
||||
location: fc.string({ minLength: 5, maxLength: 40 }),
|
||||
type: fc.constant(type),
|
||||
description: fc.string({ minLength: 20, maxLength: 200 }),
|
||||
achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), {
|
||||
minLength: 1,
|
||||
maxLength: 10,
|
||||
}),
|
||||
featured: fc.constant(true),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
idCounter = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should filter and render Software experiences in the Software category", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any experience entry with type="Software", it should be
|
||||
* filtered and rendered within the Software category group. This ensures
|
||||
* correct categorization of software-related work experiences.
|
||||
*/
|
||||
describe("Experience featured filtering", () => {
|
||||
it("renders exactly the featured experiences", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 0,
|
||||
maxLength: 5,
|
||||
}),
|
||||
(softwareExps, hardwareExps) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Software category section
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) {
|
||||
// If no software heading, there should be no software experiences
|
||||
return softwareExps.length === 0;
|
||||
}
|
||||
|
||||
// Get the parent container of software experiences
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
// Count experience cards in the software section
|
||||
const softwareCards = softwareSection.querySelectorAll("article");
|
||||
|
||||
// The number of software cards should match the number of software experiences
|
||||
return softwareCards.length === softwareExps.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter and render Hardware experiences in the Hardware category", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any experience entry with type="Hardware", it should be
|
||||
* filtered and rendered within the Hardware category group. This ensures
|
||||
* correct categorization of hardware-related work experiences.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 0,
|
||||
maxLength: 5,
|
||||
}),
|
||||
fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
(softwareExps, hardwareExps) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Hardware category section
|
||||
const hardwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Hardware"));
|
||||
|
||||
if (!hardwareHeading) {
|
||||
// If no hardware heading, there should be no hardware experiences
|
||||
return hardwareExps.length === 0;
|
||||
}
|
||||
|
||||
// Get the parent container of hardware experiences
|
||||
const hardwareSection = hardwareHeading.closest("div");
|
||||
if (!hardwareSection) return false;
|
||||
|
||||
// Count experience cards in the hardware section
|
||||
const hardwareCards = hardwareSection.querySelectorAll("article");
|
||||
|
||||
// The number of hardware cards should match the number of hardware experiences
|
||||
return hardwareCards.length === hardwareExps.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should separate Software and Hardware experiences into distinct category groups", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any mix of Software and Hardware experiences, they should be
|
||||
* rendered in separate category groups with distinct headings. No experience
|
||||
* should appear in both categories.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
(softwareExps, hardwareExps) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find both category headings
|
||||
const headings = Array.from(container.querySelectorAll("h3"));
|
||||
const softwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Software"),
|
||||
fc.array(fc.boolean(), { minLength: 1, maxLength: 8 }),
|
||||
(featuredFlags) => {
|
||||
const experiences = featuredFlags.map((featured, index) =>
|
||||
makeExperience(index, featured),
|
||||
);
|
||||
const hardwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Hardware"),
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
const articles = Array.from(container.querySelectorAll("article"));
|
||||
const expectedCount = featuredFlags.filter(Boolean).length;
|
||||
|
||||
// Both headings should exist
|
||||
if (!softwareHeading || !hardwareHeading) return false;
|
||||
|
||||
// Get all experience cards
|
||||
const allCards = container.querySelectorAll("article");
|
||||
|
||||
// Total cards should equal total experiences
|
||||
return allCards.length === allExperiences.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not render category heading when no experiences exist for that type", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any category (Software or Hardware) with zero experiences,
|
||||
* the category heading should not be rendered. This ensures clean UI without
|
||||
* empty category sections.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.oneof(
|
||||
fc.record({
|
||||
software: fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
hardware: fc.constant([]),
|
||||
}),
|
||||
fc.record({
|
||||
software: fc.constant([]),
|
||||
hardware: fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
({ software, hardware }) => {
|
||||
const allExperiences = [...software, ...hardware];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
const headings = Array.from(container.querySelectorAll("h3"));
|
||||
const softwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Software"),
|
||||
expect(articles).toHaveLength(expectedCount);
|
||||
const articleText = articles.map(
|
||||
(article) => article.textContent ?? "",
|
||||
);
|
||||
const hardwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Hardware"),
|
||||
);
|
||||
|
||||
// If software array is empty, no software heading should exist
|
||||
const softwareCheck =
|
||||
software.length === 0 ? !softwareHeading : !!softwareHeading;
|
||||
|
||||
// If hardware array is empty, no hardware heading should exist
|
||||
const hardwareCheck =
|
||||
hardware.length === 0 ? !hardwareHeading : !!hardwareHeading;
|
||||
|
||||
return softwareCheck && hardwareCheck;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain experience data integrity within each category", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any experience entry, its data (title, company, description)
|
||||
* should be rendered correctly within its assigned category. The categorization
|
||||
* process should not modify or lose experience data.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
(softwareExps, hardwareExps) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Check that all experience titles are present in the rendered output
|
||||
const allTitlesPresent = allExperiences.every((exp) => {
|
||||
const titleElements = Array.from(container.querySelectorAll("h3"));
|
||||
return titleElements.some((el) =>
|
||||
el.textContent?.includes(exp.title),
|
||||
featuredFlags.forEach((featured, index) => {
|
||||
const title = `Experience ${index}`;
|
||||
expect(articleText.some((text) => text.includes(title))).toBe(
|
||||
featured,
|
||||
);
|
||||
});
|
||||
|
||||
// Check that all company names are present
|
||||
const allCompaniesPresent = allExperiences.every((exp) => {
|
||||
return container.textContent?.includes(exp.company);
|
||||
});
|
||||
|
||||
return allTitlesPresent && allCompaniesPresent;
|
||||
unmount();
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed case and variations in type field values", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any experience entry, the type field should be matched
|
||||
* case-sensitively to ensure correct categorization. Only exact matches
|
||||
* to "Software" or "Hardware" should be categorized.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
}),
|
||||
(softwareExps) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: softwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
it("renders an empty experience list safely", () => {
|
||||
const { container } = render(<Experience experiences={[]} />);
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Software category heading
|
||||
const headings = Array.from(container.querySelectorAll("h3"));
|
||||
const softwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Software"),
|
||||
);
|
||||
|
||||
// Software heading should exist
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
// All experiences should be rendered
|
||||
const cards = container.querySelectorAll("article");
|
||||
return cards.length === softwareExps.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should render all experiences when both categories have entries", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any non-empty set of Software and Hardware experiences,
|
||||
* all experiences should be rendered across both categories. No experiences
|
||||
* should be lost during the categorization process.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 4,
|
||||
}),
|
||||
fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 1,
|
||||
maxLength: 4,
|
||||
}),
|
||||
(softwareExps, hardwareExps) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Count all rendered experience cards
|
||||
const allCards = container.querySelectorAll("article");
|
||||
|
||||
// Total cards should equal total experiences
|
||||
return allCards.length === allExperiences.length;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain category separation across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.1**
|
||||
*
|
||||
* Property: For any number of component renders with the same data,
|
||||
* the categorization should remain consistent. Software experiences
|
||||
* should always appear in the Software category and Hardware experiences
|
||||
* in the Hardware category.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 2, max: 4 }), // Number of renders
|
||||
fc.array(experienceArbitrary("Software"), {
|
||||
minLength: 1,
|
||||
maxLength: 2,
|
||||
}),
|
||||
fc.array(experienceArbitrary("Hardware"), {
|
||||
minLength: 1,
|
||||
maxLength: 2,
|
||||
}),
|
||||
(numRenders, softwareExps, hardwareExps) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
let allRendersValid = true;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container, unmount } = render(<Experience />);
|
||||
|
||||
// Check that both categories exist
|
||||
const headings = Array.from(container.querySelectorAll("h3"));
|
||||
const softwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Software"),
|
||||
);
|
||||
const hardwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Hardware"),
|
||||
);
|
||||
|
||||
if (!softwareHeading || !hardwareHeading) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
|
||||
// Check total card count
|
||||
const allCards = container.querySelectorAll("article");
|
||||
if (allCards.length !== allExperiences.length) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
expect(container.querySelector("section")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("article")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,641 +1,146 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => false,
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
article: ({ children, ...props }: any) => (
|
||||
<article {...props}>{children}</article>
|
||||
),
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div data-testid="draw-line" />,
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
jest.mock("./animations/CounterValue", () => ({
|
||||
CounterValue: ({ value, suffix }: any) => (
|
||||
<span>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock SectionLoading
|
||||
jest.mock("./SectionLoading", () => ({
|
||||
SectionLoading: ({ message }: any) => <div>{message}</div>,
|
||||
}));
|
||||
|
||||
describe("Property 6: Chronological Ordering Within Categories", () => {
|
||||
// Counter for unique IDs
|
||||
let idCounter = 0;
|
||||
|
||||
// Generator for experience entries with specific year
|
||||
const experienceWithYearArbitrary = (
|
||||
type: "Software" | "Hardware",
|
||||
year: number,
|
||||
) =>
|
||||
fc.record({
|
||||
id: fc
|
||||
.string({ minLength: 10, maxLength: 20 })
|
||||
.map((s) => `exp-${type.toLowerCase()}-${year}-${idCounter++}-${s}`),
|
||||
title: fc.string({ minLength: 5, maxLength: 50 }),
|
||||
company: fc.string({ minLength: 3, maxLength: 30 }),
|
||||
period: fc.constant(`Janeiro ${year} - Dezembro ${year}`),
|
||||
location: fc.string({ minLength: 5, maxLength: 40 }),
|
||||
type: fc.constant(type),
|
||||
description: fc.string({ minLength: 20, maxLength: 200 }),
|
||||
achievements: fc.array(fc.string({ minLength: 10, maxLength: 100 }), {
|
||||
minLength: 1,
|
||||
maxLength: 5,
|
||||
}),
|
||||
technologies: fc.array(fc.string({ minLength: 2, maxLength: 20 }), {
|
||||
minLength: 1,
|
||||
maxLength: 10,
|
||||
}),
|
||||
featured: fc.constant(true),
|
||||
});
|
||||
|
||||
// Generator for array of experiences with random years
|
||||
const experiencesWithYearsArbitrary = (
|
||||
type: "Software" | "Hardware",
|
||||
count: number,
|
||||
) => {
|
||||
return fc
|
||||
.array(fc.integer({ min: 2015, max: 2025 }), {
|
||||
minLength: count,
|
||||
maxLength: count,
|
||||
})
|
||||
.chain((years) => {
|
||||
return fc.tuple(
|
||||
...years.map((year) => experienceWithYearArbitrary(type, year)),
|
||||
);
|
||||
});
|
||||
function makeExperience(
|
||||
index: number,
|
||||
year: number,
|
||||
period = `${year} - Present`,
|
||||
): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period,
|
||||
location: "Remote",
|
||||
type: "Software",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured: true,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
idCounter = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should sort Software experiences in descending chronological order by year", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any set of Software experiences with different years,
|
||||
* they should be rendered in descending chronological order (newest first).
|
||||
* This ensures users see the most recent experiences at the top.
|
||||
*/
|
||||
describe("Experience chronological ordering", () => {
|
||||
it("sorts featured experiences by the first year in descending order", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.integer({ min: 2, max: 5 })
|
||||
.chain((count) => experiencesWithYearsArbitrary("Software", count)),
|
||||
(softwareExps) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: softwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Software category section
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
// Get the parent container of software experiences
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
// Get all experience cards in the software section
|
||||
const softwareCards = Array.from(
|
||||
softwareSection.querySelectorAll("article"),
|
||||
);
|
||||
|
||||
// Extract years from the rendered cards
|
||||
const renderedYears = softwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
// Check if years are in descending order
|
||||
for (let i = 0; i < renderedYears.length - 1; i++) {
|
||||
if (renderedYears[i] < renderedYears[i + 1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should sort Hardware experiences in descending chronological order by year", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any set of Hardware experiences with different years,
|
||||
* they should be rendered in descending chronological order (newest first).
|
||||
* This ensures users see the most recent experiences at the top.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.integer({ min: 2, max: 5 })
|
||||
.chain((count) => experiencesWithYearsArbitrary("Hardware", count)),
|
||||
(hardwareExps) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: hardwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Hardware category section
|
||||
const hardwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Hardware"));
|
||||
|
||||
if (!hardwareHeading) return false;
|
||||
|
||||
// Get the parent container of hardware experiences
|
||||
const hardwareSection = hardwareHeading.closest("div");
|
||||
if (!hardwareSection) return false;
|
||||
|
||||
// Get all experience cards in the hardware section
|
||||
const hardwareCards = Array.from(
|
||||
hardwareSection.querySelectorAll("article"),
|
||||
);
|
||||
|
||||
// Extract years from the rendered cards
|
||||
const renderedYears = hardwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
// Check if years are in descending order
|
||||
for (let i = 0; i < renderedYears.length - 1; i++) {
|
||||
if (renderedYears[i] < renderedYears[i + 1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain chronological order within each category independently", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any mix of Software and Hardware experiences with various years,
|
||||
* each category should maintain its own descending chronological order independently.
|
||||
* The ordering in one category should not affect the other.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.integer({ min: 2, max: 4 })
|
||||
.chain((softwareCount) =>
|
||||
fc
|
||||
.integer({ min: 2, max: 4 })
|
||||
.chain((hardwareCount) =>
|
||||
fc.tuple(
|
||||
experiencesWithYearsArbitrary("Software", softwareCount),
|
||||
experiencesWithYearsArbitrary("Hardware", hardwareCount),
|
||||
),
|
||||
),
|
||||
),
|
||||
([softwareExps, hardwareExps]) => {
|
||||
const allExperiences = [...softwareExps, ...hardwareExps];
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: allExperiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Check Software category ordering
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
const softwareCards = Array.from(
|
||||
softwareSection.querySelectorAll("article"),
|
||||
);
|
||||
const softwareYears = softwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
// Check if software years are in descending order
|
||||
for (let i = 0; i < softwareYears.length - 1; i++) {
|
||||
if (softwareYears[i] < softwareYears[i + 1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check Hardware category ordering
|
||||
const hardwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Hardware"));
|
||||
|
||||
if (!hardwareHeading) return false;
|
||||
|
||||
const hardwareSection = hardwareHeading.closest("div");
|
||||
if (!hardwareSection) return false;
|
||||
|
||||
const hardwareCards = Array.from(
|
||||
hardwareSection.querySelectorAll("article"),
|
||||
);
|
||||
const hardwareYears = hardwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
// Check if hardware years are in descending order
|
||||
for (let i = 0; i < hardwareYears.length - 1; i++) {
|
||||
if (hardwareYears[i] < hardwareYears[i + 1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle experiences with the same year within a category", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any set of experiences with the same year within a category,
|
||||
* they should all be rendered without errors. The relative order of same-year
|
||||
* experiences is not specified but should be stable.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 2015, max: 2025 }),
|
||||
fc.integer({ min: 2, max: 4 }),
|
||||
(year, count) => {
|
||||
const softwareExps = Array.from({ length: count }, (_, i) => ({
|
||||
id: `exp-software-${year}-${i}`,
|
||||
title: `Software Title ${i}`,
|
||||
company: `Company ${i}`,
|
||||
period: `Janeiro ${year} - Dezembro ${year}`,
|
||||
location: `Location ${i}`,
|
||||
type: "Software" as const,
|
||||
description: `Description ${i}`,
|
||||
achievements: [`Achievement ${i}`],
|
||||
technologies: [`Tech ${i}`],
|
||||
featured: true,
|
||||
}));
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: softwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Software category section
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
const softwareCards = softwareSection.querySelectorAll("article");
|
||||
|
||||
// All experiences should be rendered
|
||||
return softwareCards.length === count;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should correctly parse years from various period formats", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any experience with a period string containing a 4-digit year,
|
||||
* the year should be correctly extracted and used for chronological ordering.
|
||||
* This ensures robust parsing across different date formats.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
year: fc.integer({ min: 2015, max: 2025 }),
|
||||
format: fc.constantFrom(
|
||||
(y: number) => `Janeiro ${y} - Dezembro ${y}`,
|
||||
(y: number) => `${y} - Presente`,
|
||||
(y: number) => `Dezembro ${y} - Fevereiro ${y + 1}`,
|
||||
(y: number) => `Junho ${y} - Presente`,
|
||||
),
|
||||
}),
|
||||
{ minLength: 2, maxLength: 5 },
|
||||
),
|
||||
(periodConfigs) => {
|
||||
const softwareExps = periodConfigs.map((config, i) => ({
|
||||
id: `exp-software-${i}`,
|
||||
title: `Software Title ${i}`,
|
||||
company: `Company ${i}`,
|
||||
period: config.format(config.year),
|
||||
location: `Location ${i}`,
|
||||
type: "Software" as const,
|
||||
description: `Description ${i}`,
|
||||
achievements: [`Achievement ${i}`],
|
||||
technologies: [`Tech ${i}`],
|
||||
featured: true,
|
||||
}));
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: softwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the Software category section
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
const softwareCards = Array.from(
|
||||
softwareSection.querySelectorAll("article"),
|
||||
);
|
||||
|
||||
// Extract years from the rendered cards
|
||||
const renderedYears = softwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
// Check if years are in descending order
|
||||
for (let i = 0; i < renderedYears.length - 1; i++) {
|
||||
if (renderedYears[i] < renderedYears[i + 1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain chronological order across component re-renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any set of experiences, the chronological ordering should
|
||||
* remain consistent across multiple component renders. The sorting should
|
||||
* be deterministic and stable.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc
|
||||
.integer({ min: 3, max: 5 })
|
||||
.chain((count) => experiencesWithYearsArbitrary("Software", count)),
|
||||
fc.integer({ min: 2, max: 3 }), // Number of renders
|
||||
(softwareExps, numRenders) => {
|
||||
let firstRenderYears: number[] = [];
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: softwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container, unmount } = render(<Experience />);
|
||||
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
const softwareCards = Array.from(
|
||||
softwareSection.querySelectorAll("article"),
|
||||
);
|
||||
const renderedYears = softwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
if (i === 0) {
|
||||
firstRenderYears = renderedYears;
|
||||
} else {
|
||||
// Check if the order matches the first render
|
||||
if (renderedYears.length !== firstRenderYears.length)
|
||||
return false;
|
||||
for (let j = 0; j < renderedYears.length; j++) {
|
||||
if (renderedYears[j] !== firstRenderYears[j]) return false;
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should sort experiences with year ranges by the first year", () => {
|
||||
/**
|
||||
* **Validates: Requirements 4.3**
|
||||
*
|
||||
* Property: For any experience with a period containing multiple years
|
||||
* (e.g., "2023 - 2024"), the sorting should use the first year found.
|
||||
* This ensures consistent ordering for multi-year experiences.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.integer({ min: 2015, max: 2024 }), {
|
||||
minLength: 2,
|
||||
maxLength: 5,
|
||||
fc.array(fc.integer({ min: 1990, max: 2035 }), {
|
||||
minLength: 1,
|
||||
maxLength: 8,
|
||||
}),
|
||||
(years) => {
|
||||
const softwareExps = years.map((year, i) => ({
|
||||
id: `exp-software-${i}`,
|
||||
title: `Software Title ${i}`,
|
||||
company: `Company ${i}`,
|
||||
period: `Janeiro ${year} - Dezembro ${year + 1}`,
|
||||
location: `Location ${i}`,
|
||||
type: "Software" as const,
|
||||
description: `Description ${i}`,
|
||||
achievements: [`Achievement ${i}`],
|
||||
technologies: [`Tech ${i}`],
|
||||
featured: true,
|
||||
}));
|
||||
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences: softwareExps,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
const softwareHeading = Array.from(
|
||||
container.querySelectorAll("h3"),
|
||||
).find((h3) => h3.textContent?.includes("Software"));
|
||||
|
||||
if (!softwareHeading) return false;
|
||||
|
||||
const softwareSection = softwareHeading.closest("div");
|
||||
if (!softwareSection) return false;
|
||||
|
||||
const softwareCards = Array.from(
|
||||
softwareSection.querySelectorAll("article"),
|
||||
const experiences = years.map((year, index) =>
|
||||
makeExperience(index, year),
|
||||
);
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
const renderedYears = Array.from(
|
||||
container.querySelectorAll("article .ds-kicker"),
|
||||
).map((element) => Number.parseInt(element.textContent ?? "0", 10));
|
||||
const expectedExperiences = [...experiences].sort(
|
||||
(a, b) =>
|
||||
Number.parseInt(b.period, 10) - Number.parseInt(a.period, 10),
|
||||
);
|
||||
const expectedYears = expectedExperiences.map((experience) =>
|
||||
Number.parseInt(experience.period, 10),
|
||||
);
|
||||
const renderedTitles = Array.from(
|
||||
container.querySelectorAll("article h3"),
|
||||
).map((element) => element.textContent);
|
||||
|
||||
// Extract first year from each rendered card
|
||||
const renderedYears = softwareCards.map((card) => {
|
||||
const periodText =
|
||||
card.querySelector(".ds-kicker")?.textContent || "";
|
||||
const yearMatch = periodText.match(/\d{4}/);
|
||||
return yearMatch ? Number.parseInt(yearMatch[0], 10) : 0;
|
||||
});
|
||||
|
||||
// Check if years are in descending order
|
||||
for (let i = 0; i < renderedYears.length - 1; i++) {
|
||||
if (renderedYears[i] < renderedYears[i + 1]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
expect(renderedYears).toEqual(expectedYears);
|
||||
expect(renderedTitles).toEqual(
|
||||
expectedExperiences.map((experience) => experience.title),
|
||||
);
|
||||
unmount();
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("sorts a period range by its first year", () => {
|
||||
const experiences = [
|
||||
makeExperience(1, 2022, "2022 - 2024"),
|
||||
makeExperience(2, 2024, "2024 - Present"),
|
||||
makeExperience(3, 2021, "2021 - 2023"),
|
||||
];
|
||||
const { container } = render(<Experience experiences={experiences} />);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("article .ds-kicker")).map(
|
||||
(element) => element.textContent,
|
||||
),
|
||||
).toEqual(["2024 - Present", "2022 - 2024", "2021 - 2023"]);
|
||||
});
|
||||
});
|
||||
@@ -1,407 +1,132 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import Experience from "./Experience";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
type MotionProps = HTMLAttributes<HTMLElement> & {
|
||||
initial?: unknown;
|
||||
whileInView?: unknown;
|
||||
viewport?: unknown;
|
||||
variants?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
transition?: unknown;
|
||||
};
|
||||
|
||||
type SectionMockProps = {
|
||||
children?: ReactNode;
|
||||
title?: string;
|
||||
eyebrow?: string;
|
||||
description?: string;
|
||||
disableChildrenAnimation?: boolean;
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const createMotionElement =
|
||||
(Tag: "div" | "article") =>
|
||||
({
|
||||
children,
|
||||
initial: _initial,
|
||||
whileInView: _whileInView,
|
||||
viewport: _viewport,
|
||||
variants: _variants,
|
||||
whileHover: _whileHover,
|
||||
whileTap: _whileTap,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: MotionProps & { children?: ReactNode }) => (
|
||||
<Tag {...props}>{children}</Tag>
|
||||
);
|
||||
|
||||
// Mock framer-motion
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => false,
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
article: ({ children, ...props }: any) => (
|
||||
<article {...props}>{children}</article>
|
||||
),
|
||||
div: createMotionElement("div"),
|
||||
article: createMotionElement("article"),
|
||||
},
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
Section: ({
|
||||
children,
|
||||
title,
|
||||
eyebrow: _eyebrow,
|
||||
description: _description,
|
||||
disableChildrenAnimation: _disableChildrenAnimation,
|
||||
...props
|
||||
}: SectionMockProps) => (
|
||||
<section {...props}>
|
||||
<h2>{title}</h2>
|
||||
{children}
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div data-testid="draw-line" />,
|
||||
DrawLine: () => <span data-testid="draw-line" />,
|
||||
}));
|
||||
|
||||
jest.mock("./animations/CounterValue", () => ({
|
||||
CounterValue: ({ value, suffix }: any) => (
|
||||
<span>
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
function makeExperience(index: number): PortfolioExperience {
|
||||
return {
|
||||
id: `experience-${index}`,
|
||||
title: `Experience ${index}`,
|
||||
company: "Company",
|
||||
period: `${2020 + index} - Present`,
|
||||
location: "Remote",
|
||||
type: index % 2 === 0 ? "Software" : "Hardware",
|
||||
description: "Description",
|
||||
achievements: [],
|
||||
technologies: [],
|
||||
featured: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Mock SectionLoading
|
||||
jest.mock("./SectionLoading", () => ({
|
||||
SectionLoading: ({ message }: any) => <div>{message}</div>,
|
||||
}));
|
||||
|
||||
describe("Property 11: Semantic Heading Hierarchy", () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should maintain proper heading hierarchy (h2 for section, h3 for cards)", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any sequence of heading elements in the Experience section,
|
||||
* the heading levels should follow proper semantic hierarchy (h2 for section
|
||||
* title, h3 for experience card titles, no skipped levels). This ensures
|
||||
* screen readers can properly navigate the document structure.
|
||||
*/
|
||||
describe("Experience heading hierarchy", () => {
|
||||
it("renders one h2 section heading and h3 headings for featured cards", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
title: fc.string({ minLength: 1 }),
|
||||
company: fc.string({ minLength: 1 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.string({ minLength: 1 }),
|
||||
achievements: fc.array(fc.string({ minLength: 1 })),
|
||||
technologies: fc.array(fc.string({ minLength: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
fc.property(fc.integer({ min: 1, max: 8 }), (count) => {
|
||||
const experiences = Array.from({ length: count }, (_, index) =>
|
||||
makeExperience(index),
|
||||
);
|
||||
const { container, unmount } = render(
|
||||
<Experience experiences={experiences} />,
|
||||
);
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
|
||||
// Get all heading elements in order
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
expect(headings[0]?.tagName).toBe("H2");
|
||||
expect(container.querySelector("section > h2")).toHaveTextContent(
|
||||
"Experiência em engenharia de software e sistemas críticos",
|
||||
);
|
||||
expect(container.querySelectorAll("section > h2")).toHaveLength(1);
|
||||
expect(container.querySelectorAll("article h3")).toHaveLength(count);
|
||||
expect(
|
||||
new Set(
|
||||
Array.from(container.querySelectorAll("article h3")).map(
|
||||
(heading) => heading.textContent,
|
||||
),
|
||||
),
|
||||
).toEqual(new Set(experiences.map((experience) => experience.title)));
|
||||
expect(headings.some((heading) => heading.tagName === "H4")).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
if (headings.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract heading levels
|
||||
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
|
||||
|
||||
// Check for proper hierarchy:
|
||||
// 1. Should start with h2 (section title)
|
||||
// 2. Experience card titles should be h3
|
||||
// 3. No skipped levels (difference should not be > 1)
|
||||
|
||||
// First heading should be h2 (section title)
|
||||
if (levels[0] !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for skipped levels
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const diff = levels[i] - levels[i - 1];
|
||||
// Difference should be -1, 0, or 1 (no skipping levels)
|
||||
if (Math.abs(diff) > 1 && diff > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// All experience card titles should be h3
|
||||
const cardTitles = container.querySelectorAll("article h3");
|
||||
if (cardTitles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
unmount();
|
||||
}),
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not skip heading levels in the hierarchy", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any generated set of experiences, the heading hierarchy
|
||||
* should never skip levels (e.g., h2 directly to h4). This is critical
|
||||
* for accessibility as screen reader users rely on heading levels to
|
||||
* understand document structure.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.uuid(),
|
||||
title: fc.lorem({ maxCount: 5 }),
|
||||
company: fc.lorem({ maxCount: 2 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.lorem({ maxCount: 10 }),
|
||||
achievements: fc.array(fc.lorem({ maxCount: 5 }), { maxLength: 3 }),
|
||||
technologies: fc.array(fc.lorem({ maxCount: 1 }), { maxLength: 5 }),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 2, maxLength: 8 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
it("renders no card headings when no experiences are supplied", () => {
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Get all headings
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
const levels = headings.map((h) => parseInt(h.tagName.substring(1)));
|
||||
|
||||
// Check that we never skip levels when going deeper
|
||||
for (let i = 1; i < levels.length; i++) {
|
||||
const currentLevel = levels[i];
|
||||
const previousLevel = levels[i - 1];
|
||||
|
||||
// When going deeper (increasing level number), should only increase by 1
|
||||
if (
|
||||
currentLevel > previousLevel &&
|
||||
currentLevel - previousLevel > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use h2 for section title consistently", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any Experience section render, the main section title
|
||||
* should always be an h2 element. This maintains consistency with the
|
||||
* overall page structure where main sections use h2.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
title: fc.string({ minLength: 1 }),
|
||||
company: fc.string({ minLength: 1 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.string({ minLength: 1 }),
|
||||
achievements: fc.array(fc.string({ minLength: 1 })),
|
||||
technologies: fc.array(fc.string({ minLength: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 5 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find the section element
|
||||
const section = container.querySelector("section");
|
||||
if (!section) return false;
|
||||
|
||||
// The first heading in the section should be h2
|
||||
const firstHeading = section.querySelector("h1, h2, h3, h4, h5, h6");
|
||||
if (!firstHeading) return false;
|
||||
|
||||
return firstHeading.tagName === "H2";
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use h3 for all experience card titles", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any set of experience cards rendered, each card's title
|
||||
* should be an h3 element. This maintains proper hierarchy under the
|
||||
* section h2 and ensures consistent navigation for assistive technology.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.uuid(),
|
||||
title: fc.lorem({ maxCount: 5 }),
|
||||
company: fc.lorem({ maxCount: 2 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.lorem({ maxCount: 10 }),
|
||||
achievements: fc.array(fc.lorem({ maxCount: 5 })),
|
||||
technologies: fc.array(fc.lorem({ maxCount: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 10 },
|
||||
),
|
||||
(experiences) => {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Find all article elements (experience cards)
|
||||
const articles = container.querySelectorAll("article");
|
||||
|
||||
if (articles.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each article should have at least one h3 for the title
|
||||
let allArticlesHaveH3 = true;
|
||||
|
||||
articles.forEach((article) => {
|
||||
const h3Elements = article.querySelectorAll("h3");
|
||||
// Should have at least one h3 (the title)
|
||||
if (h3Elements.length === 0) {
|
||||
allArticlesHaveH3 = false;
|
||||
}
|
||||
});
|
||||
|
||||
return allArticlesHaveH3;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain hierarchy across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.4**
|
||||
*
|
||||
* Property: For any number of component re-renders with the same data,
|
||||
* the heading hierarchy should remain consistent. This ensures the
|
||||
* semantic structure is stable and reliable for assistive technology.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(
|
||||
fc.record({
|
||||
id: fc.string(),
|
||||
title: fc.string({ minLength: 1 }),
|
||||
company: fc.string({ minLength: 1 }),
|
||||
type: fc.constantFrom("Software", "Hardware"),
|
||||
period: fc.string({ minLength: 1 }),
|
||||
location: fc.string({ minLength: 1 }),
|
||||
description: fc.string({ minLength: 1 }),
|
||||
achievements: fc.array(fc.string({ minLength: 1 })),
|
||||
technologies: fc.array(fc.string({ minLength: 1 })),
|
||||
featured: fc.constant(true),
|
||||
}),
|
||||
{ minLength: 1, maxLength: 5 },
|
||||
),
|
||||
fc.integer({ min: 2, max: 5 }),
|
||||
(experiences, numRenders) => {
|
||||
let previousHierarchy: number[] | null = null;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
mockUsePortfolioData.mockReturnValue({
|
||||
data: {
|
||||
experiences,
|
||||
personal: {} as any,
|
||||
projects: [],
|
||||
skills: { technical: [], soft: [] },
|
||||
contact: {} as any,
|
||||
social: [],
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { container, unmount } = render(<Experience />);
|
||||
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
const currentHierarchy = headings.map((h) =>
|
||||
parseInt(h.tagName.substring(1)),
|
||||
);
|
||||
|
||||
if (previousHierarchy !== null) {
|
||||
// Hierarchy should be identical across renders
|
||||
if (
|
||||
JSON.stringify(currentHierarchy) !==
|
||||
JSON.stringify(previousHierarchy)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
previousHierarchy = currentHierarchy;
|
||||
unmount();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
expect(container.querySelector("section > h2")).toBeInTheDocument();
|
||||
expect(container.querySelectorAll("article h3")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioExperience } from "@/lib/portfolio-data";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { Section } from "./Section";
|
||||
|
||||
export default function Experience({
|
||||
experiences,
|
||||
experiences = [],
|
||||
}: {
|
||||
experiences: PortfolioExperience[];
|
||||
experiences?: PortfolioExperience[];
|
||||
}) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const sortedExperiences = [...experiences]
|
||||
@@ -48,8 +48,8 @@ export default function Experience({
|
||||
<Section
|
||||
id="experience"
|
||||
eyebrow="Selected experience"
|
||||
title="Experiencia em engenharia de software e sistemas criticos"
|
||||
description="Projetos e contextos em que combinei arquitetura de software, integracoes complexas e entrega com responsabilidade operacional."
|
||||
title="Experiência em engenharia de software e sistemas críticos"
|
||||
description="Projetos e contextos em que combinei arquitetura de software, integrações complexas e entrega com responsabilidade operacional."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<motion.div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
import fc from "fast-check";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Property 9: ARIA Labels for Links", () => {
|
||||
it("should have descriptive aria-label attributes for all social media links", () => {
|
||||
|
||||
@@ -1,264 +1,36 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
import fc from "fast-check";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Property 3: Responsive Component Layout", () => {
|
||||
it("should include Tailwind responsive utility classes for grid layout", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any Footer component, it should include Tailwind responsive
|
||||
* utility classes (sm:, md:, lg:) to adapt the grid layout across viewport sizes.
|
||||
* This ensures the component is responsive and provides optimal layout at different
|
||||
* screen widths.
|
||||
*/
|
||||
describe("Footer responsive structure", () => {
|
||||
it("keeps the responsive design-system structure across renders", () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed, testing static component
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
fc.property(fc.integer({ min: 1, max: 5 }), (renderCount) => {
|
||||
for (let index = 0; index < renderCount; index += 1) {
|
||||
const { container, unmount } = render(<Footer />);
|
||||
const footer = container.querySelector("footer");
|
||||
const grid = footer?.querySelector(".ds-grid-2");
|
||||
|
||||
// Find the main grid container
|
||||
const gridContainer = container.querySelector("footer .grid");
|
||||
expect(footer).toHaveClass("ds-section");
|
||||
expect(
|
||||
footer?.querySelector(".ds-section-shell"),
|
||||
).toBeInTheDocument();
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid?.querySelectorAll(":scope > .ds-card")).toHaveLength(2);
|
||||
|
||||
// Verify grid container exists
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const className = gridContainer.className;
|
||||
|
||||
// Check for responsive grid-cols classes
|
||||
// Should have base grid-cols-1 and responsive variants
|
||||
const hasBaseGridCols = className.includes("grid-cols-1");
|
||||
const hasMdResponsive = className.includes("md:grid-cols-");
|
||||
const hasLgResponsive = className.includes("lg:grid-cols-");
|
||||
|
||||
return hasBaseGridCols && hasMdResponsive && hasLgResponsive;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should include responsive padding classes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any Footer component, the container should use the design system
|
||||
* section class (ds-section) which provides responsive padding that scales
|
||||
* appropriately across viewport sizes. This ensures consistent spacing that
|
||||
* adapts to different screen widths.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find the footer element
|
||||
const footer = container.querySelector("footer");
|
||||
|
||||
if (!footer) {
|
||||
return false;
|
||||
unmount();
|
||||
}
|
||||
|
||||
const className = footer.className;
|
||||
|
||||
// Check for design system section class which handles responsive padding
|
||||
const hasDsSection = className.includes("ds-section");
|
||||
|
||||
// The ds-section class in globals.css provides responsive padding via CSS
|
||||
// padding: var(--sp-20) var(--sp-16) on desktop
|
||||
// padding: var(--sp-16) var(--sp-6) on mobile (via @media query)
|
||||
return hasDsSection;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
{ numRuns: 25 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should include responsive gap classes for grid spacing", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any Footer component grid, it should include gap
|
||||
* classes to adjust spacing between grid items.
|
||||
* This ensures optimal spacing that scales with the layout.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
it("uses the mobile-first grid primitive instead of the legacy grid contract", () => {
|
||||
const { container } = render(<Footer />);
|
||||
const grid = container.querySelector(".ds-grid-2");
|
||||
|
||||
// Find the grid container
|
||||
const gridContainer = container.querySelector("footer .grid");
|
||||
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const className = gridContainer.className;
|
||||
|
||||
// Check for gap classes (base gap)
|
||||
const hasGap = className.includes("gap-sp-");
|
||||
|
||||
// Should have at least base gap
|
||||
return hasGap;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain responsive classes across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any number of component renders, the Footer should consistently
|
||||
* maintain its responsive utility classes. This verifies that responsive behavior
|
||||
* is not accidentally removed during re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
|
||||
(numRenders) => {
|
||||
let allRendersValid = true;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<Footer />);
|
||||
|
||||
// Check grid container
|
||||
const gridContainer = container.querySelector("footer .grid");
|
||||
|
||||
if (!gridContainer) {
|
||||
allRendersValid = false;
|
||||
unmount();
|
||||
continue;
|
||||
}
|
||||
|
||||
const className = gridContainer.className;
|
||||
|
||||
// Verify responsive classes are present
|
||||
const hasResponsiveClasses =
|
||||
className.includes("grid-cols-1") &&
|
||||
className.includes("md:grid-cols-") &&
|
||||
className.includes("lg:grid-cols-");
|
||||
|
||||
if (!hasResponsiveClasses) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have responsive column span for copyright section", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any Footer component, the copyright section should include
|
||||
* responsive column span classes (md:col-span-2, lg:col-span-1) to adapt
|
||||
* its position in the grid layout across different viewport sizes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find the copyright section (contains "Informacoes legais" heading)
|
||||
const copyrightSection = Array.from(
|
||||
container.querySelectorAll("footer h3"),
|
||||
).find((h3) =>
|
||||
h3.textContent?.includes("Informacoes legais"),
|
||||
)?.parentElement;
|
||||
|
||||
if (!copyrightSection) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const className = copyrightSection.className;
|
||||
|
||||
// Check for responsive column span classes
|
||||
const hasMdColSpan = className.includes("md:col-span-");
|
||||
const hasLgColSpan = className.includes("lg:col-span-");
|
||||
|
||||
return hasMdColSpan && hasLgColSpan;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use consistent responsive breakpoint prefixes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any Footer component, all responsive utility classes should
|
||||
* use consistent Tailwind breakpoint prefixes (md:, lg:). This ensures the
|
||||
* component follows Tailwind's responsive design system correctly.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Get all elements with classes in the footer
|
||||
const allElements = container.querySelectorAll("footer *");
|
||||
|
||||
let hasMdPrefix = false;
|
||||
let hasLgPrefix = false;
|
||||
let hasInvalidPrefix = false;
|
||||
|
||||
allElements.forEach((element) => {
|
||||
const className = element.className;
|
||||
|
||||
if (typeof className === "string") {
|
||||
// Check for valid responsive prefixes
|
||||
if (className.includes("md:")) hasMdPrefix = true;
|
||||
if (className.includes("lg:")) hasLgPrefix = true;
|
||||
|
||||
// Check for invalid/inconsistent prefixes (sm: should not be used based on design)
|
||||
// We're looking for md: and lg: specifically
|
||||
}
|
||||
});
|
||||
|
||||
// Should have md: and lg: prefixes, no invalid ones
|
||||
return hasMdPrefix && hasLgPrefix && !hasInvalidPrefix;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have mobile-first responsive design with base classes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.6**
|
||||
*
|
||||
* Property: For any Footer component, it should follow mobile-first responsive
|
||||
* design by having base classes for mobile and progressive enhancement with
|
||||
* responsive variants. This ensures the component works on all devices.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find the grid container
|
||||
const gridContainer = container.querySelector("footer .grid");
|
||||
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const className = gridContainer.className;
|
||||
|
||||
// Mobile-first means base class (grid-cols-1) should be present
|
||||
// Then enhanced with md: and lg: variants
|
||||
const hasMobileBase = className.includes("grid-cols-1");
|
||||
const hasTabletEnhancement = className.includes("md:grid-cols-");
|
||||
const hasDesktopEnhancement = className.includes("lg:grid-cols-");
|
||||
|
||||
// Verify progressive enhancement: mobile -> tablet -> desktop
|
||||
return hasMobileBase && hasTabletEnhancement && hasDesktopEnhancement;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid).not.toHaveClass("grid-cols-1");
|
||||
expect(grid).not.toHaveClass("lg:grid-cols-3");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
import fc from "fast-check";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Property 2: Social Links Open in New Tab", () => {
|
||||
it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => {
|
||||
|
||||
+58
-287
@@ -1,326 +1,97 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Footer Component - Contact Information", () => {
|
||||
it("should render contact section heading", () => {
|
||||
describe("Footer contact and navigation", () => {
|
||||
it("renders the contact card and a mailto link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const contactHeading = screen.getByText("Contato direto");
|
||||
expect(contactHeading).toBeInTheDocument();
|
||||
expect(contactHeading.tagName).toBe("H3");
|
||||
});
|
||||
expect(screen.getByText("Contato")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "Engenharia com menos ruído e mais clareza de execução",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
it("should render email address", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
expect(emailLink).toBeInTheDocument();
|
||||
expect(emailLink.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("should have mailto link for email", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
const emailLink = screen.getByRole("link", {
|
||||
name: "[email protected]",
|
||||
});
|
||||
expect(emailLink).toHaveAttribute(
|
||||
"href",
|
||||
"mailto:[email protected]",
|
||||
);
|
||||
expect(emailLink).not.toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it("should apply correct styling classes to email link", () => {
|
||||
it("renders quick links as same-tab navigation", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
const classes = emailLink.className;
|
||||
|
||||
expect(classes).toContain("text-[12px]");
|
||||
expect(classes).toContain("hover:text-[var(--slash)]");
|
||||
const servicesLink = screen.getByRole("link", { name: "Hub de serviços" });
|
||||
expect(servicesLink).toHaveAttribute("href", "/servicos");
|
||||
expect(servicesLink).not.toHaveAttribute("target", "_blank");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Social Links", () => {
|
||||
it("should render social links section heading", () => {
|
||||
describe("Footer social links", () => {
|
||||
it("renders each social link with an accessible name", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const connectHeading = screen.getByText("Redes profissionais");
|
||||
expect(connectHeading).toBeInTheDocument();
|
||||
expect(connectHeading.tagName).toBe("H3");
|
||||
const socialLinks = Array.from(
|
||||
document.querySelectorAll("footer a[aria-label]"),
|
||||
);
|
||||
|
||||
expect(socialLinks.map((link) => link.getAttribute("aria-label"))).toEqual([
|
||||
"Visit GitHub profile",
|
||||
"Visit LinkedIn profile",
|
||||
"Visit Instagram profile",
|
||||
]);
|
||||
expect(
|
||||
new Set(socialLinks.map((link) => link.getAttribute("aria-label"))).size,
|
||||
).toBe(socialLinks.length);
|
||||
});
|
||||
|
||||
it("should render GitHub social link", () => {
|
||||
it("opens social links securely in a new tab", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
expect(githubLink).toBeInTheDocument();
|
||||
expect(githubLink.tagName).toBe("A");
|
||||
});
|
||||
const socialLinks = Array.from(
|
||||
document.querySelectorAll("footer a[aria-label]"),
|
||||
);
|
||||
|
||||
it("should render LinkedIn social link", () => {
|
||||
render(<Footer />);
|
||||
for (const link of socialLinks) {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(link.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
||||
}
|
||||
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
expect(linkedinLink).toBeInTheDocument();
|
||||
expect(linkedinLink.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("should have correct href for GitHub link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
expect(githubLink).toHaveAttribute(
|
||||
expect(screen.getByLabelText("Visit GitHub profile")).toHaveAttribute(
|
||||
"href",
|
||||
"https://github.com/evandrodevbr",
|
||||
);
|
||||
});
|
||||
|
||||
it("should have correct href for LinkedIn link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
expect(linkedinLink).toHaveAttribute(
|
||||
"href",
|
||||
"https://linkedin.com/in/evandrodevbr",
|
||||
);
|
||||
});
|
||||
|
||||
it('should open social links in new tab with target="_blank"', () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
expect(githubLink).toHaveAttribute("target", "_blank");
|
||||
expect(linkedinLink).toHaveAttribute("target", "_blank");
|
||||
});
|
||||
|
||||
it('should have rel="noopener noreferrer" for security', () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
expect(githubLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
expect(linkedinLink).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
|
||||
it("should have aria-label for accessibility", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
expect(githubLink).toHaveAttribute("aria-label", "Visit GitHub profile");
|
||||
expect(linkedinLink).toHaveAttribute(
|
||||
"aria-label",
|
||||
"Visit LinkedIn profile",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render SVG icons for social links", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
const githubSvg = githubLink.querySelector("svg");
|
||||
const linkedinSvg = linkedinLink.querySelector("svg");
|
||||
|
||||
expect(githubSvg).toBeInTheDocument();
|
||||
expect(linkedinSvg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-hidden="true" on SVG icons', () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
const linkedinLink = screen.getByLabelText("Visit LinkedIn profile");
|
||||
|
||||
const githubSvg = githubLink.querySelector("svg");
|
||||
const linkedinSvg = linkedinLink.querySelector("svg");
|
||||
|
||||
expect(githubSvg).toHaveAttribute("aria-hidden", "true");
|
||||
expect(linkedinSvg).toHaveAttribute("aria-hidden", "true");
|
||||
expect(
|
||||
screen.getByLabelText("Visit LinkedIn profile").getAttribute("href"),
|
||||
).toContain("https://www.linkedin.com/in/evandrodevbr/");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Copyright", () => {
|
||||
it("should render copyright section heading", () => {
|
||||
render(<Footer />);
|
||||
describe("Footer structure", () => {
|
||||
it("uses the current design-system shell and two-card grid", () => {
|
||||
const { container } = render(<Footer />);
|
||||
const footer = container.querySelector("footer");
|
||||
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
expect(copyrightHeading).toBeInTheDocument();
|
||||
expect(copyrightHeading.tagName).toBe("H3");
|
||||
expect(footer).toHaveClass("ds-section");
|
||||
expect(footer?.querySelector(".ds-section-shell")).toBeInTheDocument();
|
||||
expect(footer?.querySelector(".ds-grid-2")).toBeInTheDocument();
|
||||
expect(footer?.querySelectorAll(":scope .ds-card")).toHaveLength(2);
|
||||
expect(footer?.querySelectorAll("h3")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should display current year in copyright text", () => {
|
||||
it("renders the current year without hardcoded copyright text", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = screen.getByText(
|
||||
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
|
||||
);
|
||||
|
||||
expect(copyrightText).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should update copyright year dynamically", () => {
|
||||
// Test that the component uses new Date().getFullYear() dynamically
|
||||
// by verifying it shows the current year (not a hardcoded value)
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
render(<Footer />);
|
||||
|
||||
// Verify the copyright includes the current year
|
||||
const copyrightText = screen.getByText(
|
||||
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
|
||||
);
|
||||
expect(copyrightText).toBeInTheDocument();
|
||||
|
||||
// Verify it's not hardcoded to a specific year like 2023 or 2024
|
||||
// by checking that the year matches the actual current year
|
||||
expect(currentYear).toBeGreaterThanOrEqual(2024);
|
||||
});
|
||||
|
||||
it("should apply correct styling to copyright text", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const copyrightText = screen.getByText(
|
||||
new RegExp(`© ${currentYear} Evandro Fonseca Junior`),
|
||||
);
|
||||
const classes = copyrightText.className;
|
||||
|
||||
expect(classes).toContain("text-[12px]");
|
||||
expect(classes).toContain("text-[rgba(255,255,255,0.5)]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Responsive Layout", () => {
|
||||
beforeEach(() => {
|
||||
// Mock window.matchMedia for responsive tests
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
it("should have responsive grid layout classes", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const gridContainer = container.querySelector(".grid");
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
|
||||
const classes = gridContainer?.className || "";
|
||||
expect(classes).toContain("grid-cols-1");
|
||||
expect(classes).toContain("md:grid-cols-2");
|
||||
expect(classes).toContain("lg:grid-cols-3");
|
||||
});
|
||||
|
||||
it("should have responsive padding classes on container", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const innerContainer = container.querySelector(".ds-section-shell");
|
||||
expect(innerContainer).toBeInTheDocument();
|
||||
|
||||
// The ds-section class handles responsive padding via CSS
|
||||
const footer = container.querySelector("footer");
|
||||
const classes = footer?.className || "";
|
||||
expect(classes).toContain("ds-section");
|
||||
});
|
||||
|
||||
it("should have responsive gap classes on grid", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const gridContainer = container.querySelector(".grid");
|
||||
const classes = gridContainer?.className || "";
|
||||
|
||||
expect(classes).toContain("gap-sp-6");
|
||||
});
|
||||
|
||||
it("should have responsive column span for copyright section", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find the copyright section by its heading
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
const copyrightSection = copyrightHeading.closest(".ds-card");
|
||||
|
||||
expect(copyrightSection).toBeInTheDocument();
|
||||
const classes = copyrightSection?.className || "";
|
||||
|
||||
expect(classes).toContain("md:col-span-2");
|
||||
expect(classes).toContain("lg:col-span-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Design System Consistency", () => {
|
||||
it("should use design system kicker class for section labels", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const kickers = document.querySelectorAll(".ds-kicker");
|
||||
expect(kickers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should have ds-section class on footer element", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const footer = container.querySelector("footer");
|
||||
expect(footer).toBeInTheDocument();
|
||||
|
||||
const classes = footer?.className || "";
|
||||
expect(classes).toContain("ds-section");
|
||||
});
|
||||
|
||||
it("should have ds-section-dark class for dark background", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const footer = container.querySelector("footer");
|
||||
const classes = footer?.className || "";
|
||||
|
||||
expect(classes).toContain("ds-section-dark");
|
||||
});
|
||||
|
||||
it("should use design system shell class for container", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const shell = container.querySelector(".ds-section-shell");
|
||||
expect(shell).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Structure", () => {
|
||||
it("should render as footer semantic element", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const footer = container.querySelector("footer");
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have three main sections", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const contactHeading = screen.getByText("Contato direto");
|
||||
const connectHeading = screen.getByText("Redes profissionais");
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
|
||||
expect(contactHeading).toBeInTheDocument();
|
||||
expect(connectHeading).toBeInTheDocument();
|
||||
expect(copyrightHeading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have design system shell container", () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
const innerContainer = container.querySelector(".ds-section-shell");
|
||||
expect(innerContainer).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
new RegExp(`Copyright ${currentYear} Evandro Fonseca Junior`),
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -16,22 +16,22 @@ const socialLinks: SocialLink[] = [
|
||||
name: "LinkedIn",
|
||||
href: "https://www.linkedin.com/in/evandrodevbr/?skipRedirect=true",
|
||||
icon: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
|
||||
ariaLabel: "Connect on LinkedIn",
|
||||
ariaLabel: "Visit LinkedIn profile",
|
||||
},
|
||||
{
|
||||
name: "Instagram",
|
||||
href: "https://www.instagram.com/evandrodevbr/",
|
||||
icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.85.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.85-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z",
|
||||
ariaLabel: "Follow on Instagram",
|
||||
ariaLabel: "Visit Instagram profile",
|
||||
},
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{ label: "Hub de servicos", href: "/servicos" },
|
||||
{ label: "Hub de serviços", href: "/servicos" },
|
||||
{ label: "Cases", href: "/cases" },
|
||||
{ label: "Consultoria Next.js", href: "/servicos/consultoria-nextjs" },
|
||||
{
|
||||
label: "Integracao hardware + software",
|
||||
label: "Integração hardware + software",
|
||||
href: "/servicos/integracao-hardware-software-iot",
|
||||
},
|
||||
];
|
||||
@@ -50,11 +50,11 @@ export default function Footer() {
|
||||
<div className="ds-kicker">Contato</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">
|
||||
Engenharia com menos ruido e mais clareza de execucao
|
||||
Engenharia com menos ruído e mais clareza de execução
|
||||
</h3>
|
||||
<p className="ds-copy max-w-[48ch]">
|
||||
Se voce precisa de alguem para conectar produto, integracao e
|
||||
entrega com mais criterio tecnico, este portfolio e o ponto de
|
||||
Se você precisa de alguém para conectar produto, integração e
|
||||
entrega com mais critério técnico, este portfólio é o ponto de
|
||||
partida.
|
||||
</p>
|
||||
|
||||
@@ -79,14 +79,15 @@ export default function Footer() {
|
||||
Garuva, SC | Remote worldwide
|
||||
</div>
|
||||
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
|
||||
Copyright {currentYear} Evandro Fonseca Junior. All rights reserved.
|
||||
Copyright {currentYear} Evandro Fonseca Junior. All rights
|
||||
reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ds-card flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="ds-kicker">Presenca digital</div>
|
||||
<div className="ds-kicker">Presença digital</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">Canais profissionais</h3>
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Header } from "./Header";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
whileHover: _whileHover,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { motion, useReducedMotion: () => false };
|
||||
});
|
||||
|
||||
describe("Header", () => {
|
||||
it("exposes the primary navigation and the existing CV asset", () => {
|
||||
render(<Header />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "Home" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#home",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Casos" })).toHaveAttribute(
|
||||
"href",
|
||||
"/cases",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Serviços" })).toHaveAttribute(
|
||||
"href",
|
||||
"/servicos",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "Currículo" })).toHaveAttribute(
|
||||
"href",
|
||||
"/curriculo",
|
||||
);
|
||||
|
||||
const cvLink = screen.getByRole("link", { name: "Download CV" });
|
||||
expect(cvLink).toHaveAttribute("href", "/Profile.pdf");
|
||||
expect(cvLink).toHaveAttribute("download", "Evandro_Fonseca_Junior_CV.pdf");
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Navegação principal" }),
|
||||
).toHaveClass("site-header-nav");
|
||||
});
|
||||
|
||||
it("announces and controls the mobile menu", () => {
|
||||
render(<Header />);
|
||||
|
||||
const menuButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||
expect(menuButton).not.toHaveAttribute("aria-controls");
|
||||
expect(menuButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(menuButton).toHaveClass("site-header-menu-toggle");
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Navegação mobile" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
||||
"aria-controls",
|
||||
"site-navigation-menu",
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Fechar menu" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
const mobileNavigation = screen.getByRole("navigation", {
|
||||
name: "Navegação mobile",
|
||||
});
|
||||
expect(mobileNavigation).toHaveAttribute("id", "site-navigation-menu");
|
||||
expect(mobileNavigation).toHaveAttribute("aria-hidden", "false");
|
||||
|
||||
const closeButton = screen.getByRole("button", { name: "Fechar menu" });
|
||||
fireEvent.keyDown(closeButton, { key: "Escape" });
|
||||
const openButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||
expect(openButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
|
||||
it("returns focus after closing the menu from a mobile link", () => {
|
||||
render(<Header />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
|
||||
const mobileNavigation = screen.getByRole("navigation", {
|
||||
name: "Navegação mobile",
|
||||
});
|
||||
fireEvent.click(
|
||||
within(mobileNavigation).getByRole("link", { name: "Home" }),
|
||||
);
|
||||
|
||||
const openButton = screen.getByRole("button", { name: "Abrir menu" });
|
||||
expect(openButton).toHaveAttribute("aria-expanded", "false");
|
||||
expect(document.activeElement).toBe(openButton);
|
||||
});
|
||||
});
|
||||
+69
-50
@@ -1,35 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Logo } from "./Logo";
|
||||
import { type KeyboardEvent, useRef, useState } from "react";
|
||||
import {
|
||||
CV_DOWNLOAD_NAME,
|
||||
CV_URL,
|
||||
NAVIGATION_ITEMS,
|
||||
} from "@/lib/site-navigation";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
const navItems = [
|
||||
{ href: "#home", label: "Home" },
|
||||
{ href: "#about", label: "Sobre" },
|
||||
{ href: "#skills", label: "Habilidades" },
|
||||
{ href: "#experience", label: "Experiencia" },
|
||||
{ href: "#projects", label: "Projetos" },
|
||||
{ href: "#modalities", label: "Contratacao" },
|
||||
{ href: "#contact", label: "Contato" },
|
||||
];
|
||||
const MENU_ID = "site-navigation-menu";
|
||||
|
||||
export function Header() {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const menuButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeMobileMenu = () => {
|
||||
setMobileMenuOpen(false);
|
||||
menuButtonRef.current?.focus();
|
||||
};
|
||||
|
||||
const handleMenuKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (event.key !== "Escape") return;
|
||||
|
||||
event.preventDefault();
|
||||
closeMobileMenu();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="ds-section-dark fixed inset-x-0 top-0 z-50 h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-6 backdrop-blur-xl lg:px-sp-16">
|
||||
<header className="ds-section-dark fixed inset-x-0 top-0 z-[9000] h-[52px] w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/95 px-sp-8 backdrop-blur-xl lg:px-sp-16">
|
||||
<div className="ds-section-shell flex h-full items-center justify-between">
|
||||
<a href="#home" className="flex items-center gap-3 no-underline">
|
||||
<a href="/#home" className="flex items-center gap-3 no-underline">
|
||||
<Logo variant="mark" size="sm" theme="dark" className="shrink-0" />
|
||||
<span className="font-display text-sm font-extrabold tracking-tight text-white">
|
||||
evandro<span className="text-[var(--slash)]">.dev.br</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav className="hidden items-center lg:flex">
|
||||
<nav
|
||||
aria-label="Navegação principal"
|
||||
className="site-header-nav items-center"
|
||||
>
|
||||
<ul className="flex list-none items-center gap-sp-2">
|
||||
{navItems.map((item) => (
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
@@ -45,24 +58,24 @@ export function Header() {
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-sp-4">
|
||||
<span className="ds-metric hidden rounded-full border border-white/10 px-[10px] py-1 text-white/40 lg:inline-flex">
|
||||
v1.0 / 2026
|
||||
</span>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/Profile.pdf"
|
||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
||||
className="ds-link ds-link-accent hidden rounded-sm font-semibold transition-shadow hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)] lg:inline-flex"
|
||||
href={CV_URL}
|
||||
download={CV_DOWNLOAD_NAME}
|
||||
className="site-header-cv ds-link ds-link-accent rounded-sm font-semibold transition-shadow hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)]"
|
||||
strength={0.2}
|
||||
>
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
<button
|
||||
ref={menuButtonRef}
|
||||
type="button"
|
||||
className="flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent lg:hidden"
|
||||
className="site-header-menu-toggle flex h-8 w-8 flex-col items-center justify-center gap-sp-2 border-none bg-transparent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Menu"
|
||||
aria-controls={mobileMenuOpen ? MENU_ID : undefined}
|
||||
aria-label={mobileMenuOpen ? "Fechar menu" : "Abrir menu"}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<span
|
||||
className={`h-0.5 w-5 bg-white transition-transform duration-300 ${mobileMenuOpen ? "translate-y-[6px] rotate-45" : ""}`}
|
||||
@@ -77,40 +90,46 @@ export function Header() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`absolute inset-x-0 top-[52px] overflow-hidden border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl transition-all duration-300 lg:hidden lg:px-sp-16 ${mobileMenuOpen ? "max-h-[420px] opacity-100" : "max-h-0 opacity-0"}`}
|
||||
>
|
||||
<div className="ds-section-shell flex flex-col py-sp-6">
|
||||
<nav className="mb-sp-6">
|
||||
<ul className="flex list-none flex-col gap-sp-2">
|
||||
{navItems.map((item) => (
|
||||
<li key={item.href}>
|
||||
{mobileMenuOpen ? (
|
||||
<div className="site-header-mobile-panel absolute inset-x-0 top-[52px] border-b border-[rgba(255,255,255,0.06)] bg-[var(--ink)]/98 px-sp-6 backdrop-blur-xl lg:px-sp-16">
|
||||
<div className="ds-section-shell flex flex-col py-sp-6">
|
||||
<nav
|
||||
id={MENU_ID}
|
||||
aria-hidden={!mobileMenuOpen}
|
||||
aria-label="Navegação mobile"
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
>
|
||||
<ul className="flex list-none flex-col gap-sp-2">
|
||||
{NAVIGATION_ITEMS.map((item) => (
|
||||
<li key={item.href}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={item.href}
|
||||
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-[padding,color] hover:pl-sp-2 hover:text-white"
|
||||
onClick={closeMobileMenu}
|
||||
strength={0.15}
|
||||
>
|
||||
{item.label}
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={item.href}
|
||||
className="ds-link block rounded-sm py-sp-3 text-white/85 transition-all hover:pl-sp-2 hover:text-white"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
href={CV_URL}
|
||||
download={CV_DOWNLOAD_NAME}
|
||||
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
|
||||
onClick={closeMobileMenu}
|
||||
strength={0.15}
|
||||
>
|
||||
{item.label}
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
</li>
|
||||
))}
|
||||
<li>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/Profile.pdf"
|
||||
download="Evandro_Fonseca_Junior_CV.pdf"
|
||||
className="ds-link ds-link-accent mt-sp-2 inline-flex rounded-sm font-semibold"
|
||||
strength={0.15}
|
||||
>
|
||||
Download CV
|
||||
</MagneticButton>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ jest.mock("next/dynamic", () => () => {
|
||||
return DynamicComponent;
|
||||
});
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
@@ -54,9 +54,29 @@ jest.mock("framer-motion", () => {
|
||||
motion,
|
||||
useInView: () => true,
|
||||
useReducedMotion: () => true,
|
||||
useMotionValue: (initial: number) => ({
|
||||
get: () => initial,
|
||||
set: jest.fn(),
|
||||
}),
|
||||
useSpring: (
|
||||
value: { get: () => number; set: (v: number) => void },
|
||||
_config: unknown,
|
||||
) => value,
|
||||
useTransform: (
|
||||
value: { get: () => number; set: (v: number) => void },
|
||||
_input: number[],
|
||||
_output: number[],
|
||||
) => value,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("cobe", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
|
||||
@@ -11,7 +11,7 @@ jest.mock("next/dynamic", () => () => {
|
||||
return DynamicComponent;
|
||||
});
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
@@ -40,9 +40,29 @@ jest.mock("framer-motion", () => {
|
||||
motion,
|
||||
useInView: () => true,
|
||||
useReducedMotion: () => false,
|
||||
useMotionValue: (initial: number) => ({
|
||||
get: () => initial,
|
||||
set: jest.fn(),
|
||||
}),
|
||||
useSpring: (
|
||||
value: { get: () => number; set: (v: number) => void },
|
||||
_config: unknown,
|
||||
) => value,
|
||||
useTransform: (
|
||||
value: { get: () => number; set: (v: number) => void },
|
||||
_input: number[],
|
||||
_output: number[],
|
||||
) => value,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock("cobe", () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
function mockMatchMedia(matches = false) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
@@ -92,7 +112,10 @@ describe("Hero", () => {
|
||||
expect(
|
||||
screen.getByRole("link", { name: /ver casos selecionados/i }),
|
||||
).toHaveAttribute("href", "/cases");
|
||||
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: /download cv/i })).toHaveAttribute(
|
||||
"href",
|
||||
"/Profile.pdf",
|
||||
);
|
||||
expect(screen.queryByTestId("hero-scene")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -109,7 +132,7 @@ describe("Hero", () => {
|
||||
expect(screen.getByLabelText("LinkedIn")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Email")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Operacao/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Operação/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Clareza/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/Resultado/i).length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
+137
-90
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useState } from "react";
|
||||
import { shouldLoadHeroScene } from "@/app/lib/should-load-hero-scene";
|
||||
import { buildWhatsAppUrl, getWhatsAppDisplayNumber } from "@/lib/whatsapp";
|
||||
import { CV_URL } from "@/lib/site-navigation";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
import { ControlRoomGlobe } from "./cinematic/ControlRoomGlobe";
|
||||
import MagneticButton from "./cinematic/MagneticButton";
|
||||
import { ParticleBuildText } from "./cinematic/ParticleBuildText";
|
||||
import { TelemetryStrip } from "./cinematic/TelemetryStrip";
|
||||
|
||||
const HeroScene = dynamic(() => import("./cinematic/HeroScene"), {
|
||||
ssr: false,
|
||||
@@ -94,16 +98,16 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Operacao",
|
||||
value: "Fluxo pensado para discovery, build, deploy e sustentacao.",
|
||||
label: "Operação",
|
||||
value: "Fluxo pensado para discovery, build, deploy e sustentação.",
|
||||
},
|
||||
{
|
||||
label: "Clareza",
|
||||
value: "Menos ruído entre produto, engenharia e decisao tecnica.",
|
||||
value: "Menos ruído entre produto, engenharia e decisão técnica.",
|
||||
},
|
||||
{
|
||||
label: "Resultado",
|
||||
value: "Base pronta para operar, evoluir e receber proximo ciclo.",
|
||||
value: "Base pronta para operar, evoluir e receber próximo ciclo.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -114,27 +118,27 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
layout: "visual-first",
|
||||
status: "Launch sequence ready",
|
||||
chip: "Discovery → build → deploy",
|
||||
eyebrow: "MVP, produto e entrega em producao",
|
||||
eyebrow: "MVP, produto e entrega em produção",
|
||||
headline:
|
||||
"Tire seu MVP do plano e coloque em producao com base tecnica real.",
|
||||
"Tire seu MVP do plano e coloque em produção com base técnica 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",
|
||||
"Transformo escopo solto em sequência de lançamento: o que validar, o que construir primeiro e como publicar sem criar uma dívida técnica logo no início.",
|
||||
cta: "Lançar meu MVP",
|
||||
whatsappMessage:
|
||||
"Ola! Quero lancar um MVP com uma base tecnica segura. Podemos conversar?",
|
||||
sideLabel: "Sequencia de lancamento",
|
||||
"Olá! Quero lançar um MVP com uma base técnica segura. Podemos conversar?",
|
||||
sideLabel: "Sequência de lançamento",
|
||||
sideTitle: "Do primeiro corte ao deploy.",
|
||||
sideText:
|
||||
"A conversa define prioridade, risco e o menor caminho confiavel para colocar a primeira versao no ar.",
|
||||
"A conversa define prioridade, risco e o menor caminho confiável para colocar a primeira versão no ar.",
|
||||
points: [
|
||||
"Recorte de MVP sem inflar escopo.",
|
||||
"Entrega incremental com criterio de producao.",
|
||||
"Deploy e continuidade pensados desde o inicio.",
|
||||
"Entrega incremental com critério de produção.",
|
||||
"Deploy e continuidade pensados desde o início.",
|
||||
],
|
||||
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." },
|
||||
{ label: "01", value: "Diagnóstico do objetivo e gargalo principal." },
|
||||
{ label: "02", value: "Plano de construção com risco visível." },
|
||||
{ label: "03", value: "Publicação e próximo ciclo sem perder contexto." },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -144,22 +148,22 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
layout: "split",
|
||||
status: "Rescue mode available",
|
||||
chip: "Legado, instabilidade e projeto travado",
|
||||
eyebrow: "Rescue tecnico para sistemas que precisam voltar a andar",
|
||||
eyebrow: "Rescue técnico para sistemas que precisam voltar a andar",
|
||||
headline:
|
||||
"Codigo confuso, entrega travada ou sistema instavel? Vamos organizar.",
|
||||
"Código confuso, entrega travada ou sistema instável? 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.",
|
||||
"Entro no ponto onde o projeto parou: leio o fluxo real, separo sintoma de causa e transformo caos técnico em plano executável.",
|
||||
cta: "Destravar meu projeto",
|
||||
whatsappMessage:
|
||||
"Ola! Tenho um projeto travado ou instavel e quero destravar. Podemos conversar?",
|
||||
sideLabel: "Antes / depois tecnico",
|
||||
"Olá! Tenho um projeto travado ou instável e quero destravar. Podemos conversar?",
|
||||
sideLabel: "Antes / depois técnico",
|
||||
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.",
|
||||
"O primeiro objetivo não é reescrever tudo. É 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.",
|
||||
"Leitura rápida de fluxo, risco e acoplamento.",
|
||||
"Priorização do que destrava sem reescrever tudo.",
|
||||
"Correções com critério para não trocar um bug por outro.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
@@ -167,8 +171,8 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
value: "Backlog confuso, bugs recorrentes e pouca previsibilidade.",
|
||||
},
|
||||
{
|
||||
label: "Intervencao",
|
||||
value: "Mapa de risco, cortes pequenos e decisao tecnica clara.",
|
||||
label: "Intervenção",
|
||||
value: "Mapa de risco, cortes pequenos e decisão técnica clara.",
|
||||
},
|
||||
{ label: "Depois", value: "Entrega volta a andar com menos improviso." },
|
||||
],
|
||||
@@ -179,23 +183,23 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
scene: "signal",
|
||||
layout: "default",
|
||||
status: "Signal map active",
|
||||
chip: "Hardware + software + operacao",
|
||||
eyebrow: "Produtos conectados, telemetria e integracoes criticas",
|
||||
chip: "Hardware + software + operação",
|
||||
eyebrow: "Produtos conectados, telemetria e integrações críticas",
|
||||
headline:
|
||||
"Conecte dispositivo, backend e interface em uma operacao confiavel.",
|
||||
"Conecte dispositivo, backend e interface em uma operação confiável.",
|
||||
subtitle:
|
||||
"Atuo na fronteira que costuma quebrar primeiro: sinal fisico, API, painel, dados e rotina operacional funcionando como um unico produto.",
|
||||
"Atuo na fronteira que costuma quebrar primeiro: sinal físico, API, painel, dados e rotina operacional funcionando como um único produto.",
|
||||
cta: "Conectar meu produto",
|
||||
whatsappMessage:
|
||||
"Ola! Quero conectar hardware, software e operacao em um produto confiavel. Podemos conversar?",
|
||||
"Olá! Quero conectar hardware, software e operação em um produto confiável. 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.",
|
||||
"A conversa identifica fontes de dados, contratos, pontos de falha e o caminho para transformar integração em produto.",
|
||||
points: [
|
||||
"Contratos claros entre dispositivo, API e interface.",
|
||||
"Telemetria e dashboards com contexto operacional.",
|
||||
"Base pronta para automacao, IA ou analytics.",
|
||||
"Base pronta para automação, IA ou analytics.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
@@ -207,8 +211,8 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
value: "APIs, WebSockets, filas, banco e observabilidade.",
|
||||
},
|
||||
{
|
||||
label: "Saida",
|
||||
value: "Painel, operacao, alerta e decisao de produto.",
|
||||
label: "Saída",
|
||||
value: "Painel, operação, alerta e decisão de produto.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -218,22 +222,22 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
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.",
|
||||
chip: "Arquitetura antes de mais código",
|
||||
eyebrow: "Diagnóstico técnico para decidir melhor",
|
||||
headline: "Antes de escrever mais código, descubra o próximo 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",
|
||||
"Quando a base cresce sem clareza, mais features podem piorar o problema. Eu ajudo a mapear risco, arquitetura e prioridades antes da próxima decisão cara.",
|
||||
cta: "Agendar diagnóstico",
|
||||
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.",
|
||||
"Olá! Quero um diagnóstico técnico para decidir o próximo passo do meu sistema. Podemos conversar?",
|
||||
sideLabel: "Relatório técnico",
|
||||
sideTitle: "Risco, arquitetura e rota de ação.",
|
||||
sideText:
|
||||
"Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho tecnico.",
|
||||
"Ideal para produto que precisa escalar, migrar, estabilizar ou parar de acumular retrabalho técnico.",
|
||||
points: [
|
||||
"Mapa claro dos gargalos e dependencias.",
|
||||
"Trade-offs traduzidos para decisao de negocio.",
|
||||
"Plano de transicao sem promessa magica.",
|
||||
"Mapa claro dos gargalos e dependências.",
|
||||
"Trade-offs traduzidos para decisão de negócio.",
|
||||
"Plano de transição sem promessa mágica.",
|
||||
],
|
||||
proof: [
|
||||
{ label: "Risco", value: "O que pode quebrar, atrasar ou custar caro." },
|
||||
@@ -241,7 +245,7 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
label: "Arquitetura",
|
||||
value: "Onde simplificar, separar ou estabilizar primeiro.",
|
||||
},
|
||||
{ label: "Acao", value: "Proximo corte de implementacao com criterio." },
|
||||
{ label: "Ação", value: "Próximo corte de implementação com critério." },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -251,34 +255,34 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
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.",
|
||||
eyebrow: "Engenharia sênior, ownership e execução",
|
||||
headline: "Um parceiro técnico 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.",
|
||||
"Você não precisa apenas de tela bonita ou código isolado. Precisa de alguém que entenda contexto, escolha o corte certo e acompanhe até o sistema operar.",
|
||||
cta: "Falar com Evandro",
|
||||
whatsappMessage:
|
||||
"Ola Evandro! Quero conversar sobre um projeto e entender como podemos colocar esse sistema no ar.",
|
||||
"Olá 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.",
|
||||
"A conversa é comigo: contexto técnico, prioridade, risco e caminho de entrega sem perder informação entre camadas.",
|
||||
points: [
|
||||
"Atendimento consultivo com leitura tecnica real.",
|
||||
"Atendimento consultivo com leitura técnica real.",
|
||||
"Do produto ao backend sem perder contexto.",
|
||||
"Execucao com criterio, nao so codigo por demanda.",
|
||||
"Execução com critério, não só código por demanda.",
|
||||
],
|
||||
proof: [
|
||||
{
|
||||
label: "Perfil",
|
||||
value: "Engenharia full-stack com base em sistemas e integracoes.",
|
||||
value: "Engenharia full-stack com base em sistemas e integrações.",
|
||||
},
|
||||
{
|
||||
label: "Postura",
|
||||
value: "Clareza, autonomia e decisao tecnica pragmatica.",
|
||||
value: "Clareza, autonomia e decisão técnica pragmática.",
|
||||
},
|
||||
{
|
||||
label: "Acesso",
|
||||
value: "WhatsApp direto para conversa tecnica inicial.",
|
||||
value: "WhatsApp direto para conversa técnica inicial.",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -286,14 +290,15 @@ const HERO_VARIANTS: HeroVariant[] = [
|
||||
|
||||
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",
|
||||
"grid grid-cols-[minmax(0,1fr)] 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",
|
||||
"grid grid-cols-[minmax(0,1fr)] items-start gap-sp-8 lg:grid-cols-[minmax(340px,0.9fr)_minmax(0,1.1fr)] lg:gap-sp-10",
|
||||
split:
|
||||
"grid grid-cols-[minmax(0,1fr)] 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",
|
||||
"grid grid-cols-[minmax(0,1fr)] 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",
|
||||
"grid grid-cols-[minmax(0,1fr)] items-center gap-sp-8 lg:grid-cols-[minmax(0,1fr)_minmax(320px,0.72fr)] lg:gap-sp-10",
|
||||
};
|
||||
|
||||
function getGreeting(message: string) {
|
||||
@@ -303,7 +308,25 @@ function getGreeting(message: string) {
|
||||
return message;
|
||||
}
|
||||
|
||||
function SideVisual({ variant }: { variant: HeroVariant }) {
|
||||
function SideVisual({
|
||||
variant,
|
||||
shouldReduceMotion,
|
||||
}: {
|
||||
variant: HeroVariant;
|
||||
shouldReduceMotion: boolean;
|
||||
}) {
|
||||
if (variant.scene === "command") {
|
||||
return (
|
||||
<div className="mb-sp-6 space-y-sp-4">
|
||||
{/* Partículas decorativas atrás da cena: o h1 real permanece o conteúdo acessível */}
|
||||
<ParticleBuildText text={variant.headline} />
|
||||
{/* autoRotate off em reduced-motion: globo vira âncora estática */}
|
||||
<ControlRoomGlobe autoRotate={!shouldReduceMotion} />
|
||||
<TelemetryStrip reducedMotion={shouldReduceMotion} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant.scene === "launch") {
|
||||
return (
|
||||
<div className="mb-sp-6 grid gap-sp-3">
|
||||
@@ -355,7 +378,11 @@ function SideVisual({ variant }: { variant: HeroVariant }) {
|
||||
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">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
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"
|
||||
@@ -430,7 +457,6 @@ export default function Hero() {
|
||||
const variant = HERO_VARIANTS[0];
|
||||
const [shouldReduceMotion, setShouldReduceMotion] = useState(false);
|
||||
const [shouldRenderHeroScene, setShouldRenderHeroScene] = useState(false);
|
||||
const [whatsAppDisplay, setWhatsAppDisplay] = useState("WhatsApp");
|
||||
|
||||
useEffect(() => {
|
||||
const reducedMotion = window.matchMedia(
|
||||
@@ -438,7 +464,6 @@ export default function Hero() {
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
setShouldReduceMotion(reducedMotion);
|
||||
setWhatsAppDisplay(getWhatsAppDisplayNumber());
|
||||
|
||||
if (
|
||||
!shouldLoadHeroScene({
|
||||
@@ -524,7 +549,8 @@ export default function Hero() {
|
||||
/>
|
||||
<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">
|
||||
{/* pt >= altura do header fixo (52px): conteúdo do hero nunca nasce sob a barra */}
|
||||
<div className="relative z-10 mx-auto flex w-full max-w-[1240px] flex-col px-sp-6 pt-sp-16 pb-sp-16 lg:max-h-none lg:px-sp-16 lg:pt-sp-14 lg:pb-sp-12">
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
@@ -578,7 +604,7 @@ export default function Hero() {
|
||||
</motion.p>
|
||||
|
||||
<motion.h1
|
||||
className="max-w-[940px] text-balance font-display text-[clamp(3rem,7vw,6.2rem)] font-extrabold leading-[0.9] tracking-[-0.055em]"
|
||||
className="max-w-[940px] font-display text-[clamp(1.9rem,5.2vw,4.75rem)] font-extrabold leading-[0.95] tracking-[-0.055em] lg:leading-[0.9] lg:text-balance"
|
||||
variants={heroItemSlow}
|
||||
>
|
||||
{variant.headline}
|
||||
@@ -644,7 +670,7 @@ export default function Hero() {
|
||||
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/assets/Evandro_Fonseca_Junior_CV.pdf"
|
||||
href={CV_URL}
|
||||
download
|
||||
className="inline-flex min-h-12 items-center justify-center px-sp-2 text-[12px] font-medium text-[rgba(244,242,237,.76)] no-underline transition-colors duration-300 hover:text-[var(--slash)]"
|
||||
strength={0.2}
|
||||
@@ -719,7 +745,10 @@ export default function Hero() {
|
||||
transition={{ duration: 1.5, delay: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||
/>
|
||||
|
||||
<SideVisual variant={variant} />
|
||||
<SideVisual
|
||||
variant={variant}
|
||||
shouldReduceMotion={shouldReduceMotion}
|
||||
/>
|
||||
|
||||
<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)]">
|
||||
@@ -736,7 +765,16 @@ export default function Hero() {
|
||||
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}
|
||||
{/* Ícone WhatsApp inline: lucide não tem brand icons */}
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="h-4 w-4"
|
||||
>
|
||||
<path d="M12.04 2a9.88 9.88 0 0 0-8.41 4.7A9.88 9.88 0 0 0 2.1 12.6a9.8 9.8 0 0 0 1.34 5.02L2 22l4.53-1.38a9.86 9.86 0 0 0 5.51 1.66h.01a9.87 9.87 0 0 0 7.02-2.9 9.79 9.79 0 0 0 2.89-6.98 9.73 9.73 0 0 0-.29-2.35A9.88 9.88 0 0 0 12.04 2Zm5.77 13.94c-.24.68-1.42 1.3-1.96 1.35-.5.05-1.13.24-3.8-.98-3.18-1.44-5.15-4.72-5.31-4.93-.15-.22-1.25-1.75-1.16-3.29.09-1.54.92-2.28 1.23-2.59.31-.31.66-.37.88-.36h.63c.2 0 .47-.03.71.58.26.65.88 2.25.96 2.41.08.17.13.36.02.57-.11.22-.17.35-.33.54-.17.19-.35.43-.5.58-.16.16-.33.34-.15.66.18.32.81 1.42 1.75 2.3 1.2 1.14 2.21 1.49 2.52 1.66.31.17.5.14.68-.09.18-.22.78-.99.99-1.33.21-.34.42-.27.7-.16.29.11 1.82.95 2.13 1.12.31.17.52.26.6.4.07.15.07.83-.17 1.51Z" />
|
||||
</svg>
|
||||
Agendar no WhatsApp
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -776,20 +814,29 @@ export default function Hero() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 2, duration: 1 }}
|
||||
>
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-2"
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</motion.div>
|
||||
{shouldReduceMotion ? (
|
||||
<div className="flex flex-col items-center gap-2" aria-hidden="true">
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</div>
|
||||
) : (
|
||||
<motion.div
|
||||
className="flex flex-col items-center gap-2"
|
||||
animate={{ y: [0, 8, 0] }}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<span className="font-mono text-[8px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.4)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-8 w-px bg-gradient-to-b from-[var(--slash)] to-transparent opacity-50" />
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { Section } from "./Section";
|
||||
|
||||
@@ -48,7 +48,9 @@ export default function LighthouseMetrics() {
|
||||
<motion.article
|
||||
key={metric.label}
|
||||
className="ds-card flex flex-col items-center gap-sp-6 text-center lg:items-start lg:text-left"
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }
|
||||
}
|
||||
whileInView={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface LogoProps {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioProject } from "@/lib/portfolio-data";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
@@ -42,8 +42,8 @@ export default function Projects({
|
||||
<Section
|
||||
id="projects"
|
||||
eyebrow="Selected work"
|
||||
title="Projetos em Next.js, Tauri, Rust e integracao de sistemas"
|
||||
description="Cases e produtos que mostram minha atuacao em engenharia de software, consultoria tecnica e construcao de sistemas de alta exigencia."
|
||||
title="Projetos em Next.js, Tauri, Rust e integração de sistemas"
|
||||
description="Cases e produtos que mostram minha atuação em engenharia de software, consultoria técnica e construção de sistemas de alta exigência."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import type React from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioSkillCategory } from "@/lib/portfolio-data";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
@@ -41,8 +41,8 @@ export default function Skills({
|
||||
<Section
|
||||
id="skills"
|
||||
eyebrow="Core stack"
|
||||
title="Next.js, Tauri, Rust e stack para produtos criticos"
|
||||
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integracoes complexas."
|
||||
title="Next.js, Tauri, Rust e stack para produtos críticos"
|
||||
description="Tecnologias e fundamentos que uso em engenharia de software, arquitetura de software, IoT e integrações complexas."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants } from "framer-motion";
|
||||
import { motion, type Variants } from "motion/react";
|
||||
import Link from "next/link";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { serviceHubLinks } from "@/lib/site-content";
|
||||
@@ -28,20 +28,20 @@ const TIERS: Tier[] = [
|
||||
id: "discovery",
|
||||
badge: "Entrada / Baixo Risco",
|
||||
level: "01",
|
||||
title: "Auditoria & Diagnostico Tecnico",
|
||||
title: "Auditoria & Diagnóstico Técnico",
|
||||
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
|
||||
description:
|
||||
"Analise profunda de arquitetura, performance ou seguranca com entrega de um plano de acao executavel. Ideal para quem precisa de direcao antes de contratar execucao.",
|
||||
"Análise profunda de arquitetura, performance ou segurança com entrega de um plano de ação executável. Ideal para quem precisa de direção antes de contratar execução.",
|
||||
deliverables: [
|
||||
"Relatorio de gargalos e divida tecnica",
|
||||
"Mapeamento de riscos e dependencias",
|
||||
"Sugestao de stack e cronograma de mitigacao",
|
||||
"Plano de acao priorizado",
|
||||
"Relatório de gargalos e dívida técnica",
|
||||
"Mapeamento de riscos e dependências",
|
||||
"Sugestão de stack e cronograma de mitigação",
|
||||
"Plano de ação priorizado",
|
||||
],
|
||||
pricing: "Valor Fixo",
|
||||
pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.",
|
||||
pricingNote: "Contrato único. Entrega em até 5 dias úteis.",
|
||||
featured: false,
|
||||
ctaLabel: "Solicitar diagnostico",
|
||||
ctaLabel: "Solicitar diagnóstico",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
{
|
||||
@@ -51,12 +51,12 @@ const TIERS: Tier[] = [
|
||||
title: "Ciclos de Engenharia",
|
||||
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
|
||||
description:
|
||||
"Desenvolvimento continuo com a previsibilidade de orcamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
|
||||
"Desenvolvimento contínuo com a previsibilidade de orçamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
|
||||
deliverables: [
|
||||
"Features em producao a cada ciclo",
|
||||
"Refatoracao iterativa com rastreabilidade",
|
||||
"Documentacao tecnica atualizada",
|
||||
"Capacidade de pivotar prioridades no inicio de cada sprint",
|
||||
"Features em produção a cada ciclo",
|
||||
"Refatoração iterativa com rastreabilidade",
|
||||
"Documentação técnica atualizada",
|
||||
"Capacidade de pivotar prioridades no início de cada sprint",
|
||||
],
|
||||
pricing: "Fixo por Ciclo",
|
||||
pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.",
|
||||
@@ -69,19 +69,19 @@ const TIERS: Tier[] = [
|
||||
badge: "Premium / High-Ticket",
|
||||
level: "03",
|
||||
title: "Fractional Tech Lead",
|
||||
tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.",
|
||||
tagline: "Liderança técnica sênior. Sem o custo de um CTO full-time.",
|
||||
description:
|
||||
"Direcao arquitetural, gestao de risco e sustentacao de operacoes criticas. Para empresas que precisam de decisoes de alto impacto com responsabilidade continua.",
|
||||
"Direção arquitetural, gestão de risco e sustentação de operações críticas. Para empresas que precisam de decisões de alto impacto com responsabilidade contínua.",
|
||||
deliverables: [
|
||||
"SLA prioritario Zero-Risk",
|
||||
"SLA prioritário Zero-Risk",
|
||||
"Code review e lideranca do time interno",
|
||||
"Decisoes de infra: Cloud, VPS, CI/CD",
|
||||
"Consultoria estrategica recorrente",
|
||||
],
|
||||
pricing: "Retainer Mensal",
|
||||
pricingNote: "Renovacao mensal. Vagas limitadas.",
|
||||
pricingNote: "Renovação mensal. Vagas limitadas.",
|
||||
featured: false,
|
||||
ctaLabel: "Agendar conversa estrategica",
|
||||
ctaLabel: "Agendar conversa estratégica",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
];
|
||||
@@ -120,7 +120,7 @@ function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
<DrawLine delay={0.04} />
|
||||
|
||||
<div className="mt-sp-4 min-w-0">
|
||||
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(22px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
|
||||
<h3 className="ds-card-title !mt-0 text-balance !text-[clamp(19px,5vw,28px)] !leading-[1.02] !tracking-[-0.035em] [overflow-wrap:break-word] [word-break:normal]">
|
||||
{tier.title}
|
||||
</h3>
|
||||
<p className="mt-sp-2 text-[12px] leading-[1.7] text-[var(--meta)] md:text-[11px]">
|
||||
@@ -194,22 +194,30 @@ export function WorkModalities() {
|
||||
<Section
|
||||
id="modalities"
|
||||
eyebrow="Working models"
|
||||
title="Servicos de engenharia de software e consultoria tecnica."
|
||||
description="Formatos de contratacao para arquitetura de software, integracoes criticas, produtos conectados e evolucao de sistemas em producao."
|
||||
title="Serviços de engenharia de software e consultoria técnica."
|
||||
description="Formatos de contratação para arquitetura de software, integrações críticas, produtos conectados e evolução de sistemas em produção."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="mb-sp-8 ds-card bg-[rgba(10,10,10,0.02)]">
|
||||
<div className="flex flex-col gap-sp-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-[760px]">
|
||||
<div className="ds-kicker">Paginas de servico</div>
|
||||
<h3 className="ds-card-title !mt-sp-3">Especialidades organizadas por intencao de busca</h3>
|
||||
<div className="ds-kicker">Páginas de serviço</div>
|
||||
<h3 className="ds-card-title !mt-sp-3">
|
||||
Especialidades organizadas por intenção de busca
|
||||
</h3>
|
||||
<p className="ds-copy !mt-sp-3 max-w-[62ch]">
|
||||
Se voce chegou buscando uma stack, um tipo de consultoria ou um contexto tecnico especifico,
|
||||
aqui esta o caminho mais direto para a pagina certa.
|
||||
Se você chegou buscando uma stack, um tipo de consultoria ou um
|
||||
contexto técnico específico, aqui está o caminho mais direto para
|
||||
a página certa.
|
||||
</p>
|
||||
</div>
|
||||
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-accent" strength={0.2}>
|
||||
Ver hub de servicos
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/servicos"
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.2}
|
||||
>
|
||||
Ver hub de serviços
|
||||
</MagneticButton>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useInView } from "framer-motion";
|
||||
import { useInView } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CounterValue({
|
||||
const frame = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const eased = 1 - (1 - progress) ** 3;
|
||||
setDisplayValue(Math.round(value * eased));
|
||||
|
||||
if (progress < 1) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import Image, { type StaticImageData } from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
@@ -27,9 +27,7 @@ export function LazyRevealImage({
|
||||
<motion.div
|
||||
className={`relative ${className}`.trim()}
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, scale: 1.02 }
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20, scale: 1.02 }
|
||||
}
|
||||
animate={
|
||||
loaded
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TextDiffuseReveal } from "./TextDiffuseReveal";
|
||||
let mockReducedMotion = false;
|
||||
let mockInView = true;
|
||||
let mockAnimations: Array<{ animate?: Record<string, unknown> }> = [];
|
||||
jest.mock("framer-motion", () => {
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useInView } from "framer-motion";
|
||||
import { motion, useInView } from "motion/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
interface TopProgressBarProps {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React from "react";
|
||||
export default function PointCloudScene() {
|
||||
return <div data-testid="point-cloud-mock"></div>;
|
||||
}
|
||||
@@ -1,21 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import PageIntro from "./PageIntro";
|
||||
|
||||
const CustomCursor = dynamic(() => import("./CustomCursor"), { ssr: false });
|
||||
const FilmGrain = dynamic(() => import("./FilmGrain"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), { ssr: false });
|
||||
const HomeButton = dynamic(() => import("./HomeButton"), { ssr: false });
|
||||
const ScrollProgress = dynamic(() => import("./ScrollProgress"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default function CinematicOverlays() {
|
||||
const pathname = usePathname();
|
||||
const isHome = pathname === "/";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageIntro />
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
<HomeButton />
|
||||
{isHome ? (
|
||||
<>
|
||||
<CustomCursor />
|
||||
<FilmGrain />
|
||||
<ScrollProgress />
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, type ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicSectionProps {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface CinematicTextProps {
|
||||
text: string;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MOVEMENT_DAMPING = 1400;
|
||||
|
||||
const JOINVILLE_LAT = -26.3044;
|
||||
const JOINVILLE_LNG = -48.8456;
|
||||
// cobe espera radianos: theta = latitude da banda de frente (Trópico de Capricórnio), phi = longitude de frente (Joinville)
|
||||
const TROPIC_OF_CAPRICORN_RAD = (-23.4364 * Math.PI) / 180;
|
||||
const JOINVILLE_LNG_RAD = (JOINVILLE_LNG * Math.PI) / 180;
|
||||
|
||||
const CONTROL_ROOM_GLOBE_CONFIG: COBEOptions = {
|
||||
width: 800,
|
||||
height: 800,
|
||||
onRender: () => {},
|
||||
devicePixelRatio: 2,
|
||||
phi: JOINVILLE_LNG_RAD,
|
||||
theta: TROPIC_OF_CAPRICORN_RAD,
|
||||
dark: 0,
|
||||
diffuse: 1.2,
|
||||
mapSamples: 16000,
|
||||
mapBrightness: 6,
|
||||
baseColor: [0.32, 0.32, 0.32],
|
||||
markerColor: [204 / 255, 255 / 255, 0 / 255],
|
||||
glowColor: [204 / 255, 255 / 255, 0 / 255],
|
||||
markers: [
|
||||
{ location: [JOINVILLE_LAT, JOINVILLE_LNG], size: 0.12 },
|
||||
{ location: [-25.4284, -49.2733], size: 0.06 },
|
||||
{ location: [-26.0167, -48.5], size: 0.05 },
|
||||
{ location: [-25.9333, -48.7833], size: 0.04 },
|
||||
],
|
||||
} as const satisfies COBEOptions;
|
||||
|
||||
export function ControlRoomGlobe({
|
||||
className,
|
||||
config = CONTROL_ROOM_GLOBE_CONFIG,
|
||||
autoRotate = true,
|
||||
rotationSpeed = 0.003,
|
||||
}: {
|
||||
className?: string;
|
||||
config?: COBEOptions;
|
||||
autoRotate?: boolean;
|
||||
rotationSpeed?: number;
|
||||
}) {
|
||||
const [width, setWidth] = useState(0);
|
||||
const phiRef = useRef(config.phi ?? JOINVILLE_LNG_RAD);
|
||||
// Rotação livre: oscilação de theta (inclinação multi-eixo) + arraste vertical
|
||||
const thetaPhaseRef = useRef(0);
|
||||
const thetaDragRef = useRef(0);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const pointerInteracting = useRef<number | null>(null);
|
||||
const pointerYOrigin = useRef<number | null>(null);
|
||||
const isVisibleRef = useRef(true);
|
||||
|
||||
const r = useMotionValue(0);
|
||||
const rs = useSpring(r, {
|
||||
mass: 1,
|
||||
damping: 30,
|
||||
stiffness: 100,
|
||||
});
|
||||
|
||||
const updatePointerInteraction = (value: number | null) => {
|
||||
pointerInteracting.current = value;
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.cursor = value !== null ? "grabbing" : "grab";
|
||||
}
|
||||
};
|
||||
|
||||
const updateMovement = (clientX: number, clientY?: number) => {
|
||||
if (pointerInteracting.current !== null) {
|
||||
const delta = clientX - pointerInteracting.current;
|
||||
r.set(r.get() + delta / MOVEMENT_DAMPING);
|
||||
// Eixo vertical: arrasto inclina o globo (limitado p/ não virar de cabeça pra baixo)
|
||||
if (clientY !== undefined && pointerYOrigin.current !== null) {
|
||||
const deltaY = clientY - pointerYOrigin.current;
|
||||
thetaDragRef.current = Math.max(
|
||||
-0.9,
|
||||
Math.min(0.9, thetaDragRef.current + deltaY / MOVEMENT_DAMPING),
|
||||
);
|
||||
pointerYOrigin.current = clientY;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
if (canvasRef.current) {
|
||||
setWidth(canvasRef.current.offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
isVisibleRef.current = entry.isIntersecting;
|
||||
},
|
||||
{ rootMargin: "100px" },
|
||||
);
|
||||
|
||||
if (canvasRef.current) {
|
||||
observer.observe(canvasRef.current);
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
isVisibleRef.current = !document.hidden;
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener(
|
||||
"visibilitychange",
|
||||
handleVisibilityChange,
|
||||
);
|
||||
observer.disconnect();
|
||||
};
|
||||
}
|
||||
const globe = createGlobe(canvas, {
|
||||
...config,
|
||||
width: width * 2,
|
||||
height: width * 2,
|
||||
onRender: (state) => {
|
||||
if (!isVisibleRef.current || document.hidden) return;
|
||||
if (autoRotate && !pointerInteracting.current) {
|
||||
phiRef.current += rotationSpeed;
|
||||
thetaPhaseRef.current += rotationSpeed * 0.9;
|
||||
}
|
||||
state.phi = phiRef.current + rs.get();
|
||||
// Rotação livre em todos os eixos: spin (phi) + guinada senoidal (theta) + inclinação por arraste vertical
|
||||
state.theta =
|
||||
(config.theta ?? TROPIC_OF_CAPRICORN_RAD) +
|
||||
Math.sin(thetaPhaseRef.current) * 0.35 +
|
||||
thetaDragRef.current;
|
||||
state.width = width * 2;
|
||||
state.height = width * 2;
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (canvasRef.current) {
|
||||
canvasRef.current.style.opacity = "1";
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
globe.destroy();
|
||||
window.removeEventListener("resize", onResize);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [rs, config, autoRotate, rotationSpeed, width]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto aspect-[1/1] w-full max-w-[520px]",
|
||||
className,
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<canvas
|
||||
className={cn(
|
||||
"size-full opacity-0 transition-opacity duration-700 [contain:layout_paint_size]",
|
||||
)}
|
||||
ref={canvasRef}
|
||||
onPointerDown={(e) => {
|
||||
pointerInteracting.current = e.clientX;
|
||||
pointerYOrigin.current = e.clientY;
|
||||
updatePointerInteraction(e.clientX);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
updatePointerInteraction(null);
|
||||
pointerYOrigin.current = null;
|
||||
}}
|
||||
onPointerOut={() => updatePointerInteraction(null)}
|
||||
onPointerLeave={() => {
|
||||
updatePointerInteraction(null);
|
||||
pointerYOrigin.current = null;
|
||||
}}
|
||||
onMouseMove={(e) => updateMovement(e.clientX, e.clientY)}
|
||||
onTouchMove={(e) =>
|
||||
e.touches[0] &&
|
||||
updateMovement(e.touches[0].clientX, e.touches[0].clientY)
|
||||
}
|
||||
/>
|
||||
<span className="sr-only">
|
||||
Globo interativo centrado em Joinville, Santa Catarina, com marcadores
|
||||
indicando presença operacional na região Norte de SC e Curitiba.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { motion, useMotionValue } from "framer-motion";
|
||||
import { motion, useMotionValue } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function CustomCursor() {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
@@ -9,6 +9,12 @@ export default function FilmGrain() {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
if (reducedMotion || coarsePointer) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
@@ -57,9 +63,8 @@ export default function FilmGrain() {
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
className="film-grain pointer-events-none fixed inset-0 z-[9990] h-full w-full opacity-[0.35] mix-blend-overlay"
|
||||
style={{ imageRendering: "pixelated" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useMemo } from "react";
|
||||
import {
|
||||
Float,
|
||||
MeshDistortMaterial,
|
||||
MeshTransmissionMaterial,
|
||||
} from "@react-three/drei";
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Float, MeshDistortMaterial, MeshTransmissionMaterial } from "@react-three/drei";
|
||||
import { useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
function GlassSphere({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
|
||||
function GlassSphere({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
@@ -32,7 +42,13 @@ function GlassSphere({ position, scale = 1 }: { position: [number, number, numbe
|
||||
);
|
||||
}
|
||||
|
||||
function WireframeRing({ position, scale = 1 }: { position: [number, number, number]; scale?: number }) {
|
||||
function WireframeRing({
|
||||
position,
|
||||
scale = 1,
|
||||
}: {
|
||||
position: [number, number, number];
|
||||
scale?: number;
|
||||
}) {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
useFrame((state) => {
|
||||
@@ -80,7 +96,9 @@ function DataStream({ count = 50 }: { count?: number }) {
|
||||
const t = state.clock.elapsedTime;
|
||||
pointsRef.current.rotation.y = t * 0.05;
|
||||
|
||||
const posAttr = pointsRef.current.geometry.getAttribute("position") as THREE.BufferAttribute;
|
||||
const posAttr = pointsRef.current.geometry.getAttribute(
|
||||
"position",
|
||||
) as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
@@ -127,7 +145,11 @@ export default function FloatingElements({
|
||||
<Canvas
|
||||
camera={{ position: [0, 0, 6], fov: 50 }}
|
||||
dpr={[1, 1.5]}
|
||||
gl={{ antialias: false, alpha: true, powerPreference: "high-performance" }}
|
||||
gl={{
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
style={{ background: "transparent" }}
|
||||
>
|
||||
<ambientLight intensity={0.2} />
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Float, MeshDistortMaterial } from "@react-three/drei";
|
||||
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
type HeroSceneVariant =
|
||||
@@ -13,236 +12,6 @@ type HeroSceneVariant =
|
||||
| "diagnostic"
|
||||
| "portrait";
|
||||
|
||||
function ParticleField({ count = 2000 }: { count?: number }) {
|
||||
const mesh = useRef<THREE.Points>(null);
|
||||
const mouseRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
const [positions, velocities] = useMemo(() => {
|
||||
const pos = new Float32Array(count * 3);
|
||||
const vel = new Float32Array(count * 3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
pos[i3] = (Math.random() - 0.5) * 20;
|
||||
pos[i3 + 1] = (Math.random() - 0.5) * 20;
|
||||
pos[i3 + 2] = (Math.random() - 0.5) * 15;
|
||||
vel[i3] = (Math.random() - 0.5) * 0.002;
|
||||
vel[i3 + 1] = (Math.random() - 0.5) * 0.002;
|
||||
vel[i3 + 2] = (Math.random() - 0.5) * 0.001;
|
||||
}
|
||||
return [pos, vel];
|
||||
}, [count]);
|
||||
|
||||
const sizes = useMemo(() => {
|
||||
const s = new Float32Array(count);
|
||||
for (let i = 0; i < count; i++) {
|
||||
s[i] = Math.random() * 2 + 0.5;
|
||||
}
|
||||
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;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useFrame((state) => {
|
||||
if (!mesh.current) return;
|
||||
const geo = mesh.current.geometry;
|
||||
const posAttr = geo.getAttribute("position") as THREE.BufferAttribute;
|
||||
const arr = posAttr.array as Float32Array;
|
||||
const time = state.clock.elapsedTime;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const i3 = i * 3;
|
||||
arr[i3] += velocities[i3] + Math.sin(time * 0.3 + i) * 0.001;
|
||||
arr[i3 + 1] += velocities[i3 + 1] + Math.cos(time * 0.2 + i) * 0.001;
|
||||
arr[i3 + 2] += velocities[i3 + 2];
|
||||
|
||||
// Mouse influence
|
||||
const dx = mouseRef.current.x * 5 - arr[i3];
|
||||
const dy = mouseRef.current.y * 5 - arr[i3 + 1];
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 3) {
|
||||
const force = (3 - dist) * 0.0003;
|
||||
arr[i3] += dx * force;
|
||||
arr[i3 + 1] += dy * force;
|
||||
}
|
||||
|
||||
// Wrap around
|
||||
if (arr[i3] > 10) arr[i3] = -10;
|
||||
if (arr[i3] < -10) arr[i3] = 10;
|
||||
if (arr[i3 + 1] > 10) arr[i3 + 1] = -10;
|
||||
if (arr[i3 + 1] < -10) arr[i3 + 1] = 10;
|
||||
if (arr[i3 + 2] > 7.5) arr[i3 + 2] = -7.5;
|
||||
if (arr[i3 + 2] < -7.5) arr[i3 + 2] = 7.5;
|
||||
}
|
||||
posAttr.needsUpdate = true;
|
||||
|
||||
// Slow rotation
|
||||
mesh.current.rotation.y = time * 0.02;
|
||||
mesh.current.rotation.x = Math.sin(time * 0.01) * 0.1;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove);
|
||||
}, [handlePointerMove]);
|
||||
|
||||
return (
|
||||
<points ref={mesh}>
|
||||
<bufferGeometry>
|
||||
<bufferAttribute
|
||||
attach="attributes-position"
|
||||
args={[positions, 3]}
|
||||
count={count}
|
||||
/>
|
||||
<bufferAttribute
|
||||
attach="attributes-size"
|
||||
args={[sizes, 1]}
|
||||
count={count}
|
||||
/>
|
||||
</bufferGeometry>
|
||||
<pointsMaterial
|
||||
color="#ccff00"
|
||||
size={0.03}
|
||||
transparent
|
||||
opacity={0.6}
|
||||
sizeAttenuation
|
||||
blending={THREE.AdditiveBlending}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</points>
|
||||
);
|
||||
}
|
||||
|
||||
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 = 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 = layout.b[1] + Math.cos(t * 0.3) * 0.5;
|
||||
}
|
||||
if (mesh3Ref.current) {
|
||||
mesh3Ref.current.rotation.y = t * 0.08;
|
||||
mesh3Ref.current.rotation.z = t * 0.06;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Float speed={1.5} rotationIntensity={0.4} floatIntensity={0.6}>
|
||||
<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={variant === "portrait" ? 0.1 : 0.15}
|
||||
distort={0.3}
|
||||
speed={2}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
|
||||
<Float speed={1.2} rotationIntensity={0.3} floatIntensity={0.8}>
|
||||
<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
|
||||
transparent
|
||||
opacity={0.08}
|
||||
distort={0.2}
|
||||
speed={1.5}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
|
||||
<Float speed={0.8} rotationIntensity={0.2} floatIntensity={0.5}>
|
||||
<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
|
||||
transparent
|
||||
opacity={0.06}
|
||||
/>
|
||||
</mesh>
|
||||
</Float>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GridFloor() {
|
||||
const meshRef = useRef<THREE.Mesh>(null);
|
||||
|
||||
@@ -334,11 +103,8 @@ function CameraRig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function HeroScene({
|
||||
variant = "command",
|
||||
}: {
|
||||
variant?: HeroSceneVariant;
|
||||
}) {
|
||||
export default function HeroScene({ variant }: { variant?: HeroSceneVariant }) {
|
||||
void variant;
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0"
|
||||
@@ -362,18 +128,16 @@ export default function HeroScene({
|
||||
display: "block",
|
||||
}}
|
||||
>
|
||||
<fog attach="fog" args={["#0a0a0a", 5, 25]} />
|
||||
<ambientLight intensity={0.3} />
|
||||
<fog attach="fog" args={["#0a0a0a", 8, 30]} />
|
||||
<ambientLight intensity={0.25} />
|
||||
<directionalLight
|
||||
position={[5, 5, 5]}
|
||||
intensity={0.5}
|
||||
intensity={0.4}
|
||||
color="#ccff00"
|
||||
/>
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.3} color="#ffffff" />
|
||||
<pointLight position={[-5, 3, -5]} intensity={0.2} color="#ffffff" />
|
||||
|
||||
<CameraRig />
|
||||
<ParticleField count={1500} />
|
||||
<FloatingGeometry variant={variant} />
|
||||
<GridFloor />
|
||||
</Canvas>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
export default function HomeButton() {
|
||||
@@ -29,7 +29,8 @@ export default function HomeButton() {
|
||||
<span
|
||||
className="pointer-events-none absolute inset-0 rounded-full opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
boxShadow: "inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
|
||||
boxShadow:
|
||||
"inset 0 0 20px rgba(204,255,0,0.06), 0 0 24px rgba(204,255,0,0.15)",
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -63,4 +64,3 @@ export default function HomeButton() {
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, type ReactNode } from "react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
|
||||
interface HorizontalScrollProps {
|
||||
children: ReactNode;
|
||||
@@ -70,13 +70,17 @@ export default function HorizontalScroll({
|
||||
end: "left 30%",
|
||||
scrub: true,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
cleanup = () => {
|
||||
ScrollTrigger.getAll().forEach((st) => {
|
||||
if (st.trigger === container || (st.vars as { containerAnimation?: unknown }).containerAnimation === tween) {
|
||||
if (
|
||||
st.trigger === container ||
|
||||
(st.vars as { containerAnimation?: unknown }).containerAnimation ===
|
||||
tween
|
||||
) {
|
||||
st.kill();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import MagneticButton from "./MagneticButton";
|
||||
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
animate: _animate,
|
||||
transition: _transition,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return { motion };
|
||||
});
|
||||
|
||||
describe("MagneticButton semantics", () => {
|
||||
it("renders a list item directly when used inside a list", () => {
|
||||
const { container } = render(
|
||||
<ul>
|
||||
<MagneticButton as="li" className="ds-tag">
|
||||
TypeScript
|
||||
</MagneticButton>
|
||||
</ul>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("ul > li.ds-tag")).toBeInTheDocument();
|
||||
expect(container.querySelector("ul > div > li")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { motion } from "motion/react";
|
||||
import { type MouseEvent, type ReactNode, useState } from "react";
|
||||
|
||||
interface MagneticButtonProps {
|
||||
children: ReactNode;
|
||||
@@ -34,14 +34,10 @@ export default function MagneticButton({
|
||||
wrapperClassName = "",
|
||||
"aria-label": ariaLabel,
|
||||
}: MagneticButtonProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
const handleMouse = (e: React.MouseEvent) => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
const handleMouse = (e: MouseEvent<HTMLElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
@@ -55,31 +51,59 @@ export default function MagneticButton({
|
||||
setPosition({ x: 0, y: 0 });
|
||||
};
|
||||
|
||||
const Tag = as === "a" ? motion.a : as === "button" ? motion.button : as === "div" ? motion.div : as === "span" ? motion.span : motion.li;
|
||||
const Tag =
|
||||
as === "a"
|
||||
? motion.a
|
||||
: as === "button"
|
||||
? motion.button
|
||||
: as === "div"
|
||||
? motion.div
|
||||
: as === "span"
|
||||
? motion.span
|
||||
: motion.li;
|
||||
const linkProps = as === "a" ? { href, target, rel, download } : {};
|
||||
const buttonProps = as === "button" ? { type } : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`inline-block ${wrapperClassName}`.trim()}
|
||||
>
|
||||
const motionProps = {
|
||||
animate: {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
},
|
||||
transition: {
|
||||
type: "spring" as const,
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
if (as === "li") {
|
||||
return (
|
||||
<Tag
|
||||
className={className}
|
||||
className={`${className} ${wrapperClassName}`.trim()}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
animate={{
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 350,
|
||||
damping: 15,
|
||||
mass: 0.5,
|
||||
}}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`inline-block ${wrapperClassName}`.trim()}>
|
||||
<Tag
|
||||
className={className}
|
||||
onMouseMove={handleMouse}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={onClick}
|
||||
data-cursor={dataCursor}
|
||||
{...motionProps}
|
||||
{...linkProps}
|
||||
{...buttonProps}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -8,7 +8,7 @@ jest.mock("react-dom", () => ({
|
||||
createPortal: (children: ReactNode) => children,
|
||||
}));
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
jest.mock("motion/react", () => {
|
||||
const React = require("react");
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
@@ -31,9 +31,7 @@ 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(/EVANDRO.*DEV/)).toBeInTheDocument();
|
||||
expect(screen.getByText("SINAL DE ENTRADA")).toBeInTheDocument();
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
expect(screen.queryByText("000")).not.toBeInTheDocument();
|
||||
@@ -47,7 +45,9 @@ describe("PageIntro", () => {
|
||||
expect(screen.getByText("Preparando o portfólio")).toBeInTheDocument();
|
||||
|
||||
act(() => jest.runAllTimers());
|
||||
expect(screen.queryByText("Preparando o portfólio")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Preparando o portfólio"),
|
||||
).not.toBeInTheDocument();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
@@ -42,7 +42,7 @@ export default function PageIntro() {
|
||||
aria-label="Preparando o portfólio"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 opacity-30 [background-image:linear-gradient(rgba(255,255,255,.06)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.06)_1px,transparent_1px)] [background-size:48px_48px]" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[42%] h-px bg-[var(--slash)] opacity-70" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-[68%] h-px bg-[var(--slash)] opacity-70" />
|
||||
|
||||
<div className="relative m-auto w-[min(88vw,680px)]">
|
||||
<motion.p
|
||||
@@ -53,8 +53,9 @@ export default function PageIntro() {
|
||||
>
|
||||
SINAL DE ENTRADA
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
className="mt-sp-4 font-display text-[clamp(3rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.08em]"
|
||||
{/* Wordmark decorativo do overlay: NÃO é heading — o h1 real vive na página */}
|
||||
<motion.p
|
||||
className="mt-sp-4 font-display text-[clamp(2rem,12vw,8rem)] font-extrabold leading-[0.82] tracking-[-0.04em] lg:tracking-[-0.08em]"
|
||||
initial={reduceMotion ? false : { opacity: 0, y: 28 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
@@ -64,7 +65,7 @@ export default function PageIntro() {
|
||||
}}
|
||||
>
|
||||
EVANDRO<span className="text-[var(--slash)]">.</span>DEV
|
||||
</motion.h1>
|
||||
</motion.p>
|
||||
<motion.div
|
||||
className="mt-sp-8 flex items-center gap-sp-4"
|
||||
initial={reduceMotion ? false : { opacity: 0 }}
|
||||
@@ -94,7 +95,7 @@ export default function PageIntro() {
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[9px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.45)] lg:bottom-10 lg:left-12 lg:right-12">
|
||||
<div className="absolute bottom-8 left-6 right-6 flex justify-between font-mono text-[8px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.32)] lg:bottom-10 lg:left-12 lg:right-12 lg:text-[9px] lg:text-[rgba(244,242,237,.45)]">
|
||||
<span>Engenharia de software</span>
|
||||
<span>Garuva · Joinville · Curitiba</span>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const DEFAULT_TEXT =
|
||||
"Engenharia de software para sistemas que precisam operar de verdade.";
|
||||
|
||||
const MAX_DPR = 1.5;
|
||||
const ASSEMBLE_MS = 2000;
|
||||
const REPEL_RADIUS_PX = 80;
|
||||
const MIN_PARTICLES = 1200;
|
||||
const MAX_PARTICLES = 2500;
|
||||
const OFFSCREEN_POINTER = -100000;
|
||||
|
||||
type Particle = {
|
||||
sx: number;
|
||||
sy: number;
|
||||
tx: number;
|
||||
ty: number;
|
||||
ox: number;
|
||||
oy: number;
|
||||
delay: number;
|
||||
color: string;
|
||||
size: number;
|
||||
phase: number;
|
||||
speed: number;
|
||||
amp: number;
|
||||
};
|
||||
|
||||
type TrackingCapableContext = CanvasRenderingContext2D & {
|
||||
letterSpacing?: string;
|
||||
};
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
const FALLBACK_SLASH: Rgb = [204, 255, 0];
|
||||
const FALLBACK_PAPER: Rgb = [244, 242, 237];
|
||||
|
||||
function hexToRgb(hex: string): Rgb | null {
|
||||
const value = hex.trim().replace("#", "");
|
||||
const full =
|
||||
value.length === 3
|
||||
? value
|
||||
.split("")
|
||||
.map((char) => char + char)
|
||||
.join("")
|
||||
: value;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;
|
||||
return [
|
||||
Number.parseInt(full.slice(0, 2), 16),
|
||||
Number.parseInt(full.slice(2, 4), 16),
|
||||
Number.parseInt(full.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
function easeOutQuint(progress: number) {
|
||||
return 1 - (1 - progress) ** 5;
|
||||
}
|
||||
|
||||
function wrapLines(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
) {
|
||||
const lines: string[] = [];
|
||||
let current = "";
|
||||
for (const word of text.split(" ")) {
|
||||
const candidate = current ? `${current} ${word}` : word;
|
||||
if (!current || ctx.measureText(candidate).width <= maxWidth) {
|
||||
current = candidate;
|
||||
} else {
|
||||
lines.push(current);
|
||||
current = word;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
export function ParticleBuildText({ text = DEFAULT_TEXT }: { text?: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
).matches;
|
||||
|
||||
let rafId = 0;
|
||||
let running = false;
|
||||
let cancelled = false;
|
||||
let resampleTimer = 0;
|
||||
let particles: Particle[] = [];
|
||||
let hasAssembled = false;
|
||||
let elapsed = 0;
|
||||
let lastTime = 0;
|
||||
let dpr = 1;
|
||||
let intersecting = true;
|
||||
const pointer = { x: OFFSCREEN_POINTER, y: OFFSCREEN_POINTER };
|
||||
let rect = container.getBoundingClientRect();
|
||||
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const slashRgb =
|
||||
hexToRgb(rootStyles.getPropertyValue("--slash")) ?? FALLBACK_SLASH;
|
||||
const paperRgb =
|
||||
hexToRgb(rootStyles.getPropertyValue("--paper")) ?? FALLBACK_PAPER;
|
||||
|
||||
// next/font gera nome de família com hash: resolver pela classe font-display real
|
||||
const probe = document.createElement("span");
|
||||
probe.className = "font-display";
|
||||
probe.style.position = "absolute";
|
||||
probe.style.opacity = "0";
|
||||
document.body.appendChild(probe);
|
||||
const fontFamily = getComputedStyle(probe).fontFamily || "sans-serif";
|
||||
document.body.removeChild(probe);
|
||||
|
||||
const fontsReady =
|
||||
typeof document.fonts === "undefined"
|
||||
? Promise.resolve()
|
||||
: document.fonts.ready;
|
||||
|
||||
const sample = () => {
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
if (width < 60 || height < 60) return;
|
||||
|
||||
dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR);
|
||||
canvas.width = Math.round(width * dpr);
|
||||
canvas.height = Math.round(height * dpr);
|
||||
rect = container.getBoundingClientRect();
|
||||
|
||||
const offscreen = document.createElement("canvas");
|
||||
offscreen.width = canvas.width;
|
||||
offscreen.height = canvas.height;
|
||||
const offCtx = offscreen.getContext("2d");
|
||||
if (!offCtx) return;
|
||||
// Backing store é device-pixel: escala p/ que coordenadas CSS preencham o painel inteiro
|
||||
offCtx.scale(dpr, dpr);
|
||||
|
||||
const maxWidth = width * 0.92;
|
||||
let fontSize = Math.min(height * 0.22, width / 7);
|
||||
let lines: string[] = [];
|
||||
while (fontSize >= 12) {
|
||||
offCtx.font = `800 ${fontSize}px ${fontFamily}`;
|
||||
const trackingCtx = offCtx as TrackingCapableContext;
|
||||
if (trackingCtx.letterSpacing !== undefined) {
|
||||
trackingCtx.letterSpacing = "-0.04em";
|
||||
}
|
||||
lines = wrapLines(offCtx, text, maxWidth);
|
||||
if (lines.length * fontSize * 0.98 <= height * 0.8) break;
|
||||
fontSize *= 0.94;
|
||||
}
|
||||
fontSize = Math.max(fontSize, 12);
|
||||
|
||||
const lineHeight = fontSize * 0.98;
|
||||
const startY = (height - lines.length * lineHeight) / 2 + fontSize * 0.74;
|
||||
offCtx.fillStyle = "#ffffff";
|
||||
offCtx.textBaseline = "alphabetic";
|
||||
lines.forEach((line, index) => {
|
||||
const lineWidth = offCtx.measureText(line).width;
|
||||
offCtx.fillText(
|
||||
line,
|
||||
(width - lineWidth) / 2,
|
||||
startY + index * lineHeight,
|
||||
);
|
||||
});
|
||||
|
||||
const pixels = offCtx.getImageData(
|
||||
0,
|
||||
0,
|
||||
offscreen.width,
|
||||
offscreen.height,
|
||||
).data;
|
||||
const targets: Array<[number, number]> = [];
|
||||
const stride = 3;
|
||||
for (let py = 0; py < offscreen.height; py += stride) {
|
||||
for (let px = 0; px < offscreen.width; px += stride) {
|
||||
if (pixels[(py * offscreen.width + px) * 4 + 3] > 128) {
|
||||
targets.push([px, py]);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (targets.length === 0) return;
|
||||
|
||||
const desired = Math.round(
|
||||
Math.min(
|
||||
MAX_PARTICLES,
|
||||
Math.max(MIN_PARTICLES, (width * height) / 180),
|
||||
),
|
||||
);
|
||||
|
||||
particles = Array.from({ length: desired }, () => {
|
||||
const target = targets[Math.floor(Math.random() * targets.length)];
|
||||
const useSlash = Math.random() < 0.72;
|
||||
const rgb = useSlash ? slashRgb : paperRgb;
|
||||
const alpha = useSlash
|
||||
? 0.28 + Math.random() * 0.22
|
||||
: 0.25 + Math.random() * 0.15;
|
||||
return {
|
||||
tx: target[0],
|
||||
ty: target[1],
|
||||
sx: hasAssembled
|
||||
? target[0]
|
||||
: (Math.random() * 1.3 - 0.15) * canvas.width,
|
||||
sy: hasAssembled
|
||||
? target[1]
|
||||
: (Math.random() * 1.3 - 0.15) * canvas.height,
|
||||
ox: 0,
|
||||
oy: 0,
|
||||
delay: Math.random() * 0.35,
|
||||
color: `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${alpha.toFixed(2)})`,
|
||||
size: (Math.random() < 0.85 ? 1 : 2) * dpr,
|
||||
phase: Math.random() * Math.PI * 2,
|
||||
speed: 0.0005 + Math.random() * 0.0007,
|
||||
amp: (0.6 + Math.random()) * dpr,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const pointerX = (pointer.x - rect.left) * dpr;
|
||||
const pointerY = (pointer.y - rect.top) * dpr;
|
||||
const radius = REPEL_RADIUS_PX * dpr;
|
||||
const radiusSq = radius * radius;
|
||||
let allSettled = true;
|
||||
|
||||
for (const particle of particles) {
|
||||
const progress = Math.min(
|
||||
Math.max(
|
||||
(elapsed - particle.delay * ASSEMBLE_MS) / (ASSEMBLE_MS * 0.75),
|
||||
0,
|
||||
),
|
||||
1,
|
||||
);
|
||||
if (progress < 1) allSettled = false;
|
||||
const eased = easeOutQuint(progress);
|
||||
const baseX = particle.sx + (particle.tx - particle.sx) * eased;
|
||||
const baseY = particle.sy + (particle.ty - particle.sy) * eased;
|
||||
|
||||
const driftX =
|
||||
Math.sin(elapsed * particle.speed + particle.phase) *
|
||||
particle.amp *
|
||||
progress;
|
||||
const driftY =
|
||||
Math.cos(elapsed * particle.speed * 0.9 + particle.phase) *
|
||||
particle.amp *
|
||||
progress;
|
||||
|
||||
const dx = baseX + driftX - pointerX;
|
||||
const dy = baseY + driftY - pointerY;
|
||||
const distSq = dx * dx + dy * dy;
|
||||
if (distSq < radiusSq) {
|
||||
const dist = Math.sqrt(distSq) || 1;
|
||||
const force = (1 - dist / radius) * 10 * dpr;
|
||||
particle.ox += (dx / dist) * force;
|
||||
particle.oy += (dy / dist) * force;
|
||||
}
|
||||
particle.ox *= 0.86;
|
||||
particle.oy *= 0.86;
|
||||
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fillRect(
|
||||
baseX + driftX + particle.ox,
|
||||
baseY + driftY + particle.oy,
|
||||
particle.size,
|
||||
particle.size,
|
||||
);
|
||||
}
|
||||
|
||||
if (allSettled && particles.length > 0) hasAssembled = true;
|
||||
};
|
||||
|
||||
const drawStaticFrame = () => {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
for (const particle of particles) {
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fillRect(particle.tx, particle.ty, particle.size, particle.size);
|
||||
}
|
||||
};
|
||||
|
||||
const tick = (now: number) => {
|
||||
elapsed += Math.min(now - lastTime, 50);
|
||||
lastTime = now;
|
||||
render();
|
||||
};
|
||||
|
||||
const syncLoop = () => {
|
||||
const shouldRun = !reducedMotion && intersecting && !document.hidden;
|
||||
if (shouldRun && !running) {
|
||||
running = true;
|
||||
lastTime = performance.now();
|
||||
rafId = window.requestAnimationFrame(tick);
|
||||
} else if (!shouldRun && running) {
|
||||
running = false;
|
||||
window.cancelAnimationFrame(rafId);
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleResample = () => {
|
||||
window.clearTimeout(resampleTimer);
|
||||
resampleTimer = window.setTimeout(() => {
|
||||
sample();
|
||||
if (reducedMotion) drawStaticFrame();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
pointer.x = event.clientX;
|
||||
pointer.y = event.clientY;
|
||||
};
|
||||
const handlePointerLeave = () => {
|
||||
pointer.x = OFFSCREEN_POINTER;
|
||||
pointer.y = OFFSCREEN_POINTER;
|
||||
};
|
||||
const handleScroll = () => {
|
||||
rect = container.getBoundingClientRect();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
syncLoop();
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(scheduleResample);
|
||||
resizeObserver.observe(container);
|
||||
|
||||
if (reducedMotion) {
|
||||
// Variante estática: quadro final montado, sem rAF e sem listeners de ponteiro
|
||||
fontsReady.then(() => {
|
||||
if (cancelled) return;
|
||||
hasAssembled = true;
|
||||
sample();
|
||||
drawStaticFrame();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(resampleTimer);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
const intersectionObserver = new IntersectionObserver((entries) => {
|
||||
intersecting = entries.some((entry) => entry.isIntersecting);
|
||||
syncLoop();
|
||||
});
|
||||
intersectionObserver.observe(container);
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove, {
|
||||
passive: true,
|
||||
});
|
||||
document.documentElement.addEventListener(
|
||||
"pointerleave",
|
||||
handlePointerLeave,
|
||||
);
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
fontsReady.then(() => {
|
||||
if (cancelled) return;
|
||||
sample();
|
||||
syncLoop();
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.clearTimeout(resampleTimer);
|
||||
resizeObserver.disconnect();
|
||||
intersectionObserver.disconnect();
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
document.documentElement.removeEventListener(
|
||||
"pointerleave",
|
||||
handlePointerLeave,
|
||||
);
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [text]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0"
|
||||
>
|
||||
<canvas ref={canvasRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useScroll, useSpring, useTransform } from "framer-motion";
|
||||
import { motion, useScroll, useSpring, useTransform } from "motion/react";
|
||||
|
||||
export default function ScrollProgress() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
@@ -10,7 +10,11 @@ export default function ScrollProgress() {
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.02, 0.98, 1], [0, 1, 1, 0]);
|
||||
const opacity = useTransform(
|
||||
scrollYProgress,
|
||||
[0, 0.02, 0.98, 1],
|
||||
[0, 1, 1, 0],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import {
|
||||
motion,
|
||||
useAnimationFrame,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
useSpring,
|
||||
useScroll,
|
||||
} from "framer-motion";
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface ScrollVelocityTextProps {
|
||||
text: string;
|
||||
@@ -89,7 +89,9 @@ export default function ScrollVelocityText({
|
||||
className="mr-8 inline-block font-display text-[clamp(3rem,10vw,8rem)] font-extrabold uppercase leading-none tracking-[-0.04em]"
|
||||
>
|
||||
{text}
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">•</span>
|
||||
<span className="mx-8 inline-block text-[var(--slash)]">
|
||||
•
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface SectionDividerProps {
|
||||
variant?: "gradient" | "pulse" | "waveform";
|
||||
@@ -84,7 +84,9 @@ export default function SectionDivider({
|
||||
|
||||
if (variant === "pulse") {
|
||||
return (
|
||||
<div className={`flex items-center justify-center gap-2 py-8 ${className}`}>
|
||||
<div
|
||||
className={`flex items-center justify-center gap-2 py-8 ${className}`}
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useMotionValue, useSpring, useTransform } from "motion/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TelemetryMetric {
|
||||
label: string;
|
||||
value: number;
|
||||
unit: string;
|
||||
precision?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
const METRICS_CONFIG: TelemetryMetric[] = [
|
||||
{
|
||||
label: "UPTIME",
|
||||
value: 99.97,
|
||||
unit: "%",
|
||||
precision: 2,
|
||||
min: 99.9,
|
||||
max: 100,
|
||||
},
|
||||
{
|
||||
label: "INTEGRAÇÕES",
|
||||
value: 12,
|
||||
unit: " ativas",
|
||||
precision: 0,
|
||||
min: 8,
|
||||
max: 16,
|
||||
},
|
||||
{
|
||||
label: "BUILDS",
|
||||
value: 247,
|
||||
unit: " /dia",
|
||||
precision: 0,
|
||||
min: 200,
|
||||
max: 300,
|
||||
},
|
||||
{ label: "LATÊNCIA", value: 12, unit: " ms", precision: 0, min: 8, max: 28 },
|
||||
];
|
||||
|
||||
const UPDATE_INTERVAL = 3000;
|
||||
|
||||
const springConfigs = [
|
||||
{ stiffness: 300, damping: 30, mass: 0.8 },
|
||||
{ stiffness: 200, damping: 25, mass: 1 },
|
||||
{ stiffness: 150, damping: 20, mass: 1.2 },
|
||||
{ stiffness: 150, damping: 20, mass: 1.2 },
|
||||
];
|
||||
|
||||
export function TelemetryStrip({
|
||||
className,
|
||||
reducedMotion = false,
|
||||
}: {
|
||||
className?: string;
|
||||
reducedMotion?: boolean;
|
||||
}) {
|
||||
const [_metrics, setMetrics] = useState<TelemetryMetric[]>(METRICS_CONFIG);
|
||||
const pointerX = useMotionValue(0);
|
||||
const pointerY = useMotionValue(0);
|
||||
const isHovering = useRef(false);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const metricValue0 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[0].value),
|
||||
springConfigs[0],
|
||||
);
|
||||
const metricValue1 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[1].value),
|
||||
springConfigs[1],
|
||||
);
|
||||
const metricValue2 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[2].value),
|
||||
springConfigs[2],
|
||||
);
|
||||
const metricValue3 = useSpring(
|
||||
useMotionValue(METRICS_CONFIG[3].value),
|
||||
springConfigs[3],
|
||||
);
|
||||
const metricValues = [metricValue0, metricValue1, metricValue2, metricValue3];
|
||||
|
||||
// Parallax sutil em px, sinal alternado por métrica — assinatura "reage ao ponteiro"
|
||||
const pointerShiftX = useTransform(pointerX, [-1, 1], [-8, 8]);
|
||||
const pointerShiftY = useTransform(pointerY, [-1, 1], [-5, 5]);
|
||||
const pointerShiftXNeg = useTransform(pointerShiftX, (v) => -v);
|
||||
const pointerShiftYNeg = useTransform(pointerShiftY, (v) => -v);
|
||||
|
||||
const generateVariation = useRef((metric: TelemetryMetric): number => {
|
||||
const range =
|
||||
(metric.max ?? metric.value * 1.1) - (metric.min ?? metric.value * 0.9);
|
||||
const variation = (Math.random() - 0.5) * range * 0.15;
|
||||
const newValue = metric.value + variation;
|
||||
return Math.max(
|
||||
metric.min ?? -Infinity,
|
||||
Math.min(metric.max ?? Infinity, newValue),
|
||||
);
|
||||
});
|
||||
|
||||
const updateMetrics = useRef(() => {
|
||||
setMetrics((prev) =>
|
||||
prev.map((metric) => ({
|
||||
...metric,
|
||||
value: generateVariation.current(metric),
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (reducedMotion) return;
|
||||
|
||||
intervalRef.current = setInterval(updateMetrics.current, UPDATE_INTERVAL);
|
||||
return () => {
|
||||
if (intervalRef.current) clearInterval(intervalRef.current);
|
||||
};
|
||||
}, [reducedMotion]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (e: MouseEvent) => {
|
||||
const x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
const y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
pointerX.set(x);
|
||||
pointerY.set(y);
|
||||
};
|
||||
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
return () => window.removeEventListener("pointermove", handlePointerMove);
|
||||
}, [pointerX, pointerY]);
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"cinematic-glass border-[rgba(204,255,0,0.18)] bg-[rgba(10,10,10,0.82)] backdrop-blur-[24px] rounded-[var(--r-lg)] p-sp-4 lg:p-sp-5 grid grid-cols-2 gap-sp-4 lg:gap-sp-6",
|
||||
className,
|
||||
)}
|
||||
aria-label="Telemetria operacional em tempo real"
|
||||
onPointerEnter={() => {
|
||||
isHovering.current = true;
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
isHovering.current = false;
|
||||
}}
|
||||
>
|
||||
{METRICS_CONFIG.map((metric, index) => (
|
||||
<motion.div
|
||||
key={metric.label}
|
||||
className="relative flex flex-col items-start gap-1"
|
||||
style={
|
||||
reducedMotion
|
||||
? undefined
|
||||
: {
|
||||
x: index % 2 === 0 ? pointerShiftX : pointerShiftXNeg,
|
||||
y: index % 2 === 0 ? pointerShiftY : pointerShiftYNeg,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="font-mono text-[11px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.7)]">
|
||||
{metric.label}
|
||||
</div>
|
||||
<div
|
||||
className="whitespace-nowrap font-display font-extrabold text-[clamp(1.35rem,2.6vw,1.875rem)] leading-none text-[var(--paper)] tabular-nums"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{reducedMotion
|
||||
? metric.value.toFixed(metric.precision ?? 0)
|
||||
: metricValues[index].get().toFixed(metric.precision ?? 0)}
|
||||
<span className="font-mono text-[11px] font-normal text-[rgba(244,242,237,.58)]">
|
||||
{metric.unit}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-px w-full bg-[linear-gradient(90deg,var(--slash),transparent)] opacity-30"
|
||||
style={{
|
||||
transformOrigin: "left center",
|
||||
transform: reducedMotion
|
||||
? "scaleX(1)"
|
||||
: `scaleX(${Math.max(0.15, Math.min(1, metric.value / (metric.max ?? 100)))})`,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
<span className="sr-only">
|
||||
Painel de telemetria operacional: uptime 99,97 por cento, doze
|
||||
integrações ativas, duzentos e quarenta e sete builds por dia, latência
|
||||
doze milissegundos. Valores decorativos simulando uma sala de controle
|
||||
real.
|
||||
</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
// src/components/ui/NumberIcons.tsx
|
||||
import React from "react";
|
||||
import type React from "react";
|
||||
|
||||
interface NumberIconProps extends React.SVGProps<SVGSVGElement> {
|
||||
/** Tamanho em pixels. Escala mantendo o viewBox 80x80 */
|
||||
@@ -65,7 +65,7 @@ export const NumberOne = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
@@ -94,7 +94,7 @@ export const NumberTwo = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
@@ -123,7 +123,7 @@ export const NumberThree = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
@@ -152,7 +152,7 @@ export const NumberFour = ({
|
||||
<text
|
||||
x="40"
|
||||
y="54"
|
||||
fontFamily="Syne, sans-serif"
|
||||
fontFamily="Space Grotesk, sans-serif"
|
||||
fontSize="40"
|
||||
fontWeight="800"
|
||||
textAnchor="middle"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
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}`,
|
||||
title: "Currículo — Engenheiro e Desenvolvedor de Software",
|
||||
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 },
|
||||
@@ -46,10 +47,7 @@ export default function CurriculoPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<section className="ds-section ds-section-dark">
|
||||
<div className="ds-section-shell max-w-[960px]">
|
||||
<p className="ds-eyebrow">Currículo profissional</p>
|
||||
|
||||
+120
-31
@@ -1,20 +1,20 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import "tailwindcss";
|
||||
@config "../tailwind.config.js";
|
||||
|
||||
@theme {
|
||||
--spacing-sp-1: var(--sp-1);
|
||||
--spacing-sp-2: var(--sp-2);
|
||||
--spacing-sp-3: var(--sp-3);
|
||||
--spacing-sp-4: var(--sp-4);
|
||||
--spacing-sp-5: var(--sp-5);
|
||||
--spacing-sp-6: var(--sp-6);
|
||||
--spacing-sp-8: var(--sp-8);
|
||||
--spacing-sp-10: var(--sp-10);
|
||||
--spacing-sp-12: var(--sp-12);
|
||||
--spacing-sp-16: var(--sp-16);
|
||||
--spacing-sp-20: var(--sp-20);
|
||||
--spacing-sp-24: var(--sp-24);
|
||||
/* Literal values only: var() indirection here stops Tailwind v4 from generating *-sp-* utilities. */
|
||||
--spacing-sp-1: 4px;
|
||||
--spacing-sp-2: 8px;
|
||||
--spacing-sp-3: 12px;
|
||||
--spacing-sp-4: 16px;
|
||||
--spacing-sp-5: 20px;
|
||||
--spacing-sp-6: 24px;
|
||||
--spacing-sp-8: 32px;
|
||||
--spacing-sp-10: 40px;
|
||||
--spacing-sp-12: 48px;
|
||||
--spacing-sp-16: 64px;
|
||||
--spacing-sp-20: 80px;
|
||||
--spacing-sp-24: 96px;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -47,19 +47,22 @@
|
||||
--r-md: 8px;
|
||||
--r-lg: 16px;
|
||||
--r-full: 999px;
|
||||
--font-display: var(--font-syne), sans-serif;
|
||||
--font-display: var(--font-space-grotesk), sans-serif;
|
||||
--font-hero: var(--font-monument-extended), sans-serif;
|
||||
--font-mono: var(--font-geist-mono), monospace;
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Layered so Tailwind utilities (later layer) can override the zero-reset. */
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -72,6 +75,79 @@ body {
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
scroll-padding-top: 68px;
|
||||
}
|
||||
|
||||
html [id] {
|
||||
scroll-margin-top: 68px;
|
||||
}
|
||||
|
||||
.site-header-nav,
|
||||
.site-header-cv,
|
||||
.site-header-version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle,
|
||||
.site-header-mobile-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
}
|
||||
|
||||
@media (min-width: 1025px) {
|
||||
.site-header-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.site-header-cv,
|
||||
.site-header-version {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle,
|
||||
.site-header-mobile-panel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 16px;
|
||||
z-index: 10000;
|
||||
transform: translateY(-160%);
|
||||
border: 1px solid var(--slash);
|
||||
background: var(--ink);
|
||||
color: var(--white);
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s var(--ease-out);
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
:where(a, button, input, select, textarea):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
|
||||
.film-grain {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -181,7 +257,7 @@ textarea {
|
||||
|
||||
.ds-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(28px, 4vw, 44px);
|
||||
font-size: clamp(24px, 4vw, 44px);
|
||||
line-height: 1.02;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.035em;
|
||||
@@ -212,7 +288,6 @@ textarea {
|
||||
.ds-panel {
|
||||
border: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ds-card {
|
||||
@@ -231,7 +306,7 @@ textarea {
|
||||
|
||||
.ds-card-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(20px, 2.4vw, 30px);
|
||||
font-size: clamp(16px, 2.4vw, 30px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.025em;
|
||||
margin-top: var(--sp-4);
|
||||
@@ -502,14 +577,29 @@ textarea {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
|
||||
animation-duration: 0.01ms !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component animation styles. */
|
||||
animation-iteration-count: 1 !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override component transition styles. */
|
||||
transition-duration: 0.01ms !important;
|
||||
/* biome-ignore lint/complexity/noImportantStyles: reduced-motion must override smooth scrolling. */
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.site-header-nav,
|
||||
.site-header-cv,
|
||||
.site-header-version {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.site-header-menu-toggle,
|
||||
.site-header-mobile-panel {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.ds-section-header {
|
||||
flex-direction: column;
|
||||
gap: var(--sp-4);
|
||||
@@ -527,7 +617,11 @@ textarea {
|
||||
}
|
||||
|
||||
.ds-section-title {
|
||||
font-size: clamp(26px, 8vw, 34px);
|
||||
font-size: clamp(24px, 8vw, 34px);
|
||||
}
|
||||
|
||||
.ds-card-title {
|
||||
font-size: clamp(16px, 5vw, 24px);
|
||||
}
|
||||
|
||||
.ds-section-description,
|
||||
@@ -727,11 +821,6 @@ textarea {
|
||||
0 0 1px rgba(204, 255, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Smooth scroll overrides for cinematic feel */
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
/* Cinematic section reveal transforms */
|
||||
.cinematic-reveal {
|
||||
will-change: transform, opacity;
|
||||
|
||||
@@ -2,14 +2,14 @@ import { renderHook } from "@testing-library/react";
|
||||
import { useReducedMotion } from "./useReducedMotion";
|
||||
|
||||
// Mock do Framer Motion
|
||||
jest.mock("framer-motion", () => ({
|
||||
jest.mock("motion/react", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("useReducedMotion", () => {
|
||||
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
@@ -19,7 +19,7 @@ describe("useReducedMotion", () => {
|
||||
|
||||
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
@@ -29,7 +29,7 @@ describe("useReducedMotion", () => {
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
@@ -39,7 +39,7 @@ describe("useReducedMotion", () => {
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
||||
import { useReducedMotion as useFramerReducedMotion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
/**
|
||||
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function useScrollReveal(options?: {
|
||||
y?: number;
|
||||
|
||||
+31
-22
@@ -1,26 +1,27 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist_Mono, Syne } from "next/font/google";
|
||||
import { Geist_Mono, Space_Grotesk } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
import type { ReactNode } from "react";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import { regionCities, siteUrl } from "@/lib/site-content";
|
||||
import { getWhatsAppNumber } from "@/lib/whatsapp";
|
||||
import CinematicOverlays from "./components/cinematic/CinematicOverlays";
|
||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { Header } from "./components/Header";
|
||||
import { RouteTransition } from "./components/RouteTransition";
|
||||
import { AnimationProvider } from "./providers/AnimationProvider";
|
||||
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
||||
import "./globals.css";
|
||||
|
||||
const seoTitle =
|
||||
"Engenheiro da Computação e Desenvolvedor de Software em Garuva, Joinville e Curitiba | Evandro Fonseca Junior";
|
||||
const defaultTitle = "Evandro Fonseca Junior — Engenharia de Software";
|
||||
const seoDescription =
|
||||
"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 spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
display: "swap",
|
||||
variable: "--font-syne",
|
||||
variable: "--font-space-grotesk",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
@@ -48,7 +49,10 @@ const monumentExtended = localFont({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: seoTitle,
|
||||
title: {
|
||||
default: defaultTitle,
|
||||
template: "%s | Evandro Fonseca Junior",
|
||||
},
|
||||
description: seoDescription,
|
||||
keywords: [
|
||||
"desenvolvedor de software em Garuva",
|
||||
@@ -92,7 +96,7 @@ export const metadata: Metadata = {
|
||||
telephone: false,
|
||||
},
|
||||
openGraph: {
|
||||
title: seoTitle,
|
||||
title: defaultTitle,
|
||||
description: seoDescription,
|
||||
url: siteUrl,
|
||||
siteName: "Evandro.dev.br",
|
||||
@@ -109,7 +113,7 @@ export const metadata: Metadata = {
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: seoTitle,
|
||||
title: defaultTitle,
|
||||
description: seoDescription,
|
||||
creator: "@evandrodevbr",
|
||||
images: ["/opengraph-image"],
|
||||
@@ -209,7 +213,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
{
|
||||
"@type": "ProfessionalService",
|
||||
"@id": `${siteUrl}/#services`,
|
||||
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Tecnica",
|
||||
name: "Evandro Fonseca Junior | Engenharia de Software, Criação de Sites e Consultoria Técnica",
|
||||
url: siteUrl,
|
||||
image: `${siteUrl}/opengraph-image`,
|
||||
telephone: `+${getWhatsAppNumber()}`,
|
||||
@@ -226,7 +230,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
longitude: -48.8467,
|
||||
},
|
||||
description:
|
||||
"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.",
|
||||
"Serviço de engenharia de software, criação de sites, arquitetura de software, integrações críticas e consultoria técnica para Next.js, Tauri, Rust, IoT e produtos conectados.",
|
||||
areaServed: [
|
||||
...regionCities.map((name) => ({ "@type": "City", name })),
|
||||
{ "@type": "AdministrativeArea", name: "Norte de Santa Catarina" },
|
||||
@@ -249,18 +253,18 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
serviceType: [
|
||||
"Engenharia de Software",
|
||||
"Criação de Sites",
|
||||
"Consultoria Tecnica",
|
||||
"Consultoria Técnica",
|
||||
"Arquitetura de Software",
|
||||
"Integracao hardware + software",
|
||||
"Integração hardware + software",
|
||||
"Desenvolvimento Next.js",
|
||||
"Desenvolvimento Tauri",
|
||||
"Desenvolvimento Rust",
|
||||
"IoT",
|
||||
"Sistemas Criticos",
|
||||
"Sistemas Críticos",
|
||||
],
|
||||
hasOfferCatalog: {
|
||||
"@type": "OfferCatalog",
|
||||
name: "Modelos de contratacao",
|
||||
name: "Modelos de contratação",
|
||||
itemListElement: [
|
||||
{
|
||||
"@type": "Offer",
|
||||
@@ -273,7 +277,7 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
"@type": "Offer",
|
||||
itemOffered: {
|
||||
"@type": "Service",
|
||||
name: "Auditoria e diagnostico tecnico",
|
||||
name: "Auditoria e diagnóstico técnico",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -299,17 +303,22 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
return (
|
||||
<html
|
||||
lang="pt-BR"
|
||||
className={`${syne.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
className={`${spaceGrotesk.variable} ${geistMono.variable} ${monumentExtended.variable}`}
|
||||
>
|
||||
<body>
|
||||
<SmoothScrollProvider>
|
||||
<AnimationProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
<a className="skip-link" href="#main-content">
|
||||
Pular para o conteúdo
|
||||
</a>
|
||||
<Header />
|
||||
<CinematicOverlays />
|
||||
<RouteTransition>{children}</RouteTransition>
|
||||
<RouteTransition>
|
||||
<div id="main-content" tabIndex={-1}>
|
||||
{children}
|
||||
</div>
|
||||
</RouteTransition>
|
||||
<GoogleAnalytics />
|
||||
</AnimationProvider>
|
||||
</SmoothScrollProvider>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { ArrowLeft, Code2, Home } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Home, ArrowLeft, Code2 } from "lucide-react";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
export const alt = "Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
|
||||
export const alt =
|
||||
"Evandro Fonseca Junior - Engenheiro da Computação e Desenvolvedor de Software";
|
||||
export const size = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { createContext, type ReactNode, useContext } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { animationConfig } from "@/app/lib/animations/config";
|
||||
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function SmoothScrollProvider({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}) {
|
||||
export function SmoothScrollProvider({ children }: { children: ReactNode }) {
|
||||
const teardownRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -38,7 +34,7 @@ export function SmoothScrollProvider({
|
||||
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
easing: (t) => Math.min(1, 1.001 - 2 ** (-10 * t)),
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
|
||||
+104
-33
@@ -1,6 +1,8 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import {
|
||||
getRelatedCases,
|
||||
getServicePage,
|
||||
@@ -9,8 +11,6 @@ import {
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
|
||||
interface ServicePageProps {
|
||||
params: Promise<{
|
||||
@@ -30,7 +30,7 @@ export async function generateMetadata({
|
||||
|
||||
if (!service) {
|
||||
return {
|
||||
title: `Servico nao encontrado | ${siteName}`,
|
||||
title: `Serviço não encontrado`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,13 +102,13 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 1,
|
||||
name: "Inicio",
|
||||
name: "Início",
|
||||
item: siteUrl,
|
||||
},
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Servicos",
|
||||
name: "Serviços",
|
||||
item: `${siteUrl}/servicos`,
|
||||
},
|
||||
{
|
||||
@@ -124,10 +124,7 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
|
||||
<HeroBackground />
|
||||
@@ -141,10 +138,20 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
{service.intro}
|
||||
</p>
|
||||
<div className="mt-sp-8 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton as="a" href={service.ctaHref} className="ds-link ds-link-accent" strength={0.25}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.ctaHref}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{service.ctaLabel}
|
||||
</MagneticButton>
|
||||
<MagneticButton as="a" href="/cases" className="ds-link ds-link-muted" strength={0.2}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/cases"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.2}
|
||||
>
|
||||
Ver casos relacionados
|
||||
</MagneticButton>
|
||||
</div>
|
||||
@@ -157,7 +164,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
<div className="ds-grid-2">
|
||||
<article className="ds-card">
|
||||
<div className="ds-kicker">Para quem faz sentido</div>
|
||||
<h2 className="ds-card-title">Contextos em que esse servico encaixa melhor</h2>
|
||||
<h2 className="ds-card-title">
|
||||
Contextos em que esse serviço encaixa melhor
|
||||
</h2>
|
||||
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
|
||||
{service.audience.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
@@ -167,7 +176,9 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
|
||||
<article className="ds-card">
|
||||
<div className="ds-kicker">O que muda</div>
|
||||
<h2 className="ds-card-title">Resultados que eu busco destravar</h2>
|
||||
<h2 className="ds-card-title">
|
||||
Resultados que eu busco destravar
|
||||
</h2>
|
||||
<ul className="ds-list mt-sp-5 pl-[1.15rem]">
|
||||
{service.outcomes.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
@@ -186,7 +197,10 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
<h2 className="ds-card-title">Processo de trabalho</h2>
|
||||
<ol className="list-none mt-sp-5 space-y-sp-4 text-[14px] leading-8 text-[var(--ink-80)]">
|
||||
{service.process.map((step, index) => (
|
||||
<li key={step} className="border-t border-[var(--border)] pt-sp-4 first:border-t-0 first:pt-0">
|
||||
<li
|
||||
key={step}
|
||||
className="border-t border-[var(--border)] pt-sp-4 first:border-t-0 first:pt-0"
|
||||
>
|
||||
<span className="mr-sp-3 inline-flex h-7 min-w-7 items-center justify-center rounded-full border border-[var(--border)] text-[11px] font-semibold text-[var(--meta)]">
|
||||
{index + 1}
|
||||
</span>
|
||||
@@ -197,15 +211,22 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</article>
|
||||
|
||||
<article className="ds-card">
|
||||
<div className="ds-kicker">Stack e repertorio</div>
|
||||
<h2 className="ds-card-title">Tecnologias e capacidades envolvidas</h2>
|
||||
<div className="ds-kicker">Stack e repertório</div>
|
||||
<h2 className="ds-card-title">
|
||||
Tecnologias e capacidades envolvidas
|
||||
</h2>
|
||||
<p className="ds-copy">
|
||||
A stack entra como meio para resolver o problema certo, com criterio de manutencao,
|
||||
observabilidade e evolucao do produto.
|
||||
A stack entra como meio para resolver o problema certo, com
|
||||
critério de manutenção, observabilidade e evolução do produto.
|
||||
</p>
|
||||
<ul className="ds-tags">
|
||||
{service.stack.map((item) => (
|
||||
<MagneticButton key={item} as="li" className="ds-tag" strength={0.15}>
|
||||
<MagneticButton
|
||||
key={item}
|
||||
as="li"
|
||||
className="ds-tag"
|
||||
strength={0.15}
|
||||
>
|
||||
{item}
|
||||
</MagneticButton>
|
||||
))}
|
||||
@@ -220,9 +241,12 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
<div className="ds-section-header">
|
||||
<div className="ds-section-meta">
|
||||
<p className="ds-eyebrow">Casos relacionados</p>
|
||||
<h2 className="ds-section-title">Projetos que reforcam esse tipo de entrega</h2>
|
||||
<h2 className="ds-section-title">
|
||||
Projetos que reforçam esse tipo de entrega
|
||||
</h2>
|
||||
<p className="ds-section-description">
|
||||
Alguns exemplos de contexto tecnico em que esse servico se conecta a entregas reais.
|
||||
Alguns exemplos de contexto técnico em que esse serviço se
|
||||
conecta a entregas reais.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,13 +269,23 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</ul>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
{caseStudy.demoUrl ? (
|
||||
<a href={caseStudy.demoUrl} className="ds-link ds-link-accent" target="_blank" rel="noreferrer">
|
||||
<a
|
||||
href={caseStudy.demoUrl}
|
||||
className="ds-link ds-link-accent"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir projeto
|
||||
</a>
|
||||
) : null}
|
||||
{caseStudy.githubUrl ? (
|
||||
<a href={caseStudy.githubUrl} className="ds-link ds-link-muted" target="_blank" rel="noreferrer">
|
||||
Ver codigo
|
||||
<a
|
||||
href={caseStudy.githubUrl}
|
||||
className="ds-link ds-link-muted"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Ver código
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -261,28 +295,65 @@ export default async function ServicePage({ params }: ServicePageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="ds-section ds-section-dark">
|
||||
<section className="ds-section ds-section-dark pb-sp-6">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-card border-white/10 bg-[rgba(255,255,255,0.03)]">
|
||||
<div className="ds-kicker">Proximo passo</div>
|
||||
<div className="ds-kicker">Próximo passo</div>
|
||||
<h2 className="ds-card-title text-white">
|
||||
Se esse servico parece aderente ao seu contexto, a conversa inicial ja pode ser objetiva.
|
||||
Se esse serviço parece aderente ao seu contexto, a conversa
|
||||
inicial já pode ser objetiva.
|
||||
</h2>
|
||||
<p className="ds-copy !text-[rgba(255,255,255,0.76)]">
|
||||
Traga o sistema, o gargalo e o objetivo de negocio. Eu estruturo a leitura tecnica e
|
||||
indico o caminho mais seguro para avancar.
|
||||
Traga o sistema, o gargalo e o objetivo de negócio. Eu estruturo a
|
||||
leitura técnica e indico o caminho mais seguro para avançar.
|
||||
</p>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
<MagneticButton as="a" href={service.ctaHref} className="ds-link ds-link-accent" strength={0.25}>
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.ctaHref}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
{service.ctaLabel}
|
||||
</MagneticButton>
|
||||
<MagneticButton as="a" href="/servicos" className="ds-link ds-link-muted" strength={0.2}>
|
||||
Ver todos os servicos
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href="/servicos"
|
||||
className="ds-link ds-link-muted"
|
||||
strength={0.2}
|
||||
>
|
||||
Ver todos os serviços
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{service.faq && service.faq.length > 0 ? (
|
||||
<section className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-section-header">
|
||||
<div className="ds-section-meta">
|
||||
<p className="ds-eyebrow">Dúvidas comuns</p>
|
||||
<h2 className="ds-section-title">Perguntas frequentes</h2>
|
||||
<p className="ds-section-description">
|
||||
Respostas diretas às perguntas que costumam aparecer antes de
|
||||
cada projeto começar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ds-grid-2">
|
||||
{service.faq.map((item) => (
|
||||
<article key={item.question} className="ds-card">
|
||||
<h3 className="ds-card-title">{item.question}</h3>
|
||||
<p className="ds-copy">{item.answer}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
+30
-21
@@ -1,20 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
import { serviceHubLinks, servicePages, siteName, siteUrl } from "@/lib/site-content";
|
||||
import HeroBackground from "@/app/components/cinematic/HeroBackground";
|
||||
import MagneticButton from "@/app/components/cinematic/MagneticButton";
|
||||
import { safeJsonLd } from "@/lib/jsonld";
|
||||
import {
|
||||
serviceHubLinks,
|
||||
servicePages,
|
||||
siteName,
|
||||
siteUrl,
|
||||
} from "@/lib/site-content";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Servicos de Engenharia de Software e Consultoria Tecnica | ${siteName}`,
|
||||
title: "Serviços de Engenharia de Software e Consultoria Técnica",
|
||||
description:
|
||||
"Servicos de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integracao hardware + software para produtos e sistemas criticos.",
|
||||
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/servicos`,
|
||||
},
|
||||
openGraph: {
|
||||
title: `Servicos de Engenharia de Software e Consultoria Tecnica | ${siteName}`,
|
||||
title: "Serviços de Engenharia de Software e Consultoria Técnica",
|
||||
description:
|
||||
"Servicos de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integracao hardware + software para produtos e sistemas criticos.",
|
||||
"Serviços de engenharia de software, consultoria Next.js, Tauri, Rust, arquitetura de software e integração hardware + software para produtos e sistemas críticos.",
|
||||
url: `${siteUrl}/servicos`,
|
||||
siteName,
|
||||
type: "website",
|
||||
@@ -28,10 +33,10 @@ export default function ServicesIndexPage() {
|
||||
{
|
||||
"@type": "CollectionPage",
|
||||
"@id": `${siteUrl}/servicos#page`,
|
||||
name: "Servicos de Engenharia de Software e Consultoria Tecnica",
|
||||
name: "Serviços de Engenharia de Software e Consultoria Técnica",
|
||||
url: `${siteUrl}/servicos`,
|
||||
description:
|
||||
"Hub de servicos para engenharia de software, consultoria tecnica, Next.js, Tauri, Rust, IoT e arquitetura de software.",
|
||||
"Hub de serviços para engenharia de software, consultoria técnica, Next.js, Tauri, Rust, IoT e arquitetura de software.",
|
||||
},
|
||||
{
|
||||
"@type": "ItemList",
|
||||
@@ -45,11 +50,11 @@ export default function ServicesIndexPage() {
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Inicio", item: siteUrl },
|
||||
{ "@type": "ListItem", position: 1, name: "Início", item: siteUrl },
|
||||
{
|
||||
"@type": "ListItem",
|
||||
position: 2,
|
||||
name: "Servicos",
|
||||
name: "Serviços",
|
||||
item: `${siteUrl}/servicos`,
|
||||
},
|
||||
],
|
||||
@@ -59,22 +64,21 @@ export default function ServicesIndexPage() {
|
||||
|
||||
return (
|
||||
<main>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<script type="application/ld+json">{safeJsonLd(jsonLd)}</script>
|
||||
|
||||
<section className="ds-section ds-section-dark relative min-h-[340px] overflow-hidden">
|
||||
<HeroBackground />
|
||||
<div className="ds-section-shell relative z-10">
|
||||
<div className="max-w-[860px]">
|
||||
<p className="ds-eyebrow">Hub de servicos</p>
|
||||
<p className="ds-eyebrow">Hub de serviços</p>
|
||||
<h1 className="ds-section-title max-w-[14ch] text-balance text-white">
|
||||
Servicos de engenharia de software para contextos tecnicos de alta exigencia.
|
||||
Serviços de engenharia de software para contextos técnicos de
|
||||
alta exigência.
|
||||
</h1>
|
||||
<p className="ds-copy max-w-[64ch] !text-[rgba(255,255,255,0.78)]">
|
||||
Organizei as paginas de servico por intencao real de compra: engenharia de software,
|
||||
consultoria Next.js, Tauri e Rust, arquitetura de software e integracoes hardware + software.
|
||||
Organizei as páginas de serviço por intenção real de compra:
|
||||
engenharia de software, consultoria Next.js, Tauri e Rust,
|
||||
arquitetura de software e integrações hardware + software.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,12 +89,17 @@ export default function ServicesIndexPage() {
|
||||
<div className="ds-grid-2">
|
||||
{serviceHubLinks.map((service) => (
|
||||
<article key={service.href} className="ds-card">
|
||||
<div className="ds-kicker">Servico especializado</div>
|
||||
<div className="ds-kicker">Serviço especializado</div>
|
||||
<h2 className="ds-card-title">{service.label}</h2>
|
||||
<p className="ds-copy">{service.description}</p>
|
||||
<div className="mt-sp-6">
|
||||
<MagneticButton as="a" href={service.href} className="ds-link ds-link-accent" strength={0.25}>
|
||||
Abrir pagina
|
||||
<MagneticButton
|
||||
as="a"
|
||||
href={service.href}
|
||||
className="ds-link ds-link-accent"
|
||||
strength={0.25}
|
||||
>
|
||||
Abrir página
|
||||
</MagneticButton>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import type { MetadataRoute } from "next";
|
||||
import { caseStudies, servicePages, siteUrl } from "@/lib/site-content";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
import React, { useRef, useEffect, useCallback, useMemo } from "react";
|
||||
import { gsap } from "gsap";
|
||||
import { InertiaPlugin } from "gsap/InertiaPlugin";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
gsap.registerPlugin(InertiaPlugin);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { Renderer, Program, Mesh, Color, Triangle } from "ogl";
|
||||
import React, { useEffect, useRef, useMemo, useCallback } from "react";
|
||||
import { Color, Mesh, Program, Renderer, Triangle } from "ogl";
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
|
||||
type Vec2 = [number, number];
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RotatingTextProps {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
import React, { useRef, useEffect } from "react";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type CanvasStrokeStyle = string | CanvasGradient | CanvasPattern;
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { gsap } from "gsap";
|
||||
import {
|
||||
ElementType,
|
||||
createElement,
|
||||
type ElementType,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
createElement,
|
||||
useMemo,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { gsap } from "gsap";
|
||||
|
||||
interface TextTypeProps {
|
||||
className?: string;
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import {
|
||||
type MotionValue,
|
||||
motion,
|
||||
MotionValue,
|
||||
useMotionValue,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "motion/react";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import React, { PropsWithChildren, useRef } from "react";
|
||||
import React, { type PropsWithChildren, useRef } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import React, { useEffect, useId, useRef, useState } from "react";
|
||||
import type React from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* DotPattern Component Props
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import createGlobe, { COBEOptions } from "cobe";
|
||||
import createGlobe, { type COBEOptions } from "cobe";
|
||||
import { useMotionValue, useSpring } from "motion/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Plano: frontend foundation e conversão
|
||||
|
||||
## Estado inicial
|
||||
|
||||
- Branch atual: `feature/portrait-cyber-effects`.
|
||||
- Workspace limpo antes da implementação.
|
||||
- `pnpm run build` era o baseline verde conhecido.
|
||||
- Testes e Biome já possuíam falhas preexistentes; a validação distinguirá regressões dos problemas anteriores.
|
||||
|
||||
## Execução
|
||||
|
||||
### 1. Regressões primeiro
|
||||
|
||||
Arquivos:
|
||||
|
||||
- `app/components/Header.test.tsx`
|
||||
- `app/components/Hero.test.tsx`
|
||||
- `app/components/cinematic/MagneticButton.test.tsx`
|
||||
|
||||
Adicionar asserções para:
|
||||
|
||||
- links e atributos ARIA do header;
|
||||
- download do CV em `/Profile.pdf`;
|
||||
- `li` renderizado diretamente sob uma lista.
|
||||
|
||||
Verificação: rodar as suítes específicas e observar falhas antes da implementação.
|
||||
|
||||
### 2. Contratos compartilhados
|
||||
|
||||
Arquivo novo:
|
||||
|
||||
- `lib/site-navigation.ts`
|
||||
|
||||
Exportar `CV_URL` e os itens de navegação usados pelo header. O teste do Hero e o componente devem usar o mesmo contrato.
|
||||
|
||||
### 3. Navegação global
|
||||
|
||||
Arquivos:
|
||||
|
||||
- `app/layout.tsx`
|
||||
- `app/components/Header.tsx`
|
||||
- `app/components/cinematic/CinematicOverlays.tsx`
|
||||
|
||||
Adicionar header no layout, skip link, navegação desktop, disclosure mobile e remover o botão Home redundante dos overlays.
|
||||
|
||||
### 4. Correções de semântica e performance
|
||||
|
||||
Arquivos:
|
||||
|
||||
- `app/components/Hero.tsx`
|
||||
- `app/components/cinematic/MagneticButton.tsx`
|
||||
- `app/components/cinematic/FilmGrain.tsx`
|
||||
- `app/globals.css`
|
||||
|
||||
Usar `CV_URL`, renderizar `li` sem wrapper inválido, respeitar `prefers-reduced-motion`/pointer coarse no grain e adicionar foco/scroll global.
|
||||
|
||||
### 5. Validação e revisão
|
||||
|
||||
Executar:
|
||||
|
||||
```bash
|
||||
pnpm exec jest --runInBand app/components/Header.test.tsx app/components/Hero.test.tsx app/components/cinematic/MagneticButton.test.tsx
|
||||
pnpm run test -- --runInBand
|
||||
pnpm run lint
|
||||
pnpm run build
|
||||
pnpm run format
|
||||
pnpm exec biome check --write app/components/Header.tsx app/components/Hero.tsx app/components/cinematic/MagneticButton.tsx app/components/cinematic/CinematicOverlays.tsx app/components/cinematic/FilmGrain.tsx app/layout.tsx lib/site-navigation.ts
|
||||
```
|
||||
|
||||
Revisar `git diff --check`, `git diff --stat` e o diff completo. Não criar commit sem solicitação explícita.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Design: frontend foundation and conversion pass
|
||||
|
||||
## Objetivo
|
||||
|
||||
Corrigir os pontos que impedem o portfólio de funcionar como produto: navegação global ausente, download de CV quebrado, foco de teclado inconsistente e efeitos cinematográficos executando fora do contexto necessário.
|
||||
|
||||
## Decisões
|
||||
|
||||
- Reutilizar o `Header` existente no layout global, sem adicionar dependências.
|
||||
- Centralizar URLs e itens de navegação em `lib/site-navigation.ts` para evitar caminhos divergentes.
|
||||
- Manter a identidade visual escura/cinematográfica, mas deixar o conteúdo principal e as ações acessíveis sem depender de animação.
|
||||
- Usar um disclosure mobile simples e acessível: `aria-expanded`, `aria-controls`, `hidden` e fechamento por `Escape`.
|
||||
- Fazer o `MagneticButton` renderizar `li` diretamente quando solicitado, eliminando `div > li` inválido.
|
||||
- Limitar cursor customizado, grain e progresso de scroll à home; páginas internas continuam com navegação normal e menos trabalho de renderização.
|
||||
- Adicionar skip link, `scroll-padding` e foco visível global.
|
||||
|
||||
## Não incluído neste lote
|
||||
|
||||
- Redesign completo da hero.
|
||||
- Migração de animações ou troca de biblioteca.
|
||||
- Alteração de backend, conteúdo editorial ou arquitetura de rotas.
|
||||
- Correção geral dos 152 diagnósticos do Biome; apenas os arquivos tocados serão saneados.
|
||||
|
||||
## Critérios de aceite
|
||||
|
||||
1. O header aparece na home e nas rotas internas.
|
||||
2. O menu mobile informa e controla seu estado, fecha com `Escape` e não deixa links ocultos no fluxo de tabulação.
|
||||
3. Todos os CTAs de CV apontam para `/Profile.pdf`, arquivo existente.
|
||||
4. Nenhum uso de `MagneticButton as="li"` produz um elemento intermediário inválido.
|
||||
5. O alvo do skip link existe e elementos focáveis têm indicação visual clara.
|
||||
6. Testes de regressão passam para header, CV e semântica de lista.
|
||||
7. Build de produção continua passando.
|
||||
@@ -3,6 +3,8 @@ import type { Config } from "jest";
|
||||
const config: Config = {
|
||||
coverageProvider: "v8",
|
||||
testEnvironment: "jsdom",
|
||||
roots: ["<rootDir>/app", "<rootDir>/lib"],
|
||||
modulePathIgnorePatterns: ["<rootDir>/.worktrees/"],
|
||||
setupFilesAfterEnv: ["<rootDir>/jest.setup.ts"],
|
||||
moduleNameMapper: {
|
||||
"^@/(.*)$": "<rootDir>/$1",
|
||||
|
||||
+61
-2
@@ -1,6 +1,65 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import "./__mocks__/resizeObserver";
|
||||
|
||||
// Mock IntersectionObserver if not available in JSDOM
|
||||
if (typeof global.IntersectionObserver === "undefined") {
|
||||
class IntersectionObserver {
|
||||
root: Element | Document | null = null;
|
||||
rootMargin: string = "";
|
||||
thresholds: ReadonlyArray<number> = [];
|
||||
private callback: IntersectionObserverCallback;
|
||||
private elements: Set<Element> = new Set();
|
||||
|
||||
constructor(
|
||||
callback: IntersectionObserverCallback,
|
||||
options?: IntersectionObserverInit,
|
||||
) {
|
||||
this.callback = callback;
|
||||
this.root = options?.root || null;
|
||||
this.rootMargin = options?.rootMargin || "";
|
||||
const threshold = options?.threshold ?? 0;
|
||||
this.thresholds = Array.isArray(threshold) ? threshold : [threshold];
|
||||
}
|
||||
|
||||
observe(target: Element) {
|
||||
this.elements.add(target);
|
||||
setTimeout(() => {
|
||||
this.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1,
|
||||
boundingClientRect: target.getBoundingClientRect(),
|
||||
intersectionRect: target.getBoundingClientRect(),
|
||||
rootBounds:
|
||||
this.root instanceof Element
|
||||
? this.root.getBoundingClientRect()
|
||||
: null,
|
||||
time: Date.now(),
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
this,
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
unobserve(target: Element) {
|
||||
this.elements.delete(target);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.elements.clear();
|
||||
}
|
||||
|
||||
takeRecords(): IntersectionObserverEntry[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
global.IntersectionObserver =
|
||||
IntersectionObserver as typeof global.IntersectionObserver;
|
||||
}
|
||||
|
||||
// Mock PointerEvent if not available in JSDOM
|
||||
if (typeof global.PointerEvent === "undefined") {
|
||||
class PointerEvent extends MouseEvent {
|
||||
@@ -24,8 +83,8 @@ Object.defineProperty(window, "matchMedia", {
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(), // Deprecated
|
||||
removeListener: jest.fn(), // Deprecated
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export function safeJsonLd(data: unknown): string {
|
||||
return JSON.stringify(data)
|
||||
.replaceAll("<", "\\u003c")
|
||||
.replaceAll(">", "\\u003e");
|
||||
}
|
||||
+224
-94
@@ -27,6 +27,7 @@ export interface ServicePageContent {
|
||||
ctaLabel: string;
|
||||
ctaHref: string;
|
||||
relatedCaseIds: string[];
|
||||
faq?: { question: string; answer: string }[];
|
||||
}
|
||||
|
||||
export interface CaseStudyContent {
|
||||
@@ -45,8 +46,7 @@ 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",
|
||||
seoTitle: "Criação de Sites em Garuva, Joinville e Curitiba",
|
||||
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:
|
||||
@@ -74,103 +74,167 @@ export const servicePages: ServicePageContent[] = [
|
||||
ctaLabel: "Quero um site para minha empresa",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["sc-plus", "nutriscan"],
|
||||
faq: [
|
||||
{
|
||||
question: "Quanto custa um site profissional?",
|
||||
answer:
|
||||
"O custo de um site profissional depende do escopo: número de páginas, funcionalidades, integrações e exigências de SEO técnico e performance. Um site institucional enxuto custa menos do que um sistema web completo. Como desenvolvedor de sites em Joinville e região, eu prefiro definir o orçamento depois de entender o objetivo do projeto, em uma conversa inicial sem compromisso.",
|
||||
},
|
||||
{
|
||||
question: "Site sob medida ou WordPress: qual escolher?",
|
||||
answer:
|
||||
"Escolha WordPress quando o conteúdo muda toda semana e alguém da equipe precisa publicar sem ajuda técnica; escolha um site sob medida quando performance, SEO técnico e evolução futura pesam mais. Sites feitos sob medida em Next.js carregam mais rápido, têm menos superfície de manutenção e escalam para sistema web sem migração. Para muitos negócios locais, o sob medida compensa pela velocidade e pela longevidade.",
|
||||
},
|
||||
{
|
||||
question: "Você faz manutenção de site depois do lançamento?",
|
||||
answer:
|
||||
"Sim. Além do lançamento, eu estruturo projetos pensando na manutenção do site: atualizações de dependências, ajustes de conteúdo, monitoramento de performance e evolução incremental de funcionalidades. Um site profissional não termina na publicação; ele precisa continuar rápido, seguro e bem posicionado. Por isso a entrega inclui orientação sobre indexação no Google e uma base de código organizada, fácil de evoluir.",
|
||||
},
|
||||
{
|
||||
question: "Você atende empresas só de Joinville ou de outras cidades?",
|
||||
answer:
|
||||
"Eu atendo empresas em Garuva, Joinville, São Francisco do Sul, Itapoá, Jaraguá do Sul, Balneário Barra do Sul, Barra Velha e Curitiba, além de projetos remotos em todo o Brasil. O trabalho funciona igual nos dois formatos: conversa inicial, levantamento de escopo e entregas acompanhadas de perto. Para buscas locais, cada site já sai com SEO técnico pensado para a cidade e o segmento do cliente.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "engenharia-de-software",
|
||||
navLabel: "Engenharia de Software",
|
||||
eyebrow: "Servico principal",
|
||||
seoTitle:
|
||||
"Engenharia de Software para Produtos e Sistemas Criticos | Evandro Fonseca Junior",
|
||||
eyebrow: "Serviço principal",
|
||||
seoTitle: "Engenharia de Software para Produtos e Sistemas Críticos",
|
||||
metaDescription:
|
||||
"Engenharia de software para evolucao de produtos, integracoes complexas e sistemas criticos. Entrega ponta a ponta com foco em arquitetura clara, estabilidade e velocidade de execucao.",
|
||||
"Engenharia de software para evolução de produtos, integrações complexas e sistemas críticos. Entrega ponta a ponta com foco em arquitetura clara, estabilidade e velocidade de execução.",
|
||||
headline:
|
||||
"Engenharia de software para evoluir produto sem aumentar o caos tecnico.",
|
||||
"Engenharia de software para evoluir produto sem aumentar o caos técnico.",
|
||||
intro:
|
||||
"Atuo em discovery, arquitetura, implementacao e sustentacao de software para empresas que precisam entregar com previsibilidade, sem sacrificar clareza tecnica nem operacao.",
|
||||
"Atuo em discovery, arquitetura, implementação e sustentação de software para empresas de Joinville, Curitiba e de todo o Brasil que precisam entregar com previsibilidade, sem sacrificar clareza técnica nem operação.",
|
||||
audience: [
|
||||
"Times que ja tem produto em operacao e precisam voltar a ganhar velocidade.",
|
||||
"Empresas que sofrem com integracoes, retrabalho e backlog tecnico acumulado.",
|
||||
"Operacoes em que confiabilidade e continuidade pesam mais do que velocidade teatral.",
|
||||
"Times que já tem produto em operação e precisam voltar a ganhar velocidade.",
|
||||
"Empresas que sofrem com integrações, retrabalho e backlog técnico acumulado.",
|
||||
"Operações em que confiabilidade e continuidade pesam mais do que velocidade teatral.",
|
||||
],
|
||||
outcomes: [
|
||||
"Arquitetura mais legivel para manter e escalar.",
|
||||
"Arquitetura mais legível para manter e escalar.",
|
||||
"Entrega incremental com menos retrabalho entre produto e engenharia.",
|
||||
"Integracoes mais robustas entre sistemas, APIs e fluxos internos.",
|
||||
"Base tecnica preparada para crescer sem depender de improviso constante.",
|
||||
"Integrações mais robustas entre sistemas, APIs e fluxos internos.",
|
||||
"Base técnica preparada para crescer sem depender de improviso constante.",
|
||||
],
|
||||
process: [
|
||||
"Diagnostico do contexto tecnico, do risco e do gargalo principal.",
|
||||
"Definicao do recorte de entrega com prioridades e trade-offs claros.",
|
||||
"Implementacao com rastreabilidade, code review e criterio de producao.",
|
||||
"Handoff ou continuidade com documentacao e proximo ciclo recomendado.",
|
||||
"Diagnóstico do contexto técnico, do risco e do gargalo principal.",
|
||||
"Definição do recorte de entrega com prioridades e trade-offs claros.",
|
||||
"Implementação com rastreabilidade, code review e critério de produção.",
|
||||
"Handoff ou continuidade com documentação e próximo ciclo recomendado.",
|
||||
],
|
||||
stack: ["Next.js", "TypeScript", "Node.js", "PostgreSQL", "APIs REST"],
|
||||
ctaLabel: "Agendar conversa sobre engenharia",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["nutriscan", "reddit-explorer", "sc-plus"],
|
||||
faq: [
|
||||
{
|
||||
question:
|
||||
"Como contratar um desenvolvedor para integração de sistemas?",
|
||||
answer:
|
||||
"Procure um desenvolvedor que comece pelo diagnóstico: entender os dois sistemas, os dados envolvidos e o impacto de falha antes de escrever código. Peça clareza sobre contratos de API, tratamento de erros e observabilidade, não apenas prazo. Na integração de sistemas, o critério principal é reduzir risco operacional; um profissional que explica trade-offs com honestidade costuma entregar com mais previsibilidade.",
|
||||
},
|
||||
{
|
||||
question: "Quanto custa desenvolver um sistema sob medida?",
|
||||
answer:
|
||||
"O investimento em um sistema sob medida acompanha a complexidade: regras de negócio, integrações, volume de usuários e requisitos de confiabilidade. Antes de qualquer proposta, eu faço um diagnóstico do contexto para dimensionar o recorte certo e evitar pagar por complexidade desnecessária. Assim o orçamento reflete o problema real, e não uma estimativa genérica por hora ou por tela.",
|
||||
},
|
||||
{
|
||||
question: "Você trabalha com sistemas legados?",
|
||||
answer:
|
||||
"Sim. Parte do trabalho de engenharia de software é justamente evoluir sistemas legados sem parar a operação: mapear acoplamentos, estabilizar pontos críticos e migrar aos poucos, com testes e rastreabilidade. Nem todo legado precisa ser reescrito; muitas vezes a decisão certa é isolar o que funciona e modernizar o que trava a entrega. O diagnóstico define esse recorte antes de qualquer refatoração.",
|
||||
},
|
||||
{
|
||||
question: "Como funciona o início do trabalho com você?",
|
||||
answer:
|
||||
"Tudo começa com uma conversa objetiva sobre o contexto: o produto, o gargalo e o risco. Depois disso eu proponho um recorte de entrega com prioridades e trade-offs claros, e a implementação segue com code review e critério de produção. Você acompanha o progresso em ciclos curtos, com documentação e handoff estruturado ao final de cada etapa.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "consultoria-nextjs",
|
||||
navLabel: "Consultoria Next.js",
|
||||
eyebrow: "Stack especializada",
|
||||
seoTitle:
|
||||
"Consultoria Next.js para Produtos, SEO e Performance | Evandro Fonseca Junior",
|
||||
seoTitle: "Consultoria Next.js para Produtos, SEO e Performance",
|
||||
metaDescription:
|
||||
"Consultoria Next.js para App Router, SEO tecnico, performance, arquitetura e evolucao de produto. Ideal para projetos que precisam escalar sem perder clareza e Core Web Vitals.",
|
||||
"Consultoria Next.js para App Router, SEO técnico, performance, arquitetura e evolução de produto. Ideal para projetos que precisam escalar sem perder clareza e Core Web Vitals.",
|
||||
headline:
|
||||
"Consultoria Next.js para produto, performance e SEO tecnico sem gambiarra.",
|
||||
"Consultoria Next.js para produto, performance e SEO técnico sem gambiarra.",
|
||||
intro:
|
||||
"Ajudo equipes a usar Next.js com mais criterio arquitetural, melhor separacao entre server e client, e foco real em performance, indexacao e experiencia de produto.",
|
||||
"Ajudo equipes a usar Next.js com mais critério arquitetural, melhor separação entre server e client, e foco real em performance, indexação e experiência de produto.",
|
||||
audience: [
|
||||
"Produtos em Next.js que perderam clareza com o crescimento da base.",
|
||||
"Sites e plataformas que dependem de SEO tecnico e performance para converter.",
|
||||
"Sites e plataformas que dependem de SEO técnico e performance para converter.",
|
||||
"Times que precisam migrar, estabilizar ou revisar arquitetura App Router.",
|
||||
],
|
||||
outcomes: [
|
||||
"Estrutura mais consistente para App Router, Server Components e dados.",
|
||||
"Melhoras praticas em title, metadata, schema, semantica e links internos.",
|
||||
"Reducao de gargalos em renderizacao, bundle e fetch.",
|
||||
"Base mais pronta para crescer sem regressao de SEO ou UX.",
|
||||
"Melhoras práticas em title, metadata, schema, semântica e links internos.",
|
||||
"Redução de gargalos em renderização, bundle e fetch.",
|
||||
"Base mais pronta para crescer sem regressão de SEO ou UX.",
|
||||
],
|
||||
process: [
|
||||
"Auditoria de arquitetura, rotas, metadata e performance.",
|
||||
"Priorizacao das correcoes de maior impacto comercial e tecnico.",
|
||||
"Implementacao ou acompanhamento do time interno nas partes criticas.",
|
||||
"Validacao final com build, semantica e consistencia entre paginas.",
|
||||
"Priorização das correções de maior impacto comercial e técnico.",
|
||||
"Implementação ou acompanhamento do time interno nas partes críticas.",
|
||||
"Validação final com build, semântica e consistência entre páginas.",
|
||||
],
|
||||
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO tecnico"],
|
||||
stack: ["Next.js", "React", "TypeScript", "Tailwind CSS", "SEO técnico"],
|
||||
ctaLabel: "Solicitar consultoria Next.js",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["nutriscan", "twins-lab", "sc-plus"],
|
||||
faq: [
|
||||
{
|
||||
question: "Next.js vale a pena para site institucional?",
|
||||
answer:
|
||||
"Vale, principalmente quando o site institucional precisa competir por busca orgânica. Next.js entrega renderização no servidor, Core Web Vitals bons e controle fino de metadata e dados estruturados, fatores que pesam no ranking. Para um site simples demais, pode ser mais stack do que o necessário; para quem quer performance, SEO técnico e caminho para evoluir a plataforma, é uma escolha sólida.",
|
||||
},
|
||||
{
|
||||
question: "Quando faz sentido contratar uma consultoria de Next.js?",
|
||||
answer:
|
||||
"Faz sentido quando o projeto cresceu e o time perdeu clareza: rotas confusas, mistura excessiva entre client e server, queda de performance ou regressão de SEO. Uma consultoria Next.js audita arquitetura e metadata, prioriza as correções de maior impacto e implementa junto com o time interno nas partes críticas. O retorno aparece em build mais previsível, indexação estável e menos retrabalho.",
|
||||
},
|
||||
{
|
||||
question: "Quanto custa uma consultoria de Next.js?",
|
||||
answer:
|
||||
"Depende do formato de trabalho: uma auditoria pontual de arquitetura e performance tem peso diferente de um acompanhamento contínuo ou da implementação completa das correções. O que define o investimento é o tamanho da base, o número de rotas críticas e a urgência dos gargalos. Depois de uma leitura inicial do projeto, eu consigo propor um escopo com expectativa clara de esforço.",
|
||||
},
|
||||
{
|
||||
question: "Você atende times remotos ou só presencial?",
|
||||
answer:
|
||||
"Sim, atendo equipes em Joinville, Curitiba e em qualquer cidade do Brasil, presencial ou remoto. A consultoria funciona bem à distância: auditoria documentada, sessões de alinhamento com o time e implementação guiada nas partes críticas. Para empresas da região Norte de Santa Catarina e de Curitiba, também há a opção de encontros presenciais nos momentos de diagnóstico e validação.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "tauri-rust",
|
||||
navLabel: "Tauri + Rust",
|
||||
eyebrow: "Desktop e performance",
|
||||
seoTitle:
|
||||
"Desenvolvimento Tauri e Rust para Apps Desktop | Evandro Fonseca Junior",
|
||||
seoTitle: "Desenvolvimento Tauri e Rust para Apps Desktop",
|
||||
metaDescription:
|
||||
"Desenvolvimento com Tauri e Rust para apps desktop mais leves, seguros e eficientes. Consultoria tecnica para produtos que precisam de performance, integracao local e distribuicao confiavel.",
|
||||
"Desenvolvimento com Tauri e Rust para apps desktop mais leves, seguros e eficientes. Consultoria técnica para produtos que precisam de performance, integração local e distribuição confiável.",
|
||||
headline:
|
||||
"Tauri e Rust para apps desktop mais leves, seguros e prontos para operar.",
|
||||
intro:
|
||||
"Uso Tauri e Rust quando o produto precisa de executavel desktop com footprint menor, melhor controle do ambiente local e mais previsibilidade do que stacks mais pesadas.",
|
||||
"Uso Tauri e Rust quando o produto precisa de executável desktop com footprint menor, melhor controle do ambiente local e mais previsibilidade do que stacks mais pesadas.",
|
||||
audience: [
|
||||
"Produtos desktop que precisam integrar recursos do sistema operacional.",
|
||||
"Ferramentas internas com demanda por distribuicao leve e manutencao simples.",
|
||||
"Times avaliando migracao de stack desktop ou construindo um MVP tecnico robusto.",
|
||||
"Ferramentas internas com demanda por distribuição leve e manutenção simples.",
|
||||
"Times avaliando migração de stack desktop ou construindo um MVP técnico robusto.",
|
||||
],
|
||||
outcomes: [
|
||||
"Aplicacoes desktop com melhor eficiencia de recursos.",
|
||||
"Fluxos locais e integracoes nativas com criterio tecnico mais alto.",
|
||||
"Arquitetura preparada para distribuicao, atualizacao e manutencao.",
|
||||
"Menos atrito entre interface moderna e motor de execucao confiavel.",
|
||||
"Aplicações desktop com melhor eficiência de recursos.",
|
||||
"Fluxos locais e integrações nativas com critério técnico mais alto.",
|
||||
"Arquitetura preparada para distribuição, atualização e manutenção.",
|
||||
"Menos atrito entre interface moderna e motor de execução confiável.",
|
||||
],
|
||||
process: [
|
||||
"Definicao da necessidade real de desktop e do papel de Rust no produto.",
|
||||
"Desenho da arquitetura entre frontend, camada nativa e distribuicao.",
|
||||
"Implementacao dos fluxos criticos com foco em confiabilidade e ergonomia.",
|
||||
"Empacotamento, validacao e estrategia de evolucao do aplicativo.",
|
||||
"Definição da necessidade real de desktop e do papel de Rust no produto.",
|
||||
"Desenho da arquitetura entre frontend, camada nativa e distribuição.",
|
||||
"Implementação dos fluxos críticos com foco em confiabilidade e ergonomia.",
|
||||
"Empacotamento, validação e estratégia de evolução do aplicativo.",
|
||||
],
|
||||
stack: [
|
||||
"Tauri",
|
||||
@@ -182,85 +246,151 @@ export const servicePages: ServicePageContent[] = [
|
||||
ctaLabel: "Conversar sobre Tauri e Rust",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["gcc-installer"],
|
||||
faq: [
|
||||
{
|
||||
question: "Vale a pena criar app desktop com Tauri e Rust?",
|
||||
answer:
|
||||
"Vale quando o aplicativo precisa ser leve, seguro e próximo do sistema operacional. Apps desktop em Tauri usam o WebView do próprio sistema em vez de embutir um navegador inteiro, o que reduz bastante o tamanho do executável e o consumo de memória. Rust entra onde há processamento local crítico. Se o produto é só um formulário em janela, talvez uma web app resolva.",
|
||||
},
|
||||
{
|
||||
question: "Tauri ou Electron: qual escolher?",
|
||||
answer:
|
||||
"Prefira Tauri quando distribuição leve, segurança e consumo de recursos importam: o executável final é muito menor porque reaproveita o WebView do sistema. Electron ainda faz sentido quando o time já domina Node.js e precisa de ecossistema maduro. Em projetos novos que exigem integração nativa e desempenho, eu costumo recomendar Tauri com lógica crítica em Rust e interface web moderna.",
|
||||
},
|
||||
{
|
||||
question: "Rust é difícil de manter para times pequenos?",
|
||||
answer:
|
||||
"Rust tem curva de aprendizado real, mas a manutenção tende a ficar mais fácil no longo prazo: o compilador elimina categorias inteiras de bugs antes da produção. Para times pequenos, a estratégia é usar Rust apenas na camada que justifica o investimento, como integração com o sistema, performance e segurança, e manter a interface em TypeScript. Assim a complexidade fica onde traz retorno.",
|
||||
},
|
||||
{
|
||||
question:
|
||||
"Você desenvolve apps desktop para empresas em Santa Catarina?",
|
||||
answer:
|
||||
"Sim. Eu desenvolvo aplicativos desktop para empresas em Joinville, Curitiba e em todo o Brasil, com atendimento presencial ou remoto. O fluxo cobre da arquitetura ao empacotamento: definição do papel de Rust, implementação dos fluxos críticos, estratégia de distribuição e atualização do executável. Ferramentas internas que precisam rodar em máquinas da operação são um caso muito comum nesse formato de trabalho.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "integracao-hardware-software-iot",
|
||||
navLabel: "Hardware + Software + IoT",
|
||||
eyebrow: "Produtos conectados",
|
||||
seoTitle: "Integracao Hardware + Software e IoT | Evandro Fonseca Junior",
|
||||
seoTitle: "Integração Hardware + Software e IoT",
|
||||
metaDescription:
|
||||
"Integracao hardware + software, IoT e fluxos conectados para produtos e operacoes criticas. Da coleta de dados ao painel, com APIs, telemetria e arquitetura de software confiavel.",
|
||||
"Integração hardware + software, IoT e fluxos conectados para produtos e operações críticas. Da coleta de dados ao painel, com APIs, telemetria e arquitetura de software confiável.",
|
||||
headline:
|
||||
"Integracao hardware + software para produtos conectados que precisam operar de verdade.",
|
||||
"Integração hardware + software para produtos conectados que precisam operar de verdade.",
|
||||
intro:
|
||||
"Atuo na camada que costuma quebrar primeiro: a fronteira entre dispositivo, backend, painel e operacao. Estruturo esses fluxos para que dados, comando e observabilidade funcionem como produto, nao como remendo.",
|
||||
"Atuo na camada que costuma quebrar primeiro: a fronteira entre dispositivo, backend, painel e operação. Estruturo esses fluxos para que dados, comando e observabilidade funcionem como produto, não como remendo.",
|
||||
audience: [
|
||||
"Empresas com dispositivos, sensores ou equipamentos conectados ao software.",
|
||||
"Operacoes que dependem de telemetria, dashboards e troca de dados em tempo real.",
|
||||
"Times que precisam ligar produto digital a processos fisicos com confiabilidade.",
|
||||
"Operações que dependem de telemetria, dashboards e troca de dados em tempo real.",
|
||||
"Times que precisam ligar produto digital a processos físicos com confiabilidade.",
|
||||
],
|
||||
outcomes: [
|
||||
"Fluxos mais robustos entre dispositivo, API, banco e interface.",
|
||||
"Menos perda de contexto entre operacao fisica e produto digital.",
|
||||
"Arquitetura com observabilidade e manutencao mais previsivel.",
|
||||
"Base melhor para evoluir IA, automacao ou analytics em cima dos dados.",
|
||||
"Menos perda de contexto entre operação física e produto digital.",
|
||||
"Arquitetura com observabilidade e manutenção mais previsível.",
|
||||
"Base melhor para evoluir IA, automação ou analytics em cima dos dados.",
|
||||
],
|
||||
process: [
|
||||
"Mapeamento de sinal, eventos, integracoes e pontos de falha.",
|
||||
"Definicao de contratos entre hardware, backend e interfaces.",
|
||||
"Implementacao do fluxo com testes e tratamento de excecoes relevantes.",
|
||||
"Entrega com documentacao de operacao, limites e proximo passo.",
|
||||
"Mapeamento de sinal, eventos, integrações e pontos de falha.",
|
||||
"Definição de contratos entre hardware, backend e interfaces.",
|
||||
"Implementação do fluxo com testes e tratamento de exceções relevantes.",
|
||||
"Entrega com documentação de operação, limites e próximo passo.",
|
||||
],
|
||||
stack: [
|
||||
"IoT",
|
||||
"WebSockets",
|
||||
"APIs",
|
||||
"Dashboards",
|
||||
"Integracao de sistemas",
|
||||
"Integração de sistemas",
|
||||
],
|
||||
ctaLabel: "Falar sobre integracao e IoT",
|
||||
ctaLabel: "Falar sobre integração e IoT",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["twins-lab"],
|
||||
faq: [
|
||||
{
|
||||
question: "Como integrar equipamentos e sistemas legados?",
|
||||
answer:
|
||||
"Comece mapeando o que o equipamento realmente expõe: portas seriais, sinais digitais, arquivos, protocolos industriais ou APIs antigas. A partir desse inventário, eu desenho uma camada de integração com contratos claros entre hardware, backend e interfaces, com tratamento de falha e observabilidade desde o primeiro dia. Sistemas legados raramente precisam ser substituídos; quase sempre precisam ser conectados com segurança.",
|
||||
},
|
||||
{
|
||||
question: "O que é integração hardware e software na prática?",
|
||||
answer:
|
||||
"É fazer o dado físico virar informação útil: o sensor registra, o backend valida e armazena, o painel mostra e o comando volta para o equipamento quando necessário. Na prática, isso envolve telemetria, WebSockets ou filas para tempo real, contratos de API e tratamento dos casos em que o dispositivo cai. Quando essa fronteira é bem desenhada, a operação física e o produto digital falam a mesma língua.",
|
||||
},
|
||||
{
|
||||
question: "Vale a pena investir em IoT na indústria?",
|
||||
answer:
|
||||
"Vale quando existe dado físico hoje desperdiçado: leitura manual, planilha isolada, decisão por estimativa. Projetos de IoT industriais transformam esse dado em telemetria e painéis que sustentam decisão em tempo real, especialmente em parques fabris como o de Joinville e região. O investimento só compensa, porém, quando a integração é tratada como produto, com observabilidade e manutenção previstas, e não como remendo pontual.",
|
||||
},
|
||||
{
|
||||
question: "Como funciona um projeto de integração com você?",
|
||||
answer:
|
||||
"O projeto começa com o mapeamento de sinal, eventos, integrações existentes e pontos de falha. Em seguida eu defino os contratos entre hardware, backend e interfaces, implemento o fluxo com testes e tratamento das exceções relevantes, e entrego com documentação de operação, limites conhecidos e próximo passo recomendado. Atendo indústrias e empresas de tecnologia em Joinville, Curitiba e remotamente.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "arquitetura-de-software",
|
||||
navLabel: "Arquitetura de Software",
|
||||
eyebrow: "Diagnostico e decisao",
|
||||
seoTitle:
|
||||
"Arquitetura de Software e Consultoria Tecnica | Evandro Fonseca Junior",
|
||||
eyebrow: "Diagnóstico e decisão",
|
||||
seoTitle: "Arquitetura de Software e Consultoria Técnica",
|
||||
metaDescription:
|
||||
"Arquitetura de software para reduzir complexidade, destravar evolucao e organizar integracoes criticas. Consultoria tecnica para produtos que precisam de direcao antes de mais codigo.",
|
||||
"Arquitetura de software para reduzir complexidade, destravar evolução e organizar integrações críticas. Consultoria técnica para produtos que precisam de direção antes de mais código.",
|
||||
headline:
|
||||
"Arquitetura de software para times que precisam de clareza antes de acelerar.",
|
||||
intro:
|
||||
"Quando o problema nao e falta de codigo, mas excesso de acoplamento, retrabalho e decisao mal distribuida, a arquitetura certa devolve velocidade, leitura e confianca ao time.",
|
||||
"Quando o problema não é falta de código, mas excesso de acoplamento, retrabalho e decisão mal distribuída, a arquitetura certa devolve velocidade, leitura e confiança ao time.",
|
||||
audience: [
|
||||
"Produtos que cresceram rapido e perderam legibilidade tecnica.",
|
||||
"Times travados entre refatorar tudo ou seguir acumulando divida.",
|
||||
"Operacoes com muitas dependencias, integracoes ou regras sensiveis.",
|
||||
"Produtos que cresceram rápido e perderam legibilidade técnica.",
|
||||
"Times travados entre refatorar tudo ou seguir acumulando dívida.",
|
||||
"Operações com muitas dependências, integrações ou regras sensíveis.",
|
||||
],
|
||||
outcomes: [
|
||||
"Mapa claro dos gargalos e do risco tecnico real.",
|
||||
"Prioridades de arquitetura ligadas a impacto de negocio.",
|
||||
"Plano executavel de refatoracao, estabilizacao ou reestruturacao.",
|
||||
"Mais qualidade de decisao entre produto, engenharia e operacao.",
|
||||
"Mapa claro dos gargalos e do risco técnico real.",
|
||||
"Prioridades de arquitetura ligadas a impacto de negócio.",
|
||||
"Plano executável de refatoração, estabilização ou reestruturação.",
|
||||
"Mais qualidade de decisão entre produto, engenharia e operação.",
|
||||
],
|
||||
process: [
|
||||
"Leitura da base, fluxos, integracoes e dores do time.",
|
||||
"Diagnostico escrito com pontos de risco, oportunidades e trade-offs.",
|
||||
"Proposta de arquitetura alvo e plano de transicao realista.",
|
||||
"Acompanhamento da execucao ou handoff estruturado para o time interno.",
|
||||
"Leitura da base, fluxos, integrações e dores do time.",
|
||||
"Diagnóstico escrito com pontos de risco, oportunidades e trade-offs.",
|
||||
"Proposta de arquitetura alvo e plano de transição realista.",
|
||||
"Acompanhamento da execução ou handoff estruturado para o time interno.",
|
||||
],
|
||||
stack: [
|
||||
"Arquitetura de software",
|
||||
"APIs",
|
||||
"Performance",
|
||||
"Integracoes",
|
||||
"Sistemas criticos",
|
||||
"Integrações",
|
||||
"Sistemas críticos",
|
||||
],
|
||||
ctaLabel: "Solicitar diagnostico arquitetural",
|
||||
ctaLabel: "Solicitar diagnóstico arquitetural",
|
||||
ctaHref: "/#contact",
|
||||
relatedCaseIds: ["nutriscan", "twins-lab", "reddit-explorer"],
|
||||
faq: [
|
||||
{
|
||||
question: "Quando investir em arquitetura de software?",
|
||||
answer:
|
||||
"Invista quando o custo de mudança começou a crescer mais rápido que o valor entregue: toda feature demora, todo deploy assusta e a dívida técnica vira pauta constante. Arquitetura de software vale o investimento nesse ponto, porque organiza acoplamento, integrações e responsabilidades antes que o problema multiplique. Antes do primeiro produto validado, geralmente basta uma base simples e bem separada.",
|
||||
},
|
||||
{
|
||||
question: "O que é dívida técnica e como resolver?",
|
||||
answer:
|
||||
"Dívida técnica é o acúmulo de decisões rápidas que hoje custam velocidade: código duplicado, acoplamento excessivo, falta de testes e integrações frágeis. Ela se resolve com diagnóstico antes de refatoração: mapear os gargalos reais, priorizar o que trava entrega e operação, e evoluir em incrementos seguros. Reescrever tudo raramente é a resposta; a maioria dos sistemas melhora com transição planejada.",
|
||||
},
|
||||
{
|
||||
question: "Como funciona um diagnóstico de arquitetura?",
|
||||
answer:
|
||||
"Eu leio a base de código, os fluxos, as integrações e as dores do time, e devolvo um diagnóstico escrito com pontos de risco, oportunidades e trade-offs explícitos. A partir dele vem a proposta de arquitetura alvo e um plano de transição realista, priorizado por impacto de negócio. O formato funciona tanto para times internos quanto para fundadores técnicos conduzindo a própria evolução.",
|
||||
},
|
||||
{
|
||||
question: "Você faz consultoria de arquitetura remota ou presencial?",
|
||||
answer:
|
||||
"Dos dois jeitos. Atendo empresas em Joinville, Curitiba e em todo o Brasil, com diagnóstico e acompanhamento funcionando bem à distância; encontros presenciais ficam para os momentos de maior densidade, como kickoff e validação. Como a entrega central é um diagnóstico escrito e um plano executável, o formato remoto não reduz a profundidade da análise nem a qualidade do handoff para o time.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -268,11 +398,11 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "nutriscan",
|
||||
title: "NutriScan",
|
||||
subtitle: "PWA com Next.js para analise nutricional e operacao offline",
|
||||
subtitle: "PWA com Next.js para análise nutricional e operação offline",
|
||||
summary:
|
||||
"Projeto em Next.js 14 com App Router, historico de uso, scanner de codigo de barras e cache estrategico para reduzir dependencia de rede.",
|
||||
"Projeto em Next.js 14 com App Router, histórico de uso, scanner de código de barras e cache estratégico para reduzir dependência de rede.",
|
||||
impact:
|
||||
"Bom exemplo de engenharia de software orientada a produto, performance e experiencia de uso em contexto real.",
|
||||
"Bom exemplo de engenharia de software orientada a produto, performance e experiência de uso em contexto real.",
|
||||
stack: ["Next.js 14", "TypeScript", "MongoDB", "PWA", "Tailwind CSS"],
|
||||
demoUrl: "https://nutriscan.evandro.dev.br",
|
||||
githubUrl: "https://github.com/evandrodevbr/nutriscan",
|
||||
@@ -282,9 +412,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
title: "Twins Lab",
|
||||
subtitle: "Plataforma IoT industrial com dados em tempo real",
|
||||
summary:
|
||||
"Produto com dashboards, autenticacao, WebSockets e camada de IA para monitoramento de dispositivos e operacao industrial conectada.",
|
||||
"Produto com dashboards, autenticação, WebSockets e camada de IA para monitoramento de dispositivos e operação industrial conectada.",
|
||||
impact:
|
||||
"Case forte para integracao hardware + software, telemetria e sistemas criticos com atualizacao em tempo real.",
|
||||
"Case forte para integração hardware + software, telemetria e sistemas críticos com atualização em tempo real.",
|
||||
stack: ["Next.js", "TypeScript", "NextAuth", "WebSockets", "OpenRouter AI"],
|
||||
demoUrl: "https://twins.evandro.dev.br",
|
||||
githubUrl: "https://github.com/evandrodevbr/twins-lab",
|
||||
@@ -292,11 +422,11 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
{
|
||||
id: "reddit-explorer",
|
||||
title: "Reddit Posts Explorer",
|
||||
subtitle: "Aplicacao orientada a dados com API, cache e busca",
|
||||
subtitle: "Aplicação orientada a dados com API, cache e busca",
|
||||
summary:
|
||||
"Projeto em Astro e MongoDB para explorar conteudo com filtros, ordenacao e cache, mantendo boa experiencia em consultas frequentes.",
|
||||
"Projeto em Astro e MongoDB para explorar conteúdo com filtros, ordenação e cache, mantendo boa experiência em consultas frequentes.",
|
||||
impact:
|
||||
"Mostra capacidade de desenhar consumo de API, camada de persistencia e experiencia de busca sem sobrecarregar a aplicacao.",
|
||||
"Mostra capacidade de desenhar consumo de API, camada de persistência e experiência de busca sem sobrecarregar a aplicação.",
|
||||
stack: ["Astro.js", "MongoDB", "Mongoose", "TypeScript", "Reddit API"],
|
||||
githubUrl: "https://github.com/evandrodevbr/reddit-search",
|
||||
},
|
||||
@@ -305,9 +435,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
title: "SC Plus Aquicultura",
|
||||
subtitle: "Site institucional em Next.js com foco em performance e SEO",
|
||||
summary:
|
||||
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura tecnica de indexacao.",
|
||||
"Landing page em Next.js 15 otimizada para Core Web Vitals, metadata e estrutura técnica de indexação.",
|
||||
impact:
|
||||
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO tecnico trabalham juntos.",
|
||||
"Exemplo de projeto em que clareza de mensagem, velocidade e SEO técnico trabalham juntos.",
|
||||
stack: ["Next.js 15", "React 19", "Tailwind CSS 4", "TypeScript"],
|
||||
demoUrl: "https://scplus.evandro.dev.br",
|
||||
githubUrl: "https://github.com/evandrodevbr/scplus",
|
||||
@@ -316,11 +446,11 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
id: "gcc-installer",
|
||||
title: "MinGW GCC Installer",
|
||||
subtitle:
|
||||
"Ferramenta desktop para automacao de ambiente de desenvolvimento",
|
||||
"Ferramenta desktop para automação de ambiente de desenvolvimento",
|
||||
summary:
|
||||
"Aplicacao em Python para simplificar setup local no Windows, com download assincrono, checagem de compatibilidade e integracao com PATH.",
|
||||
"Aplicação em Python para simplificar setup local no Windows, com download assíncrono, checagem de compatibilidade e integração com PATH.",
|
||||
impact:
|
||||
"Mostra familiaridade com software desktop, integracao com sistema operacional e automacao de setup tecnico.",
|
||||
"Mostra familiaridade com software desktop, integração com sistema operacional e automação de setup técnico.",
|
||||
stack: ["Python", "Tkinter", "Win32 API", "Threading", "7-Zip"],
|
||||
githubUrl: "https://github.com/evandrodevbr/gcc-installer",
|
||||
},
|
||||
@@ -329,9 +459,9 @@ export const caseStudies: CaseStudyContent[] = [
|
||||
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.",
|
||||
"Site institucional em Next.js 16 com cena 3D procedural em Three.js: uma casa wireframe que se transforma em placa-mãe conforme o scroll. Quatro áreas de engenharia — civil, elétrica, eletrônica/automação 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.",
|
||||
"Demonstra engenharia de software aplicada a presença digital: cena 3D gerada em runtime sem assets externos, animação dirigida por scroll e código 100% tipado.",
|
||||
stack: [
|
||||
"Next.js 16",
|
||||
"React 19",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const CV_URL = "/Profile.pdf";
|
||||
export const CV_DOWNLOAD_NAME = "Evandro_Fonseca_Junior_CV.pdf";
|
||||
|
||||
export const NAVIGATION_ITEMS = [
|
||||
{ href: "/#home", label: "Home" },
|
||||
{ href: "/cases", label: "Casos" },
|
||||
{ href: "/servicos", label: "Serviços" },
|
||||
{ href: "/curriculo", label: "Currículo" },
|
||||
{ href: "/#about", label: "Sobre" },
|
||||
{ href: "/#contact", label: "Contato" },
|
||||
] as const;
|
||||
+5
-1
@@ -13,7 +13,6 @@ const nextConfig: NextConfig = {
|
||||
root: process.cwd(),
|
||||
},
|
||||
|
||||
|
||||
// Headers de cache e segurança
|
||||
async headers(): Promise<
|
||||
Array<{ source: string; headers: Array<{ key: string; value: string }> }>
|
||||
@@ -35,6 +34,11 @@ const nextConfig: NextConfig = {
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=()",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy-Report-Only",
|
||||
value:
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.googletagmanager.com https://www.google-analytics.com; connect-src 'self' https://www.google-analytics.com https://analytics.google.com; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-ancestors 'none'; object-src 'none'; base-uri 'self'",
|
||||
},
|
||||
];
|
||||
|
||||
// HSTS apenas em produção com HTTPS
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cobe": "^0.6.4",
|
||||
"framer-motion": "^12.23.12",
|
||||
"gsap": "^3.13.0",
|
||||
"lenis": "^1.3.18",
|
||||
"lucide-react": "^0.542.0",
|
||||
|
||||
Generated
-3
@@ -57,9 +57,6 @@ importers:
|
||||
cobe:
|
||||
specifier: ^0.6.4
|
||||
version: 0.6.4
|
||||
framer-motion:
|
||||
specifier: ^12.23.12
|
||||
version: 12.23.12([email protected]([email protected]))([email protected])
|
||||
gsap:
|
||||
specifier: ^3.13.0
|
||||
version: 3.13.0
|
||||
|
||||
+1
-1
@@ -44,7 +44,7 @@ module.exports = {
|
||||
"border-light": "rgba(10,10,10,.06)",
|
||||
},
|
||||
fontFamily: {
|
||||
display: ["Syne", "sans-serif"],
|
||||
display: ["Space Grotesk", "sans-serif"],
|
||||
mono: ["Geist Mono", "monospace"],
|
||||
},
|
||||
fontSize: {
|
||||
|
||||
Reference in new issue
Block a user