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:
48
src/App.tsx
48
src/App.tsx
@ -1,6 +1,6 @@
|
|||||||
import { Header, Footer, Container } from './components/layout'
|
import { Header, Footer, Container } from './components/layout'
|
||||||
import { Button } from './components/ui/Button'
|
|
||||||
import { Card } from './components/ui/Card'
|
import { Card } from './components/ui/Card'
|
||||||
|
import { Hero, Stats, Contact } from './components/sections'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -9,27 +9,11 @@ function App() {
|
|||||||
|
|
||||||
{/* Main content with top padding for fixed header */}
|
{/* Main content with top padding for fixed header */}
|
||||||
<main className="flex-1 pt-20">
|
<main className="flex-1 pt-20">
|
||||||
{/* Hero Section Placeholder */}
|
{/* Hero Section */}
|
||||||
<section id="home" className="py-20 bg-gradient-to-br from-bg-primary to-bg-secondary">
|
<Hero />
|
||||||
<Container>
|
|
||||||
<div className="text-center max-w-3xl mx-auto">
|
{/* Stats Section */}
|
||||||
<h1 className="text-5xl md:text-6xl font-bold text-text-primary mb-6">
|
<Stats />
|
||||||
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>
|
|
||||||
|
|
||||||
{/* Services Section Placeholder */}
|
{/* Services Section Placeholder */}
|
||||||
<section id="services" className="py-20">
|
<section id="services" className="py-20">
|
||||||
@ -73,27 +57,13 @@ function App() {
|
|||||||
Our Process
|
Our Process
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-center text-text-secondary max-w-2xl mx-auto">
|
<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>
|
</p>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Contact Section Placeholder */}
|
{/* Contact Section */}
|
||||||
<section id="contact" className="py-20">
|
<Contact />
|
||||||
<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>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
160
src/components/sections/Contact.test.tsx
Normal file
160
src/components/sections/Contact.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
223
src/components/sections/Contact.tsx
Normal file
223
src/components/sections/Contact.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/sections/Hero.test.tsx
Normal file
78
src/components/sections/Hero.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
120
src/components/sections/Hero.tsx
Normal file
120
src/components/sections/Hero.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/components/sections/Stats.test.tsx
Normal file
92
src/components/sections/Stats.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
103
src/components/sections/Stats.tsx
Normal file
103
src/components/sections/Stats.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
3
src/components/sections/index.ts
Normal file
3
src/components/sections/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { Hero } from './Hero'
|
||||||
|
export { Stats } from './Stats'
|
||||||
|
export { Contact } from './Contact'
|
||||||
129
src/components/ui/AnimatedSection.test.tsx
Normal file
129
src/components/ui/AnimatedSection.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
src/components/ui/AnimatedSection.tsx
Normal file
78
src/components/ui/AnimatedSection.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
86
src/components/ui/Input.test.tsx
Normal file
86
src/components/ui/Input.test.tsx
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
81
src/components/ui/Input.tsx
Normal file
81
src/components/ui/Input.tsx
Normal 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
7
src/hooks/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export { useIntersectionObserver } from './useIntersectionObserver'
|
||||||
|
export type {
|
||||||
|
UseIntersectionObserverOptions,
|
||||||
|
UseIntersectionObserverReturn,
|
||||||
|
} from './useIntersectionObserver'
|
||||||
|
|
||||||
|
export { useAnimateOnScroll } from './useAnimateOnScroll'
|
||||||
101
src/hooks/useAnimateOnScroll.test.ts
Normal file
101
src/hooks/useAnimateOnScroll.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
45
src/hooks/useAnimateOnScroll.ts
Normal file
45
src/hooks/useAnimateOnScroll.ts
Normal 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 } }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/hooks/useIntersectionObserver.test.ts
Normal file
141
src/hooks/useIntersectionObserver.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
59
src/hooks/useIntersectionObserver.ts
Normal file
59
src/hooks/useIntersectionObserver.ts
Normal 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
46
src/utils/animations.ts
Normal 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
|
||||||
Reference in New Issue
Block a user