feat: Add ProgramsSection component for comprehensive student support
- Implemented ProgramsSection with various support programs including School Supplies, Clothing Support, Emergency Assistance, Educational Technology, Mentorship Programs, and Family Support Services. - Integrated framer-motion for animations and transitions. - Added a call-to-action button for requesting program support. test: Create unit tests for HeroSection component - Developed tests for rendering, accessibility, and functionality of the HeroSection component using Vitest and Testing Library. - Mocked framer-motion for testing purposes. refactor: Update sections index file to include ProgramsSection - Modified index.tsx to export ProgramsSection alongside existing sections. feat: Implement LazyImage component for optimized image loading - Created LazyImage component with lazy loading, error handling, and blur placeholder support. - Utilized framer-motion for loading animations. feat: Add PerformanceMonitor component for real-time performance metrics - Developed PerformanceMonitor to display web vitals and bundle performance metrics. - Included toggle functionality for development mode. feat: Create usePerformance hook for performance monitoring - Implemented usePerformance hook to track web vitals such as FCP, LCP, FID, CLS, and TTFB. - Added useBundlePerformance hook for monitoring bundle size and loading performance. test: Set up testing utilities and mocks for components - Established testing utilities for rendering components with context providers. - Mocked common hooks and framer-motion components for consistent testing. feat: Introduce bundleAnalyzer utility for analyzing bundle performance - Created BundleAnalyzer class to analyze bundle size, suggest optimizations, and generate reports. - Implemented helper functions for Vite integration and performance monitoring. chore: Configure Vitest for testing environment and coverage - Set up Vitest configuration with global variables, jsdom environment, and coverage thresholds.
This commit is contained in:
105
src/components/SEO/SEOHead.tsx
Normal file
105
src/components/SEO/SEOHead.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
interface SEOHeadProps {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
url?: string
|
||||
type?: 'website' | 'article'
|
||||
article?: {
|
||||
author?: string
|
||||
publishedTime?: string
|
||||
modifiedTime?: string
|
||||
tags?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
export function SEOHead({
|
||||
title = 'Miracles in Motion - Empowering Students with Essential Support',
|
||||
description = 'A 501(c)(3) nonprofit providing students with school supplies, clothing, and emergency support to help them succeed in school and life.',
|
||||
image = '/og-image.jpg',
|
||||
url = 'https://miraclesinmotion.org',
|
||||
type = 'website',
|
||||
article
|
||||
}: SEOHeadProps) {
|
||||
const fullTitle = title.includes('Miracles in Motion') ? title : `${title} | Miracles in Motion`
|
||||
const fullUrl = url.startsWith('http') ? url : `https://miraclesinmotion.org${url}`
|
||||
const fullImage = image.startsWith('http') ? image : `https://miraclesinmotion.org${image}`
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
{/* Basic Meta Tags */}
|
||||
<title>{fullTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content="nonprofit, education, student support, school supplies, clothing assistance, emergency aid, 501c3, charity, community" />
|
||||
<link rel="canonical" href={fullUrl} />
|
||||
|
||||
{/* Open Graph Meta Tags */}
|
||||
<meta property="og:title" content={fullTitle} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={fullImage} />
|
||||
<meta property="og:url" content={fullUrl} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content="Miracles in Motion" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
{/* Twitter Card Meta Tags */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={fullImage} />
|
||||
<meta name="twitter:site" content="@MiraclesInMotion" />
|
||||
<meta name="twitter:creator" content="@MiraclesInMotion" />
|
||||
|
||||
{/* Article-specific meta tags */}
|
||||
{type === 'article' && article && (
|
||||
<>
|
||||
{article.author && <meta property="article:author" content={article.author} />}
|
||||
{article.publishedTime && <meta property="article:published_time" content={article.publishedTime} />}
|
||||
{article.modifiedTime && <meta property="article:modified_time" content={article.modifiedTime} />}
|
||||
{article.tags && article.tags.map((tag, index) => (
|
||||
<meta key={index} property="article:tag" content={tag} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Additional SEO Meta Tags */}
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="Miracles in Motion" />
|
||||
<meta name="revisit-after" content="7 days" />
|
||||
<meta name="rating" content="General" />
|
||||
|
||||
{/* Structured Data */}
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Miracles in Motion",
|
||||
"url": "https://miraclesinmotion.org",
|
||||
"logo": "https://miraclesinmotion.org/logo.png",
|
||||
"description": description,
|
||||
"foundingDate": "2020",
|
||||
"areaServed": "United States",
|
||||
"sameAs": [
|
||||
"https://facebook.com/miraclesinmotion",
|
||||
"https://twitter.com/miraclesinmotion",
|
||||
"https://instagram.com/miraclesinmotion"
|
||||
],
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+1-555-123-4567",
|
||||
"contactType": "customer service",
|
||||
"email": "contact@miraclesinmotion.org"
|
||||
},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": "US"
|
||||
},
|
||||
"nonprofitStatus": "501(c)(3)"
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
export default SEOHead
|
||||
72
src/components/__tests__/Footer.test.tsx
Normal file
72
src/components/__tests__/Footer.test.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { Footer } from '../Footer'
|
||||
|
||||
// Mock the LogoMark component
|
||||
vi.mock('../ui/LogoMark', () => ({
|
||||
LogoMark: () => <div data-testid="logo-mark">Logo</div>
|
||||
}))
|
||||
|
||||
describe('Footer Component', () => {
|
||||
it('renders footer with logo and brand information', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
|
||||
expect(screen.getByText('Miracles in Motion')).toBeInTheDocument()
|
||||
expect(screen.getByText('Essentials for every student')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders organization description', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText(/A 501\(c\)\(3\) nonprofit providing students/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders social media links', () => {
|
||||
render(<Footer />)
|
||||
|
||||
const socialLinks = screen.getAllByRole('link')
|
||||
const socialIcons = socialLinks.filter(link =>
|
||||
link.getAttribute('href') === '#'
|
||||
)
|
||||
|
||||
expect(socialIcons).toHaveLength(3) // Facebook, Instagram, Globe
|
||||
})
|
||||
|
||||
it('renders Get Involved section with correct links', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText('Get Involved')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Donate' })).toHaveAttribute('href', '#/donate')
|
||||
expect(screen.getByRole('link', { name: 'Volunteer' })).toHaveAttribute('href', '#/volunteers')
|
||||
expect(screen.getByRole('link', { name: 'Corporate Partnerships' })).toHaveAttribute('href', '#/sponsors')
|
||||
expect(screen.getByRole('link', { name: 'Success Stories' })).toHaveAttribute('href', '#/stories')
|
||||
})
|
||||
|
||||
it('renders Organization section with correct links', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText('Organization')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Testimonials' })).toHaveAttribute('href', '#/testimonies')
|
||||
expect(screen.getByRole('link', { name: 'Legal & Policies' })).toHaveAttribute('href', '#/legal')
|
||||
expect(screen.getByRole('link', { name: 'Contact Us' })).toHaveAttribute('href', 'mailto:contact@miraclesinmotion.org')
|
||||
expect(screen.getByRole('link', { name: '(555) 123-4567' })).toHaveAttribute('href', 'tel:+15551234567')
|
||||
})
|
||||
|
||||
it('renders copyright information', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText(/© 2024 Miracles in Motion. All rights reserved./)).toBeInTheDocument()
|
||||
expect(screen.getByText(/501\(c\)\(3\) nonprofit organization./)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has proper accessibility structure', () => {
|
||||
render(<Footer />)
|
||||
|
||||
const footer = screen.getByRole('contentinfo')
|
||||
expect(footer).toBeInTheDocument()
|
||||
|
||||
const headings = screen.getAllByRole('heading', { level: 3 })
|
||||
expect(headings).toHaveLength(2) // "Get Involved" and "Organization"
|
||||
})
|
||||
})
|
||||
82
src/components/__tests__/Navigation.test.tsx
Normal file
82
src/components/__tests__/Navigation.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { Navigation } from '../Navigation'
|
||||
|
||||
// Mock the UI components
|
||||
vi.mock('../ui', () => ({
|
||||
LogoMark: () => <div data-testid="logo-mark">Logo</div>,
|
||||
Magnetic: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
|
||||
}))
|
||||
|
||||
describe('Navigation Component', () => {
|
||||
const mockProps = {
|
||||
darkMode: false,
|
||||
setDarkMode: vi.fn(),
|
||||
mobileMenuOpen: false,
|
||||
setMobileMenuOpen: vi.fn()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders navigation with logo and brand name', () => {
|
||||
render(<Navigation {...mockProps} />)
|
||||
|
||||
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
|
||||
expect(screen.getByText('Miracles in Motion')).toBeInTheDocument()
|
||||
expect(screen.getByText('Essentials for every student')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders desktop navigation links', () => {
|
||||
render(<Navigation {...mockProps} />)
|
||||
|
||||
expect(screen.getByLabelText('Read success stories')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('View testimonies')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Volunteer opportunities')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Corporate partnerships')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Request assistance')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Portal login')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles dark mode when button is clicked', () => {
|
||||
render(<Navigation {...mockProps} />)
|
||||
|
||||
const darkModeButtons = screen.getAllByLabelText('Switch to dark mode')
|
||||
fireEvent.click(darkModeButtons[0])
|
||||
|
||||
expect(mockProps.setDarkMode).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('toggles mobile menu when hamburger button is clicked', () => {
|
||||
render(<Navigation {...mockProps} />)
|
||||
|
||||
const mobileMenuButton = screen.getByLabelText('Open navigation menu')
|
||||
fireEvent.click(mobileMenuButton)
|
||||
|
||||
expect(mockProps.setMobileMenuOpen).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('displays mobile menu when mobileMenuOpen is true', () => {
|
||||
render(<Navigation {...mockProps} mobileMenuOpen={true} />)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'Mobile navigation menu' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles keyboard navigation correctly', () => {
|
||||
render(<Navigation {...mockProps} />)
|
||||
|
||||
const darkModeButton = screen.getAllByLabelText('Switch to dark mode')[0]
|
||||
fireEvent.keyDown(darkModeButton, { key: 'Enter', code: 'Enter' })
|
||||
|
||||
expect(mockProps.setDarkMode).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('displays correct icon based on dark mode state', () => {
|
||||
const { rerender } = render(<Navigation {...mockProps} darkMode={false} />)
|
||||
expect(screen.getAllByLabelText('Switch to dark mode')).toHaveLength(2)
|
||||
|
||||
rerender(<Navigation {...mockProps} darkMode={true} />)
|
||||
expect(screen.getAllByLabelText('Switch to light mode')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
101
src/components/sections/HeroSection.tsx
Normal file
101
src/components/sections/HeroSection.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useRef } from 'react'
|
||||
import { motion, useScroll, useTransform } from 'framer-motion'
|
||||
import { ArrowRight, Heart, Sparkles } from 'lucide-react'
|
||||
|
||||
export function HeroSection() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: containerRef,
|
||||
offset: ['start start', 'end start']
|
||||
})
|
||||
|
||||
const y = useTransform(scrollYProgress, [0, 1], ['0%', '50%'])
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.5], [1, 0])
|
||||
|
||||
return (
|
||||
<section ref={containerRef} className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
{/* Animated Background */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-br from-primary-50 via-white to-secondary-50 dark:from-gray-900 dark:to-purple-900"
|
||||
style={{ y }}
|
||||
/>
|
||||
|
||||
{/* Floating Elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{[...Array(20)].map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 bg-primary-300/20 rounded-full"
|
||||
initial={{
|
||||
x: Math.random() * window.innerWidth,
|
||||
y: Math.random() * window.innerHeight
|
||||
}}
|
||||
animate={{
|
||||
y: [null, Math.random() * -100 - 50],
|
||||
opacity: [0, 1, 0]
|
||||
}}
|
||||
transition={{
|
||||
duration: Math.random() * 3 + 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 5
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<motion.div
|
||||
className="relative z-10 mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 text-center"
|
||||
style={{ opacity }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<Sparkles className="w-8 h-8 text-primary-600 animate-pulse" />
|
||||
<span className="text-lg font-medium text-primary-600 uppercase tracking-wider">
|
||||
501(c)3 Non-Profit Organization
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl md:text-7xl lg:text-8xl font-bold text-gray-900 dark:text-white mb-6 leading-tight">
|
||||
Miracles in{' '}
|
||||
<span className="bg-gradient-to-r from-primary-600 via-secondary-600 to-primary-800 bg-clip-text text-transparent animate-pulse">
|
||||
Motion
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-4xl mx-auto leading-relaxed">
|
||||
Empowering students with essential supplies, clothing, and support to succeed in school and life.
|
||||
Every child deserves the tools they need to learn and grow.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-6 justify-center">
|
||||
<motion.a
|
||||
href="#/donate"
|
||||
className="btn-primary inline-flex items-center justify-center text-lg px-8 py-4"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Heart className="mr-3 h-6 w-6" />
|
||||
Donate Now
|
||||
</motion.a>
|
||||
<motion.a
|
||||
href="#/request-assistance"
|
||||
className="btn-secondary inline-flex items-center justify-center text-lg px-8 py-4"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
Get Help
|
||||
<ArrowRight className="ml-3 h-6 w-6" />
|
||||
</motion.a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeroSection
|
||||
150
src/components/sections/ImpactSection.tsx
Normal file
150
src/components/sections/ImpactSection.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { motion, useSpring, useMotionValue, useTransform } from 'framer-motion'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Users, Heart, Backpack, Star, TrendingUp, Award } from 'lucide-react'
|
||||
import { SectionHeader } from '../ui/SectionHeader'
|
||||
|
||||
function AnimatedCounter({ target, suffix = '' }: { target: number, suffix?: string }) {
|
||||
const ref = useRef<HTMLSpanElement>(null)
|
||||
const motionValue = useMotionValue(0)
|
||||
const springValue = useSpring(motionValue, { duration: 2000 })
|
||||
const displayed = useTransform(springValue, (latest) =>
|
||||
Math.round(latest).toLocaleString() + suffix
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
motionValue.set(target)
|
||||
}, [motionValue, target])
|
||||
|
||||
useEffect(() => {
|
||||
return displayed.onChange((latest) => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = latest
|
||||
}
|
||||
})
|
||||
}, [displayed])
|
||||
|
||||
return <span ref={ref} />
|
||||
}
|
||||
|
||||
export function ImpactSection() {
|
||||
const stats = [
|
||||
{
|
||||
icon: Users,
|
||||
value: 2847,
|
||||
label: "Students Helped",
|
||||
description: "Individual students who received direct support",
|
||||
trend: "+23% this year",
|
||||
color: "from-blue-500 to-blue-600"
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
value: 1203,
|
||||
label: "Families Supported",
|
||||
description: "Complete family units assisted with comprehensive care",
|
||||
trend: "+15% this year",
|
||||
color: "from-red-500 to-red-600"
|
||||
},
|
||||
{
|
||||
icon: Backpack,
|
||||
value: 15624,
|
||||
label: "Items Distributed",
|
||||
description: "School supplies, clothing, and essential items provided",
|
||||
trend: "+31% this year",
|
||||
color: "from-green-500 to-green-600"
|
||||
},
|
||||
{
|
||||
icon: Star,
|
||||
value: 8456,
|
||||
label: "Volunteer Hours",
|
||||
description: "Dedicated community service hours contributed",
|
||||
trend: "+42% this year",
|
||||
color: "from-yellow-500 to-yellow-600"
|
||||
},
|
||||
{
|
||||
icon: TrendingUp,
|
||||
value: 94,
|
||||
suffix: "%",
|
||||
label: "Success Rate",
|
||||
description: "Students showing improved academic performance",
|
||||
trend: "Consistent excellence",
|
||||
color: "from-purple-500 to-purple-600"
|
||||
},
|
||||
{
|
||||
icon: Award,
|
||||
value: 156,
|
||||
label: "Partner Organizations",
|
||||
description: "Schools, nonprofits, and businesses in our network",
|
||||
trend: "+67% this year",
|
||||
color: "from-orange-500 to-orange-600"
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-gradient-to-br from-gray-50 to-white dark:from-gray-800 dark:to-gray-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeader
|
||||
eyebrow="Our Impact"
|
||||
title="Making a Measurable Difference"
|
||||
subtitle="Real numbers, real change, real lives transformed through community support"
|
||||
/>
|
||||
|
||||
<div className="mt-16 grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-lg hover:shadow-xl transition-all duration-300 border border-gray-100 dark:border-gray-700"
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className={`w-16 h-16 mb-6 bg-gradient-to-br ${stat.color} rounded-xl flex items-center justify-center`}>
|
||||
<stat.icon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
|
||||
{/* Main Number */}
|
||||
<div className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<AnimatedCounter target={stat.value} suffix={stat.suffix} />
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{stat.label}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{stat.description}
|
||||
</p>
|
||||
|
||||
{/* Trend */}
|
||||
<div className="flex items-center text-sm font-medium text-green-600 dark:text-green-400">
|
||||
<TrendingUp className="h-4 w-4 mr-1" />
|
||||
{stat.trend}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Additional Impact Statement */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="mt-16 text-center bg-gradient-to-r from-primary-500 to-secondary-600 rounded-2xl p-8 text-white"
|
||||
>
|
||||
<h3 className="text-2xl font-bold mb-4">
|
||||
Every Number Represents a Life Changed
|
||||
</h3>
|
||||
<p className="text-lg opacity-90 max-w-3xl mx-auto">
|
||||
Behind every statistic is a student who can now focus on learning, a family with renewed hope,
|
||||
and a community growing stronger together.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImpactSection
|
||||
111
src/components/sections/ProgramsSection.tsx
Normal file
111
src/components/sections/ProgramsSection.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Backpack, Shirt, Users, Heart, School, Home } from 'lucide-react'
|
||||
import { SectionHeader } from '../ui/SectionHeader'
|
||||
|
||||
export function ProgramsSection() {
|
||||
const programs = [
|
||||
{
|
||||
icon: Backpack,
|
||||
title: "School Supplies",
|
||||
description: "Essential learning materials including notebooks, pens, calculators, and art supplies",
|
||||
impact: "2,847 students equipped",
|
||||
color: "from-blue-500 to-blue-600"
|
||||
},
|
||||
{
|
||||
icon: Shirt,
|
||||
title: "Clothing Support",
|
||||
description: "Quality clothing, shoes, and seasonal items to help students feel confident",
|
||||
impact: "1,203 wardrobes completed",
|
||||
color: "from-green-500 to-green-600"
|
||||
},
|
||||
{
|
||||
icon: Users,
|
||||
title: "Emergency Assistance",
|
||||
description: "Rapid response for urgent family needs including food, shelter, and utilities",
|
||||
impact: "856 families supported",
|
||||
color: "from-red-500 to-red-600"
|
||||
},
|
||||
{
|
||||
icon: School,
|
||||
title: "Educational Technology",
|
||||
description: "Laptops, tablets, and internet access for remote learning success",
|
||||
impact: "645 devices provided",
|
||||
color: "from-purple-500 to-purple-600"
|
||||
},
|
||||
{
|
||||
icon: Heart,
|
||||
title: "Mentorship Programs",
|
||||
description: "One-on-one support and guidance for academic and personal growth",
|
||||
impact: "432 mentor relationships",
|
||||
color: "from-pink-500 to-pink-600"
|
||||
},
|
||||
{
|
||||
icon: Home,
|
||||
title: "Family Support Services",
|
||||
description: "Comprehensive assistance for housing, transportation, and childcare",
|
||||
impact: "298 families stabilized",
|
||||
color: "from-orange-500 to-orange-600"
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="py-20 bg-white dark:bg-gray-900">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeader
|
||||
eyebrow="Our Programs"
|
||||
title="Comprehensive Student Support"
|
||||
subtitle="We provide holistic assistance that addresses the full spectrum of student needs"
|
||||
/>
|
||||
|
||||
<div className="mt-16 grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{programs.map((program, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="card group hover:shadow-xl transition-all duration-300"
|
||||
>
|
||||
<div className={`w-16 h-16 mb-6 bg-gradient-to-br ${program.color} rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform duration-300`}>
|
||||
<program.icon className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-3 text-gray-900 dark:text-white">
|
||||
{program.title}
|
||||
</h3>
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-4 leading-relaxed">
|
||||
{program.description}
|
||||
</p>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-sm font-medium text-primary-600 dark:text-primary-400">
|
||||
📊 {program.impact}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Call to Action */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="mt-16 text-center"
|
||||
>
|
||||
<a
|
||||
href="#/request-assistance"
|
||||
className="btn-primary inline-flex items-center justify-center"
|
||||
>
|
||||
Request Program Support
|
||||
<Backpack className="ml-2 h-5 w-5" />
|
||||
</a>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProgramsSection
|
||||
64
src/components/sections/__tests__/HeroSection.test.tsx
Normal file
64
src/components/sections/__tests__/HeroSection.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { HeroSection } from '../HeroSection'
|
||||
|
||||
// Mock framer-motion
|
||||
vi.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
a: ({ children, ...props }: any) => <a {...props}>{children}</a>
|
||||
},
|
||||
useScroll: () => ({ scrollYProgress: { get: () => 0 } }),
|
||||
useTransform: () => ({ get: () => 0 }),
|
||||
}))
|
||||
|
||||
describe('HeroSection Component', () => {
|
||||
it('renders hero section with main heading', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
expect(screen.getByText('Miracles in')).toBeInTheDocument()
|
||||
expect(screen.getByText('Motion')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays 501c3 organization badge', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
expect(screen.getByText('501(c)3 Non-Profit Organization')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders main description text', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
expect(screen.getByText(/Empowering students with essential supplies/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Every child deserves the tools they need/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders call-to-action buttons', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const donateButton = screen.getByRole('link', { name: /Donate Now/ })
|
||||
const helpButton = screen.getByRole('link', { name: /Get Help/ })
|
||||
|
||||
expect(donateButton).toHaveAttribute('href', '#/donate')
|
||||
expect(helpButton).toHaveAttribute('href', '#/request-assistance')
|
||||
})
|
||||
|
||||
it('has proper semantic structure', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const section = screen.getByRole('region')
|
||||
expect(section).toBeInTheDocument()
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes accessibility features', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const buttons = screen.getAllByRole('link')
|
||||
buttons.forEach(button => {
|
||||
expect(button).toHaveClass(/btn-/)
|
||||
})
|
||||
})
|
||||
})
|
||||
9
src/components/sections/index.tsx
Normal file
9
src/components/sections/index.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
// Section Components Export
|
||||
export { HeroSection } from './HeroSection'
|
||||
export { ProgramsSection } from './ProgramsSection'
|
||||
export { ImpactSection } from './ImpactSection'
|
||||
|
||||
// Re-export all
|
||||
export * from './HeroSection'
|
||||
export * from './ProgramsSection'
|
||||
export * from './ImpactSection'
|
||||
139
src/components/ui/LazyImage.tsx
Normal file
139
src/components/ui/LazyImage.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useState, useRef, useEffect, ImgHTMLAttributes } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
interface LazyImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src' | 'loading'> {
|
||||
src: string
|
||||
alt: string
|
||||
placeholder?: string
|
||||
blurDataURL?: string
|
||||
priority?: boolean
|
||||
sizes?: string
|
||||
quality?: number
|
||||
onLoadComplete?: () => void
|
||||
}
|
||||
|
||||
export function LazyImage({
|
||||
src,
|
||||
alt,
|
||||
placeholder = '/placeholder.svg',
|
||||
blurDataURL,
|
||||
priority = false,
|
||||
className = '',
|
||||
onLoadComplete,
|
||||
...props
|
||||
}: LazyImageProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false)
|
||||
const [isInView, setIsInView] = useState(priority)
|
||||
const [error, setError] = useState(false)
|
||||
const imgRef = useRef<HTMLImageElement>(null)
|
||||
const [imageSrc, setImageSrc] = useState(priority ? src : placeholder)
|
||||
|
||||
// Intersection Observer for lazy loading
|
||||
useEffect(() => {
|
||||
if (priority) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsInView(true)
|
||||
setImageSrc(src)
|
||||
observer.disconnect()
|
||||
}
|
||||
},
|
||||
{ rootMargin: '50px' }
|
||||
)
|
||||
|
||||
const currentImg = imgRef.current
|
||||
if (currentImg) {
|
||||
observer.observe(currentImg)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentImg) observer.unobserve(currentImg)
|
||||
}
|
||||
}, [src, priority])
|
||||
|
||||
// Handle image load
|
||||
const handleLoad = () => {
|
||||
setIsLoaded(true)
|
||||
onLoadComplete?.()
|
||||
}
|
||||
|
||||
// Handle image error
|
||||
const handleError = () => {
|
||||
setError(true)
|
||||
setImageSrc(placeholder)
|
||||
}
|
||||
|
||||
// Generate optimized src with quality and format
|
||||
const getOptimizedSrc = (originalSrc: string, quality = 85) => {
|
||||
// Check if it's already optimized or external
|
||||
if (originalSrc.includes('?') || originalSrc.startsWith('http')) {
|
||||
return originalSrc
|
||||
}
|
||||
|
||||
// Add quality parameter for supported formats
|
||||
if (originalSrc.includes('.jpg') || originalSrc.includes('.jpeg')) {
|
||||
return `${originalSrc}?quality=${quality}&format=webp`
|
||||
}
|
||||
|
||||
return originalSrc
|
||||
}
|
||||
|
||||
const optimizedSrc = getOptimizedSrc(imageSrc)
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
<AnimatePresence>
|
||||
{blurDataURL && !isLoaded && (
|
||||
<motion.div
|
||||
initial={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="absolute inset-0 z-10"
|
||||
style={{
|
||||
backgroundImage: `url(${blurDataURL})`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
filter: 'blur(10px)',
|
||||
transform: 'scale(1.1)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.img
|
||||
ref={imgRef}
|
||||
src={optimizedSrc}
|
||||
alt={alt}
|
||||
loading={priority ? 'eager' : 'lazy'}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: isLoaded ? 1 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className={`w-full h-full object-cover ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
style={props.style}
|
||||
/>
|
||||
|
||||
{/* Loading skeleton */}
|
||||
{!isLoaded && !error && (
|
||||
<div className="absolute inset-0 bg-gray-200 animate-pulse flex items-center justify-center">
|
||||
<div className="w-8 h-8 bg-gray-300 rounded-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-gray-100 flex items-center justify-center text-gray-400">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-2 bg-gray-300 rounded" />
|
||||
<p className="text-sm">Failed to load image</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LazyImage
|
||||
211
src/components/ui/PerformanceMonitor.tsx
Normal file
211
src/components/ui/PerformanceMonitor.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { usePerformance, useBundlePerformance } from '@/hooks/usePerformance'
|
||||
import { Activity, Zap, Globe, Image, Code } from 'lucide-react'
|
||||
|
||||
interface PerformanceMonitorProps {
|
||||
showDetailed?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PerformanceMonitor({
|
||||
showDetailed = false,
|
||||
className = ''
|
||||
}: PerformanceMonitorProps) {
|
||||
const { metrics, isLoading } = usePerformance()
|
||||
const bundleMetrics = useBundlePerformance()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
// Toggle visibility in development mode
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.ctrlKey && e.shiftKey && e.key === 'P') {
|
||||
setIsVisible(!isVisible)
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.addEventListener('keydown', handleKeyPress)
|
||||
return () => window.removeEventListener('keydown', handleKeyPress)
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
// Don't render in production unless explicitly requested
|
||||
if (process.env.NODE_ENV === 'production' && !showDetailed) return null
|
||||
if (!isVisible && process.env.NODE_ENV === 'development') return null
|
||||
|
||||
const getScoreColor = (value: number | null, thresholds: [number, number]) => {
|
||||
if (value === null) return 'text-gray-400'
|
||||
if (value <= thresholds[0]) return 'text-green-500'
|
||||
if (value <= thresholds[1]) return 'text-yellow-500'
|
||||
return 'text-red-500'
|
||||
}
|
||||
|
||||
const formatMetric = (value: number | null, suffix = 'ms') => {
|
||||
return value ? `${Math.round(value)}${suffix}` : 'N/A'
|
||||
}
|
||||
|
||||
const webVitalsData = [
|
||||
{
|
||||
label: 'FCP',
|
||||
description: 'First Contentful Paint',
|
||||
value: metrics.FCP,
|
||||
threshold: [1800, 3000] as [number, number],
|
||||
icon: Activity,
|
||||
good: '< 1.8s',
|
||||
poor: '> 3.0s'
|
||||
},
|
||||
{
|
||||
label: 'LCP',
|
||||
description: 'Largest Contentful Paint',
|
||||
value: metrics.LCP,
|
||||
threshold: [2500, 4000] as [number, number],
|
||||
icon: Globe,
|
||||
good: '< 2.5s',
|
||||
poor: '> 4.0s'
|
||||
},
|
||||
{
|
||||
label: 'FID',
|
||||
description: 'First Input Delay',
|
||||
value: metrics.FID,
|
||||
threshold: [100, 300] as [number, number],
|
||||
icon: Zap,
|
||||
good: '< 100ms',
|
||||
poor: '> 300ms'
|
||||
},
|
||||
{
|
||||
label: 'CLS',
|
||||
description: 'Cumulative Layout Shift',
|
||||
value: metrics.CLS,
|
||||
threshold: [0.1, 0.25] as [number, number],
|
||||
icon: Activity,
|
||||
good: '< 0.1',
|
||||
poor: '> 0.25',
|
||||
suffix: ''
|
||||
},
|
||||
{
|
||||
label: 'TTFB',
|
||||
description: 'Time to First Byte',
|
||||
value: metrics.TTFB,
|
||||
threshold: [800, 1800] as [number, number],
|
||||
icon: Globe,
|
||||
good: '< 800ms',
|
||||
poor: '> 1.8s'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className={`fixed top-4 right-4 z-50 bg-white/95 backdrop-blur-sm border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm ${className}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-sm text-gray-900 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4" />
|
||||
Performance Monitor
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsVisible(false)}
|
||||
className="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
Measuring performance...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* Web Vitals */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">Core Web Vitals</h4>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{webVitalsData.slice(0, 3).map((metric) => {
|
||||
const IconComponent = metric.icon
|
||||
const colorClass = getScoreColor(metric.value, metric.threshold)
|
||||
|
||||
return (
|
||||
<div key={metric.label} className="text-center">
|
||||
<div className="flex items-center justify-center mb-1">
|
||||
<IconComponent className={`w-3 h-3 ${colorClass}`} />
|
||||
</div>
|
||||
<div className={`text-xs font-mono ${colorClass}`}>
|
||||
{formatMetric(metric.value, metric.suffix || 'ms')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{metric.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional Metrics */}
|
||||
{showDetailed && (
|
||||
<>
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">Additional Metrics</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{webVitalsData.slice(3).map((metric) => {
|
||||
const colorClass = getScoreColor(metric.value, metric.threshold)
|
||||
return (
|
||||
<div key={metric.label} className="text-center">
|
||||
<div className={`text-xs font-mono ${colorClass}`}>
|
||||
{formatMetric(metric.value, metric.suffix || 'ms')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">{metric.label}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bundle Metrics */}
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-700 mb-2">Bundle Size</h4>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<Code className="w-3 h-3 text-blue-500" />
|
||||
JavaScript
|
||||
</span>
|
||||
<span className="font-mono">{bundleMetrics.jsSize}KB</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<div className="w-3 h-3 bg-green-500 rounded-sm" />
|
||||
CSS
|
||||
</span>
|
||||
<span className="font-mono">{bundleMetrics.cssSize}KB</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1">
|
||||
<Image className="w-3 h-3 text-purple-500" />
|
||||
Images
|
||||
</span>
|
||||
<span className="font-mono">{bundleMetrics.imageSize}KB</span>
|
||||
</div>
|
||||
<div className="border-t pt-1 flex items-center justify-between text-xs font-medium">
|
||||
<span>Total</span>
|
||||
<span className="font-mono">{bundleMetrics.totalSize}KB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
<div className="text-xs text-gray-500 border-t pt-2">
|
||||
Press <kbd className="px-1 py-0.5 bg-gray-100 rounded text-xs">Ctrl+Shift+P</kbd> to toggle
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PerformanceMonitor
|
||||
Reference in New Issue
Block a user