Complete Phase 2: Core sections and animation system

- Hero section with gradient headline, CTAs, capability badges
- Stats section with animated count-up numbers
- Contact section with form validation
- Input component for form fields
- useIntersectionObserver hook for scroll detection
- useAnimateOnScroll hook for scroll-triggered animations
- AnimatedSection wrapper component
- Animation variants utility (fade-up, fade-in, slide-left/right)

147 passing tests (65 new tests added)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 16:37:33 -07:00
parent c915a3cd66
commit f368ae9f8d
18 changed files with 1561 additions and 39 deletions

View File

@ -1,6 +1,6 @@
import { Header, Footer, Container } from './components/layout'
import { Button } from './components/ui/Button'
import { Card } from './components/ui/Card'
import { Hero, Stats, Contact } from './components/sections'
function App() {
return (
@ -9,27 +9,11 @@ function App() {
{/* Main content with top padding for fixed header */}
<main className="flex-1 pt-20">
{/* Hero Section Placeholder */}
<section id="home" className="py-20 bg-gradient-to-br from-bg-primary to-bg-secondary">
<Container>
<div className="text-center max-w-3xl mx-auto">
<h1 className="text-5xl md:text-6xl font-bold text-text-primary mb-6">
Building <span className="text-primary">Digital</span> Experiences
</h1>
<p className="text-xl text-text-secondary mb-8">
Custom software solutions that transform your business ideas into reality.
</p>
<div className="flex flex-wrap justify-center gap-4">
<Button variant="primary" size="lg">
Get Started
</Button>
<Button variant="secondary" size="lg">
Learn More
</Button>
</div>
</div>
</Container>
</section>
{/* Hero Section */}
<Hero />
{/* Stats Section */}
<Stats />
{/* Services Section Placeholder */}
<section id="services" className="py-20">
@ -73,27 +57,13 @@ function App() {
Our Process
</h2>
<p className="text-center text-text-secondary max-w-2xl mx-auto">
Phase 2 will add the animated workflow carousel here.
Phase 3 will add the animated workflow carousel here.
</p>
</Container>
</section>
{/* Contact Section Placeholder */}
<section id="contact" className="py-20">
<Container>
<h2 className="text-3xl md:text-4xl font-bold text-center text-text-primary mb-12">
Get In Touch
</h2>
<div className="max-w-md mx-auto text-center">
<p className="text-text-secondary mb-8">
Ready to start your project? Contact us today.
</p>
<Button variant="primary" size="lg">
Contact Us
</Button>
</div>
</Container>
</section>
{/* Contact Section */}
<Contact />
</main>
<Footer />

View File

@ -0,0 +1,160 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Contact } from './Contact'
describe('Contact', () => {
it('renders section header', () => {
render(<Contact />)
expect(screen.getByText("Let's Build Something Great")).toBeInTheDocument()
})
it('renders all contact methods', () => {
render(<Contact />)
// Contact method titles and values
expect(screen.getByText('hello@maverickapplications.com')).toBeInTheDocument()
expect(screen.getByText('Response Time')).toBeInTheDocument()
expect(screen.getByText('Within 24 hours')).toBeInTheDocument()
expect(screen.getByText('Location')).toBeInTheDocument()
expect(screen.getByText('Worldwide Remote')).toBeInTheDocument()
})
it('renders contact form', () => {
render(<Contact />)
expect(screen.getByRole('form')).toBeInTheDocument()
})
it('renders all form fields', () => {
render(<Contact />)
expect(screen.getByLabelText('Name')).toBeInTheDocument()
expect(screen.getByRole('textbox', { name: /email/i })).toBeInTheDocument()
expect(screen.getByLabelText('Project Type')).toBeInTheDocument()
expect(screen.getByLabelText('Message')).toBeInTheDocument()
})
it('renders as section element with accessible label', () => {
render(<Contact />)
expect(screen.getByRole('region', { name: /contact/i })).toBeInTheDocument()
})
})
describe('Contact Form', () => {
it('validates required fields', async () => {
const user = userEvent.setup()
render(<Contact />)
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument()
})
})
it('validates email format', async () => {
const user = userEvent.setup()
render(<Contact />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'invalid-email')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText('Please enter a valid email')).toBeInTheDocument()
})
})
it('displays error messages for invalid fields', async () => {
const user = userEvent.setup()
render(<Contact />)
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument()
expect(screen.getByText('Email is required')).toBeInTheDocument()
expect(screen.getByText('Project type is required')).toBeInTheDocument()
expect(screen.getByText('Message is required')).toBeInTheDocument()
})
})
it('calls onSubmit with form data when valid', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
projectType: 'Web App',
message: 'Test message',
})
})
})
it('shows success message after submission', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
})
it('disables submit button while submitting', async () => {
const handleSubmit = vi.fn().mockImplementation(
() => new Promise((resolve) => setTimeout(resolve, 100))
)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
const submitButton = screen.getByRole('button', { name: /send message/i })
await user.click(submitButton)
expect(submitButton).toBeDisabled()
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
})
it('hides form and shows success after submission', async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined)
const user = userEvent.setup()
render(<Contact onSubmit={handleSubmit} />)
await user.type(screen.getByLabelText('Name'), 'John Doe')
await user.type(screen.getByRole('textbox', { name: /email/i }), 'john@example.com')
await user.type(screen.getByLabelText('Project Type'), 'Web App')
await user.type(screen.getByLabelText('Message'), 'Test message')
await user.click(screen.getByRole('button', { name: /send message/i }))
await waitFor(() => {
expect(screen.getByText(/thank you/i)).toBeInTheDocument()
})
// Form should be hidden after success
expect(screen.queryByRole('form')).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /send message/i })).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,223 @@
import { useState } from 'react'
import { Mail, MessageSquare, Globe, CheckCircle } from 'lucide-react'
import { Container } from '../layout/Container'
import { Card } from '../ui/Card'
import { Button } from '../ui/Button'
import { Input } from '../ui/Input'
import type { LucideIcon } from 'lucide-react'
interface ContactMethod {
icon: LucideIcon
title: string
value: string
}
interface ContactFormData {
name: string
email: string
projectType: string
message: string
}
interface ContactProps {
/** Form submission handler */
onSubmit?: (data: ContactFormData) => Promise<void>
}
interface FormErrors {
name?: string
email?: string
projectType?: string
message?: string
}
const contactMethods: ContactMethod[] = [
{ icon: Mail, title: 'Email', value: 'hello@maverickapplications.com' },
{ icon: MessageSquare, title: 'Response Time', value: 'Within 24 hours' },
{ icon: Globe, title: 'Location', value: 'Worldwide Remote' },
]
const initialFormData: ContactFormData = {
name: '',
email: '',
projectType: '',
message: '',
}
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
function validateForm(data: ContactFormData): FormErrors {
const errors: FormErrors = {}
if (!data.name.trim()) {
errors.name = 'Name is required'
}
if (!data.email.trim()) {
errors.email = 'Email is required'
} else if (!validateEmail(data.email)) {
errors.email = 'Please enter a valid email'
}
if (!data.projectType.trim()) {
errors.projectType = 'Project type is required'
}
if (!data.message.trim()) {
errors.message = 'Message is required'
}
return errors
}
export function Contact({ onSubmit }: ContactProps) {
const [formData, setFormData] = useState<ContactFormData>(initialFormData)
const [errors, setErrors] = useState<FormErrors>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const validationErrors = validateForm(formData)
setErrors(validationErrors)
if (Object.keys(validationErrors).length > 0) {
return
}
setIsSubmitting(true)
try {
if (onSubmit) {
await onSubmit(formData)
} else {
// Default behavior: simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000))
}
setIsSuccess(true)
setFormData(initialFormData)
} finally {
setIsSubmitting(false)
}
}
const handleFieldChange = (field: keyof ContactFormData) => (value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }))
// Clear error when user starts typing
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }))
}
}
return (
<section
id="contact"
aria-label="Contact section"
className="bg-slate-50 py-20 md:py-28"
>
<Container>
<div className="grid gap-12 lg:grid-cols-2 lg:gap-16">
{/* Contact Info */}
<div className="space-y-8">
<div>
<h2 className="text-3xl md:text-4xl font-bold text-slate-900 mb-4">
Let's Build Something Great
</h2>
<p className="text-lg text-secondary">
Ready to bring your idea to life? Get in touch and let's discuss your project.
</p>
</div>
<div className="space-y-4">
{contactMethods.map(({ icon: Icon, title, value }) => (
<div
key={title}
className="flex items-center gap-4 p-4 bg-white rounded-lg border border-slate-200"
>
<div className="flex-shrink-0 w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<Icon className="w-5 h-5 text-primary" />
</div>
<div>
<div className="text-sm font-medium text-slate-500">{title}</div>
<div className="text-slate-900">{value}</div>
</div>
</div>
))}
</div>
</div>
{/* Contact Form */}
<Card className="p-8">
{isSuccess ? (
<div className="text-center py-8">
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-slate-900 mb-2">
Thank you!
</h3>
<p className="text-secondary">
We'll get back to you within 24 hours.
</p>
</div>
) : (
<form role="form" onSubmit={handleSubmit} noValidate className="space-y-6">
<Input
label="Name"
name="name"
placeholder="Your name"
required
value={formData.name}
onChange={handleFieldChange('name')}
error={errors.name}
/>
<Input
label="Email"
name="email"
type="email"
placeholder="you@example.com"
required
value={formData.email}
onChange={handleFieldChange('email')}
error={errors.email}
/>
<Input
label="Project Type"
name="projectType"
placeholder="Web App, Mobile App, etc."
required
value={formData.projectType}
onChange={handleFieldChange('projectType')}
error={errors.projectType}
/>
<Input
label="Message"
name="message"
type="textarea"
placeholder="Tell us about your project..."
required
value={formData.message}
onChange={handleFieldChange('message')}
error={errors.message}
/>
<Button
variant="primary"
type="submit"
disabled={isSubmitting}
className="w-full"
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</Button>
</form>
)}
</Card>
</div>
</Container>
</section>
)
}

