feat: add animated components for enhanced UI/UX
- Introduced DrawLine component for drawing animations. - Added LazyRevealImage component for lazy loading images with reveal animations. - Implemented TextDiffuseReveal for animated text reveals with staggered effects. - Created TopProgressBar for a dynamic loading indicator. - Added PointCloud mock for testing animations. - Developed useReducedMotion hook to handle user preferences for reduced motion. - Centralized animation configurations in config.ts for easier management. - Defined animation variants in variants.ts for consistent animation effects. - Established AnimationProvider to supply global animation settings. - Created tests for AnimationProvider and useReducedMotion hook. - Added SVG assets for branding and UI elements. - Documented frontend review plan and hero circuit board animation plan.
This commit is contained in:
1 parent
0ab6930f92
commit
08dd046887
76 files changed
+21682
-1197
No files matched your search
+118
-48
@@ -1,17 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import profilePhoto from "@/public/edev.jpeg";
|
||||
import { Section } from "./Section";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||
|
||||
const STORY_DATA = [
|
||||
{
|
||||
id: "KERNEL_INIT",
|
||||
title: "Engenharia de fundamentos",
|
||||
id: "HARDWARE_BRIDGE",
|
||||
title: "Do físico ao digital",
|
||||
content:
|
||||
"Projeto sistemas com foco em previsibilidade, manutenção e crescimento real. Escala entra na arquitetura desde o início.",
|
||||
"Construo a ponte entre hardware e software. Projeto sistemas capazes de orquestrar dispositivos físicos, protocolos de baixo nível e interfaces web modernas com baixa latência e alta resiliência.",
|
||||
},
|
||||
{
|
||||
id: "RUNTIME_EXEC",
|
||||
title: "Entrega sob restrições",
|
||||
content:
|
||||
"Atuo bem em cenários com legado, prazos apertados e decisões de produto em movimento, sem sacrificar legibilidade técnica.",
|
||||
"Atuo com naturalidade em cenários críticos envolvendo integrações de hardware, APIs complexas e infraestrutura legada, sempre priorizando a estabilidade e a legibilidade técnica.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -19,77 +25,141 @@ const VALUES_DATA = [
|
||||
{
|
||||
title: "Pragmatismo arquitetural",
|
||||
description:
|
||||
"Decisões guiadas por ROI, operabilidade e clareza para o time que mantém o sistema depois.",
|
||||
"Decisões guiadas por contexto real e operabilidade. A melhor tecnologia é aquela que resolve o problema sem adicionar complexidade inútil ao sistema.",
|
||||
},
|
||||
{
|
||||
title: "Fundação técnica",
|
||||
title: "Fundação & Integração",
|
||||
description:
|
||||
"Fundamentos fortes em dados, protocolos e modelagem para evitar soluções frágeis e modismos vazios.",
|
||||
"Domínio de protocolos de rede e arquiteturas distribuídas para conectar desde microcontroladores até sistemas robustos na nuvem.",
|
||||
},
|
||||
{
|
||||
title: "Ownership real",
|
||||
title: "Ownership ponta a ponta",
|
||||
description:
|
||||
"Responsabilidade sobre entrega, comunicação e qualidade final, não só sobre o trecho de código escrito.",
|
||||
"Responsabilidade que atravessa camadas: do pulso elétrico no hardware até a renderização do último componente na tela do usuário.",
|
||||
},
|
||||
{
|
||||
title: "Security by design",
|
||||
title: "Security & Safety",
|
||||
description:
|
||||
"Integridade, proteção de dados e conformidade tratadas como parte do produto, não como remendo.",
|
||||
"Segurança e resiliência tratadas como base. Sistemas desenhados para falhar graciosamente e proteger dados do dispositivo à nuvem.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const staggerContainer: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: {
|
||||
duration: 0.8,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardHover = shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 18px 40px rgba(10,10,10,0.16)",
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="about"
|
||||
eyebrow="WHOAMI.sys"
|
||||
title="Engenharia orientada por clareza, não por ruído"
|
||||
description="Transformo complexidade técnica em sistemas estáveis, operáveis e alinhados ao negócio."
|
||||
title="Engenharia orientada por clareza, nao por ruido"
|
||||
description="Transformo complexidade tecnica em sistemas estaveis, operaveis e alinhados ao negocio."
|
||||
disableChildrenAnimation
|
||||
aside={
|
||||
<div className="mt-sp-4 ds-metric">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash" />
|
||||
Garuva, SC · Remote worldwide
|
||||
Garuva, SC | Remote worldwide
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="ds-grid-2">
|
||||
<div className="ds-grid-2">
|
||||
{STORY_DATA.map((step, index) => (
|
||||
<Reveal key={step.id} y={30} duration={0.8} delay={0.1 * index}>
|
||||
<article className="ds-card h-full">
|
||||
<div className="ds-kicker">{step.id}</div>
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy">{step.content}</p>
|
||||
</article>
|
||||
</Reveal>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<motion.div
|
||||
className="flex flex-col gap-sp-6"
|
||||
variants={staggerContainer}
|
||||
>
|
||||
{STORY_DATA.map((step) => (
|
||||
<motion.article
|
||||
key={step.id}
|
||||
className="ds-card"
|
||||
variants={cardVariants}
|
||||
whileHover={cardHover}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div className="ds-kicker">{step.id}</div>
|
||||
<DrawLine delay={0.03} />
|
||||
<h3 className="ds-card-title">{step.title}</h3>
|
||||
<p className="ds-copy">{step.content}</p>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<Reveal y={30} duration={0.8} delay={0.2}>
|
||||
<div className="ds-card h-full">
|
||||
<div className="ds-kicker">System Signature</div>
|
||||
<h3 className="ds-card-title">Base técnica e postura de entrega</h3>
|
||||
<p className="ds-copy">
|
||||
Evandro Fonseca Junior. Engenheiro Full-Stack com foco em Next.js,
|
||||
React, Node.js, Tauri, integrações e arquitetura pragmática.
|
||||
</p>
|
||||
<div className="ds-grid-2 mt-sp-6">
|
||||
{VALUES_DATA.map((value) => (
|
||||
<div
|
||||
key={value.title}
|
||||
className="border-t border-[var(--border)] pt-sp-4"
|
||||
>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.08em]">
|
||||
{value.title}
|
||||
</div>
|
||||
<p className="ds-copy !mt-sp-2">{value.description}</p>
|
||||
<motion.article
|
||||
className="ds-card h-full"
|
||||
variants={cardVariants}
|
||||
whileHover={cardHover}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<LazyRevealImage
|
||||
src={profilePhoto}
|
||||
alt="Retrato de Evandro"
|
||||
className="mb-sp-6 h-[220px] w-full overflow-hidden rounded-[6px] border border-[var(--border)]"
|
||||
imageClassName="object-cover"
|
||||
/>
|
||||
<div className="ds-kicker">System Signature</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">Base tecnica e postura de entrega</h3>
|
||||
<p className="ds-copy">
|
||||
Evandro Fonseca Junior. Engenheiro de Software Full-Stack com foco
|
||||
na convergência entre hardware e aplicações web modernas.
|
||||
Especialista em TypeScript, Node.js, ecossistema React e automação
|
||||
de dispositivos físicos.
|
||||
</p>
|
||||
<div className="ds-grid-2 mt-sp-6">
|
||||
{VALUES_DATA.map((value) => (
|
||||
<div
|
||||
key={value.title}
|
||||
className="border-t border-[var(--border)] pt-sp-4"
|
||||
>
|
||||
<div className="text-[11px] font-medium uppercase tracking-[0.08em]">
|
||||
{value.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="ds-copy !mt-sp-2">{value.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Reveal>
|
||||
</div>
|
||||
</motion.article>
|
||||
</motion.div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
import fc from "fast-check";
|
||||
|
||||
// Mock framer-motion with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
motion: {
|
||||
div: ({ children, animate, transition, ...props }: any) => {
|
||||
return <div {...props}>{children}</div>;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Property 10: Alt Text for Images", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have non-empty alt attributes for all company logo images", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image in the CompanyLogos component,
|
||||
* it should have a non-empty alt attribute describing the company.
|
||||
* This ensures accessibility for screen readers and assistive technologies.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify we found images
|
||||
if (logoImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all images have non-empty alt text
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have descriptive alt text that includes company name", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image, the alt text should be descriptive
|
||||
* and include identifying information about the company. This ensures
|
||||
* meaningful accessibility information is provided.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify we found images
|
||||
if (logoImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all alt text contains "logo" or is descriptive
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
if (!alt) return false;
|
||||
|
||||
const altLower = alt.toLowerCase();
|
||||
// Alt text should contain "logo" or be at least 5 characters (descriptive)
|
||||
return altLower.includes("logo") || alt.trim().length >= 5;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain alt text consistency across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any number of component renders, the alt text for each
|
||||
* company logo should remain consistent. This ensures stable accessibility
|
||||
* information across re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 2, max: 5 }), // Test 2-5 renders
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(numRenders, shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
let firstRenderAltTexts: string[] = [];
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
const currentAltTexts = Array.from(logoImages).map(
|
||||
(img) => img.getAttribute("alt") || "",
|
||||
);
|
||||
|
||||
if (i === 0) {
|
||||
firstRenderAltTexts = currentAltTexts;
|
||||
} else {
|
||||
// Compare with first render
|
||||
if (currentAltTexts.length !== firstRenderAltTexts.length) {
|
||||
unmount();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that alt texts match (accounting for duplicates in animated mode)
|
||||
const expectedCount = shouldReduce ? 5 : 10;
|
||||
if (currentAltTexts.length !== expectedCount) {
|
||||
unmount();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have unique alt text for each distinct company", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any set of company logos, each distinct company should have
|
||||
* its own unique alt text. This ensures each company is properly identified
|
||||
* for accessibility purposes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true; // Use static mode to get unique logos
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
const altTexts = Array.from(logoImages).map(
|
||||
(img) => img.getAttribute("alt") || "",
|
||||
);
|
||||
|
||||
// In static mode, we should have 5 unique alt texts
|
||||
const uniqueAltTexts = new Set(altTexts);
|
||||
|
||||
// Should have 5 unique alt texts (one per company)
|
||||
return uniqueAltTexts.size === 5 && altTexts.length === 5;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should preserve alt text in both animated and static modes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any reduced motion preference, all company logo images
|
||||
* should maintain their alt text attributes. The animation mode should
|
||||
* not affect accessibility attributes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify all images have alt attributes
|
||||
const allHaveAlt = Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
|
||||
// Verify correct number of images based on mode
|
||||
const expectedCount = shouldReduce ? 5 : 10;
|
||||
const hasCorrectCount = logoImages.length === expectedCount;
|
||||
|
||||
return allHaveAlt && hasCorrectCount;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have empty or whitespace-only alt text", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image, the alt text should not be empty
|
||||
* or consist only of whitespace. This ensures meaningful accessibility
|
||||
* information is always provided.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Check that no image has empty or whitespace-only alt text
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
|
||||
// Alt must exist and not be empty after trimming
|
||||
if (!alt || alt.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Alt should not be just whitespace
|
||||
return alt !== alt.trim() ? false : true;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have alt text that matches the expected company names", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image, the alt text should correspond to
|
||||
* one of the expected company names (Apolo Energia, Julia Beauty Lashes,
|
||||
* Innovo, TeraByteShop, Pichau). This ensures correct identification.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true; // Use static mode for clarity
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
const altTexts = Array.from(logoImages).map(
|
||||
(img) => img.getAttribute("alt") || "",
|
||||
);
|
||||
|
||||
// Expected company names in alt text
|
||||
const expectedCompanies = [
|
||||
"Apolo Energia",
|
||||
"Julia Beauty Lashes",
|
||||
"Innovo",
|
||||
"TeraByteShop",
|
||||
"Pichau",
|
||||
];
|
||||
|
||||
// Check that each alt text contains one of the expected company names
|
||||
return altTexts.every((alt) =>
|
||||
expectedCompanies.some((company) => alt.includes(company)),
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain alt text when images fail to load", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.2**
|
||||
*
|
||||
* Property: For any company logo image that fails to load, the alt text
|
||||
* should still be present in the DOM. This ensures accessibility is
|
||||
* maintained even when images are unavailable.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify all images have alt text before any error handling
|
||||
const allHaveAltBeforeError = Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
|
||||
// The alt attribute should be present regardless of load state
|
||||
return allHaveAltBeforeError;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
import fc from "fast-check";
|
||||
|
||||
// Mock framer-motion with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
motion: {
|
||||
div: ({ children, animate, transition, ...props }: any) => {
|
||||
// Capture animation props for testing
|
||||
const animationProps = {
|
||||
"data-animate": JSON.stringify(animate),
|
||||
"data-transition": JSON.stringify(transition),
|
||||
};
|
||||
return (
|
||||
<div {...props} {...animationProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("Property 4: Reduced Motion Respect", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should render static grid layout when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any reduced motion preference, when useReducedMotion() returns true,
|
||||
* the CompanyLogos component should render a static grid layout without motion
|
||||
* animations. This ensures accessibility compliance with prefers-reduced-motion.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for animated motion.div with animation props
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
|
||||
// Check for static grid layout
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
|
||||
if (shouldReduce) {
|
||||
// When reduced motion is enabled:
|
||||
// - Should NOT have animated elements
|
||||
// - Should have static grid layout
|
||||
return animatedElements.length === 0 && staticGrid !== null;
|
||||
} else {
|
||||
// When reduced motion is disabled:
|
||||
// - Should have animated elements
|
||||
// - Should NOT have static grid layout
|
||||
return animatedElements.length > 0 && staticGrid === null;
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should exclude horizontal scroll animation when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any component render with reduced motion enabled, the horizontal
|
||||
* scroll animation (x: [0, '-50%']) should be completely absent. This verifies
|
||||
* that motion animations are properly disabled.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed
|
||||
() => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for animation props
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
|
||||
let hasScrollAnimation = false;
|
||||
|
||||
animatedElements.forEach((element) => {
|
||||
const animateValue = element.getAttribute("data-animate");
|
||||
if (animateValue && animateValue !== "undefined") {
|
||||
try {
|
||||
const props = JSON.parse(animateValue);
|
||||
|
||||
// Check for x animation (horizontal scroll)
|
||||
if (props.x !== undefined) {
|
||||
hasScrollAnimation = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Should not have scroll animation when reduced motion is enabled
|
||||
return !hasScrollAnimation;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should display all company logos in both animated and static modes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any reduced motion preference, all company logos should be
|
||||
* displayed. The reduced motion preference should only affect the animation
|
||||
* style, not the content visibility.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll('img[alt*="logo"]');
|
||||
|
||||
// Should have at least 5 logos (the original set)
|
||||
// In animated mode, we have duplicates, so it will be 10
|
||||
// In static mode, we have exactly 5
|
||||
if (shouldReduce) {
|
||||
return logoImages.length === 5;
|
||||
} else {
|
||||
return logoImages.length === 10; // Duplicated for infinite scroll
|
||||
}
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain accessibility attributes regardless of reduced motion preference", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3, 6.2**
|
||||
*
|
||||
* Property: For any reduced motion preference, all company logo images should
|
||||
* maintain proper alt text attributes. Accessibility should not be compromised
|
||||
* by the animation mode.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.boolean(), // Random reduced motion preference
|
||||
(shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Find all logo images
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
// Verify we found images
|
||||
if (logoImages.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all images have non-empty alt text
|
||||
return Array.from(logoImages).every((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
return alt !== null && alt.trim().length > 0;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should consistently apply reduced motion across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: For any number of component renders with reduced motion enabled,
|
||||
* the static layout should be consistently rendered without animations.
|
||||
* This verifies that the reduced motion behavior is stable across re-renders.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 1, max: 5 }), // Test 1-5 renders
|
||||
(numRenders) => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
let allRendersValid = true;
|
||||
|
||||
for (let i = 0; i < numRenders; i++) {
|
||||
const { container, unmount } = render(<CompanyLogos />);
|
||||
|
||||
// Check for animated elements
|
||||
const animatedElements =
|
||||
container.querySelectorAll("[data-animate]");
|
||||
|
||||
// Check for static grid
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
|
||||
// Should have no animations and should have static grid
|
||||
if (animatedElements.length > 0 || staticGrid === null) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should use grid layout classes when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: When reduced motion is enabled, the component should use CSS grid
|
||||
* layout classes (grid, grid-cols-*) to display logos in a static arrangement.
|
||||
* This ensures proper visual presentation without animation.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for grid layout classes
|
||||
const gridContainer = container.querySelector(".grid");
|
||||
|
||||
if (!gridContainer) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify grid has responsive column classes
|
||||
const hasGridCols = gridContainer.className.includes("grid-cols-");
|
||||
|
||||
return hasGridCols;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have infinite repeat transition when reduced motion is enabled", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: When reduced motion is enabled, the component should not have
|
||||
* transition configurations with infinite repeat. This ensures no continuous
|
||||
* motion occurs when users prefer reduced motion.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Check for transition props
|
||||
const elementsWithTransition =
|
||||
container.querySelectorAll("[data-transition]");
|
||||
|
||||
let hasInfiniteRepeat = false;
|
||||
|
||||
elementsWithTransition.forEach((element) => {
|
||||
const transitionValue = element.getAttribute("data-transition");
|
||||
if (transitionValue && transitionValue !== "undefined") {
|
||||
try {
|
||||
const props = JSON.parse(transitionValue);
|
||||
|
||||
// Check for infinite repeat
|
||||
if (props.repeat === Infinity || props.repeat === "Infinity") {
|
||||
hasInfiniteRepeat = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Skip invalid JSON
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Should not have infinite repeat when reduced motion is enabled
|
||||
return !hasInfiniteRepeat;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should switch between animated and static modes based on reduced motion preference", () => {
|
||||
/**
|
||||
* **Validates: Requirements 3.6, 6.3**
|
||||
*
|
||||
* Property: The component should render different layouts based on the reduced
|
||||
* motion preference. When the preference changes, the component should adapt
|
||||
* accordingly. This verifies the conditional rendering logic.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.boolean(), { minLength: 2, maxLength: 5 }), // Sequence of preference changes
|
||||
(preferenceSequence) => {
|
||||
let allTransitionsValid = true;
|
||||
|
||||
preferenceSequence.forEach((shouldReduce) => {
|
||||
mockReducedMotion = shouldReduce;
|
||||
|
||||
const { container, unmount } = render(<CompanyLogos />);
|
||||
|
||||
const hasAnimations =
|
||||
container.querySelectorAll("[data-animate]").length > 0;
|
||||
const hasStaticGrid = container.querySelector(".grid") !== null;
|
||||
|
||||
// Verify correct mode is rendered
|
||||
if (shouldReduce) {
|
||||
if (hasAnimations || !hasStaticGrid) {
|
||||
allTransitionsValid = false;
|
||||
}
|
||||
} else {
|
||||
if (!hasAnimations || hasStaticGrid) {
|
||||
allTransitionsValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
return allTransitionsValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import CompanyLogos from "./CompanyLogos";
|
||||
|
||||
// Mock framer-motion with configurable useReducedMotion
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const actual = jest.requireActual("framer-motion");
|
||||
return {
|
||||
...actual,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
motion: {
|
||||
div: ({ children, animate, transition, ...props }: any) => {
|
||||
// Capture animation props for testing
|
||||
const animationProps = {
|
||||
"data-animate": JSON.stringify(animate),
|
||||
"data-transition": JSON.stringify(transition),
|
||||
};
|
||||
return (
|
||||
<div {...props} {...animationProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Logo Rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should render all five company logos", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// In animated mode, logos are duplicated (10 total)
|
||||
const logoImages = container.querySelectorAll('img[alt*="logo"]');
|
||||
expect(logoImages.length).toBe(10); // 5 logos x 2 (duplicated for infinite scroll)
|
||||
});
|
||||
|
||||
it("should render Apolo Energia logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
|
||||
expect(apoloLogos.length).toBe(2); // Duplicated for infinite scroll
|
||||
expect(apoloLogos[0]).toHaveAttribute("src", "/logos/apolo.svg");
|
||||
});
|
||||
|
||||
it("should render Julia Beauty Lashes logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
|
||||
expect(juliaLogos.length).toBe(2);
|
||||
expect(juliaLogos[0]).toHaveAttribute("src", "/logos/julia.svg");
|
||||
});
|
||||
|
||||
it("should render Innovo logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const innovoLogos = screen.getAllByAltText("Innovo logo");
|
||||
expect(innovoLogos.length).toBe(2);
|
||||
expect(innovoLogos[0]).toHaveAttribute("src", "/logos/innovo.svg");
|
||||
});
|
||||
|
||||
it("should render TeraByteShop logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
|
||||
expect(terabyteLogos.length).toBe(2);
|
||||
expect(terabyteLogos[0]).toHaveAttribute("src", "/logos/terabyte.svg");
|
||||
});
|
||||
|
||||
it("should render Pichau logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
const pichauLogos = screen.getAllByAltText("Pichau logo");
|
||||
expect(pichauLogos.length).toBe(2);
|
||||
expect(pichauLogos[0]).toHaveAttribute("src", "/logos/pichau.svg");
|
||||
});
|
||||
|
||||
it("should have alt text for all logo images", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
expect(alt).toBeTruthy();
|
||||
expect(alt?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should duplicate logo array for seamless infinite scroll", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Each company should appear exactly twice
|
||||
const apoloLogos = screen.getAllByAltText("Apolo Energia logo");
|
||||
const juliaLogos = screen.getAllByAltText("Julia Beauty Lashes logo");
|
||||
const innovoLogos = screen.getAllByAltText("Innovo logo");
|
||||
const terabyteLogos = screen.getAllByAltText("TeraByteShop logo");
|
||||
const pichauLogos = screen.getAllByAltText("Pichau logo");
|
||||
|
||||
expect(apoloLogos.length).toBe(2);
|
||||
expect(juliaLogos.length).toBe(2);
|
||||
expect(innovoLogos.length).toBe(2);
|
||||
expect(terabyteLogos.length).toBe(2);
|
||||
expect(pichauLogos.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Animation Configuration", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have horizontal scroll animation configuration", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-animate]");
|
||||
expect(animatedElement).toBeInTheDocument();
|
||||
|
||||
const animateValue = animatedElement?.getAttribute("data-animate");
|
||||
expect(animateValue).toBeTruthy();
|
||||
|
||||
const animateProps = JSON.parse(animateValue!);
|
||||
expect(animateProps.x).toEqual([0, "-50%"]);
|
||||
});
|
||||
|
||||
it("should have 30-second animation duration", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-transition]");
|
||||
expect(animatedElement).toBeInTheDocument();
|
||||
|
||||
const transitionValue = animatedElement?.getAttribute("data-transition");
|
||||
expect(transitionValue).toBeTruthy();
|
||||
|
||||
const transitionProps = JSON.parse(transitionValue!);
|
||||
expect(transitionProps.duration).toBe(30);
|
||||
});
|
||||
|
||||
it("should have infinite repeat configuration", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-transition]");
|
||||
const transitionValue = animatedElement?.getAttribute("data-transition");
|
||||
const transitionProps = JSON.parse(transitionValue!);
|
||||
|
||||
// JSON.stringify converts Infinity to null, so we check for null
|
||||
expect(transitionProps.repeat).toBe(null);
|
||||
});
|
||||
|
||||
it("should use linear easing for smooth animation", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-transition]");
|
||||
const transitionValue = animatedElement?.getAttribute("data-transition");
|
||||
const transitionProps = JSON.parse(transitionValue!);
|
||||
|
||||
expect(transitionProps.ease).toBe("linear");
|
||||
});
|
||||
|
||||
it("should have flex layout with gap-sp-12 for logo spacing", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-animate]");
|
||||
const classes = animatedElement?.className || "";
|
||||
|
||||
expect(classes).toContain("flex");
|
||||
expect(classes).toContain("gap-sp-12");
|
||||
});
|
||||
|
||||
it("should have items-center alignment", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElement = container.querySelector("[data-animate]");
|
||||
const classes = animatedElement?.className || "";
|
||||
|
||||
expect(classes).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Reduced Motion Fallback", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should render static grid when reduced motion is enabled", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
expect(staticGrid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not have animated elements when reduced motion is enabled", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
expect(animatedElements.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should render exactly 5 logos in static mode (no duplication)", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll('img[alt*="logo"]');
|
||||
expect(logoImages.length).toBe(5);
|
||||
});
|
||||
|
||||
it("should have responsive grid columns in static mode", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
const classes = staticGrid?.className || "";
|
||||
|
||||
expect(classes).toContain("grid-cols-2");
|
||||
expect(classes).toContain("sm:grid-cols-3");
|
||||
expect(classes).toContain("md:grid-cols-5");
|
||||
});
|
||||
|
||||
it("should have gap-sp-6 spacing in static grid", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
const classes = staticGrid?.className || "";
|
||||
|
||||
expect(classes).toContain("gap-sp-6");
|
||||
});
|
||||
|
||||
it("should maintain all logo alt text in static mode", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const alt = img.getAttribute("alt");
|
||||
expect(alt).toBeTruthy();
|
||||
expect(alt?.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Missing Logo Image Handling", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have onError handler for logo images", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
// Check that the image element has an onError handler
|
||||
// In React, this is attached as a prop, so we verify the element exists
|
||||
expect(img).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should apply grayscale filter to logo images", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("grayscale");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have hover effect to remove grayscale", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("hover:grayscale-0");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have opacity transition on hover", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("opacity-60");
|
||||
expect(classes).toContain("hover:opacity-100");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have transition-all for smooth hover effects", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("transition-all");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Responsive Layout", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have section element as container", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have full width class", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("w-full");
|
||||
});
|
||||
|
||||
it("should have overflow-hidden for animation containment", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("overflow-hidden");
|
||||
});
|
||||
|
||||
it("should have border-b for visual separation", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("border-b");
|
||||
});
|
||||
|
||||
it("should have background color class", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
const classes = section?.className || "";
|
||||
|
||||
expect(classes).toContain("bg-[var(--paper)]");
|
||||
});
|
||||
|
||||
it("should have py-sp-8 vertical padding", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// In animated mode, padding is on inner div
|
||||
const innerDiv = container.querySelector(".py-sp-8");
|
||||
expect(innerDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have responsive padding in static mode", () => {
|
||||
mockReducedMotion = true;
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const innerContainer = container.querySelector(".container");
|
||||
const classes = innerContainer?.className || "";
|
||||
|
||||
expect(classes).toContain("px-sp-6");
|
||||
expect(classes).toContain("py-sp-8");
|
||||
expect(classes).toContain("md:px-sp-10");
|
||||
expect(classes).toContain("lg:px-sp-16");
|
||||
});
|
||||
|
||||
it("should have mx-auto for centering in static mode", () => {
|
||||
mockReducedMotion = true;
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const innerContainer = container.querySelector(".container");
|
||||
const classes = innerContainer?.className || "";
|
||||
|
||||
expect(classes).toContain("mx-auto");
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Logo Sizing and Styling", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should have h-12 height for all logos", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("h-12");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have w-auto width for aspect ratio preservation", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("w-auto");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have object-contain for proper image scaling", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoImages = container.querySelectorAll("img");
|
||||
|
||||
logoImages.forEach((img) => {
|
||||
const classes = img.className;
|
||||
expect(classes).toContain("object-contain");
|
||||
});
|
||||
});
|
||||
|
||||
it("should have flex-shrink-0 on logo containers in animated mode", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoContainers = container.querySelectorAll("[data-animate] > div");
|
||||
|
||||
logoContainers.forEach((div) => {
|
||||
const classes = div.className;
|
||||
expect(classes).toContain("flex-shrink-0");
|
||||
});
|
||||
});
|
||||
|
||||
it("should center logos within their containers", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const logoContainers = container.querySelectorAll("[data-animate] > div");
|
||||
|
||||
logoContainers.forEach((div) => {
|
||||
const classes = div.className;
|
||||
expect(classes).toContain("flex");
|
||||
expect(classes).toContain("items-center");
|
||||
expect(classes).toContain("justify-center");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CompanyLogos Component - Accessibility", () => {
|
||||
beforeEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("should use semantic section element", () => {
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
const section = container.querySelector("section");
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have descriptive alt text for each logo", () => {
|
||||
render(<CompanyLogos />);
|
||||
|
||||
expect(screen.getAllByAltText("Apolo Energia logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("Julia Beauty Lashes logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("Innovo logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("TeraByteShop logo")).toHaveLength(2);
|
||||
expect(screen.getAllByAltText("Pichau logo")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should respect reduced motion preference", () => {
|
||||
mockReducedMotion = true;
|
||||
const { container } = render(<CompanyLogos />);
|
||||
|
||||
// Should render static grid instead of animation
|
||||
const staticGrid = container.querySelector(".grid");
|
||||
const animatedElements = container.querySelectorAll("[data-animate]");
|
||||
|
||||
expect(staticGrid).toBeInTheDocument();
|
||||
expect(animatedElements.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should maintain content visibility in both modes", () => {
|
||||
// Test animated mode
|
||||
mockReducedMotion = false;
|
||||
const { container: animatedContainer } = render(<CompanyLogos />);
|
||||
const animatedLogos =
|
||||
animatedContainer.querySelectorAll('img[alt*="logo"]');
|
||||
|
||||
// Test static mode
|
||||
mockReducedMotion = true;
|
||||
const { container: staticContainer } = render(<CompanyLogos />);
|
||||
const staticLogos = staticContainer.querySelectorAll('img[alt*="logo"]');
|
||||
|
||||
// Both modes should display logos (different counts due to duplication)
|
||||
expect(animatedLogos.length).toBeGreaterThan(0);
|
||||
expect(staticLogos.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* CompanyLogos Component
|
||||
*
|
||||
* Displays an infinite-scrolling banner of company logos after the header.
|
||||
* Features smooth horizontal animation with reduced motion support.
|
||||
*
|
||||
* Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8
|
||||
*/
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Image from "next/image";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
|
||||
// TypeScript interface for company logos
|
||||
export interface CompanyLogo {
|
||||
name: string; // Company name
|
||||
imagePath: string; // Path to logo image (SVG preferred)
|
||||
alt: string; // Alt text for accessibility
|
||||
}
|
||||
|
||||
// Company logos data
|
||||
const companies: CompanyLogo[] = [
|
||||
{
|
||||
name: "Apolo Energia",
|
||||
imagePath: "/logos/apolo.svg",
|
||||
alt: "Apolo Energia logo",
|
||||
},
|
||||
{
|
||||
name: "Julia Beauty Lashes",
|
||||
imagePath: "/logos/julia.svg",
|
||||
alt: "Julia Beauty Lashes logo",
|
||||
},
|
||||
{
|
||||
name: "Innovo",
|
||||
imagePath: "/logos/innovo.svg",
|
||||
alt: "Innovo logo",
|
||||
},
|
||||
{
|
||||
name: "TeraByteShop",
|
||||
imagePath: "/logos/terabyte.svg",
|
||||
alt: "TeraByteShop logo",
|
||||
},
|
||||
{
|
||||
name: "Pichau",
|
||||
imagePath: "/logos/pichau.svg",
|
||||
alt: "Pichau logo",
|
||||
},
|
||||
];
|
||||
|
||||
export default function CompanyLogos() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
// Duplicate logo array for seamless infinite scroll
|
||||
const duplicatedLogos = [...companies, ...companies];
|
||||
|
||||
// Static grid for reduced motion preference
|
||||
if (shouldReduceMotion) {
|
||||
return (
|
||||
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
|
||||
<div className="container mx-auto px-sp-6 py-sp-8 md:px-sp-10 lg:px-sp-16">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-sp-6 items-center justify-items-center">
|
||||
{companies.map((logo) => (
|
||||
<div key={logo.name} className="flex items-center justify-center">
|
||||
<Image
|
||||
src={logo.imagePath}
|
||||
alt={logo.alt}
|
||||
width={150}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain grayscale opacity-60"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// Animated infinite scroll
|
||||
return (
|
||||
<section className="w-full border-b border-[rgba(255,255,255,0.06)] bg-[var(--paper)] overflow-hidden">
|
||||
<div className="py-sp-8">
|
||||
<motion.div
|
||||
className="flex gap-sp-12 items-center"
|
||||
animate={{
|
||||
x: [0, "-50%"],
|
||||
}}
|
||||
transition={{
|
||||
duration: 30,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{duplicatedLogos.map((logo, index) => (
|
||||
<div
|
||||
key={`${logo.name}-${index}`}
|
||||
className="flex-shrink-0 flex items-center justify-center"
|
||||
>
|
||||
<Image
|
||||
src={logo.imagePath}
|
||||
alt={logo.alt}
|
||||
width={150}
|
||||
height={48}
|
||||
className="h-12 w-auto object-contain grayscale hover:grayscale-0 opacity-60 hover:opacity-100 transition-all duration-300"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+82
-14
@@ -2,11 +2,14 @@
|
||||
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { Section } from "./Section";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
|
||||
const WHATSAPP_NUMBER = "5547992742365";
|
||||
|
||||
export default function Contact() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [formData, setFormData] = useState({
|
||||
name: "",
|
||||
email: "",
|
||||
@@ -25,30 +28,88 @@ export default function Contact() {
|
||||
window.open(whatsappUrl, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.05,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 18, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="contact"
|
||||
eyebrow="CONTACT_PROTOCOL.init"
|
||||
title="Vamos discutir o próximo sistema"
|
||||
title="Vamos discutir o proximo sistema"
|
||||
description="Descreva o contexto, o gargalo ou o produto. O contato abre direto no WhatsApp com briefing estruturado."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="ds-grid-2">
|
||||
<div className="ds-card">
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
<motion.div
|
||||
className="ds-card"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div className="ds-kicker">Response SLA</div>
|
||||
<h3 className="ds-card-title">Retorno inicial em até 24h</h3>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title">Retorno inicial em ate 24h</h3>
|
||||
<p className="ds-copy">
|
||||
Ideal para discovery técnico, revisão de arquitetura, rescue de
|
||||
projeto, otimização de performance ou construção de produto do zero.
|
||||
Ideal para discovery tecnico, revisao de arquitetura, rescue de
|
||||
projeto, otimizacao de performance ou construcao de produto do zero.
|
||||
</p>
|
||||
<div className="mt-sp-6 space-y-sp-4 text-[12px] leading-7 text-[rgba(255,255,255,.7)]">
|
||||
<div>WhatsApp: +55 47 99274-2365</div>
|
||||
<div>E-mail: evandrofonsecajunior@gmail.com</div>
|
||||
<div>Base: Garuva, Santa Catarina · atuação remota</div>
|
||||
<div>Base: Garuva, Santa Catarina | atuacao remota</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="ds-card space-y-sp-5">
|
||||
<motion.form
|
||||
onSubmit={handleSubmit}
|
||||
className="ds-card space-y-sp-5"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 42px rgba(0, 0, 0, 0.4)",
|
||||
}
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="contact-name" className="ds-kicker block">
|
||||
Nome completo
|
||||
@@ -103,7 +164,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 Técnica Sênior</option>
|
||||
<option value="consultancy">Consultoria Tecnica Senior</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -125,11 +186,18 @@ export default function Contact() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="ds-link ds-link-accent border-0">
|
||||
|
||||
<motion.button
|
||||
type="submit"
|
||||
className="ds-link ds-link-accent border-0"
|
||||
whileHover={shouldReduceMotion ? undefined : { scale: 1.03 }}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
||||
>
|
||||
Iniciar protocolo de contato
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</motion.button>
|
||||
</motion.form>
|
||||
</motion.div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
+128
-140
@@ -14,18 +14,23 @@ export function ContactForm({ formType, setFormType }: ContactFormProps) {
|
||||
const [contactMethod, setContactMethod] = useState<"whatsapp" | "email">(
|
||||
"whatsapp",
|
||||
);
|
||||
const [userName, setUserName] = useState<string>("");
|
||||
const [userEmail, setUserEmail] = useState<string>("");
|
||||
const [userPhone, setUserPhone] = useState<string>("");
|
||||
const [userCompany, setUserCompany] = useState<string>("");
|
||||
const [howFound, setHowFound] = useState<string>("");
|
||||
const [projectType, setProjectType] = useState<string>("");
|
||||
const [otherProjectType, setOtherProjectType] = useState<string>(""); // Novo estado para "Outro" tipo de projeto
|
||||
const [desiredDeadline, setDesiredDeadline] = useState<string>("");
|
||||
const [budgetValue, setBudgetValue] = useState<string>(""); // Novo estado para o valor do orçamento
|
||||
const [technicalComplexity, setTechnicalComplexity] = useState<string>("");
|
||||
const [hasInternalTeam, setHasInternalTeam] = useState<string>("");
|
||||
const [customMessage, setCustomMessage] = useState<string>("");
|
||||
|
||||
// MENTORIA: Agrupamento de estado para reduzir complexidade e re-renders
|
||||
const [formData, setFormData] = useState({
|
||||
userName: "",
|
||||
userEmail: "",
|
||||
userPhone: "",
|
||||
userCompany: "",
|
||||
howFound: "",
|
||||
projectType: "",
|
||||
otherProjectType: "",
|
||||
desiredDeadline: "",
|
||||
budgetValue: "",
|
||||
technicalComplexity: "",
|
||||
hasInternalTeam: "",
|
||||
customMessage: "",
|
||||
});
|
||||
|
||||
const [isDecisor, setIsDecisor] = useState<string>("");
|
||||
const [projectApproved, setProjectApproved] = useState<string>("");
|
||||
const [urgency, setUrgency] = useState<string>("");
|
||||
@@ -62,31 +67,31 @@ export function ContactForm({ formType, setFormType }: ContactFormProps) {
|
||||
|
||||
return `Olá Evandro, espero que esta ${dayPart} esteja sendo produtiva.
|
||||
|
||||
Meu nome é ${userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
|
||||
Meu nome é ${formData.userName} e entrei em contato pois vi seu portfólio e identifiquei que suas habilidades como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.
|
||||
|
||||
Gostaria de agendar uma conversa para discutirmos detalhes e entender como podemos trabalhar juntos de forma eficiente e produtiva.
|
||||
|
||||
Fico no aguardo do seu contato para marcarmos um horário que seja conveniente para ambos.
|
||||
|
||||
Atenciosamente,
|
||||
${userName}`;
|
||||
${formData.userName}`;
|
||||
};
|
||||
|
||||
const generateAdvancedMessage = () => {
|
||||
if (customMessage) return customMessage;
|
||||
if (formData.customMessage) return formData.customMessage;
|
||||
|
||||
// Saudação profissional
|
||||
let message = `Prezado Evandro,\n\n`;
|
||||
|
||||
// Informações do contato
|
||||
message += `Meu nome é ${userName}`;
|
||||
if (userCompany) {
|
||||
message += `, represento a empresa ${userCompany}`;
|
||||
message += `Meu nome é ${formData.userName}`;
|
||||
if (formData.userCompany) {
|
||||
message += `, represento a empresa ${formData.userCompany}`;
|
||||
}
|
||||
message += `.\n`;
|
||||
|
||||
if (howFound) {
|
||||
message += `Conheci seu trabalho através de ${howFound}.\n`;
|
||||
if (formData.howFound) {
|
||||
message += `Conheci seu trabalho através de ${formData.howFound}.\n`;
|
||||
}
|
||||
|
||||
message += `\n`;
|
||||
@@ -94,31 +99,31 @@ ${userName}`;
|
||||
// Detalhes do projeto
|
||||
message += `=== DETALHES DO PROJETO ===\n`;
|
||||
message += `Tipo de projeto: `;
|
||||
if (projectType === "outro" && otherProjectType) {
|
||||
message += `${otherProjectType}\n`;
|
||||
} else if (projectType) {
|
||||
message += `${projectType}\n`;
|
||||
if (formData.projectType === "outro" && formData.otherProjectType) {
|
||||
message += `${formData.otherProjectType}\n`;
|
||||
} else if (formData.projectType) {
|
||||
message += `${formData.projectType}\n`;
|
||||
} else {
|
||||
message += `Não especificado\n`;
|
||||
}
|
||||
|
||||
if (desiredDeadline) {
|
||||
message += `Prazo desejado: ${desiredDeadline}\n`;
|
||||
if (formData.desiredDeadline) {
|
||||
message += `Prazo desejado: ${formData.desiredDeadline}\n`;
|
||||
}
|
||||
|
||||
if (budgetValue) {
|
||||
message += `Orçamento disponível: ${budgetValue}\n`;
|
||||
if (formData.budgetValue) {
|
||||
message += `Orçamento disponível: ${formData.budgetValue}\n`;
|
||||
}
|
||||
|
||||
if (technicalComplexity) {
|
||||
message += `Complexidade técnica estimada: ${technicalComplexity}\n`;
|
||||
if (formData.technicalComplexity) {
|
||||
message += `Complexidade técnica estimada: ${formData.technicalComplexity}\n`;
|
||||
}
|
||||
|
||||
if (hasInternalTeam) {
|
||||
if (formData.hasInternalTeam) {
|
||||
message += `Equipe técnica interna: ${
|
||||
hasInternalTeam === "sim"
|
||||
formData.hasInternalTeam === "sim"
|
||||
? "Sim"
|
||||
: hasInternalTeam === "nao"
|
||||
: formData.hasInternalTeam === "nao"
|
||||
? "Não"
|
||||
: "Parcialmente"
|
||||
}\n`;
|
||||
@@ -128,11 +133,11 @@ ${userName}`;
|
||||
|
||||
// Contatos
|
||||
message += `=== INFORMAÇÕES PARA CONTATO ===\n`;
|
||||
if (userEmail) {
|
||||
message += `📧 Email: ${userEmail}\n`;
|
||||
if (formData.userEmail) {
|
||||
message += `📧 Email: ${formData.userEmail}\n`;
|
||||
}
|
||||
if (userPhone) {
|
||||
message += `📱 Telefone/WhatsApp: ${userPhone}\n`;
|
||||
if (formData.userPhone) {
|
||||
message += `📱 Telefone/WhatsApp: ${formData.userPhone}\n`;
|
||||
}
|
||||
|
||||
message += `\n`;
|
||||
@@ -141,69 +146,38 @@ ${userName}`;
|
||||
message += `Estou entrando em contato pois acredito que seus conhecimentos como Desenvolvedor Full-Stack podem agregar muito ao projeto que tenho em mente.\n\n`;
|
||||
message += `Fico no aguardo do seu contato para discutirmos detalhes e definirmos os próximos passos.\n\n`;
|
||||
message += `Atenciosamente,\n`;
|
||||
message += `${userName}`;
|
||||
message += `${formData.userName}`;
|
||||
|
||||
return message;
|
||||
};
|
||||
|
||||
const handleNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setUserName(e.target.value);
|
||||
if (e.target.value.trim() !== "") setNameError(null);
|
||||
};
|
||||
const handleInputChange =
|
||||
(field: keyof typeof formData) =>
|
||||
(
|
||||
e: ChangeEvent<
|
||||
HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement
|
||||
>,
|
||||
) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: e.target.value }));
|
||||
|
||||
const handleEmailChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setUserEmail(e.target.value);
|
||||
if (e.target.value.trim() !== "") setEmailError(null);
|
||||
};
|
||||
|
||||
const handlePhoneChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setUserPhone(e.target.value);
|
||||
if (e.target.value.trim() !== "") setPhoneError(null);
|
||||
};
|
||||
// Clear specific errors if the user starts typing
|
||||
if (e.target.value.trim() !== "") {
|
||||
if (field === "userName") setNameError(null);
|
||||
if (field === "userEmail") setEmailError(null);
|
||||
if (field === "userPhone") setPhoneError(null);
|
||||
if (field === "otherProjectType") setOtherProjectTypeError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNameFocus = () => setNameFocused(true);
|
||||
const handleEmailFocus = () => setEmailFocused(true);
|
||||
const handlePhoneFocus = () => setPhoneFocused(true);
|
||||
|
||||
const handleCompanyChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setUserCompany(e.target.value);
|
||||
};
|
||||
|
||||
const handleHowFoundChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setHowFound(e.target.value);
|
||||
};
|
||||
|
||||
const handleProjectTypeChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setProjectType(e.target.value);
|
||||
};
|
||||
|
||||
const handleDesiredDeadlineChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setDesiredDeadline(e.target.value);
|
||||
};
|
||||
|
||||
const handleBudgetValueChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setBudgetValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleTechnicalComplexityChange = (
|
||||
e: ChangeEvent<HTMLSelectElement>,
|
||||
) => {
|
||||
setTechnicalComplexity(e.target.value);
|
||||
};
|
||||
|
||||
const handleHasInternalTeamChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
setHasInternalTeam(e.target.value);
|
||||
};
|
||||
|
||||
const handleMessageChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setCustomMessage(e.target.value);
|
||||
};
|
||||
|
||||
const generateWhatsAppUrl = () => {
|
||||
const message = encodeURIComponent(
|
||||
formType === "advanced"
|
||||
? generateAdvancedMessage()
|
||||
: customMessage || getGreetingMessageWithUserName(),
|
||||
: formData.customMessage || getGreetingMessageWithUserName(),
|
||||
);
|
||||
const phone = data.contact.whatsapp.replace(/\D/g, ""); // Remove caracteres não numéricos
|
||||
return `https://wa.me/${phone}?text=${message}`;
|
||||
@@ -214,8 +188,8 @@ ${userName}`;
|
||||
const body = encodeURIComponent(
|
||||
formType === "advanced"
|
||||
? generateAdvancedMessage()
|
||||
: `Nome: ${userName}\nEmail: ${userEmail}\nTelefone: ${userPhone}\nEmpresa/Posição: ${userCompany}\nComo conheceu: ${howFound}\n\nTipo de Projeto: ${projectType}\nPrazo Desejado: ${desiredDeadline}\nOrçamento Disponível: ${budgetValue}\nComplexidade Técnica: ${technicalComplexity}\nTem equipe interna: ${hasInternalTeam}\n\nMensagem: ${
|
||||
customMessage || getGreetingMessageWithUserName()
|
||||
: `Nome: ${formData.userName}\nEmail: ${formData.userEmail}\nTelefone: ${formData.userPhone}\nEmpresa/Posição: ${formData.userCompany}\nComo conheceu: ${formData.howFound}\n\nTipo de Projeto: ${formData.projectType}\nPrazo Desejado: ${formData.desiredDeadline}\nOrçamento Disponível: ${formData.budgetValue}\nComplexidade Técnica: ${formData.technicalComplexity}\nTem equipe interna: ${formData.hasInternalTeam}\n\nMensagem: ${
|
||||
formData.customMessage || getGreetingMessageWithUserName()
|
||||
}`,
|
||||
);
|
||||
return `mailto:${data.contact.email}?subject=${subject}&body=${body}`;
|
||||
@@ -225,7 +199,7 @@ ${userName}`;
|
||||
e.preventDefault();
|
||||
|
||||
let hasError = false;
|
||||
if (userName.trim() === "") {
|
||||
if (formData.userName.trim() === "") {
|
||||
setNameError("Por favor, insira seu nome.");
|
||||
hasError = true;
|
||||
} else {
|
||||
@@ -233,21 +207,24 @@ ${userName}`;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(userEmail)) {
|
||||
if (!emailRegex.test(formData.userEmail)) {
|
||||
setEmailError("Por favor, insira um email válido.");
|
||||
hasError = true;
|
||||
} else {
|
||||
setEmailError(null);
|
||||
}
|
||||
|
||||
if (userPhone.trim() === "") {
|
||||
if (formData.userPhone.trim() === "") {
|
||||
setPhoneError("Por favor, insira seu telefone.");
|
||||
hasError = true;
|
||||
} else {
|
||||
setPhoneError(null);
|
||||
}
|
||||
|
||||
if (projectType === "outro" && otherProjectType.trim() === "") {
|
||||
if (
|
||||
formData.projectType === "outro" &&
|
||||
formData.otherProjectType.trim() === ""
|
||||
) {
|
||||
setOtherProjectTypeError("Por favor, especifique o tipo de projeto.");
|
||||
hasError = true;
|
||||
} else {
|
||||
@@ -260,10 +237,13 @@ ${userName}`;
|
||||
return; // Ignora a submissão
|
||||
}
|
||||
|
||||
// Validação de tempo de preenchimento (mínimo de 3 segundos)
|
||||
// UX FIX: Validação de tempo com feedback ou limite menor
|
||||
const timeElapsed = Date.now() - startTime;
|
||||
if (timeElapsed < 3000) {
|
||||
// Reduzido para 1000ms para evitar falsos positivos com autofill
|
||||
if (timeElapsed < 1000) {
|
||||
console.log("Formulário preenchido muito rápido, provável bot.");
|
||||
// Opcional: Mostrar erro para humano real que digita muito rápido
|
||||
// alert("Por favor, verifique os dados antes de enviar.");
|
||||
return; // Ignora a submissão
|
||||
}
|
||||
|
||||
@@ -271,9 +251,11 @@ ${userName}`;
|
||||
|
||||
// Verificação de interação mínima
|
||||
if (!nameFocused || !emailFocused || !phoneFocused) {
|
||||
console.log("Campos principais não foram focados, provável bot.");
|
||||
// Opcional: exibir uma mensagem de erro para o usuário
|
||||
return;
|
||||
// MENTORIA: Isso pode bloquear usuários de Screen Readers ou Autofill agressivo.
|
||||
// Considere remover ou tornar apenas um aviso no console.
|
||||
console.warn(
|
||||
"Campos principais não foram focados, verificando integridade.",
|
||||
);
|
||||
}
|
||||
|
||||
if (contactMethod === "whatsapp") {
|
||||
@@ -284,13 +266,20 @@ ${userName}`;
|
||||
};
|
||||
|
||||
const getCtaMessage = () => {
|
||||
if (projectType === "consultoria") {
|
||||
if (formData.projectType === "consultoria") {
|
||||
return "Agendar Consultoria Gratuita";
|
||||
}
|
||||
if (budgetValue && parseFloat(budgetValue.replace(/\D/g, "")) < 2000) {
|
||||
if (
|
||||
formData.budgetValue &&
|
||||
parseFloat(formData.budgetValue.replace(/\D/g, "")) < 2000
|
||||
) {
|
||||
return "Discutir Soluções de Baixo Custo";
|
||||
}
|
||||
if (projectType && desiredDeadline && budgetValue) {
|
||||
if (
|
||||
formData.projectType &&
|
||||
formData.desiredDeadline &&
|
||||
formData.budgetValue
|
||||
) {
|
||||
return "Receber Proposta Personalizada";
|
||||
}
|
||||
return "Enviar Formulário";
|
||||
@@ -301,7 +290,7 @@ ${userName}`;
|
||||
let hourlyRate = 55; // Taxa base R$ 55/h
|
||||
|
||||
// Estimativa de horas baseada no tipo de projeto
|
||||
switch (projectType) {
|
||||
switch (formData.projectType) {
|
||||
case "web-app":
|
||||
hours += 80; // Aplicação web completa
|
||||
break;
|
||||
@@ -328,7 +317,7 @@ ${userName}`;
|
||||
}
|
||||
|
||||
// Ajuste de horas baseado no prazo
|
||||
switch (desiredDeadline) {
|
||||
switch (formData.desiredDeadline) {
|
||||
case "1-3-weeks":
|
||||
hours *= 1.3; // Urgência aumenta horas
|
||||
break;
|
||||
@@ -343,7 +332,7 @@ ${userName}`;
|
||||
}
|
||||
|
||||
// Ajuste de horas baseado na complexidade
|
||||
switch (technicalComplexity) {
|
||||
switch (formData.technicalComplexity) {
|
||||
case "alta":
|
||||
hours *= 1.4;
|
||||
hourlyRate = 65; // Taxa maior para alta complexidade
|
||||
@@ -359,7 +348,7 @@ ${userName}`;
|
||||
break;
|
||||
}
|
||||
|
||||
if (hasInternalTeam === "nao") {
|
||||
if (formData.hasInternalTeam === "nao") {
|
||||
hours *= 1.2; // Sem equipe interna = mais horas necessárias
|
||||
}
|
||||
|
||||
@@ -440,8 +429,8 @@ ${userName}`;
|
||||
nameError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="Seu nome"
|
||||
value={userName}
|
||||
onChange={handleNameChange}
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange("userName")}
|
||||
onFocus={handleNameFocus}
|
||||
required
|
||||
/>
|
||||
@@ -495,8 +484,8 @@ ${userName}`;
|
||||
nameError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="Seu nome completo"
|
||||
value={userName}
|
||||
onChange={handleNameChange}
|
||||
value={formData.userName}
|
||||
onChange={handleInputChange("userName")}
|
||||
onFocus={handleNameFocus}
|
||||
required
|
||||
/>
|
||||
@@ -518,8 +507,8 @@ ${userName}`;
|
||||
emailError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="[email protected]"
|
||||
value={userEmail}
|
||||
onChange={handleEmailChange}
|
||||
value={formData.userEmail}
|
||||
onChange={handleInputChange("userEmail")}
|
||||
onFocus={handleEmailFocus}
|
||||
required
|
||||
/>
|
||||
@@ -541,8 +530,8 @@ ${userName}`;
|
||||
phoneError ? "border-red-500" : "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="(XX) XXXXX-XXXX"
|
||||
value={userPhone}
|
||||
onChange={handlePhoneChange}
|
||||
value={formData.userPhone}
|
||||
onChange={handleInputChange("userPhone")}
|
||||
onFocus={handlePhoneFocus}
|
||||
required
|
||||
/>
|
||||
@@ -562,8 +551,8 @@ ${userName}`;
|
||||
id="user-company"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="Nome da empresa, sua posição"
|
||||
value={userCompany}
|
||||
onChange={handleCompanyChange}
|
||||
value={formData.userCompany}
|
||||
onChange={handleInputChange("userCompany")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -582,9 +571,10 @@ ${userName}`;
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={howFound}
|
||||
onChange={handleHowFoundChange}
|
||||
value={formData.howFound}
|
||||
onChange={handleInputChange("howFound")}
|
||||
>
|
||||
{" "}
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione uma opção
|
||||
</option>
|
||||
@@ -664,8 +654,8 @@ ${userName}`;
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={projectType}
|
||||
onChange={handleProjectTypeChange}
|
||||
value={formData.projectType}
|
||||
onChange={handleInputChange("projectType")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione o tipo
|
||||
@@ -708,7 +698,7 @@ ${userName}`;
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
{projectType === "outro" && (
|
||||
{formData.projectType === "outro" && (
|
||||
<div>
|
||||
<label
|
||||
htmlFor="other-project-type"
|
||||
@@ -725,12 +715,8 @@ ${userName}`;
|
||||
: "border-white/10"
|
||||
} text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent`}
|
||||
placeholder="Ex: Desenvolvimento de Plugin, Automação de Processos"
|
||||
value={otherProjectType}
|
||||
onChange={(e) => {
|
||||
setOtherProjectType(e.target.value);
|
||||
if (e.target.value.trim() !== "")
|
||||
setOtherProjectTypeError(null);
|
||||
}}
|
||||
value={formData.otherProjectType}
|
||||
onChange={handleInputChange("otherProjectType")}
|
||||
required
|
||||
/>
|
||||
{otherProjectTypeError && (
|
||||
@@ -756,8 +742,8 @@ ${userName}`;
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={desiredDeadline}
|
||||
onChange={handleDesiredDeadlineChange}
|
||||
value={formData.desiredDeadline}
|
||||
onChange={handleInputChange("desiredDeadline")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione o prazo
|
||||
@@ -806,8 +792,8 @@ ${userName}`;
|
||||
id="budget-value"
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="Ex: 5000, 10000, A Combinar"
|
||||
value={budgetValue}
|
||||
onChange={handleBudgetValueChange}
|
||||
value={formData.budgetValue}
|
||||
onChange={handleInputChange("budgetValue")}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -826,8 +812,8 @@ ${userName}`;
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={technicalComplexity}
|
||||
onChange={handleTechnicalComplexityChange}
|
||||
value={formData.technicalComplexity}
|
||||
onChange={handleInputChange("technicalComplexity")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione a complexidade
|
||||
@@ -865,8 +851,8 @@ ${userName}`;
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "16px",
|
||||
}}
|
||||
value={hasInternalTeam}
|
||||
onChange={handleHasInternalTeamChange}
|
||||
value={formData.hasInternalTeam}
|
||||
onChange={handleInputChange("hasInternalTeam")}
|
||||
>
|
||||
<option value="" className="bg-gray-800 text-white">
|
||||
Selecione
|
||||
@@ -898,8 +884,8 @@ ${userName}`;
|
||||
rows={4}
|
||||
className="w-full p-3 rounded-lg bg-white/5 border border-white/10 text-white placeholder-gray-500 focus:ring-2 focus:ring-cyan-500 focus:border-transparent"
|
||||
placeholder="Descreva seu projeto ou suas necessidades aqui..."
|
||||
value={customMessage}
|
||||
onChange={handleMessageChange}
|
||||
value={formData.customMessage}
|
||||
onChange={handleInputChange("customMessage")}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
@@ -913,11 +899,13 @@ ${userName}`;
|
||||
{getCtaMessage()}
|
||||
</button>
|
||||
</motion.div>
|
||||
{projectType && desiredDeadline && budgetValue && (
|
||||
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
|
||||
{getEstimatedValue()}
|
||||
</p>
|
||||
)}
|
||||
{formData.projectType &&
|
||||
formData.desiredDeadline &&
|
||||
formData.budgetValue && (
|
||||
<p className="text-center text-lg font-semibold text-cyan-400 mt-4">
|
||||
{getEstimatedValue()}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
|
||||
// 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div 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 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.
|
||||
*/
|
||||
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"),
|
||||
);
|
||||
const hardwareHeading = headings.find((h3) =>
|
||||
h3.textContent?.includes("Hardware"),
|
||||
);
|
||||
|
||||
// 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"),
|
||||
);
|
||||
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),
|
||||
);
|
||||
});
|
||||
|
||||
// Check that all company names are present
|
||||
const allCompaniesPresent = allExperiences.every((exp) => {
|
||||
return container.textContent?.includes(exp.company);
|
||||
});
|
||||
|
||||
return allTitlesPresent && allCompaniesPresent;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,641 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
|
||||
// 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div 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)),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
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,
|
||||
}),
|
||||
(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"),
|
||||
);
|
||||
|
||||
// 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;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,407 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Experience from "./Experience";
|
||||
import fc from "fast-check";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
|
||||
// Mock the portfolio data hook
|
||||
jest.mock("@/lib/usePortfolioData");
|
||||
const mockUsePortfolioData = usePortfolioData as jest.MockedFunction<
|
||||
typeof usePortfolioData
|
||||
>;
|
||||
|
||||
// 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock Section component
|
||||
jest.mock("./Section", () => ({
|
||||
Section: ({ children, ...props }: any) => (
|
||||
<section {...props}>{children}</section>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock animation components
|
||||
jest.mock("./animations/DrawLine", () => ({
|
||||
DrawLine: () => <div 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 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.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
|
||||
const { container } = render(<Experience />);
|
||||
|
||||
// Get all heading elements in order
|
||||
const headings = Array.from(
|
||||
container.querySelectorAll("h1, h2, h3, h4, h5, h6"),
|
||||
);
|
||||
|
||||
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 },
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { Section } from "./Section";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { CounterValue } from "./animations/CounterValue";
|
||||
import { SectionLoading } from "./SectionLoading";
|
||||
|
||||
export default function Experience() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const sortedExperiences = useMemo(() => {
|
||||
if (!data?.experiences) return [];
|
||||
return [...data.experiences]
|
||||
.filter((exp) => exp.featured)
|
||||
.sort((a, b) => {
|
||||
const yearA = parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
const yearB = parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
const yearA = Number.parseInt(a.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
const yearB = Number.parseInt(b.period.match(/\d{4}/)?.[0] || "0", 10);
|
||||
return yearB - yearA;
|
||||
});
|
||||
}, [data]);
|
||||
|
||||
if (loading) return <div className="ds-loading">Loading system logs</div>;
|
||||
if (error || !data)
|
||||
if (loading) {
|
||||
return <SectionLoading message="Loading system logs" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <div className="ds-error">Error loading system logs</div>;
|
||||
}
|
||||
|
||||
const listVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="experience"
|
||||
eyebrow="SYSTEM_LOGS.history"
|
||||
title="Trajetória focada em produto, legado e crescimento sustentável"
|
||||
description="Experiências recentes onde combinei entrega rápida com responsabilidade arquitetural."
|
||||
title="Trajetoria focada em produto, legado e crescimento sustentavel"
|
||||
description="Experiencias recentes onde combinei entrega rapida com responsabilidade arquitetural."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="space-y-sp-6">
|
||||
{sortedExperiences.map((exp, index) => (
|
||||
<Reveal key={exp.id} y={50} duration={0.8} delay={0.1 * index}>
|
||||
<article className="ds-card">
|
||||
<div className="ds-grid-2">
|
||||
<div>
|
||||
<div className="ds-kicker">{exp.period}</div>
|
||||
<h3 className="ds-card-title">{exp.title}</h3>
|
||||
<div className="mt-sp-3 text-[11px] uppercase tracking-[0.1em] text-meta">
|
||||
{exp.company} · {exp.type}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-start gap-sp-3 lg:justify-end">
|
||||
<span className="ds-tag">{exp.location}</span>
|
||||
<span className="ds-tag">
|
||||
{exp.technologies.length} tecnologias
|
||||
</span>
|
||||
<motion.div
|
||||
className="space-y-sp-6"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.18 }}
|
||||
variants={listVariants}
|
||||
>
|
||||
{sortedExperiences.map((exp) => (
|
||||
<motion.article
|
||||
key={exp.id}
|
||||
className="ds-card h-full"
|
||||
variants={itemVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 16px 36px rgba(10,10,10,0.15)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.995 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 20 }}
|
||||
>
|
||||
<div className="flex flex-col lg:grid lg:grid-cols-[1fr_auto] gap-sp-4 items-start">
|
||||
<div>
|
||||
<div className="ds-kicker">{exp.period}</div>
|
||||
<DrawLine delay={0.04} />
|
||||
<h3 className="ds-card-title">{exp.title}</h3>
|
||||
<div className="mt-sp-3 text-[11px] uppercase tracking-[0.1em] text-meta">
|
||||
{exp.company} | {exp.type}
|
||||
</div>
|
||||
</div>
|
||||
<p className="ds-copy">{exp.description}</p>
|
||||
<ul className="ds-list">
|
||||
{exp.achievements?.map((achievement) => (
|
||||
<li key={achievement}>{achievement}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{exp.technologies?.map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</Reveal>
|
||||
<div className="flex flex-wrap items-start justify-start gap-sp-3 lg:justify-end">
|
||||
<span className="ds-tag">{exp.location}</span>
|
||||
<span className="ds-tag">
|
||||
<CounterValue
|
||||
value={exp.technologies.length}
|
||||
suffix=" tecnologias"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="ds-copy">{exp.description}</p>
|
||||
<ul className="ds-list">
|
||||
{exp.achievements?.map((achievement) => (
|
||||
<li key={achievement}>{achievement}</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{exp.technologies?.map((tech) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
import fc from "fast-check";
|
||||
|
||||
describe("Property 9: ARIA Labels for Links", () => {
|
||||
it("should have descriptive aria-label attributes for all social media links", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link in the Footer component, it should have
|
||||
* a descriptive aria-label attribute that identifies the platform and action.
|
||||
* This ensures accessibility for users with assistive technologies.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed, testing static component
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links (they have SVG icons and are in the Connect section)
|
||||
const socialLinks = container.querySelectorAll(
|
||||
"footer a[aria-label]",
|
||||
);
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each social link has a non-empty, descriptive aria-label
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
|
||||
// aria-label must exist and be non-empty
|
||||
if (!ariaLabel || ariaLabel.trim().length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// aria-label should be descriptive (at least 5 characters)
|
||||
// and should describe an action (contain words like "Visit", "View", "Go to", etc.)
|
||||
return ariaLabel.length >= 5;
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have unique aria-label for each social platform", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any set of social media links, each should have a unique
|
||||
* aria-label that identifies the specific platform. This ensures users can
|
||||
* distinguish between different social links when using screen readers.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links with aria-labels
|
||||
const socialLinks = container.querySelectorAll("footer a[aria-label]");
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Collect all aria-labels
|
||||
const ariaLabels = socialLinksWithSvg.map((link) =>
|
||||
link.getAttribute("aria-label"),
|
||||
);
|
||||
|
||||
// Verify we have aria-labels
|
||||
if (ariaLabels.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that all aria-labels are unique
|
||||
const uniqueLabels = new Set(ariaLabels);
|
||||
return uniqueLabels.size === ariaLabels.length;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have aria-label that identifies both platform and action", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link, the aria-label should contain both
|
||||
* the platform name (GitHub, LinkedIn, etc.) and an action verb (Visit, View, etc.).
|
||||
* This provides complete context for assistive technology users.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links
|
||||
const socialLinks = container.querySelectorAll("footer a[aria-label]");
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Common action verbs for social links
|
||||
const actionVerbs = ["visit", "view", "go to", "open", "see", "check"];
|
||||
|
||||
// Check each link has an action verb in its aria-label
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
|
||||
if (!ariaLabel) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const lowerLabel = ariaLabel.toLowerCase();
|
||||
|
||||
// Check if aria-label contains at least one action verb
|
||||
return actionVerbs.some((verb) => lowerLabel.includes(verb));
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain aria-labels across multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any number of component renders, social links should consistently
|
||||
* maintain their aria-label attributes. This verifies that accessibility attributes
|
||||
* are 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 />);
|
||||
|
||||
// Find social links
|
||||
const socialLinks = container.querySelectorAll(
|
||||
"footer a[aria-label]",
|
||||
);
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter(
|
||||
(link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
},
|
||||
);
|
||||
|
||||
// Check each link has a valid aria-label
|
||||
socialLinksWithSvg.forEach((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
|
||||
if (!ariaLabel || ariaLabel.trim().length < 5) {
|
||||
allRendersValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should have aria-label for every social link with target="_blank"', () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social link that opens in a new tab (target="_blank"),
|
||||
* it must have an aria-label. This ensures users with assistive technologies
|
||||
* are informed about where the link leads before activating it.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all links that open in new tab
|
||||
const newTabLinks = container.querySelectorAll(
|
||||
'footer a[target="_blank"]',
|
||||
);
|
||||
|
||||
// Verify we found links
|
||||
if (newTabLinks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each new tab link should have an aria-label
|
||||
return Array.from(newTabLinks).every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
return ariaLabel !== null && ariaLabel.trim().length > 0;
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have empty or whitespace-only aria-labels", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link with an aria-label attribute,
|
||||
* the value should not be empty or contain only whitespace. Empty
|
||||
* aria-labels provide no accessibility benefit and can confuse screen readers.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links
|
||||
const socialLinks = container.querySelectorAll("footer a[aria-label]");
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that no aria-label is empty or whitespace-only
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
return ariaLabel !== null && ariaLabel.trim().length > 0;
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have aria-label that matches or relates to the link title", () => {
|
||||
/**
|
||||
* **Validates: Requirements 6.1**
|
||||
*
|
||||
* Property: For any social media link with both aria-label and title attributes,
|
||||
* the aria-label should provide context that relates to the title (platform name).
|
||||
* This ensures consistency between visual tooltips and screen reader announcements.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links with both aria-label and title
|
||||
const socialLinks = container.querySelectorAll(
|
||||
"footer a[aria-label][title]",
|
||||
);
|
||||
|
||||
// Filter to only social links (those with SVG children)
|
||||
const socialLinksWithSvg = Array.from(socialLinks).filter((link) => {
|
||||
return link.querySelector("svg") !== null;
|
||||
});
|
||||
|
||||
// If no links have both attributes, that's okay (not all implementations require title)
|
||||
if (socialLinksWithSvg.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check that aria-label relates to title
|
||||
return socialLinksWithSvg.every((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
const title = link.getAttribute("title");
|
||||
|
||||
if (!ariaLabel || !title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// aria-label should contain the platform name from title
|
||||
// or title should be contained in aria-label
|
||||
const lowerLabel = ariaLabel.toLowerCase();
|
||||
const lowerTitle = title.toLowerCase();
|
||||
|
||||
return (
|
||||
lowerLabel.includes(lowerTitle) || lowerTitle.includes(lowerLabel)
|
||||
);
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
import fc from "fast-check";
|
||||
|
||||
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.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed, testing static component
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find the main grid container
|
||||
const gridContainer = container.querySelector("footer .grid");
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 },
|
||||
);
|
||||
});
|
||||
|
||||
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 />);
|
||||
|
||||
// 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 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
import fc from "fast-check";
|
||||
|
||||
describe("Property 2: Social Links Open in New Tab", () => {
|
||||
it('should have target="_blank" and rel="noopener noreferrer" for all social links', () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any social media link in the Footer component, it should have
|
||||
* target="_blank" and rel="noopener noreferrer" attributes to open in a new tab
|
||||
* securely. This ensures consistent behavior across all social links and prevents
|
||||
* security vulnerabilities (tabnabbing).
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.constant(null), // No random input needed, testing static component
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social media links (they are within the "Connect" section)
|
||||
const socialLinks = container.querySelectorAll(
|
||||
'a[aria-label*="Visit"]',
|
||||
);
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check each social link
|
||||
let allLinksValid = true;
|
||||
|
||||
socialLinks.forEach((link) => {
|
||||
const target = link.getAttribute("target");
|
||||
const rel = link.getAttribute("rel");
|
||||
|
||||
// Both attributes must be present and correct
|
||||
if (target !== "_blank" || rel !== "noopener noreferrer") {
|
||||
allLinksValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return allLinksValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should consistently apply security attributes across all social link variations", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any subset of social links rendered, each link should maintain
|
||||
* the security attributes (target="_blank" and rel="noopener noreferrer").
|
||||
* This property verifies that the security pattern is consistently applied
|
||||
* regardless of which social platforms are included.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.integer({ min: 0, max: 10 }), // Random seed for variation
|
||||
() => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Get all anchor tags in the footer
|
||||
const allLinks = container.querySelectorAll("footer a");
|
||||
|
||||
// Filter to only social links (those with SVG icons and aria-labels about visiting profiles)
|
||||
const socialLinks = Array.from(allLinks).filter((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
const hasSvg = link.querySelector("svg") !== null;
|
||||
return ariaLabel && ariaLabel.includes("Visit") && hasSvg;
|
||||
});
|
||||
|
||||
// Verify we found social links
|
||||
if (socialLinks.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check that ALL social links have the required attributes
|
||||
return socialLinks.every((link) => {
|
||||
const target = link.getAttribute("target");
|
||||
const rel = link.getAttribute("rel");
|
||||
return target === "_blank" && rel === "noopener noreferrer";
|
||||
});
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should not apply new tab attributes to non-social links", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: Only social media links should have target="_blank". Other links
|
||||
* in the footer (like email) should not force new tab behavior. This ensures
|
||||
* the new tab behavior is intentionally applied only where appropriate.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Get all anchor tags in the footer
|
||||
const allLinks = container.querySelectorAll("footer a");
|
||||
|
||||
// Separate social links from other links
|
||||
const socialLinks: Element[] = [];
|
||||
const otherLinks: Element[] = [];
|
||||
|
||||
allLinks.forEach((link) => {
|
||||
const ariaLabel = link.getAttribute("aria-label");
|
||||
const hasSvg = link.querySelector("svg") !== null;
|
||||
|
||||
if (ariaLabel && ariaLabel.includes("Visit") && hasSvg) {
|
||||
socialLinks.push(link);
|
||||
} else {
|
||||
otherLinks.push(link);
|
||||
}
|
||||
});
|
||||
|
||||
// All social links should have target="_blank"
|
||||
const allSocialLinksHaveTarget = socialLinks.every((link) => {
|
||||
return link.getAttribute("target") === "_blank";
|
||||
});
|
||||
|
||||
// Non-social links (like email) should NOT have target="_blank"
|
||||
const noOtherLinksHaveTarget = otherLinks.every((link) => {
|
||||
return link.getAttribute("target") !== "_blank";
|
||||
});
|
||||
|
||||
return allSocialLinksHaveTarget && noOtherLinksHaveTarget;
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should maintain security attributes after multiple renders", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any number of component renders, social links should consistently
|
||||
* maintain their security attributes. This verifies that the attributes are not
|
||||
* accidentally removed or modified 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 />);
|
||||
|
||||
// Find social links
|
||||
const socialLinks = container.querySelectorAll(
|
||||
'a[aria-label*="Visit"]',
|
||||
);
|
||||
|
||||
// Check each link
|
||||
socialLinks.forEach((link) => {
|
||||
const target = link.getAttribute("target");
|
||||
const rel = link.getAttribute("rel");
|
||||
|
||||
if (target !== "_blank" || rel !== "noopener noreferrer") {
|
||||
allRendersValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
unmount();
|
||||
}
|
||||
|
||||
return allRendersValid;
|
||||
},
|
||||
),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have both target and rel attributes present together", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: For any social link, if target="_blank" is present, then
|
||||
* rel="noopener noreferrer" must also be present. These attributes work
|
||||
* together to provide secure new tab behavior, and one without the other
|
||||
* creates a security vulnerability.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all links with target="_blank"
|
||||
const linksWithTarget = container.querySelectorAll(
|
||||
'footer a[target="_blank"]',
|
||||
);
|
||||
|
||||
// Verify we found links
|
||||
if (linksWithTarget.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Each link with target="_blank" must have rel="noopener noreferrer"
|
||||
return Array.from(linksWithTarget).every((link) => {
|
||||
const rel = link.getAttribute("rel");
|
||||
return rel === "noopener noreferrer";
|
||||
});
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
|
||||
it("should have exactly the expected number of social links with security attributes", () => {
|
||||
/**
|
||||
* **Validates: Requirements 2.7**
|
||||
*
|
||||
* Property: The Footer should render a specific number of social links (based on
|
||||
* the socialLinks array), and all of them should have the security attributes.
|
||||
* This ensures no links are accidentally missing the attributes.
|
||||
*/
|
||||
fc.assert(
|
||||
fc.property(fc.constant(null), () => {
|
||||
const { container } = render(<Footer />);
|
||||
|
||||
// Find all social links with security attributes
|
||||
const secureLinks = container.querySelectorAll(
|
||||
'footer a[target="_blank"][rel="noopener noreferrer"]',
|
||||
);
|
||||
|
||||
// Find all social links (with aria-label containing "Visit")
|
||||
const allSocialLinks = container.querySelectorAll(
|
||||
'footer a[aria-label*="Visit"]',
|
||||
);
|
||||
|
||||
// The number of secure links should equal the number of social links
|
||||
// This ensures ALL social links have the security attributes
|
||||
return (
|
||||
secureLinks.length === allSocialLinks.length &&
|
||||
allSocialLinks.length > 0
|
||||
);
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,323 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Footer from "./Footer";
|
||||
|
||||
describe("Footer Component - Contact Information", () => {
|
||||
it("should render contact section heading", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const contactHeading = screen.getByText("Contato direto");
|
||||
expect(contactHeading).toBeInTheDocument();
|
||||
expect(contactHeading.tagName).toBe("H3");
|
||||
});
|
||||
|
||||
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]");
|
||||
expect(emailLink).toHaveAttribute("href", "mailto:[email protected]");
|
||||
});
|
||||
|
||||
it("should apply correct styling classes to email link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const emailLink = screen.getByText("[email protected]");
|
||||
const classes = emailLink.className;
|
||||
|
||||
expect(classes).toContain("text-[12px]");
|
||||
expect(classes).toContain("hover:text-[var(--slash)]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Social Links", () => {
|
||||
it("should render social links section heading", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const connectHeading = screen.getByText("Redes profissionais");
|
||||
expect(connectHeading).toBeInTheDocument();
|
||||
expect(connectHeading.tagName).toBe("H3");
|
||||
});
|
||||
|
||||
it("should render GitHub social link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const githubLink = screen.getByLabelText("Visit GitHub profile");
|
||||
expect(githubLink).toBeInTheDocument();
|
||||
expect(githubLink.tagName).toBe("A");
|
||||
});
|
||||
|
||||
it("should render LinkedIn social link", () => {
|
||||
render(<Footer />);
|
||||
|
||||
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(
|
||||
"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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Footer Component - Copyright", () => {
|
||||
it("should render copyright section heading", () => {
|
||||
render(<Footer />);
|
||||
|
||||
const copyrightHeading = screen.getByText("Informacoes legais");
|
||||
expect(copyrightHeading).toBeInTheDocument();
|
||||
expect(copyrightHeading.tagName).toBe("H3");
|
||||
});
|
||||
|
||||
it("should display current year in 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();
|
||||
});
|
||||
});
|
||||
+116
-20
@@ -1,29 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
/**
|
||||
* Footer Component
|
||||
*
|
||||
* Displays contact information, social links, and copyright in a consistent footer layout.
|
||||
* Follows the minimalist design system with Monument Extended typography and #ccff00 accent color.
|
||||
*
|
||||
* Requirements: 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.7
|
||||
*/
|
||||
|
||||
// TypeScript interface for social media links
|
||||
export interface SocialLink {
|
||||
name: string; // Display name (e.g., "GitHub")
|
||||
href: string; // Full URL
|
||||
icon: string; // SVG path or icon identifier
|
||||
ariaLabel: string; // Accessibility label
|
||||
}
|
||||
|
||||
// Social links data
|
||||
const socialLinks: SocialLink[] = [
|
||||
{
|
||||
name: "GitHub",
|
||||
href: "https://github.com/evandrodevbr",
|
||||
icon: "M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z",
|
||||
ariaLabel: "Visit GitHub profile",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Footer() {
|
||||
const [blink, setBlink] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setBlink((prev) => !prev);
|
||||
}, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="w-full py-sp-8 border-t border-[rgba(255,255,255,0.06)] flex items-center justify-center font-mono text-[12px] text-[rgba(255,255,255,0.5)] bg-transparent">
|
||||
<div className="flex items-center">
|
||||
<span className="text-[rgba(255,255,255,0.7)]">evandro@dev</span>
|
||||
<span className="text-[rgba(255,255,255,0.7)]">:</span>
|
||||
<span className="text-[#E8FF00]">~</span>
|
||||
<span className="text-[rgba(255,255,255,0.7)]">$ exit</span>
|
||||
<span
|
||||
className={`inline-block w-[6px] h-[14px] bg-[rgba(255,255,255,0.8)] ml-1 align-middle transition-opacity duration-100 ${
|
||||
blink ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<footer className="ds-section">
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-grid-2">
|
||||
{/* Contact & Legal */}
|
||||
<div className="ds-card flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="ds-kicker">SYSTEM_INFO</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">
|
||||
Transformando complexidade técnica em sistemas estáveis
|
||||
</h3>
|
||||
|
||||
<div className="mt-sp-6">
|
||||
<p className="text-[12px] uppercase tracking-wider text-[var(--meta)] mb-2">
|
||||
Contato
|
||||
</p>
|
||||
<a
|
||||
href="mailto:[email protected]"
|
||||
className="inline-block text-[18px] font-bold text-[var(--ink)] hover:text-[var(--slash)] transition-colors duration-200"
|
||||
>
|
||||
evandro@dev.br
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8">
|
||||
<div className="ds-metric">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slash animate-pulse" />
|
||||
Garuva, SC | Remote worldwide
|
||||
</div>
|
||||
<p className="mt-sp-4 text-[12px] text-[var(--meta)]">
|
||||
© {currentYear} Evandro Fonseca Junior. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="ds-card flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="ds-kicker">SOCIAL_LINKS</div>
|
||||
<span className="ds-draw-line" />
|
||||
<h3 className="ds-card-title">Redes profissionais</h3>
|
||||
|
||||
<div className="mt-sp-6 flex flex-col gap-sp-4">
|
||||
{socialLinks.map((link) => (
|
||||
<a
|
||||
key={link.name}
|
||||
href={link.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={link.ariaLabel}
|
||||
className="group flex items-center justify-between p-sp-4 border border-[var(--border)] bg-[rgba(10,10,10,0.02)] rounded-[8px] hover:bg-[rgba(10,10,10,0.04)] hover:border-[var(--slash)] transition-all duration-300"
|
||||
>
|
||||
<div className="flex items-center gap-sp-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-[var(--meta)] group-hover:text-[var(--ink)] transition-colors"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={link.icon} />
|
||||
</svg>
|
||||
<span className="text-[14px] font-medium text-[var(--ink-80)] group-hover:text-[var(--ink)] transition-colors">
|
||||
{link.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[var(--slash)] opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 font-bold">
|
||||
→
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-8 ds-metric">Open to collaboration</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
let mockReducedMotion = false;
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
initial,
|
||||
animate,
|
||||
whileHover,
|
||||
whileTap,
|
||||
...props
|
||||
}: {
|
||||
children?: ReactNode;
|
||||
initial?: unknown;
|
||||
animate?: unknown;
|
||||
whileHover?: unknown;
|
||||
whileTap?: unknown;
|
||||
} & Record<string, unknown>) => {
|
||||
const dataProps = {
|
||||
...(initial !== undefined
|
||||
? { "data-initial": JSON.stringify(initial) }
|
||||
: {}),
|
||||
...(animate !== undefined
|
||||
? { "data-animate": JSON.stringify(animate) }
|
||||
: {}),
|
||||
...(whileHover !== undefined
|
||||
? { "data-while-hover": JSON.stringify(whileHover) }
|
||||
: {}),
|
||||
...(whileTap !== undefined
|
||||
? { "data-while-tap": JSON.stringify(whileTap) }
|
||||
: {}),
|
||||
};
|
||||
|
||||
return React.createElement(tag, { ...props, ...dataProps }, children);
|
||||
};
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
||||
motion,
|
||||
useInView: () => true,
|
||||
useReducedMotion: () => mockReducedMotion,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Hero reduced motion", () => {
|
||||
afterEach(() => {
|
||||
mockReducedMotion = false;
|
||||
});
|
||||
|
||||
it("removes hover/tap scale animations when reduced motion is enabled", () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
const { container } = render(<Hero />);
|
||||
|
||||
const primaryAction = screen.getByText(/Iniciar projeto/i).closest("a");
|
||||
const secondaryAction = screen.getByText(/Ver projetos/i).closest("a");
|
||||
|
||||
expect(primaryAction).not.toHaveAttribute("data-while-hover");
|
||||
expect(primaryAction).not.toHaveAttribute("data-while-tap");
|
||||
expect(secondaryAction).not.toHaveAttribute("data-while-hover");
|
||||
expect(secondaryAction).not.toHaveAttribute("data-while-tap");
|
||||
|
||||
const rootMotionBlock = container.querySelector(
|
||||
"div[data-initial][data-animate]",
|
||||
);
|
||||
expect(rootMotionBlock).toBeInTheDocument();
|
||||
expect(rootMotionBlock?.getAttribute("data-initial")).toContain(
|
||||
'"opacity":0',
|
||||
);
|
||||
expect(rootMotionBlock?.getAttribute("data-animate")).toContain(
|
||||
'"opacity":1',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps essential hero content visible with reduced motion enabled", () => {
|
||||
mockReducedMotion = true;
|
||||
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Available for selected builds/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Hero from "./Hero";
|
||||
|
||||
jest.mock("framer-motion", () => {
|
||||
const React = require("react");
|
||||
|
||||
const motion = new Proxy(
|
||||
{},
|
||||
{
|
||||
get: (_, tag: string) => {
|
||||
const Component = ({
|
||||
children,
|
||||
...props
|
||||
}: { children?: ReactNode } & Record<string, unknown>) =>
|
||||
React.createElement(tag, props, children);
|
||||
Component.displayName = `motion.${tag}`;
|
||||
return Component;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => children,
|
||||
motion,
|
||||
useInView: () => true,
|
||||
useReducedMotion: () => false,
|
||||
};
|
||||
});
|
||||
|
||||
describe("Hero", () => {
|
||||
it("renders the upgraded hero content and primary actions", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getByLabelText("evandro.dev.br")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Engenharia de software para produtos conectados, interfaces de alta performance e sistemas criticos/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/Iniciar projeto/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Ver projetos/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Download CV/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Execution Brief/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders social links and proof content", () => {
|
||||
render(<Hero />);
|
||||
|
||||
expect(screen.getAllByLabelText("GitHub").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("LinkedIn").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByLabelText("Email").length).toBeGreaterThan(0);
|
||||
|
||||
expect(screen.getByText(/Garuva, SC \/ Brasil/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Especialidade/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Escopo/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Formato/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Scroll/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+283
-231
@@ -1,8 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Logo } from "./Logo";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
const SOCIAL_LINKS = [
|
||||
{
|
||||
@@ -32,102 +30,26 @@ const SOCIAL_LINKS = [
|
||||
},
|
||||
];
|
||||
|
||||
const textToType = "evandro.dev.br";
|
||||
const PROOF_ITEMS = [
|
||||
{
|
||||
label: "Especialidade",
|
||||
value:
|
||||
"Produtos conectados, interfaces de alta performance e sistemas criticos.",
|
||||
},
|
||||
{
|
||||
label: "Escopo",
|
||||
value:
|
||||
"Da integracao com hardware e APIs complexas ao frontend e operacao.",
|
||||
},
|
||||
{
|
||||
label: "Formato",
|
||||
value:
|
||||
"Consultoria, execucao ponta a ponta e colaboracao remota para squads.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Hero() {
|
||||
const [phase, setPhase] = useState<"typing" | "idle" | "glitch" | "logo">(
|
||||
"typing",
|
||||
);
|
||||
const [typedCount, setTypedCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
if (phase === "typing") {
|
||||
if (typedCount < textToType.length) {
|
||||
timeout = setTimeout(() => {
|
||||
setTypedCount((c) => c + 1);
|
||||
}, 120); // typing speed
|
||||
} else {
|
||||
timeout = setTimeout(() => {
|
||||
setPhase("idle");
|
||||
}, 1200); // Wait after typing finishes
|
||||
}
|
||||
} else if (phase === "idle") {
|
||||
timeout = setTimeout(() => {
|
||||
setPhase("glitch");
|
||||
}, 100); // trigger glitch
|
||||
} else if (phase === "glitch") {
|
||||
timeout = setTimeout(() => {
|
||||
setPhase("logo");
|
||||
}, 600); // Glitch out duration
|
||||
} else if (phase === "logo") {
|
||||
timeout = setTimeout(() => {
|
||||
setTypedCount(0);
|
||||
setPhase("typing");
|
||||
}, 3500); // Logo display and blink duration
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, [phase, typedCount]);
|
||||
|
||||
const renderTypedText = () => {
|
||||
const chars = [];
|
||||
for (let i = 0; i < typedCount; i++) {
|
||||
const char = textToType[i];
|
||||
if (i < 7) {
|
||||
chars.push(
|
||||
<span key={i} style={{ color: "#FFFFFF" }}>
|
||||
{char}
|
||||
</span>,
|
||||
);
|
||||
} else if (i === 7) {
|
||||
chars.push(<br key="br1" />);
|
||||
chars.push(
|
||||
<span key={i} style={{ color: "#E8FF00" }}>
|
||||
{char}
|
||||
</span>,
|
||||
);
|
||||
} else if (i < 11) {
|
||||
chars.push(
|
||||
<span key={i} style={{ color: "#E8FF00" }}>
|
||||
{char}
|
||||
</span>,
|
||||
);
|
||||
} else if (i === 11) {
|
||||
chars.push(<br key="br2" />);
|
||||
chars.push(
|
||||
<span key={i} style={{ color: "#FFFFFF" }}>
|
||||
{char}
|
||||
</span>,
|
||||
);
|
||||
} else {
|
||||
chars.push(
|
||||
<span key={i} style={{ color: "#FFFFFF" }}>
|
||||
{char}
|
||||
</span>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// blinking cursor
|
||||
if (phase === "typing" || phase === "idle") {
|
||||
chars.push(
|
||||
<motion.span
|
||||
key="cursor"
|
||||
animate={{ opacity: [1, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 0.8 }}
|
||||
style={{
|
||||
color: "#E8FF00",
|
||||
display: "inline-block",
|
||||
marginLeft: "4px",
|
||||
}}
|
||||
>
|
||||
_
|
||||
</motion.span>,
|
||||
);
|
||||
}
|
||||
return chars;
|
||||
};
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const hour = new Date().getHours();
|
||||
const greeting =
|
||||
@@ -140,157 +62,287 @@ export default function Hero() {
|
||||
return (
|
||||
<section
|
||||
id="home"
|
||||
className="relative overflow-hidden bg-[#0A0A0A] px-sp-4 py-sp-12 text-white lg:px-sp-8"
|
||||
className="relative flex min-h-[100svh] w-full items-center overflow-hidden bg-[var(--ink)] text-[#f4f2ed]"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-grid-light opacity-70" />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 h-40 bg-[radial-gradient(circle_at_top,rgba(232,255,0,0.18),transparent_60%)]" />
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_50%,rgba(10,10,10,0.2)_0%,var(--ink)_100%)]" />
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.03] mix-blend-screen"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-40 bg-gradient-to-t from-[var(--ink)] to-transparent" />
|
||||
|
||||
<div className="relative z-10 mx-auto grid min-h-[calc(100vh)] w-full max-w-[1000px] grid-cols-1 items-center gap-8">
|
||||
<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-0 pointer-events-none">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
transition={{ duration: 0.8, ease: "easeOut", delay: 3.2 }}
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 24 }}
|
||||
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="grid items-end gap-sp-10 lg:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)] pointer-events-auto"
|
||||
>
|
||||
<div className="mb-sp-8 inline-flex items-center gap-sp-2">
|
||||
<span
|
||||
className="h-1.5 w-1.5 animate-pulse rounded-full"
|
||||
style={{ background: "#E8FF00" }}
|
||||
/>
|
||||
<span
|
||||
className="text-[9px] uppercase tracking-[0.2em]"
|
||||
style={{ color: "rgba(255,255,255,.38)" }}
|
||||
<div className="max-w-[720px]">
|
||||
<div className="mb-sp-6 inline-flex items-center gap-sp-3 border border-[rgba(255,255,255,0.12)] bg-[rgba(10,10,10,0.4)] px-4 py-2 backdrop-blur-xl">
|
||||
<motion.span
|
||||
className="h-2 w-2 bg-[var(--slash)]"
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: [1, 0.25, 1] }
|
||||
}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.5,
|
||||
ease: "easeInOut",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}
|
||||
}
|
||||
/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.2em] text-[rgba(244,242,237,.82)] drop-shadow-md">
|
||||
Available for selected builds {"//"} Remote worldwide
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<motion.p
|
||||
className="mb-sp-4 max-w-[220px] font-mono text-[10px] uppercase tracking-[0.22em] text-[var(--slash)] drop-shadow-lg"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 12 }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.7,
|
||||
delay: 0.08,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
Systems engineer available
|
||||
</span>
|
||||
</div>
|
||||
Software engineering for products, operations & critical flows
|
||||
</motion.p>
|
||||
|
||||
<div className="relative mb-sp-6 flex min-h-[40vw] sm:min-h-[220px] md:min-h-[280px] lg:min-h-[350px] items-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{(phase === "typing" || phase === "idle") && (
|
||||
<motion.h1
|
||||
key="text"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, filter: "blur(0px)", scale: 1 }}
|
||||
exit={{ opacity: 0, filter: "blur(12px)", scale: 1.05 }}
|
||||
transition={{ duration: 0.5, ease: "easeInOut" }}
|
||||
className="absolute text-[13vw] sm:text-7xl md:text-[6rem] lg:text-[7rem] xl:text-[8rem] leading-[0.9] tracking-[-0.01em]"
|
||||
style={{
|
||||
color: "#FFFFFF",
|
||||
fontFamily: "'Monument Extended', sans-serif",
|
||||
fontWeight: 800,
|
||||
}}
|
||||
aria-label="evandro.dev.br"
|
||||
>
|
||||
{renderTypedText()}
|
||||
</motion.h1>
|
||||
)}
|
||||
|
||||
{phase === "logo" && (
|
||||
<motion.div
|
||||
key="logo"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{
|
||||
opacity: [0, 1, 0, 1, 1, 0.5, 1, 0],
|
||||
scale: 1,
|
||||
filter: [
|
||||
"blur(4px)",
|
||||
"blur(0px)",
|
||||
"blur(0px)",
|
||||
"blur(0px)",
|
||||
"blur(0px)",
|
||||
"blur(2px)",
|
||||
"blur(0px)",
|
||||
"blur(10px)",
|
||||
],
|
||||
}}
|
||||
transition={{
|
||||
duration: 3.2,
|
||||
times: [0, 0.05, 0.1, 0.15, 0.8, 0.85, 0.9, 1],
|
||||
}}
|
||||
className="absolute flex items-center justify-start w-full"
|
||||
>
|
||||
<Logo
|
||||
variant="mark"
|
||||
size="xl"
|
||||
theme="dark"
|
||||
className="w-[80%] max-w-[320px] h-auto drop-shadow-[0_0_20px_rgba(232,255,0,0.3)]"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="mb-sp-10 max-w-[460px] text-[13px] leading-7"
|
||||
style={{ color: "rgba(255,255,255,.72)" }}
|
||||
>
|
||||
Engenheiro de Software especializado em aplicacoes de alta
|
||||
performance, interfaces de produto e sistemas criticos com Next.js,
|
||||
React, Tauri e arquitetura escalavel.
|
||||
</p>
|
||||
|
||||
<div className="mb-sp-8 mt-sp-6 flex flex-wrap items-center gap-sp-4">
|
||||
<a
|
||||
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
|
||||
className="inline-flex h-11 items-center justify-center rounded-sm px-sp-6 text-[11px] font-semibold uppercase tracking-[0.08em] no-underline transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_0_0_1px_rgba(232,255,0,.8),0_8px_24px_rgba(232,255,0,.35)]"
|
||||
style={{ background: "#E8FF00", color: "#0A0A0A" }}
|
||||
<h1
|
||||
className="max-w-[920px] text-balance font-display text-[clamp(2.9rem,7vw,6.4rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-transparent bg-clip-text bg-gradient-to-br from-[#ffffff] to-[#a3a3a3]"
|
||||
aria-label="evandro.dev.br"
|
||||
>
|
||||
Iniciar projeto
|
||||
</a>
|
||||
Engenharia de software para produtos conectados, interfaces de
|
||||
alta performance e sistemas criticos.
|
||||
</h1>
|
||||
|
||||
<a
|
||||
href="/assets/Evandro_Fonseca_Junior_CV.pdf"
|
||||
download
|
||||
className="inline-flex h-11 items-center justify-center rounded-sm border px-sp-6 text-[11px] font-semibold uppercase tracking-[0.08em] no-underline transition-all duration-200 hover:-translate-y-0.5 hover:bg-white/5"
|
||||
style={{ borderColor: "rgba(255,255,255,.18)", color: "#FFFFFF" }}
|
||||
<motion.p
|
||||
className="mt-sp-6 max-w-[640px] text-[15px] leading-7 text-[rgba(244,242,237,.9)] lg:text-[16px] drop-shadow-md"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.16,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
Download CV
|
||||
</a>
|
||||
Da integracao com hardware e APIs complexas ao frontend moderno,
|
||||
construo sistemas estaveis, operaveis e prontos para crescer sem
|
||||
sacrificar clareza tecnica.
|
||||
</motion.p>
|
||||
|
||||
<div className="flex items-center gap-sp-2">
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full transition-all duration-200 hover:bg-white/10 hover:scale-110"
|
||||
style={{
|
||||
color: "rgba(255,255,255,.84)",
|
||||
}}
|
||||
aria-label={link.label}
|
||||
>
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill={link.stroke ? "none" : "currentColor"}
|
||||
stroke={link.stroke ? "currentColor" : "none"}
|
||||
strokeWidth={link.stroke ? "2" : undefined}
|
||||
role="img"
|
||||
<motion.div
|
||||
className="mt-sp-8 flex flex-wrap items-center gap-sp-4"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.24,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<motion.a
|
||||
href={`https://wa.me/5547992742365?text=${encodeURIComponent(greeting)}`}
|
||||
className="inline-flex min-h-12 items-center justify-center bg-[var(--slash)] px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.12em] text-[#0A0A0A] no-underline transition-[transform,background-color,box-shadow] duration-300 hover:bg-white hover:shadow-[0_0_20px_rgba(204,255,0,0.4)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||
whileHover={shouldReduceMotion ? undefined : { scale: 1.02 }}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.98 }}
|
||||
>
|
||||
Iniciar projeto
|
||||
</motion.a>
|
||||
|
||||
<motion.a
|
||||
href="#projects"
|
||||
className="inline-flex min-h-12 items-center justify-center border border-[rgba(244,242,237,.28)] bg-[rgba(10,10,10,0.4)] backdrop-blur-md px-sp-8 font-mono text-[11px] font-bold uppercase tracking-[0.12em] text-[#f4f2ed] no-underline transition-[transform,border-color,background-color,color] duration-300 hover:border-[var(--slash)] hover:bg-[rgba(204,255,0,0.08)] hover:text-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||
whileHover={shouldReduceMotion ? undefined : { scale: 1.02 }}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.98 }}
|
||||
>
|
||||
Ver projetos
|
||||
</motion.a>
|
||||
|
||||
<a
|
||||
href="/assets/Evandro_Fonseca_Junior_CV.pdf"
|
||||
download
|
||||
className="inline-flex min-h-12 items-center justify-center px-sp-2 text-[12px] font-medium text-[rgba(244,242,237,.9)] no-underline transition-colors duration-300 hover:text-[var(--slash)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)] drop-shadow-md"
|
||||
>
|
||||
Download CV
|
||||
</a>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-sp-10 flex flex-wrap items-center gap-x-sp-6 gap-y-sp-3 border-t border-[rgba(255,255,255,.08)] pt-sp-6"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.8,
|
||||
delay: 0.32,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
|
||||
<span className="h-2 w-2 rounded-full bg-[var(--slash)] shadow-[0_0_8px_var(--slash)]" />
|
||||
Garuva, SC / Brasil
|
||||
</div>
|
||||
<div className="inline-flex items-center gap-sp-3 text-[11px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.72)]">
|
||||
<span className="h-[1px] w-6 bg-[rgba(255,255,255,.2)]" />
|
||||
Next.js, Tauri, Rust, Node.js
|
||||
</div>
|
||||
<div className="flex items-center gap-sp-2">
|
||||
{SOCIAL_LINKS.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.href}
|
||||
target={link.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
link.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full border border-[rgba(244,242,237,0.18)] bg-[rgba(10,10,10,0.4)] backdrop-blur-md text-[rgba(244,242,237,.88)] transition-[transform,border-color,color,background-color] duration-300 hover:border-[var(--slash)] hover:bg-[rgba(204,255,0,0.1)] hover:text-[var(--slash)] hover:shadow-[0_0_15px_rgba(204,255,0,0.2)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--slash)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ink)]"
|
||||
aria-label={link.label}
|
||||
>
|
||||
{link.icon}
|
||||
</svg>
|
||||
</a>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill={link.stroke ? "none" : "currentColor"}
|
||||
stroke={link.stroke ? "currentColor" : "none"}
|
||||
strokeWidth={link.stroke ? "2" : undefined}
|
||||
role="img"
|
||||
aria-label={link.label}
|
||||
>
|
||||
{link.icon}
|
||||
</svg>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.aside
|
||||
className="relative overflow-hidden border border-[rgba(255,255,255,0.15)] bg-[linear-gradient(180deg,rgba(10,10,10,0.6),rgba(10,10,10,0.3))] p-sp-6 backdrop-blur-xl lg:p-sp-8"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, x: 24 }
|
||||
}
|
||||
animate={shouldReduceMotion ? { opacity: 1 } : { opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.9, delay: 0.22, ease: [0.16, 1, 0.3, 1] }}
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-[linear-gradient(90deg,transparent,rgba(204,255,0,0.9),transparent)]" />
|
||||
<div className="absolute inset-y-0 left-0 w-px bg-[linear-gradient(180deg,rgba(204,255,0,0.4),transparent)]" />
|
||||
|
||||
<div className="flex items-start justify-between gap-sp-4 border-b border-[rgba(255,255,255,0.08)] pb-sp-5">
|
||||
<div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]">
|
||||
Execution Brief
|
||||
</div>
|
||||
<h2 className="mt-sp-2 font-display text-[clamp(1.35rem,3vw,2rem)] font-extrabold leading-[1.02] tracking-[-0.03em] text-[#f7f4ee]">
|
||||
Clareza de arquitetura antes de complexidade.
|
||||
</h2>
|
||||
</div>
|
||||
<div className="shrink-0 border border-[rgba(204,255,0,0.3)] bg-[rgba(204,255,0,0.05)] px-3 py-2 font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
2026
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-sp-6 space-y-sp-5">
|
||||
{PROOF_ITEMS.map((item, index) => (
|
||||
<motion.article
|
||||
key={item.label}
|
||||
className="border-b border-[rgba(255,255,255,0.08)] pb-sp-5 last:border-b-0 last:pb-0"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 12 }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.65,
|
||||
delay: 0.28 + index * 0.08,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.7)]">
|
||||
{item.label}
|
||||
</div>
|
||||
<p className="mt-sp-2 text-[14px] leading-6 text-[rgba(244,242,237,.94)]">
|
||||
{item.value}
|
||||
</p>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="text-[10px] uppercase tracking-[0.14em]"
|
||||
style={{ color: "rgba(255,255,255,.38)" }}
|
||||
>
|
||||
Garuva, SC | Remote worldwide
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="mt-sp-8 grid gap-sp-4 sm:grid-cols-2"
|
||||
initial={
|
||||
shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 16 }
|
||||
}
|
||||
animate={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, y: 0 }
|
||||
}
|
||||
transition={{
|
||||
duration: 0.75,
|
||||
delay: 0.48,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.4)] p-sp-4 hover:border-[var(--slash)] transition-colors duration-300">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]">
|
||||
Focus
|
||||
</div>
|
||||
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.9)]">
|
||||
Sistemas com baixa latencia, alta previsibilidade e manutencao
|
||||
sustentavel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="border border-[rgba(255,255,255,0.1)] bg-[rgba(10,10,10,0.4)] p-sp-4 hover:border-[var(--slash)] transition-colors duration-300">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--slash)]">
|
||||
Availability
|
||||
</div>
|
||||
<p className="mt-sp-2 text-[13px] leading-6 text-[rgba(244,242,237,.9)]">
|
||||
Aberto a novos projetos com ownership tecnico, discovery e
|
||||
entrega iterativa.
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.aside>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="absolute bottom-10 left-1/2 flex -translate-x-1/2 flex-col items-center gap-3 opacity-50 z-10 pointer-events-none"
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0 }}
|
||||
animate={shouldReduceMotion ? { opacity: 0.5 } : { opacity: 0.5 }}
|
||||
transition={{ delay: 1, duration: 1 }}
|
||||
>
|
||||
<span className="font-mono text-[9px] uppercase tracking-widest text-[var(--slash)]">
|
||||
Scroll
|
||||
</span>
|
||||
<div className="h-10 w-[1px] bg-gradient-to-b from-[var(--slash)] to-transparent" />
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+31
-7
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface LogoProps {
|
||||
variant?: "mark" | "wordmark" | "icon";
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
@@ -23,11 +25,12 @@ export function Logo({
|
||||
showTagline = false,
|
||||
}: Readonly<LogoProps>) {
|
||||
const dimensions = sizeMap[size];
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const colors = {
|
||||
dark: {
|
||||
primary: "#FFFFFF",
|
||||
slash: "#E8FF00",
|
||||
slash: "#CCFF00",
|
||||
},
|
||||
light: {
|
||||
primary: "#0A0A0A",
|
||||
@@ -41,9 +44,22 @@ export function Logo({
|
||||
|
||||
const c = colors[theme];
|
||||
|
||||
const hoverMotion = shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
scale: 1.03,
|
||||
rotate: -2,
|
||||
};
|
||||
|
||||
const hoverTransition = {
|
||||
type: "spring" as const,
|
||||
stiffness: 280,
|
||||
damping: 18,
|
||||
};
|
||||
|
||||
if (variant === "icon") {
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 64 64"
|
||||
@@ -51,6 +67,8 @@ export function Logo({
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro icon logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro icon logo</title>
|
||||
<rect width="64" height="64" rx="14" fill={c.primary} />
|
||||
@@ -89,13 +107,17 @@ export function Logo({
|
||||
r="5"
|
||||
fill={theme === "dark" ? "#0A0A0A" : c.primary}
|
||||
/>
|
||||
</svg>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "wordmark") {
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
<motion.div
|
||||
className={`flex items-center gap-3 ${className}`}
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
@@ -134,12 +156,12 @@ export function Logo({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
<motion.svg
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
viewBox="0 0 120 80"
|
||||
@@ -147,6 +169,8 @@ export function Logo({
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="Evandro mark logo"
|
||||
whileHover={hoverMotion}
|
||||
transition={hoverTransition}
|
||||
>
|
||||
<title>Evandro mark logo</title>
|
||||
<rect x="10" y="8" width="9" height="64" fill={c.primary} />
|
||||
@@ -155,6 +179,6 @@ export function Logo({
|
||||
<rect x="19" y="63" width="28" height="9" fill={c.primary} />
|
||||
<polygon points="50,55 57,55 74,25 67,25" fill={c.slash} />
|
||||
<circle cx="80" cy="63" r="7" fill={c.primary} />
|
||||
</svg>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { Logo } from "./Logo";
|
||||
|
||||
export function Preloader() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [complete, setComplete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = "hidden";
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
const duration = 2500;
|
||||
const interval = 25;
|
||||
const steps = duration / interval;
|
||||
let currentStep = 0;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
currentStep++;
|
||||
const newProgress = Math.min(Math.floor((currentStep / steps) * 100), 100);
|
||||
setProgress(newProgress);
|
||||
|
||||
if (currentStep >= steps) {
|
||||
clearInterval(timer);
|
||||
setTimeout(() => {
|
||||
setComplete(true);
|
||||
setTimeout(() => {
|
||||
document.body.style.overflow = "auto";
|
||||
}, 1000); // Wait for exit animation
|
||||
}, 400); // Pause briefly at exactly 100%
|
||||
}
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
document.body.style.overflow = "auto";
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{!complete && (
|
||||
<motion.div
|
||||
initial={{ y: 0 }}
|
||||
exit={{ y: "-100vh" }}
|
||||
transition={{ duration: 0.9, ease: [0.76, 0, 0.24, 1] }}
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-[var(--ink)] text-white w-screen h-screen"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center gap-8 w-full px-6">
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<Logo
|
||||
variant="mark"
|
||||
size="xl"
|
||||
theme="dark"
|
||||
className="w-32 h-auto md:w-48 lg:w-56 drop-shadow-[0_0_20px_rgba(232,255,0,0.15)]"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.3, ease: "easeOut" }}
|
||||
className="text-center"
|
||||
>
|
||||
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-extrabold tracking-tight font-display mb-2">
|
||||
<span style={{ color: "var(--slash)" }}>Evandro</span><span className="text-white">.dev.br</span>
|
||||
</h1>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.5 }}
|
||||
className="w-full max-w-[200px] mt-4"
|
||||
>
|
||||
<div className="h-[2px] w-full overflow-hidden bg-white/10 rounded-full">
|
||||
<motion.div
|
||||
className="h-full bg-[var(--slash)]"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.1, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center text-[11px] uppercase tracking-widest text-[var(--slash)] font-mono opacity-80">
|
||||
{progress}%
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
+124
-60
@@ -1,90 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { Section } from "./Section";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { CounterValue } from "./animations/CounterValue";
|
||||
import { SectionLoading } from "./SectionLoading";
|
||||
|
||||
export default function Projects() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const projects = useMemo(
|
||||
() => data?.projects.filter((p) => p.featured) || [],
|
||||
[data],
|
||||
);
|
||||
|
||||
if (loading)
|
||||
return <div className="ds-loading">Loading featured projects</div>;
|
||||
if (error)
|
||||
if (loading) {
|
||||
return <SectionLoading dark message="Loading featured projects" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="ds-error">Error loading featured projects</div>;
|
||||
}
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 24, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="projects"
|
||||
eyebrow="PRODUCAO_INTELECTUAL"
|
||||
title="Projetos com recorte técnico claro e impacto observável"
|
||||
description="Seleção de produtos e ferramentas onde performance, confiabilidade e clareza de arquitetura foram requisitos reais."
|
||||
title="Projetos com recorte tecnico claro e impacto observavel"
|
||||
description="Selecao de produtos e ferramentas onde performance, confiabilidade e clareza de arquitetura foram requisitos reais."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="ds-grid-2">
|
||||
{projects.map((project, index) => (
|
||||
<Reveal key={project.id} y={50} duration={0.8} delay={0.1 * index}>
|
||||
<article className="ds-card h-full">
|
||||
<div className="flex items-start justify-between gap-sp-6">
|
||||
<div>
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
<p className="mt-sp-2 text-[11px] uppercase tracking-[0.1em] text-[rgba(255,255,255,.36)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<span className="ds-tag">{project.status}</span>
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
{projects.map((project) => (
|
||||
<motion.article
|
||||
key={project.id}
|
||||
className="ds-card h-full"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 44px rgba(0, 0, 0, 0.42)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 170, damping: 18 }}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-sp-6">
|
||||
<div>
|
||||
<div className="ds-kicker">{project.category}</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title">{project.title}</h3>
|
||||
<p className="mt-sp-2 text-[11px] uppercase tracking-[0.1em] text-[rgba(255,255,255,.36)]">
|
||||
{project.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<p className="ds-copy">{project.description}</p>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
{project.githubUrl && (
|
||||
<a
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
)}
|
||||
{project.demoUrl && (
|
||||
<a
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
>
|
||||
Live demo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{project.technologies.slice(0, 6).map((tech: string) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-4">
|
||||
<span className="ds-tag">{project.status}</span>
|
||||
</div>
|
||||
|
||||
<p className="ds-copy">{project.description}</p>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-3">
|
||||
{project.githubUrl && (
|
||||
<motion.a
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-muted"
|
||||
whileHover={shouldReduceMotion ? undefined : { scale: 1.03 }}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
||||
>
|
||||
GitHub
|
||||
</motion.a>
|
||||
)}
|
||||
{project.demoUrl && (
|
||||
<motion.a
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="ds-link ds-link-accent"
|
||||
whileHover={shouldReduceMotion ? undefined : { scale: 1.03 }}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.97 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
||||
>
|
||||
Live demo
|
||||
</motion.a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{project.technologies.slice(0, 6).map((tech: string) => (
|
||||
<li key={tech} className="ds-tag">
|
||||
{tech}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-sp-6 flex flex-wrap gap-sp-4">
|
||||
<span className="ds-metric">
|
||||
Complexity{" "}
|
||||
<CounterValue
|
||||
value={project.metrics?.complexity || 3}
|
||||
suffix="/5"
|
||||
/>
|
||||
</span>
|
||||
{project.metrics?.teamSize && (
|
||||
<span className="ds-metric">
|
||||
Complexity {project.metrics?.complexity || 3}/5
|
||||
Team {project.metrics.teamSize}
|
||||
</span>
|
||||
{project.metrics?.teamSize && (
|
||||
<span className="ds-metric">
|
||||
Team {project.metrics.teamSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</Reveal>
|
||||
)}
|
||||
</div>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
+44
-12
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { useScrollReveal } from "../hooks/useScrollReveal";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface RevealProps {
|
||||
children: ReactNode;
|
||||
@@ -17,20 +18,51 @@ interface RevealProps {
|
||||
|
||||
export function Reveal({
|
||||
children,
|
||||
y,
|
||||
duration,
|
||||
delay,
|
||||
ease,
|
||||
start,
|
||||
end,
|
||||
scrub,
|
||||
y = 36,
|
||||
duration = 0.8,
|
||||
delay = 0,
|
||||
className = "",
|
||||
}: RevealProps) {
|
||||
const ref = useScrollReveal({ y, duration, delay, ease, start, end, scrub });
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [animating, setAnimating] = useState(!shouldReduceMotion);
|
||||
|
||||
return (
|
||||
<div ref={ref as React.RefObject<HTMLDivElement>} className={className}>
|
||||
<motion.div
|
||||
className={className}
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y, clipPath: "inset(100% 0 0 0)" }
|
||||
}
|
||||
whileInView={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, y: 0, clipPath: "inset(0% 0 0 0)" }
|
||||
}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: {
|
||||
duration,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}
|
||||
}
|
||||
style={{
|
||||
willChange:
|
||||
animating && !shouldReduceMotion
|
||||
? "transform, opacity, clip-path"
|
||||
: undefined,
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
if (!shouldReduceMotion) {
|
||||
setAnimating(true);
|
||||
}
|
||||
}}
|
||||
onAnimationComplete={() => setAnimating(false)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
interface RouteTransitionProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function RouteTransition({ children }: Readonly<RouteTransitionProps>) {
|
||||
const pathname = usePathname();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
<motion.div
|
||||
key={pathname}
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: -20 }}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0.2 }
|
||||
: {
|
||||
opacity: { duration: 0.3, ease: [0.16, 1, 0.3, 1] },
|
||||
y: {
|
||||
duration: 0.4,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
},
|
||||
}
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
+46
-17
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { motion, Variants } from "framer-motion";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { TextDiffuseReveal } from "./animations/TextDiffuseReveal";
|
||||
import { Reveal } from "./Reveal";
|
||||
|
||||
interface SectionProps {
|
||||
@@ -25,11 +27,33 @@ export const Section = ({
|
||||
children,
|
||||
disableChildrenAnimation = false,
|
||||
}: SectionProps) => {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const baseClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.02,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: { opacity: 0, y: 20 },
|
||||
visible: { opacity: 1, y: 0, transition: { duration: 0.6, ease: "easeOut" } }
|
||||
hidden: shouldReduceMotion ? { opacity: 0 } : { opacity: 0, y: 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25, ease: [0.16, 1, 0.3, 1] }
|
||||
: {
|
||||
type: "spring",
|
||||
stiffness: 60,
|
||||
damping: 20,
|
||||
mass: 0.85,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -39,13 +63,8 @@ export const Section = ({
|
||||
className="ds-section-header"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, margin: "-100px" }}
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: { staggerChildren: 0.15 }
|
||||
}
|
||||
}}
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
>
|
||||
<div className="ds-section-meta">
|
||||
{eyebrow && (
|
||||
@@ -53,20 +72,30 @@ export const Section = ({
|
||||
{eyebrow}
|
||||
</motion.p>
|
||||
)}
|
||||
<motion.h2 className="ds-section-title" variants={itemVariants}>
|
||||
{title}
|
||||
</motion.h2>
|
||||
|
||||
<h2 className="ds-section-title">
|
||||
<TextDiffuseReveal text={title} />
|
||||
</h2>
|
||||
|
||||
{description && (
|
||||
<motion.p className="ds-section-description" variants={itemVariants}>
|
||||
<motion.p
|
||||
className="ds-section-description"
|
||||
variants={itemVariants}
|
||||
>
|
||||
{description}
|
||||
</motion.p>
|
||||
)}
|
||||
|
||||
<DrawLine delay={0.16} />
|
||||
</div>
|
||||
{aside && <motion.aside variants={itemVariants}>{aside}</motion.aside>}
|
||||
|
||||
{aside && (
|
||||
<motion.aside variants={itemVariants}>{aside}</motion.aside>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
|
||||
{!disableChildrenAnimation ? (
|
||||
<Reveal y={60} duration={1} delay={0.2}>
|
||||
<Reveal y={40} duration={0.9} delay={0.12}>
|
||||
<div>{children}</div>
|
||||
</Reveal>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { TopProgressBar } from "./animations/TopProgressBar";
|
||||
|
||||
interface SectionLoadingProps {
|
||||
message: string;
|
||||
dark?: boolean;
|
||||
skeletons?: number;
|
||||
}
|
||||
|
||||
export function SectionLoading({
|
||||
message,
|
||||
dark = false,
|
||||
skeletons = 2,
|
||||
}: Readonly<SectionLoadingProps>) {
|
||||
const sectionClass = dark ? "ds-section ds-section-dark" : "ds-section";
|
||||
|
||||
return (
|
||||
<section className={sectionClass}>
|
||||
<TopProgressBar />
|
||||
<div className="ds-section-shell">
|
||||
<div className="ds-loading">{message}</div>
|
||||
<div className="ds-grid-2 mt-sp-6" aria-hidden="true">
|
||||
{Array.from({ length: skeletons }).map((_, index) => (
|
||||
<div key={`skeleton-${index}`} className="ds-card ds-skeleton-card">
|
||||
<div className="ds-skeleton ds-skeleton-kicker" />
|
||||
<div className="ds-skeleton ds-skeleton-title" />
|
||||
<div className="ds-skeleton ds-skeleton-line" />
|
||||
<div className="ds-skeleton ds-skeleton-line" />
|
||||
<div className="ds-skeleton ds-skeleton-line ds-skeleton-line-short" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+73
-21
@@ -1,46 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { usePortfolioData } from "@/lib/usePortfolioData";
|
||||
import { Section } from "./Section";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { CounterValue } from "./animations/CounterValue";
|
||||
import { SectionLoading } from "./SectionLoading";
|
||||
|
||||
export default function Skills() {
|
||||
const { data, loading, error } = usePortfolioData();
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
if (loading) {
|
||||
return <SectionLoading dark message="Loading skill registry" />;
|
||||
}
|
||||
|
||||
if (loading) return <div className="ds-loading">Loading skill registry</div>;
|
||||
if (error || !data) {
|
||||
return <div className="ds-error">Error loading skill registry</div>;
|
||||
}
|
||||
|
||||
const gridVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.06,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cardVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.8, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="skills"
|
||||
eyebrow="MODULE_REGISTRY.lib"
|
||||
title="Stack modular para produto, operação e escala"
|
||||
description="Ferramentas escolhidas por previsibilidade operacional, velocidade de entrega e facilidade de manutenção."
|
||||
title="Stack modular para produto, operacao e escala"
|
||||
description="Ferramentas escolhidas por previsibilidade operacional, velocidade de entrega e facilidade de manutencao."
|
||||
dark
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div className="ds-grid-2">
|
||||
<motion.div
|
||||
className="ds-grid-2"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={gridVariants}
|
||||
>
|
||||
{data.skills.technical.map((category, index) => (
|
||||
<Reveal key={category.category} y={40} duration={0.8} delay={0.1 * index}>
|
||||
<article className="ds-card h-full">
|
||||
<div className="flex items-center justify-between gap-sp-4">
|
||||
<div className="ds-kicker">ID_0{index + 1}X</div>
|
||||
<div className="ds-metric">{category.skills.length} items</div>
|
||||
<motion.article
|
||||
key={category.category}
|
||||
className="ds-card h-full"
|
||||
variants={cardVariants}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 20px 40px rgba(0, 0, 0, 0.35)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-sp-4">
|
||||
<div className="ds-kicker">ID_0{index + 1}X</div>
|
||||
<div className="ds-metric">
|
||||
<CounterValue value={category.skills.length} suffix=" items" />
|
||||
</div>
|
||||
<h3 className="ds-card-title">{category.category}</h3>
|
||||
<ul className="ds-tags mt-sp-6">
|
||||
{category.skills.map((skill) => (
|
||||
<li key={skill} className="ds-tag">
|
||||
{skill}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
</Reveal>
|
||||
</div>
|
||||
<DrawLine delay={0.05} />
|
||||
<h3 className="ds-card-title mb-sp-8">{category.category}</h3>
|
||||
<ul className="ds-tags">
|
||||
{category.skills.map((skill) => (
|
||||
<li key={skill} className="ds-tag">
|
||||
{skill}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
+102
-180
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, type Variants, useReducedMotion } from "framer-motion";
|
||||
import { Section } from "./Section";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
|
||||
interface Tier {
|
||||
id: string;
|
||||
@@ -24,20 +24,20 @@ const TIERS: Tier[] = [
|
||||
id: "discovery",
|
||||
badge: "Entrada / Baixo Risco",
|
||||
level: "01",
|
||||
title: "Auditoria & Diagnóstico Técnico",
|
||||
title: "Auditoria & Diagnostico Tecnico",
|
||||
tagline: "Fotografia clara do problema antes de qualquer comprometimento.",
|
||||
description:
|
||||
"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.",
|
||||
"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.",
|
||||
deliverables: [
|
||||
"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",
|
||||
"Relatorio de gargalos e divida tecnica",
|
||||
"Mapeamento de riscos e dependencias",
|
||||
"Sugestao de stack e cronograma de mitigacao",
|
||||
"Plano de acao priorizado",
|
||||
],
|
||||
pricing: "Valor Fixo",
|
||||
pricingNote: "Contrato único. Entrega em até 5 dias úteis.",
|
||||
pricingNote: "Contrato unico. Entrega em ate 5 dias uteis.",
|
||||
featured: false,
|
||||
ctaLabel: "Solicitar diagnóstico",
|
||||
ctaLabel: "Solicitar diagnostico",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
{
|
||||
@@ -47,12 +47,12 @@ const TIERS: Tier[] = [
|
||||
title: "Ciclos de Engenharia",
|
||||
tagline: "Capacidade produtiva garantida. Entrega a cada 2 semanas.",
|
||||
description:
|
||||
"Desenvolvimento contínuo com a previsibilidade de orçamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
|
||||
"Desenvolvimento continuo com a previsibilidade de orcamento do escopo fechado e a agilidade de sprints. Milestones claros, sem amarras de contrato longo.",
|
||||
deliverables: [
|
||||
"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",
|
||||
"Features em producao a cada ciclo",
|
||||
"Refatoracao iterativa com rastreabilidade",
|
||||
"Documentacao tecnica atualizada",
|
||||
"Capacidade de pivotar prioridades no inicio de cada sprint",
|
||||
],
|
||||
pricing: "Fixo por Ciclo",
|
||||
pricingNote: "Assinatura quinzenal ou mensal. Cancele a qualquer ciclo.",
|
||||
@@ -65,226 +65,148 @@ const TIERS: Tier[] = [
|
||||
badge: "Premium / High-Ticket",
|
||||
level: "03",
|
||||
title: "Fractional Tech Lead",
|
||||
tagline: "Liderança técnica senior. Sem o custo de um CTO full-time.",
|
||||
tagline: "Lideranca tecnica senior. Sem o custo de um CTO full-time.",
|
||||
description:
|
||||
"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.",
|
||||
"Direcao arquitetural, gestao de risco e sustentacao de operacoes criticas. Para empresas que precisam de decisoes de alto impacto com responsabilidade continua.",
|
||||
deliverables: [
|
||||
"SLA prioritário Zero-Risk",
|
||||
"Code review e liderança do time interno",
|
||||
"Decisões de infra: Cloud, VPS, CI/CD",
|
||||
"Consultoria estratégica recorrente",
|
||||
"SLA prioritario Zero-Risk",
|
||||
"Code review e lideranca do time interno",
|
||||
"Decisoes de infra: Cloud, VPS, CI/CD",
|
||||
"Consultoria estrategica recorrente",
|
||||
],
|
||||
pricing: "Retainer Mensal",
|
||||
pricingNote: "Renovação mensal. Vagas limitadas.",
|
||||
pricingNote: "Renovacao mensal. Vagas limitadas.",
|
||||
featured: false,
|
||||
ctaLabel: "Agendar conversa estratégica",
|
||||
ctaLabel: "Agendar conversa estrategica",
|
||||
ctaHref: "#contact",
|
||||
},
|
||||
];
|
||||
|
||||
function TierCard({ tier }: { tier: Tier }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
function TierCard({ tier }: Readonly<{ tier: Tier }>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<article
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onFocus={() => setHovered(true)}
|
||||
onBlur={() => setHovered(false)}
|
||||
<motion.article
|
||||
tabIndex={0}
|
||||
className={`ds-card h-full relative outline-none ${
|
||||
tier.featured ? "border-[var(--slash)] shadow-sm" : ""
|
||||
}`}
|
||||
style={{
|
||||
border: tier.featured
|
||||
? "2px solid #0A0A0A"
|
||||
: "1px solid rgba(10,10,10,0.1)",
|
||||
background: tier.featured ? "#0A0A0A" : "#fff",
|
||||
padding: "var(--sp-6)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "var(--sp-4)",
|
||||
position: "relative",
|
||||
transition: "border-color 0.3s ease, box-shadow 0.3s ease",
|
||||
outline: "none",
|
||||
cursor: "default",
|
||||
boxShadow: hovered
|
||||
? "0 8px 32px rgba(10,10,10,0.1)"
|
||||
: "0 1px 4px rgba(10,10,10,0.04)",
|
||||
borderWidth: tier.featured ? "2px" : "1px",
|
||||
}}
|
||||
whileHover={
|
||||
shouldReduceMotion
|
||||
? undefined
|
||||
: {
|
||||
y: -4,
|
||||
boxShadow: "0 24px 50px rgba(0, 0, 0, 0.08)",
|
||||
}
|
||||
}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.99 }}
|
||||
transition={{ type: "spring", stiffness: 180, damping: 18 }}
|
||||
>
|
||||
{/* Top row */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: "9px",
|
||||
letterSpacing: "0.16em",
|
||||
textTransform: "uppercase",
|
||||
color: tier.featured ? "#E8FF00" : "rgba(10,10,10,0.4)",
|
||||
}}
|
||||
>
|
||||
{tier.badge}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: "11px",
|
||||
color: tier.featured
|
||||
? "rgba(255,255,255,0.15)"
|
||||
: "rgba(10,10,10,0.12)",
|
||||
fontWeight: 800,
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="ds-kicker">{tier.badge}</span>
|
||||
<span className="font-display text-[11px] font-extrabold tracking-widest text-[var(--meta)]">
|
||||
{tier.level}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: "clamp(18px, 2.2vw, 26px)",
|
||||
lineHeight: 1.1,
|
||||
letterSpacing: "-0.02em",
|
||||
fontWeight: 800,
|
||||
color: tier.featured ? "#fff" : "#0A0A0A",
|
||||
}}
|
||||
>
|
||||
{tier.title}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
marginTop: "var(--sp-2)",
|
||||
fontSize: "11px",
|
||||
color: tier.featured
|
||||
? "rgba(232,255,0,0.85)"
|
||||
: "rgba(10,10,10,0.5)",
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<DrawLine delay={0.04} />
|
||||
|
||||
<div className="mt-sp-4">
|
||||
<h3 className="ds-card-title">{tier.title}</h3>
|
||||
<p className="mt-sp-2 text-[11px] font-medium leading-relaxed text-[var(--meta)]">
|
||||
{tier.tagline}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
color: tier.featured
|
||||
? "rgba(255,255,255,0.7)"
|
||||
: "rgba(10,10,10,0.65)",
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className="ds-copy">{tier.description}</p>
|
||||
|
||||
{/* Deliverables */}
|
||||
<ul
|
||||
style={{
|
||||
paddingLeft: "var(--sp-5)",
|
||||
fontSize: "12px",
|
||||
color: tier.featured ? "rgba(255,255,255,0.6)" : "rgba(10,10,10,0.6)",
|
||||
lineHeight: 1.9,
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{tier.deliverables.map((d) => (
|
||||
<li key={d} style={{ listStyleType: "disc" }}>
|
||||
{d}
|
||||
</li>
|
||||
<ul className="ds-list">
|
||||
{tier.deliverables.map((deliverable) => (
|
||||
<li key={deliverable}>{deliverable}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Pricing */}
|
||||
<div
|
||||
style={{
|
||||
borderTop: tier.featured
|
||||
? "1px solid rgba(255,255,255,0.1)"
|
||||
: "1px solid rgba(10,10,10,0.08)",
|
||||
paddingTop: "var(--sp-4)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-display)",
|
||||
fontSize: "clamp(14px, 1.5vw, 18px)",
|
||||
fontWeight: 800,
|
||||
color: tier.featured ? "#E8FF00" : "#0A0A0A",
|
||||
letterSpacing: "-0.01em",
|
||||
}}
|
||||
>
|
||||
<div className="mt-sp-6 border-t border-[var(--border)] pt-sp-4">
|
||||
<div className="font-display text-[clamp(14px,1.5vw,18px)] font-extrabold tracking-tight">
|
||||
{tier.pricing}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: tier.featured
|
||||
? "rgba(255,255,255,0.3)"
|
||||
: "rgba(10,10,10,0.35)",
|
||||
marginTop: "4px",
|
||||
letterSpacing: "0.06em",
|
||||
}}
|
||||
>
|
||||
<div className="mt-1 text-[10px] tracking-widest text-[var(--meta)] uppercase">
|
||||
{tier.pricingNote}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA — reveals on hover/focus */}
|
||||
<a
|
||||
<motion.a
|
||||
href={tier.ctaHref}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "12px 18px",
|
||||
fontSize: "10px",
|
||||
letterSpacing: "0.12em",
|
||||
textTransform: "uppercase",
|
||||
textDecoration: "none",
|
||||
fontWeight: 700,
|
||||
background: tier.featured ? "#E8FF00" : "#0A0A0A",
|
||||
color: tier.featured ? "#0A0A0A" : "#fff",
|
||||
transition: "opacity 0.25s ease, transform 0.25s ease",
|
||||
opacity: hovered ? 1 : 0,
|
||||
transform: hovered ? "translateY(0)" : "translateY(6px)",
|
||||
pointerEvents: hovered ? "auto" : "none",
|
||||
}}
|
||||
className={`ds-link mt-sp-6 justify-center w-full ${
|
||||
tier.featured ? "ds-link-accent" : "ds-link-muted"
|
||||
}`}
|
||||
whileHover={shouldReduceMotion ? undefined : { scale: 1.02 }}
|
||||
whileTap={shouldReduceMotion ? undefined : { scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 260, damping: 20 }}
|
||||
>
|
||||
{tier.ctaLabel} →
|
||||
</a>
|
||||
</article>
|
||||
{tier.ctaLabel}
|
||||
{" ->"}
|
||||
</motion.a>
|
||||
</motion.article>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkModalities() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const containerVariants: Variants = {
|
||||
hidden: {},
|
||||
visible: {
|
||||
transition: {
|
||||
delayChildren: 0.08,
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const itemVariants: Variants = {
|
||||
hidden: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, y: 20, clipPath: "inset(100% 0 0 0)" },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
clipPath: "inset(0% 0 0 0)",
|
||||
transition: shouldReduceMotion
|
||||
? { duration: 0.25 }
|
||||
: { duration: 0.75, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="modalities"
|
||||
eyebrow="CONTRACT_PROTOCOLS.sys"
|
||||
title="Engenharia sob demanda. Previsibilidade de entrega sem overhead."
|
||||
description="Três níveis de contratação desenhados como uma esteira de valor — do diagnóstico rápido à parceria estratégica de longo prazo."
|
||||
description="Tres niveis de contratacao desenhados como uma esteira de valor, do diagnostico rapido a parceria estrategica de longo prazo."
|
||||
disableChildrenAnimation
|
||||
>
|
||||
<div
|
||||
<motion.div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
|
||||
gap: "var(--sp-6)",
|
||||
}}
|
||||
className="max-md:grid-cols-1 max-md:!flex max-md:!flex-col"
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={{ once: true, amount: 0.2 }}
|
||||
variants={containerVariants}
|
||||
>
|
||||
{TIERS.map((tier, index) => (
|
||||
<Reveal key={tier.id} y={40} duration={0.8} delay={0.1 * index}>
|
||||
{TIERS.map((tier) => (
|
||||
<motion.div key={tier.id} variants={itemVariants}>
|
||||
<TierCard tier={tier} />
|
||||
</Reveal>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useInView, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface CounterValueProps {
|
||||
value: number;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
once?: boolean;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
export function CounterValue({
|
||||
value,
|
||||
className = "",
|
||||
duration = 1200,
|
||||
prefix = "",
|
||||
suffix = "",
|
||||
once = true,
|
||||
amount = 0.45,
|
||||
}: Readonly<CounterValueProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const isInView = useInView(ref, { once, amount });
|
||||
const [displayValue, setDisplayValue] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) return;
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
setDisplayValue(value);
|
||||
return;
|
||||
}
|
||||
|
||||
const start = performance.now();
|
||||
let rafId = 0;
|
||||
|
||||
const frame = (now: number) => {
|
||||
const elapsed = now - start;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
setDisplayValue(Math.round(value * eased));
|
||||
|
||||
if (progress < 1) {
|
||||
rafId = requestAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(frame);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [isInView, value, duration, shouldReduceMotion]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className={className}>
|
||||
{prefix}
|
||||
{displayValue}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface DrawLineProps {
|
||||
className?: string;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function DrawLine({
|
||||
className = "",
|
||||
delay = 0,
|
||||
}: Readonly<DrawLineProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [animating, setAnimating] = useState(!shouldReduceMotion);
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={`ds-draw-line ${className}`.trim()}
|
||||
initial={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, scaleX: 0 }}
|
||||
whileInView={
|
||||
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scaleX: 1 }
|
||||
}
|
||||
viewport={{ once: true, amount: 0.7 }}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.25 : 0.6,
|
||||
delay,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
style={{
|
||||
transformOrigin: "left",
|
||||
willChange:
|
||||
animating && !shouldReduceMotion ? "transform, opacity" : undefined,
|
||||
}}
|
||||
onAnimationComplete={() => setAnimating(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Image, { type StaticImageData } from "next/image";
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface LazyRevealImageProps {
|
||||
src: string | StaticImageData;
|
||||
alt: string;
|
||||
className?: string;
|
||||
imageClassName?: string;
|
||||
sizes?: string;
|
||||
}
|
||||
|
||||
export function LazyRevealImage({
|
||||
src,
|
||||
alt,
|
||||
className = "",
|
||||
imageClassName = "object-cover",
|
||||
sizes = "(max-width: 1024px) 100vw, 50vw",
|
||||
}: Readonly<LazyRevealImageProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [animating, setAnimating] = useState(!shouldReduceMotion);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`relative ${className}`.trim()}
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, filter: "blur(20px)", scale: 1.05 }
|
||||
}
|
||||
animate={
|
||||
loaded
|
||||
? shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { opacity: 1, filter: "blur(0px)", scale: 1 }
|
||||
: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { opacity: 0, filter: "blur(20px)", scale: 1.05 }
|
||||
}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.25 : 0.75,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
}}
|
||||
style={{
|
||||
willChange:
|
||||
animating && !shouldReduceMotion
|
||||
? "filter, transform, opacity"
|
||||
: undefined,
|
||||
}}
|
||||
onAnimationComplete={() => setAnimating(false)}
|
||||
>
|
||||
<Image
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
loading="lazy"
|
||||
sizes={sizes}
|
||||
placeholder={typeof src === "string" ? "empty" : "blur"}
|
||||
onLoad={() => {
|
||||
setAnimating(true);
|
||||
setLoaded(true);
|
||||
}}
|
||||
className={imageClassName}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useInView, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface TextDiffuseRevealProps {
|
||||
text: string;
|
||||
className?: string;
|
||||
startDelay?: number;
|
||||
once?: boolean;
|
||||
amount?: number;
|
||||
}
|
||||
|
||||
const EXPO_OUT = [0.16, 1, 0.3, 1] as const;
|
||||
|
||||
export function TextDiffuseReveal({
|
||||
text,
|
||||
className = "",
|
||||
startDelay = 0,
|
||||
once = true,
|
||||
amount = 0.3,
|
||||
}: Readonly<TextDiffuseRevealProps>) {
|
||||
const ref = useRef<HTMLSpanElement | null>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
const isInView = useInView(ref, { once, amount });
|
||||
const [staggerDelay, setStaggerDelay] = useState(0.03);
|
||||
const [completedChars, setCompletedChars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(max-width: 768px)");
|
||||
const updateStagger = () => setStaggerDelay(media.matches ? 0.015 : 0.03);
|
||||
|
||||
updateStagger();
|
||||
media.addEventListener("change", updateStagger);
|
||||
return () => media.removeEventListener("change", updateStagger);
|
||||
}, []);
|
||||
|
||||
const tokens = useMemo(() => text.split(/(\n|\s+)/), [text]);
|
||||
const animatedChars = useMemo(
|
||||
() =>
|
||||
Array.from(text).filter((char) => char !== "\n" && char !== " ").length,
|
||||
[text],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInView) {
|
||||
setCompletedChars(0);
|
||||
}
|
||||
}, [isInView]);
|
||||
|
||||
const hasActiveWillChange =
|
||||
!shouldReduceMotion && isInView && completedChars < animatedChars;
|
||||
|
||||
let animatedIndex = 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
aria-label={text}
|
||||
style={{
|
||||
willChange: hasActiveWillChange
|
||||
? "filter, opacity, transform"
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{tokens.map((token, tokenIndex) => {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (token === "\n") {
|
||||
return <br key={`line-${tokenIndex}`} />;
|
||||
}
|
||||
|
||||
if (/^\s+$/.test(token)) {
|
||||
return (
|
||||
<span key={`space-${tokenIndex}`} aria-hidden="true">
|
||||
{token.replace(/ /g, "\u00A0")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`word-${tokenIndex}`}
|
||||
className="inline-block whitespace-nowrap"
|
||||
>
|
||||
{Array.from(token).map((char, charIndex) => {
|
||||
const currentIndex = animatedIndex;
|
||||
animatedIndex += 1;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
key={`char-${tokenIndex}-${charIndex}-${char}`}
|
||||
className="inline-block"
|
||||
initial={
|
||||
shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
animate={
|
||||
isInView
|
||||
? shouldReduceMotion
|
||||
? { opacity: 1 }
|
||||
: { filter: "blur(0px)", opacity: 1, scale: 1 }
|
||||
: shouldReduceMotion
|
||||
? { opacity: 0 }
|
||||
: { filter: "blur(12px)", opacity: 0, scale: 0.9 }
|
||||
}
|
||||
transition={{
|
||||
duration: shouldReduceMotion ? 0.3 : 0.6,
|
||||
ease: EXPO_OUT,
|
||||
delay: startDelay + currentIndex * staggerDelay,
|
||||
}}
|
||||
onAnimationComplete={() => {
|
||||
if (!shouldReduceMotion) {
|
||||
setCompletedChars((count) =>
|
||||
count < animatedChars ? count + 1 : count,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{char}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
interface TopProgressBarProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TopProgressBar({
|
||||
className = "",
|
||||
}: Readonly<TopProgressBarProps>) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pointer-events-none fixed inset-x-0 top-0 z-[120] h-[2px] overflow-hidden bg-white/10 ${className}`.trim()}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<motion.div
|
||||
className="h-full w-1/3 bg-[var(--slash)] shadow-[0_0_12px_rgba(204,255,0,0.85)]"
|
||||
initial={shouldReduceMotion ? { opacity: 1, x: "0%" } : { x: "-120%" }}
|
||||
animate={
|
||||
shouldReduceMotion
|
||||
? { opacity: 1, x: "0%" }
|
||||
: { x: ["-120%", "15%", "45%", "110%"] }
|
||||
}
|
||||
transition={
|
||||
shouldReduceMotion
|
||||
? { duration: 0 }
|
||||
: {
|
||||
duration: 1.5,
|
||||
ease: [0.16, 1, 0.3, 1],
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
repeatDelay: 0.1,
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
export default function PointCloudScene() {
|
||||
return <div data-testid="point-cloud-mock"></div>;
|
||||
}
|
||||
+123
-7
@@ -44,8 +44,8 @@
|
||||
--paper: #f4f2ed;
|
||||
--paper-dark: #eceae4;
|
||||
--white: #ffffff;
|
||||
--slash: #e8ff00;
|
||||
--slash-dim: rgba(232, 255, 0, 0.15);
|
||||
--slash: #ccff00;
|
||||
--slash-dim: rgba(204, 255, 0, 0.16);
|
||||
--meta: #777;
|
||||
--border: rgba(10, 10, 10, 0.1);
|
||||
--border-light: rgba(10, 10, 10, 0.06);
|
||||
@@ -158,7 +158,7 @@ textarea {
|
||||
}
|
||||
|
||||
.ds-section-meta {
|
||||
max-width: 400px;
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.ds-eyebrow,
|
||||
@@ -205,16 +205,21 @@ textarea {
|
||||
.ds-panel {
|
||||
border: 1px solid var(--border);
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.ds-card {
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
padding: var(--sp-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ds-panel {
|
||||
background: rgba(10, 10, 10, 0.02);
|
||||
padding: var(--sp-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ds-card-title {
|
||||
@@ -230,23 +235,26 @@ textarea {
|
||||
color: var(--ink-80);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ds-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-2);
|
||||
gap: var(--sp-3);
|
||||
list-style: none;
|
||||
margin-top: auto;
|
||||
padding-top: var(--sp-8);
|
||||
}
|
||||
|
||||
.ds-tag {
|
||||
padding: 6px 10px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-full);
|
||||
background: rgba(10, 10, 10, 0.03);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ds-metric {
|
||||
@@ -293,6 +301,7 @@ textarea {
|
||||
padding-left: var(--sp-5);
|
||||
font-size: 12px;
|
||||
line-height: 1.8;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ds-list li::marker {
|
||||
@@ -337,6 +346,113 @@ textarea {
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.ds-draw-line {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
margin-top: var(--sp-5);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(10, 10, 10, 0.55) 0%,
|
||||
rgba(10, 10, 10, 0.08) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.ds-section-dark .ds-draw-line {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(204, 255, 0, 0.75) 0%,
|
||||
rgba(204, 255, 0, 0.1) 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes ds-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes marquee-reverse {
|
||||
0% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: marquee 40s linear infinite;
|
||||
}
|
||||
|
||||
.animate-marquee-reverse {
|
||||
animation: marquee-reverse 45s linear infinite;
|
||||
}
|
||||
|
||||
.ds-skeleton-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.ds-skeleton {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.ds-skeleton::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(90deg, #111 0%, #1f1f1f 50%, #111 100%);
|
||||
background-size: 220% 100%;
|
||||
animation: ds-shimmer 1.4s linear infinite;
|
||||
}
|
||||
|
||||
.ds-skeleton-kicker {
|
||||
height: 10px;
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
.ds-skeleton-title {
|
||||
height: 26px;
|
||||
width: 72%;
|
||||
}
|
||||
|
||||
.ds-skeleton-line {
|
||||
height: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ds-skeleton-line-short {
|
||||
width: 62%;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.ds-section {
|
||||
padding: var(--sp-16) var(--sp-6);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useReducedMotion } from "./useReducedMotion";
|
||||
|
||||
// Mock do Framer Motion
|
||||
jest.mock("framer-motion", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("useReducedMotion", () => {
|
||||
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
mockUseReducedMotion.mockReturnValue(false);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
mockUseReducedMotion.mockReturnValue(true);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
mockUseReducedMotion.mockReturnValue(null);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("framer-motion");
|
||||
mockUseReducedMotion.mockReturnValue(undefined);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion as useFramerReducedMotion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
||||
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
||||
*
|
||||
* @returns {boolean} true se prefers-reduced-motion está ativo, false caso contrário
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const reducedMotion = useReducedMotion();
|
||||
*
|
||||
* return (
|
||||
* <motion.div
|
||||
* animate={{ opacity: 1, y: reducedMotion ? 0 : 20 }}
|
||||
* />
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
return useFramerReducedMotion() ?? false;
|
||||
}
|
||||
@@ -14,10 +14,10 @@ export function useScrollReveal(options?: {
|
||||
scrub?: boolean | number;
|
||||
}) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
|
||||
// Lazy registration to avoid Next.js hydration mismatch issues
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
@@ -37,12 +37,20 @@ export function useScrollReveal(options?: {
|
||||
end: options?.end ?? "top 50%",
|
||||
scrub: options?.scrub ?? false,
|
||||
},
|
||||
}
|
||||
},
|
||||
);
|
||||
}, ref);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, [options?.y, options?.duration, options?.delay, options?.ease, options?.start, options?.end, options?.scrub]);
|
||||
}, [
|
||||
options?.y,
|
||||
options?.duration,
|
||||
options?.delay,
|
||||
options?.ease,
|
||||
options?.start,
|
||||
options?.end,
|
||||
options?.scrub,
|
||||
]);
|
||||
|
||||
return ref;
|
||||
}
|
||||
+21
-26
@@ -1,15 +1,16 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { ReactNode } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import GoogleAnalytics from "./components/GoogleAnalytics";
|
||||
import { RouteTransition } from "./components/RouteTransition";
|
||||
import { SmoothScrollProvider } from "./providers/SmoothScrollProvider";
|
||||
import { AnimationProvider } from "./providers/AnimationProvider";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
// ... existing metadata ...
|
||||
title:
|
||||
"Evandro Fonseca Junior | Engenheiro de Software (Next.js, Tauri & Rust)",
|
||||
description:
|
||||
"Engenheiro Full-Stack especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalável.",
|
||||
"Engenheiro Full-Stack especializado em aplicacoes de alta performance e sistemas criticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalavel.",
|
||||
keywords: [
|
||||
"engenheiro full-stack",
|
||||
"engenheiro de software",
|
||||
@@ -18,19 +19,15 @@ export const metadata: Metadata = {
|
||||
"Tauri",
|
||||
"Rust",
|
||||
"Python",
|
||||
"Next.js specialist",
|
||||
"React developer",
|
||||
"Tauri developer",
|
||||
"TypeScript",
|
||||
"PostgreSQL",
|
||||
"MySQL",
|
||||
"computação em nuvem",
|
||||
"cloud computing",
|
||||
"IoT",
|
||||
"automação",
|
||||
"automacao",
|
||||
"arquitetura de software",
|
||||
"sistemas escaláveis",
|
||||
"High Performance",
|
||||
"System Architecture",
|
||||
"sistemas escalaveis",
|
||||
"high performance",
|
||||
],
|
||||
authors: [{ name: "Evandro Fonseca Junior" }],
|
||||
creator: "Evandro Fonseca Junior",
|
||||
@@ -48,7 +45,7 @@ export const metadata: Metadata = {
|
||||
title:
|
||||
"Evandro Fonseca Junior | Engenheiro de Software (Next.js, Tauri & Rust)",
|
||||
description:
|
||||
"Engenheiro Full-Stack especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalável.",
|
||||
"Engenheiro Full-Stack especializado em aplicacoes de alta performance e sistemas criticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalavel.",
|
||||
url: "https://evandro.dev.br",
|
||||
siteName: "Evandro Fonseca Junior",
|
||||
locale: "pt_BR",
|
||||
@@ -58,7 +55,7 @@ export const metadata: Metadata = {
|
||||
url: "/opengraph-image",
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: "Evandro Fonseca Junior - Engenheiro de Software (Next.js, Tauri & Rust)",
|
||||
alt: "Evandro Fonseca Junior - Engenheiro de Software",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -67,7 +64,7 @@ export const metadata: Metadata = {
|
||||
title:
|
||||
"Evandro Fonseca Junior | Engenheiro de Software (Next.js, Tauri & Rust)",
|
||||
description:
|
||||
"Engenheiro Full-Stack especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js, React, Tauri e arquitetura escalável.",
|
||||
"Engenheiro Full-Stack especializado em aplicacoes de alta performance e sistemas criticos.",
|
||||
creator: "@evandrodevbr",
|
||||
images: ["/opengraph-image"],
|
||||
},
|
||||
@@ -85,7 +82,7 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#ffffff",
|
||||
themeColor: "#000000",
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 5,
|
||||
@@ -116,20 +113,16 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
"Tauri",
|
||||
"Rust",
|
||||
"TypeScript",
|
||||
"High Performance Computing",
|
||||
"System Architecture",
|
||||
"Cloud Computing",
|
||||
"IoT",
|
||||
"Automation",
|
||||
"PostgreSQL",
|
||||
"MySQL",
|
||||
],
|
||||
worksFor: {
|
||||
"@type": "Organization",
|
||||
name: "Freelancer / Consultor Independente",
|
||||
},
|
||||
description:
|
||||
"Engenheiro de software especializado em aplicações de alta performance e sistemas críticos. Desenvolvimento com Next.js 15, React 19, Tauri e arquitetura escalável. Experiência em automação, computação em nuvem e sistemas distribuídos.",
|
||||
"Engenheiro de software especializado em aplicacoes de alta performance e sistemas criticos. Desenvolvimento com Next.js 15, React 19, Tauri e arquitetura escalavel.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -140,12 +133,14 @@ export default function RootLayout({ children }: Readonly<RootLayoutProps>) {
|
||||
</head>
|
||||
<body>
|
||||
<SmoothScrollProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
{children}
|
||||
<GoogleAnalytics />
|
||||
<AnimationProvider>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<RouteTransition>{children}</RouteTransition>
|
||||
<GoogleAnalytics />
|
||||
</AnimationProvider>
|
||||
</SmoothScrollProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Animation Configuration
|
||||
* Centralized configuration for all animations in the portfolio
|
||||
*/
|
||||
|
||||
export const animationConfig = {
|
||||
// Easing curves
|
||||
easings: {
|
||||
easeOut: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
easeIn: "cubic-bezier(0.4, 0, 1, 1)",
|
||||
spring: {
|
||||
default: { stiffness: 200, damping: 20 },
|
||||
hover: { stiffness: 260, damping: 20 },
|
||||
magnetic: { stiffness: 150, damping: 15 },
|
||||
gentle: { stiffness: 60, damping: 20 },
|
||||
},
|
||||
},
|
||||
|
||||
// Durations (in seconds)
|
||||
durations: {
|
||||
instant: 0.15,
|
||||
fast: 0.3,
|
||||
normal: 0.6,
|
||||
slow: 0.9,
|
||||
verySlow: 1.2,
|
||||
},
|
||||
|
||||
// Scroll trigger
|
||||
scrollTrigger: {
|
||||
threshold: 0.2,
|
||||
once: true,
|
||||
margin: "0px",
|
||||
},
|
||||
|
||||
// Stagger
|
||||
stagger: {
|
||||
default: 0.08,
|
||||
fast: 0.04,
|
||||
slow: 0.12,
|
||||
reducedMotion: 0.03,
|
||||
},
|
||||
|
||||
// Parallax
|
||||
parallax: {
|
||||
speed: 0.5, // 50% mais lento que scroll normal
|
||||
maxDistance: 100, // pixels
|
||||
},
|
||||
|
||||
// Magnetic effect
|
||||
magnetic: {
|
||||
radius: 80, // pixels
|
||||
strength: 12, // max movement in pixels
|
||||
},
|
||||
|
||||
// Cursor
|
||||
cursor: {
|
||||
size: 40,
|
||||
expandedSize: 60,
|
||||
followDelay: 0.1,
|
||||
},
|
||||
|
||||
// Reduced motion overrides
|
||||
reducedMotion: {
|
||||
duration: 0.15,
|
||||
stagger: 0.03,
|
||||
disableParallax: true,
|
||||
disableMagnetic: true,
|
||||
disableCursor: true,
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Animation Variants
|
||||
* Preset variants for Framer Motion animations
|
||||
*/
|
||||
|
||||
export const variants = {
|
||||
// Fade in
|
||||
fadeIn: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
|
||||
// Fade in + slide up
|
||||
fadeInUp: {
|
||||
initial: { opacity: 0, y: 24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: -24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Fade in + slide down
|
||||
fadeInDown: {
|
||||
initial: { opacity: 0, y: -24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, y: 24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Fade in + slide left
|
||||
fadeInLeft: {
|
||||
initial: { opacity: 0, x: 24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, x: -24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Fade in + slide right
|
||||
fadeInRight: {
|
||||
initial: { opacity: 0, x: -24, filter: "blur(8px)" },
|
||||
animate: { opacity: 1, x: 0, filter: "blur(0px)" },
|
||||
exit: { opacity: 0, x: 24, filter: "blur(8px)" },
|
||||
},
|
||||
|
||||
// Scale in
|
||||
scaleIn: {
|
||||
initial: { opacity: 0, scale: 0.95 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.95 },
|
||||
},
|
||||
|
||||
// Image reveal
|
||||
imageReveal: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.98,
|
||||
filter: "blur(8px)",
|
||||
clipPath: "inset(0 100% 0 0)",
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
},
|
||||
},
|
||||
|
||||
// Text reveal (per word)
|
||||
textReveal: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
filter: "blur(6px)",
|
||||
clipPath: "inset(0 100% 0 0)",
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
filter: "blur(0px)",
|
||||
clipPath: "inset(0 0% 0 0)",
|
||||
},
|
||||
},
|
||||
|
||||
// Stagger container
|
||||
staggerContainer: {
|
||||
animate: {
|
||||
transition: {
|
||||
staggerChildren: 0.08,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Reduced motion variants
|
||||
reducedMotion: {
|
||||
fadeIn: {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
+13
-5
@@ -1,23 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Suspense } from "react";
|
||||
import About from "./components/About";
|
||||
import Contact from "./components/Contact";
|
||||
import Experience from "./components/Experience";
|
||||
import Footer from "./components/Footer";
|
||||
import Hero from "./components/Hero";
|
||||
import Projects from "./components/Projects";
|
||||
import { SectionLoading } from "./components/SectionLoading";
|
||||
import Skills from "./components/Skills";
|
||||
import { WorkModalities } from "./components/WorkModalities";
|
||||
import { Preloader } from "./components/Preloader";
|
||||
import Footer from "./components/Footer";
|
||||
|
||||
const Projects = dynamic(() => import("./components/Projects"));
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
||||
<Preloader />
|
||||
<main className="ds-main">
|
||||
<Hero />
|
||||
<About />
|
||||
<Skills />
|
||||
<Experience />
|
||||
<Projects />
|
||||
<Suspense
|
||||
fallback={<SectionLoading message="Carregando projetos..." />}
|
||||
>
|
||||
<Projects />
|
||||
</Suspense>
|
||||
<WorkModalities />
|
||||
<Contact />
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { AnimationProvider, useAnimationConfig } from "./AnimationProvider";
|
||||
|
||||
// Mock do useReducedMotion
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
|
||||
// Test component that uses the hook
|
||||
function TestComponent() {
|
||||
const config = useAnimationConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="reduced-motion">{String(config.reducedMotion)}</div>
|
||||
<div data-testid="ease-out">{config.easings.easeOut}</div>
|
||||
<div data-testid="duration-normal">{config.durations.normal}</div>
|
||||
<div data-testid="spring-default-stiffness">
|
||||
{config.easings.spring.default.stiffness}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("AnimationProvider", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("provides animation config with reducedMotion false", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("reduced-motion")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("ease-out")).toHaveTextContent(
|
||||
"cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
);
|
||||
expect(screen.getByTestId("duration-normal")).toHaveTextContent("0.6");
|
||||
expect(screen.getByTestId("spring-default-stiffness")).toHaveTextContent(
|
||||
"200",
|
||||
);
|
||||
});
|
||||
|
||||
it("provides animation config with reducedMotion true", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("reduced-motion")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("provides all easing configurations", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
const config = screen.getByTestId("ease-out").textContent;
|
||||
expect(config).toBe("cubic-bezier(0.16, 1, 0.3, 1)");
|
||||
});
|
||||
|
||||
it("provides all duration configurations", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<TestComponent />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
const duration = screen.getByTestId("duration-normal").textContent;
|
||||
expect(duration).toBe("0.6");
|
||||
});
|
||||
|
||||
it("throws error when useAnimationConfig is used outside provider", () => {
|
||||
// Suppress console.error for this test
|
||||
const consoleError = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TestComponent />);
|
||||
}).toThrow("useAnimationConfig must be used within AnimationProvider");
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAnimationConfig", () => {
|
||||
it("returns config object with correct structure", () => {
|
||||
const { useReducedMotion } = jest.requireMock(
|
||||
"@/app/hooks/useReducedMotion",
|
||||
);
|
||||
useReducedMotion.mockReturnValue(false);
|
||||
|
||||
function ConfigStructureTest() {
|
||||
const config = useAnimationConfig();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="has-reduced-motion">
|
||||
{String("reducedMotion" in config)}
|
||||
</div>
|
||||
<div data-testid="has-easings">{String("easings" in config)}</div>
|
||||
<div data-testid="has-durations">{String("durations" in config)}</div>
|
||||
<div data-testid="has-spring">
|
||||
{String("spring" in config.easings)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render(
|
||||
<AnimationProvider>
|
||||
<ConfigStructureTest />
|
||||
</AnimationProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("has-reduced-motion")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("has-easings")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("has-durations")).toHaveTextContent("true");
|
||||
expect(screen.getByTestId("has-spring")).toHaveTextContent("true");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, type ReactNode } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { animationConfig } from "@/app/lib/animations/config";
|
||||
|
||||
/**
|
||||
* Animation Configuration Context
|
||||
* Provides global animation settings including reduced motion detection
|
||||
*/
|
||||
interface AnimationConfig {
|
||||
reducedMotion: boolean;
|
||||
easings: typeof animationConfig.easings;
|
||||
durations: typeof animationConfig.durations;
|
||||
}
|
||||
|
||||
const AnimationContext = createContext<AnimationConfig | undefined>(undefined);
|
||||
|
||||
interface AnimationProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimationProvider
|
||||
*
|
||||
* Provider component that supplies global animation configuration through React context.
|
||||
* Automatically detects user's reduced motion preference and provides centralized
|
||||
* animation settings (easings, durations) to all child components.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // In app/layout.tsx
|
||||
* <AnimationProvider>
|
||||
* <YourApp />
|
||||
* </AnimationProvider>
|
||||
* ```
|
||||
*
|
||||
* @see useAnimationConfig - Hook to consume this context
|
||||
*/
|
||||
export function AnimationProvider({ children }: AnimationProviderProps) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
const config: AnimationConfig = {
|
||||
reducedMotion,
|
||||
easings: animationConfig.easings,
|
||||
durations: animationConfig.durations,
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimationContext.Provider value={config}>
|
||||
{children}
|
||||
</AnimationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* useAnimationConfig
|
||||
*
|
||||
* Hook to access global animation configuration from AnimationProvider context.
|
||||
* Provides reduced motion state, easing curves, and duration presets.
|
||||
*
|
||||
* @returns {AnimationConfig} Animation configuration object
|
||||
* @throws {Error} If used outside of AnimationProvider
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { reducedMotion, easings, durations } = useAnimationConfig();
|
||||
*
|
||||
* return (
|
||||
* <motion.div
|
||||
* animate={{
|
||||
* opacity: 1,
|
||||
* y: reducedMotion ? 0 : 20
|
||||
* }}
|
||||
* transition={{
|
||||
* duration: durations.normal,
|
||||
* ease: easings.easeOut
|
||||
* }}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAnimationConfig(): AnimationConfig {
|
||||
const context = useContext(AnimationContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error("useAnimationConfig must be used within AnimationProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
Reference in new issue
Block a user