View File

@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { Hero } from './Hero'
describe('Hero', () => {
it('renders headline correctly', () => {
render(<Hero />)
expect(screen.getByText('Software that ships.')).toBeInTheDocument()
})
it('renders subheadline correctly', () => {
render(<Hero />)
expect(
screen.getByText(/From concept to production in weeks, not months/)
).toBeInTheDocument()
})
it('renders custom headline when provided', () => {
render(<Hero headline="Custom Headline" />)
expect(screen.getByText('Custom Headline')).toBeInTheDocument()
expect(screen.queryByText('Software that ships.')).not.toBeInTheDocument()
})
it('renders custom subheadline when provided', () => {
render(<Hero subheadline="Custom subheadline text" />)
expect(screen.getByText('Custom subheadline text')).toBeInTheDocument()
})
it('renders primary CTA button', () => {
render(<Hero />)
expect(screen.getByRole('link', { name: 'Start Your Project' })).toBeInTheDocument()
})
it('renders secondary CTA button', () => {
render(<Hero />)
expect(screen.getByRole('link', { name: 'See How We Work' })).toBeInTheDocument()
})
it('primary CTA links to #contact', () => {
render(<Hero />)
const primaryCTA = screen.getByRole('link', { name: 'Start Your Project' })
expect(primaryCTA).toHaveAttribute('href', '#contact')
})
it('secondary CTA links to #process', () => {
render(<Hero />)
const secondaryCTA = screen.getByRole('link', { name: 'See How We Work' })
expect(secondaryCTA).toHaveAttribute('href', '#process')
})
it('renders capability badges', () => {
render(<Hero />)
expect(screen.getByText('Fast Delivery')).toBeInTheDocument()
expect(screen.getByText('Secure by Default')).toBeInTheDocument()
expect(screen.getByText('Modern Stack')).toBeInTheDocument()
expect(screen.getByText('Cloud Native')).toBeInTheDocument()
expect(screen.getByText('Built to Scale')).toBeInTheDocument()
expect(screen.getByText('Pixel Perfect')).toBeInTheDocument()
})
it('applies gradient text class to headline', () => {
render(<Hero />)
const headline = screen.getByText('Software that ships.')
expect(headline).toHaveClass('bg-gradient-to-r')
expect(headline).toHaveClass('bg-clip-text')
expect(headline).toHaveClass('text-transparent')
})
it('renders as section element', () => {
render(<Hero />)
expect(screen.getByRole('region')).toBeInTheDocument()
})
it('has accessible label', () => {
render(<Hero />)
expect(screen.getByRole('region', { name: /hero/i })).toBeInTheDocument()
})
})

View File

@ -0,0 +1,120 @@
import { motion } from 'framer-motion'
import { Zap, Shield, Layers, Cloud, TrendingUp, Sparkles } from 'lucide-react'
import { cn } from '../../lib/utils'
import { Button } from '../ui/Button'
import { Container } from '../layout/Container'
interface HeroProps {
/** Optional custom headline */
headline?: string
/** Optional custom subheadline */
subheadline?: string
}
const DEFAULT_HEADLINE = 'Software that ships.'
const DEFAULT_SUBHEADLINE = 'From concept to production in weeks, not months. Custom solutions built fast, built right.'
const capabilities = [
{ label: 'Fast Delivery', icon: Zap },
{ label: 'Secure by Default', icon: Shield },
{ label: 'Modern Stack', icon: Layers },
{ label: 'Cloud Native', icon: Cloud },
{ label: 'Built to Scale', icon: TrendingUp },
{ label: 'Pixel Perfect', icon: Sparkles },
]
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
}
const itemVariants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
}
export function Hero({ headline, subheadline }: HeroProps) {
const displayHeadline = headline ?? DEFAULT_HEADLINE
const displaySubheadline = subheadline ?? DEFAULT_SUBHEADLINE
return (
<section
aria-label="Hero section"
className="relative overflow-hidden bg-gradient-to-br from-slate-50 via-blue-50/30 to-white py-20 md:py-32"
>
{/* Decorative skewed overlay */}
<div className="absolute inset-0 -skew-y-3 bg-gradient-to-r from-primary/5 to-transparent" />
<Container className="relative">
<motion.div
variants={containerVariants}
initial="hidden"
animate="visible"
className="grid gap-12 lg:grid-cols-2 lg:gap-16 items-center"
>
{/* Text content */}
<div className="space-y-8">
<motion.h1
variants={itemVariants}
className={cn(
'text-4xl md:text-[3.5rem] font-extrabold leading-tight',
'bg-gradient-to-r from-slate-900 to-primary bg-clip-text text-transparent'
)}
>
{displayHeadline}
</motion.h1>
<motion.p
variants={itemVariants}
className="text-xl text-secondary max-w-[500px]"
>
{displaySubheadline}
</motion.p>
<motion.div
variants={itemVariants}
className="flex flex-wrap gap-4"
>
<Button variant="primary" size="lg" href="#contact">
Start Your Project
</Button>
<Button variant="secondary" size="lg" href="#process">
See How We Work
</Button>
</motion.div>
</div>
{/* Capability badges */}
<motion.div
variants={itemVariants}
className="grid grid-cols-2 sm:grid-cols-3 gap-4"
>
{capabilities.map(({ label, icon: Icon }) => (
<div
key={label}
className={cn(
'flex items-center gap-3 p-4',
'bg-white rounded-xl shadow-sm',
'hover:-translate-y-1 hover:shadow-md',
'transition-all duration-300'
)}
>
<Icon className="w-5 h-5 text-primary flex-shrink-0" />
<span className="text-sm font-medium text-slate-700">
{label}
</span>
</div>
))}
</motion.div>
</motion.div>
</Container>
</section>
)
}

View File

@ -0,0 +1,92 @@
import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { Stats } from './Stats'
// Mock IntersectionObserver
class MockIntersectionObserver {
callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
// Immediately trigger as visible for testing
setTimeout(() => {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}, 0)
}
observe() {}
unobserve() {}
disconnect() {}
}
describe('Stats', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders all default stats', () => {
render(<Stats />)
expect(screen.getByText('Projects Delivered')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
expect(screen.getByText('Support')).toBeInTheDocument()
expect(screen.getByText('Client Rating')).toBeInTheDocument()
})
it('renders custom stats when provided', () => {
const customStats = [
{ id: 'custom1', value: 50, label: 'Custom Stat' },
{ id: 'custom2', value: 75, suffix: '%', label: 'Percentage Stat' },
]
render(<Stats stats={customStats} />)
expect(screen.getByText('Custom Stat')).toBeInTheDocument()
expect(screen.getByText('Percentage Stat')).toBeInTheDocument()
expect(screen.queryByText('Projects Delivered')).not.toBeInTheDocument()
})
it('displays stat labels correctly', () => {
render(<Stats />)
expect(screen.getByText('Projects Delivered')).toBeInTheDocument()
expect(screen.getByText('Uptime')).toBeInTheDocument()
expect(screen.getByText('Support')).toBeInTheDocument()
expect(screen.getByText('Client Rating')).toBeInTheDocument()
})
it('displays stat values with suffixes', () => {
render(<Stats />)
// Values show with their suffixes
expect(screen.getByText(/\+/)).toBeInTheDocument() // 100+
expect(screen.getByText(/%/)).toBeInTheDocument() // 99.9%
expect(screen.getByText(/\/7/)).toBeInTheDocument() // 24/7
expect(screen.getByText(/★/)).toBeInTheDocument() // 5★
})
it('applies primary background color', () => {
render(<Stats />)
const section = screen.getByRole('region', { name: /stats/i })
expect(section).toHaveClass('bg-primary')
})
it('renders in a grid layout', () => {
render(<Stats />)
const grid = screen.getByTestId('stats-grid')
expect(grid).toHaveClass('grid')
})
it('renders as section element with accessible label', () => {
render(<Stats />)
expect(screen.getByRole('region', { name: /stats/i })).toBeInTheDocument()
})
it('renders correct number of stat items', () => {
render(<Stats />)
const statItems = screen.getAllByTestId(/^stat-/)
expect(statItems).toHaveLength(4)
})
})

View File

@ -0,0 +1,103 @@
import { useEffect, useState, useRef } from 'react'
import { Container } from '../layout/Container'
import { useIntersectionObserver } from '../../hooks/useIntersectionObserver'
interface Stat {
id: string
value: number
suffix?: string
label: string
}
interface StatsProps {
/** Optional custom stats */
stats?: Stat[]
}
const defaultStats: Stat[] = [
{ id: 'projects', value: 100, suffix: '+', label: 'Projects Delivered' },
{ id: 'uptime', value: 99.9, suffix: '%', label: 'Uptime' },
{ id: 'support', value: 24, suffix: '/7', label: 'Support' },
{ id: 'rating', value: 5, suffix: '★', label: 'Client Rating' },
]
function useCountUp(end: number, isVisible: boolean, duration: number = 2000) {
const [count, setCount] = useState(0)
const hasAnimated = useRef(false)
useEffect(() => {
if (!isVisible || hasAnimated.current) return
hasAnimated.current = true
const startTime = performance.now()
const isDecimal = end % 1 !== 0
const decimalPlaces = isDecimal ? 1 : 0
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Ease out cubic
const easeOut = 1 - Math.pow(1 - progress, 3)
const currentValue = end * easeOut
if (isDecimal) {
setCount(parseFloat(currentValue.toFixed(decimalPlaces)))
} else {
setCount(Math.floor(currentValue))
}
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
}, [end, duration, isVisible])
return count
}
function StatItem({ stat, isVisible }: { stat: Stat; isVisible: boolean }) {
const count = useCountUp(stat.value, isVisible)
return (
<div
data-testid={`stat-${stat.id}`}
className="text-center py-4"
>
<div className="text-5xl md:text-6xl font-extrabold mb-2">
{count}
{stat.suffix && <span>{stat.suffix}</span>}
</div>
<div className="text-lg opacity-90">{stat.label}</div>
</div>
)
}
export function Stats({ stats }: StatsProps) {
const displayStats = stats ?? defaultStats
const { ref, isVisible } = useIntersectionObserver<HTMLElement>({
threshold: 0.2,
triggerOnce: true,
})
return (
<section
ref={ref}
aria-label="Stats section"
className="bg-primary text-white py-16 md:py-20"
>
<Container>
<div
data-testid="stats-grid"
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8"
>
{displayStats.map((stat) => (
<StatItem key={stat.id} stat={stat} isVisible={isVisible} />
))}
</div>
</Container>
</section>
)
}

View File

@ -0,0 +1,3 @@
export { Hero } from './Hero'
export { Stats } from './Stats'
export { Contact } from './Contact'

View File

@ -0,0 +1,129 @@
import { render, screen, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { AnimatedSection } from './AnimatedSection'
// Mock IntersectionObserver
class MockIntersectionObserver {
callback: IntersectionObserverCallback
constructor(callback: IntersectionObserverCallback) {
this.callback = callback
// Immediately trigger as visible for testing
setTimeout(() => {
this.callback(
[{ isIntersecting: true } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}, 0)
}
observe() {}
unobserve() {}
disconnect() {}
}
describe('AnimatedSection', () => {
beforeEach(() => {
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('renders children correctly', () => {
render(
<AnimatedSection>
<p>Test content</p>
</AnimatedSection>
)
expect(screen.getByText('Test content')).toBeInTheDocument()
})
it('applies custom className', () => {
render(
<AnimatedSection className="custom-class">
<p>Content</p>
</AnimatedSection>
)
const container = screen.getByText('Content').parentElement
expect(container).toHaveClass('custom-class')
})
it('renders with default fade-up animation', async () => {
render(
<AnimatedSection>
<p>Content</p>
</AnimatedSection>
)
// Component should render without errors
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('applies different animation variants', () => {
const { rerender } = render(
<AnimatedSection animation="slide-left">
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
rerender(
<AnimatedSection animation="slide-right">
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
rerender(
<AnimatedSection animation="fade-in">
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('renders multiple children', () => {
render(
<AnimatedSection>
<p>Child 1</p>
<p>Child 2</p>
<p>Child 3</p>
</AnimatedSection>
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
expect(screen.getByText('Child 3')).toBeInTheDocument()
})
it('renders with stagger enabled', () => {
render(
<AnimatedSection stagger>
<p>Child 1</p>
<p>Child 2</p>
</AnimatedSection>
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
})
it('accepts custom delay', () => {
render(
<AnimatedSection delay={500}>
<p>Content</p>
</AnimatedSection>
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('accepts custom stagger delay', () => {
render(
<AnimatedSection stagger staggerDelay={200}>
<p>Child 1</p>
<p>Child 2</p>
</AnimatedSection>
)
expect(screen.getByText('Child 1')).toBeInTheDocument()
expect(screen.getByText('Child 2')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,78 @@
import { motion } from 'framer-motion'
import { Children, cloneElement, isValidElement } from 'react'
import { cn } from '../../lib/utils'
import { useAnimateOnScroll } from '../../hooks/useAnimateOnScroll'
import { animationVariants, staggerContainer, type AnimationVariant } from '../../utils/animations'
interface AnimatedSectionProps {
children: React.ReactNode
className?: string
/** Animation variant */
animation?: AnimationVariant
/** Delay before animation starts (ms) */
delay?: number
/** Stagger children animations */
stagger?: boolean
/** Stagger delay between children (ms) */
staggerDelay?: number
}
export function AnimatedSection({
children,
className,
animation = 'fade-up',
delay = 0,
stagger = false,
staggerDelay = 100,
}: AnimatedSectionProps) {
const { ref, isVisible } = useAnimateOnScroll<HTMLDivElement>({
animation,
delay,
})
if (stagger) {
const customStaggerContainer = {
hidden: {},
visible: {
transition: {
staggerChildren: staggerDelay / 1000,
delayChildren: delay / 1000,
},
},
}
return (
<motion.div
ref={ref}
className={cn(className)}
initial="hidden"
animate={isVisible ? 'visible' : 'hidden'}
variants={customStaggerContainer}
>
{Children.map(children, (child) => {
if (isValidElement(child)) {
return (
<motion.div variants={animationVariants[animation]}>
{child}
</motion.div>
)
}
return child
})}
</motion.div>
)
}
return (
<motion.div
ref={ref}
className={cn(className)}
initial="hidden"
animate={isVisible ? 'visible' : 'hidden'}
variants={animationVariants[animation]}
transition={delay > 0 ? { delay: delay / 1000 } : undefined}
>
{children}
</motion.div>
)
}

View File

@ -0,0 +1,86 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Input } from './Input'
describe('Input', () => {
it('renders label correctly', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} />)
expect(screen.getByLabelText('Name')).toBeInTheDocument()
})
it('renders input with correct type', () => {
render(<Input label="Email" name="email" type="email" value="" onChange={() => {}} />)
const input = screen.getByLabelText('Email')
expect(input).toHaveAttribute('type', 'email')
})
it('renders text input by default', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} />)
const input = screen.getByLabelText('Name')
expect(input).toHaveAttribute('type', 'text')
})
it('renders textarea when type is textarea', () => {
render(<Input label="Message" name="message" type="textarea" value="" onChange={() => {}} />)
const textarea = screen.getByLabelText('Message')
expect(textarea.tagName).toBe('TEXTAREA')
})
it('displays error message when error prop is set', () => {
render(
<Input label="Name" name="name" value="" onChange={() => {}} error="Name is required" />
)
expect(screen.getByText('Name is required')).toBeInTheDocument()
})
it('calls onChange when value changes', async () => {
const handleChange = vi.fn()
const user = userEvent.setup()
render(<Input label="Name" name="name" value="" onChange={handleChange} />)
await user.type(screen.getByLabelText('Name'), 'John')
expect(handleChange).toHaveBeenCalled()
})
it('applies error styles when error is present', () => {
render(
<Input label="Name" name="name" value="" onChange={() => {}} error="Name is required" />
)
const input = screen.getByLabelText('Name')
expect(input).toHaveClass('border-red-500')
})
it('applies normal styles when no error', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} />)
const input = screen.getByLabelText('Name')
expect(input).toHaveClass('border-slate-300')
expect(input).not.toHaveClass('border-red-500')
})
it('renders placeholder correctly', () => {
render(
<Input
label="Name"
name="name"
value=""
onChange={() => {}}
placeholder="Enter your name"
/>
)
expect(screen.getByPlaceholderText('Enter your name')).toBeInTheDocument()
})
it('marks field as required when required prop is true', () => {
render(<Input label="Name" name="name" value="" onChange={() => {}} required />)
const input = screen.getByLabelText('Name')
expect(input).toBeRequired()
})
it('displays current value', () => {
render(<Input label="Name" name="name" value="John Doe" onChange={() => {}} />)
const input = screen.getByLabelText('Name')
expect(input).toHaveValue('John Doe')
})
})

View File

@ -0,0 +1,81 @@
import { cn } from '../../lib/utils'
interface InputProps {
/** Field label */
label: string
/** Field name */
name: string
/** Input type */
type?: 'text' | 'email' | 'textarea'
/** Placeholder text */
placeholder?: string
/** Whether field is required */
required?: boolean
/** Error message */
error?: string
/** Current value */
value: string
/** Change handler */
onChange: (value: string) => void
}
export function Input({
label,
name,
type = 'text',
placeholder,
required = false,
error,
value,
onChange,
}: InputProps) {
const inputId = `input-${name}`
const baseInputStyles = cn(
'w-full px-4 py-3 rounded-lg border transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary',
error ? 'border-red-500' : 'border-slate-300'
)
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
) => {
onChange(e.target.value)
}
return (
<div className="space-y-1">
<label htmlFor={inputId} className="block text-sm font-medium text-slate-700">
{label}
</label>
{type === 'textarea' ? (
<textarea
id={inputId}
name={name}
placeholder={placeholder}
required={required}
value={value}
onChange={handleChange}
rows={4}
className={baseInputStyles}
/>
) : (
<input
id={inputId}
name={name}
type={type}
placeholder={placeholder}
required={required}
value={value}
onChange={handleChange}
className={baseInputStyles}
/>
)}
{error && (
<p className="text-sm text-red-500">{error}</p>
)}
</div>
)
}

7
src/hooks/index.ts Normal file
View File

@ -0,0 +1,7 @@
export { useIntersectionObserver } from './useIntersectionObserver'
export type {
UseIntersectionObserverOptions,
UseIntersectionObserverReturn,
} from './useIntersectionObserver'
export { useAnimateOnScroll } from './useAnimateOnScroll'

View File

@ -0,0 +1,101 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useAnimateOnScroll } from './useAnimateOnScroll'
// Mock IntersectionObserver
let mockObserverInstances: MockIntersectionObserver[] = []
class MockIntersectionObserver {
callback: IntersectionObserverCallback
options: IntersectionObserverInit
constructor(callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) {
this.callback = callback
this.options = options
mockObserverInstances.push(this)
}
observe() {}
unobserve() {}
disconnect() {}
simulateIntersection(isIntersecting: boolean) {
this.callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}
}
describe('useAnimateOnScroll', () => {
beforeEach(() => {
mockObserverInstances = []
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns ref and isVisible', () => {
const { result } = renderHook(() => useAnimateOnScroll())
expect(result.current.ref).toBeDefined()
expect(result.current.isVisible).toBeDefined()
})
it('returns isVisible as false initially', () => {
const { result } = renderHook(() => useAnimateOnScroll())
expect(result.current.isVisible).toBe(false)
})
it('returns isVisible as true when element enters viewport', () => {
const element = document.createElement('div')
const { result } = renderHook(() => {
const hook = useAnimateOnScroll()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
if (mockObserverInstances.length > 0) {
const observer = mockObserverInstances[mockObserverInstances.length - 1]
act(() => {
observer.simulateIntersection(true)
})
expect(result.current.isVisible).toBe(true)
}
})
it('returns animationProps with variants and initial/animate states', () => {
const { result } = renderHook(() => useAnimateOnScroll())
expect(result.current.animationProps).toBeDefined()
expect(result.current.animationProps.initial).toBe('hidden')
expect(result.current.animationProps.variants).toBeDefined()
})
it('sets animate to visible when in viewport', () => {
const element = document.createElement('div')
const { result } = renderHook(() => {
const hook = useAnimateOnScroll()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
if (mockObserverInstances.length > 0) {
const observer = mockObserverInstances[mockObserverInstances.length - 1]
act(() => {
observer.simulateIntersection(true)
})
expect(result.current.animationProps.animate).toBe('visible')
}
})
it('uses provided animation variant', () => {
const { result } = renderHook(() => useAnimateOnScroll({ animation: 'slide-left' }))
expect(result.current.animationProps.variants).toBeDefined()
})
it('applies custom delay via transition', () => {
const { result } = renderHook(() => useAnimateOnScroll({ delay: 200 }))
expect(result.current.animationProps.transition?.delay).toBe(0.2)
})
})

View File

@ -0,0 +1,45 @@
import { useIntersectionObserver } from './useIntersectionObserver'
import { animationVariants, type AnimationVariant } from '../utils/animations'
interface UseAnimateOnScrollOptions {
/** Animation variant to use */
animation?: AnimationVariant
/** Animation delay in ms */
delay?: number
/** Only animate once (default: true) */
once?: boolean
}
interface UseAnimateOnScrollReturn<T extends HTMLElement> {
ref: React.RefObject<T | null>
isVisible: boolean
animationProps: {
initial: 'hidden'
animate: 'hidden' | 'visible'
variants: typeof animationVariants[AnimationVariant]
transition?: { delay: number }
}
}
export function useAnimateOnScroll<T extends HTMLElement = HTMLDivElement>(
options: UseAnimateOnScrollOptions = {}
): UseAnimateOnScrollReturn<T> {
const { animation = 'fade-up', delay = 0, once = true } = options
const { ref, isVisible } = useIntersectionObserver<T>({
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
triggerOnce: once,
})
return {
ref,
isVisible,
animationProps: {
initial: 'hidden',
animate: isVisible ? 'visible' : 'hidden',
variants: animationVariants[animation],
...(delay > 0 && { transition: { delay: delay / 1000 } }),
},
}
}

View File

@ -0,0 +1,141 @@
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { useIntersectionObserver } from './useIntersectionObserver'
// Mock IntersectionObserver
let mockObserverInstances: MockIntersectionObserver[] = []
class MockIntersectionObserver {
callback: IntersectionObserverCallback
options: IntersectionObserverInit
observedElements: Element[] = []
constructor(callback: IntersectionObserverCallback, options: IntersectionObserverInit = {}) {
this.callback = callback
this.options = options
mockObserverInstances.push(this)
}
observe(element: Element) {
this.observedElements.push(element)
}
unobserve(element: Element) {
this.observedElements = this.observedElements.filter((el) => el !== element)
}
disconnect() {
this.observedElements = []
}
// Helper to simulate intersection
simulateIntersection(isIntersecting: boolean) {
this.callback(
[{ isIntersecting, target: this.observedElements[0] } as IntersectionObserverEntry],
this as unknown as IntersectionObserver
)
}
}
describe('useIntersectionObserver', () => {
beforeEach(() => {
mockObserverInstances = []
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('returns a ref object', () => {
const { result } = renderHook(() => useIntersectionObserver())
expect(result.current.ref).toBeDefined()
expect(result.current.ref.current).toBe(null)
})
it('returns isVisible as false initially', () => {
const { result } = renderHook(() => useIntersectionObserver())
expect(result.current.isVisible).toBe(false)
})
it('sets isVisible to true when element enters viewport', () => {
const { result } = renderHook(() => useIntersectionObserver())
// Simulate element being attached
const element = document.createElement('div')
Object.defineProperty(result.current.ref, 'current', {
value: element,
writable: true,
})
// Re-render to trigger effect with the element
const { result: result2 } = renderHook(() => useIntersectionObserver())
Object.defineProperty(result2.current.ref, 'current', {
value: element,
writable: true,
})
// Manually trigger the effect by checking for observer
if (mockObserverInstances.length > 0) {
const observer = mockObserverInstances[mockObserverInstances.length - 1]
act(() => {
observer.simulateIntersection(true)
})
expect(result2.current.isVisible).toBe(true)
}
})
it('uses custom threshold option', () => {
const element = document.createElement('div')
renderHook(() => {
const hook = useIntersectionObserver({ threshold: 0.5 })
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
// The observer should be created with threshold 0.5
const observer = mockObserverInstances.find((o) => o.options.threshold === 0.5)
expect(observer).toBeDefined()
})
it('uses custom rootMargin option', () => {
const element = document.createElement('div')
renderHook(() => {
const hook = useIntersectionObserver({ rootMargin: '100px' })
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
const observer = mockObserverInstances.find((o) => o.options.rootMargin === '100px')
expect(observer).toBeDefined()
})
it('uses default options when not specified', () => {
const element = document.createElement('div')
renderHook(() => {
const hook = useIntersectionObserver()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
const observer = mockObserverInstances[mockObserverInstances.length - 1]
expect(observer.options.threshold).toBe(0.1)
expect(observer.options.rootMargin).toBe('0px 0px -50px 0px')
})
it('disconnects observer on unmount', () => {
const element = document.createElement('div')
const { unmount } = renderHook(() => {
const hook = useIntersectionObserver()
Object.defineProperty(hook.ref, 'current', { value: element, writable: true })
return hook
})
const observer = mockObserverInstances[mockObserverInstances.length - 1]
const disconnectSpy = vi.spyOn(observer, 'disconnect')
unmount()
expect(disconnectSpy).toHaveBeenCalled()
})
})

View File

@ -0,0 +1,59 @@
import { useEffect, useRef, useState } from 'react'
interface UseIntersectionObserverOptions {
/** Percentage of element visible to trigger (0-1) */
threshold?: number
/** Margin around root */
rootMargin?: string
/** Only trigger once */
triggerOnce?: boolean
}
interface UseIntersectionObserverReturn<T extends HTMLElement> {
/** Ref to attach to the element */
ref: React.RefObject<T | null>
/** Whether the element is currently visible */
isVisible: boolean
}
export function useIntersectionObserver<T extends HTMLElement = HTMLDivElement>(
options: UseIntersectionObserverOptions = {}
): UseIntersectionObserverReturn<T> {
const {
threshold = 0.1,
rootMargin = '0px 0px -50px 0px',
triggerOnce = true,
} = options
const ref = useRef<T>(null)
const [isVisible, setIsVisible] = useState(false)
const hasTriggered = useRef(false)
useEffect(() => {
const element = ref.current
if (!element) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
if (triggerOnce) {
hasTriggered.current = true
observer.disconnect()
}
} else if (!triggerOnce && !hasTriggered.current) {
setIsVisible(false)
}
},
{ threshold, rootMargin }
)
observer.observe(element)
return () => observer.disconnect()
}, [threshold, rootMargin, triggerOnce])
return { ref, isVisible }
}
export type { UseIntersectionObserverOptions, UseIntersectionObserverReturn }

46
src/utils/animations.ts Normal file
View File

@ -0,0 +1,46 @@
import type { Variants } from 'framer-motion'
export const animationVariants: Record<string, Variants> = {
'fade-up': {
hidden: { opacity: 0, y: 40 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.6, ease: 'easeOut' },
},
},
'fade-in': {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { duration: 0.5, ease: 'easeOut' },
},
},
'slide-left': {
hidden: { opacity: 0, x: -40 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.5, ease: 'easeOut' },
},
},
'slide-right': {
hidden: { opacity: 0, x: 40 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.5, ease: 'easeOut' },
},
},
}
export const staggerContainer: Variants = {
hidden: {},
visible: {
transition: {
staggerChildren: 0.1,
},
},
}
export type AnimationVariant = keyof typeof animationVariants