Deploy to production - ensure all endpoints operational
This commit is contained in:
+4413
-4413
File diff suppressed because it is too large
Load Diff
+266
-266
@@ -1,267 +1,267 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { Sun, Moon, Menu, X } from 'lucide-react'
|
||||
|
||||
// Contexts
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { NotificationProvider } from './contexts/NotificationContext'
|
||||
|
||||
// Pages
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { DonatePage } from './pages/DonatePage'
|
||||
|
||||
// Components
|
||||
import AIAssistancePortal from './components/AIAssistancePortal'
|
||||
import AdvancedAnalyticsDashboard from './components/AdvancedAnalyticsDashboard'
|
||||
import MobileVolunteerApp from './components/MobileVolunteerApp'
|
||||
import StaffTrainingDashboard from './components/StaffTrainingDashboard'
|
||||
|
||||
// Hooks
|
||||
import { useHashRoute } from './hooks/useCommon'
|
||||
|
||||
// Analytics
|
||||
import { analytics } from './utils/analytics'
|
||||
|
||||
// Main App Component
|
||||
const App: React.FC = () => {
|
||||
const [darkMode, setDarkMode] = useState<boolean>(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false)
|
||||
const { route } = useHashRoute()
|
||||
|
||||
// Initialize analytics
|
||||
useEffect(() => {
|
||||
analytics.init()
|
||||
}, [])
|
||||
|
||||
// Dark mode toggle
|
||||
const toggleDarkMode = (): void => {
|
||||
setDarkMode(prev => {
|
||||
const newMode = !prev
|
||||
document.documentElement.classList.toggle('dark', newMode)
|
||||
localStorage.setItem('darkMode', newMode.toString())
|
||||
return newMode
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem('darkMode') === 'true'
|
||||
setDarkMode(savedMode)
|
||||
document.documentElement.classList.toggle('dark', savedMode)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [route])
|
||||
|
||||
const renderPage = (): React.ReactNode => {
|
||||
switch (route) {
|
||||
case '/':
|
||||
return <HomePage />
|
||||
case '/donate':
|
||||
return <DonatePage />
|
||||
case '/ai-assistance':
|
||||
return <AIAssistancePortal userRole="admin" />
|
||||
case '/analytics':
|
||||
return <AdvancedAnalyticsDashboard />
|
||||
case '/mobile-volunteer':
|
||||
return <MobileVolunteerApp />
|
||||
case '/staff-training':
|
||||
return <StaffTrainingDashboard />
|
||||
default:
|
||||
return <HomePage />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-purple-900">
|
||||
{/* Navigation */}
|
||||
<Navigation
|
||||
darkMode={darkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
<AnimatePresence mode="wait">
|
||||
{renderPage()}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Navigation Component
|
||||
interface NavigationProps {
|
||||
darkMode: boolean
|
||||
toggleDarkMode: () => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const Navigation: React.FC<NavigationProps> = ({
|
||||
darkMode,
|
||||
toggleDarkMode,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen
|
||||
}) => {
|
||||
const navItems = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Donate', href: '/donate' },
|
||||
{ label: 'Volunteer', href: '/volunteer' },
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Contact', href: '/contact' }
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-white/20 dark:border-gray-700/50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<a href="#/" className="text-2xl font-bold text-primary-600 hover:text-primary-700 transition-colors">
|
||||
Miracles in Motion
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={`#${item.href}`}
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors font-medium"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="md:hidden flex items-center space-x-2">
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={`#${item.href}`}
|
||||
className="block px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer Component
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Organization Info */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<h3 className="text-2xl font-bold mb-4">Miracles in Motion</h3>
|
||||
<p className="text-gray-300 mb-4 max-w-md">
|
||||
A 501(c)3 non-profit organization dedicated to providing essential support
|
||||
to students and families in need. Every contribution makes a lasting impact.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
EIN: 88-1234567 • All donations are tax-deductible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-4">Quick Links</h4>
|
||||
<div className="space-y-2">
|
||||
<a href="#/donate" className="block text-gray-300 hover:text-white transition-colors">
|
||||
Donate Now
|
||||
</a>
|
||||
<a href="#/volunteer" className="block text-gray-300 hover:text-white transition-colors">
|
||||
Volunteer
|
||||
</a>
|
||||
<a href="#/about" className="block text-gray-300 hover:text-white transition-colors">
|
||||
About Us
|
||||
</a>
|
||||
<a href="#/impact" className="block text-gray-300 hover:text-white transition-colors">
|
||||
Our Impact
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-4">Contact</h4>
|
||||
<div className="space-y-2 text-gray-300">
|
||||
<p>contact@mim4u.org</p>
|
||||
<p>(818) 491-6884</p>
|
||||
<p>20274 Via Medici<br />Porter Ranch, CA 91326</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2025 Miracles in Motion. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import { Sun, Moon, Menu, X } from 'lucide-react'
|
||||
|
||||
// Contexts
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { NotificationProvider } from './contexts/NotificationContext'
|
||||
|
||||
// Pages
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { DonatePage } from './pages/DonatePage'
|
||||
|
||||
// Components
|
||||
import AIAssistancePortal from './components/AIAssistancePortal'
|
||||
import AdvancedAnalyticsDashboard from './components/AdvancedAnalyticsDashboard'
|
||||
import MobileVolunteerApp from './components/MobileVolunteerApp'
|
||||
import StaffTrainingDashboard from './components/StaffTrainingDashboard'
|
||||
|
||||
// Hooks
|
||||
import { useHashRoute } from './hooks/useCommon'
|
||||
|
||||
// Analytics
|
||||
import { analytics } from './utils/analytics'
|
||||
|
||||
// Main App Component
|
||||
const App: React.FC = () => {
|
||||
const [darkMode, setDarkMode] = useState<boolean>(false)
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState<boolean>(false)
|
||||
const { route } = useHashRoute()
|
||||
|
||||
// Initialize analytics
|
||||
useEffect(() => {
|
||||
analytics.init()
|
||||
}, [])
|
||||
|
||||
// Dark mode toggle
|
||||
const toggleDarkMode = (): void => {
|
||||
setDarkMode(prev => {
|
||||
const newMode = !prev
|
||||
document.documentElement.classList.toggle('dark', newMode)
|
||||
localStorage.setItem('darkMode', newMode.toString())
|
||||
return newMode
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize dark mode from localStorage
|
||||
useEffect(() => {
|
||||
const savedMode = localStorage.getItem('darkMode') === 'true'
|
||||
setDarkMode(savedMode)
|
||||
document.documentElement.classList.toggle('dark', savedMode)
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [route])
|
||||
|
||||
const renderPage = (): React.ReactNode => {
|
||||
switch (route) {
|
||||
case '/':
|
||||
return <HomePage />
|
||||
case '/donate':
|
||||
return <DonatePage />
|
||||
case '/ai-assistance':
|
||||
return <AIAssistancePortal userRole="admin" />
|
||||
case '/analytics':
|
||||
return <AdvancedAnalyticsDashboard />
|
||||
case '/mobile-volunteer':
|
||||
return <MobileVolunteerApp />
|
||||
case '/staff-training':
|
||||
return <StaffTrainingDashboard />
|
||||
default:
|
||||
return <HomePage />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthProvider>
|
||||
<NotificationProvider>
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-purple-900">
|
||||
{/* Navigation */}
|
||||
<Navigation
|
||||
darkMode={darkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<main>
|
||||
<AnimatePresence mode="wait">
|
||||
{renderPage()}
|
||||
</AnimatePresence>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
</div>
|
||||
</NotificationProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Navigation Component
|
||||
interface NavigationProps {
|
||||
darkMode: boolean
|
||||
toggleDarkMode: () => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const Navigation: React.FC<NavigationProps> = ({
|
||||
darkMode,
|
||||
toggleDarkMode,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen
|
||||
}) => {
|
||||
const navItems = [
|
||||
{ label: 'Home', href: '/' },
|
||||
{ label: 'Donate', href: '/donate' },
|
||||
{ label: 'Volunteer', href: '/volunteer' },
|
||||
{ label: 'About', href: '/about' },
|
||||
{ label: 'Contact', href: '/contact' }
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-md border-b border-white/20 dark:border-gray-700/50">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center">
|
||||
<a href="#/" className="text-2xl font-bold text-primary-600 hover:text-primary-700 transition-colors">
|
||||
Miracles in Motion
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex items-center space-x-8">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={`#${item.href}`}
|
||||
className="text-gray-600 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors font-medium"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu Button */}
|
||||
<div className="md:hidden flex items-center space-x-2">
|
||||
<button
|
||||
onClick={toggleDarkMode}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
{darkMode ? (
|
||||
<Sun className="w-5 h-5 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5 text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 rounded-lg bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
) : (
|
||||
<Menu className="w-5 h-5 text-gray-600 dark:text-gray-300" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden py-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<a
|
||||
key={item.href}
|
||||
href={`#${item.href}`}
|
||||
className="block px-4 py-2 text-gray-600 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
// Footer Component
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
{/* Organization Info */}
|
||||
<div className="col-span-1 md:col-span-2">
|
||||
<h3 className="text-2xl font-bold mb-4">Miracles in Motion</h3>
|
||||
<p className="text-gray-300 mb-4 max-w-md">
|
||||
A 501(c)3 non-profit organization dedicated to providing essential support
|
||||
to students and families in need. Every contribution makes a lasting impact.
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">
|
||||
EIN: 88-1234567 • All donations are tax-deductible
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-4">Quick Links</h4>
|
||||
<div className="space-y-2">
|
||||
<a href="#/donate" className="block text-gray-300 hover:text-white transition-colors">
|
||||
Donate Now
|
||||
</a>
|
||||
<a href="#/volunteer" className="block text-gray-300 hover:text-white transition-colors">
|
||||
Volunteer
|
||||
</a>
|
||||
<a href="#/about" className="block text-gray-300 hover:text-white transition-colors">
|
||||
About Us
|
||||
</a>
|
||||
<a href="#/impact" className="block text-gray-300 hover:text-white transition-colors">
|
||||
Our Impact
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h4 className="font-semibold mb-4">Contact</h4>
|
||||
<div className="space-y-2 text-gray-300">
|
||||
<p>contact@mim4u.org</p>
|
||||
<p>(818) 491-6884</p>
|
||||
<p>20274 Via Medici<br />Porter Ranch, CA 91326</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-700 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2025 Miracles in Motion. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
+230
-230
@@ -1,231 +1,231 @@
|
||||
// Simplified Browser-Compatible Processing Pipeline
|
||||
import type {
|
||||
StudentRequest,
|
||||
MatchResult,
|
||||
AIUpdate,
|
||||
AIInsight
|
||||
} from './types'
|
||||
|
||||
// Simple browser-compatible processing
|
||||
export class SimpleBrowserProcessor {
|
||||
private processingQueue: StudentRequest[] = []
|
||||
private isProcessing = false
|
||||
private subscribers: Set<(update: AIUpdate) => void> = new Set()
|
||||
|
||||
// Add request to processing queue
|
||||
async addRequest(request: StudentRequest): Promise<void> {
|
||||
this.processingQueue.push(request)
|
||||
console.log(`📋 Added request ${request.id} to processing queue`)
|
||||
|
||||
// Start processing if not already running
|
||||
if (!this.isProcessing) {
|
||||
this.processQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to processing updates
|
||||
subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
this.subscribers.add(callback)
|
||||
return () => this.subscribers.delete(callback)
|
||||
}
|
||||
|
||||
// Notify subscribers of updates
|
||||
private notify(update: AIUpdate): void {
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Error in processing callback:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process the queue
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessing || this.processingQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
console.log('🔄 Starting processing queue...')
|
||||
|
||||
while (this.processingQueue.length > 0) {
|
||||
const request = this.processingQueue.shift()!
|
||||
|
||||
try {
|
||||
this.notify({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
message: `Processing request for ${request.studentName}`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Mock AI processing with realistic delay
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
const matches = await this.mockAIMatching(request)
|
||||
|
||||
this.notify({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
message: `Found ${matches.length} potential matches`,
|
||||
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing request:', error)
|
||||
this.notify({
|
||||
type: 'alert',
|
||||
requestId: request.id,
|
||||
message: 'Processing failed',
|
||||
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessing = false
|
||||
console.log('✅ Processing queue completed')
|
||||
}
|
||||
|
||||
// Mock AI matching for browser demo
|
||||
private async mockAIMatching(request: StudentRequest): Promise<MatchResult[]> {
|
||||
console.log(`🤖 AI processing: ${request.description}`)
|
||||
|
||||
// Simulate AI analysis
|
||||
const categoryMatch = this.getCategoryMatch(request.category)
|
||||
const urgencyMultiplier = this.getUrgencyMultiplier(request.urgency)
|
||||
|
||||
return [{
|
||||
resourceId: `resource-${Date.now()}`,
|
||||
resourceName: categoryMatch.name,
|
||||
resourceType: this.mapCategoryToResourceType(request.category),
|
||||
confidenceScore: 0.75 + (Math.random() * 0.2), // 75-95%
|
||||
estimatedImpact: categoryMatch.impact * urgencyMultiplier,
|
||||
logisticalComplexity: categoryMatch.complexity,
|
||||
estimatedCost: categoryMatch.cost,
|
||||
fulfillmentTimeline: categoryMatch.timeline,
|
||||
reasoningFactors: [
|
||||
'AI-powered semantic analysis',
|
||||
`${request.category} category match`,
|
||||
`${request.urgency} urgency level`,
|
||||
'Location compatibility verified'
|
||||
],
|
||||
riskFactors: categoryMatch.risks
|
||||
}]
|
||||
}
|
||||
|
||||
private getCategoryMatch(category: string) {
|
||||
const categoryData: Record<string, any> = {
|
||||
'clothing': {
|
||||
name: 'School Clothing Package',
|
||||
impact: 8.5,
|
||||
complexity: 2.0,
|
||||
cost: 60,
|
||||
timeline: '3-5 days',
|
||||
risks: ['Size verification needed', 'Seasonal appropriateness']
|
||||
},
|
||||
'supplies': {
|
||||
name: 'Complete Supply Kit',
|
||||
impact: 9.0,
|
||||
complexity: 1.5,
|
||||
cost: 35,
|
||||
timeline: '1-2 days',
|
||||
risks: ['Grade-level appropriateness']
|
||||
},
|
||||
'food': {
|
||||
name: 'Emergency Food Support',
|
||||
impact: 9.5,
|
||||
complexity: 3.0,
|
||||
cost: 45,
|
||||
timeline: '4-6 hours',
|
||||
risks: ['Dietary restrictions', 'Perishable items']
|
||||
},
|
||||
'transportation': {
|
||||
name: 'Transport Assistance',
|
||||
impact: 7.5,
|
||||
complexity: 4.0,
|
||||
cost: 25,
|
||||
timeline: '1-3 days',
|
||||
risks: ['Schedule coordination', 'Safety verification']
|
||||
},
|
||||
'emergency': {
|
||||
name: 'Emergency Response Package',
|
||||
impact: 10.0,
|
||||
complexity: 3.5,
|
||||
cost: 100,
|
||||
timeline: '2-4 hours',
|
||||
risks: ['Immediate availability', 'Specialized needs']
|
||||
}
|
||||
}
|
||||
|
||||
return categoryData[category] || categoryData['supplies']
|
||||
}
|
||||
|
||||
private getUrgencyMultiplier(urgency: string): number {
|
||||
const multipliers: Record<string, number> = {
|
||||
'low': 0.8,
|
||||
'medium': 1.0,
|
||||
'high': 1.3,
|
||||
'critical': 1.6
|
||||
}
|
||||
return multipliers[urgency] || 1.0
|
||||
}
|
||||
|
||||
private mapCategoryToResourceType(category: string): 'supplies' | 'clothing' | 'food' | 'transport' | 'emergency' | 'other' {
|
||||
const mapping: Record<string, 'supplies' | 'clothing' | 'food' | 'transport' | 'emergency' | 'other'> = {
|
||||
'school-supplies': 'supplies',
|
||||
'clothing': 'clothing',
|
||||
'food': 'food',
|
||||
'transportation': 'transport',
|
||||
'emergency': 'emergency'
|
||||
}
|
||||
return mapping[category] || 'other'
|
||||
}
|
||||
|
||||
// Generate insights for dashboard
|
||||
async generateInsights(requests: StudentRequest[]): Promise<AIInsight[]> {
|
||||
const insights: AIInsight[] = []
|
||||
|
||||
// Category analysis
|
||||
const categoryCount = requests.reduce((acc, req) => {
|
||||
acc[req.category] = (acc[req.category] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
const topCategory = Object.entries(categoryCount)
|
||||
.sort(([,a], [,b]) => b - a)[0]
|
||||
|
||||
if (topCategory) {
|
||||
insights.push({
|
||||
id: `insight-category-${Date.now()}`,
|
||||
type: 'trend',
|
||||
title: `High Demand: ${topCategory[0]}`,
|
||||
description: `${topCategory[1]} requests for ${topCategory[0]} in recent batch. Consider stocking additional resources.`,
|
||||
confidence: 0.85,
|
||||
severity: topCategory[1] > 3 ? 'medium' : 'low',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
// Urgency analysis
|
||||
const criticalCount = requests.filter(r => r.urgency.toString() === 'critical').length
|
||||
if (criticalCount > 0) {
|
||||
insights.push({
|
||||
id: `insight-urgency-${Date.now()}`,
|
||||
type: 'recommendation',
|
||||
title: 'Critical Requests Detected',
|
||||
description: `${criticalCount} critical priority requests require immediate attention.`,
|
||||
confidence: 1.0,
|
||||
severity: 'high',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
return insights
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
// Simplified Browser-Compatible Processing Pipeline
|
||||
import type {
|
||||
StudentRequest,
|
||||
MatchResult,
|
||||
AIUpdate,
|
||||
AIInsight
|
||||
} from './types'
|
||||
|
||||
// Simple browser-compatible processing
|
||||
export class SimpleBrowserProcessor {
|
||||
private processingQueue: StudentRequest[] = []
|
||||
private isProcessing = false
|
||||
private subscribers: Set<(update: AIUpdate) => void> = new Set()
|
||||
|
||||
// Add request to processing queue
|
||||
async addRequest(request: StudentRequest): Promise<void> {
|
||||
this.processingQueue.push(request)
|
||||
console.log(`📋 Added request ${request.id} to processing queue`)
|
||||
|
||||
// Start processing if not already running
|
||||
if (!this.isProcessing) {
|
||||
this.processQueue()
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to processing updates
|
||||
subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
this.subscribers.add(callback)
|
||||
return () => this.subscribers.delete(callback)
|
||||
}
|
||||
|
||||
// Notify subscribers of updates
|
||||
private notify(update: AIUpdate): void {
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Error in processing callback:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process the queue
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessing || this.processingQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
console.log('🔄 Starting processing queue...')
|
||||
|
||||
while (this.processingQueue.length > 0) {
|
||||
const request = this.processingQueue.shift()!
|
||||
|
||||
try {
|
||||
this.notify({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
message: `Processing request for ${request.studentName}`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Mock AI processing with realistic delay
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
const matches = await this.mockAIMatching(request)
|
||||
|
||||
this.notify({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
message: `Found ${matches.length} potential matches`,
|
||||
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing request:', error)
|
||||
this.notify({
|
||||
type: 'alert',
|
||||
requestId: request.id,
|
||||
message: 'Processing failed',
|
||||
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.isProcessing = false
|
||||
console.log('✅ Processing queue completed')
|
||||
}
|
||||
|
||||
// Mock AI matching for browser demo
|
||||
private async mockAIMatching(request: StudentRequest): Promise<MatchResult[]> {
|
||||
console.log(`🤖 AI processing: ${request.description}`)
|
||||
|
||||
// Simulate AI analysis
|
||||
const categoryMatch = this.getCategoryMatch(request.category)
|
||||
const urgencyMultiplier = this.getUrgencyMultiplier(request.urgency)
|
||||
|
||||
return [{
|
||||
resourceId: `resource-${Date.now()}`,
|
||||
resourceName: categoryMatch.name,
|
||||
resourceType: this.mapCategoryToResourceType(request.category),
|
||||
confidenceScore: 0.75 + (Math.random() * 0.2), // 75-95%
|
||||
estimatedImpact: categoryMatch.impact * urgencyMultiplier,
|
||||
logisticalComplexity: categoryMatch.complexity,
|
||||
estimatedCost: categoryMatch.cost,
|
||||
fulfillmentTimeline: categoryMatch.timeline,
|
||||
reasoningFactors: [
|
||||
'AI-powered semantic analysis',
|
||||
`${request.category} category match`,
|
||||
`${request.urgency} urgency level`,
|
||||
'Location compatibility verified'
|
||||
],
|
||||
riskFactors: categoryMatch.risks
|
||||
}]
|
||||
}
|
||||
|
||||
private getCategoryMatch(category: string) {
|
||||
const categoryData: Record<string, any> = {
|
||||
'clothing': {
|
||||
name: 'School Clothing Package',
|
||||
impact: 8.5,
|
||||
complexity: 2.0,
|
||||
cost: 60,
|
||||
timeline: '3-5 days',
|
||||
risks: ['Size verification needed', 'Seasonal appropriateness']
|
||||
},
|
||||
'supplies': {
|
||||
name: 'Complete Supply Kit',
|
||||
impact: 9.0,
|
||||
complexity: 1.5,
|
||||
cost: 35,
|
||||
timeline: '1-2 days',
|
||||
risks: ['Grade-level appropriateness']
|
||||
},
|
||||
'food': {
|
||||
name: 'Emergency Food Support',
|
||||
impact: 9.5,
|
||||
complexity: 3.0,
|
||||
cost: 45,
|
||||
timeline: '4-6 hours',
|
||||
risks: ['Dietary restrictions', 'Perishable items']
|
||||
},
|
||||
'transportation': {
|
||||
name: 'Transport Assistance',
|
||||
impact: 7.5,
|
||||
complexity: 4.0,
|
||||
cost: 25,
|
||||
timeline: '1-3 days',
|
||||
risks: ['Schedule coordination', 'Safety verification']
|
||||
},
|
||||
'emergency': {
|
||||
name: 'Emergency Response Package',
|
||||
impact: 10.0,
|
||||
complexity: 3.5,
|
||||
cost: 100,
|
||||
timeline: '2-4 hours',
|
||||
risks: ['Immediate availability', 'Specialized needs']
|
||||
}
|
||||
}
|
||||
|
||||
return categoryData[category] || categoryData['supplies']
|
||||
}
|
||||
|
||||
private getUrgencyMultiplier(urgency: string): number {
|
||||
const multipliers: Record<string, number> = {
|
||||
'low': 0.8,
|
||||
'medium': 1.0,
|
||||
'high': 1.3,
|
||||
'critical': 1.6
|
||||
}
|
||||
return multipliers[urgency] || 1.0
|
||||
}
|
||||
|
||||
private mapCategoryToResourceType(category: string): 'supplies' | 'clothing' | 'food' | 'transport' | 'emergency' | 'other' {
|
||||
const mapping: Record<string, 'supplies' | 'clothing' | 'food' | 'transport' | 'emergency' | 'other'> = {
|
||||
'school-supplies': 'supplies',
|
||||
'clothing': 'clothing',
|
||||
'food': 'food',
|
||||
'transportation': 'transport',
|
||||
'emergency': 'emergency'
|
||||
}
|
||||
return mapping[category] || 'other'
|
||||
}
|
||||
|
||||
// Generate insights for dashboard
|
||||
async generateInsights(requests: StudentRequest[]): Promise<AIInsight[]> {
|
||||
const insights: AIInsight[] = []
|
||||
|
||||
// Category analysis
|
||||
const categoryCount = requests.reduce((acc, req) => {
|
||||
acc[req.category] = (acc[req.category] || 0) + 1
|
||||
return acc
|
||||
}, {} as Record<string, number>)
|
||||
|
||||
const topCategory = Object.entries(categoryCount)
|
||||
.sort(([,a], [,b]) => b - a)[0]
|
||||
|
||||
if (topCategory) {
|
||||
insights.push({
|
||||
id: `insight-category-${Date.now()}`,
|
||||
type: 'trend',
|
||||
title: `High Demand: ${topCategory[0]}`,
|
||||
description: `${topCategory[1]} requests for ${topCategory[0]} in recent batch. Consider stocking additional resources.`,
|
||||
confidence: 0.85,
|
||||
severity: topCategory[1] > 3 ? 'medium' : 'low',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
// Urgency analysis
|
||||
const criticalCount = requests.filter(r => r.urgency.toString() === 'critical').length
|
||||
if (criticalCount > 0) {
|
||||
insights.push({
|
||||
id: `insight-urgency-${Date.now()}`,
|
||||
type: 'recommendation',
|
||||
title: 'Critical Requests Detected',
|
||||
description: `${criticalCount} critical priority requests require immediate attention.`,
|
||||
confidence: 1.0,
|
||||
severity: 'high',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
return insights
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const browserProcessor = new SimpleBrowserProcessor()
|
||||
@@ -1,94 +1,94 @@
|
||||
// AI Model Lazy Loading Implementation
|
||||
import * as tf from '@tensorflow/tfjs'
|
||||
import type { StudentRequest, MatchResult } from './types'
|
||||
|
||||
export class OptimizedStudentAssistanceAI {
|
||||
private static models: Map<string, any> = new Map()
|
||||
private static modelUrls: Record<string, string> = {
|
||||
'text-vectorization': '/models/text-vectorizer.json',
|
||||
'matching-engine': '/models/matcher.json',
|
||||
'priority-classifier': '/models/priority.json'
|
||||
}
|
||||
|
||||
// Lazy load models on demand
|
||||
private static async loadModel(modelType: string) {
|
||||
if (this.models.has(modelType)) {
|
||||
return this.models.get(modelType)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🤖 Loading AI model: ${modelType}`)
|
||||
const model = await tf.loadLayersModel(this.modelUrls[modelType])
|
||||
this.models.set(modelType, model)
|
||||
console.log(`✅ Model ${modelType} loaded successfully`)
|
||||
return model
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load model ${modelType}:`, error)
|
||||
// Fallback to rule-based system
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Preload critical models in background
|
||||
static async preloadCriticalModels() {
|
||||
try {
|
||||
// Load text vectorization model first (most commonly used)
|
||||
await this.loadModel('text-vectorization')
|
||||
|
||||
// Load others in background
|
||||
setTimeout(() => this.loadModel('matching-engine'), 2000)
|
||||
setTimeout(() => this.loadModel('priority-classifier'), 4000)
|
||||
} catch (error) {
|
||||
console.warn('Background model preloading failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async processRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Load models as needed
|
||||
const textModel = await OptimizedStudentAssistanceAI.loadModel('text-vectorization')
|
||||
const matchingModel = await OptimizedStudentAssistanceAI.loadModel('matching-engine')
|
||||
|
||||
// Process with loaded models or fallback to rule-based
|
||||
if (textModel && matchingModel) {
|
||||
return this.aiBasedMatching(request, textModel, matchingModel)
|
||||
} else {
|
||||
return this.ruleBasedMatching(request)
|
||||
}
|
||||
}
|
||||
|
||||
private async aiBasedMatching(request: StudentRequest, textModel: any, matchingModel: any): Promise<MatchResult[]> {
|
||||
// AI-powered matching logic
|
||||
console.log('🤖 Using AI-powered matching', { request: request.id, textModel: !!textModel, matchingModel: !!matchingModel })
|
||||
// Mock implementation for now
|
||||
return [{
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'School Supply Kit',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.85,
|
||||
estimatedImpact: 8.5,
|
||||
logisticalComplexity: 2.1,
|
||||
estimatedCost: 50,
|
||||
fulfillmentTimeline: '2-3 days',
|
||||
reasoningFactors: ['AI-based match using TensorFlow models', 'High confidence score'],
|
||||
riskFactors: ['Low risk - standard supplies']
|
||||
}]
|
||||
}
|
||||
|
||||
private async ruleBasedMatching(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Fallback rule-based matching
|
||||
console.log('📏 Using rule-based matching (fallback)', { request: request.id })
|
||||
// Mock implementation for now
|
||||
return [{
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'Basic Supply Kit',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.65,
|
||||
estimatedImpact: 6.5,
|
||||
logisticalComplexity: 3.2,
|
||||
estimatedCost: 50,
|
||||
fulfillmentTimeline: '3-5 days',
|
||||
reasoningFactors: ['Rule-based fallback matching', 'Basic category match'],
|
||||
riskFactors: ['Medium risk - manual verification needed']
|
||||
}]
|
||||
}
|
||||
// AI Model Lazy Loading Implementation
|
||||
import * as tf from '@tensorflow/tfjs'
|
||||
import type { StudentRequest, MatchResult } from './types'
|
||||
|
||||
export class OptimizedStudentAssistanceAI {
|
||||
private static models: Map<string, any> = new Map()
|
||||
private static modelUrls: Record<string, string> = {
|
||||
'text-vectorization': '/models/text-vectorizer.json',
|
||||
'matching-engine': '/models/matcher.json',
|
||||
'priority-classifier': '/models/priority.json'
|
||||
}
|
||||
|
||||
// Lazy load models on demand
|
||||
private static async loadModel(modelType: string) {
|
||||
if (this.models.has(modelType)) {
|
||||
return this.models.get(modelType)
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🤖 Loading AI model: ${modelType}`)
|
||||
const model = await tf.loadLayersModel(this.modelUrls[modelType])
|
||||
this.models.set(modelType, model)
|
||||
console.log(`✅ Model ${modelType} loaded successfully`)
|
||||
return model
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load model ${modelType}:`, error)
|
||||
// Fallback to rule-based system
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Preload critical models in background
|
||||
static async preloadCriticalModels() {
|
||||
try {
|
||||
// Load text vectorization model first (most commonly used)
|
||||
await this.loadModel('text-vectorization')
|
||||
|
||||
// Load others in background
|
||||
setTimeout(() => this.loadModel('matching-engine'), 2000)
|
||||
setTimeout(() => this.loadModel('priority-classifier'), 4000)
|
||||
} catch (error) {
|
||||
console.warn('Background model preloading failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async processRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Load models as needed
|
||||
const textModel = await OptimizedStudentAssistanceAI.loadModel('text-vectorization')
|
||||
const matchingModel = await OptimizedStudentAssistanceAI.loadModel('matching-engine')
|
||||
|
||||
// Process with loaded models or fallback to rule-based
|
||||
if (textModel && matchingModel) {
|
||||
return this.aiBasedMatching(request, textModel, matchingModel)
|
||||
} else {
|
||||
return this.ruleBasedMatching(request)
|
||||
}
|
||||
}
|
||||
|
||||
private async aiBasedMatching(request: StudentRequest, textModel: any, matchingModel: any): Promise<MatchResult[]> {
|
||||
// AI-powered matching logic
|
||||
console.log('🤖 Using AI-powered matching', { request: request.id, textModel: !!textModel, matchingModel: !!matchingModel })
|
||||
// Mock implementation for now
|
||||
return [{
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'School Supply Kit',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.85,
|
||||
estimatedImpact: 8.5,
|
||||
logisticalComplexity: 2.1,
|
||||
estimatedCost: 50,
|
||||
fulfillmentTimeline: '2-3 days',
|
||||
reasoningFactors: ['AI-based match using TensorFlow models', 'High confidence score'],
|
||||
riskFactors: ['Low risk - standard supplies']
|
||||
}]
|
||||
}
|
||||
|
||||
private async ruleBasedMatching(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Fallback rule-based matching
|
||||
console.log('📏 Using rule-based matching (fallback)', { request: request.id })
|
||||
// Mock implementation for now
|
||||
return [{
|
||||
resourceId: 'resource-1',
|
||||
resourceName: 'Basic Supply Kit',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.65,
|
||||
estimatedImpact: 6.5,
|
||||
logisticalComplexity: 3.2,
|
||||
estimatedCost: 50,
|
||||
fulfillmentTimeline: '3-5 days',
|
||||
reasoningFactors: ['Rule-based fallback matching', 'Basic category match'],
|
||||
riskFactors: ['Medium risk - manual verification needed']
|
||||
}]
|
||||
}
|
||||
}
|
||||
+447
-447
@@ -1,448 +1,448 @@
|
||||
// Phase 3: Browser-Compatible Processing Pipeline for AI Assistance Matching
|
||||
import {
|
||||
StudentRequest,
|
||||
MatchResult,
|
||||
AIInsight,
|
||||
AIUpdate,
|
||||
ProcessingPipelineConfig
|
||||
} from './types'
|
||||
|
||||
// Re-export from the new browser processor
|
||||
export { browserProcessor, SimpleBrowserProcessor } from './BrowserProcessor'
|
||||
|
||||
// Browser-compatible job queue
|
||||
class BrowserQueue<T> {
|
||||
private jobs: Array<{ id: string; data: T; priority: number; timestamp: number }> = []
|
||||
private processors: Map<string, (job: { id: string; data: T }) => Promise<void>> = new Map()
|
||||
|
||||
async add(jobType: string, data: T, options: { priority: number; attempts?: number; backoff?: string } = { priority: 0 }): Promise<{ id: string }> {
|
||||
const job = {
|
||||
id: `job-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data,
|
||||
priority: options.priority,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
this.jobs.push(job)
|
||||
this.jobs.sort((a, b) => b.priority - a.priority) // Higher priority first
|
||||
|
||||
// Process job immediately for demo
|
||||
setTimeout(() => this.processNextJob(jobType), 100)
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
process(jobType: string, _concurrency: number, processor: (job: { id: string; data: T }) => Promise<void>): void {
|
||||
this.processors.set(jobType, processor)
|
||||
}
|
||||
|
||||
private async processNextJob(jobType: string): Promise<void> {
|
||||
const processor = this.processors.get(jobType)
|
||||
if (!processor || this.jobs.length === 0) return
|
||||
|
||||
const job = this.jobs.shift()!
|
||||
|
||||
try {
|
||||
await processor(job)
|
||||
} catch (error) {
|
||||
console.error(`Error processing job ${job.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification Service for real-time updates
|
||||
class NotificationService {
|
||||
private subscribers: Set<(update: AIUpdate) => void> = new Set()
|
||||
|
||||
subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
this.subscribers.add(callback)
|
||||
return () => this.subscribers.delete(callback)
|
||||
}
|
||||
|
||||
notify(update: AIUpdate): void {
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Error in notification callback:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async notifyStudent(studentId: string, _assignment: any): Promise<void> {
|
||||
console.log(`📧 Notifying student ${studentId} about assignment`)
|
||||
// In production: send email, SMS, or push notification
|
||||
}
|
||||
|
||||
async notifyVolunteer(volunteerId: string, _assignment: any): Promise<void> {
|
||||
console.log(`📧 Notifying volunteer ${volunteerId} about new assignment`)
|
||||
// In production: send volunteer notification
|
||||
}
|
||||
|
||||
async notifyCoordinators(_assignment: any): Promise<void> {
|
||||
console.log(`📧 Notifying coordinators about new assignment`)
|
||||
// In production: alert coordination team
|
||||
}
|
||||
|
||||
async updateDonors(estimatedCost: number): Promise<void> {
|
||||
console.log(`💰 Updating donors about $${estimatedCost} impact opportunity`)
|
||||
// In production: trigger donor engagement campaign
|
||||
}
|
||||
|
||||
async notifyReviewer(reviewer: any, _reviewTask: any, aiInsights: any): Promise<void> {
|
||||
console.log(`👥 Notifying reviewer ${reviewer.id} about review task with AI confidence: ${aiInsights.aiConfidence}`)
|
||||
// In production: send detailed review notification with AI recommendations
|
||||
}
|
||||
}
|
||||
|
||||
// Assignment and Review Management
|
||||
class AssignmentManager {
|
||||
private static assignments: Map<string, any> = new Map()
|
||||
|
||||
static async createAssignment(assignmentData: any): Promise<any> {
|
||||
const assignment = {
|
||||
id: `assign-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...assignmentData,
|
||||
createdAt: new Date(),
|
||||
status: 'pending'
|
||||
}
|
||||
|
||||
this.assignments.set(assignment.id, assignment)
|
||||
console.log(`✅ Created assignment ${assignment.id}`)
|
||||
return assignment
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<any | null> {
|
||||
return this.assignments.get(id) || null
|
||||
}
|
||||
}
|
||||
|
||||
class ReviewManager {
|
||||
private static reviewTasks: Map<string, any> = new Map()
|
||||
|
||||
static async createReviewTask(taskData: any): Promise<any> {
|
||||
const task = {
|
||||
id: `review-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...taskData,
|
||||
createdAt: new Date(),
|
||||
status: 'pending'
|
||||
}
|
||||
|
||||
this.reviewTasks.set(task.id, task)
|
||||
console.log(`📋 Created review task ${task.id}`)
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
// Main Processing Pipeline - Simplified browser-compatible version
|
||||
export class RealTimeProcessingPipeline {
|
||||
private queue: BrowserQueue<StudentRequest>
|
||||
private notificationService: NotificationService
|
||||
private config: ProcessingPipelineConfig
|
||||
|
||||
constructor(config: Partial<ProcessingPipelineConfig> = {}) {
|
||||
this.queue = new BrowserQueue<StudentRequest>()
|
||||
this.notificationService = new NotificationService()
|
||||
this.config = {
|
||||
autoApprovalThreshold: 0.85,
|
||||
urgencyWeights: {
|
||||
'emergency': 1.0,
|
||||
'high': 0.8,
|
||||
'medium': 0.5,
|
||||
'low': 0.2
|
||||
},
|
||||
categoryWeights: {
|
||||
'emergency-housing': 1.0,
|
||||
'food-assistance': 0.9,
|
||||
'medical-needs': 0.85,
|
||||
'clothing': 0.7,
|
||||
'school-supplies': 0.6,
|
||||
'transportation': 0.5,
|
||||
'technology': 0.4,
|
||||
'extracurricular': 0.3,
|
||||
'other': 0.4
|
||||
},
|
||||
maxProcessingTime: 5000, // 5 seconds
|
||||
retryAttempts: 3,
|
||||
notificationEnabled: true,
|
||||
...config
|
||||
}
|
||||
|
||||
this.setupQueueProcessors()
|
||||
}
|
||||
|
||||
private setupQueueProcessors(): void {
|
||||
this.queue.process('analyze-request', 5, async (job: { id: string; data: StudentRequest }) => {
|
||||
const request = job.data
|
||||
|
||||
try {
|
||||
console.log(`🔄 Processing request ${request.id} for ${request.studentName}`)
|
||||
|
||||
// Mock AI processing for browser demo
|
||||
const matches = await this.mockProcessRequest(request)
|
||||
|
||||
// Auto-approval for high-confidence matches
|
||||
if (matches.length > 0 && matches[0].confidenceScore >= this.config.autoApprovalThreshold) {
|
||||
await this.autoApproveRequest(request, matches[0])
|
||||
} else {
|
||||
await this.routeForHumanReview(request, matches)
|
||||
}
|
||||
|
||||
// Update real-time dashboard
|
||||
await this.updateDashboard(request.id, matches)
|
||||
|
||||
// Notify subscribers of processing completion
|
||||
this.notificationService.notify({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
status: matches.length > 0 && matches[0].confidenceScore >= this.config.autoApprovalThreshold ? 'auto-approved' : 'under-review',
|
||||
recommendations: matches,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
await this.handleProcessingError(request, error as Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async submitRequest(request: StudentRequest): Promise<string> {
|
||||
console.log(`📥 Submitting request from ${request.studentName}: ${request.category}`)
|
||||
|
||||
// Add to processing queue with priority based on urgency
|
||||
const priority = this.calculatePriority(request.urgency)
|
||||
const job = await this.queue.add('analyze-request', request, {
|
||||
priority,
|
||||
attempts: this.config.retryAttempts,
|
||||
backoff: 'exponential'
|
||||
})
|
||||
|
||||
// Immediate acknowledgment
|
||||
await this.sendAcknowledgment(request)
|
||||
|
||||
return job.id
|
||||
}
|
||||
|
||||
private calculatePriority(urgency: string): number {
|
||||
return this.config.urgencyWeights[urgency as keyof typeof this.config.urgencyWeights] || 0.5
|
||||
}
|
||||
|
||||
private async sendAcknowledgment(request: StudentRequest): Promise<void> {
|
||||
console.log(`✉️ Sending acknowledgment to ${request.studentName}`)
|
||||
// In production: send immediate confirmation email/SMS
|
||||
}
|
||||
|
||||
private async autoApproveRequest(request: StudentRequest, match: MatchResult): Promise<void> {
|
||||
console.log(`🤖 Auto-approving request ${request.id} with ${(match.confidenceScore * 100).toFixed(1)}% confidence`)
|
||||
|
||||
// Create assistance assignment
|
||||
const assignment = await AssignmentManager.createAssignment({
|
||||
requestId: request.id,
|
||||
studentId: request.studentId,
|
||||
studentName: request.studentName,
|
||||
resourceId: match.resourceId,
|
||||
resourceName: match.resourceName,
|
||||
volunteerId: match.volunteerMatch?.id,
|
||||
volunteerName: match.volunteerMatch?.volunteerName,
|
||||
scheduledDate: new Date(Date.now() + 24 * 60 * 60 * 1000), // Tomorrow
|
||||
estimatedCost: match.estimatedCost,
|
||||
approvalStatus: 'auto-approved',
|
||||
confidence: match.confidenceScore,
|
||||
aiRecommendation: true,
|
||||
urgency: request.urgency,
|
||||
category: request.category
|
||||
})
|
||||
|
||||
// Notify all stakeholders
|
||||
if (this.config.notificationEnabled) {
|
||||
await Promise.all([
|
||||
this.notificationService.notifyStudent(request.studentId, assignment),
|
||||
match.volunteerMatch ? this.notificationService.notifyVolunteer(assignment.volunteerId, assignment) : Promise.resolve(),
|
||||
this.notificationService.notifyCoordinators(assignment),
|
||||
this.notificationService.updateDonors(assignment.estimatedCost)
|
||||
])
|
||||
}
|
||||
|
||||
// Notify real-time subscribers
|
||||
this.notificationService.notify({
|
||||
type: 'auto-approval',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Request automatically approved with ${(match.confidenceScore * 100).toFixed(1)}% confidence`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Track decision for learning
|
||||
await this.trackDecision(request, match, 'auto-approved')
|
||||
}
|
||||
|
||||
private async routeForHumanReview(request: StudentRequest, matches: MatchResult[]): Promise<void> {
|
||||
console.log(`👤 Routing request ${request.id} for human review`)
|
||||
|
||||
// Determine best reviewer based on request type and matches
|
||||
const reviewer = await this.selectOptimalReviewer(request, matches)
|
||||
|
||||
// Create review assignment
|
||||
const reviewTask = await ReviewManager.createReviewTask({
|
||||
requestId: request.id,
|
||||
assignedTo: reviewer.id,
|
||||
assignedToName: reviewer.name,
|
||||
aiRecommendations: matches,
|
||||
priority: this.calculateReviewPriority(request, matches),
|
||||
deadline: this.calculateReviewDeadline(request.urgency),
|
||||
studentName: request.studentName,
|
||||
category: request.category,
|
||||
urgency: request.urgency
|
||||
})
|
||||
|
||||
// Notify reviewer with AI insights
|
||||
if (this.config.notificationEnabled) {
|
||||
await this.notificationService.notifyReviewer(reviewer, reviewTask, {
|
||||
aiConfidence: matches[0]?.confidenceScore || 0,
|
||||
recommendedAction: this.generateRecommendation(matches),
|
||||
riskFactors: matches[0]?.riskFactors || []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async selectOptimalReviewer(request: StudentRequest, _matches: MatchResult[]) {
|
||||
// Mock reviewer selection - in production, this would use actual staff data
|
||||
const reviewers = [
|
||||
{ id: 'rev1', name: 'Sarah Martinez', specialties: ['clothing', 'emergency-housing'], workload: 5 },
|
||||
{ id: 'rev2', name: 'John Davis', specialties: ['food-assistance', 'transportation'], workload: 3 },
|
||||
{ id: 'rev3', name: 'Lisa Chen', specialties: ['school-supplies', 'technology'], workload: 7 },
|
||||
{ id: 'rev4', name: 'Mike Johnson', specialties: ['medical-needs', 'other'], workload: 4 }
|
||||
]
|
||||
|
||||
// Select reviewer based on specialty and workload
|
||||
const categoryReviewers = reviewers.filter(r =>
|
||||
r.specialties.includes(request.category) || r.specialties.includes('other')
|
||||
)
|
||||
|
||||
// Return reviewer with lowest workload
|
||||
return categoryReviewers.sort((a, b) => a.workload - b.workload)[0] || reviewers[0]
|
||||
}
|
||||
|
||||
private calculateReviewPriority(request: StudentRequest, matches: MatchResult[]): number {
|
||||
let priority = this.config.urgencyWeights[request.urgency as keyof typeof this.config.urgencyWeights] || 0.5
|
||||
|
||||
// Boost priority for high AI confidence but below threshold
|
||||
if (matches.length > 0) {
|
||||
const topMatch = matches[0]
|
||||
if (topMatch.confidenceScore > 0.7 && topMatch.confidenceScore < this.config.autoApprovalThreshold) {
|
||||
priority += 0.2
|
||||
}
|
||||
}
|
||||
|
||||
// Boost priority for critical categories
|
||||
priority += this.config.categoryWeights[request.category as keyof typeof this.config.categoryWeights] || 0
|
||||
|
||||
return Math.min(priority, 1.0)
|
||||
}
|
||||
|
||||
private calculateReviewDeadline(urgency: string): Date {
|
||||
const now = new Date()
|
||||
switch (urgency) {
|
||||
case 'emergency':
|
||||
return new Date(now.getTime() + 30 * 60 * 1000) // 30 minutes
|
||||
case 'high':
|
||||
return new Date(now.getTime() + 2 * 60 * 60 * 1000) // 2 hours
|
||||
case 'medium':
|
||||
return new Date(now.getTime() + 8 * 60 * 60 * 1000) // 8 hours
|
||||
case 'low':
|
||||
default:
|
||||
return new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours
|
||||
}
|
||||
}
|
||||
|
||||
private generateRecommendation(matches: MatchResult[]): string {
|
||||
if (matches.length === 0) return 'No suitable matches found - manual resource allocation needed'
|
||||
|
||||
const topMatch = matches[0]
|
||||
if (topMatch.confidenceScore > 0.8) {
|
||||
return `Strong AI recommendation: ${topMatch.resourceName} (${(topMatch.confidenceScore * 100).toFixed(1)}% confidence)`
|
||||
} else if (topMatch.confidenceScore > 0.6) {
|
||||
return `Moderate AI recommendation: ${topMatch.resourceName} - review for accuracy`
|
||||
} else {
|
||||
return `Low confidence match: manual evaluation recommended`
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDashboard(requestId: string, _matches: MatchResult[]): Promise<void> {
|
||||
console.log(`📊 Updating dashboard for request ${requestId}`)
|
||||
// In production: update real-time analytics dashboard
|
||||
}
|
||||
|
||||
private async handleProcessingError(request: StudentRequest, error: Error): Promise<void> {
|
||||
console.error(`❌ Error processing request ${request.id}:`, error.message)
|
||||
|
||||
// Notify administrators of processing error
|
||||
this.notificationService.notify({
|
||||
type: 'alert',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Processing error: ${error.message}`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Route to manual processing
|
||||
await this.routeForHumanReview(request, [])
|
||||
}
|
||||
|
||||
private async trackDecision(request: StudentRequest, _match: MatchResult, decision: string): Promise<void> {
|
||||
console.log(`📈 Tracking decision: ${decision} for request ${request.id}`)
|
||||
// In production: log decision for ML model training
|
||||
}
|
||||
|
||||
// Public methods for integration
|
||||
subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
return this.notificationService.subscribe(callback)
|
||||
}
|
||||
|
||||
private async mockProcessRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Mock implementation for browser demo
|
||||
console.log('Mock processing request:', request.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
return [{
|
||||
resourceId: 'mock-resource-1',
|
||||
resourceName: 'Mock School Supplies',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.8,
|
||||
estimatedImpact: 8.0,
|
||||
logisticalComplexity: 2.5,
|
||||
estimatedCost: 50,
|
||||
fulfillmentTimeline: '2-3 days',
|
||||
reasoningFactors: ['Mock processing', 'Demo mode'],
|
||||
riskFactors: ['Demo data only']
|
||||
}]
|
||||
}
|
||||
|
||||
async generateInsights(requests: StudentRequest[]): Promise<AIInsight[]> {
|
||||
// Mock insights for browser demo
|
||||
return requests.map((req, index) => ({
|
||||
id: `insight-${index}`,
|
||||
type: 'recommendation' as const,
|
||||
title: `Insight for ${req.studentName}`,
|
||||
description: `Mock insight for request ${req.id}`,
|
||||
confidence: 0.75,
|
||||
timestamp: new Date()
|
||||
}))
|
||||
}
|
||||
|
||||
getConfig(): ProcessingPipelineConfig {
|
||||
return { ...this.config }
|
||||
}
|
||||
|
||||
updateConfig(newConfig: Partial<ProcessingPipelineConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig }
|
||||
console.log('🔧 Pipeline configuration updated')
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for backward compatibility
|
||||
export const pipeline = new RealTimeProcessingPipeline()
|
||||
|
||||
// Export classes for testing and advanced usage
|
||||
// Phase 3: Browser-Compatible Processing Pipeline for AI Assistance Matching
|
||||
import {
|
||||
StudentRequest,
|
||||
MatchResult,
|
||||
AIInsight,
|
||||
AIUpdate,
|
||||
ProcessingPipelineConfig
|
||||
} from './types'
|
||||
|
||||
// Re-export from the new browser processor
|
||||
export { browserProcessor, SimpleBrowserProcessor } from './BrowserProcessor'
|
||||
|
||||
// Browser-compatible job queue
|
||||
class BrowserQueue<T> {
|
||||
private jobs: Array<{ id: string; data: T; priority: number; timestamp: number }> = []
|
||||
private processors: Map<string, (job: { id: string; data: T }) => Promise<void>> = new Map()
|
||||
|
||||
async add(jobType: string, data: T, options: { priority: number; attempts?: number; backoff?: string } = { priority: 0 }): Promise<{ id: string }> {
|
||||
const job = {
|
||||
id: `job-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
data,
|
||||
priority: options.priority,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
this.jobs.push(job)
|
||||
this.jobs.sort((a, b) => b.priority - a.priority) // Higher priority first
|
||||
|
||||
// Process job immediately for demo
|
||||
setTimeout(() => this.processNextJob(jobType), 100)
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
process(jobType: string, _concurrency: number, processor: (job: { id: string; data: T }) => Promise<void>): void {
|
||||
this.processors.set(jobType, processor)
|
||||
}
|
||||
|
||||
private async processNextJob(jobType: string): Promise<void> {
|
||||
const processor = this.processors.get(jobType)
|
||||
if (!processor || this.jobs.length === 0) return
|
||||
|
||||
const job = this.jobs.shift()!
|
||||
|
||||
try {
|
||||
await processor(job)
|
||||
} catch (error) {
|
||||
console.error(`Error processing job ${job.id}:`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notification Service for real-time updates
|
||||
class NotificationService {
|
||||
private subscribers: Set<(update: AIUpdate) => void> = new Set()
|
||||
|
||||
subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
this.subscribers.add(callback)
|
||||
return () => this.subscribers.delete(callback)
|
||||
}
|
||||
|
||||
notify(update: AIUpdate): void {
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Error in notification callback:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async notifyStudent(studentId: string, _assignment: any): Promise<void> {
|
||||
console.log(`📧 Notifying student ${studentId} about assignment`)
|
||||
// In production: send email, SMS, or push notification
|
||||
}
|
||||
|
||||
async notifyVolunteer(volunteerId: string, _assignment: any): Promise<void> {
|
||||
console.log(`📧 Notifying volunteer ${volunteerId} about new assignment`)
|
||||
// In production: send volunteer notification
|
||||
}
|
||||
|
||||
async notifyCoordinators(_assignment: any): Promise<void> {
|
||||
console.log(`📧 Notifying coordinators about new assignment`)
|
||||
// In production: alert coordination team
|
||||
}
|
||||
|
||||
async updateDonors(estimatedCost: number): Promise<void> {
|
||||
console.log(`💰 Updating donors about $${estimatedCost} impact opportunity`)
|
||||
// In production: trigger donor engagement campaign
|
||||
}
|
||||
|
||||
async notifyReviewer(reviewer: any, _reviewTask: any, aiInsights: any): Promise<void> {
|
||||
console.log(`👥 Notifying reviewer ${reviewer.id} about review task with AI confidence: ${aiInsights.aiConfidence}`)
|
||||
// In production: send detailed review notification with AI recommendations
|
||||
}
|
||||
}
|
||||
|
||||
// Assignment and Review Management
|
||||
class AssignmentManager {
|
||||
private static assignments: Map<string, any> = new Map()
|
||||
|
||||
static async createAssignment(assignmentData: any): Promise<any> {
|
||||
const assignment = {
|
||||
id: `assign-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...assignmentData,
|
||||
createdAt: new Date(),
|
||||
status: 'pending'
|
||||
}
|
||||
|
||||
this.assignments.set(assignment.id, assignment)
|
||||
console.log(`✅ Created assignment ${assignment.id}`)
|
||||
return assignment
|
||||
}
|
||||
|
||||
static async getById(id: string): Promise<any | null> {
|
||||
return this.assignments.get(id) || null
|
||||
}
|
||||
}
|
||||
|
||||
class ReviewManager {
|
||||
private static reviewTasks: Map<string, any> = new Map()
|
||||
|
||||
static async createReviewTask(taskData: any): Promise<any> {
|
||||
const task = {
|
||||
id: `review-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
...taskData,
|
||||
createdAt: new Date(),
|
||||
status: 'pending'
|
||||
}
|
||||
|
||||
this.reviewTasks.set(task.id, task)
|
||||
console.log(`📋 Created review task ${task.id}`)
|
||||
return task
|
||||
}
|
||||
}
|
||||
|
||||
// Main Processing Pipeline - Simplified browser-compatible version
|
||||
export class RealTimeProcessingPipeline {
|
||||
private queue: BrowserQueue<StudentRequest>
|
||||
private notificationService: NotificationService
|
||||
private config: ProcessingPipelineConfig
|
||||
|
||||
constructor(config: Partial<ProcessingPipelineConfig> = {}) {
|
||||
this.queue = new BrowserQueue<StudentRequest>()
|
||||
this.notificationService = new NotificationService()
|
||||
this.config = {
|
||||
autoApprovalThreshold: 0.85,
|
||||
urgencyWeights: {
|
||||
'emergency': 1.0,
|
||||
'high': 0.8,
|
||||
'medium': 0.5,
|
||||
'low': 0.2
|
||||
},
|
||||
categoryWeights: {
|
||||
'emergency-housing': 1.0,
|
||||
'food-assistance': 0.9,
|
||||
'medical-needs': 0.85,
|
||||
'clothing': 0.7,
|
||||
'school-supplies': 0.6,
|
||||
'transportation': 0.5,
|
||||
'technology': 0.4,
|
||||
'extracurricular': 0.3,
|
||||
'other': 0.4
|
||||
},
|
||||
maxProcessingTime: 5000, // 5 seconds
|
||||
retryAttempts: 3,
|
||||
notificationEnabled: true,
|
||||
...config
|
||||
}
|
||||
|
||||
this.setupQueueProcessors()
|
||||
}
|
||||
|
||||
private setupQueueProcessors(): void {
|
||||
this.queue.process('analyze-request', 5, async (job: { id: string; data: StudentRequest }) => {
|
||||
const request = job.data
|
||||
|
||||
try {
|
||||
console.log(`🔄 Processing request ${request.id} for ${request.studentName}`)
|
||||
|
||||
// Mock AI processing for browser demo
|
||||
const matches = await this.mockProcessRequest(request)
|
||||
|
||||
// Auto-approval for high-confidence matches
|
||||
if (matches.length > 0 && matches[0].confidenceScore >= this.config.autoApprovalThreshold) {
|
||||
await this.autoApproveRequest(request, matches[0])
|
||||
} else {
|
||||
await this.routeForHumanReview(request, matches)
|
||||
}
|
||||
|
||||
// Update real-time dashboard
|
||||
await this.updateDashboard(request.id, matches)
|
||||
|
||||
// Notify subscribers of processing completion
|
||||
this.notificationService.notify({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
status: matches.length > 0 && matches[0].confidenceScore >= this.config.autoApprovalThreshold ? 'auto-approved' : 'under-review',
|
||||
recommendations: matches,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
await this.handleProcessingError(request, error as Error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async submitRequest(request: StudentRequest): Promise<string> {
|
||||
console.log(`📥 Submitting request from ${request.studentName}: ${request.category}`)
|
||||
|
||||
// Add to processing queue with priority based on urgency
|
||||
const priority = this.calculatePriority(request.urgency)
|
||||
const job = await this.queue.add('analyze-request', request, {
|
||||
priority,
|
||||
attempts: this.config.retryAttempts,
|
||||
backoff: 'exponential'
|
||||
})
|
||||
|
||||
// Immediate acknowledgment
|
||||
await this.sendAcknowledgment(request)
|
||||
|
||||
return job.id
|
||||
}
|
||||
|
||||
private calculatePriority(urgency: string): number {
|
||||
return this.config.urgencyWeights[urgency as keyof typeof this.config.urgencyWeights] || 0.5
|
||||
}
|
||||
|
||||
private async sendAcknowledgment(request: StudentRequest): Promise<void> {
|
||||
console.log(`✉️ Sending acknowledgment to ${request.studentName}`)
|
||||
// In production: send immediate confirmation email/SMS
|
||||
}
|
||||
|
||||
private async autoApproveRequest(request: StudentRequest, match: MatchResult): Promise<void> {
|
||||
console.log(`🤖 Auto-approving request ${request.id} with ${(match.confidenceScore * 100).toFixed(1)}% confidence`)
|
||||
|
||||
// Create assistance assignment
|
||||
const assignment = await AssignmentManager.createAssignment({
|
||||
requestId: request.id,
|
||||
studentId: request.studentId,
|
||||
studentName: request.studentName,
|
||||
resourceId: match.resourceId,
|
||||
resourceName: match.resourceName,
|
||||
volunteerId: match.volunteerMatch?.id,
|
||||
volunteerName: match.volunteerMatch?.volunteerName,
|
||||
scheduledDate: new Date(Date.now() + 24 * 60 * 60 * 1000), // Tomorrow
|
||||
estimatedCost: match.estimatedCost,
|
||||
approvalStatus: 'auto-approved',
|
||||
confidence: match.confidenceScore,
|
||||
aiRecommendation: true,
|
||||
urgency: request.urgency,
|
||||
category: request.category
|
||||
})
|
||||
|
||||
// Notify all stakeholders
|
||||
if (this.config.notificationEnabled) {
|
||||
await Promise.all([
|
||||
this.notificationService.notifyStudent(request.studentId, assignment),
|
||||
match.volunteerMatch ? this.notificationService.notifyVolunteer(assignment.volunteerId, assignment) : Promise.resolve(),
|
||||
this.notificationService.notifyCoordinators(assignment),
|
||||
this.notificationService.updateDonors(assignment.estimatedCost)
|
||||
])
|
||||
}
|
||||
|
||||
// Notify real-time subscribers
|
||||
this.notificationService.notify({
|
||||
type: 'auto-approval',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Request automatically approved with ${(match.confidenceScore * 100).toFixed(1)}% confidence`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Track decision for learning
|
||||
await this.trackDecision(request, match, 'auto-approved')
|
||||
}
|
||||
|
||||
private async routeForHumanReview(request: StudentRequest, matches: MatchResult[]): Promise<void> {
|
||||
console.log(`👤 Routing request ${request.id} for human review`)
|
||||
|
||||
// Determine best reviewer based on request type and matches
|
||||
const reviewer = await this.selectOptimalReviewer(request, matches)
|
||||
|
||||
// Create review assignment
|
||||
const reviewTask = await ReviewManager.createReviewTask({
|
||||
requestId: request.id,
|
||||
assignedTo: reviewer.id,
|
||||
assignedToName: reviewer.name,
|
||||
aiRecommendations: matches,
|
||||
priority: this.calculateReviewPriority(request, matches),
|
||||
deadline: this.calculateReviewDeadline(request.urgency),
|
||||
studentName: request.studentName,
|
||||
category: request.category,
|
||||
urgency: request.urgency
|
||||
})
|
||||
|
||||
// Notify reviewer with AI insights
|
||||
if (this.config.notificationEnabled) {
|
||||
await this.notificationService.notifyReviewer(reviewer, reviewTask, {
|
||||
aiConfidence: matches[0]?.confidenceScore || 0,
|
||||
recommendedAction: this.generateRecommendation(matches),
|
||||
riskFactors: matches[0]?.riskFactors || []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async selectOptimalReviewer(request: StudentRequest, _matches: MatchResult[]) {
|
||||
// Mock reviewer selection - in production, this would use actual staff data
|
||||
const reviewers = [
|
||||
{ id: 'rev1', name: 'Sarah Martinez', specialties: ['clothing', 'emergency-housing'], workload: 5 },
|
||||
{ id: 'rev2', name: 'John Davis', specialties: ['food-assistance', 'transportation'], workload: 3 },
|
||||
{ id: 'rev3', name: 'Lisa Chen', specialties: ['school-supplies', 'technology'], workload: 7 },
|
||||
{ id: 'rev4', name: 'Mike Johnson', specialties: ['medical-needs', 'other'], workload: 4 }
|
||||
]
|
||||
|
||||
// Select reviewer based on specialty and workload
|
||||
const categoryReviewers = reviewers.filter(r =>
|
||||
r.specialties.includes(request.category) || r.specialties.includes('other')
|
||||
)
|
||||
|
||||
// Return reviewer with lowest workload
|
||||
return categoryReviewers.sort((a, b) => a.workload - b.workload)[0] || reviewers[0]
|
||||
}
|
||||
|
||||
private calculateReviewPriority(request: StudentRequest, matches: MatchResult[]): number {
|
||||
let priority = this.config.urgencyWeights[request.urgency as keyof typeof this.config.urgencyWeights] || 0.5
|
||||
|
||||
// Boost priority for high AI confidence but below threshold
|
||||
if (matches.length > 0) {
|
||||
const topMatch = matches[0]
|
||||
if (topMatch.confidenceScore > 0.7 && topMatch.confidenceScore < this.config.autoApprovalThreshold) {
|
||||
priority += 0.2
|
||||
}
|
||||
}
|
||||
|
||||
// Boost priority for critical categories
|
||||
priority += this.config.categoryWeights[request.category as keyof typeof this.config.categoryWeights] || 0
|
||||
|
||||
return Math.min(priority, 1.0)
|
||||
}
|
||||
|
||||
private calculateReviewDeadline(urgency: string): Date {
|
||||
const now = new Date()
|
||||
switch (urgency) {
|
||||
case 'emergency':
|
||||
return new Date(now.getTime() + 30 * 60 * 1000) // 30 minutes
|
||||
case 'high':
|
||||
return new Date(now.getTime() + 2 * 60 * 60 * 1000) // 2 hours
|
||||
case 'medium':
|
||||
return new Date(now.getTime() + 8 * 60 * 60 * 1000) // 8 hours
|
||||
case 'low':
|
||||
default:
|
||||
return new Date(now.getTime() + 24 * 60 * 60 * 1000) // 24 hours
|
||||
}
|
||||
}
|
||||
|
||||
private generateRecommendation(matches: MatchResult[]): string {
|
||||
if (matches.length === 0) return 'No suitable matches found - manual resource allocation needed'
|
||||
|
||||
const topMatch = matches[0]
|
||||
if (topMatch.confidenceScore > 0.8) {
|
||||
return `Strong AI recommendation: ${topMatch.resourceName} (${(topMatch.confidenceScore * 100).toFixed(1)}% confidence)`
|
||||
} else if (topMatch.confidenceScore > 0.6) {
|
||||
return `Moderate AI recommendation: ${topMatch.resourceName} - review for accuracy`
|
||||
} else {
|
||||
return `Low confidence match: manual evaluation recommended`
|
||||
}
|
||||
}
|
||||
|
||||
private async updateDashboard(requestId: string, _matches: MatchResult[]): Promise<void> {
|
||||
console.log(`📊 Updating dashboard for request ${requestId}`)
|
||||
// In production: update real-time analytics dashboard
|
||||
}
|
||||
|
||||
private async handleProcessingError(request: StudentRequest, error: Error): Promise<void> {
|
||||
console.error(`❌ Error processing request ${request.id}:`, error.message)
|
||||
|
||||
// Notify administrators of processing error
|
||||
this.notificationService.notify({
|
||||
type: 'alert',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Processing error: ${error.message}`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Route to manual processing
|
||||
await this.routeForHumanReview(request, [])
|
||||
}
|
||||
|
||||
private async trackDecision(request: StudentRequest, _match: MatchResult, decision: string): Promise<void> {
|
||||
console.log(`📈 Tracking decision: ${decision} for request ${request.id}`)
|
||||
// In production: log decision for ML model training
|
||||
}
|
||||
|
||||
// Public methods for integration
|
||||
subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
return this.notificationService.subscribe(callback)
|
||||
}
|
||||
|
||||
private async mockProcessRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Mock implementation for browser demo
|
||||
console.log('Mock processing request:', request.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
return [{
|
||||
resourceId: 'mock-resource-1',
|
||||
resourceName: 'Mock School Supplies',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.8,
|
||||
estimatedImpact: 8.0,
|
||||
logisticalComplexity: 2.5,
|
||||
estimatedCost: 50,
|
||||
fulfillmentTimeline: '2-3 days',
|
||||
reasoningFactors: ['Mock processing', 'Demo mode'],
|
||||
riskFactors: ['Demo data only']
|
||||
}]
|
||||
}
|
||||
|
||||
async generateInsights(requests: StudentRequest[]): Promise<AIInsight[]> {
|
||||
// Mock insights for browser demo
|
||||
return requests.map((req, index) => ({
|
||||
id: `insight-${index}`,
|
||||
type: 'recommendation' as const,
|
||||
title: `Insight for ${req.studentName}`,
|
||||
description: `Mock insight for request ${req.id}`,
|
||||
confidence: 0.75,
|
||||
timestamp: new Date()
|
||||
}))
|
||||
}
|
||||
|
||||
getConfig(): ProcessingPipelineConfig {
|
||||
return { ...this.config }
|
||||
}
|
||||
|
||||
updateConfig(newConfig: Partial<ProcessingPipelineConfig>): void {
|
||||
this.config = { ...this.config, ...newConfig }
|
||||
console.log('🔧 Pipeline configuration updated')
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance for backward compatibility
|
||||
export const pipeline = new RealTimeProcessingPipeline()
|
||||
|
||||
// Export classes for testing and advanced usage
|
||||
export { NotificationService, AssignmentManager, ReviewManager }
|
||||
+802
-802
File diff suppressed because it is too large
Load Diff
+190
-190
@@ -1,191 +1,191 @@
|
||||
// Phase 3: AI Types and Interfaces
|
||||
|
||||
export interface StudentRequest {
|
||||
id: string
|
||||
studentId: string
|
||||
studentName: string
|
||||
description: string
|
||||
category: AssistanceCategory
|
||||
urgency: UrgencyLevel
|
||||
location: GeographicLocation
|
||||
constraints: RequestConstraints
|
||||
deadline?: Date
|
||||
submittedAt: Date
|
||||
estimatedCost?: number
|
||||
requiredSkills?: string[]
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
resourceId: string
|
||||
resourceName: string
|
||||
resourceType: 'clothing' | 'supplies' | 'food' | 'transport' | 'emergency' | 'other'
|
||||
confidenceScore: number
|
||||
estimatedImpact: number
|
||||
logisticalComplexity: number
|
||||
volunteerMatch?: VolunteerAssignment
|
||||
estimatedCost: number
|
||||
fulfillmentTimeline: string
|
||||
reasoningFactors: string[]
|
||||
riskFactors: string[]
|
||||
}
|
||||
|
||||
export interface RequestAnalysis {
|
||||
primaryNeeds: NeedCategory[]
|
||||
urgencyScore: number
|
||||
complexityEstimate: number
|
||||
resourceRequirements: ResourceRequirement[]
|
||||
locationConstraints?: GeographicConstraint[]
|
||||
timeConstraints?: TemporalConstraint[]
|
||||
requiredSkills?: string[]
|
||||
estimatedBudget: number
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
id: string
|
||||
type: 'anomaly' | 'optimization' | 'trend' | 'prediction' | 'recommendation'
|
||||
title: string
|
||||
description: string
|
||||
confidence: number
|
||||
severity?: 'low' | 'medium' | 'high' | 'critical'
|
||||
timestamp: Date
|
||||
actionItems?: string[]
|
||||
estimatedImpact?: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AIMetrics {
|
||||
accuracyRate: number
|
||||
accuracyTrend: number
|
||||
avgProcessingTime: number
|
||||
speedTrend: number
|
||||
autoApprovalRate: number
|
||||
automationTrend: number
|
||||
impactPredictionAccuracy: number
|
||||
impactTrend: number
|
||||
totalRequestsProcessed: number
|
||||
successfulMatches: number
|
||||
}
|
||||
|
||||
export interface AIUpdate {
|
||||
type: 'request-processed' | 'new-insight' | 'auto-approval' | 'model-updated' | 'alert'
|
||||
requestId?: string
|
||||
studentName?: string
|
||||
status?: string
|
||||
recommendations?: MatchResult[]
|
||||
insight?: AIInsight
|
||||
message?: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export type AssistanceCategory =
|
||||
| 'clothing'
|
||||
| 'school-supplies'
|
||||
| 'food-assistance'
|
||||
| 'transportation'
|
||||
| 'emergency-housing'
|
||||
| 'medical-needs'
|
||||
| 'technology'
|
||||
| 'extracurricular'
|
||||
| 'other'
|
||||
|
||||
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'emergency'
|
||||
|
||||
export interface GeographicLocation {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
address?: string
|
||||
city: string
|
||||
state: string
|
||||
zipCode: string
|
||||
schoolDistrict?: string
|
||||
}
|
||||
|
||||
export interface RequestConstraints {
|
||||
maxBudget?: number
|
||||
timeframe: 'immediate' | 'within-week' | 'within-month' | 'flexible'
|
||||
deliveryMethod: 'pickup' | 'delivery' | 'mail' | 'school-delivery' | 'any'
|
||||
privacyLevel: 'anonymous' | 'semi-anonymous' | 'open'
|
||||
specialRequirements?: string[]
|
||||
}
|
||||
|
||||
export interface VolunteerAssignment {
|
||||
id: string
|
||||
volunteerId: string
|
||||
volunteerName: string
|
||||
skills: string[]
|
||||
availability: Date[]
|
||||
location: GeographicLocation
|
||||
rating: number
|
||||
completedAssignments: number
|
||||
}
|
||||
|
||||
export interface NeedCategory {
|
||||
category: AssistanceCategory
|
||||
subcategory?: string
|
||||
priority: number
|
||||
quantity?: number
|
||||
specifications?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ResourceRequirement {
|
||||
type: string
|
||||
quantity: number
|
||||
specifications: Record<string, any>
|
||||
alternatives?: string[]
|
||||
estimatedCost: number
|
||||
}
|
||||
|
||||
export interface GeographicConstraint {
|
||||
maxDistance: number
|
||||
preferredAreas?: string[]
|
||||
excludedAreas?: string[]
|
||||
}
|
||||
|
||||
export interface TemporalConstraint {
|
||||
earliestStart: Date
|
||||
latestCompletion: Date
|
||||
preferredTimes?: string[]
|
||||
blackoutPeriods?: DateRange[]
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export interface ImpactPrediction {
|
||||
estimatedBeneficiaries: number
|
||||
successProbability: number
|
||||
timeToImpact: number
|
||||
sustainabilityScore: number
|
||||
rippleEffects: RippleEffect[]
|
||||
measurableOutcomes: string[]
|
||||
}
|
||||
|
||||
export interface RippleEffect {
|
||||
type: 'family' | 'community' | 'academic' | 'social' | 'economic'
|
||||
description: string
|
||||
estimatedBeneficiaries: number
|
||||
confidenceLevel: number
|
||||
}
|
||||
|
||||
export interface LearningFeedback {
|
||||
requestId: string
|
||||
matchId: string
|
||||
outcome: 'successful' | 'partial' | 'failed'
|
||||
actualCost: number
|
||||
actualTimeToComplete: number
|
||||
satisfactionScore: number
|
||||
issues?: string[]
|
||||
improvements?: string[]
|
||||
measuredImpact?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ProcessingPipelineConfig {
|
||||
autoApprovalThreshold: number
|
||||
urgencyWeights: Record<UrgencyLevel, number>
|
||||
categoryWeights: Record<AssistanceCategory, number>
|
||||
maxProcessingTime: number
|
||||
retryAttempts: number
|
||||
notificationEnabled: boolean
|
||||
// Phase 3: AI Types and Interfaces
|
||||
|
||||
export interface StudentRequest {
|
||||
id: string
|
||||
studentId: string
|
||||
studentName: string
|
||||
description: string
|
||||
category: AssistanceCategory
|
||||
urgency: UrgencyLevel
|
||||
location: GeographicLocation
|
||||
constraints: RequestConstraints
|
||||
deadline?: Date
|
||||
submittedAt: Date
|
||||
estimatedCost?: number
|
||||
requiredSkills?: string[]
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
resourceId: string
|
||||
resourceName: string
|
||||
resourceType: 'clothing' | 'supplies' | 'food' | 'transport' | 'emergency' | 'other'
|
||||
confidenceScore: number
|
||||
estimatedImpact: number
|
||||
logisticalComplexity: number
|
||||
volunteerMatch?: VolunteerAssignment
|
||||
estimatedCost: number
|
||||
fulfillmentTimeline: string
|
||||
reasoningFactors: string[]
|
||||
riskFactors: string[]
|
||||
}
|
||||
|
||||
export interface RequestAnalysis {
|
||||
primaryNeeds: NeedCategory[]
|
||||
urgencyScore: number
|
||||
complexityEstimate: number
|
||||
resourceRequirements: ResourceRequirement[]
|
||||
locationConstraints?: GeographicConstraint[]
|
||||
timeConstraints?: TemporalConstraint[]
|
||||
requiredSkills?: string[]
|
||||
estimatedBudget: number
|
||||
}
|
||||
|
||||
export interface AIInsight {
|
||||
id: string
|
||||
type: 'anomaly' | 'optimization' | 'trend' | 'prediction' | 'recommendation'
|
||||
title: string
|
||||
description: string
|
||||
confidence: number
|
||||
severity?: 'low' | 'medium' | 'high' | 'critical'
|
||||
timestamp: Date
|
||||
actionItems?: string[]
|
||||
estimatedImpact?: string
|
||||
data?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface AIMetrics {
|
||||
accuracyRate: number
|
||||
accuracyTrend: number
|
||||
avgProcessingTime: number
|
||||
speedTrend: number
|
||||
autoApprovalRate: number
|
||||
automationTrend: number
|
||||
impactPredictionAccuracy: number
|
||||
impactTrend: number
|
||||
totalRequestsProcessed: number
|
||||
successfulMatches: number
|
||||
}
|
||||
|
||||
export interface AIUpdate {
|
||||
type: 'request-processed' | 'new-insight' | 'auto-approval' | 'model-updated' | 'alert'
|
||||
requestId?: string
|
||||
studentName?: string
|
||||
status?: string
|
||||
recommendations?: MatchResult[]
|
||||
insight?: AIInsight
|
||||
message?: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export type AssistanceCategory =
|
||||
| 'clothing'
|
||||
| 'school-supplies'
|
||||
| 'food-assistance'
|
||||
| 'transportation'
|
||||
| 'emergency-housing'
|
||||
| 'medical-needs'
|
||||
| 'technology'
|
||||
| 'extracurricular'
|
||||
| 'other'
|
||||
|
||||
export type UrgencyLevel = 'low' | 'medium' | 'high' | 'emergency'
|
||||
|
||||
export interface GeographicLocation {
|
||||
latitude?: number
|
||||
longitude?: number
|
||||
address?: string
|
||||
city: string
|
||||
state: string
|
||||
zipCode: string
|
||||
schoolDistrict?: string
|
||||
}
|
||||
|
||||
export interface RequestConstraints {
|
||||
maxBudget?: number
|
||||
timeframe: 'immediate' | 'within-week' | 'within-month' | 'flexible'
|
||||
deliveryMethod: 'pickup' | 'delivery' | 'mail' | 'school-delivery' | 'any'
|
||||
privacyLevel: 'anonymous' | 'semi-anonymous' | 'open'
|
||||
specialRequirements?: string[]
|
||||
}
|
||||
|
||||
export interface VolunteerAssignment {
|
||||
id: string
|
||||
volunteerId: string
|
||||
volunteerName: string
|
||||
skills: string[]
|
||||
availability: Date[]
|
||||
location: GeographicLocation
|
||||
rating: number
|
||||
completedAssignments: number
|
||||
}
|
||||
|
||||
export interface NeedCategory {
|
||||
category: AssistanceCategory
|
||||
subcategory?: string
|
||||
priority: number
|
||||
quantity?: number
|
||||
specifications?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ResourceRequirement {
|
||||
type: string
|
||||
quantity: number
|
||||
specifications: Record<string, any>
|
||||
alternatives?: string[]
|
||||
estimatedCost: number
|
||||
}
|
||||
|
||||
export interface GeographicConstraint {
|
||||
maxDistance: number
|
||||
preferredAreas?: string[]
|
||||
excludedAreas?: string[]
|
||||
}
|
||||
|
||||
export interface TemporalConstraint {
|
||||
earliestStart: Date
|
||||
latestCompletion: Date
|
||||
preferredTimes?: string[]
|
||||
blackoutPeriods?: DateRange[]
|
||||
}
|
||||
|
||||
export interface DateRange {
|
||||
start: Date
|
||||
end: Date
|
||||
}
|
||||
|
||||
export interface ImpactPrediction {
|
||||
estimatedBeneficiaries: number
|
||||
successProbability: number
|
||||
timeToImpact: number
|
||||
sustainabilityScore: number
|
||||
rippleEffects: RippleEffect[]
|
||||
measurableOutcomes: string[]
|
||||
}
|
||||
|
||||
export interface RippleEffect {
|
||||
type: 'family' | 'community' | 'academic' | 'social' | 'economic'
|
||||
description: string
|
||||
estimatedBeneficiaries: number
|
||||
confidenceLevel: number
|
||||
}
|
||||
|
||||
export interface LearningFeedback {
|
||||
requestId: string
|
||||
matchId: string
|
||||
outcome: 'successful' | 'partial' | 'failed'
|
||||
actualCost: number
|
||||
actualTimeToComplete: number
|
||||
satisfactionScore: number
|
||||
issues?: string[]
|
||||
improvements?: string[]
|
||||
measuredImpact?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface ProcessingPipelineConfig {
|
||||
autoApprovalThreshold: number
|
||||
urgencyWeights: Record<UrgencyLevel, number>
|
||||
categoryWeights: Record<AssistanceCategory, number>
|
||||
maxProcessingTime: number
|
||||
retryAttempts: number
|
||||
notificationEnabled: boolean
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,370 +1,370 @@
|
||||
// Phase 3B: Advanced Analytics Dashboard for Nonprofit Impact Tracking
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface ImpactMetrics {
|
||||
totalStudentsServed: number
|
||||
totalResourcesAllocated: number
|
||||
totalDonationsProcessed: number
|
||||
averageResponseTime: number
|
||||
costEfficiencyRatio: number
|
||||
volunteerEngagement: number
|
||||
schoolPartnershipGrowth: number
|
||||
monthlyTrends: MonthlyTrend[]
|
||||
}
|
||||
|
||||
interface MonthlyTrend {
|
||||
month: string
|
||||
studentsServed: number
|
||||
resourcesAllocated: number
|
||||
donations: number
|
||||
efficiency: number
|
||||
}
|
||||
|
||||
interface PredictiveAnalysis {
|
||||
nextMonthDemand: number
|
||||
resourceNeeds: ResourceForecast[]
|
||||
budgetProjection: number
|
||||
volunteerRequirement: number
|
||||
riskFactors: string[]
|
||||
opportunities: string[]
|
||||
}
|
||||
|
||||
interface ResourceForecast {
|
||||
category: string
|
||||
predictedDemand: number
|
||||
currentInventory: number
|
||||
recommendedPurchase: number
|
||||
urgencyLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
}
|
||||
|
||||
interface GeographicData {
|
||||
region: string
|
||||
studentsServed: number
|
||||
averageNeed: number
|
||||
responseTime: number
|
||||
efficiency: number
|
||||
coordinates: [number, number]
|
||||
}
|
||||
|
||||
const AdvancedAnalyticsDashboard: React.FC = () => {
|
||||
const [metrics, setMetrics] = useState<ImpactMetrics | null>(null)
|
||||
const [predictions, setPredictions] = useState<PredictiveAnalysis | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeographicData[]>([])
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState<'week' | 'month' | 'quarter' | 'year'>('month')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalyticsData()
|
||||
}, [selectedTimeframe])
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Simulate loading comprehensive analytics
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
setMetrics({
|
||||
totalStudentsServed: 2847,
|
||||
totalResourcesAllocated: 15690,
|
||||
totalDonationsProcessed: 89234,
|
||||
averageResponseTime: 4.2,
|
||||
costEfficiencyRatio: 0.87,
|
||||
volunteerEngagement: 0.93,
|
||||
schoolPartnershipGrowth: 0.24,
|
||||
monthlyTrends: [
|
||||
{ month: 'Jan', studentsServed: 234, resourcesAllocated: 1250, donations: 7800, efficiency: 0.85 },
|
||||
{ month: 'Feb', studentsServed: 289, resourcesAllocated: 1420, donations: 8900, efficiency: 0.87 },
|
||||
{ month: 'Mar', studentsServed: 312, resourcesAllocated: 1580, donations: 9200, efficiency: 0.89 },
|
||||
{ month: 'Apr', studentsServed: 298, resourcesAllocated: 1490, donations: 8700, efficiency: 0.88 },
|
||||
{ month: 'May', studentsServed: 356, resourcesAllocated: 1780, donations: 10500, efficiency: 0.91 },
|
||||
{ month: 'Jun', studentsServed: 378, resourcesAllocated: 1890, donations: 11200, efficiency: 0.93 }
|
||||
]
|
||||
})
|
||||
|
||||
setPredictions({
|
||||
nextMonthDemand: 425,
|
||||
budgetProjection: 12800,
|
||||
volunteerRequirement: 67,
|
||||
resourceNeeds: [
|
||||
{ category: 'School Supplies', predictedDemand: 156, currentInventory: 89, recommendedPurchase: 75, urgencyLevel: 'medium' },
|
||||
{ category: 'Clothing', predictedDemand: 134, currentInventory: 45, recommendedPurchase: 95, urgencyLevel: 'high' },
|
||||
{ category: 'Food Assistance', predictedDemand: 89, currentInventory: 67, recommendedPurchase: 30, urgencyLevel: 'low' },
|
||||
{ category: 'Technology', predictedDemand: 46, currentInventory: 12, recommendedPurchase: 40, urgencyLevel: 'critical' }
|
||||
],
|
||||
riskFactors: [
|
||||
'Increased demand in back-to-school season',
|
||||
'Volunteer availability declining in summer',
|
||||
'Technology needs growing faster than budget'
|
||||
],
|
||||
opportunities: [
|
||||
'Partnership with local tech companies for device donations',
|
||||
'Summer clothing drive potential',
|
||||
'Grant opportunity for educational technology'
|
||||
]
|
||||
})
|
||||
|
||||
setGeoData([
|
||||
{ region: 'Downtown Schools', studentsServed: 156, averageNeed: 3.2, responseTime: 3.8, efficiency: 0.91, coordinates: [-122.4194, 37.7749] },
|
||||
{ region: 'Suburban East', studentsServed: 98, averageNeed: 2.8, responseTime: 4.5, efficiency: 0.85, coordinates: [-122.3894, 37.7849] },
|
||||
{ region: 'North District', studentsServed: 134, averageNeed: 3.6, responseTime: 4.1, efficiency: 0.88, coordinates: [-122.4094, 37.7949] },
|
||||
{ region: 'South Valley', studentsServed: 89, averageNeed: 2.9, responseTime: 5.2, efficiency: 0.82, coordinates: [-122.4294, 37.7649] }
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const getUrgencyColor = (urgency: string) => {
|
||||
switch (urgency) {
|
||||
case 'critical': return 'bg-red-500'
|
||||
case 'high': return 'bg-orange-500'
|
||||
case 'medium': return 'bg-yellow-500'
|
||||
case 'low': return 'bg-green-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full"
|
||||
/>
|
||||
<span className="ml-4 text-lg text-blue-700">Loading Advanced Analytics...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">Impact Analytics Dashboard</h1>
|
||||
<p className="text-lg text-gray-600">Comprehensive insights into our nonprofit's reach and effectiveness</p>
|
||||
|
||||
<div className="flex gap-4 mt-4">
|
||||
{(['week', 'month', 'quarter', 'year'] as const).map((timeframe) => (
|
||||
<button
|
||||
key={timeframe}
|
||||
onClick={() => setSelectedTimeframe(timeframe)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
selectedTimeframe === timeframe
|
||||
? 'bg-blue-500 text-white shadow-lg'
|
||||
: 'bg-white text-gray-700 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
{timeframe.charAt(0).toUpperCase() + timeframe.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Key Metrics Grid */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"
|
||||
>
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Students Served</h3>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold">👥</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600 mb-2">{metrics?.totalStudentsServed.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-500">+12% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Resources Allocated</h3>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-green-600 font-bold">📦</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">{metrics?.totalResourcesAllocated.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-500">+8% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Donations Processed</h3>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-purple-600 font-bold">💝</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">{formatCurrency(metrics?.totalDonationsProcessed || 0)}</div>
|
||||
<div className="text-sm text-gray-500">+15% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Efficiency Ratio</h3>
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-orange-600 font-bold">⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-orange-600 mb-2">{formatPercentage(metrics?.costEfficiencyRatio || 0)}</div>
|
||||
<div className="text-sm text-gray-500">+3% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Trends Chart */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg mb-8"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Monthly Impact Trends</h3>
|
||||
<div className="h-80 flex items-end justify-between gap-4">
|
||||
{metrics?.monthlyTrends.map((trend, index) => (
|
||||
<div key={trend.month} className="flex-1 flex flex-col items-center">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: `${(trend.studentsServed / 400) * 100}%` }}
|
||||
transition={{ delay: 0.6 + index * 0.1, duration: 0.8 }}
|
||||
className="bg-gradient-to-t from-blue-500 to-blue-300 rounded-t-lg w-full mb-2 min-h-[20px]"
|
||||
/>
|
||||
<div className="text-sm font-medium text-gray-700">{trend.month}</div>
|
||||
<div className="text-xs text-gray-500">{trend.studentsServed}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Predictive Analysis and Geographic Data */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Resource Forecasting */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Resource Demand Forecast</h3>
|
||||
<div className="space-y-4">
|
||||
{predictions?.resourceNeeds.map((resource) => (
|
||||
<div key={resource.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-700">{resource.category}</h4>
|
||||
<span className={`px-2 py-1 rounded text-xs text-white ${getUrgencyColor(resource.urgencyLevel)}`}>
|
||||
{resource.urgencyLevel.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Predicted Need:</span>
|
||||
<div className="font-medium">{resource.predictedDemand}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Current Stock:</span>
|
||||
<div className="font-medium">{resource.currentInventory}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Recommended:</span>
|
||||
<div className="font-medium text-blue-600">+{resource.recommendedPurchase}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Geographic Performance */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Geographic Performance</h3>
|
||||
<div className="space-y-4">
|
||||
{geoData.map((region) => (
|
||||
<div key={region.region} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-700">{region.region}</h4>
|
||||
<div className="text-sm text-gray-500">{formatPercentage(region.efficiency)} efficiency</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Students Served:</span>
|
||||
<div className="font-medium">{region.studentsServed}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Response:</span>
|
||||
<div className="font-medium">{region.responseTime}h</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Need Level:</span>
|
||||
<div className="font-medium">{region.averageNeed}/5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Insights and Recommendations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.0 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">AI-Generated Insights & Recommendations</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-red-600 mb-3">⚠️ Risk Factors</h4>
|
||||
<ul className="space-y-2">
|
||||
{predictions?.riskFactors.map((risk, index) => (
|
||||
<li key={index} className="text-sm text-gray-700 flex items-start">
|
||||
<span className="text-red-500 mr-2">•</span>
|
||||
{risk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-green-600 mb-3">💡 Opportunities</h4>
|
||||
<ul className="space-y-2">
|
||||
{predictions?.opportunities.map((opportunity, index) => (
|
||||
<li key={index} className="text-sm text-gray-700 flex items-start">
|
||||
<span className="text-green-500 mr-2">•</span>
|
||||
{opportunity}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Phase 3B: Advanced Analytics Dashboard for Nonprofit Impact Tracking
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
|
||||
interface ImpactMetrics {
|
||||
totalStudentsServed: number
|
||||
totalResourcesAllocated: number
|
||||
totalDonationsProcessed: number
|
||||
averageResponseTime: number
|
||||
costEfficiencyRatio: number
|
||||
volunteerEngagement: number
|
||||
schoolPartnershipGrowth: number
|
||||
monthlyTrends: MonthlyTrend[]
|
||||
}
|
||||
|
||||
interface MonthlyTrend {
|
||||
month: string
|
||||
studentsServed: number
|
||||
resourcesAllocated: number
|
||||
donations: number
|
||||
efficiency: number
|
||||
}
|
||||
|
||||
interface PredictiveAnalysis {
|
||||
nextMonthDemand: number
|
||||
resourceNeeds: ResourceForecast[]
|
||||
budgetProjection: number
|
||||
volunteerRequirement: number
|
||||
riskFactors: string[]
|
||||
opportunities: string[]
|
||||
}
|
||||
|
||||
interface ResourceForecast {
|
||||
category: string
|
||||
predictedDemand: number
|
||||
currentInventory: number
|
||||
recommendedPurchase: number
|
||||
urgencyLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
}
|
||||
|
||||
interface GeographicData {
|
||||
region: string
|
||||
studentsServed: number
|
||||
averageNeed: number
|
||||
responseTime: number
|
||||
efficiency: number
|
||||
coordinates: [number, number]
|
||||
}
|
||||
|
||||
const AdvancedAnalyticsDashboard: React.FC = () => {
|
||||
const [metrics, setMetrics] = useState<ImpactMetrics | null>(null)
|
||||
const [predictions, setPredictions] = useState<PredictiveAnalysis | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeographicData[]>([])
|
||||
const [selectedTimeframe, setSelectedTimeframe] = useState<'week' | 'month' | 'quarter' | 'year'>('month')
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadAnalyticsData()
|
||||
}, [selectedTimeframe])
|
||||
|
||||
const loadAnalyticsData = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
// Simulate loading comprehensive analytics
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
setMetrics({
|
||||
totalStudentsServed: 2847,
|
||||
totalResourcesAllocated: 15690,
|
||||
totalDonationsProcessed: 89234,
|
||||
averageResponseTime: 4.2,
|
||||
costEfficiencyRatio: 0.87,
|
||||
volunteerEngagement: 0.93,
|
||||
schoolPartnershipGrowth: 0.24,
|
||||
monthlyTrends: [
|
||||
{ month: 'Jan', studentsServed: 234, resourcesAllocated: 1250, donations: 7800, efficiency: 0.85 },
|
||||
{ month: 'Feb', studentsServed: 289, resourcesAllocated: 1420, donations: 8900, efficiency: 0.87 },
|
||||
{ month: 'Mar', studentsServed: 312, resourcesAllocated: 1580, donations: 9200, efficiency: 0.89 },
|
||||
{ month: 'Apr', studentsServed: 298, resourcesAllocated: 1490, donations: 8700, efficiency: 0.88 },
|
||||
{ month: 'May', studentsServed: 356, resourcesAllocated: 1780, donations: 10500, efficiency: 0.91 },
|
||||
{ month: 'Jun', studentsServed: 378, resourcesAllocated: 1890, donations: 11200, efficiency: 0.93 }
|
||||
]
|
||||
})
|
||||
|
||||
setPredictions({
|
||||
nextMonthDemand: 425,
|
||||
budgetProjection: 12800,
|
||||
volunteerRequirement: 67,
|
||||
resourceNeeds: [
|
||||
{ category: 'School Supplies', predictedDemand: 156, currentInventory: 89, recommendedPurchase: 75, urgencyLevel: 'medium' },
|
||||
{ category: 'Clothing', predictedDemand: 134, currentInventory: 45, recommendedPurchase: 95, urgencyLevel: 'high' },
|
||||
{ category: 'Food Assistance', predictedDemand: 89, currentInventory: 67, recommendedPurchase: 30, urgencyLevel: 'low' },
|
||||
{ category: 'Technology', predictedDemand: 46, currentInventory: 12, recommendedPurchase: 40, urgencyLevel: 'critical' }
|
||||
],
|
||||
riskFactors: [
|
||||
'Increased demand in back-to-school season',
|
||||
'Volunteer availability declining in summer',
|
||||
'Technology needs growing faster than budget'
|
||||
],
|
||||
opportunities: [
|
||||
'Partnership with local tech companies for device donations',
|
||||
'Summer clothing drive potential',
|
||||
'Grant opportunity for educational technology'
|
||||
]
|
||||
})
|
||||
|
||||
setGeoData([
|
||||
{ region: 'Downtown Schools', studentsServed: 156, averageNeed: 3.2, responseTime: 3.8, efficiency: 0.91, coordinates: [-122.4194, 37.7749] },
|
||||
{ region: 'Suburban East', studentsServed: 98, averageNeed: 2.8, responseTime: 4.5, efficiency: 0.85, coordinates: [-122.3894, 37.7849] },
|
||||
{ region: 'North District', studentsServed: 134, averageNeed: 3.6, responseTime: 4.1, efficiency: 0.88, coordinates: [-122.4094, 37.7949] },
|
||||
{ region: 'South Valley', studentsServed: 89, averageNeed: 2.9, responseTime: 5.2, efficiency: 0.82, coordinates: [-122.4294, 37.7649] }
|
||||
])
|
||||
} catch (error) {
|
||||
console.error('Error loading analytics:', error)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const getUrgencyColor = (urgency: string) => {
|
||||
switch (urgency) {
|
||||
case 'critical': return 'bg-red-500'
|
||||
case 'high': return 'bg-orange-500'
|
||||
case 'medium': return 'bg-yellow-500'
|
||||
case 'low': return 'bg-green-500'
|
||||
default: return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
minimumFractionDigits: 0
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
|
||||
className="w-12 h-12 border-4 border-blue-500 border-t-transparent rounded-full"
|
||||
/>
|
||||
<span className="ml-4 text-lg text-blue-700">Loading Advanced Analytics...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">Impact Analytics Dashboard</h1>
|
||||
<p className="text-lg text-gray-600">Comprehensive insights into our nonprofit's reach and effectiveness</p>
|
||||
|
||||
<div className="flex gap-4 mt-4">
|
||||
{(['week', 'month', 'quarter', 'year'] as const).map((timeframe) => (
|
||||
<button
|
||||
key={timeframe}
|
||||
onClick={() => setSelectedTimeframe(timeframe)}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
selectedTimeframe === timeframe
|
||||
? 'bg-blue-500 text-white shadow-lg'
|
||||
: 'bg-white text-gray-700 hover:bg-blue-50'
|
||||
}`}
|
||||
>
|
||||
{timeframe.charAt(0).toUpperCase() + timeframe.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Key Metrics Grid */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"
|
||||
>
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Students Served</h3>
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold">👥</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-blue-600 mb-2">{metrics?.totalStudentsServed.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-500">+12% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Resources Allocated</h3>
|
||||
<div className="w-10 h-10 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-green-600 font-bold">📦</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-green-600 mb-2">{metrics?.totalResourcesAllocated.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-500">+8% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Donations Processed</h3>
|
||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-purple-600 font-bold">💝</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-purple-600 mb-2">{formatCurrency(metrics?.totalDonationsProcessed || 0)}</div>
|
||||
<div className="text-sm text-gray-500">+15% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-700">Efficiency Ratio</h3>
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||
<span className="text-orange-600 font-bold">⚡</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-orange-600 mb-2">{formatPercentage(metrics?.costEfficiencyRatio || 0)}</div>
|
||||
<div className="text-sm text-gray-500">+3% from last {selectedTimeframe}</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Trends Chart */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg mb-8"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Monthly Impact Trends</h3>
|
||||
<div className="h-80 flex items-end justify-between gap-4">
|
||||
{metrics?.monthlyTrends.map((trend, index) => (
|
||||
<div key={trend.month} className="flex-1 flex flex-col items-center">
|
||||
<motion.div
|
||||
initial={{ height: 0 }}
|
||||
animate={{ height: `${(trend.studentsServed / 400) * 100}%` }}
|
||||
transition={{ delay: 0.6 + index * 0.1, duration: 0.8 }}
|
||||
className="bg-gradient-to-t from-blue-500 to-blue-300 rounded-t-lg w-full mb-2 min-h-[20px]"
|
||||
/>
|
||||
<div className="text-sm font-medium text-gray-700">{trend.month}</div>
|
||||
<div className="text-xs text-gray-500">{trend.studentsServed}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Predictive Analysis and Geographic Data */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||||
{/* Resource Forecasting */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Resource Demand Forecast</h3>
|
||||
<div className="space-y-4">
|
||||
{predictions?.resourceNeeds.map((resource) => (
|
||||
<div key={resource.category} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-700">{resource.category}</h4>
|
||||
<span className={`px-2 py-1 rounded text-xs text-white ${getUrgencyColor(resource.urgencyLevel)}`}>
|
||||
{resource.urgencyLevel.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Predicted Need:</span>
|
||||
<div className="font-medium">{resource.predictedDemand}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Current Stock:</span>
|
||||
<div className="font-medium">{resource.currentInventory}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Recommended:</span>
|
||||
<div className="font-medium text-blue-600">+{resource.recommendedPurchase}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Geographic Performance */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">Geographic Performance</h3>
|
||||
<div className="space-y-4">
|
||||
{geoData.map((region) => (
|
||||
<div key={region.region} className="border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-700">{region.region}</h4>
|
||||
<div className="text-sm text-gray-500">{formatPercentage(region.efficiency)} efficiency</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Students Served:</span>
|
||||
<div className="font-medium">{region.studentsServed}</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Response:</span>
|
||||
<div className="font-medium">{region.responseTime}h</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Avg Need Level:</span>
|
||||
<div className="font-medium">{region.averageNeed}/5</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Insights and Recommendations */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 1.0 }}
|
||||
className="bg-white rounded-xl p-6 shadow-lg"
|
||||
>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-6">AI-Generated Insights & Recommendations</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 className="font-medium text-red-600 mb-3">⚠️ Risk Factors</h4>
|
||||
<ul className="space-y-2">
|
||||
{predictions?.riskFactors.map((risk, index) => (
|
||||
<li key={index} className="text-sm text-gray-700 flex items-start">
|
||||
<span className="text-red-500 mr-2">•</span>
|
||||
{risk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-green-600 mb-3">💡 Opportunities</h4>
|
||||
<ul className="space-y-2">
|
||||
{predictions?.opportunities.map((opportunity, index) => (
|
||||
<li key={index} className="text-sm text-gray-700 flex items-start">
|
||||
<span className="text-green-500 mr-2">•</span>
|
||||
{opportunity}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AdvancedAnalyticsDashboard
|
||||
+63
-63
@@ -1,64 +1,64 @@
|
||||
import {
|
||||
Facebook,
|
||||
Globe,
|
||||
Instagram,
|
||||
} from 'lucide-react'
|
||||
import { LogoMark } from './ui/LogoMark'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="relative mt-24 border-t border-white/30 bg-white/50 backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 lg:grid-cols-4">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<LogoMark />
|
||||
<div>
|
||||
<div className="font-semibold">Miracles in Motion</div>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">Essentials for every student</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 max-w-md text-sm text-neutral-600 dark:text-neutral-400">
|
||||
A 501(c)(3) nonprofit providing students with school supplies, clothing, and emergency support to help them succeed.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-4">
|
||||
<a href="#" className="text-neutral-600 hover:text-primary-600 dark:text-neutral-400">
|
||||
<Facebook className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-neutral-600 hover:text-primary-600 dark:text-neutral-400">
|
||||
<Instagram className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-neutral-600 hover:text-primary-600 dark:text-neutral-400">
|
||||
<Globe className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Get Involved</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li><a href="#/donate" className="navlink">Donate</a></li>
|
||||
<li><a href="#/volunteers" className="navlink">Volunteer</a></li>
|
||||
<li><a href="#/sponsors" className="navlink">Corporate Partnerships</a></li>
|
||||
<li><a href="#/stories" className="navlink">Success Stories</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Organization</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li><a href="#/testimonies" className="navlink">Testimonials</a></li>
|
||||
<li><a href="#/legal" className="navlink">Legal & Policies</a></li>
|
||||
<li><a href="mailto:[email protected]" className="navlink">Contact Us</a></li>
|
||||
<li><a href="tel:+18184916884" className="navlink">(818) 491-6884</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-white/30 pt-8 text-center text-xs text-neutral-500 dark:border-white/10 dark:text-neutral-400">
|
||||
<p>© 2025 Miracles in Motion. All rights reserved. EIN: 88-1234567</p>
|
||||
<p className="mt-1">501(c)(3) nonprofit organization. Donations are tax-deductible to the extent allowed by law.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
import {
|
||||
Facebook,
|
||||
Globe,
|
||||
Instagram,
|
||||
} from 'lucide-react'
|
||||
import { LogoMark } from './ui/LogoMark'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="relative mt-24 border-t border-white/30 bg-white/50 backdrop-blur dark:border-white/10 dark:bg-white/5">
|
||||
<div className="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
|
||||
<div className="grid gap-8 lg:grid-cols-4">
|
||||
<div className="lg:col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<LogoMark />
|
||||
<div>
|
||||
<div className="font-semibold">Miracles in Motion</div>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">Essentials for every student</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 max-w-md text-sm text-neutral-600 dark:text-neutral-400">
|
||||
A 501(c)(3) nonprofit providing students with school supplies, clothing, and emergency support to help them succeed.
|
||||
</p>
|
||||
<div className="mt-4 flex gap-4">
|
||||
<a href="#" className="text-neutral-600 hover:text-primary-600 dark:text-neutral-400">
|
||||
<Facebook className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-neutral-600 hover:text-primary-600 dark:text-neutral-400">
|
||||
<Instagram className="h-5 w-5" />
|
||||
</a>
|
||||
<a href="#" className="text-neutral-600 hover:text-primary-600 dark:text-neutral-400">
|
||||
<Globe className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Get Involved</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li><a href="#/donate" className="navlink">Donate</a></li>
|
||||
<li><a href="#/volunteers" className="navlink">Volunteer</a></li>
|
||||
<li><a href="#/sponsors" className="navlink">Corporate Partnerships</a></li>
|
||||
<li><a href="#/stories" className="navlink">Success Stories</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Organization</h3>
|
||||
<ul className="mt-4 space-y-2 text-sm">
|
||||
<li><a href="#/testimonies" className="navlink">Testimonials</a></li>
|
||||
<li><a href="#/legal" className="navlink">Legal & Policies</a></li>
|
||||
<li><a href="mailto:[email protected]" className="navlink">Contact Us</a></li>
|
||||
<li><a href="tel:+18184916884" className="navlink">(818) 491-6884</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t border-white/30 pt-8 text-center text-xs text-neutral-500 dark:border-white/10 dark:text-neutral-400">
|
||||
<p>© 2025 Miracles in Motion. All rights reserved. EIN: 88-1234567</p>
|
||||
<p className="mt-1">501(c)(3) nonprofit organization. Donations are tax-deductible to the extent allowed by law.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
export default Footer
|
||||
File diff suppressed because it is too large
Load Diff
+153
-153
@@ -1,154 +1,154 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Heart,
|
||||
Menu,
|
||||
Moon,
|
||||
SunMedium,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Import UI components
|
||||
import { Magnetic, LogoMark } from './ui'
|
||||
|
||||
interface NavProps {
|
||||
darkMode: boolean
|
||||
setDarkMode: (value: boolean) => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMenuOpen }: NavProps) {
|
||||
// Close mobile menu when route changes
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [window.location.hash])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8" role="navigation" aria-label="Main navigation">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="#/"
|
||||
className="flex items-center gap-3 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-lg p-1"
|
||||
aria-label="Miracles in Motion - Home"
|
||||
>
|
||||
<LogoMark />
|
||||
<div className="-space-y-1">
|
||||
<div className="font-semibold tracking-tight">Miracles in Motion</div>
|
||||
<div className="text-xs text-neutral-600 dark:text-neutral-400">Essentials for every student</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/stories" aria-label="Read success stories">Stories</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/testimonies" aria-label="View testimonies">Testimonies</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/volunteers" aria-label="Volunteer opportunities">Volunteers</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/sponsors" aria-label="Corporate partnerships">Corporate</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/request-assistance" aria-label="Request assistance">Get Help</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/portals" aria-label="Portal login">Portals</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Magnetic>
|
||||
<a
|
||||
href="#/donate"
|
||||
className="btn-primary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
aria-label="Make a donation"
|
||||
>
|
||||
<Heart className="mr-2 h-4 w-4" aria-hidden="true" /> Donate
|
||||
</a>
|
||||
</Magnetic>
|
||||
<button
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
onKeyDown={(e) => handleKeyDown(e, () => setDarkMode(!darkMode))}
|
||||
className="group rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
{darkMode ? (
|
||||
<SunMedium className="h-5 w-5 transition group-hover:rotate-12" aria-hidden="true" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 transition group-hover:-rotate-12" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
<Magnetic>
|
||||
<a
|
||||
href="#/donate"
|
||||
className="btn-primary text-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
aria-label="Make a donation"
|
||||
>
|
||||
<Heart className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Magnetic>
|
||||
<button
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
{darkMode ? (
|
||||
<SunMedium className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={mobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
onKeyDown={(e) => handleKeyDown(e, () => setMobileMenuOpen(!mobileMenuOpen))}
|
||||
className="rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
id="mobile-menu"
|
||||
className="md:hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
height: mobileMenuOpen ? 'auto' : 0,
|
||||
opacity: mobileMenuOpen ? 1 : 0
|
||||
}}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="border-t border-neutral-200/50 bg-white/95 px-4 py-3 backdrop-blur dark:border-white/10 dark:bg-black/90">
|
||||
<div className="space-y-3">
|
||||
<a className="block py-2 text-sm font-medium" href="#/stories">Stories</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/testimonies">Testimonies</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/volunteers">Volunteers</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/sponsors">Corporate</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/request-assistance">Get Help</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/portals">Portals</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
import React, { useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import {
|
||||
Heart,
|
||||
Menu,
|
||||
Moon,
|
||||
SunMedium,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
|
||||
// Import UI components
|
||||
import { Magnetic, LogoMark } from './ui'
|
||||
|
||||
interface NavProps {
|
||||
darkMode: boolean
|
||||
setDarkMode: (value: boolean) => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (value: boolean) => void
|
||||
}
|
||||
|
||||
export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMenuOpen }: NavProps) {
|
||||
// Close mobile menu when route changes
|
||||
useEffect(() => {
|
||||
setMobileMenuOpen(false)
|
||||
}, [window.location.hash])
|
||||
|
||||
// Handle keyboard navigation
|
||||
const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
action()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<nav className="mx-auto flex w-full max-w-7xl items-center justify-between px-4 py-3 sm:px-6 lg:px-8" role="navigation" aria-label="Main navigation">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="#/"
|
||||
className="flex items-center gap-3 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded-lg p-1"
|
||||
aria-label="Miracles in Motion - Home"
|
||||
>
|
||||
<LogoMark />
|
||||
<div className="-space-y-1">
|
||||
<div className="font-semibold tracking-tight">Miracles in Motion</div>
|
||||
<div className="text-xs text-neutral-600 dark:text-neutral-400">Essentials for every student</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden items-center gap-6 md:flex">
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/stories" aria-label="Read success stories">Stories</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/testimonies" aria-label="View testimonies">Testimonies</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/volunteers" aria-label="Volunteer opportunities">Volunteers</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/sponsors" aria-label="Corporate partnerships">Corporate</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/request-assistance" aria-label="Request assistance">Get Help</a>
|
||||
<a className="navlink focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded" href="#/portals" aria-label="Portal login">Portals</a>
|
||||
</div>
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Magnetic>
|
||||
<a
|
||||
href="#/donate"
|
||||
className="btn-primary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
aria-label="Make a donation"
|
||||
>
|
||||
<Heart className="mr-2 h-4 w-4" aria-hidden="true" /> Donate
|
||||
</a>
|
||||
</Magnetic>
|
||||
<button
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
onKeyDown={(e) => handleKeyDown(e, () => setDarkMode(!darkMode))}
|
||||
className="group rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
{darkMode ? (
|
||||
<SunMedium className="h-5 w-5 transition group-hover:rotate-12" aria-hidden="true" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 transition group-hover:-rotate-12" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile Actions */}
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
<Magnetic>
|
||||
<a
|
||||
href="#/donate"
|
||||
className="btn-primary text-sm px-3 py-2 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
aria-label="Make a donation"
|
||||
>
|
||||
<Heart className="h-4 w-4" aria-hidden="true" />
|
||||
</a>
|
||||
</Magnetic>
|
||||
<button
|
||||
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
onClick={() => setDarkMode(!darkMode)}
|
||||
className="rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
>
|
||||
{darkMode ? (
|
||||
<SunMedium className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={mobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu'}
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
onKeyDown={(e) => handleKeyDown(e, () => setMobileMenuOpen(!mobileMenuOpen))}
|
||||
className="rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-controls="mobile-menu"
|
||||
>
|
||||
{mobileMenuOpen ? (
|
||||
<X className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Menu className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Mobile Menu */}
|
||||
<motion.div
|
||||
id="mobile-menu"
|
||||
className="md:hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
height: mobileMenuOpen ? 'auto' : 0,
|
||||
opacity: mobileMenuOpen ? 1 : 0
|
||||
}}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="border-t border-neutral-200/50 bg-white/95 px-4 py-3 backdrop-blur dark:border-white/10 dark:bg-black/90">
|
||||
<div className="space-y-3">
|
||||
<a className="block py-2 text-sm font-medium" href="#/stories">Stories</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/testimonies">Testimonies</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/volunteers">Volunteers</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/sponsors">Corporate</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/request-assistance">Get Help</a>
|
||||
<a className="block py-2 text-sm font-medium" href="#/portals">Portals</a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Navigation
|
||||
+104
-104
@@ -1,105 +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-818-491-6884",
|
||||
"contactType": "customer service",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": "US"
|
||||
},
|
||||
"nonprofitStatus": "501(c)(3)"
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
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-818-491-6884",
|
||||
"contactType": "customer service",
|
||||
"email": "[email protected]"
|
||||
},
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"addressCountry": "US"
|
||||
},
|
||||
"nonprofitStatus": "501(c)(3)"
|
||||
})}
|
||||
</script>
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
|
||||
export default SEOHead
|
||||
@@ -1,69 +1,69 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
interface SEOProps {
|
||||
title?: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
image?: string
|
||||
url?: string
|
||||
type?: 'website' | 'article' | 'organization'
|
||||
}
|
||||
|
||||
export const SEO: React.FC<SEOProps> = ({
|
||||
title = 'Miracles In Motion - Supporting Students in Need',
|
||||
description = 'A 501(c)3 non-profit providing school supplies, clothing, and emergency assistance to students and families in need.',
|
||||
keywords = ['nonprofit', 'charity', '501c3', 'student assistance', 'school supplies', 'donations'],
|
||||
image = 'https://miraclesinmotion.org/og-image.png',
|
||||
url = 'https://miraclesinmotion.org',
|
||||
type = 'website'
|
||||
}) => {
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Miracles In Motion",
|
||||
"description": description,
|
||||
"url": url,
|
||||
"logo": "https://miraclesinmotion.org/logo.png",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+1-555-123-4567",
|
||||
"contactType": "Customer Service"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://facebook.com/miraclesinmotion",
|
||||
"https://instagram.com/miraclesinmotion"
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords.join(', ')} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:type" content={type} />
|
||||
|
||||
{/* Twitter Card */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={image} />
|
||||
|
||||
{/* Structured Data */}
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(structuredData)}
|
||||
</script>
|
||||
|
||||
{/* Additional Meta Tags */}
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="Miracles In Motion" />
|
||||
<link rel="canonical" href={url} />
|
||||
</Helmet>
|
||||
)
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet-async'
|
||||
|
||||
interface SEOProps {
|
||||
title?: string
|
||||
description?: string
|
||||
keywords?: string[]
|
||||
image?: string
|
||||
url?: string
|
||||
type?: 'website' | 'article' | 'organization'
|
||||
}
|
||||
|
||||
export const SEO: React.FC<SEOProps> = ({
|
||||
title = 'Miracles In Motion - Supporting Students in Need',
|
||||
description = 'A 501(c)3 non-profit providing school supplies, clothing, and emergency assistance to students and families in need.',
|
||||
keywords = ['nonprofit', 'charity', '501c3', 'student assistance', 'school supplies', 'donations'],
|
||||
image = 'https://miraclesinmotion.org/og-image.png',
|
||||
url = 'https://miraclesinmotion.org',
|
||||
type = 'website'
|
||||
}) => {
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "Miracles In Motion",
|
||||
"description": description,
|
||||
"url": url,
|
||||
"logo": "https://miraclesinmotion.org/logo.png",
|
||||
"contactPoint": {
|
||||
"@type": "ContactPoint",
|
||||
"telephone": "+1-555-123-4567",
|
||||
"contactType": "Customer Service"
|
||||
},
|
||||
"sameAs": [
|
||||
"https://facebook.com/miraclesinmotion",
|
||||
"https://instagram.com/miraclesinmotion"
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<Helmet>
|
||||
<title>{title}</title>
|
||||
<meta name="description" content={description} />
|
||||
<meta name="keywords" content={keywords.join(', ')} />
|
||||
|
||||
{/* Open Graph */}
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={image} />
|
||||
<meta property="og:url" content={url} />
|
||||
<meta property="og:type" content={type} />
|
||||
|
||||
{/* Twitter Card */}
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={title} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={image} />
|
||||
|
||||
{/* Structured Data */}
|
||||
<script type="application/ld+json">
|
||||
{JSON.stringify(structuredData)}
|
||||
</script>
|
||||
|
||||
{/* Additional Meta Tags */}
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="Miracles In Motion" />
|
||||
<link rel="canonical" href={url} />
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,74 +1,74 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/dom'
|
||||
import '@testing-library/jest-dom'
|
||||
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: HTMLElement) =>
|
||||
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:[email protected]')
|
||||
expect(screen.getByRole('link', { name: '(818) 491-6884' })).toHaveAttribute('href', 'tel:+18184916884')
|
||||
})
|
||||
|
||||
it('renders copyright information', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText(/© 2025 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"
|
||||
})
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/dom'
|
||||
import '@testing-library/jest-dom'
|
||||
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: HTMLElement) =>
|
||||
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:[email protected]')
|
||||
expect(screen.getByRole('link', { name: '(818) 491-6884' })).toHaveAttribute('href', 'tel:+18184916884')
|
||||
})
|
||||
|
||||
it('renders copyright information', () => {
|
||||
render(<Footer />)
|
||||
|
||||
expect(screen.getByText(/© 2025 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"
|
||||
})
|
||||
})
|
||||
@@ -1,87 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { screen, fireEvent } from '@testing-library/dom'
|
||||
import '@testing-library/jest-dom'
|
||||
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} />)
|
||||
|
||||
const storiesLinks = screen.getAllByText('Stories')
|
||||
expect(storiesLinks).toHaveLength(2) // One in desktop nav, one in mobile nav
|
||||
expect(screen.getByText('Testimonies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Volunteers')).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)
|
||||
})
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { screen, fireEvent } from '@testing-library/dom'
|
||||
import '@testing-library/jest-dom'
|
||||
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} />)
|
||||
|
||||
const storiesLinks = screen.getAllByText('Stories')
|
||||
expect(storiesLinks).toHaveLength(2) // One in desktop nav, one in mobile nav
|
||||
expect(screen.getByText('Testimonies')).toBeInTheDocument()
|
||||
expect(screen.getByText('Volunteers')).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)
|
||||
})
|
||||
})
|
||||
@@ -1,283 +1,283 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe,
|
||||
useElements
|
||||
} from '@stripe/react-stripe-js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import {
|
||||
CreditCard,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Heart,
|
||||
Users,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
|
||||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || '')
|
||||
|
||||
interface PaymentFormProps {
|
||||
amount: number
|
||||
isRecurring?: boolean
|
||||
onSuccess?: (paymentIntent: any) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
|
||||
|
||||
function PaymentForm({ amount, isRecurring = false, onSuccess, onError }: PaymentFormProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'processing' | 'succeeded' | 'failed'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
const formatter = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
return formatter.format(value / 100) // Stripe uses cents
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setPaymentStatus('processing')
|
||||
setErrorMessage('')
|
||||
|
||||
const cardElement = elements.getElement(CardElement)
|
||||
if (!cardElement) return
|
||||
|
||||
try {
|
||||
// Create payment intent
|
||||
const response = await fetch('/api/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
recurring: isRecurring,
|
||||
customer: customerInfo
|
||||
})
|
||||
})
|
||||
|
||||
const { client_secret } = await response.json()
|
||||
|
||||
// Confirm payment
|
||||
const { error, paymentIntent } = await stripe.confirmCardPayment(client_secret, {
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
billing_details: {
|
||||
name: customerInfo.name,
|
||||
email: customerInfo.email
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || t('donate.error'))
|
||||
setPaymentStatus('failed')
|
||||
onError?.(error.message || t('donate.error'))
|
||||
} else {
|
||||
setPaymentStatus('succeeded')
|
||||
onSuccess?.(paymentIntent)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message || t('donate.error'))
|
||||
setPaymentStatus('failed')
|
||||
onError?.(error.message)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const cardElementOptions = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4'
|
||||
}
|
||||
}
|
||||
},
|
||||
hidePostalCode: false
|
||||
}
|
||||
|
||||
if (paymentStatus === 'succeeded') {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('donate.success')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your donation of {formatCurrency(amount)} will help students in need.
|
||||
</p>
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-center gap-2 text-purple-600 mb-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<span className="font-semibold">Impact Preview</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
You've just provided school supplies for {Math.floor(amount / 2500)} students!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Customer Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Donor Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={customerInfo.name}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Payment Information
|
||||
</h3>
|
||||
|
||||
<div className="p-4 border border-gray-300 rounded-lg">
|
||||
<CardElement options={cardElementOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Donation Summary */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{isRecurring ? 'Monthly' : 'One-time'} Donation
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-purple-600">
|
||||
{formatCurrency(amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>Secure payment powered by Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-red-700 text-sm">{errorMessage}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || isLoading || paymentStatus === 'processing'}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{t('donate.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart className="w-5 h-5" />
|
||||
Donate {formatCurrency(amount)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function StripePaymentForm(props: PaymentFormProps) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentForm {...props} />
|
||||
</Elements>
|
||||
)
|
||||
}
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
Elements,
|
||||
CardElement,
|
||||
useStripe,
|
||||
useElements
|
||||
} from '@stripe/react-stripe-js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import {
|
||||
CreditCard,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
Heart,
|
||||
Users,
|
||||
Sparkles
|
||||
} from 'lucide-react'
|
||||
|
||||
// Initialize Stripe
|
||||
const stripePromise = loadStripe(process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || '')
|
||||
|
||||
interface PaymentFormProps {
|
||||
amount: number
|
||||
isRecurring?: boolean
|
||||
onSuccess?: (paymentIntent: any) => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
|
||||
|
||||
|
||||
function PaymentForm({ amount, isRecurring = false, onSuccess, onError }: PaymentFormProps) {
|
||||
const { t, i18n } = useTranslation()
|
||||
const stripe = useStripe()
|
||||
const elements = useElements()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [paymentStatus, setPaymentStatus] = useState<'idle' | 'processing' | 'succeeded' | 'failed'>('idle')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: ''
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
const formatter = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
})
|
||||
return formatter.format(value / 100) // Stripe uses cents
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setPaymentStatus('processing')
|
||||
setErrorMessage('')
|
||||
|
||||
const cardElement = elements.getElement(CardElement)
|
||||
if (!cardElement) return
|
||||
|
||||
try {
|
||||
// Create payment intent
|
||||
const response = await fetch('/api/create-payment-intent', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amount,
|
||||
currency: 'usd',
|
||||
recurring: isRecurring,
|
||||
customer: customerInfo
|
||||
})
|
||||
})
|
||||
|
||||
const { client_secret } = await response.json()
|
||||
|
||||
// Confirm payment
|
||||
const { error, paymentIntent } = await stripe.confirmCardPayment(client_secret, {
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
billing_details: {
|
||||
name: customerInfo.name,
|
||||
email: customerInfo.email
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (error) {
|
||||
setErrorMessage(error.message || t('donate.error'))
|
||||
setPaymentStatus('failed')
|
||||
onError?.(error.message || t('donate.error'))
|
||||
} else {
|
||||
setPaymentStatus('succeeded')
|
||||
onSuccess?.(paymentIntent)
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMessage(error.message || t('donate.error'))
|
||||
setPaymentStatus('failed')
|
||||
onError?.(error.message)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const cardElementOptions = {
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#424770',
|
||||
'::placeholder': {
|
||||
color: '#aab7c4'
|
||||
}
|
||||
}
|
||||
},
|
||||
hidePostalCode: false
|
||||
}
|
||||
|
||||
if (paymentStatus === 'succeeded') {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="text-center py-12"
|
||||
>
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle2 className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
{t('donate.success')}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Your donation of {formatCurrency(amount)} will help students in need.
|
||||
</p>
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-center gap-2 text-purple-600 mb-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<span className="font-semibold">Impact Preview</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">
|
||||
You've just provided school supplies for {Math.floor(amount / 2500)} students!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Customer Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Donor Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Full Name <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={customerInfo.name}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="John Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Email Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={customerInfo.email}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="[email protected]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Phone Number (Optional)
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={customerInfo.phone}
|
||||
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500"
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Payment Information
|
||||
</h3>
|
||||
|
||||
<div className="p-4 border border-gray-300 rounded-lg">
|
||||
<CardElement options={cardElementOptions} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Donation Summary */}
|
||||
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{isRecurring ? 'Monthly' : 'One-time'} Donation
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-purple-600">
|
||||
{formatCurrency(amount)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<Lock className="w-4 h-4" />
|
||||
<span>Secure payment powered by Stripe</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" />
|
||||
<p className="text-red-700 text-sm">{errorMessage}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!stripe || isLoading || paymentStatus === 'processing'}
|
||||
className="w-full bg-gradient-to-r from-purple-600 to-blue-600 text-white py-4 px-6 rounded-lg font-semibold text-lg hover:from-purple-700 hover:to-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{t('donate.processing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Heart className="w-5 h-5" />
|
||||
Donate {formatCurrency(amount)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export function StripePaymentForm(props: PaymentFormProps) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<PaymentForm {...props} />
|
||||
</Elements>
|
||||
)
|
||||
}
|
||||
|
||||
export default StripePaymentForm
|
||||
@@ -1,101 +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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,150 +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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,111 +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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,64 +1,64 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/dom'
|
||||
import '@testing-library/jest-dom'
|
||||
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 heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Miracles in Motion')
|
||||
})
|
||||
|
||||
it('includes accessibility features', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const buttons = screen.getAllByRole('link')
|
||||
buttons.forEach((button: HTMLElement) => {
|
||||
expect(button).toHaveClass(/btn-/)
|
||||
})
|
||||
})
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { screen } from '@testing-library/dom'
|
||||
import '@testing-library/jest-dom'
|
||||
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 heading = screen.getByRole('heading', { level: 1 })
|
||||
expect(heading).toBeInTheDocument()
|
||||
expect(heading).toHaveTextContent('Miracles in Motion')
|
||||
})
|
||||
|
||||
it('includes accessibility features', () => {
|
||||
render(<HeroSection />)
|
||||
|
||||
const buttons = screen.getAllByRole('link')
|
||||
buttons.forEach((button: HTMLElement) => {
|
||||
expect(button).toHaveClass(/btn-/)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,9 +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'
|
||||
// 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'
|
||||
+22
-22
@@ -1,23 +1,23 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface CardProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Card({ title, icon: Icon, children }: CardProps) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-primary-500 to-secondary-600 text-white shadow">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="font-semibold tracking-tight">{title}</div>
|
||||
</div>
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface CardProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Card({ title, icon: Icon, children }: CardProps) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-primary-500 to-secondary-600 text-white shadow">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="font-semibold tracking-tight">{title}</div>
|
||||
</div>
|
||||
<div className="mt-3">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Card
|
||||
@@ -1,226 +1,226 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Globe, ChevronDown } from 'lucide-react'
|
||||
import { languages } from '@/i18n/config'
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string
|
||||
variant?: 'default' | 'minimal' | 'mobile'
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({
|
||||
className = '',
|
||||
variant = 'default'
|
||||
}: LanguageSwitcherProps) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const currentLang = i18n.language || 'en'
|
||||
const currentLanguage = languages[currentLang as keyof typeof languages]
|
||||
|
||||
const handleLanguageChange = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode)
|
||||
setIsOpen(false)
|
||||
|
||||
// Update document direction for RTL languages
|
||||
const langConfig = languages[langCode as keyof typeof languages]
|
||||
document.documentElement.dir = langConfig.dir
|
||||
document.documentElement.lang = langCode
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('i18nextLng', langCode)
|
||||
}
|
||||
|
||||
const dropdownVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: 'easeOut'
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: 'easeIn'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label={t('accessibility.toggleLanguage')}
|
||||
>
|
||||
<span className="text-lg">{currentLanguage?.flag || '🌐'}</span>
|
||||
<span className="text-sm font-medium">{currentLang.toUpperCase()}</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="absolute top-full right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 min-w-[140px] z-50"
|
||||
>
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-gray-50 transition-colors flex items-center gap-2 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{lang.flag}</span>
|
||||
<span className="text-sm">{lang.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'mobile') {
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-gray-500" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Language / Idioma
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{currentLanguage?.flag} {currentLanguage?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="mt-2 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{lang.flag}</span>
|
||||
<div>
|
||||
<div className="font-medium">{lang.name}</div>
|
||||
<div className="text-sm text-gray-500">{code.toUpperCase()}</div>
|
||||
</div>
|
||||
{code === currentLang && (
|
||||
<div className="ml-auto w-2 h-2 bg-purple-600 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-gray-200 hover:border-purple-300 transition-colors shadow-sm"
|
||||
aria-label={t('accessibility.toggleLanguage')}
|
||||
>
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-lg">{currentLanguage?.flag || '🌐'}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{currentLanguage?.name || 'English'}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="absolute top-full right-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 py-2 min-w-[200px] z-50"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Select Language
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors flex items-center gap-3 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{lang.flag}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{lang.name}</div>
|
||||
<div className="text-xs text-gray-500">{code.toUpperCase()}</div>
|
||||
</div>
|
||||
{code === currentLang && (
|
||||
<div className="w-2 h-2 bg-purple-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Globe, ChevronDown } from 'lucide-react'
|
||||
import { languages } from '@/i18n/config'
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
className?: string
|
||||
variant?: 'default' | 'minimal' | 'mobile'
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({
|
||||
className = '',
|
||||
variant = 'default'
|
||||
}: LanguageSwitcherProps) {
|
||||
const { i18n, t } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const currentLang = i18n.language || 'en'
|
||||
const currentLanguage = languages[currentLang as keyof typeof languages]
|
||||
|
||||
const handleLanguageChange = (langCode: string) => {
|
||||
i18n.changeLanguage(langCode)
|
||||
setIsOpen(false)
|
||||
|
||||
// Update document direction for RTL languages
|
||||
const langConfig = languages[langCode as keyof typeof languages]
|
||||
document.documentElement.dir = langConfig.dir
|
||||
document.documentElement.lang = langCode
|
||||
|
||||
// Store preference
|
||||
localStorage.setItem('i18nextLng', langCode)
|
||||
}
|
||||
|
||||
const dropdownVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
y: 0,
|
||||
transition: {
|
||||
duration: 0.2,
|
||||
ease: 'easeOut'
|
||||
}
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: -10,
|
||||
transition: {
|
||||
duration: 0.15,
|
||||
ease: 'easeIn'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (variant === 'minimal') {
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1 p-2 rounded-lg hover:bg-gray-100 transition-colors"
|
||||
aria-label={t('accessibility.toggleLanguage')}
|
||||
>
|
||||
<span className="text-lg">{currentLanguage?.flag || '🌐'}</span>
|
||||
<span className="text-sm font-medium">{currentLang.toUpperCase()}</span>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="absolute top-full right-0 mt-1 bg-white rounded-lg shadow-lg border border-gray-200 py-1 min-w-[140px] z-50"
|
||||
>
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-gray-50 transition-colors flex items-center gap-2 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{lang.flag}</span>
|
||||
<span className="text-sm">{lang.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (variant === 'mobile') {
|
||||
return (
|
||||
<div className={`w-full ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200 hover:border-purple-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="w-5 h-5 text-gray-500" />
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
Language / Idioma
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{currentLanguage?.flag} {currentLanguage?.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="mt-2 bg-white rounded-lg shadow-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-4 py-3 text-left hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-b-0 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xl">{lang.flag}</span>
|
||||
<div>
|
||||
<div className="font-medium">{lang.name}</div>
|
||||
<div className="text-sm text-gray-500">{code.toUpperCase()}</div>
|
||||
</div>
|
||||
{code === currentLang && (
|
||||
<div className="ml-auto w-2 h-2 bg-purple-600 rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default variant
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-white rounded-lg border border-gray-200 hover:border-purple-300 transition-colors shadow-sm"
|
||||
aria-label={t('accessibility.toggleLanguage')}
|
||||
>
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-lg">{currentLanguage?.flag || '🌐'}</span>
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{currentLanguage?.name || 'English'}
|
||||
</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-500 transition-transform ${
|
||||
isOpen ? 'rotate-180' : ''
|
||||
}`} />
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Dropdown */}
|
||||
<motion.div
|
||||
variants={dropdownVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="absolute top-full right-0 mt-2 bg-white rounded-lg shadow-xl border border-gray-200 py-2 min-w-[200px] z-50"
|
||||
>
|
||||
<div className="px-3 py-2 border-b border-gray-100">
|
||||
<div className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Select Language
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{Object.entries(languages).map(([code, lang]) => (
|
||||
<button
|
||||
key={code}
|
||||
onClick={() => handleLanguageChange(code)}
|
||||
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors flex items-center gap-3 ${
|
||||
code === currentLang ? 'bg-purple-50 text-purple-600' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">{lang.flag}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{lang.name}</div>
|
||||
<div className="text-xs text-gray-500">{code.toUpperCase()}</div>
|
||||
</div>
|
||||
{code === currentLang && (
|
||||
<div className="w-2 h-2 bg-purple-600 rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LanguageSwitcher
|
||||
+135
-135
@@ -1,136 +1,136 @@
|
||||
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 [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) {
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
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 [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) {
|
||||
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
|
||||
@@ -1,24 +1,24 @@
|
||||
// LogoMark component
|
||||
import { motion } from 'framer-motion'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
export function LogoMark() {
|
||||
return (
|
||||
<div className="relative grid h-10 w-10 place-items-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary-500 via-secondary-500 to-secondary-600 shadow-lg shadow-primary-500/20">
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-60"
|
||||
animate={{
|
||||
background: [
|
||||
"radial-gradient(120px 80px at 20% 20%, rgba(255,255,255,0.4), transparent)",
|
||||
"radial-gradient(120px 80px at 80% 30%, rgba(255,255,255,0.4), transparent)",
|
||||
"radial-gradient(120px 80px at 50% 80%, rgba(255,255,255,0.4), transparent)",
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<Sparkles className="relative h-6 w-6 text-white drop-shadow" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// LogoMark component
|
||||
import { motion } from 'framer-motion'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
export function LogoMark() {
|
||||
return (
|
||||
<div className="relative grid h-10 w-10 place-items-center overflow-hidden rounded-2xl bg-gradient-to-br from-primary-500 via-secondary-500 to-secondary-600 shadow-lg shadow-primary-500/20">
|
||||
<motion.div
|
||||
className="absolute inset-0 opacity-60"
|
||||
animate={{
|
||||
background: [
|
||||
"radial-gradient(120px 80px at 20% 20%, rgba(255,255,255,0.4), transparent)",
|
||||
"radial-gradient(120px 80px at 80% 30%, rgba(255,255,255,0.4), transparent)",
|
||||
"radial-gradient(120px 80px at 50% 80%, rgba(255,255,255,0.4), transparent)",
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 6, repeat: Infinity, ease: "easeInOut" }}
|
||||
/>
|
||||
<Sparkles className="relative h-6 w-6 text-white drop-shadow" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogoMark
|
||||
@@ -1,15 +1,15 @@
|
||||
import React from 'react'
|
||||
|
||||
interface MagneticProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Magnetic({ children }: MagneticProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
import React from 'react'
|
||||
|
||||
interface MagneticProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Magnetic({ children }: MagneticProps) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Magnetic
|
||||
@@ -1,211 +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>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1,17 +1,17 @@
|
||||
interface SectionHeaderProps {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export function SectionHeader({ eyebrow, title, subtitle }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="section-header">
|
||||
{eyebrow && <div className="section-eyebrow">{eyebrow}</div>}
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{subtitle && <p className="section-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
export function SectionHeader({ eyebrow, title, subtitle }: SectionHeaderProps) {
|
||||
return (
|
||||
<div className="section-header">
|
||||
{eyebrow && <div className="section-eyebrow">{eyebrow}</div>}
|
||||
<h2 className="section-title">{title}</h2>
|
||||
{subtitle && <p className="section-subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SectionHeader
|
||||
+10
-10
@@ -1,11 +1,11 @@
|
||||
// UI Component Exports
|
||||
export { LogoMark } from './LogoMark'
|
||||
export { Magnetic } from './Magnetic'
|
||||
export { SectionHeader } from './SectionHeader'
|
||||
export { Card } from './Card'
|
||||
|
||||
// Re-export everything
|
||||
export * from './LogoMark'
|
||||
export * from './Magnetic'
|
||||
export * from './SectionHeader'
|
||||
// UI Component Exports
|
||||
export { LogoMark } from './LogoMark'
|
||||
export { Magnetic } from './Magnetic'
|
||||
export { SectionHeader } from './SectionHeader'
|
||||
export { Card } from './Card'
|
||||
|
||||
// Re-export everything
|
||||
export * from './LogoMark'
|
||||
export * from './Magnetic'
|
||||
export * from './SectionHeader'
|
||||
export * from './Card'
|
||||
+102
-102
@@ -1,103 +1,103 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react'
|
||||
|
||||
// Types
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
role: 'admin' | 'volunteer' | 'resource'
|
||||
name: string
|
||||
lastLogin: Date
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: AuthUser | null
|
||||
login: (email: string, password: string) => Promise<boolean>
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
// Auth Provider Component
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const login = async (email: string, password: string): Promise<boolean> => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Mock authentication - replace with real API call
|
||||
console.log('🔐 Attempting login for:', email)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Mock user data based on email
|
||||
const mockUsers: Record<string, AuthUser> = {
|
||||
'[email protected]': {
|
||||
id: '1',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
name: 'Admin User',
|
||||
lastLogin: new Date(),
|
||||
permissions: ['all']
|
||||
},
|
||||
'[email protected]': {
|
||||
id: '2',
|
||||
email: '[email protected]',
|
||||
role: 'volunteer',
|
||||
name: 'Volunteer User',
|
||||
lastLogin: new Date(),
|
||||
permissions: ['view_requests', 'update_assignments']
|
||||
}
|
||||
}
|
||||
|
||||
const authenticatedUser = mockUsers[email]
|
||||
if (authenticatedUser && password === 'demo123') {
|
||||
setUser(authenticatedUser)
|
||||
localStorage.setItem('authToken', `token-${authenticatedUser.id}`)
|
||||
console.log('✅ Login successful')
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ Login failed')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return false
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = (): void => {
|
||||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
console.log('👋 User logged out')
|
||||
}
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
isLoading
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom hook for using auth context
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react'
|
||||
|
||||
// Types
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
role: 'admin' | 'volunteer' | 'resource'
|
||||
name: string
|
||||
lastLogin: Date
|
||||
permissions: string[]
|
||||
}
|
||||
|
||||
export interface AuthContextType {
|
||||
user: AuthUser | null
|
||||
login: (email: string, password: string) => Promise<boolean>
|
||||
logout: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const AuthContext = createContext<AuthContextType | null>(null)
|
||||
|
||||
// Auth Provider Component
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const login = async (email: string, password: string): Promise<boolean> => {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
// Mock authentication - replace with real API call
|
||||
console.log('🔐 Attempting login for:', email)
|
||||
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
// Mock user data based on email
|
||||
const mockUsers: Record<string, AuthUser> = {
|
||||
'[email protected]': {
|
||||
id: '1',
|
||||
email: '[email protected]',
|
||||
role: 'admin',
|
||||
name: 'Admin User',
|
||||
lastLogin: new Date(),
|
||||
permissions: ['all']
|
||||
},
|
||||
'[email protected]': {
|
||||
id: '2',
|
||||
email: '[email protected]',
|
||||
role: 'volunteer',
|
||||
name: 'Volunteer User',
|
||||
lastLogin: new Date(),
|
||||
permissions: ['view_requests', 'update_assignments']
|
||||
}
|
||||
}
|
||||
|
||||
const authenticatedUser = mockUsers[email]
|
||||
if (authenticatedUser && password === 'demo123') {
|
||||
setUser(authenticatedUser)
|
||||
localStorage.setItem('authToken', `token-${authenticatedUser.id}`)
|
||||
console.log('✅ Login successful')
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ Login failed')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
return false
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const logout = (): void => {
|
||||
setUser(null)
|
||||
localStorage.removeItem('authToken')
|
||||
console.log('👋 User logged out')
|
||||
}
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
isLoading
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom hook for using auth context
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
@@ -1,85 +1,85 @@
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react'
|
||||
|
||||
// Types
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: 'success' | 'info' | 'warning' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
timestamp: Date
|
||||
read: boolean
|
||||
actions?: { label: string; action: () => void }[]
|
||||
}
|
||||
|
||||
export interface NotificationContextType {
|
||||
notifications: Notification[]
|
||||
addNotification: (notif: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void
|
||||
markAsRead: (id: string) => void
|
||||
removeNotification: (id: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const NotificationContext = createContext<NotificationContextType | null>(null)
|
||||
|
||||
// Notification Provider Component
|
||||
export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
|
||||
const addNotification = (notif: Omit<Notification, 'id' | 'timestamp' | 'read'>): void => {
|
||||
const newNotification: Notification = {
|
||||
...notif,
|
||||
id: Math.random().toString(36),
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
}
|
||||
|
||||
setNotifications(prev => [newNotification, ...prev])
|
||||
|
||||
// Auto-remove success notifications after 5 seconds
|
||||
if (notif.type === 'success') {
|
||||
setTimeout(() => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== newNotification.id))
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const markAsRead = (id: string): void => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notif =>
|
||||
notif.id === id ? { ...notif, read: true } : notif
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const removeNotification = (id: string): void => {
|
||||
setNotifications(prev => prev.filter(notif => notif.id !== id))
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
setNotifications([])
|
||||
}
|
||||
|
||||
const value: NotificationContextType = {
|
||||
notifications,
|
||||
addNotification,
|
||||
markAsRead,
|
||||
removeNotification,
|
||||
clearAll
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom hook for using notification context
|
||||
export const useNotifications = (): NotificationContextType => {
|
||||
const context = useContext(NotificationContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotifications must be used within a NotificationProvider')
|
||||
}
|
||||
return context
|
||||
import React, { createContext, useContext, useState, ReactNode } from 'react'
|
||||
|
||||
// Types
|
||||
export interface Notification {
|
||||
id: string
|
||||
type: 'success' | 'info' | 'warning' | 'error'
|
||||
title: string
|
||||
message: string
|
||||
timestamp: Date
|
||||
read: boolean
|
||||
actions?: { label: string; action: () => void }[]
|
||||
}
|
||||
|
||||
export interface NotificationContextType {
|
||||
notifications: Notification[]
|
||||
addNotification: (notif: Omit<Notification, 'id' | 'timestamp' | 'read'>) => void
|
||||
markAsRead: (id: string) => void
|
||||
removeNotification: (id: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
// Create Context
|
||||
const NotificationContext = createContext<NotificationContextType | null>(null)
|
||||
|
||||
// Notification Provider Component
|
||||
export const NotificationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
|
||||
const addNotification = (notif: Omit<Notification, 'id' | 'timestamp' | 'read'>): void => {
|
||||
const newNotification: Notification = {
|
||||
...notif,
|
||||
id: Math.random().toString(36),
|
||||
timestamp: new Date(),
|
||||
read: false
|
||||
}
|
||||
|
||||
setNotifications(prev => [newNotification, ...prev])
|
||||
|
||||
// Auto-remove success notifications after 5 seconds
|
||||
if (notif.type === 'success') {
|
||||
setTimeout(() => {
|
||||
setNotifications(prev => prev.filter(n => n.id !== newNotification.id))
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const markAsRead = (id: string): void => {
|
||||
setNotifications(prev =>
|
||||
prev.map(notif =>
|
||||
notif.id === id ? { ...notif, read: true } : notif
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const removeNotification = (id: string): void => {
|
||||
setNotifications(prev => prev.filter(notif => notif.id !== id))
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
setNotifications([])
|
||||
}
|
||||
|
||||
const value: NotificationContextType = {
|
||||
notifications,
|
||||
addNotification,
|
||||
markAsRead,
|
||||
removeNotification,
|
||||
clearAll
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Custom hook for using notification context
|
||||
export const useNotifications = (): NotificationContextType => {
|
||||
const context = useContext(NotificationContext)
|
||||
if (!context) {
|
||||
throw new Error('useNotifications must be used within a NotificationProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
+314
-314
@@ -1,315 +1,315 @@
|
||||
// Phase 3B: Browser-Compatible CRM Integration (Mock Implementation)
|
||||
import type { StudentRequest, MatchResult } from '../ai/types'
|
||||
|
||||
// Note: In a real implementation, this would connect to Salesforce via REST API
|
||||
// For browser demo, we use mock implementations
|
||||
|
||||
export interface SalesforceConfig {
|
||||
instanceUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
username: string
|
||||
password: string
|
||||
securityToken: string
|
||||
apiVersion: string
|
||||
}
|
||||
|
||||
export interface SalesforceContact {
|
||||
Id: string
|
||||
Name: string
|
||||
Email: string
|
||||
Phone: string
|
||||
Account: {
|
||||
Id: string
|
||||
Name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SalesforceCase {
|
||||
Id: string
|
||||
Subject: string
|
||||
Description: string
|
||||
Status: 'New' | 'In Progress' | 'Closed' | 'Escalated'
|
||||
Priority: 'Low' | 'Medium' | 'High' | 'Critical'
|
||||
ContactId: string
|
||||
CaseNumber: string
|
||||
CreatedDate: string
|
||||
LastModifiedDate: string
|
||||
}
|
||||
|
||||
export interface NPSPAllocation {
|
||||
Id: string
|
||||
Amount: number
|
||||
GAU__c: string // General Accounting Unit
|
||||
Opportunity__c: string
|
||||
Percent: number
|
||||
}
|
||||
|
||||
class SalesforceConnector {
|
||||
private config: SalesforceConfig
|
||||
private accessToken: string | null = null
|
||||
private instanceUrl: string = ''
|
||||
|
||||
constructor(config: SalesforceConfig) {
|
||||
this.config = config
|
||||
this.instanceUrl = config.instanceUrl
|
||||
}
|
||||
|
||||
// Authentication with Salesforce
|
||||
async authenticate(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.instanceUrl}/services/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
username: this.config.username,
|
||||
password: this.config.password + this.config.securityToken
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Authentication failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
this.accessToken = data.access_token
|
||||
this.instanceUrl = data.instance_url
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Salesforce authentication error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Create assistance request case in Salesforce
|
||||
async createAssistanceCase(request: StudentRequest): Promise<string | null> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
const caseData = {
|
||||
Subject: `Student Assistance Request - ${request.category}`,
|
||||
Description: this.formatRequestDescription(request),
|
||||
Status: 'New',
|
||||
Priority: this.determinePriority(request),
|
||||
Origin: 'AI Portal',
|
||||
Type: 'Student Assistance',
|
||||
// Custom fields for nonprofit
|
||||
Student_Name__c: request.studentName,
|
||||
Student_ID__c: request.studentId,
|
||||
Need_Category__c: request.category,
|
||||
Urgency_Level__c: request.urgency,
|
||||
Location_City__c: request.location.city,
|
||||
Location_State__c: request.location.state,
|
||||
Location_Zip__c: request.location.zipCode
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.instanceUrl}/services/data/v${this.config.apiVersion}/sobjects/Case`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(caseData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create case: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.id
|
||||
} catch (error) {
|
||||
console.error('Error creating Salesforce case:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Update case with AI matching results
|
||||
async updateCaseWithMatching(caseId: string, matchResult: MatchResult): Promise<boolean> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
AI_Match_Confidence__c: matchResult.confidenceScore,
|
||||
Recommended_Resource_Id__c: matchResult.resourceId,
|
||||
Recommended_Resource_Name__c: matchResult.resourceName,
|
||||
Resource_Type__c: matchResult.resourceType,
|
||||
Estimated_Impact__c: matchResult.estimatedImpact,
|
||||
Estimated_Cost__c: matchResult.estimatedCost,
|
||||
Fulfillment_Timeline__c: matchResult.fulfillmentTimeline,
|
||||
Last_AI_Update__c: new Date().toISOString()
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.instanceUrl}/services/data/v${this.config.apiVersion}/sobjects/Case/${caseId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
})
|
||||
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error('Error updating Salesforce case:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Get nonprofit contacts (volunteers, donors, partners)
|
||||
async getContacts(recordType?: string): Promise<SalesforceContact[]> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
let query = `SELECT Id, Name, Email, Phone, Account.Id, Account.Name FROM Contact`
|
||||
|
||||
if (recordType) {
|
||||
query += ` WHERE RecordType.Name = '${recordType}'`
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.instanceUrl}/services/data/v${this.config.apiVersion}/query?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch contacts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.records
|
||||
} catch (error) {
|
||||
console.error('Error fetching Salesforce contacts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Create NPSP allocation for resource tracking
|
||||
async createResourceAllocation(opportunityId: string, amount: number, gauId: string): Promise<string | null> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
const allocationData = {
|
||||
Amount__c: amount,
|
||||
GAU__c: gauId,
|
||||
Opportunity__c: opportunityId,
|
||||
Percent__c: 100
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.instanceUrl}/services/data/v${this.config.apiVersion}/sobjects/Allocation__c`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(allocationData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create allocation: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.id
|
||||
} catch (error) {
|
||||
console.error('Error creating resource allocation:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get donation opportunities for matching
|
||||
async getDonationOpportunities(category?: string): Promise<any[]> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
let query = `SELECT Id, Name, Amount, StageName, CloseDate, Account.Name
|
||||
FROM Opportunity
|
||||
WHERE StageName IN ('Pledged', 'Posted')
|
||||
AND CloseDate >= TODAY`
|
||||
|
||||
if (category) {
|
||||
query += ` AND Category__c = '${category}'`
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.instanceUrl}/services/data/v${this.config.apiVersion}/query?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch opportunities: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.records
|
||||
} catch (error) {
|
||||
console.error('Error fetching donation opportunities:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private formatRequestDescription(request: StudentRequest): string {
|
||||
return `
|
||||
AI-Generated Student Assistance Request
|
||||
|
||||
Student Information:
|
||||
- Name: ${request.studentName}
|
||||
- ID: ${request.studentId}
|
||||
- Location: ${request.location.city}, ${request.location.state} ${request.location.zipCode}
|
||||
|
||||
Request Details:
|
||||
- Category: ${request.category}
|
||||
- Urgency: ${request.urgency}
|
||||
- Description: ${request.description}
|
||||
|
||||
Additional Information:
|
||||
- Estimated Cost: $${request.estimatedCost || 0}
|
||||
- Required Skills: ${request.requiredSkills?.join(', ') || 'None specified'}
|
||||
- Deadline: ${request.deadline ? new Date(request.deadline).toLocaleDateString() : 'Not specified'}
|
||||
|
||||
Submission Details:
|
||||
- Submitted: ${new Date(request.submittedAt).toLocaleString()}
|
||||
- Request ID: ${request.id}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
private determinePriority(request: StudentRequest): 'Low' | 'Medium' | 'High' | 'Critical' {
|
||||
switch (request.urgency) {
|
||||
case 'emergency':
|
||||
return 'Critical'
|
||||
case 'high':
|
||||
return 'High'
|
||||
case 'medium':
|
||||
return 'Medium'
|
||||
case 'low':
|
||||
default:
|
||||
return 'Low'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3B: Browser-Compatible CRM Integration (Mock Implementation)
|
||||
import type { StudentRequest, MatchResult } from '../ai/types'
|
||||
|
||||
// Note: In a real implementation, this would connect to Salesforce via REST API
|
||||
// For browser demo, we use mock implementations
|
||||
|
||||
export interface SalesforceConfig {
|
||||
instanceUrl: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
username: string
|
||||
password: string
|
||||
securityToken: string
|
||||
apiVersion: string
|
||||
}
|
||||
|
||||
export interface SalesforceContact {
|
||||
Id: string
|
||||
Name: string
|
||||
Email: string
|
||||
Phone: string
|
||||
Account: {
|
||||
Id: string
|
||||
Name: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface SalesforceCase {
|
||||
Id: string
|
||||
Subject: string
|
||||
Description: string
|
||||
Status: 'New' | 'In Progress' | 'Closed' | 'Escalated'
|
||||
Priority: 'Low' | 'Medium' | 'High' | 'Critical'
|
||||
ContactId: string
|
||||
CaseNumber: string
|
||||
CreatedDate: string
|
||||
LastModifiedDate: string
|
||||
}
|
||||
|
||||
export interface NPSPAllocation {
|
||||
Id: string
|
||||
Amount: number
|
||||
GAU__c: string // General Accounting Unit
|
||||
Opportunity__c: string
|
||||
Percent: number
|
||||
}
|
||||
|
||||
class SalesforceConnector {
|
||||
private config: SalesforceConfig
|
||||
private accessToken: string | null = null
|
||||
private instanceUrl: string = ''
|
||||
|
||||
constructor(config: SalesforceConfig) {
|
||||
this.config = config
|
||||
this.instanceUrl = config.instanceUrl
|
||||
}
|
||||
|
||||
// Authentication with Salesforce
|
||||
async authenticate(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${this.config.instanceUrl}/services/oauth2/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'password',
|
||||
client_id: this.config.clientId,
|
||||
client_secret: this.config.clientSecret,
|
||||
username: this.config.username,
|
||||
password: this.config.password + this.config.securityToken
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Authentication failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
this.accessToken = data.access_token
|
||||
this.instanceUrl = data.instance_url
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Salesforce authentication error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Create assistance request case in Salesforce
|
||||
async createAssistanceCase(request: StudentRequest): Promise<string | null> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
const caseData = {
|
||||
Subject: `Student Assistance Request - ${request.category}`,
|
||||
Description: this.formatRequestDescription(request),
|
||||
Status: 'New',
|
||||
Priority: this.determinePriority(request),
|
||||
Origin: 'AI Portal',
|
||||
Type: 'Student Assistance',
|
||||
// Custom fields for nonprofit
|
||||
Student_Name__c: request.studentName,
|
||||
Student_ID__c: request.studentId,
|
||||
Need_Category__c: request.category,
|
||||
Urgency_Level__c: request.urgency,
|
||||
Location_City__c: request.location.city,
|
||||
Location_State__c: request.location.state,
|
||||
Location_Zip__c: request.location.zipCode
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.instanceUrl}/services/data/v${this.config.apiVersion}/sobjects/Case`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(caseData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create case: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.id
|
||||
} catch (error) {
|
||||
console.error('Error creating Salesforce case:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Update case with AI matching results
|
||||
async updateCaseWithMatching(caseId: string, matchResult: MatchResult): Promise<boolean> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = {
|
||||
AI_Match_Confidence__c: matchResult.confidenceScore,
|
||||
Recommended_Resource_Id__c: matchResult.resourceId,
|
||||
Recommended_Resource_Name__c: matchResult.resourceName,
|
||||
Resource_Type__c: matchResult.resourceType,
|
||||
Estimated_Impact__c: matchResult.estimatedImpact,
|
||||
Estimated_Cost__c: matchResult.estimatedCost,
|
||||
Fulfillment_Timeline__c: matchResult.fulfillmentTimeline,
|
||||
Last_AI_Update__c: new Date().toISOString()
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.instanceUrl}/services/data/v${this.config.apiVersion}/sobjects/Case/${caseId}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(updateData)
|
||||
})
|
||||
|
||||
return response.ok
|
||||
} catch (error) {
|
||||
console.error('Error updating Salesforce case:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Get nonprofit contacts (volunteers, donors, partners)
|
||||
async getContacts(recordType?: string): Promise<SalesforceContact[]> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
let query = `SELECT Id, Name, Email, Phone, Account.Id, Account.Name FROM Contact`
|
||||
|
||||
if (recordType) {
|
||||
query += ` WHERE RecordType.Name = '${recordType}'`
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.instanceUrl}/services/data/v${this.config.apiVersion}/query?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch contacts: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.records
|
||||
} catch (error) {
|
||||
console.error('Error fetching Salesforce contacts:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Create NPSP allocation for resource tracking
|
||||
async createResourceAllocation(opportunityId: string, amount: number, gauId: string): Promise<string | null> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
const allocationData = {
|
||||
Amount__c: amount,
|
||||
GAU__c: gauId,
|
||||
Opportunity__c: opportunityId,
|
||||
Percent__c: 100
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.instanceUrl}/services/data/v${this.config.apiVersion}/sobjects/Allocation__c`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(allocationData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create allocation: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return result.id
|
||||
} catch (error) {
|
||||
console.error('Error creating resource allocation:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Get donation opportunities for matching
|
||||
async getDonationOpportunities(category?: string): Promise<any[]> {
|
||||
if (!this.accessToken) {
|
||||
await this.authenticate()
|
||||
}
|
||||
|
||||
try {
|
||||
let query = `SELECT Id, Name, Amount, StageName, CloseDate, Account.Name
|
||||
FROM Opportunity
|
||||
WHERE StageName IN ('Pledged', 'Posted')
|
||||
AND CloseDate >= TODAY`
|
||||
|
||||
if (category) {
|
||||
query += ` AND Category__c = '${category}'`
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${this.instanceUrl}/services/data/v${this.config.apiVersion}/query?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch opportunities: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.records
|
||||
} catch (error) {
|
||||
console.error('Error fetching donation opportunities:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private formatRequestDescription(request: StudentRequest): string {
|
||||
return `
|
||||
AI-Generated Student Assistance Request
|
||||
|
||||
Student Information:
|
||||
- Name: ${request.studentName}
|
||||
- ID: ${request.studentId}
|
||||
- Location: ${request.location.city}, ${request.location.state} ${request.location.zipCode}
|
||||
|
||||
Request Details:
|
||||
- Category: ${request.category}
|
||||
- Urgency: ${request.urgency}
|
||||
- Description: ${request.description}
|
||||
|
||||
Additional Information:
|
||||
- Estimated Cost: $${request.estimatedCost || 0}
|
||||
- Required Skills: ${request.requiredSkills?.join(', ') || 'None specified'}
|
||||
- Deadline: ${request.deadline ? new Date(request.deadline).toLocaleDateString() : 'Not specified'}
|
||||
|
||||
Submission Details:
|
||||
- Submitted: ${new Date(request.submittedAt).toLocaleString()}
|
||||
- Request ID: ${request.id}
|
||||
`.trim()
|
||||
}
|
||||
|
||||
private determinePriority(request: StudentRequest): 'Low' | 'Medium' | 'High' | 'Critical' {
|
||||
switch (request.urgency) {
|
||||
case 'emergency':
|
||||
return 'Critical'
|
||||
case 'high':
|
||||
return 'High'
|
||||
case 'medium':
|
||||
return 'Medium'
|
||||
case 'low':
|
||||
default:
|
||||
return 'Low'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { SalesforceConnector }
|
||||
+83
-83
@@ -1,84 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Hash-based routing hook
|
||||
export const useHashRoute = () => {
|
||||
const parseRoute = (): string => window.location.hash?.slice(1) || "/"
|
||||
|
||||
const [route, setRoute] = useState<string>(parseRoute())
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = (): void => setRoute(parseRoute())
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
|
||||
const navigate = (newRoute: string): void => {
|
||||
window.location.hash = newRoute
|
||||
}
|
||||
|
||||
return { route, navigate }
|
||||
}
|
||||
|
||||
// Local storage hook with TypeScript support
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)): void => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
|
||||
// Media query hook
|
||||
export const useMediaQuery = (query: string): boolean => {
|
||||
const [matches, setMatches] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query)
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches)
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent): void => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
media.addEventListener('change', listener)
|
||||
return () => media.removeEventListener('change', listener)
|
||||
}, [matches, query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// Debounce hook
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Hash-based routing hook
|
||||
export const useHashRoute = () => {
|
||||
const parseRoute = (): string => window.location.hash?.slice(1) || "/"
|
||||
|
||||
const [route, setRoute] = useState<string>(parseRoute())
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = (): void => setRoute(parseRoute())
|
||||
|
||||
window.addEventListener("hashchange", handleHashChange)
|
||||
return () => window.removeEventListener("hashchange", handleHashChange)
|
||||
}, [])
|
||||
|
||||
const navigate = (newRoute: string): void => {
|
||||
window.location.hash = newRoute
|
||||
}
|
||||
|
||||
return { route, navigate }
|
||||
}
|
||||
|
||||
// Local storage hook with TypeScript support
|
||||
export const useLocalStorage = <T>(key: string, initialValue: T) => {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : initialValue
|
||||
} catch (error) {
|
||||
console.warn(`Error reading localStorage key "${key}":`, error)
|
||||
return initialValue
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)): void => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value
|
||||
setStoredValue(valueToStore)
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore))
|
||||
} catch (error) {
|
||||
console.warn(`Error setting localStorage key "${key}":`, error)
|
||||
}
|
||||
}
|
||||
|
||||
return [storedValue, setValue] as const
|
||||
}
|
||||
|
||||
// Media query hook
|
||||
export const useMediaQuery = (query: string): boolean => {
|
||||
const [matches, setMatches] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia(query)
|
||||
if (media.matches !== matches) {
|
||||
setMatches(media.matches)
|
||||
}
|
||||
|
||||
const listener = (event: MediaQueryListEvent): void => {
|
||||
setMatches(event.matches)
|
||||
}
|
||||
|
||||
media.addEventListener('change', listener)
|
||||
return () => media.removeEventListener('change', listener)
|
||||
}, [matches, query])
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
// Debounce hook
|
||||
export const useDebounce = <T>(value: T, delay: number): T => {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
+107
-107
@@ -1,108 +1,108 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Types
|
||||
export interface ImpactCalculation {
|
||||
students: number
|
||||
families: number
|
||||
backpacks: number
|
||||
clothing: number
|
||||
emergency: number
|
||||
annual: {
|
||||
students: number
|
||||
families: number
|
||||
totalImpact: string
|
||||
}
|
||||
}
|
||||
|
||||
// Custom hook for donation impact calculations
|
||||
export const useDonationImpact = (amount: number): ImpactCalculation => {
|
||||
const [impact, setImpact] = useState<ImpactCalculation>({
|
||||
students: 0,
|
||||
families: 0,
|
||||
backpacks: 0,
|
||||
clothing: 0,
|
||||
emergency: 0,
|
||||
annual: {
|
||||
students: 0,
|
||||
families: 0,
|
||||
totalImpact: '0 students supported annually'
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const calculateImpact = (donationAmount: number): ImpactCalculation => {
|
||||
const students = Math.floor(donationAmount / 25) // $25 per student for basic supplies
|
||||
const families = Math.floor(donationAmount / 50) // $50 per family for comprehensive support
|
||||
const backpacks = Math.floor(donationAmount / 30) // $30 for complete backpack kit
|
||||
const clothing = Math.floor(donationAmount / 45) // $45 for clothing items
|
||||
const emergency = Math.floor(donationAmount / 75) // $75 for emergency assistance
|
||||
|
||||
return {
|
||||
students,
|
||||
families,
|
||||
backpacks,
|
||||
clothing,
|
||||
emergency,
|
||||
annual: {
|
||||
students: Math.floor((donationAmount * 12) / 25),
|
||||
families: Math.floor((donationAmount * 12) / 50),
|
||||
totalImpact: `${Math.floor((donationAmount * 12) / 25)} students supported annually`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setImpact(calculateImpact(amount))
|
||||
}, [amount])
|
||||
|
||||
return impact
|
||||
}
|
||||
|
||||
// Hook for form validation
|
||||
export const useFormValidation = <T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
validationRules: Record<keyof T, (value: any) => string | null>
|
||||
) => {
|
||||
const [values, setValues] = useState<T>(initialValues)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
|
||||
|
||||
const validate = (fieldName?: keyof T): boolean => {
|
||||
const fieldsToValidate = fieldName ? [fieldName] : Object.keys(validationRules) as (keyof T)[]
|
||||
const newErrors: Partial<Record<keyof T, string>> = { ...errors }
|
||||
let isValid = true
|
||||
|
||||
fieldsToValidate.forEach((field) => {
|
||||
const error = validationRules[field](values[field])
|
||||
if (error) {
|
||||
newErrors[field] = error
|
||||
isValid = false
|
||||
} else {
|
||||
delete newErrors[field]
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return isValid
|
||||
}
|
||||
|
||||
const setValue = (fieldName: keyof T, value: any): void => {
|
||||
setValues(prev => ({ ...prev, [fieldName]: value }))
|
||||
setTouched(prev => ({ ...prev, [fieldName]: true }))
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
setValues(initialValues)
|
||||
setErrors({})
|
||||
setTouched({})
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
setValue,
|
||||
validate,
|
||||
resetForm,
|
||||
isValid: Object.keys(errors).length === 0
|
||||
}
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
// Types
|
||||
export interface ImpactCalculation {
|
||||
students: number
|
||||
families: number
|
||||
backpacks: number
|
||||
clothing: number
|
||||
emergency: number
|
||||
annual: {
|
||||
students: number
|
||||
families: number
|
||||
totalImpact: string
|
||||
}
|
||||
}
|
||||
|
||||
// Custom hook for donation impact calculations
|
||||
export const useDonationImpact = (amount: number): ImpactCalculation => {
|
||||
const [impact, setImpact] = useState<ImpactCalculation>({
|
||||
students: 0,
|
||||
families: 0,
|
||||
backpacks: 0,
|
||||
clothing: 0,
|
||||
emergency: 0,
|
||||
annual: {
|
||||
students: 0,
|
||||
families: 0,
|
||||
totalImpact: '0 students supported annually'
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const calculateImpact = (donationAmount: number): ImpactCalculation => {
|
||||
const students = Math.floor(donationAmount / 25) // $25 per student for basic supplies
|
||||
const families = Math.floor(donationAmount / 50) // $50 per family for comprehensive support
|
||||
const backpacks = Math.floor(donationAmount / 30) // $30 for complete backpack kit
|
||||
const clothing = Math.floor(donationAmount / 45) // $45 for clothing items
|
||||
const emergency = Math.floor(donationAmount / 75) // $75 for emergency assistance
|
||||
|
||||
return {
|
||||
students,
|
||||
families,
|
||||
backpacks,
|
||||
clothing,
|
||||
emergency,
|
||||
annual: {
|
||||
students: Math.floor((donationAmount * 12) / 25),
|
||||
families: Math.floor((donationAmount * 12) / 50),
|
||||
totalImpact: `${Math.floor((donationAmount * 12) / 25)} students supported annually`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setImpact(calculateImpact(amount))
|
||||
}, [amount])
|
||||
|
||||
return impact
|
||||
}
|
||||
|
||||
// Hook for form validation
|
||||
export const useFormValidation = <T extends Record<string, any>>(
|
||||
initialValues: T,
|
||||
validationRules: Record<keyof T, (value: any) => string | null>
|
||||
) => {
|
||||
const [values, setValues] = useState<T>(initialValues)
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({})
|
||||
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({})
|
||||
|
||||
const validate = (fieldName?: keyof T): boolean => {
|
||||
const fieldsToValidate = fieldName ? [fieldName] : Object.keys(validationRules) as (keyof T)[]
|
||||
const newErrors: Partial<Record<keyof T, string>> = { ...errors }
|
||||
let isValid = true
|
||||
|
||||
fieldsToValidate.forEach((field) => {
|
||||
const error = validationRules[field](values[field])
|
||||
if (error) {
|
||||
newErrors[field] = error
|
||||
isValid = false
|
||||
} else {
|
||||
delete newErrors[field]
|
||||
}
|
||||
})
|
||||
|
||||
setErrors(newErrors)
|
||||
return isValid
|
||||
}
|
||||
|
||||
const setValue = (fieldName: keyof T, value: any): void => {
|
||||
setValues(prev => ({ ...prev, [fieldName]: value }))
|
||||
setTouched(prev => ({ ...prev, [fieldName]: true }))
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
setValues(initialValues)
|
||||
setErrors({})
|
||||
setTouched({})
|
||||
}
|
||||
|
||||
return {
|
||||
values,
|
||||
errors,
|
||||
touched,
|
||||
setValue,
|
||||
validate,
|
||||
resetForm,
|
||||
isValid: Object.keys(errors).length === 0
|
||||
}
|
||||
}
|
||||
+224
-224
@@ -1,225 +1,225 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface PerformanceMetrics {
|
||||
FCP: number | null // First Contentful Paint
|
||||
LCP: number | null // Largest Contentful Paint
|
||||
FID: number | null // First Input Delay
|
||||
CLS: number | null // Cumulative Layout Shift
|
||||
TTFB: number | null // Time to First Byte
|
||||
}
|
||||
|
||||
interface WebVitals {
|
||||
metrics: PerformanceMetrics
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Performance monitoring hook
|
||||
export function usePerformance(): WebVitals {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
||||
FCP: null,
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: null,
|
||||
TTFB: null
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const updateMetric = useCallback((name: keyof PerformanceMetrics, value: number) => {
|
||||
setMetrics(prev => ({ ...prev, [name]: value }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Web Vitals API is available
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Measure TTFB from Navigation Timing API
|
||||
const measureTTFB = () => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
||||
if (navigation) {
|
||||
const ttfb = navigation.responseStart - navigation.requestStart
|
||||
updateMetric('TTFB', ttfb)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FCP using PerformanceObserver
|
||||
const measureFCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByName('first-contentful-paint')
|
||||
if (entries.length > 0) {
|
||||
updateMetric('FCP', entries[0].startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure LCP using PerformanceObserver
|
||||
const measureLCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1]
|
||||
updateMetric('LCP', lastEntry.startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('LCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FID using PerformanceObserver
|
||||
const measureFID = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.name === 'first-input') {
|
||||
const fid = entry.processingStart - entry.startTime
|
||||
updateMetric('FID', fid)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['first-input'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FID measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure CLS using PerformanceObserver
|
||||
const measureCLS = () => {
|
||||
try {
|
||||
let clsValue = 0
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value
|
||||
updateMetric('CLS', clsValue)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['layout-shift'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('CLS measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize measurements
|
||||
measureTTFB()
|
||||
const cleanupFCP = measureFCP()
|
||||
const cleanupLCP = measureLCP()
|
||||
const cleanupFID = measureFID()
|
||||
const cleanupCLS = measureCLS()
|
||||
|
||||
// Set loading to false after initial measurements
|
||||
const timeout = setTimeout(() => setIsLoading(false), 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
cleanupFCP()
|
||||
cleanupLCP()
|
||||
cleanupFID()
|
||||
cleanupCLS()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { metrics, isLoading }
|
||||
}
|
||||
|
||||
// Hook for monitoring bundle size and loading performance
|
||||
export function useBundlePerformance() {
|
||||
const [bundleMetrics, setBundleMetrics] = useState({
|
||||
totalSize: 0,
|
||||
jsSize: 0,
|
||||
cssSize: 0,
|
||||
imageSize: 0,
|
||||
loadTime: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByType('resource')
|
||||
let jsSize = 0
|
||||
let cssSize = 0
|
||||
let imageSize = 0
|
||||
let totalSize = 0
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
const size = entry.encodedBodySize || entry.transferSize || 0
|
||||
totalSize += size
|
||||
|
||||
if (entry.name.includes('.js')) jsSize += size
|
||||
else if (entry.name.includes('.css')) cssSize += size
|
||||
else if (entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) imageSize += size
|
||||
})
|
||||
|
||||
setBundleMetrics({
|
||||
totalSize: Math.round(totalSize / 1024), // KB
|
||||
jsSize: Math.round(jsSize / 1024),
|
||||
cssSize: Math.round(cssSize / 1024),
|
||||
imageSize: Math.round(imageSize / 1024),
|
||||
loadTime: performance.now()
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe({ entryTypes: ['resource'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return bundleMetrics
|
||||
}
|
||||
|
||||
// Analytics tracking for performance metrics
|
||||
export function trackPerformanceMetrics(metrics: PerformanceMetrics) {
|
||||
if (typeof window === 'undefined' || !(window as any).gtag) return
|
||||
|
||||
const { FCP, LCP, FID, CLS, TTFB } = metrics
|
||||
|
||||
// Send to Google Analytics
|
||||
if (FCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FCP',
|
||||
value: Math.round(FCP)
|
||||
})
|
||||
|
||||
if (LCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'LCP',
|
||||
value: Math.round(LCP)
|
||||
})
|
||||
|
||||
if (FID) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FID',
|
||||
value: Math.round(FID)
|
||||
})
|
||||
|
||||
if (CLS) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'CLS',
|
||||
value: Math.round(CLS * 1000) // Convert to ms
|
||||
})
|
||||
|
||||
if (TTFB) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'TTFB',
|
||||
value: Math.round(TTFB)
|
||||
})
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group('Performance Metrics:')
|
||||
console.table(metrics)
|
||||
console.groupEnd()
|
||||
}
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
|
||||
interface PerformanceMetrics {
|
||||
FCP: number | null // First Contentful Paint
|
||||
LCP: number | null // Largest Contentful Paint
|
||||
FID: number | null // First Input Delay
|
||||
CLS: number | null // Cumulative Layout Shift
|
||||
TTFB: number | null // Time to First Byte
|
||||
}
|
||||
|
||||
interface WebVitals {
|
||||
metrics: PerformanceMetrics
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// Performance monitoring hook
|
||||
export function usePerformance(): WebVitals {
|
||||
const [metrics, setMetrics] = useState<PerformanceMetrics>({
|
||||
FCP: null,
|
||||
LCP: null,
|
||||
FID: null,
|
||||
CLS: null,
|
||||
TTFB: null
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const updateMetric = useCallback((name: keyof PerformanceMetrics, value: number) => {
|
||||
setMetrics(prev => ({ ...prev, [name]: value }))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Web Vitals API is available
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// Measure TTFB from Navigation Timing API
|
||||
const measureTTFB = () => {
|
||||
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming
|
||||
if (navigation) {
|
||||
const ttfb = navigation.responseStart - navigation.requestStart
|
||||
updateMetric('TTFB', ttfb)
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FCP using PerformanceObserver
|
||||
const measureFCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByName('first-contentful-paint')
|
||||
if (entries.length > 0) {
|
||||
updateMetric('FCP', entries[0].startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure LCP using PerformanceObserver
|
||||
const measureLCP = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
if (entries.length > 0) {
|
||||
const lastEntry = entries[entries.length - 1]
|
||||
updateMetric('LCP', lastEntry.startTime)
|
||||
}
|
||||
})
|
||||
observer.observe({ entryTypes: ['largest-contentful-paint'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('LCP measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure FID using PerformanceObserver
|
||||
const measureFID = () => {
|
||||
try {
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.name === 'first-input') {
|
||||
const fid = entry.processingStart - entry.startTime
|
||||
updateMetric('FID', fid)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['first-input'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('FID measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Measure CLS using PerformanceObserver
|
||||
const measureCLS = () => {
|
||||
try {
|
||||
let clsValue = 0
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntries()
|
||||
entries.forEach((entry: any) => {
|
||||
if (!entry.hadRecentInput) {
|
||||
clsValue += entry.value
|
||||
updateMetric('CLS', clsValue)
|
||||
}
|
||||
})
|
||||
})
|
||||
observer.observe({ entryTypes: ['layout-shift'] })
|
||||
return () => observer.disconnect()
|
||||
} catch (error) {
|
||||
console.warn('CLS measurement not supported')
|
||||
return () => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize measurements
|
||||
measureTTFB()
|
||||
const cleanupFCP = measureFCP()
|
||||
const cleanupLCP = measureLCP()
|
||||
const cleanupFID = measureFID()
|
||||
const cleanupCLS = measureCLS()
|
||||
|
||||
// Set loading to false after initial measurements
|
||||
const timeout = setTimeout(() => setIsLoading(false), 2000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout)
|
||||
cleanupFCP()
|
||||
cleanupLCP()
|
||||
cleanupFID()
|
||||
cleanupCLS()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { metrics, isLoading }
|
||||
}
|
||||
|
||||
// Hook for monitoring bundle size and loading performance
|
||||
export function useBundlePerformance() {
|
||||
const [bundleMetrics, setBundleMetrics] = useState({
|
||||
totalSize: 0,
|
||||
jsSize: 0,
|
||||
cssSize: 0,
|
||||
imageSize: 0,
|
||||
loadTime: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByType('resource')
|
||||
let jsSize = 0
|
||||
let cssSize = 0
|
||||
let imageSize = 0
|
||||
let totalSize = 0
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
const size = entry.encodedBodySize || entry.transferSize || 0
|
||||
totalSize += size
|
||||
|
||||
if (entry.name.includes('.js')) jsSize += size
|
||||
else if (entry.name.includes('.css')) cssSize += size
|
||||
else if (entry.name.match(/\.(png|jpg|jpeg|gif|svg|webp)$/)) imageSize += size
|
||||
})
|
||||
|
||||
setBundleMetrics({
|
||||
totalSize: Math.round(totalSize / 1024), // KB
|
||||
jsSize: Math.round(jsSize / 1024),
|
||||
cssSize: Math.round(cssSize / 1024),
|
||||
imageSize: Math.round(imageSize / 1024),
|
||||
loadTime: performance.now()
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe({ entryTypes: ['resource'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return bundleMetrics
|
||||
}
|
||||
|
||||
// Analytics tracking for performance metrics
|
||||
export function trackPerformanceMetrics(metrics: PerformanceMetrics) {
|
||||
if (typeof window === 'undefined' || !(window as any).gtag) return
|
||||
|
||||
const { FCP, LCP, FID, CLS, TTFB } = metrics
|
||||
|
||||
// Send to Google Analytics
|
||||
if (FCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FCP',
|
||||
value: Math.round(FCP)
|
||||
})
|
||||
|
||||
if (LCP) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'LCP',
|
||||
value: Math.round(LCP)
|
||||
})
|
||||
|
||||
if (FID) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'FID',
|
||||
value: Math.round(FID)
|
||||
})
|
||||
|
||||
if (CLS) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'CLS',
|
||||
value: Math.round(CLS * 1000) // Convert to ms
|
||||
})
|
||||
|
||||
if (TTFB) (window as any).gtag('event', 'timing_complete', {
|
||||
name: 'TTFB',
|
||||
value: Math.round(TTFB)
|
||||
})
|
||||
|
||||
// Log to console in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.group('Performance Metrics:')
|
||||
console.table(metrics)
|
||||
console.groupEnd()
|
||||
}
|
||||
}
|
||||
+80
-80
@@ -1,81 +1,81 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import Backend from 'i18next-http-backend'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
|
||||
// Import translation files
|
||||
import enTranslations from './locales/en.json'
|
||||
import esTranslations from './locales/es.json'
|
||||
import frTranslations from './locales/fr.json'
|
||||
import deTranslations from './locales/de.json'
|
||||
import zhTranslations from './locales/zh.json'
|
||||
import arTranslations from './locales/ar.json'
|
||||
import ptTranslations from './locales/pt.json'
|
||||
import ruTranslations from './locales/ru.json'
|
||||
|
||||
// Language configuration
|
||||
export const languages = {
|
||||
en: { name: 'English', flag: '🇺🇸', dir: 'ltr' },
|
||||
es: { name: 'Español', flag: '🇪🇸', dir: 'ltr' },
|
||||
fr: { name: 'Français', flag: '🇫🇷', dir: 'ltr' },
|
||||
de: { name: 'Deutsch', flag: '🇩🇪', dir: 'ltr' },
|
||||
zh: { name: '中文', flag: '🇨🇳', dir: 'ltr' },
|
||||
ar: { name: 'العربية', flag: '🇸🇦', dir: 'rtl' },
|
||||
pt: { name: 'Português', flag: '🇧🇷', dir: 'ltr' },
|
||||
ru: { name: 'Русский', flag: '🇷🇺', dir: 'ltr' }
|
||||
}
|
||||
|
||||
// Resources object
|
||||
const resources = {
|
||||
en: { translation: enTranslations },
|
||||
es: { translation: esTranslations },
|
||||
fr: { translation: frTranslations },
|
||||
de: { translation: deTranslations },
|
||||
zh: { translation: zhTranslations },
|
||||
ar: { translation: arTranslations },
|
||||
pt: { translation: ptTranslations },
|
||||
ru: { translation: ruTranslations }
|
||||
}
|
||||
|
||||
// Initialize i18n
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'i18nextLng'
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false // React already does escaping
|
||||
},
|
||||
|
||||
resources,
|
||||
|
||||
// Namespace configuration
|
||||
defaultNS: 'translation',
|
||||
ns: ['translation'],
|
||||
|
||||
// React options
|
||||
react: {
|
||||
useSuspense: false,
|
||||
bindI18n: 'languageChanged',
|
||||
bindI18nStore: '',
|
||||
transEmptyNodeValue: '',
|
||||
transSupportBasicHtmlNodes: true,
|
||||
transKeepBasicHtmlNodesFor: ['br', 'strong', 'i', 'em', 'span']
|
||||
},
|
||||
|
||||
// Backend options for loading translations
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json'
|
||||
}
|
||||
})
|
||||
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import Backend from 'i18next-http-backend'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
|
||||
// Import translation files
|
||||
import enTranslations from './locales/en.json'
|
||||
import esTranslations from './locales/es.json'
|
||||
import frTranslations from './locales/fr.json'
|
||||
import deTranslations from './locales/de.json'
|
||||
import zhTranslations from './locales/zh.json'
|
||||
import arTranslations from './locales/ar.json'
|
||||
import ptTranslations from './locales/pt.json'
|
||||
import ruTranslations from './locales/ru.json'
|
||||
|
||||
// Language configuration
|
||||
export const languages = {
|
||||
en: { name: 'English', flag: '🇺🇸', dir: 'ltr' },
|
||||
es: { name: 'Español', flag: '🇪🇸', dir: 'ltr' },
|
||||
fr: { name: 'Français', flag: '🇫🇷', dir: 'ltr' },
|
||||
de: { name: 'Deutsch', flag: '🇩🇪', dir: 'ltr' },
|
||||
zh: { name: '中文', flag: '🇨🇳', dir: 'ltr' },
|
||||
ar: { name: 'العربية', flag: '🇸🇦', dir: 'rtl' },
|
||||
pt: { name: 'Português', flag: '🇧🇷', dir: 'ltr' },
|
||||
ru: { name: 'Русский', flag: '🇷🇺', dir: 'ltr' }
|
||||
}
|
||||
|
||||
// Resources object
|
||||
const resources = {
|
||||
en: { translation: enTranslations },
|
||||
es: { translation: esTranslations },
|
||||
fr: { translation: frTranslations },
|
||||
de: { translation: deTranslations },
|
||||
zh: { translation: zhTranslations },
|
||||
ar: { translation: arTranslations },
|
||||
pt: { translation: ptTranslations },
|
||||
ru: { translation: ruTranslations }
|
||||
}
|
||||
|
||||
// Initialize i18n
|
||||
i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
caches: ['localStorage'],
|
||||
lookupLocalStorage: 'i18nextLng'
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false // React already does escaping
|
||||
},
|
||||
|
||||
resources,
|
||||
|
||||
// Namespace configuration
|
||||
defaultNS: 'translation',
|
||||
ns: ['translation'],
|
||||
|
||||
// React options
|
||||
react: {
|
||||
useSuspense: false,
|
||||
bindI18n: 'languageChanged',
|
||||
bindI18nStore: '',
|
||||
transEmptyNodeValue: '',
|
||||
transSupportBasicHtmlNodes: true,
|
||||
transKeepBasicHtmlNodesFor: ['br', 'strong', 'i', 'em', 'span']
|
||||
},
|
||||
|
||||
// Backend options for loading translations
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json'
|
||||
}
|
||||
})
|
||||
|
||||
export default i18n
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "الرئيسية",
|
||||
"about": "حولنا",
|
||||
"programs": "البرامج",
|
||||
"donate": "تبرع",
|
||||
"volunteer": "متطوع",
|
||||
"contact": "اتصل بنا"
|
||||
},
|
||||
"hero": {
|
||||
"title": "تمكين الطلاب بـ",
|
||||
"titleHighlight": "الدعم الأساسي",
|
||||
"subtitle": "منظمة غير ربحية 501(c)(3) تقدم للطلاب المستلزمات المدرسية والملابس والدعم الطارئ لمساعدتهم على النجاح في المدرسة والحياة."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "مساعد الطلاب الذكي",
|
||||
"placeholder": "كيف يمكنني مساعدتك اليوم؟"
|
||||
}
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "الرئيسية",
|
||||
"about": "حولنا",
|
||||
"programs": "البرامج",
|
||||
"donate": "تبرع",
|
||||
"volunteer": "متطوع",
|
||||
"contact": "اتصل بنا"
|
||||
},
|
||||
"hero": {
|
||||
"title": "تمكين الطلاب بـ",
|
||||
"titleHighlight": "الدعم الأساسي",
|
||||
"subtitle": "منظمة غير ربحية 501(c)(3) تقدم للطلاب المستلزمات المدرسية والملابس والدعم الطارئ لمساعدتهم على النجاح في المدرسة والحياة."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "مساعد الطلاب الذكي",
|
||||
"placeholder": "كيف يمكنني مساعدتك اليوم؟"
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Startseite",
|
||||
"about": "Über uns",
|
||||
"programs": "Programme",
|
||||
"donate": "Spenden",
|
||||
"volunteer": "Freiwilliger",
|
||||
"contact": "Kontakt"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Studenten stärken mit",
|
||||
"titleHighlight": "wesentlicher Unterstützung",
|
||||
"subtitle": "Eine gemeinnützige 501(c)(3) Organisation, die Studenten mit Schulmaterialien, Kleidung und Notfallunterstützung versorgt."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "KI-Studienassistent",
|
||||
"placeholder": "Wie kann ich Ihnen heute helfen?"
|
||||
}
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Startseite",
|
||||
"about": "Über uns",
|
||||
"programs": "Programme",
|
||||
"donate": "Spenden",
|
||||
"volunteer": "Freiwilliger",
|
||||
"contact": "Kontakt"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Studenten stärken mit",
|
||||
"titleHighlight": "wesentlicher Unterstützung",
|
||||
"subtitle": "Eine gemeinnützige 501(c)(3) Organisation, die Studenten mit Schulmaterialien, Kleidung und Notfallunterstützung versorgt."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "KI-Studienassistent",
|
||||
"placeholder": "Wie kann ich Ihnen heute helfen?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+190
-190
@@ -1,191 +1,191 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"programs": "Programs",
|
||||
"donate": "Donate",
|
||||
"volunteer": "Volunteer",
|
||||
"contact": "Contact",
|
||||
"dashboard": "Dashboard",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Empowering Students with",
|
||||
"titleHighlight": "Essential Support",
|
||||
"subtitle": "A 501(c)(3) nonprofit providing students with school supplies, clothing, and emergency support to help them succeed in school and life.",
|
||||
"primaryButton": "Make a Donation",
|
||||
"secondaryButton": "Learn More",
|
||||
"stats": {
|
||||
"studentsHelped": "Students Helped",
|
||||
"suppliesDistributed": "Supplies Distributed",
|
||||
"schoolsPartnered": "School Partners",
|
||||
"volunteersActive": "Active Volunteers"
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"title": "Our Programs",
|
||||
"subtitle": "Comprehensive support systems designed to help students thrive",
|
||||
"schoolSupplies": {
|
||||
"title": "School Supplies",
|
||||
"description": "Essential learning materials for academic success",
|
||||
"impact": "12,000+ students equipped"
|
||||
},
|
||||
"clothing": {
|
||||
"title": "Clothing Assistance",
|
||||
"description": "Proper attire to boost confidence and school attendance",
|
||||
"impact": "8,500+ students clothed"
|
||||
},
|
||||
"emergency": {
|
||||
"title": "Emergency Support",
|
||||
"description": "Rapid assistance for urgent student needs",
|
||||
"impact": "2,100+ emergencies resolved"
|
||||
},
|
||||
"mentalHealth": {
|
||||
"title": "Mental Health",
|
||||
"description": "Counseling and wellness support services",
|
||||
"impact": "5,200+ students counseled"
|
||||
},
|
||||
"tutoring": {
|
||||
"title": "Academic Tutoring",
|
||||
"description": "One-on-one and group learning support",
|
||||
"impact": "3,800+ tutoring hours"
|
||||
},
|
||||
"meals": {
|
||||
"title": "Meal Programs",
|
||||
"description": "Nutrition support for better learning outcomes",
|
||||
"impact": "45,000+ meals provided"
|
||||
},
|
||||
"cta": "Join Our Mission"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Our Impact",
|
||||
"subtitle": "Real numbers, real change in students' lives",
|
||||
"metrics": {
|
||||
"totalStudents": "Total Students Helped",
|
||||
"totalSupplies": "Supplies Distributed",
|
||||
"totalVolunteers": "Volunteer Hours",
|
||||
"totalDonations": "Funds Raised"
|
||||
}
|
||||
},
|
||||
"donate": {
|
||||
"title": "Make a Difference Today",
|
||||
"subtitle": "Your donation directly supports students in need",
|
||||
"amounts": {
|
||||
"25": "$25 - Supplies for 1 student",
|
||||
"50": "$50 - Clothing for 1 student",
|
||||
"100": "$100 - Emergency support",
|
||||
"250": "$250 - Monthly tutoring"
|
||||
},
|
||||
"customAmount": "Custom Amount",
|
||||
"monthly": "Monthly",
|
||||
"oneTime": "One-time",
|
||||
"processing": "Processing...",
|
||||
"success": "Thank you for your donation!",
|
||||
"error": "Payment failed. Please try again."
|
||||
},
|
||||
"footer": {
|
||||
"mission": "Our Mission",
|
||||
"missionText": "Empowering students with essential resources for educational success and personal growth.",
|
||||
"quickLinks": "Quick Links",
|
||||
"contact": "Contact Info",
|
||||
"address": "123 Hope Street, Education City, EC 12345",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"email": "[email protected]",
|
||||
"social": "Follow Us",
|
||||
"newsletter": "Newsletter",
|
||||
"newsletterText": "Stay updated with our latest programs and impact stories.",
|
||||
"subscribe": "Subscribe",
|
||||
"copyright": "© 2024 Miracles in Motion. All rights reserved.",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "AI Student Assistant",
|
||||
"placeholder": "How can I help you today?",
|
||||
"thinking": "Thinking...",
|
||||
"error": "I'm having trouble right now. Please try again.",
|
||||
"suggestions": [
|
||||
"How do I apply for school supplies?",
|
||||
"What clothing assistance is available?",
|
||||
"How can I get emergency support?",
|
||||
"Tell me about tutoring programs"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "Hi! I'm here to help you learn about our programs and services. What would you like to know?",
|
||||
"typing": "Assistant is typing...",
|
||||
"send": "Send",
|
||||
"clear": "Clear Chat",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"analytics": {
|
||||
"title": "Analytics Dashboard",
|
||||
"overview": "Overview",
|
||||
"students": "Students",
|
||||
"donations": "Donations",
|
||||
"volunteers": "Volunteers",
|
||||
"programs": "Programs",
|
||||
"engagement": "Engagement",
|
||||
"performance": "Performance"
|
||||
},
|
||||
"realtime": {
|
||||
"title": "Real-time Updates",
|
||||
"newDonation": "New donation received",
|
||||
"newStudent": "New student registered",
|
||||
"programUpdate": "Program update available",
|
||||
"volunteerJoined": "New volunteer joined"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"required": "Required",
|
||||
"optional": "Optional",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading...",
|
||||
"success": "Success!",
|
||||
"error": "An error occurred",
|
||||
"validation": {
|
||||
"email": "Please enter a valid email",
|
||||
"phone": "Please enter a valid phone number",
|
||||
"required": "This field is required",
|
||||
"minLength": "Minimum {{count}} characters required",
|
||||
"maxLength": "Maximum {{count}} characters allowed"
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"skipToContent": "Skip to main content",
|
||||
"toggleMenu": "Toggle navigation menu",
|
||||
"toggleLanguage": "Toggle language menu",
|
||||
"closeModal": "Close modal",
|
||||
"loading": "Loading content",
|
||||
"imageAlt": "Image description: {{description}}"
|
||||
},
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"close": "Close",
|
||||
"open": "Open",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"clear": "Clear",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"continue": "Continue"
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"about": "About",
|
||||
"programs": "Programs",
|
||||
"donate": "Donate",
|
||||
"volunteer": "Volunteer",
|
||||
"contact": "Contact",
|
||||
"dashboard": "Dashboard",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Empowering Students with",
|
||||
"titleHighlight": "Essential Support",
|
||||
"subtitle": "A 501(c)(3) nonprofit providing students with school supplies, clothing, and emergency support to help them succeed in school and life.",
|
||||
"primaryButton": "Make a Donation",
|
||||
"secondaryButton": "Learn More",
|
||||
"stats": {
|
||||
"studentsHelped": "Students Helped",
|
||||
"suppliesDistributed": "Supplies Distributed",
|
||||
"schoolsPartnered": "School Partners",
|
||||
"volunteersActive": "Active Volunteers"
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"title": "Our Programs",
|
||||
"subtitle": "Comprehensive support systems designed to help students thrive",
|
||||
"schoolSupplies": {
|
||||
"title": "School Supplies",
|
||||
"description": "Essential learning materials for academic success",
|
||||
"impact": "12,000+ students equipped"
|
||||
},
|
||||
"clothing": {
|
||||
"title": "Clothing Assistance",
|
||||
"description": "Proper attire to boost confidence and school attendance",
|
||||
"impact": "8,500+ students clothed"
|
||||
},
|
||||
"emergency": {
|
||||
"title": "Emergency Support",
|
||||
"description": "Rapid assistance for urgent student needs",
|
||||
"impact": "2,100+ emergencies resolved"
|
||||
},
|
||||
"mentalHealth": {
|
||||
"title": "Mental Health",
|
||||
"description": "Counseling and wellness support services",
|
||||
"impact": "5,200+ students counseled"
|
||||
},
|
||||
"tutoring": {
|
||||
"title": "Academic Tutoring",
|
||||
"description": "One-on-one and group learning support",
|
||||
"impact": "3,800+ tutoring hours"
|
||||
},
|
||||
"meals": {
|
||||
"title": "Meal Programs",
|
||||
"description": "Nutrition support for better learning outcomes",
|
||||
"impact": "45,000+ meals provided"
|
||||
},
|
||||
"cta": "Join Our Mission"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Our Impact",
|
||||
"subtitle": "Real numbers, real change in students' lives",
|
||||
"metrics": {
|
||||
"totalStudents": "Total Students Helped",
|
||||
"totalSupplies": "Supplies Distributed",
|
||||
"totalVolunteers": "Volunteer Hours",
|
||||
"totalDonations": "Funds Raised"
|
||||
}
|
||||
},
|
||||
"donate": {
|
||||
"title": "Make a Difference Today",
|
||||
"subtitle": "Your donation directly supports students in need",
|
||||
"amounts": {
|
||||
"25": "$25 - Supplies for 1 student",
|
||||
"50": "$50 - Clothing for 1 student",
|
||||
"100": "$100 - Emergency support",
|
||||
"250": "$250 - Monthly tutoring"
|
||||
},
|
||||
"customAmount": "Custom Amount",
|
||||
"monthly": "Monthly",
|
||||
"oneTime": "One-time",
|
||||
"processing": "Processing...",
|
||||
"success": "Thank you for your donation!",
|
||||
"error": "Payment failed. Please try again."
|
||||
},
|
||||
"footer": {
|
||||
"mission": "Our Mission",
|
||||
"missionText": "Empowering students with essential resources for educational success and personal growth.",
|
||||
"quickLinks": "Quick Links",
|
||||
"contact": "Contact Info",
|
||||
"address": "123 Hope Street, Education City, EC 12345",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"email": "[email protected]",
|
||||
"social": "Follow Us",
|
||||
"newsletter": "Newsletter",
|
||||
"newsletterText": "Stay updated with our latest programs and impact stories.",
|
||||
"subscribe": "Subscribe",
|
||||
"copyright": "© 2024 Miracles in Motion. All rights reserved.",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Service"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "AI Student Assistant",
|
||||
"placeholder": "How can I help you today?",
|
||||
"thinking": "Thinking...",
|
||||
"error": "I'm having trouble right now. Please try again.",
|
||||
"suggestions": [
|
||||
"How do I apply for school supplies?",
|
||||
"What clothing assistance is available?",
|
||||
"How can I get emergency support?",
|
||||
"Tell me about tutoring programs"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "Hi! I'm here to help you learn about our programs and services. What would you like to know?",
|
||||
"typing": "Assistant is typing...",
|
||||
"send": "Send",
|
||||
"clear": "Clear Chat",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"analytics": {
|
||||
"title": "Analytics Dashboard",
|
||||
"overview": "Overview",
|
||||
"students": "Students",
|
||||
"donations": "Donations",
|
||||
"volunteers": "Volunteers",
|
||||
"programs": "Programs",
|
||||
"engagement": "Engagement",
|
||||
"performance": "Performance"
|
||||
},
|
||||
"realtime": {
|
||||
"title": "Real-time Updates",
|
||||
"newDonation": "New donation received",
|
||||
"newStudent": "New student registered",
|
||||
"programUpdate": "Program update available",
|
||||
"volunteerJoined": "New volunteer joined"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"required": "Required",
|
||||
"optional": "Optional",
|
||||
"submit": "Submit",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"confirm": "Confirm",
|
||||
"loading": "Loading...",
|
||||
"success": "Success!",
|
||||
"error": "An error occurred",
|
||||
"validation": {
|
||||
"email": "Please enter a valid email",
|
||||
"phone": "Please enter a valid phone number",
|
||||
"required": "This field is required",
|
||||
"minLength": "Minimum {{count}} characters required",
|
||||
"maxLength": "Maximum {{count}} characters allowed"
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"skipToContent": "Skip to main content",
|
||||
"toggleMenu": "Toggle navigation menu",
|
||||
"toggleLanguage": "Toggle language menu",
|
||||
"closeModal": "Close modal",
|
||||
"loading": "Loading content",
|
||||
"imageAlt": "Image description: {{description}}"
|
||||
},
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"back": "Back",
|
||||
"next": "Next",
|
||||
"previous": "Previous",
|
||||
"close": "Close",
|
||||
"open": "Open",
|
||||
"search": "Search",
|
||||
"filter": "Filter",
|
||||
"sort": "Sort",
|
||||
"clear": "Clear",
|
||||
"refresh": "Refresh",
|
||||
"retry": "Retry",
|
||||
"continue": "Continue"
|
||||
}
|
||||
}
|
||||
+190
-190
@@ -1,191 +1,191 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Inicio",
|
||||
"about": "Acerca de",
|
||||
"programs": "Programas",
|
||||
"donate": "Donar",
|
||||
"volunteer": "Voluntario",
|
||||
"contact": "Contacto",
|
||||
"dashboard": "Panel",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"profile": "Perfil",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Empoderando Estudiantes con",
|
||||
"titleHighlight": "Apoyo Esencial",
|
||||
"subtitle": "Una organización sin fines de lucro 501(c)(3) que proporciona a los estudiantes útiles escolares, ropa y apoyo de emergencia para ayudarlos a tener éxito en la escuela y la vida.",
|
||||
"primaryButton": "Hacer una Donación",
|
||||
"secondaryButton": "Saber Más",
|
||||
"stats": {
|
||||
"studentsHelped": "Estudiantes Ayudados",
|
||||
"suppliesDistributed": "Suministros Distribuidos",
|
||||
"schoolsPartnered": "Escuelas Asociadas",
|
||||
"volunteersActive": "Voluntarios Activos"
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"title": "Nuestros Programas",
|
||||
"subtitle": "Sistemas de apoyo integral diseñados para ayudar a los estudiantes a prosperar",
|
||||
"schoolSupplies": {
|
||||
"title": "Útiles Escolares",
|
||||
"description": "Materiales de aprendizaje esenciales para el éxito académico",
|
||||
"impact": "12,000+ estudiantes equipados"
|
||||
},
|
||||
"clothing": {
|
||||
"title": "Asistencia de Ropa",
|
||||
"description": "Vestimenta adecuada para aumentar la confianza y la asistencia escolar",
|
||||
"impact": "8,500+ estudiantes vestidos"
|
||||
},
|
||||
"emergency": {
|
||||
"title": "Apoyo de Emergencia",
|
||||
"description": "Asistencia rápida para necesidades urgentes de estudiantes",
|
||||
"impact": "2,100+ emergencias resueltas"
|
||||
},
|
||||
"mentalHealth": {
|
||||
"title": "Salud Mental",
|
||||
"description": "Servicios de consejería y apoyo al bienestar",
|
||||
"impact": "5,200+ estudiantes aconsejados"
|
||||
},
|
||||
"tutoring": {
|
||||
"title": "Tutoría Académica",
|
||||
"description": "Apoyo de aprendizaje individual y grupal",
|
||||
"impact": "3,800+ horas de tutoría"
|
||||
},
|
||||
"meals": {
|
||||
"title": "Programas de Comidas",
|
||||
"description": "Apoyo nutricional para mejores resultados de aprendizaje",
|
||||
"impact": "45,000+ comidas proporcionadas"
|
||||
},
|
||||
"cta": "Únete a Nuestra Misión"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Nuestro Impacto",
|
||||
"subtitle": "Números reales, cambios reales en las vidas de los estudiantes",
|
||||
"metrics": {
|
||||
"totalStudents": "Total de Estudiantes Ayudados",
|
||||
"totalSupplies": "Suministros Distribuidos",
|
||||
"totalVolunteers": "Horas de Voluntariado",
|
||||
"totalDonations": "Fondos Recaudados"
|
||||
}
|
||||
},
|
||||
"donate": {
|
||||
"title": "Haz la Diferencia Hoy",
|
||||
"subtitle": "Tu donación apoya directamente a estudiantes necesitados",
|
||||
"amounts": {
|
||||
"25": "$25 - Suministros para 1 estudiante",
|
||||
"50": "$50 - Ropa para 1 estudiante",
|
||||
"100": "$100 - Apoyo de emergencia",
|
||||
"250": "$250 - Tutoría mensual"
|
||||
},
|
||||
"customAmount": "Cantidad Personalizada",
|
||||
"monthly": "Mensual",
|
||||
"oneTime": "Una vez",
|
||||
"processing": "Procesando...",
|
||||
"success": "¡Gracias por tu donación!",
|
||||
"error": "El pago falló. Por favor intenta de nuevo."
|
||||
},
|
||||
"footer": {
|
||||
"mission": "Nuestra Misión",
|
||||
"missionText": "Empoderando estudiantes con recursos esenciales para el éxito educativo y crecimiento personal.",
|
||||
"quickLinks": "Enlaces Rápidos",
|
||||
"contact": "Información de Contacto",
|
||||
"address": "123 Hope Street, Education City, EC 12345",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"email": "[email protected]",
|
||||
"social": "Síguenos",
|
||||
"newsletter": "Boletín",
|
||||
"newsletterText": "Mantente actualizado con nuestros últimos programas e historias de impacto.",
|
||||
"subscribe": "Suscribirse",
|
||||
"copyright": "© 2024 Miracles in Motion. Todos los derechos reservados.",
|
||||
"privacy": "Política de Privacidad",
|
||||
"terms": "Términos de Servicio"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "Asistente de IA para Estudiantes",
|
||||
"placeholder": "¿Cómo puedo ayudarte hoy?",
|
||||
"thinking": "Pensando...",
|
||||
"error": "Tengo problemas ahora. Por favor intenta de nuevo.",
|
||||
"suggestions": [
|
||||
"¿Cómo solicito útiles escolares?",
|
||||
"¿Qué asistencia de ropa está disponible?",
|
||||
"¿Cómo puedo obtener apoyo de emergencia?",
|
||||
"Cuéntame sobre los programas de tutoría"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "¡Hola! Estoy aquí para ayudarte a aprender sobre nuestros programas y servicios. ¿Qué te gustaría saber?",
|
||||
"typing": "El asistente está escribiendo...",
|
||||
"send": "Enviar",
|
||||
"clear": "Limpiar Chat",
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"analytics": {
|
||||
"title": "Panel de Análisis",
|
||||
"overview": "Resumen",
|
||||
"students": "Estudiantes",
|
||||
"donations": "Donaciones",
|
||||
"volunteers": "Voluntarios",
|
||||
"programs": "Programas",
|
||||
"engagement": "Participación",
|
||||
"performance": "Rendimiento"
|
||||
},
|
||||
"realtime": {
|
||||
"title": "Actualizaciones en Tiempo Real",
|
||||
"newDonation": "Nueva donación recibida",
|
||||
"newStudent": "Nuevo estudiante registrado",
|
||||
"programUpdate": "Actualización de programa disponible",
|
||||
"volunteerJoined": "Nuevo voluntario se unió"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"required": "Requerido",
|
||||
"optional": "Opcional",
|
||||
"submit": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"confirm": "Confirmar",
|
||||
"loading": "Cargando...",
|
||||
"success": "¡Éxito!",
|
||||
"error": "Ocurrió un error",
|
||||
"validation": {
|
||||
"email": "Por favor ingresa un email válido",
|
||||
"phone": "Por favor ingresa un número de teléfono válido",
|
||||
"required": "Este campo es requerido",
|
||||
"minLength": "Se requieren mínimo {{count}} caracteres",
|
||||
"maxLength": "Máximo {{count}} caracteres permitidos"
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"skipToContent": "Saltar al contenido principal",
|
||||
"toggleMenu": "Alternar menú de navegación",
|
||||
"toggleLanguage": "Alternar menú de idioma",
|
||||
"closeModal": "Cerrar modal",
|
||||
"loading": "Cargando contenido",
|
||||
"imageAlt": "Descripción de imagen: {{description}}"
|
||||
},
|
||||
"common": {
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"back": "Atrás",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"close": "Cerrar",
|
||||
"open": "Abrir",
|
||||
"search": "Buscar",
|
||||
"filter": "Filtrar",
|
||||
"sort": "Ordenar",
|
||||
"clear": "Limpiar",
|
||||
"refresh": "Actualizar",
|
||||
"retry": "Reintentar",
|
||||
"continue": "Continuar"
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Inicio",
|
||||
"about": "Acerca de",
|
||||
"programs": "Programas",
|
||||
"donate": "Donar",
|
||||
"volunteer": "Voluntario",
|
||||
"contact": "Contacto",
|
||||
"dashboard": "Panel",
|
||||
"login": "Iniciar sesión",
|
||||
"logout": "Cerrar sesión",
|
||||
"profile": "Perfil",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Empoderando Estudiantes con",
|
||||
"titleHighlight": "Apoyo Esencial",
|
||||
"subtitle": "Una organización sin fines de lucro 501(c)(3) que proporciona a los estudiantes útiles escolares, ropa y apoyo de emergencia para ayudarlos a tener éxito en la escuela y la vida.",
|
||||
"primaryButton": "Hacer una Donación",
|
||||
"secondaryButton": "Saber Más",
|
||||
"stats": {
|
||||
"studentsHelped": "Estudiantes Ayudados",
|
||||
"suppliesDistributed": "Suministros Distribuidos",
|
||||
"schoolsPartnered": "Escuelas Asociadas",
|
||||
"volunteersActive": "Voluntarios Activos"
|
||||
}
|
||||
},
|
||||
"programs": {
|
||||
"title": "Nuestros Programas",
|
||||
"subtitle": "Sistemas de apoyo integral diseñados para ayudar a los estudiantes a prosperar",
|
||||
"schoolSupplies": {
|
||||
"title": "Útiles Escolares",
|
||||
"description": "Materiales de aprendizaje esenciales para el éxito académico",
|
||||
"impact": "12,000+ estudiantes equipados"
|
||||
},
|
||||
"clothing": {
|
||||
"title": "Asistencia de Ropa",
|
||||
"description": "Vestimenta adecuada para aumentar la confianza y la asistencia escolar",
|
||||
"impact": "8,500+ estudiantes vestidos"
|
||||
},
|
||||
"emergency": {
|
||||
"title": "Apoyo de Emergencia",
|
||||
"description": "Asistencia rápida para necesidades urgentes de estudiantes",
|
||||
"impact": "2,100+ emergencias resueltas"
|
||||
},
|
||||
"mentalHealth": {
|
||||
"title": "Salud Mental",
|
||||
"description": "Servicios de consejería y apoyo al bienestar",
|
||||
"impact": "5,200+ estudiantes aconsejados"
|
||||
},
|
||||
"tutoring": {
|
||||
"title": "Tutoría Académica",
|
||||
"description": "Apoyo de aprendizaje individual y grupal",
|
||||
"impact": "3,800+ horas de tutoría"
|
||||
},
|
||||
"meals": {
|
||||
"title": "Programas de Comidas",
|
||||
"description": "Apoyo nutricional para mejores resultados de aprendizaje",
|
||||
"impact": "45,000+ comidas proporcionadas"
|
||||
},
|
||||
"cta": "Únete a Nuestra Misión"
|
||||
},
|
||||
"impact": {
|
||||
"title": "Nuestro Impacto",
|
||||
"subtitle": "Números reales, cambios reales en las vidas de los estudiantes",
|
||||
"metrics": {
|
||||
"totalStudents": "Total de Estudiantes Ayudados",
|
||||
"totalSupplies": "Suministros Distribuidos",
|
||||
"totalVolunteers": "Horas de Voluntariado",
|
||||
"totalDonations": "Fondos Recaudados"
|
||||
}
|
||||
},
|
||||
"donate": {
|
||||
"title": "Haz la Diferencia Hoy",
|
||||
"subtitle": "Tu donación apoya directamente a estudiantes necesitados",
|
||||
"amounts": {
|
||||
"25": "$25 - Suministros para 1 estudiante",
|
||||
"50": "$50 - Ropa para 1 estudiante",
|
||||
"100": "$100 - Apoyo de emergencia",
|
||||
"250": "$250 - Tutoría mensual"
|
||||
},
|
||||
"customAmount": "Cantidad Personalizada",
|
||||
"monthly": "Mensual",
|
||||
"oneTime": "Una vez",
|
||||
"processing": "Procesando...",
|
||||
"success": "¡Gracias por tu donación!",
|
||||
"error": "El pago falló. Por favor intenta de nuevo."
|
||||
},
|
||||
"footer": {
|
||||
"mission": "Nuestra Misión",
|
||||
"missionText": "Empoderando estudiantes con recursos esenciales para el éxito educativo y crecimiento personal.",
|
||||
"quickLinks": "Enlaces Rápidos",
|
||||
"contact": "Información de Contacto",
|
||||
"address": "123 Hope Street, Education City, EC 12345",
|
||||
"phone": "+1 (555) 123-4567",
|
||||
"email": "[email protected]",
|
||||
"social": "Síguenos",
|
||||
"newsletter": "Boletín",
|
||||
"newsletterText": "Mantente actualizado con nuestros últimos programas e historias de impacto.",
|
||||
"subscribe": "Suscribirse",
|
||||
"copyright": "© 2024 Miracles in Motion. Todos los derechos reservados.",
|
||||
"privacy": "Política de Privacidad",
|
||||
"terms": "Términos de Servicio"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "Asistente de IA para Estudiantes",
|
||||
"placeholder": "¿Cómo puedo ayudarte hoy?",
|
||||
"thinking": "Pensando...",
|
||||
"error": "Tengo problemas ahora. Por favor intenta de nuevo.",
|
||||
"suggestions": [
|
||||
"¿Cómo solicito útiles escolares?",
|
||||
"¿Qué asistencia de ropa está disponible?",
|
||||
"¿Cómo puedo obtener apoyo de emergencia?",
|
||||
"Cuéntame sobre los programas de tutoría"
|
||||
]
|
||||
},
|
||||
"chat": {
|
||||
"welcome": "¡Hola! Estoy aquí para ayudarte a aprender sobre nuestros programas y servicios. ¿Qué te gustaría saber?",
|
||||
"typing": "El asistente está escribiendo...",
|
||||
"send": "Enviar",
|
||||
"clear": "Limpiar Chat",
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"analytics": {
|
||||
"title": "Panel de Análisis",
|
||||
"overview": "Resumen",
|
||||
"students": "Estudiantes",
|
||||
"donations": "Donaciones",
|
||||
"volunteers": "Voluntarios",
|
||||
"programs": "Programas",
|
||||
"engagement": "Participación",
|
||||
"performance": "Rendimiento"
|
||||
},
|
||||
"realtime": {
|
||||
"title": "Actualizaciones en Tiempo Real",
|
||||
"newDonation": "Nueva donación recibida",
|
||||
"newStudent": "Nuevo estudiante registrado",
|
||||
"programUpdate": "Actualización de programa disponible",
|
||||
"volunteerJoined": "Nuevo voluntario se unió"
|
||||
}
|
||||
},
|
||||
"forms": {
|
||||
"required": "Requerido",
|
||||
"optional": "Opcional",
|
||||
"submit": "Enviar",
|
||||
"cancel": "Cancelar",
|
||||
"save": "Guardar",
|
||||
"edit": "Editar",
|
||||
"delete": "Eliminar",
|
||||
"confirm": "Confirmar",
|
||||
"loading": "Cargando...",
|
||||
"success": "¡Éxito!",
|
||||
"error": "Ocurrió un error",
|
||||
"validation": {
|
||||
"email": "Por favor ingresa un email válido",
|
||||
"phone": "Por favor ingresa un número de teléfono válido",
|
||||
"required": "Este campo es requerido",
|
||||
"minLength": "Se requieren mínimo {{count}} caracteres",
|
||||
"maxLength": "Máximo {{count}} caracteres permitidos"
|
||||
}
|
||||
},
|
||||
"accessibility": {
|
||||
"skipToContent": "Saltar al contenido principal",
|
||||
"toggleMenu": "Alternar menú de navegación",
|
||||
"toggleLanguage": "Alternar menú de idioma",
|
||||
"closeModal": "Cerrar modal",
|
||||
"loading": "Cargando contenido",
|
||||
"imageAlt": "Descripción de imagen: {{description}}"
|
||||
},
|
||||
"common": {
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"ok": "OK",
|
||||
"back": "Atrás",
|
||||
"next": "Siguiente",
|
||||
"previous": "Anterior",
|
||||
"close": "Cerrar",
|
||||
"open": "Abrir",
|
||||
"search": "Buscar",
|
||||
"filter": "Filtrar",
|
||||
"sort": "Ordenar",
|
||||
"clear": "Limpiar",
|
||||
"refresh": "Actualizar",
|
||||
"retry": "Reintentar",
|
||||
"continue": "Continuar"
|
||||
}
|
||||
}
|
||||
+33
-33
@@ -1,34 +1,34 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Accueil",
|
||||
"about": "À propos",
|
||||
"programs": "Programmes",
|
||||
"donate": "Faire un don",
|
||||
"volunteer": "Bénévole",
|
||||
"contact": "Contact",
|
||||
"dashboard": "Tableau de bord",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"profile": "Profil",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Autonomiser les étudiants avec",
|
||||
"titleHighlight": "un soutien essentiel",
|
||||
"subtitle": "Une organisation à but non lucratif 501(c)(3) fournissant aux étudiants des fournitures scolaires, des vêtements et un soutien d'urgence pour les aider à réussir à l'école et dans la vie.",
|
||||
"primaryButton": "Faire un don",
|
||||
"secondaryButton": "En savoir plus"
|
||||
},
|
||||
"programs": {
|
||||
"title": "Nos programmes",
|
||||
"subtitle": "Systèmes de soutien complets conçus pour aider les étudiants à s'épanouir"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "Assistant IA pour étudiants",
|
||||
"placeholder": "Comment puis-je vous aider aujourd'hui ?",
|
||||
"thinking": "Réflexion...",
|
||||
"error": "J'ai des difficultés en ce moment. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Accueil",
|
||||
"about": "À propos",
|
||||
"programs": "Programmes",
|
||||
"donate": "Faire un don",
|
||||
"volunteer": "Bénévole",
|
||||
"contact": "Contact",
|
||||
"dashboard": "Tableau de bord",
|
||||
"login": "Connexion",
|
||||
"logout": "Déconnexion",
|
||||
"profile": "Profil",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Autonomiser les étudiants avec",
|
||||
"titleHighlight": "un soutien essentiel",
|
||||
"subtitle": "Une organisation à but non lucratif 501(c)(3) fournissant aux étudiants des fournitures scolaires, des vêtements et un soutien d'urgence pour les aider à réussir à l'école et dans la vie.",
|
||||
"primaryButton": "Faire un don",
|
||||
"secondaryButton": "En savoir plus"
|
||||
},
|
||||
"programs": {
|
||||
"title": "Nos programmes",
|
||||
"subtitle": "Systèmes de soutien complets conçus pour aider les étudiants à s'épanouir"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "Assistant IA pour étudiants",
|
||||
"placeholder": "Comment puis-je vous aider aujourd'hui ?",
|
||||
"thinking": "Réflexion...",
|
||||
"error": "J'ai des difficultés en ce moment. Veuillez réessayer."
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Início",
|
||||
"about": "Sobre",
|
||||
"programs": "Programas",
|
||||
"donate": "Doar",
|
||||
"volunteer": "Voluntário",
|
||||
"contact": "Contato"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Capacitando estudantes com",
|
||||
"titleHighlight": "apoio essencial",
|
||||
"subtitle": "Uma organização sem fins lucrativos 501(c)(3) fornecendo aos estudantes materiais escolares, roupas e apoio de emergência para ajudá-los a ter sucesso na escola e na vida."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "Assistente de IA para Estudantes",
|
||||
"placeholder": "Como posso ajudar você hoje?"
|
||||
}
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Início",
|
||||
"about": "Sobre",
|
||||
"programs": "Programas",
|
||||
"donate": "Doar",
|
||||
"volunteer": "Voluntário",
|
||||
"contact": "Contato"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Capacitando estudantes com",
|
||||
"titleHighlight": "apoio essencial",
|
||||
"subtitle": "Uma organização sem fins lucrativos 501(c)(3) fornecendo aos estudantes materiais escolares, roupas e apoio de emergência para ajudá-los a ter sucesso na escola e na vida."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "Assistente de IA para Estudantes",
|
||||
"placeholder": "Como posso ajudar você hoje?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Главная",
|
||||
"about": "О нас",
|
||||
"programs": "Программы",
|
||||
"donate": "Пожертвовать",
|
||||
"volunteer": "Волонтер",
|
||||
"contact": "Контакты"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Расширяем возможности студентов с",
|
||||
"titleHighlight": "необходимой поддержкой",
|
||||
"subtitle": "Некоммерческая организация 501(c)(3), предоставляющая студентам школьные принадлежности, одежду и экстренную поддержку, чтобы помочь им добиться успеха в школе и жизни."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "ИИ-помощник для студентов",
|
||||
"placeholder": "Как я могу помочь вам сегодня?"
|
||||
}
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "Главная",
|
||||
"about": "О нас",
|
||||
"programs": "Программы",
|
||||
"donate": "Пожертвовать",
|
||||
"volunteer": "Волонтер",
|
||||
"contact": "Контакты"
|
||||
},
|
||||
"hero": {
|
||||
"title": "Расширяем возможности студентов с",
|
||||
"titleHighlight": "необходимой поддержкой",
|
||||
"subtitle": "Некоммерческая организация 501(c)(3), предоставляющая студентам школьные принадлежности, одежду и экстренную поддержку, чтобы помочь им добиться успеха в школе и жизни."
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "ИИ-помощник для студентов",
|
||||
"placeholder": "Как я могу помочь вам сегодня?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-20
@@ -1,21 +1,21 @@
|
||||
{
|
||||
"navigation": {
|
||||
"home": "首页",
|
||||
"about": "关于我们",
|
||||
"programs": "项目",
|
||||
"donate": "捐赠",
|
||||
"volunteer": "志愿者",
|
||||
"contact": "联系我们"
|
||||
},
|
||||
"hero": {
|
||||
"title": "为学生提供",
|
||||
"titleHighlight": "基本支持",
|
||||
"subtitle": "501(c)(3)非营利组织,为学生提供学习用品、衣物和紧急支持,帮助他们在学校和生活中取得成功。"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "AI学生助手",
|
||||
"placeholder": "今天我能为您做些什么?"
|
||||
}
|
||||
}
|
||||
{
|
||||
"navigation": {
|
||||
"home": "首页",
|
||||
"about": "关于我们",
|
||||
"programs": "项目",
|
||||
"donate": "捐赠",
|
||||
"volunteer": "志愿者",
|
||||
"contact": "联系我们"
|
||||
},
|
||||
"hero": {
|
||||
"title": "为学生提供",
|
||||
"titleHighlight": "基本支持",
|
||||
"subtitle": "501(c)(3)非营利组织,为学生提供学习用品、衣物和紧急支持,帮助他们在学校和生活中取得成功。"
|
||||
},
|
||||
"ai": {
|
||||
"assistant": {
|
||||
"title": "AI学生助手",
|
||||
"placeholder": "今天我能为您做些什么?"
|
||||
}
|
||||
}
|
||||
}
|
||||
+425
-425
@@ -1,426 +1,426 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #ec4899, #8b5cf6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #db2777, #7c3aed);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Button Components */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, #db2777, #7c3aed); /* Replace with your primary-600 and secondary-600 colors */
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(236, 72, 153, 0.25), 0 4px 6px -4px rgba(236, 72, 153, 0.25);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 20px 25px -5px rgba(236, 72, 153, 0.25), 0 8px 10px -6px rgba(236, 72, 153, 0.25);
|
||||
}
|
||||
.btn-primary:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 10px 15px -3px rgba(236, 72, 153, 0.25), 0 4px 6px -4px rgba(236, 72, 153, 0.25);
|
||||
}
|
||||
.btn-primary:focus-visible {
|
||||
outline: 2px solid #ec4899;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #d4d4d8; /* border-neutral-300 */
|
||||
background-color: rgba(255,255,255,0.7); /* bg-white/70 */
|
||||
padding-left: 1.5rem; /* px-6 */
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem; /* py-3 */
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 500; /* font-medium */
|
||||
color: #52525b; /* text-neutral-700 */
|
||||
backdrop-filter: blur(8px); /* backdrop-blur */
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-secondary:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #737373, 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-secondary:focus-visible {
|
||||
outline: 2px solid #737373; /* focus:ring-neutral-500 */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-secondary {
|
||||
border: 1px solid rgba(255,255,255,0.2); /* dark:border-white/20 */
|
||||
background-color: rgba(255,255,255,0.1); /* dark:bg-white/10 */
|
||||
color: #e5e5e5; /* dark:text-neutral-200 */
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(255,255,255,0.2); /* dark:hover:bg-white/20 */
|
||||
}
|
||||
}
|
||||
|
||||
.btn-white {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-color: #fff;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #18181b;
|
||||
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.btn-white:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-white:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fff, 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-white:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.navlink {
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 500; /* font-medium */
|
||||
color: #52525b; /* text-neutral-600 */
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.navlink:hover {
|
||||
color: #ec4899; /* text-primary-600 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.navlink {
|
||||
color: #d4d4d8; /* dark:text-neutral-300 */
|
||||
}
|
||||
.navlink:hover {
|
||||
color: #a78bfa; /* dark:hover:text-primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Components */
|
||||
.input-field {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #d1d5db; /* gray-300 */
|
||||
background-color: #fff;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #111827; /* gray-900 */
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.input-field::placeholder {
|
||||
color: #6b7280; /* gray-500 */
|
||||
opacity: 1;
|
||||
}
|
||||
.input-field:focus {
|
||||
border-color: #ec4899; /* primary-500 */
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 0 0 2px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
.input-field:focus-visible {
|
||||
outline: 2px solid #ec4899;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.input-field {
|
||||
border: 1px solid #4b5563; /* gray-600 */
|
||||
background-color: #1f2937; /* gray-800 */
|
||||
color: #f3f4f6; /* gray-100 */
|
||||
}
|
||||
.input-field::placeholder {
|
||||
color: #9ca3af; /* gray-400 */
|
||||
}
|
||||
.input-field:focus {
|
||||
border-color: #a21caf; /* primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem; /* rounded-xl */
|
||||
border: 1px solid rgba(255,255,255,0.3); /* border-white/30 */
|
||||
background-color: rgba(255,255,255,0.7); /* bg-white/70 */
|
||||
padding-left: 0.75rem; /* px-3 */
|
||||
padding-right: 0.75rem;
|
||||
padding-top: 0.5rem; /* py-2 */
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
backdrop-filter: blur(8px); /* backdrop-blur */
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: #ec4899; /* primary-500 */
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 0 0 2px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: 2px solid #ec4899;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.input {
|
||||
border: 1px solid rgba(255,255,255,0.1); /* dark:border-white/10 */
|
||||
background-color: rgba(255,255,255,0.1); /* dark:bg-white/10 */
|
||||
}
|
||||
.input:focus {
|
||||
border-color: #a21caf; /* dark:focus:border-primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Components */
|
||||
.card {
|
||||
border-radius: 1rem; /* rounded-2xl */
|
||||
border: 1px solid rgba(255,255,255,0.3); /* border-white/30 */
|
||||
background-color: rgba(255,255,255,0.7); /* bg-white/70 */
|
||||
padding: 1.5rem; /* p-6 */
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1); /* shadow-xl */
|
||||
backdrop-filter: blur(8px); /* backdrop-blur */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
border: 1px solid rgba(255,255,255,0.1); /* dark:border-white/10 */
|
||||
background-color: rgba(255,255,255,0.05); /* dark:bg-white/5 */
|
||||
}
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-0.25rem); /* -translate-y-1 */
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25), 0 8px 10px -6px rgba(0,0,0,0.1); /* shadow-2xl */
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 48rem; /* 3xl = 48rem */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em; /* tracking-wider */
|
||||
color: #db2777; /* text-primary-600 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-eyebrow {
|
||||
color: #a78bfa; /* dark:text-primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 0.5rem; /* mt-2 */
|
||||
font-size: 1.875rem; /* text-3xl */
|
||||
font-weight: 700; /* font-bold */
|
||||
letter-spacing: -0.025em; /* tracking-tight */
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.section-title {
|
||||
font-size: 2.25rem; /* sm:text-4xl */
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin-top: 1rem; /* mt-4 */
|
||||
font-size: 1.125rem; /* text-lg */
|
||||
color: #52525b; /* text-neutral-600 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-subtitle {
|
||||
color: #d4d4d8; /* dark:text-neutral-300 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background-image: linear-gradient(to right, #ec4899, #a21caf, #7c3aed); /* Replace with your Tailwind color values */
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Glass Effect */
|
||||
.glass {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Text Balance */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Animation Utilities */
|
||||
.animate-in {
|
||||
animation: animate-in 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus States for Accessibility */
|
||||
.focus-visible\:ring-2:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px rgba(236, 72, 153, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
.btn-primary {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
color: black !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
color: black !important;
|
||||
background: none !important;
|
||||
-webkit-text-fill-color: initial !important;
|
||||
}
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-feature-settings: 'rlig' 1, 'calt' 1;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(135deg, #ec4899, #8b5cf6);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(135deg, #db2777, #7c3aed);
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Button Components */
|
||||
.btn-primary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-image: linear-gradient(to right, #db2777, #7c3aed); /* Replace with your primary-600 and secondary-600 colors */
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(236, 72, 153, 0.25), 0 4px 6px -4px rgba(236, 72, 153, 0.25);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 20px 25px -5px rgba(236, 72, 153, 0.25), 0 8px 10px -6px rgba(236, 72, 153, 0.25);
|
||||
}
|
||||
.btn-primary:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 10px 15px -3px rgba(236, 72, 153, 0.25), 0 4px 6px -4px rgba(236, 72, 153, 0.25);
|
||||
}
|
||||
.btn-primary:focus-visible {
|
||||
outline: 2px solid #ec4899;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #d4d4d8; /* border-neutral-300 */
|
||||
background-color: rgba(255,255,255,0.7); /* bg-white/70 */
|
||||
padding-left: 1.5rem; /* px-6 */
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem; /* py-3 */
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 500; /* font-medium */
|
||||
color: #52525b; /* text-neutral-700 */
|
||||
backdrop-filter: blur(8px); /* backdrop-blur */
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #fff;
|
||||
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-secondary:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #737373, 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-secondary:focus-visible {
|
||||
outline: 2px solid #737373; /* focus:ring-neutral-500 */
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.btn-secondary {
|
||||
border: 1px solid rgba(255,255,255,0.2); /* dark:border-white/20 */
|
||||
background-color: rgba(255,255,255,0.1); /* dark:bg-white/10 */
|
||||
color: #e5e5e5; /* dark:text-neutral-200 */
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: rgba(255,255,255,0.2); /* dark:hover:bg-white/20 */
|
||||
}
|
||||
}
|
||||
|
||||
.btn-white {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-color: #fff;
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #18181b;
|
||||
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.btn-white:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-white:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #fff, 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.btn-white:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.navlink {
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
font-weight: 500; /* font-medium */
|
||||
color: #52525b; /* text-neutral-600 */
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.navlink:hover {
|
||||
color: #ec4899; /* text-primary-600 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.navlink {
|
||||
color: #d4d4d8; /* dark:text-neutral-300 */
|
||||
}
|
||||
.navlink:hover {
|
||||
color: #a78bfa; /* dark:hover:text-primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Form Components */
|
||||
.input-field {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #d1d5db; /* gray-300 */
|
||||
background-color: #fff;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
padding-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: #111827; /* gray-900 */
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.input-field::placeholder {
|
||||
color: #6b7280; /* gray-500 */
|
||||
opacity: 1;
|
||||
}
|
||||
.input-field:focus {
|
||||
border-color: #ec4899; /* primary-500 */
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 0 0 2px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
.input-field:focus-visible {
|
||||
outline: 2px solid #ec4899;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.input-field {
|
||||
border: 1px solid #4b5563; /* gray-600 */
|
||||
background-color: #1f2937; /* gray-800 */
|
||||
color: #f3f4f6; /* gray-100 */
|
||||
}
|
||||
.input-field::placeholder {
|
||||
color: #9ca3af; /* gray-400 */
|
||||
}
|
||||
.input-field:focus {
|
||||
border-color: #a21caf; /* primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-clamp-3 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem; /* rounded-xl */
|
||||
border: 1px solid rgba(255,255,255,0.3); /* border-white/30 */
|
||||
background-color: rgba(255,255,255,0.7); /* bg-white/70 */
|
||||
padding-left: 0.75rem; /* px-3 */
|
||||
padding-right: 0.75rem;
|
||||
padding-top: 0.5rem; /* py-2 */
|
||||
padding-bottom: 0.5rem;
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
backdrop-filter: blur(8px); /* backdrop-blur */
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
outline: none;
|
||||
}
|
||||
.input:focus {
|
||||
border-color: #ec4899; /* primary-500 */
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #ec4899, 0 0 0 2px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
.input:focus-visible {
|
||||
outline: 2px solid #ec4899;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.input {
|
||||
border: 1px solid rgba(255,255,255,0.1); /* dark:border-white/10 */
|
||||
background-color: rgba(255,255,255,0.1); /* dark:bg-white/10 */
|
||||
}
|
||||
.input:focus {
|
||||
border-color: #a21caf; /* dark:focus:border-primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
/* Card Components */
|
||||
.card {
|
||||
border-radius: 1rem; /* rounded-2xl */
|
||||
border: 1px solid rgba(255,255,255,0.3); /* border-white/30 */
|
||||
background-color: rgba(255,255,255,0.7); /* bg-white/70 */
|
||||
padding: 1.5rem; /* p-6 */
|
||||
box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1), 0 8px 10px -6px rgba(0,0,0,0.1); /* shadow-xl */
|
||||
backdrop-filter: blur(8px); /* backdrop-blur */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card {
|
||||
border: 1px solid rgba(255,255,255,0.1); /* dark:border-white/10 */
|
||||
background-color: rgba(255,255,255,0.05); /* dark:bg-white/5 */
|
||||
}
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.card-hover:hover {
|
||||
transform: translateY(-0.25rem); /* -translate-y-1 */
|
||||
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25), 0 8px 10px -6px rgba(0,0,0,0.1); /* shadow-2xl */
|
||||
}
|
||||
|
||||
/* Section Header */
|
||||
.section-header {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 48rem; /* 3xl = 48rem */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.section-eyebrow {
|
||||
font-size: 0.875rem; /* text-sm */
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em; /* tracking-wider */
|
||||
color: #db2777; /* text-primary-600 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-eyebrow {
|
||||
color: #a78bfa; /* dark:text-primary-400 */
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 0.5rem; /* mt-2 */
|
||||
font-size: 1.875rem; /* text-3xl */
|
||||
font-weight: 700; /* font-bold */
|
||||
letter-spacing: -0.025em; /* tracking-tight */
|
||||
line-height: 2.25rem;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.section-title {
|
||||
font-size: 2.25rem; /* sm:text-4xl */
|
||||
line-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin-top: 1rem; /* mt-4 */
|
||||
font-size: 1.125rem; /* text-lg */
|
||||
color: #52525b; /* text-neutral-600 */
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.section-subtitle {
|
||||
color: #d4d4d8; /* dark:text-neutral-300 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Gradient Text */
|
||||
.gradient-text {
|
||||
background-image: linear-gradient(to right, #ec4899, #a21caf, #7c3aed); /* Replace with your Tailwind color values */
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Glass Effect */
|
||||
.glass {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.glass-dark {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Text Balance */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Animation Utilities */
|
||||
.animate-in {
|
||||
animation: animate-in 0.6s ease-out;
|
||||
}
|
||||
|
||||
@keyframes animate-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(1rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus States for Accessibility */
|
||||
.focus-visible\:ring-2:focus-visible {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
box-shadow: 0 0 0 2px rgba(236, 72, 153, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast Mode Support */
|
||||
@media (prefers-contrast: high) {
|
||||
.btn-primary {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
.card {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/* Reduced Motion Support */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
.animate-marquee {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Styles */
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
color: black !important;
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
color: black !important;
|
||||
background: none !important;
|
||||
-webkit-text-fill-color: initial !important;
|
||||
}
|
||||
}
|
||||
+178
-178
@@ -1,179 +1,179 @@
|
||||
import React, { ReactNode, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { Navigation } from '../components/Navigation'
|
||||
import { Footer } from '../components/Footer'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
darkMode?: boolean
|
||||
setDarkMode?: (value: boolean) => void
|
||||
}
|
||||
|
||||
interface PageShellProps {
|
||||
title: string
|
||||
icon?: LucideIcon
|
||||
eyebrow?: string
|
||||
subtitle?: string
|
||||
cta?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Main layout wrapper with Navigation and Footer
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
darkMode = false,
|
||||
setDarkMode = () => {}
|
||||
}) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-br from-purple-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-purple-900 ${className}`}>
|
||||
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur dark:bg-black/80">
|
||||
<Navigation
|
||||
darkMode={darkMode}
|
||||
setDarkMode={setDarkMode}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Page shell component for consistent page structure
|
||||
export const PageShell: React.FC<PageShellProps> = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
eyebrow,
|
||||
subtitle,
|
||||
cta,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className={`mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12 ${className}`}>
|
||||
{/* Page Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
{eyebrow && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary-600" />}
|
||||
<span className="text-sm font-medium text-primary-600 uppercase tracking-wider">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{subtitle && (
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-8">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{cta && (
|
||||
<div className="flex justify-center">
|
||||
{cta}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Page Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Section header component
|
||||
interface SectionHeaderProps {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SectionHeader: React.FC<SectionHeaderProps> = ({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-center mb-12 ${className}`}>
|
||||
{eyebrow && (
|
||||
<div className="text-sm font-medium text-primary-600 uppercase tracking-wider mb-2">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card component for consistent styling
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
padding?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
hover = false,
|
||||
padding = 'md'
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white/70 dark:bg-white/5
|
||||
backdrop-blur-sm
|
||||
border border-white/30 dark:border-white/10
|
||||
rounded-xl shadow-lg
|
||||
${hover ? 'hover:shadow-xl transition-shadow duration-300' : ''}
|
||||
${paddingClasses[padding]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
import React, { ReactNode, useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { Navigation } from '../components/Navigation'
|
||||
import { Footer } from '../components/Footer'
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
darkMode?: boolean
|
||||
setDarkMode?: (value: boolean) => void
|
||||
}
|
||||
|
||||
interface PageShellProps {
|
||||
title: string
|
||||
icon?: LucideIcon
|
||||
eyebrow?: string
|
||||
subtitle?: string
|
||||
cta?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Main layout wrapper with Navigation and Footer
|
||||
export const MainLayout: React.FC<MainLayoutProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
darkMode = false,
|
||||
setDarkMode = () => {}
|
||||
}) => {
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen bg-gradient-to-br from-purple-50 via-white to-pink-50 dark:from-gray-900 dark:via-gray-800 dark:to-purple-900 ${className}`}>
|
||||
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur dark:bg-black/80">
|
||||
<Navigation
|
||||
darkMode={darkMode}
|
||||
setDarkMode={setDarkMode}
|
||||
mobileMenuOpen={mobileMenuOpen}
|
||||
setMobileMenuOpen={setMobileMenuOpen}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<main id="content">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Page shell component for consistent page structure
|
||||
export const PageShell: React.FC<PageShellProps> = ({
|
||||
title,
|
||||
icon: Icon,
|
||||
eyebrow,
|
||||
subtitle,
|
||||
cta,
|
||||
children,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<MainLayout>
|
||||
<div className={`mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-12 ${className}`}>
|
||||
{/* Page Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
{eyebrow && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
{Icon && <Icon className="w-5 h-5 text-primary-600" />}
|
||||
<span className="text-sm font-medium text-primary-600 uppercase tracking-wider">
|
||||
{eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{subtitle && (
|
||||
<p className="text-xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-8">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{cta && (
|
||||
<div className="flex justify-center">
|
||||
{cta}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Page Content */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Section header component
|
||||
interface SectionHeaderProps {
|
||||
eyebrow?: string
|
||||
title: string
|
||||
subtitle?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const SectionHeader: React.FC<SectionHeaderProps> = ({
|
||||
eyebrow,
|
||||
title,
|
||||
subtitle,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<div className={`text-center mb-12 ${className}`}>
|
||||
{eyebrow && (
|
||||
<div className="text-sm font-medium text-primary-600 uppercase tracking-wider mb-2">
|
||||
{eyebrow}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Card component for consistent styling
|
||||
interface CardProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
padding?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
hover = false,
|
||||
padding = 'md'
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
bg-white/70 dark:bg-white/5
|
||||
backdrop-blur-sm
|
||||
border border-white/30 dark:border-white/10
|
||||
rounded-xl shadow-lg
|
||||
${hover ? 'hover:shadow-xl transition-shadow duration-300' : ''}
|
||||
${paddingClasses[padding]}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+13
-13
@@ -1,14 +1,14 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import i18n from './i18n/config'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nextProvider>
|
||||
</React.StrictMode>,
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { I18nextProvider } from 'react-i18next'
|
||||
import i18n from './i18n/config'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<App />
|
||||
</I18nextProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
+368
-368
@@ -1,369 +1,369 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Heart, CreditCard, Shield, Lock, CheckCircle2 } from 'lucide-react'
|
||||
import { PageShell, Card } from '@/layouts/MainLayout'
|
||||
import { useDonationImpact } from '@/hooks/useDonationImpact'
|
||||
import { useFormValidation } from '@/hooks/useDonationImpact'
|
||||
import { formatCurrency } from '@/utils/helpers'
|
||||
import { trackEvent, trackPageView } from '@/utils/analytics'
|
||||
|
||||
interface DonatePageProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const DonatePage: React.FC<DonatePageProps> = ({ className }) => {
|
||||
const [selectedAmount, setSelectedAmount] = useState<number>(50)
|
||||
const [customAmount, setCustomAmount] = useState<string>('')
|
||||
const [isCustom, setIsCustom] = useState<boolean>(false)
|
||||
|
||||
const finalAmount = isCustom && customAmount ? parseInt(customAmount) || 0 : selectedAmount
|
||||
const impact = useDonationImpact(finalAmount)
|
||||
|
||||
useEffect(() => {
|
||||
trackPageView('/donate', 'Donate - Support Students in Need')
|
||||
}, [])
|
||||
|
||||
const donationTiers = [
|
||||
{ amount: 25, label: "Supplies for one student" },
|
||||
{ amount: 50, label: "Fill a backpack with essentials" },
|
||||
{ amount: 75, label: "Shoes + warm coat" },
|
||||
{ amount: 100, label: "Complete school outfit & supplies" },
|
||||
]
|
||||
|
||||
const handleAmountSelect = (amount: number): void => {
|
||||
setSelectedAmount(amount)
|
||||
setIsCustom(false)
|
||||
setCustomAmount('')
|
||||
trackEvent('donation_amount_selected', { amount, type: 'preset' })
|
||||
}
|
||||
|
||||
const handleCustomAmount = (value: string): void => {
|
||||
setCustomAmount(value)
|
||||
setIsCustom(true)
|
||||
if (parseInt(value)) {
|
||||
trackEvent('donation_amount_selected', { amount: parseInt(value), type: 'custom' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Make a Difference Today"
|
||||
icon={Heart}
|
||||
eyebrow="Every dollar counts"
|
||||
subtitle="Your donation directly supports students with school supplies, clothing, and emergency assistance."
|
||||
className={className}
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Donation Tiers */}
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Choose Your Impact Level
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
{donationTiers.map((tier) => (
|
||||
<button
|
||||
key={tier.amount}
|
||||
onClick={() => handleAmountSelect(tier.amount)}
|
||||
className={`p-4 rounded-lg border-2 transition-all duration-200 text-left ${
|
||||
selectedAmount === tier.amount && !isCustom
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-lg text-gray-900 dark:text-white">
|
||||
{formatCurrency(tier.amount)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{tier.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Or enter a custom amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={customAmount}
|
||||
onChange={(e) => handleCustomAmount(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Donation Form */}
|
||||
<DonationForm amount={finalAmount} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Impact Calculator */}
|
||||
<ImpactCalculator impact={impact} amount={finalAmount} />
|
||||
|
||||
{/* Trust Badges */}
|
||||
<TrustBadges />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Impact Calculator Component
|
||||
interface ImpactCalculatorProps {
|
||||
impact: ReturnType<typeof useDonationImpact>
|
||||
amount: number
|
||||
}
|
||||
|
||||
const ImpactCalculator: React.FC<ImpactCalculatorProps> = ({ impact, amount }) => {
|
||||
if (amount <= 0) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Heart className="w-5 h-5 text-primary-600" />
|
||||
Your Impact
|
||||
</h3>
|
||||
|
||||
<div className="text-2xl font-bold text-primary-600 mb-4">
|
||||
{formatCurrency(amount)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{impact.students > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.students}
|
||||
label={`student${impact.students > 1 ? 's' : ''} with supplies`}
|
||||
/>
|
||||
)}
|
||||
{impact.backpacks > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.backpacks}
|
||||
label={`backpack kit${impact.backpacks > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{impact.clothing > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.clothing}
|
||||
label={`clothing item${impact.clothing > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{impact.emergency > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.emergency}
|
||||
label={`emergency response${impact.emergency > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Annual Impact:</strong> {impact.annual.totalImpact}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const ImpactItem: React.FC<{ count: number; label: string }> = ({ count, label }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm">
|
||||
<strong>{count}</strong> {label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Donation Form Component
|
||||
interface DonationFormProps {
|
||||
amount: number
|
||||
}
|
||||
|
||||
const DonationForm: React.FC<DonationFormProps> = ({ amount }) => {
|
||||
const [paymentMethod, setPaymentMethod] = useState<'card' | 'paypal'>('card')
|
||||
|
||||
const formValidation = useFormValidation(
|
||||
{
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
},
|
||||
{
|
||||
email: (value) => {
|
||||
if (!value) return 'Email is required'
|
||||
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format'
|
||||
return null
|
||||
},
|
||||
firstName: (value) => !value ? 'First name is required' : null,
|
||||
lastName: (value) => !value ? 'Last name is required' : null,
|
||||
phone: (value) => {
|
||||
if (!value) return null // Optional
|
||||
if (!/^\+?[\d\s\-\(\)]{10,}$/.test(value)) return 'Invalid phone format'
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent): void => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formValidation.validate()) {
|
||||
trackEvent('donation_form_submitted', {
|
||||
amount,
|
||||
payment_method: paymentMethod,
|
||||
form_valid: true
|
||||
})
|
||||
|
||||
// Process donation
|
||||
console.log('Processing donation:', { amount, paymentMethod, ...formValidation.values })
|
||||
} else {
|
||||
trackEvent('donation_form_error', {
|
||||
amount,
|
||||
errors: Object.keys(formValidation.errors)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Complete Your Donation
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formValidation.values.firstName}
|
||||
onChange={(e) => formValidation.setValue('firstName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800"
|
||||
/>
|
||||
{formValidation.errors.firstName && (
|
||||
<p className="text-red-500 text-sm mt-1">{formValidation.errors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formValidation.values.lastName}
|
||||
onChange={(e) => formValidation.setValue('lastName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800"
|
||||
/>
|
||||
{formValidation.errors.lastName && (
|
||||
<p className="text-red-500 text-sm mt-1">{formValidation.errors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formValidation.values.email}
|
||||
onChange={(e) => formValidation.setValue('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800"
|
||||
/>
|
||||
{formValidation.errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{formValidation.errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Payment Method
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className={`p-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-colors ${
|
||||
paymentMethod === 'card'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Credit Card
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('paypal')}
|
||||
className={`p-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-colors ${
|
||||
paymentMethod === 'paypal'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
PayPal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={amount <= 0}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full btn-primary py-4 text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Donate {formatCurrency(amount)}
|
||||
</motion.button>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Trust Badges Component
|
||||
const TrustBadges: React.FC = () => (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Secure & Trusted
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">SSL Encrypted Payments</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">501(c)3 Tax Deductible</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">100% Goes to Students</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500">
|
||||
EIN: 12-3456789 • All donations are tax-deductible to the fullest extent allowed by law.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Heart, CreditCard, Shield, Lock, CheckCircle2 } from 'lucide-react'
|
||||
import { PageShell, Card } from '@/layouts/MainLayout'
|
||||
import { useDonationImpact } from '@/hooks/useDonationImpact'
|
||||
import { useFormValidation } from '@/hooks/useDonationImpact'
|
||||
import { formatCurrency } from '@/utils/helpers'
|
||||
import { trackEvent, trackPageView } from '@/utils/analytics'
|
||||
|
||||
interface DonatePageProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const DonatePage: React.FC<DonatePageProps> = ({ className }) => {
|
||||
const [selectedAmount, setSelectedAmount] = useState<number>(50)
|
||||
const [customAmount, setCustomAmount] = useState<string>('')
|
||||
const [isCustom, setIsCustom] = useState<boolean>(false)
|
||||
|
||||
const finalAmount = isCustom && customAmount ? parseInt(customAmount) || 0 : selectedAmount
|
||||
const impact = useDonationImpact(finalAmount)
|
||||
|
||||
useEffect(() => {
|
||||
trackPageView('/donate', 'Donate - Support Students in Need')
|
||||
}, [])
|
||||
|
||||
const donationTiers = [
|
||||
{ amount: 25, label: "Supplies for one student" },
|
||||
{ amount: 50, label: "Fill a backpack with essentials" },
|
||||
{ amount: 75, label: "Shoes + warm coat" },
|
||||
{ amount: 100, label: "Complete school outfit & supplies" },
|
||||
]
|
||||
|
||||
const handleAmountSelect = (amount: number): void => {
|
||||
setSelectedAmount(amount)
|
||||
setIsCustom(false)
|
||||
setCustomAmount('')
|
||||
trackEvent('donation_amount_selected', { amount, type: 'preset' })
|
||||
}
|
||||
|
||||
const handleCustomAmount = (value: string): void => {
|
||||
setCustomAmount(value)
|
||||
setIsCustom(true)
|
||||
if (parseInt(value)) {
|
||||
trackEvent('donation_amount_selected', { amount: parseInt(value), type: 'custom' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
title="Make a Difference Today"
|
||||
icon={Heart}
|
||||
eyebrow="Every dollar counts"
|
||||
subtitle="Your donation directly supports students with school supplies, clothing, and emergency assistance."
|
||||
className={className}
|
||||
>
|
||||
<div className="grid gap-8 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Donation Tiers */}
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Choose Your Impact Level
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
{donationTiers.map((tier) => (
|
||||
<button
|
||||
key={tier.amount}
|
||||
onClick={() => handleAmountSelect(tier.amount)}
|
||||
className={`p-4 rounded-lg border-2 transition-all duration-200 text-left ${
|
||||
selectedAmount === tier.amount && !isCustom
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-primary-300'
|
||||
}`}
|
||||
>
|
||||
<div className="font-semibold text-lg text-gray-900 dark:text-white">
|
||||
{formatCurrency(tier.amount)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{tier.label}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Or enter a custom amount
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-500">
|
||||
$
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={customAmount}
|
||||
onChange={(e) => handleCustomAmount(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Donation Form */}
|
||||
<DonationForm amount={finalAmount} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Impact Calculator */}
|
||||
<ImpactCalculator impact={impact} amount={finalAmount} />
|
||||
|
||||
{/* Trust Badges */}
|
||||
<TrustBadges />
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
// Impact Calculator Component
|
||||
interface ImpactCalculatorProps {
|
||||
impact: ReturnType<typeof useDonationImpact>
|
||||
amount: number
|
||||
}
|
||||
|
||||
const ImpactCalculator: React.FC<ImpactCalculatorProps> = ({ impact, amount }) => {
|
||||
if (amount <= 0) return null
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||
<Heart className="w-5 h-5 text-primary-600" />
|
||||
Your Impact
|
||||
</h3>
|
||||
|
||||
<div className="text-2xl font-bold text-primary-600 mb-4">
|
||||
{formatCurrency(amount)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{impact.students > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.students}
|
||||
label={`student${impact.students > 1 ? 's' : ''} with supplies`}
|
||||
/>
|
||||
)}
|
||||
{impact.backpacks > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.backpacks}
|
||||
label={`backpack kit${impact.backpacks > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{impact.clothing > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.clothing}
|
||||
label={`clothing item${impact.clothing > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{impact.emergency > 0 && (
|
||||
<ImpactItem
|
||||
count={impact.emergency}
|
||||
label={`emergency response${impact.emergency > 1 ? 's' : ''}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<strong>Annual Impact:</strong> {impact.annual.totalImpact}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const ImpactItem: React.FC<{ count: number; label: string }> = ({ count, label }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
<span className="text-sm">
|
||||
<strong>{count}</strong> {label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
// Donation Form Component
|
||||
interface DonationFormProps {
|
||||
amount: number
|
||||
}
|
||||
|
||||
const DonationForm: React.FC<DonationFormProps> = ({ amount }) => {
|
||||
const [paymentMethod, setPaymentMethod] = useState<'card' | 'paypal'>('card')
|
||||
|
||||
const formValidation = useFormValidation(
|
||||
{
|
||||
email: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: ''
|
||||
},
|
||||
{
|
||||
email: (value) => {
|
||||
if (!value) return 'Email is required'
|
||||
if (!/\S+@\S+\.\S+/.test(value)) return 'Invalid email format'
|
||||
return null
|
||||
},
|
||||
firstName: (value) => !value ? 'First name is required' : null,
|
||||
lastName: (value) => !value ? 'Last name is required' : null,
|
||||
phone: (value) => {
|
||||
if (!value) return null // Optional
|
||||
if (!/^\+?[\d\s\-\(\)]{10,}$/.test(value)) return 'Invalid phone format'
|
||||
return null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent): void => {
|
||||
e.preventDefault()
|
||||
|
||||
if (formValidation.validate()) {
|
||||
trackEvent('donation_form_submitted', {
|
||||
amount,
|
||||
payment_method: paymentMethod,
|
||||
form_valid: true
|
||||
})
|
||||
|
||||
// Process donation
|
||||
console.log('Processing donation:', { amount, paymentMethod, ...formValidation.values })
|
||||
} else {
|
||||
trackEvent('donation_form_error', {
|
||||
amount,
|
||||
errors: Object.keys(formValidation.errors)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Complete Your Donation
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Contact Information */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
First Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formValidation.values.firstName}
|
||||
onChange={(e) => formValidation.setValue('firstName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800"
|
||||
/>
|
||||
{formValidation.errors.firstName && (
|
||||
<p className="text-red-500 text-sm mt-1">{formValidation.errors.firstName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Last Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formValidation.values.lastName}
|
||||
onChange={(e) => formValidation.setValue('lastName', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800"
|
||||
/>
|
||||
{formValidation.errors.lastName && (
|
||||
<p className="text-red-500 text-sm mt-1">{formValidation.errors.lastName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Email Address *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formValidation.values.email}
|
||||
onChange={(e) => formValidation.setValue('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800"
|
||||
/>
|
||||
{formValidation.errors.email && (
|
||||
<p className="text-red-500 text-sm mt-1">{formValidation.errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Payment Method
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className={`p-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-colors ${
|
||||
paymentMethod === 'card'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-5 h-5" />
|
||||
Credit Card
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPaymentMethod('paypal')}
|
||||
className={`p-3 rounded-lg border-2 flex items-center justify-center gap-2 transition-colors ${
|
||||
paymentMethod === 'paypal'
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
PayPal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={amount <= 0}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full btn-primary py-4 text-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Donate {formatCurrency(amount)}
|
||||
</motion.button>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Trust Badges Component
|
||||
const TrustBadges: React.FC = () => (
|
||||
<Card>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
Secure & Trusted
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">SSL Encrypted Payments</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Lock className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">501(c)3 Tax Deductible</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<span className="text-sm">100% Goes to Students</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-gray-500">
|
||||
EIN: 12-3456789 • All donations are tax-deductible to the fullest extent allowed by law.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
+203
-203
@@ -1,204 +1,204 @@
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Heart, Globe, ArrowRight, CheckCircle2 } from 'lucide-react'
|
||||
import { MainLayout, SectionHeader, Card } from '@/layouts/MainLayout'
|
||||
import { trackEvent, trackPageView } from '@/utils/analytics'
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
trackPageView('/', 'Home - Miracles In Motion')
|
||||
}, [])
|
||||
|
||||
const handleDonateClick = (): void => {
|
||||
trackEvent('cta_clicked', { button: 'hero_donate', location: 'homepage' })
|
||||
window.location.hash = '/donate'
|
||||
}
|
||||
|
||||
const handleVolunteerClick = (): void => {
|
||||
trackEvent('cta_clicked', { button: 'hero_volunteer', location: 'homepage' })
|
||||
window.location.hash = '/volunteer'
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<Heart className="w-8 h-8 text-primary-600" />
|
||||
<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 font-bold text-gray-900 dark:text-white mb-6">
|
||||
Miracles in <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-pink-600">Motion</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-12 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-4 justify-center">
|
||||
<button
|
||||
onClick={handleDonateClick}
|
||||
className="btn-primary text-lg px-8 py-4 group"
|
||||
>
|
||||
Donate Now
|
||||
<Heart className="w-5 h-5 ml-2 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleVolunteerClick}
|
||||
className="btn-secondary text-lg px-8 py-4 group"
|
||||
>
|
||||
Volunteer Today
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary-200/30 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-pink-200/30 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Impact Stats */}
|
||||
<section className="py-16 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeader
|
||||
eyebrow="Our Impact"
|
||||
title="Making a Real Difference"
|
||||
subtitle="Transparent, measurable outcomes powered by community partnerships"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ImpactStat label="Students Helped" value="4,200+" />
|
||||
<ImpactStat label="Schools Partnered" value="38" />
|
||||
<ImpactStat label="Avg Response Time" value="24 hrs" />
|
||||
<ImpactStat label="Counties Served" value="6" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What We Do */}
|
||||
<section className="py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeader
|
||||
eyebrow="What We Do"
|
||||
title="Supporting Student Success"
|
||||
subtitle="Comprehensive support to remove barriers and create opportunities"
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<ServiceCard
|
||||
icon={<CheckCircle2 className="w-8 h-8 text-primary-600" />}
|
||||
title="School Supplies"
|
||||
description="Backpacks, notebooks, pencils, and all the essentials students need to succeed in the classroom."
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
icon={<Heart className="w-8 h-8 text-primary-600" />}
|
||||
title="Clothing & Shoes"
|
||||
description="Weather-appropriate clothing and sturdy shoes so students can attend school with confidence."
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
icon={<Globe className="w-8 h-8 text-primary-600" />}
|
||||
title="Emergency Support"
|
||||
description="Rapid response assistance for urgent needs including food, transportation, and crisis support."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="py-20 bg-gradient-to-r from-primary-600 to-pink-600">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-6">
|
||||
Ready to Make a Difference?
|
||||
</h2>
|
||||
<p className="text-xl text-white/90 mb-8">
|
||||
Join our community of supporters helping students succeed. Every contribution creates ripples of positive change.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDonateClick}
|
||||
className="bg-white text-primary-600 hover:bg-gray-50 font-semibold px-8 py-4 rounded-lg transition-colors"
|
||||
>
|
||||
Start Supporting Students Today
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Impact Stat Component
|
||||
interface ImpactStatProps {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const ImpactStat: React.FC<ImpactStatProps> = ({ label, value }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<div className="text-3xl md:text-4xl font-bold text-primary-600 mb-2">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-300 font-medium">
|
||||
{label}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// Service Card Component
|
||||
interface ServiceCardProps {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ServiceCard: React.FC<ServiceCardProps> = ({ icon, title, description }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="h-full text-center hover:shadow-xl transition-shadow duration-300">
|
||||
<div className="flex justify-center mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Heart, Globe, ArrowRight, CheckCircle2 } from 'lucide-react'
|
||||
import { MainLayout, SectionHeader, Card } from '@/layouts/MainLayout'
|
||||
import { trackEvent, trackPageView } from '@/utils/analytics'
|
||||
|
||||
export const HomePage: React.FC = () => {
|
||||
React.useEffect(() => {
|
||||
trackPageView('/', 'Home - Miracles In Motion')
|
||||
}, [])
|
||||
|
||||
const handleDonateClick = (): void => {
|
||||
trackEvent('cta_clicked', { button: 'hero_donate', location: 'homepage' })
|
||||
window.location.hash = '/donate'
|
||||
}
|
||||
|
||||
const handleVolunteerClick = (): void => {
|
||||
trackEvent('cta_clicked', { button: 'hero_volunteer', location: 'homepage' })
|
||||
window.location.hash = '/volunteer'
|
||||
}
|
||||
|
||||
return (
|
||||
<MainLayout>
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-20">
|
||||
<div className="text-center">
|
||||
<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">
|
||||
<Heart className="w-8 h-8 text-primary-600" />
|
||||
<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 font-bold text-gray-900 dark:text-white mb-6">
|
||||
Miracles in <span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-600 to-pink-600">Motion</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 max-w-3xl mx-auto mb-12 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-4 justify-center">
|
||||
<button
|
||||
onClick={handleDonateClick}
|
||||
className="btn-primary text-lg px-8 py-4 group"
|
||||
>
|
||||
Donate Now
|
||||
<Heart className="w-5 h-5 ml-2 group-hover:scale-110 transition-transform" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleVolunteerClick}
|
||||
className="btn-secondary text-lg px-8 py-4 group"
|
||||
>
|
||||
Volunteer Today
|
||||
<ArrowRight className="w-5 h-5 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Background Elements */}
|
||||
<div className="absolute inset-0 -z-10">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-primary-200/30 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-pink-200/30 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Impact Stats */}
|
||||
<section className="py-16 bg-white/50 dark:bg-gray-800/50 backdrop-blur-sm">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeader
|
||||
eyebrow="Our Impact"
|
||||
title="Making a Real Difference"
|
||||
subtitle="Transparent, measurable outcomes powered by community partnerships"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<ImpactStat label="Students Helped" value="4,200+" />
|
||||
<ImpactStat label="Schools Partnered" value="38" />
|
||||
<ImpactStat label="Avg Response Time" value="24 hrs" />
|
||||
<ImpactStat label="Counties Served" value="6" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What We Do */}
|
||||
<section className="py-20">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<SectionHeader
|
||||
eyebrow="What We Do"
|
||||
title="Supporting Student Success"
|
||||
subtitle="Comprehensive support to remove barriers and create opportunities"
|
||||
/>
|
||||
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<ServiceCard
|
||||
icon={<CheckCircle2 className="w-8 h-8 text-primary-600" />}
|
||||
title="School Supplies"
|
||||
description="Backpacks, notebooks, pencils, and all the essentials students need to succeed in the classroom."
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
icon={<Heart className="w-8 h-8 text-primary-600" />}
|
||||
title="Clothing & Shoes"
|
||||
description="Weather-appropriate clothing and sturdy shoes so students can attend school with confidence."
|
||||
/>
|
||||
|
||||
<ServiceCard
|
||||
icon={<Globe className="w-8 h-8 text-primary-600" />}
|
||||
title="Emergency Support"
|
||||
description="Rapid response assistance for urgent needs including food, transportation, and crisis support."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action */}
|
||||
<section className="py-20 bg-gradient-to-r from-primary-600 to-pink-600">
|
||||
<div className="mx-auto max-w-4xl px-4 sm:px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<h2 className="text-3xl md:text-4xl font-bold text-white mb-6">
|
||||
Ready to Make a Difference?
|
||||
</h2>
|
||||
<p className="text-xl text-white/90 mb-8">
|
||||
Join our community of supporters helping students succeed. Every contribution creates ripples of positive change.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDonateClick}
|
||||
className="bg-white text-primary-600 hover:bg-gray-50 font-semibold px-8 py-4 rounded-lg transition-colors"
|
||||
>
|
||||
Start Supporting Students Today
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
|
||||
// Impact Stat Component
|
||||
interface ImpactStatProps {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const ImpactStat: React.FC<ImpactStatProps> = ({ label, value }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center"
|
||||
>
|
||||
<Card className="h-full">
|
||||
<div className="text-3xl md:text-4xl font-bold text-primary-600 mb-2">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-300 font-medium">
|
||||
{label}
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
|
||||
// Service Card Component
|
||||
interface ServiceCardProps {
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
const ServiceCard: React.FC<ServiceCardProps> = ({ icon, title, description }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
whileHover={{ y: -5 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Card className="h-full text-center hover:shadow-xl transition-shadow duration-300">
|
||||
<div className="flex justify-center mb-4">
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
+479
-479
@@ -1,480 +1,480 @@
|
||||
// Phase 3B: Browser-Compatible Real-Time Processing System
|
||||
import type { StudentRequest, MatchResult, AIUpdate } from '../ai/types'
|
||||
|
||||
// Browser-compatible AI processor
|
||||
class BrowserAIProcessor {
|
||||
async processRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Mock implementation for browser
|
||||
console.log('Processing request in browser:', request.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
return [{
|
||||
resourceId: 'browser-resource-1',
|
||||
resourceName: 'Browser Mock Resource',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.75,
|
||||
estimatedImpact: 7.5,
|
||||
logisticalComplexity: 2.0,
|
||||
estimatedCost: 45,
|
||||
fulfillmentTimeline: '2-4 days',
|
||||
reasoningFactors: ['Browser-based processing', 'Mock data for demo'],
|
||||
riskFactors: ['Demo mode - not real matching']
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
interface RealTimeConfig {
|
||||
enableWebSockets: boolean
|
||||
batchSize: number
|
||||
processingInterval: number
|
||||
maxConcurrentRequests: number
|
||||
enablePredictiveLoading: boolean
|
||||
cacheTimeout: number
|
||||
enableOfflineMode: boolean
|
||||
}
|
||||
|
||||
interface ProcessingMetrics {
|
||||
totalProcessed: number
|
||||
averageProcessingTime: number
|
||||
successRate: number
|
||||
errorCount: number
|
||||
queueLength: number
|
||||
activeProcessors: number
|
||||
throughputPerMinute: number
|
||||
lastProcessedAt: Date
|
||||
}
|
||||
|
||||
interface DataSyncStatus {
|
||||
salesforceSync: boolean
|
||||
databaseSync: boolean
|
||||
cacheSync: boolean
|
||||
aiModelSync: boolean
|
||||
lastSyncAt: Date
|
||||
pendingSyncCount: number
|
||||
}
|
||||
|
||||
class RealTimeProcessor {
|
||||
private ai: BrowserAIProcessor
|
||||
private salesforce?: any // Mock for browser demo
|
||||
private config: RealTimeConfig
|
||||
private processingQueue: StudentRequest[] = []
|
||||
private activeProcessors = new Map<string, Promise<MatchResult[]>>()
|
||||
private metrics: ProcessingMetrics
|
||||
private syncStatus: DataSyncStatus
|
||||
private subscribers: ((update: AIUpdate) => void)[] = []
|
||||
private websocket?: WebSocket
|
||||
private processTimer?: number
|
||||
private isProcessing = false
|
||||
|
||||
constructor(config: RealTimeConfig, salesforceConfig?: any) {
|
||||
this.config = config
|
||||
this.ai = new BrowserAIProcessor()
|
||||
|
||||
if (salesforceConfig) {
|
||||
this.salesforce = { mock: true } // Mock for browser demo
|
||||
}
|
||||
|
||||
this.metrics = {
|
||||
totalProcessed: 0,
|
||||
averageProcessingTime: 0,
|
||||
successRate: 0,
|
||||
errorCount: 0,
|
||||
queueLength: 0,
|
||||
activeProcessors: 0,
|
||||
throughputPerMinute: 0,
|
||||
lastProcessedAt: new Date()
|
||||
}
|
||||
|
||||
this.syncStatus = {
|
||||
salesforceSync: false,
|
||||
databaseSync: false,
|
||||
cacheSync: false,
|
||||
aiModelSync: false,
|
||||
lastSyncAt: new Date(),
|
||||
pendingSyncCount: 0
|
||||
}
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initializing Real-Time Processing System
|
||||
|
||||
// Initialize AI engine
|
||||
// AI Engine ready
|
||||
|
||||
// Initialize Salesforce connection
|
||||
if (this.salesforce) {
|
||||
const connected = await this.salesforce.authenticate()
|
||||
this.syncStatus.salesforceSync = connected
|
||||
console.log(connected ? '✅ Salesforce connected' : '⚠️ Salesforce connection failed')
|
||||
}
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
if (this.config.enableWebSockets) {
|
||||
this.setupWebSocket()
|
||||
}
|
||||
|
||||
// Start processing timer
|
||||
this.startProcessingTimer()
|
||||
|
||||
console.log('🎯 Real-Time Processing System Online')
|
||||
} catch (error) {
|
||||
// Failed to initialize real-time processor - error handled
|
||||
}
|
||||
}
|
||||
|
||||
private setupWebSocket(): void {
|
||||
try {
|
||||
const wsUrl = process.env.NODE_ENV === 'production'
|
||||
? 'wss://miracles-in-motion.org/websocket'
|
||||
: 'ws://localhost:8080/websocket'
|
||||
|
||||
this.websocket = new WebSocket(wsUrl)
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
// WebSocket connected for real-time updates
|
||||
this.broadcastUpdate({
|
||||
type: 'model-updated',
|
||||
message: 'Real-time processing system online',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleWebSocketMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.websocket.onclose = () => {
|
||||
console.log('🔌 WebSocket disconnected, attempting reconnection...')
|
||||
setTimeout(() => this.setupWebSocket(), 5000)
|
||||
}
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to setup WebSocket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private handleWebSocketMessage(data: any): void {
|
||||
switch (data.type) {
|
||||
case 'new-request':
|
||||
this.addToQueue(data.request)
|
||||
break
|
||||
case 'priority-update':
|
||||
this.updateRequestPriority(data.requestId, data.priority)
|
||||
break
|
||||
case 'system-status':
|
||||
this.handleSystemStatusUpdate(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private startProcessingTimer(): void {
|
||||
this.processTimer = window.setInterval(() => {
|
||||
if (!this.isProcessing && this.processingQueue.length > 0) {
|
||||
this.processNextBatch()
|
||||
}
|
||||
this.updateMetrics()
|
||||
}, this.config.processingInterval)
|
||||
}
|
||||
|
||||
// Public API Methods
|
||||
public addToQueue(request: StudentRequest): void {
|
||||
this.processingQueue.push(request)
|
||||
this.metrics.queueLength = this.processingQueue.length
|
||||
|
||||
this.broadcastUpdate({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
status: 'queued',
|
||||
message: `Request added to processing queue (${this.metrics.queueLength} pending)`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Trigger immediate processing if queue was empty
|
||||
if (this.processingQueue.length === 1 && !this.isProcessing) {
|
||||
setTimeout(() => this.processNextBatch(), 100)
|
||||
}
|
||||
}
|
||||
|
||||
public async processNextBatch(): Promise<void> {
|
||||
if (this.isProcessing || this.processingQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
const batchSize = Math.min(this.config.batchSize, this.processingQueue.length)
|
||||
const batch = this.processingQueue.splice(0, batchSize)
|
||||
|
||||
console.log(`📊 Processing batch of ${batch.length} requests...`)
|
||||
|
||||
const processingPromises = batch.map(request => this.processSingleRequest(request))
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(processingPromises)
|
||||
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++
|
||||
this.handleProcessingSuccess(batch[index], result.value)
|
||||
} else {
|
||||
errorCount++
|
||||
this.handleProcessingError(batch[index], result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
// Update metrics
|
||||
this.metrics.totalProcessed += batch.length
|
||||
this.metrics.errorCount += errorCount
|
||||
this.metrics.successRate = (this.metrics.totalProcessed - this.metrics.errorCount) / this.metrics.totalProcessed
|
||||
this.metrics.lastProcessedAt = new Date()
|
||||
|
||||
console.log(`✅ Batch complete: ${successCount} success, ${errorCount} errors`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Batch processing error:', error)
|
||||
} finally {
|
||||
this.isProcessing = false
|
||||
this.metrics.queueLength = this.processingQueue.length
|
||||
}
|
||||
}
|
||||
|
||||
private async processSingleRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Process with AI
|
||||
const matches = await this.ai.processRequest(request)
|
||||
|
||||
// Create Salesforce case if connected
|
||||
if (this.salesforce && matches.length > 0) {
|
||||
const caseId = await this.salesforce.createAssistanceCase(request)
|
||||
if (caseId) {
|
||||
await this.salesforce.updateCaseWithMatching(caseId, matches[0])
|
||||
}
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
this.updateAverageProcessingTime(processingTime)
|
||||
|
||||
return matches
|
||||
} catch (error) {
|
||||
console.error(`Error processing request ${request.id}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessingSuccess(request: StudentRequest, matches: MatchResult[]): void {
|
||||
this.broadcastUpdate({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
status: 'completed',
|
||||
recommendations: matches,
|
||||
message: `Found ${matches.length} potential matches`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Auto-approve high confidence matches
|
||||
const highConfidenceMatches = matches.filter(match => match.confidenceScore >= 0.85)
|
||||
if (highConfidenceMatches.length > 0) {
|
||||
this.broadcastUpdate({
|
||||
type: 'auto-approval',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Auto-approved ${highConfidenceMatches.length} high-confidence matches`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessingError(request: StudentRequest, error: any): void {
|
||||
console.error(`Processing failed for ${request.id}:`, error)
|
||||
|
||||
this.broadcastUpdate({
|
||||
type: 'alert',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Processing failed: ${error.message || 'Unknown error'}`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Re-queue with lower priority if retryable
|
||||
if (this.isRetryableError(error)) {
|
||||
setTimeout(() => {
|
||||
this.processingQueue.push({ ...request, urgency: 'low' })
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
private isRetryableError(error: any): boolean {
|
||||
// Define retryable error conditions
|
||||
return error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'RATE_LIMIT' ||
|
||||
error.message?.includes('timeout')
|
||||
}
|
||||
|
||||
private updateAverageProcessingTime(newTime: number): void {
|
||||
const totalProcessed = this.metrics.totalProcessed
|
||||
const currentAverage = this.metrics.averageProcessingTime
|
||||
this.metrics.averageProcessingTime = ((currentAverage * totalProcessed) + newTime) / (totalProcessed + 1)
|
||||
}
|
||||
|
||||
private updateMetrics(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// Calculate throughput (simplified)
|
||||
this.metrics.throughputPerMinute = this.metrics.totalProcessed > 0 ?
|
||||
Math.round(this.metrics.totalProcessed / ((now - this.metrics.lastProcessedAt.getTime()) / 60000)) : 0
|
||||
|
||||
this.metrics.activeProcessors = this.activeProcessors.size
|
||||
}
|
||||
|
||||
private updateRequestPriority(requestId: string, newPriority: string): void {
|
||||
const requestIndex = this.processingQueue.findIndex(req => req.id === requestId)
|
||||
if (requestIndex !== -1) {
|
||||
this.processingQueue[requestIndex].urgency = newPriority as any
|
||||
|
||||
// Re-sort queue by priority
|
||||
this.processingQueue.sort((a, b) => {
|
||||
const priorityOrder = { emergency: 4, high: 3, medium: 2, low: 1 }
|
||||
return priorityOrder[b.urgency] - priorityOrder[a.urgency]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleSystemStatusUpdate(data: any): void {
|
||||
// Update sync status based on external system updates
|
||||
if (data.salesforce !== undefined) {
|
||||
this.syncStatus.salesforceSync = data.salesforce
|
||||
}
|
||||
if (data.database !== undefined) {
|
||||
this.syncStatus.databaseSync = data.database
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription methods for real-time updates
|
||||
public subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
this.subscribers.push(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.subscribers.indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.subscribers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastUpdate(update: AIUpdate): void {
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Error in subscriber callback:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Send to WebSocket if connected
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
this.websocket.send(JSON.stringify(update))
|
||||
}
|
||||
}
|
||||
|
||||
// Status and metrics getters
|
||||
public getMetrics(): ProcessingMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
public getSyncStatus(): DataSyncStatus {
|
||||
return { ...this.syncStatus }
|
||||
}
|
||||
|
||||
public getQueueStatus(): { length: number; processing: boolean; nextEstimatedTime?: number } {
|
||||
return {
|
||||
length: this.processingQueue.length,
|
||||
processing: this.isProcessing,
|
||||
nextEstimatedTime: this.processingQueue.length > 0 ?
|
||||
this.metrics.averageProcessingTime * Math.ceil(this.processingQueue.length / this.config.batchSize) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
public pause(): void {
|
||||
if (this.processTimer) {
|
||||
clearInterval(this.processTimer)
|
||||
this.processTimer = undefined
|
||||
}
|
||||
console.log('⏸️ Real-time processing paused')
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (!this.processTimer) {
|
||||
this.startProcessingTimer()
|
||||
console.log('▶️ Real-time processing resumed')
|
||||
}
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
console.log('🛑 Shutting down real-time processor...')
|
||||
|
||||
this.pause()
|
||||
|
||||
// Wait for active processors to complete
|
||||
if (this.activeProcessors.size > 0) {
|
||||
console.log(`⏳ Waiting for ${this.activeProcessors.size} active processors...`)
|
||||
await Promise.all(this.activeProcessors.values())
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (this.websocket) {
|
||||
this.websocket.close()
|
||||
}
|
||||
|
||||
console.log('✅ Real-time processor shutdown complete')
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function for easy initialization
|
||||
export const createRealTimeProcessor = (config: Partial<RealTimeConfig> = {}): RealTimeProcessor => {
|
||||
const defaultConfig: RealTimeConfig = {
|
||||
enableWebSockets: true,
|
||||
batchSize: 5,
|
||||
processingInterval: 2000,
|
||||
maxConcurrentRequests: 10,
|
||||
enablePredictiveLoading: true,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
enableOfflineMode: true
|
||||
}
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config }
|
||||
|
||||
// Salesforce config from environment variables
|
||||
const salesforceConfig = process.env.NODE_ENV === 'production' ? {
|
||||
instanceUrl: process.env.REACT_APP_SALESFORCE_URL || '',
|
||||
clientId: process.env.REACT_APP_SALESFORCE_CLIENT_ID || '',
|
||||
clientSecret: process.env.REACT_APP_SALESFORCE_CLIENT_SECRET || '',
|
||||
username: process.env.REACT_APP_SALESFORCE_USERNAME || '',
|
||||
password: process.env.REACT_APP_SALESFORCE_PASSWORD || '',
|
||||
securityToken: process.env.REACT_APP_SALESFORCE_TOKEN || '',
|
||||
apiVersion: '58.0'
|
||||
} : undefined
|
||||
|
||||
return new RealTimeProcessor(finalConfig, salesforceConfig)
|
||||
}
|
||||
|
||||
export { RealTimeProcessor }
|
||||
// Phase 3B: Browser-Compatible Real-Time Processing System
|
||||
import type { StudentRequest, MatchResult, AIUpdate } from '../ai/types'
|
||||
|
||||
// Browser-compatible AI processor
|
||||
class BrowserAIProcessor {
|
||||
async processRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
// Mock implementation for browser
|
||||
console.log('Processing request in browser:', request.id)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
return [{
|
||||
resourceId: 'browser-resource-1',
|
||||
resourceName: 'Browser Mock Resource',
|
||||
resourceType: 'supplies',
|
||||
confidenceScore: 0.75,
|
||||
estimatedImpact: 7.5,
|
||||
logisticalComplexity: 2.0,
|
||||
estimatedCost: 45,
|
||||
fulfillmentTimeline: '2-4 days',
|
||||
reasoningFactors: ['Browser-based processing', 'Mock data for demo'],
|
||||
riskFactors: ['Demo mode - not real matching']
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
interface RealTimeConfig {
|
||||
enableWebSockets: boolean
|
||||
batchSize: number
|
||||
processingInterval: number
|
||||
maxConcurrentRequests: number
|
||||
enablePredictiveLoading: boolean
|
||||
cacheTimeout: number
|
||||
enableOfflineMode: boolean
|
||||
}
|
||||
|
||||
interface ProcessingMetrics {
|
||||
totalProcessed: number
|
||||
averageProcessingTime: number
|
||||
successRate: number
|
||||
errorCount: number
|
||||
queueLength: number
|
||||
activeProcessors: number
|
||||
throughputPerMinute: number
|
||||
lastProcessedAt: Date
|
||||
}
|
||||
|
||||
interface DataSyncStatus {
|
||||
salesforceSync: boolean
|
||||
databaseSync: boolean
|
||||
cacheSync: boolean
|
||||
aiModelSync: boolean
|
||||
lastSyncAt: Date
|
||||
pendingSyncCount: number
|
||||
}
|
||||
|
||||
class RealTimeProcessor {
|
||||
private ai: BrowserAIProcessor
|
||||
private salesforce?: any // Mock for browser demo
|
||||
private config: RealTimeConfig
|
||||
private processingQueue: StudentRequest[] = []
|
||||
private activeProcessors = new Map<string, Promise<MatchResult[]>>()
|
||||
private metrics: ProcessingMetrics
|
||||
private syncStatus: DataSyncStatus
|
||||
private subscribers: ((update: AIUpdate) => void)[] = []
|
||||
private websocket?: WebSocket
|
||||
private processTimer?: number
|
||||
private isProcessing = false
|
||||
|
||||
constructor(config: RealTimeConfig, salesforceConfig?: any) {
|
||||
this.config = config
|
||||
this.ai = new BrowserAIProcessor()
|
||||
|
||||
if (salesforceConfig) {
|
||||
this.salesforce = { mock: true } // Mock for browser demo
|
||||
}
|
||||
|
||||
this.metrics = {
|
||||
totalProcessed: 0,
|
||||
averageProcessingTime: 0,
|
||||
successRate: 0,
|
||||
errorCount: 0,
|
||||
queueLength: 0,
|
||||
activeProcessors: 0,
|
||||
throughputPerMinute: 0,
|
||||
lastProcessedAt: new Date()
|
||||
}
|
||||
|
||||
this.syncStatus = {
|
||||
salesforceSync: false,
|
||||
databaseSync: false,
|
||||
cacheSync: false,
|
||||
aiModelSync: false,
|
||||
lastSyncAt: new Date(),
|
||||
pendingSyncCount: 0
|
||||
}
|
||||
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
try {
|
||||
// Initializing Real-Time Processing System
|
||||
|
||||
// Initialize AI engine
|
||||
// AI Engine ready
|
||||
|
||||
// Initialize Salesforce connection
|
||||
if (this.salesforce) {
|
||||
const connected = await this.salesforce.authenticate()
|
||||
this.syncStatus.salesforceSync = connected
|
||||
console.log(connected ? '✅ Salesforce connected' : '⚠️ Salesforce connection failed')
|
||||
}
|
||||
|
||||
// Setup WebSocket for real-time updates
|
||||
if (this.config.enableWebSockets) {
|
||||
this.setupWebSocket()
|
||||
}
|
||||
|
||||
// Start processing timer
|
||||
this.startProcessingTimer()
|
||||
|
||||
console.log('🎯 Real-Time Processing System Online')
|
||||
} catch (error) {
|
||||
// Failed to initialize real-time processor - error handled
|
||||
}
|
||||
}
|
||||
|
||||
private setupWebSocket(): void {
|
||||
try {
|
||||
const wsUrl = process.env.NODE_ENV === 'production'
|
||||
? 'wss://miracles-in-motion.org/websocket'
|
||||
: 'ws://localhost:8080/websocket'
|
||||
|
||||
this.websocket = new WebSocket(wsUrl)
|
||||
|
||||
this.websocket.onopen = () => {
|
||||
// WebSocket connected for real-time updates
|
||||
this.broadcastUpdate({
|
||||
type: 'model-updated',
|
||||
message: 'Real-time processing system online',
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
|
||||
this.websocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
this.handleWebSocketMessage(data)
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error)
|
||||
}
|
||||
}
|
||||
|
||||
this.websocket.onclose = () => {
|
||||
console.log('🔌 WebSocket disconnected, attempting reconnection...')
|
||||
setTimeout(() => this.setupWebSocket(), 5000)
|
||||
}
|
||||
|
||||
this.websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to setup WebSocket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private handleWebSocketMessage(data: any): void {
|
||||
switch (data.type) {
|
||||
case 'new-request':
|
||||
this.addToQueue(data.request)
|
||||
break
|
||||
case 'priority-update':
|
||||
this.updateRequestPriority(data.requestId, data.priority)
|
||||
break
|
||||
case 'system-status':
|
||||
this.handleSystemStatusUpdate(data)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private startProcessingTimer(): void {
|
||||
this.processTimer = window.setInterval(() => {
|
||||
if (!this.isProcessing && this.processingQueue.length > 0) {
|
||||
this.processNextBatch()
|
||||
}
|
||||
this.updateMetrics()
|
||||
}, this.config.processingInterval)
|
||||
}
|
||||
|
||||
// Public API Methods
|
||||
public addToQueue(request: StudentRequest): void {
|
||||
this.processingQueue.push(request)
|
||||
this.metrics.queueLength = this.processingQueue.length
|
||||
|
||||
this.broadcastUpdate({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
status: 'queued',
|
||||
message: `Request added to processing queue (${this.metrics.queueLength} pending)`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Trigger immediate processing if queue was empty
|
||||
if (this.processingQueue.length === 1 && !this.isProcessing) {
|
||||
setTimeout(() => this.processNextBatch(), 100)
|
||||
}
|
||||
}
|
||||
|
||||
public async processNextBatch(): Promise<void> {
|
||||
if (this.isProcessing || this.processingQueue.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
this.isProcessing = true
|
||||
const batchSize = Math.min(this.config.batchSize, this.processingQueue.length)
|
||||
const batch = this.processingQueue.splice(0, batchSize)
|
||||
|
||||
console.log(`📊 Processing batch of ${batch.length} requests...`)
|
||||
|
||||
const processingPromises = batch.map(request => this.processSingleRequest(request))
|
||||
|
||||
try {
|
||||
const results = await Promise.allSettled(processingPromises)
|
||||
|
||||
let successCount = 0
|
||||
let errorCount = 0
|
||||
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++
|
||||
this.handleProcessingSuccess(batch[index], result.value)
|
||||
} else {
|
||||
errorCount++
|
||||
this.handleProcessingError(batch[index], result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
// Update metrics
|
||||
this.metrics.totalProcessed += batch.length
|
||||
this.metrics.errorCount += errorCount
|
||||
this.metrics.successRate = (this.metrics.totalProcessed - this.metrics.errorCount) / this.metrics.totalProcessed
|
||||
this.metrics.lastProcessedAt = new Date()
|
||||
|
||||
console.log(`✅ Batch complete: ${successCount} success, ${errorCount} errors`)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Batch processing error:', error)
|
||||
} finally {
|
||||
this.isProcessing = false
|
||||
this.metrics.queueLength = this.processingQueue.length
|
||||
}
|
||||
}
|
||||
|
||||
private async processSingleRequest(request: StudentRequest): Promise<MatchResult[]> {
|
||||
const startTime = Date.now()
|
||||
|
||||
try {
|
||||
// Process with AI
|
||||
const matches = await this.ai.processRequest(request)
|
||||
|
||||
// Create Salesforce case if connected
|
||||
if (this.salesforce && matches.length > 0) {
|
||||
const caseId = await this.salesforce.createAssistanceCase(request)
|
||||
if (caseId) {
|
||||
await this.salesforce.updateCaseWithMatching(caseId, matches[0])
|
||||
}
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime
|
||||
this.updateAverageProcessingTime(processingTime)
|
||||
|
||||
return matches
|
||||
} catch (error) {
|
||||
console.error(`Error processing request ${request.id}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessingSuccess(request: StudentRequest, matches: MatchResult[]): void {
|
||||
this.broadcastUpdate({
|
||||
type: 'request-processed',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
status: 'completed',
|
||||
recommendations: matches,
|
||||
message: `Found ${matches.length} potential matches`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Auto-approve high confidence matches
|
||||
const highConfidenceMatches = matches.filter(match => match.confidenceScore >= 0.85)
|
||||
if (highConfidenceMatches.length > 0) {
|
||||
this.broadcastUpdate({
|
||||
type: 'auto-approval',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Auto-approved ${highConfidenceMatches.length} high-confidence matches`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleProcessingError(request: StudentRequest, error: any): void {
|
||||
console.error(`Processing failed for ${request.id}:`, error)
|
||||
|
||||
this.broadcastUpdate({
|
||||
type: 'alert',
|
||||
requestId: request.id,
|
||||
studentName: request.studentName,
|
||||
message: `Processing failed: ${error.message || 'Unknown error'}`,
|
||||
timestamp: new Date()
|
||||
})
|
||||
|
||||
// Re-queue with lower priority if retryable
|
||||
if (this.isRetryableError(error)) {
|
||||
setTimeout(() => {
|
||||
this.processingQueue.push({ ...request, urgency: 'low' })
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
private isRetryableError(error: any): boolean {
|
||||
// Define retryable error conditions
|
||||
return error.code === 'NETWORK_ERROR' ||
|
||||
error.code === 'RATE_LIMIT' ||
|
||||
error.message?.includes('timeout')
|
||||
}
|
||||
|
||||
private updateAverageProcessingTime(newTime: number): void {
|
||||
const totalProcessed = this.metrics.totalProcessed
|
||||
const currentAverage = this.metrics.averageProcessingTime
|
||||
this.metrics.averageProcessingTime = ((currentAverage * totalProcessed) + newTime) / (totalProcessed + 1)
|
||||
}
|
||||
|
||||
private updateMetrics(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// Calculate throughput (simplified)
|
||||
this.metrics.throughputPerMinute = this.metrics.totalProcessed > 0 ?
|
||||
Math.round(this.metrics.totalProcessed / ((now - this.metrics.lastProcessedAt.getTime()) / 60000)) : 0
|
||||
|
||||
this.metrics.activeProcessors = this.activeProcessors.size
|
||||
}
|
||||
|
||||
private updateRequestPriority(requestId: string, newPriority: string): void {
|
||||
const requestIndex = this.processingQueue.findIndex(req => req.id === requestId)
|
||||
if (requestIndex !== -1) {
|
||||
this.processingQueue[requestIndex].urgency = newPriority as any
|
||||
|
||||
// Re-sort queue by priority
|
||||
this.processingQueue.sort((a, b) => {
|
||||
const priorityOrder = { emergency: 4, high: 3, medium: 2, low: 1 }
|
||||
return priorityOrder[b.urgency] - priorityOrder[a.urgency]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private handleSystemStatusUpdate(data: any): void {
|
||||
// Update sync status based on external system updates
|
||||
if (data.salesforce !== undefined) {
|
||||
this.syncStatus.salesforceSync = data.salesforce
|
||||
}
|
||||
if (data.database !== undefined) {
|
||||
this.syncStatus.databaseSync = data.database
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription methods for real-time updates
|
||||
public subscribe(callback: (update: AIUpdate) => void): () => void {
|
||||
this.subscribers.push(callback)
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.subscribers.indexOf(callback)
|
||||
if (index > -1) {
|
||||
this.subscribers.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastUpdate(update: AIUpdate): void {
|
||||
this.subscribers.forEach(callback => {
|
||||
try {
|
||||
callback(update)
|
||||
} catch (error) {
|
||||
console.error('Error in subscriber callback:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Send to WebSocket if connected
|
||||
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||
this.websocket.send(JSON.stringify(update))
|
||||
}
|
||||
}
|
||||
|
||||
// Status and metrics getters
|
||||
public getMetrics(): ProcessingMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
public getSyncStatus(): DataSyncStatus {
|
||||
return { ...this.syncStatus }
|
||||
}
|
||||
|
||||
public getQueueStatus(): { length: number; processing: boolean; nextEstimatedTime?: number } {
|
||||
return {
|
||||
length: this.processingQueue.length,
|
||||
processing: this.isProcessing,
|
||||
nextEstimatedTime: this.processingQueue.length > 0 ?
|
||||
this.metrics.averageProcessingTime * Math.ceil(this.processingQueue.length / this.config.batchSize) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Lifecycle management
|
||||
public pause(): void {
|
||||
if (this.processTimer) {
|
||||
clearInterval(this.processTimer)
|
||||
this.processTimer = undefined
|
||||
}
|
||||
console.log('⏸️ Real-time processing paused')
|
||||
}
|
||||
|
||||
public resume(): void {
|
||||
if (!this.processTimer) {
|
||||
this.startProcessingTimer()
|
||||
console.log('▶️ Real-time processing resumed')
|
||||
}
|
||||
}
|
||||
|
||||
public async shutdown(): Promise<void> {
|
||||
console.log('🛑 Shutting down real-time processor...')
|
||||
|
||||
this.pause()
|
||||
|
||||
// Wait for active processors to complete
|
||||
if (this.activeProcessors.size > 0) {
|
||||
console.log(`⏳ Waiting for ${this.activeProcessors.size} active processors...`)
|
||||
await Promise.all(this.activeProcessors.values())
|
||||
}
|
||||
|
||||
// Close WebSocket
|
||||
if (this.websocket) {
|
||||
this.websocket.close()
|
||||
}
|
||||
|
||||
console.log('✅ Real-time processor shutdown complete')
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function for easy initialization
|
||||
export const createRealTimeProcessor = (config: Partial<RealTimeConfig> = {}): RealTimeProcessor => {
|
||||
const defaultConfig: RealTimeConfig = {
|
||||
enableWebSockets: true,
|
||||
batchSize: 5,
|
||||
processingInterval: 2000,
|
||||
maxConcurrentRequests: 10,
|
||||
enablePredictiveLoading: true,
|
||||
cacheTimeout: 300000, // 5 minutes
|
||||
enableOfflineMode: true
|
||||
}
|
||||
|
||||
const finalConfig = { ...defaultConfig, ...config }
|
||||
|
||||
// Salesforce config from environment variables
|
||||
const salesforceConfig = process.env.NODE_ENV === 'production' ? {
|
||||
instanceUrl: process.env.REACT_APP_SALESFORCE_URL || '',
|
||||
clientId: process.env.REACT_APP_SALESFORCE_CLIENT_ID || '',
|
||||
clientSecret: process.env.REACT_APP_SALESFORCE_CLIENT_SECRET || '',
|
||||
username: process.env.REACT_APP_SALESFORCE_USERNAME || '',
|
||||
password: process.env.REACT_APP_SALESFORCE_PASSWORD || '',
|
||||
securityToken: process.env.REACT_APP_SALESFORCE_TOKEN || '',
|
||||
apiVersion: '58.0'
|
||||
} : undefined
|
||||
|
||||
return new RealTimeProcessor(finalConfig, salesforceConfig)
|
||||
}
|
||||
|
||||
export { RealTimeProcessor }
|
||||
export type { RealTimeConfig, ProcessingMetrics, DataSyncStatus }
|
||||
+77
-77
@@ -1,78 +1,78 @@
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
|
||||
Object.defineProperty(global, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
window.ResizeObserver = MockResizeObserver
|
||||
global.ResizeObserver = MockResizeObserver
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
})
|
||||
|
||||
// Mock window.location
|
||||
delete (window as any).location
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
hash: '#/',
|
||||
pathname: '/',
|
||||
get search() {
|
||||
return '';
|
||||
},
|
||||
get href() {
|
||||
return 'http://localhost:3000/';
|
||||
},
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
},
|
||||
import '@testing-library/jest-dom'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock IntersectionObserver
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
|
||||
Object.defineProperty(global, 'IntersectionObserver', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: MockIntersectionObserver,
|
||||
})
|
||||
|
||||
// Mock ResizeObserver
|
||||
class MockResizeObserver {
|
||||
observe = vi.fn()
|
||||
disconnect = vi.fn()
|
||||
unobserve = vi.fn()
|
||||
}
|
||||
|
||||
window.ResizeObserver = MockResizeObserver
|
||||
global.ResizeObserver = MockResizeObserver
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
})
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn(),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock
|
||||
})
|
||||
|
||||
// Mock window.location
|
||||
delete (window as any).location
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: {
|
||||
...window.location,
|
||||
hash: '#/',
|
||||
pathname: '/',
|
||||
get search() {
|
||||
return '';
|
||||
},
|
||||
get href() {
|
||||
return 'http://localhost:3000/';
|
||||
},
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
},
|
||||
});
|
||||
+90
-90
@@ -1,91 +1,91 @@
|
||||
import { ReactElement } from 'react'
|
||||
import { render, RenderOptions } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock contexts for testing
|
||||
const MockAuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div data-testid="auth-provider">{children}</div>
|
||||
}
|
||||
|
||||
const MockNotificationProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div data-testid="notification-provider">{children}</div>
|
||||
}
|
||||
|
||||
const MockLanguageProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div data-testid="language-provider">{children}</div>
|
||||
}
|
||||
|
||||
const AllProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<MockAuthProvider>
|
||||
<MockNotificationProvider>
|
||||
<MockLanguageProvider>
|
||||
{children}
|
||||
</MockLanguageProvider>
|
||||
</MockNotificationProvider>
|
||||
</MockAuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const customRender = (
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => render(ui, { wrapper: AllProviders, ...options })
|
||||
|
||||
// Mock implementations for common hooks
|
||||
export const mockUseAuth = () => ({
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
export const mockUseNotifications = () => ({
|
||||
notifications: [],
|
||||
addNotification: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
unreadCount: 0
|
||||
})
|
||||
|
||||
export const mockUseLanguage = () => ({
|
||||
currentLanguage: { code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
changeLanguage: vi.fn(),
|
||||
t: (key: string) => key
|
||||
})
|
||||
|
||||
// Mock framer-motion components
|
||||
export const mockMotionComponents = {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
section: ({ children, ...props }: any) => <section {...props}>{children}</section>,
|
||||
a: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
button: ({ children, ...props }: any) => <button {...props}>{children}</button>
|
||||
}
|
||||
|
||||
// Test data generators
|
||||
export const createMockStudentRequest = (overrides = {}) => ({
|
||||
id: 'req-123',
|
||||
studentId: 'student-123',
|
||||
studentName: 'John Doe',
|
||||
description: 'Need school supplies',
|
||||
category: 'school-supplies' as const,
|
||||
urgency: 'medium' as const,
|
||||
location: { address: '123 Test St', city: 'Test City', state: 'TS', zip: '12345' },
|
||||
constraints: { budget: 100, timeline: '1 week' },
|
||||
submittedAt: new Date(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
export const createMockUser = (overrides = {}) => ({
|
||||
id: 'user-123',
|
||||
email: '[email protected]',
|
||||
role: 'admin' as const,
|
||||
name: 'Test User',
|
||||
lastLogin: new Date(),
|
||||
permissions: ['read', 'write', 'delete'],
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Re-export everything from testing library
|
||||
export * from '@testing-library/react'
|
||||
import { ReactElement } from 'react'
|
||||
import { render, RenderOptions } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
// Mock contexts for testing
|
||||
const MockAuthProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div data-testid="auth-provider">{children}</div>
|
||||
}
|
||||
|
||||
const MockNotificationProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div data-testid="notification-provider">{children}</div>
|
||||
}
|
||||
|
||||
const MockLanguageProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
return <div data-testid="language-provider">{children}</div>
|
||||
}
|
||||
|
||||
const AllProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<MockAuthProvider>
|
||||
<MockNotificationProvider>
|
||||
<MockLanguageProvider>
|
||||
{children}
|
||||
</MockLanguageProvider>
|
||||
</MockNotificationProvider>
|
||||
</MockAuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
const customRender = (
|
||||
ui: ReactElement,
|
||||
options?: Omit<RenderOptions, 'wrapper'>
|
||||
) => render(ui, { wrapper: AllProviders, ...options })
|
||||
|
||||
// Mock implementations for common hooks
|
||||
export const mockUseAuth = () => ({
|
||||
user: null,
|
||||
login: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
isLoading: false
|
||||
})
|
||||
|
||||
export const mockUseNotifications = () => ({
|
||||
notifications: [],
|
||||
addNotification: vi.fn(),
|
||||
markAsRead: vi.fn(),
|
||||
clearAll: vi.fn(),
|
||||
unreadCount: 0
|
||||
})
|
||||
|
||||
export const mockUseLanguage = () => ({
|
||||
currentLanguage: { code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
changeLanguage: vi.fn(),
|
||||
t: (key: string) => key
|
||||
})
|
||||
|
||||
// Mock framer-motion components
|
||||
export const mockMotionComponents = {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
section: ({ children, ...props }: any) => <section {...props}>{children}</section>,
|
||||
a: ({ children, ...props }: any) => <a {...props}>{children}</a>,
|
||||
button: ({ children, ...props }: any) => <button {...props}>{children}</button>
|
||||
}
|
||||
|
||||
// Test data generators
|
||||
export const createMockStudentRequest = (overrides = {}) => ({
|
||||
id: 'req-123',
|
||||
studentId: 'student-123',
|
||||
studentName: 'John Doe',
|
||||
description: 'Need school supplies',
|
||||
category: 'school-supplies' as const,
|
||||
urgency: 'medium' as const,
|
||||
location: { address: '123 Test St', city: 'Test City', state: 'TS', zip: '12345' },
|
||||
constraints: { budget: 100, timeline: '1 week' },
|
||||
submittedAt: new Date(),
|
||||
...overrides
|
||||
})
|
||||
|
||||
export const createMockUser = (overrides = {}) => ({
|
||||
id: 'user-123',
|
||||
email: '[email protected]',
|
||||
role: 'admin' as const,
|
||||
name: 'Test User',
|
||||
lastLogin: new Date(),
|
||||
permissions: ['read', 'write', 'delete'],
|
||||
...overrides
|
||||
})
|
||||
|
||||
// Re-export everything from testing library
|
||||
export * from '@testing-library/react'
|
||||
export { customRender as render }
|
||||
+127
-127
@@ -1,128 +1,128 @@
|
||||
// Analytics and tracking utilities
|
||||
|
||||
interface TrackingEvent {
|
||||
event: string
|
||||
properties?: Record<string, any>
|
||||
userId?: string
|
||||
}
|
||||
|
||||
class Analytics {
|
||||
private isEnabled: boolean = false
|
||||
private userId: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.isEnabled = process.env.VITE_ANALYTICS_ENABLED === 'true'
|
||||
}
|
||||
|
||||
// Initialize analytics
|
||||
init(userId?: string): void {
|
||||
this.userId = userId || null
|
||||
if (this.isEnabled) {
|
||||
// Analytics initialized for user
|
||||
}
|
||||
}
|
||||
|
||||
// Track page views
|
||||
trackPageView(page: string, title?: string): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
const event: TrackingEvent = {
|
||||
event: 'page_view',
|
||||
properties: {
|
||||
page,
|
||||
title: title || document.title,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href
|
||||
},
|
||||
userId: this.userId || undefined
|
||||
}
|
||||
|
||||
this.sendEvent(event)
|
||||
}
|
||||
|
||||
// Track custom events
|
||||
trackEvent(eventName: string, properties?: Record<string, any>): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
const event: TrackingEvent = {
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
page: window.location.pathname
|
||||
},
|
||||
userId: this.userId || undefined
|
||||
}
|
||||
|
||||
this.sendEvent(event)
|
||||
}
|
||||
|
||||
// Track donation events
|
||||
trackDonation(amount: number, method: string): void {
|
||||
this.trackEvent('donation_completed', {
|
||||
amount,
|
||||
method,
|
||||
impact: this.calculateImpactString(amount)
|
||||
})
|
||||
}
|
||||
|
||||
// Track volunteer signups
|
||||
trackVolunteerSignup(volunteerType: string): void {
|
||||
this.trackEvent('volunteer_signup', {
|
||||
type: volunteerType
|
||||
})
|
||||
}
|
||||
|
||||
// Track form submissions
|
||||
trackFormSubmission(formName: string, success: boolean): void {
|
||||
this.trackEvent('form_submission', {
|
||||
form_name: formName,
|
||||
success
|
||||
})
|
||||
}
|
||||
|
||||
// Send event to analytics service
|
||||
private sendEvent(event: TrackingEvent): void {
|
||||
if (typeof window !== 'undefined' && window.gtag) {
|
||||
// Google Analytics 4
|
||||
window.gtag('event', event.event, {
|
||||
custom_parameters: event.properties,
|
||||
user_id: event.userId
|
||||
})
|
||||
}
|
||||
|
||||
// Console logging for development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// Analytics event tracked
|
||||
}
|
||||
|
||||
// Could also send to other analytics services here
|
||||
// this.sendToMixpanel(event)
|
||||
// this.sendToAmplitude(event)
|
||||
}
|
||||
|
||||
private calculateImpactString(amount: number): string {
|
||||
const students = Math.floor(amount / 25)
|
||||
return `${students} students supported`
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const analytics = new Analytics()
|
||||
|
||||
// Track event helper function
|
||||
export const trackEvent = (eventName: string, properties?: Record<string, any>): void => {
|
||||
analytics.trackEvent(eventName, properties)
|
||||
}
|
||||
|
||||
// Track page view helper function
|
||||
export const trackPageView = (page: string, title?: string): void => {
|
||||
analytics.trackPageView(page, title)
|
||||
}
|
||||
|
||||
// Declare global gtag function for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag: (...args: any[]) => void
|
||||
}
|
||||
// Analytics and tracking utilities
|
||||
|
||||
interface TrackingEvent {
|
||||
event: string
|
||||
properties?: Record<string, any>
|
||||
userId?: string
|
||||
}
|
||||
|
||||
class Analytics {
|
||||
private isEnabled: boolean = false
|
||||
private userId: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.isEnabled = process.env.VITE_ANALYTICS_ENABLED === 'true'
|
||||
}
|
||||
|
||||
// Initialize analytics
|
||||
init(userId?: string): void {
|
||||
this.userId = userId || null
|
||||
if (this.isEnabled) {
|
||||
// Analytics initialized for user
|
||||
}
|
||||
}
|
||||
|
||||
// Track page views
|
||||
trackPageView(page: string, title?: string): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
const event: TrackingEvent = {
|
||||
event: 'page_view',
|
||||
properties: {
|
||||
page,
|
||||
title: title || document.title,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href
|
||||
},
|
||||
userId: this.userId || undefined
|
||||
}
|
||||
|
||||
this.sendEvent(event)
|
||||
}
|
||||
|
||||
// Track custom events
|
||||
trackEvent(eventName: string, properties?: Record<string, any>): void {
|
||||
if (!this.isEnabled) return
|
||||
|
||||
const event: TrackingEvent = {
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
timestamp: new Date().toISOString(),
|
||||
page: window.location.pathname
|
||||
},
|
||||
userId: this.userId || undefined
|
||||
}
|
||||
|
||||
this.sendEvent(event)
|
||||
}
|
||||
|
||||
// Track donation events
|
||||
trackDonation(amount: number, method: string): void {
|
||||
this.trackEvent('donation_completed', {
|
||||
amount,
|
||||
method,
|
||||
impact: this.calculateImpactString(amount)
|
||||
})
|
||||
}
|
||||
|
||||
// Track volunteer signups
|
||||
trackVolunteerSignup(volunteerType: string): void {
|
||||
this.trackEvent('volunteer_signup', {
|
||||
type: volunteerType
|
||||
})
|
||||
}
|
||||
|
||||
// Track form submissions
|
||||
trackFormSubmission(formName: string, success: boolean): void {
|
||||
this.trackEvent('form_submission', {
|
||||
form_name: formName,
|
||||
success
|
||||
})
|
||||
}
|
||||
|
||||
// Send event to analytics service
|
||||
private sendEvent(event: TrackingEvent): void {
|
||||
if (typeof window !== 'undefined' && window.gtag) {
|
||||
// Google Analytics 4
|
||||
window.gtag('event', event.event, {
|
||||
custom_parameters: event.properties,
|
||||
user_id: event.userId
|
||||
})
|
||||
}
|
||||
|
||||
// Console logging for development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// Analytics event tracked
|
||||
}
|
||||
|
||||
// Could also send to other analytics services here
|
||||
// this.sendToMixpanel(event)
|
||||
// this.sendToAmplitude(event)
|
||||
}
|
||||
|
||||
private calculateImpactString(amount: number): string {
|
||||
const students = Math.floor(amount / 25)
|
||||
return `${students} students supported`
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const analytics = new Analytics()
|
||||
|
||||
// Track event helper function
|
||||
export const trackEvent = (eventName: string, properties?: Record<string, any>): void => {
|
||||
analytics.trackEvent(eventName, properties)
|
||||
}
|
||||
|
||||
// Track page view helper function
|
||||
export const trackPageView = (page: string, title?: string): void => {
|
||||
analytics.trackPageView(page, title)
|
||||
}
|
||||
|
||||
// Declare global gtag function for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
gtag: (...args: any[]) => void
|
||||
}
|
||||
}
|
||||
+289
-289
@@ -1,290 +1,290 @@
|
||||
// Bundle Analysis Utilities
|
||||
interface BundleStats {
|
||||
totalSize: number
|
||||
chunks: ChunkInfo[]
|
||||
dependencies: DependencyInfo[]
|
||||
duplicates: string[]
|
||||
}
|
||||
|
||||
interface ChunkInfo {
|
||||
name: string
|
||||
size: number
|
||||
modules: string[]
|
||||
type: 'entry' | 'vendor' | 'async'
|
||||
}
|
||||
|
||||
interface DependencyInfo {
|
||||
name: string
|
||||
version: string
|
||||
size: number
|
||||
treeshakeable: boolean
|
||||
sideEffects: boolean
|
||||
}
|
||||
|
||||
// Analyze bundle performance and suggest optimizations
|
||||
export class BundleAnalyzer {
|
||||
private stats: BundleStats | null = null
|
||||
|
||||
async analyzeBuild(_statsFile?: string): Promise<BundleStats> {
|
||||
// In a real implementation, this would parse webpack/vite stats
|
||||
// For now, we'll simulate bundle analysis
|
||||
const mockStats: BundleStats = {
|
||||
totalSize: 245000, // 245KB
|
||||
chunks: [
|
||||
{
|
||||
name: 'main',
|
||||
size: 85000,
|
||||
modules: ['src/main.tsx', 'src/App.tsx'],
|
||||
type: 'entry'
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
size: 120000,
|
||||
modules: ['react', 'react-dom', 'framer-motion'],
|
||||
type: 'vendor'
|
||||
},
|
||||
{
|
||||
name: 'async-donation',
|
||||
size: 40000,
|
||||
modules: ['src/pages/DonatePage'],
|
||||
type: 'async'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
size: 45000,
|
||||
treeshakeable: false,
|
||||
sideEffects: false
|
||||
},
|
||||
{
|
||||
name: 'framer-motion',
|
||||
version: '10.16.4',
|
||||
size: 35000,
|
||||
treeshakeable: true,
|
||||
sideEffects: false
|
||||
},
|
||||
{
|
||||
name: 'lucide-react',
|
||||
version: '0.294.0',
|
||||
size: 15000,
|
||||
treeshakeable: true,
|
||||
sideEffects: false
|
||||
}
|
||||
],
|
||||
duplicates: []
|
||||
}
|
||||
|
||||
this.stats = mockStats
|
||||
return mockStats
|
||||
}
|
||||
|
||||
generateOptimizationSuggestions(): OptimizationSuggestion[] {
|
||||
if (!this.stats) return []
|
||||
|
||||
const suggestions: OptimizationSuggestion[] = []
|
||||
|
||||
// Check for large chunks
|
||||
this.stats.chunks.forEach(chunk => {
|
||||
if (chunk.size > 100000) {
|
||||
suggestions.push({
|
||||
type: 'chunk-size',
|
||||
severity: 'warning',
|
||||
message: `Large chunk detected: ${chunk.name} (${(chunk.size / 1024).toFixed(1)}KB)`,
|
||||
recommendation: 'Consider code splitting or lazy loading for this chunk',
|
||||
impact: 'medium'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check for non-treeshakeable dependencies
|
||||
this.stats.dependencies.forEach(dep => {
|
||||
if (!dep.treeshakeable && dep.size > 20000) {
|
||||
suggestions.push({
|
||||
type: 'treeshaking',
|
||||
severity: 'info',
|
||||
message: `Non-treeshakeable dependency: ${dep.name} (${(dep.size / 1024).toFixed(1)}KB)`,
|
||||
recommendation: 'Look for lighter alternatives or import specific modules',
|
||||
impact: 'low'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check total bundle size
|
||||
if (this.stats.totalSize > 300000) {
|
||||
suggestions.push({
|
||||
type: 'bundle-size',
|
||||
severity: 'error',
|
||||
message: `Bundle size is large: ${(this.stats.totalSize / 1024).toFixed(1)}KB`,
|
||||
recommendation: 'Implement aggressive code splitting and lazy loading',
|
||||
impact: 'high'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for duplicate dependencies
|
||||
if (this.stats.duplicates.length > 0) {
|
||||
suggestions.push({
|
||||
type: 'duplicates',
|
||||
severity: 'warning',
|
||||
message: `Duplicate dependencies found: ${this.stats.duplicates.join(', ')}`,
|
||||
recommendation: 'Configure webpack/vite to deduplicate shared dependencies',
|
||||
impact: 'medium'
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
generateReport(): BundleReport {
|
||||
if (!this.stats) throw new Error('No stats available. Run analyzeBuild first.')
|
||||
|
||||
const suggestions = this.generateOptimizationSuggestions()
|
||||
const score = this.calculatePerformanceScore()
|
||||
|
||||
return {
|
||||
stats: this.stats,
|
||||
suggestions,
|
||||
score,
|
||||
timestamp: new Date().toISOString(),
|
||||
recommendations: this.generateRecommendations(score)
|
||||
}
|
||||
}
|
||||
|
||||
private calculatePerformanceScore(): number {
|
||||
if (!this.stats) return 0
|
||||
|
||||
let score = 100
|
||||
|
||||
// Deduct points for large bundle size
|
||||
if (this.stats.totalSize > 200000) score -= 20
|
||||
if (this.stats.totalSize > 300000) score -= 30
|
||||
|
||||
// Deduct points for large chunks
|
||||
this.stats.chunks.forEach(chunk => {
|
||||
if (chunk.size > 100000) score -= 10
|
||||
})
|
||||
|
||||
// Deduct points for non-treeshakeable deps
|
||||
const nonTreeshakeable = this.stats.dependencies.filter(dep => !dep.treeshakeable)
|
||||
score -= nonTreeshakeable.length * 5
|
||||
|
||||
// Deduct points for duplicates
|
||||
score -= this.stats.duplicates.length * 10
|
||||
|
||||
return Math.max(0, Math.min(100, score))
|
||||
}
|
||||
|
||||
private generateRecommendations(score: number): string[] {
|
||||
const recommendations: string[] = []
|
||||
|
||||
if (score < 50) {
|
||||
recommendations.push(
|
||||
'Implement aggressive code splitting',
|
||||
'Use dynamic imports for route-based splitting',
|
||||
'Consider removing heavy dependencies',
|
||||
'Implement preloading for critical resources'
|
||||
)
|
||||
} else if (score < 70) {
|
||||
recommendations.push(
|
||||
'Optimize large chunks with lazy loading',
|
||||
'Review dependency usage for tree-shaking opportunities',
|
||||
'Consider using CDN for large libraries'
|
||||
)
|
||||
} else if (score < 85) {
|
||||
recommendations.push(
|
||||
'Fine-tune code splitting boundaries',
|
||||
'Optimize asset loading strategies'
|
||||
)
|
||||
} else {
|
||||
recommendations.push(
|
||||
'Your bundle is well optimized!',
|
||||
'Consider monitoring performance over time'
|
||||
)
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
}
|
||||
|
||||
interface OptimizationSuggestion {
|
||||
type: 'chunk-size' | 'treeshaking' | 'bundle-size' | 'duplicates'
|
||||
severity: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
recommendation: string
|
||||
impact: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
interface BundleReport {
|
||||
stats: BundleStats
|
||||
suggestions: OptimizationSuggestion[]
|
||||
score: number
|
||||
timestamp: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const bundleAnalyzer = new BundleAnalyzer()
|
||||
|
||||
// Helper functions for Vite integration
|
||||
export function createBundleAnalyzerPlugin() {
|
||||
return {
|
||||
name: 'bundle-analyzer',
|
||||
writeBundle(_options: any, bundle: any) {
|
||||
if (process.env.ANALYZE_BUNDLE) {
|
||||
// Bundle analysis performed
|
||||
|
||||
let totalSize = 0
|
||||
const chunks: any[] = []
|
||||
|
||||
Object.entries(bundle).forEach(([name, chunk]: [string, any]) => {
|
||||
if (chunk.type === 'chunk') {
|
||||
const size = chunk.code.length
|
||||
totalSize += size
|
||||
chunks.push({ name, size, modules: chunk.modules || [] })
|
||||
}
|
||||
})
|
||||
|
||||
console.table(chunks.map(chunk => ({
|
||||
name: chunk.name,
|
||||
size: `${(chunk.size / 1024).toFixed(1)}KB`,
|
||||
modules: chunk.modules.length
|
||||
})))
|
||||
|
||||
// Total bundle size calculated
|
||||
|
||||
if (totalSize > 300000) {
|
||||
console.warn('⚠️ Bundle size is large. Consider code splitting.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Performance monitoring in production
|
||||
export function setupBundleMonitoring() {
|
||||
if (typeof window === 'undefined' || process.env.NODE_ENV !== 'production') return
|
||||
|
||||
// Monitor bundle loading performance
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByType('resource')
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.name.includes('.js') || entry.name.includes('.css')) {
|
||||
const loadTime = entry.loadEnd - entry.loadStart
|
||||
const size = entry.encodedBodySize || entry.transferSize
|
||||
|
||||
// Send metrics to analytics
|
||||
if (window.gtag) {
|
||||
window.gtag('event', 'bundle_performance', {
|
||||
resource_name: entry.name.split('/').pop(),
|
||||
load_time: Math.round(loadTime),
|
||||
size_kb: Math.round(size / 1024)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe({ entryTypes: ['resource'] })
|
||||
// Bundle Analysis Utilities
|
||||
interface BundleStats {
|
||||
totalSize: number
|
||||
chunks: ChunkInfo[]
|
||||
dependencies: DependencyInfo[]
|
||||
duplicates: string[]
|
||||
}
|
||||
|
||||
interface ChunkInfo {
|
||||
name: string
|
||||
size: number
|
||||
modules: string[]
|
||||
type: 'entry' | 'vendor' | 'async'
|
||||
}
|
||||
|
||||
interface DependencyInfo {
|
||||
name: string
|
||||
version: string
|
||||
size: number
|
||||
treeshakeable: boolean
|
||||
sideEffects: boolean
|
||||
}
|
||||
|
||||
// Analyze bundle performance and suggest optimizations
|
||||
export class BundleAnalyzer {
|
||||
private stats: BundleStats | null = null
|
||||
|
||||
async analyzeBuild(_statsFile?: string): Promise<BundleStats> {
|
||||
// In a real implementation, this would parse webpack/vite stats
|
||||
// For now, we'll simulate bundle analysis
|
||||
const mockStats: BundleStats = {
|
||||
totalSize: 245000, // 245KB
|
||||
chunks: [
|
||||
{
|
||||
name: 'main',
|
||||
size: 85000,
|
||||
modules: ['src/main.tsx', 'src/App.tsx'],
|
||||
type: 'entry'
|
||||
},
|
||||
{
|
||||
name: 'vendor',
|
||||
size: 120000,
|
||||
modules: ['react', 'react-dom', 'framer-motion'],
|
||||
type: 'vendor'
|
||||
},
|
||||
{
|
||||
name: 'async-donation',
|
||||
size: 40000,
|
||||
modules: ['src/pages/DonatePage'],
|
||||
type: 'async'
|
||||
}
|
||||
],
|
||||
dependencies: [
|
||||
{
|
||||
name: 'react',
|
||||
version: '18.2.0',
|
||||
size: 45000,
|
||||
treeshakeable: false,
|
||||
sideEffects: false
|
||||
},
|
||||
{
|
||||
name: 'framer-motion',
|
||||
version: '10.16.4',
|
||||
size: 35000,
|
||||
treeshakeable: true,
|
||||
sideEffects: false
|
||||
},
|
||||
{
|
||||
name: 'lucide-react',
|
||||
version: '0.294.0',
|
||||
size: 15000,
|
||||
treeshakeable: true,
|
||||
sideEffects: false
|
||||
}
|
||||
],
|
||||
duplicates: []
|
||||
}
|
||||
|
||||
this.stats = mockStats
|
||||
return mockStats
|
||||
}
|
||||
|
||||
generateOptimizationSuggestions(): OptimizationSuggestion[] {
|
||||
if (!this.stats) return []
|
||||
|
||||
const suggestions: OptimizationSuggestion[] = []
|
||||
|
||||
// Check for large chunks
|
||||
this.stats.chunks.forEach(chunk => {
|
||||
if (chunk.size > 100000) {
|
||||
suggestions.push({
|
||||
type: 'chunk-size',
|
||||
severity: 'warning',
|
||||
message: `Large chunk detected: ${chunk.name} (${(chunk.size / 1024).toFixed(1)}KB)`,
|
||||
recommendation: 'Consider code splitting or lazy loading for this chunk',
|
||||
impact: 'medium'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check for non-treeshakeable dependencies
|
||||
this.stats.dependencies.forEach(dep => {
|
||||
if (!dep.treeshakeable && dep.size > 20000) {
|
||||
suggestions.push({
|
||||
type: 'treeshaking',
|
||||
severity: 'info',
|
||||
message: `Non-treeshakeable dependency: ${dep.name} (${(dep.size / 1024).toFixed(1)}KB)`,
|
||||
recommendation: 'Look for lighter alternatives or import specific modules',
|
||||
impact: 'low'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Check total bundle size
|
||||
if (this.stats.totalSize > 300000) {
|
||||
suggestions.push({
|
||||
type: 'bundle-size',
|
||||
severity: 'error',
|
||||
message: `Bundle size is large: ${(this.stats.totalSize / 1024).toFixed(1)}KB`,
|
||||
recommendation: 'Implement aggressive code splitting and lazy loading',
|
||||
impact: 'high'
|
||||
})
|
||||
}
|
||||
|
||||
// Check for duplicate dependencies
|
||||
if (this.stats.duplicates.length > 0) {
|
||||
suggestions.push({
|
||||
type: 'duplicates',
|
||||
severity: 'warning',
|
||||
message: `Duplicate dependencies found: ${this.stats.duplicates.join(', ')}`,
|
||||
recommendation: 'Configure webpack/vite to deduplicate shared dependencies',
|
||||
impact: 'medium'
|
||||
})
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
|
||||
generateReport(): BundleReport {
|
||||
if (!this.stats) throw new Error('No stats available. Run analyzeBuild first.')
|
||||
|
||||
const suggestions = this.generateOptimizationSuggestions()
|
||||
const score = this.calculatePerformanceScore()
|
||||
|
||||
return {
|
||||
stats: this.stats,
|
||||
suggestions,
|
||||
score,
|
||||
timestamp: new Date().toISOString(),
|
||||
recommendations: this.generateRecommendations(score)
|
||||
}
|
||||
}
|
||||
|
||||
private calculatePerformanceScore(): number {
|
||||
if (!this.stats) return 0
|
||||
|
||||
let score = 100
|
||||
|
||||
// Deduct points for large bundle size
|
||||
if (this.stats.totalSize > 200000) score -= 20
|
||||
if (this.stats.totalSize > 300000) score -= 30
|
||||
|
||||
// Deduct points for large chunks
|
||||
this.stats.chunks.forEach(chunk => {
|
||||
if (chunk.size > 100000) score -= 10
|
||||
})
|
||||
|
||||
// Deduct points for non-treeshakeable deps
|
||||
const nonTreeshakeable = this.stats.dependencies.filter(dep => !dep.treeshakeable)
|
||||
score -= nonTreeshakeable.length * 5
|
||||
|
||||
// Deduct points for duplicates
|
||||
score -= this.stats.duplicates.length * 10
|
||||
|
||||
return Math.max(0, Math.min(100, score))
|
||||
}
|
||||
|
||||
private generateRecommendations(score: number): string[] {
|
||||
const recommendations: string[] = []
|
||||
|
||||
if (score < 50) {
|
||||
recommendations.push(
|
||||
'Implement aggressive code splitting',
|
||||
'Use dynamic imports for route-based splitting',
|
||||
'Consider removing heavy dependencies',
|
||||
'Implement preloading for critical resources'
|
||||
)
|
||||
} else if (score < 70) {
|
||||
recommendations.push(
|
||||
'Optimize large chunks with lazy loading',
|
||||
'Review dependency usage for tree-shaking opportunities',
|
||||
'Consider using CDN for large libraries'
|
||||
)
|
||||
} else if (score < 85) {
|
||||
recommendations.push(
|
||||
'Fine-tune code splitting boundaries',
|
||||
'Optimize asset loading strategies'
|
||||
)
|
||||
} else {
|
||||
recommendations.push(
|
||||
'Your bundle is well optimized!',
|
||||
'Consider monitoring performance over time'
|
||||
)
|
||||
}
|
||||
|
||||
return recommendations
|
||||
}
|
||||
}
|
||||
|
||||
interface OptimizationSuggestion {
|
||||
type: 'chunk-size' | 'treeshaking' | 'bundle-size' | 'duplicates'
|
||||
severity: 'info' | 'warning' | 'error'
|
||||
message: string
|
||||
recommendation: string
|
||||
impact: 'low' | 'medium' | 'high'
|
||||
}
|
||||
|
||||
interface BundleReport {
|
||||
stats: BundleStats
|
||||
suggestions: OptimizationSuggestion[]
|
||||
score: number
|
||||
timestamp: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const bundleAnalyzer = new BundleAnalyzer()
|
||||
|
||||
// Helper functions for Vite integration
|
||||
export function createBundleAnalyzerPlugin() {
|
||||
return {
|
||||
name: 'bundle-analyzer',
|
||||
writeBundle(_options: any, bundle: any) {
|
||||
if (process.env.ANALYZE_BUNDLE) {
|
||||
// Bundle analysis performed
|
||||
|
||||
let totalSize = 0
|
||||
const chunks: any[] = []
|
||||
|
||||
Object.entries(bundle).forEach(([name, chunk]: [string, any]) => {
|
||||
if (chunk.type === 'chunk') {
|
||||
const size = chunk.code.length
|
||||
totalSize += size
|
||||
chunks.push({ name, size, modules: chunk.modules || [] })
|
||||
}
|
||||
})
|
||||
|
||||
console.table(chunks.map(chunk => ({
|
||||
name: chunk.name,
|
||||
size: `${(chunk.size / 1024).toFixed(1)}KB`,
|
||||
modules: chunk.modules.length
|
||||
})))
|
||||
|
||||
// Total bundle size calculated
|
||||
|
||||
if (totalSize > 300000) {
|
||||
console.warn('⚠️ Bundle size is large. Consider code splitting.')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Performance monitoring in production
|
||||
export function setupBundleMonitoring() {
|
||||
if (typeof window === 'undefined' || process.env.NODE_ENV !== 'production') return
|
||||
|
||||
// Monitor bundle loading performance
|
||||
const observer = new PerformanceObserver((entryList) => {
|
||||
const entries = entryList.getEntriesByType('resource')
|
||||
|
||||
entries.forEach((entry: any) => {
|
||||
if (entry.name.includes('.js') || entry.name.includes('.css')) {
|
||||
const loadTime = entry.loadEnd - entry.loadStart
|
||||
const size = entry.encodedBodySize || entry.transferSize
|
||||
|
||||
// Send metrics to analytics
|
||||
if (window.gtag) {
|
||||
window.gtag('event', 'bundle_performance', {
|
||||
resource_name: entry.name.split('/').pop(),
|
||||
load_time: Math.round(loadTime),
|
||||
size_kb: Math.round(size / 1024)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe({ entryTypes: ['resource'] })
|
||||
}
|
||||
+121
-121
@@ -1,122 +1,122 @@
|
||||
// Enhanced impact calculation utilities
|
||||
export interface ImpactCalculation {
|
||||
students: number
|
||||
families: number
|
||||
backpacks: number
|
||||
clothing: number
|
||||
emergency: number
|
||||
annual: {
|
||||
students: number
|
||||
families: number
|
||||
totalImpact: string
|
||||
}
|
||||
}
|
||||
|
||||
export const calculateDonationImpact = (amount: number): ImpactCalculation => {
|
||||
const students = Math.floor(amount / 25) // $25 per student for basic supplies
|
||||
const families = Math.floor(amount / 50) // $50 per family for comprehensive support
|
||||
const backpacks = Math.floor(amount / 30) // $30 for complete backpack kit
|
||||
const clothing = Math.floor(amount / 45) // $45 for clothing items
|
||||
const emergency = Math.floor(amount / 75) // $75 for emergency assistance
|
||||
|
||||
return {
|
||||
students,
|
||||
families,
|
||||
backpacks,
|
||||
clothing,
|
||||
emergency,
|
||||
annual: {
|
||||
students: Math.floor((amount * 12) / 25),
|
||||
families: Math.floor((amount * 12) / 50),
|
||||
totalImpact: `${Math.floor((amount * 12) / 25)} students supported annually`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format currency
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// Format numbers with commas
|
||||
export const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
// Validate email
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
// Validate phone number
|
||||
export const validatePhone = (phone: string): boolean => {
|
||||
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/
|
||||
return phoneRegex.test(phone.replace(/\D/g, ''))
|
||||
}
|
||||
|
||||
// Generate unique ID
|
||||
export const generateId = (): string => {
|
||||
return Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
// Debounce function
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => func(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle function
|
||||
export const throttle = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let inThrottle: boolean
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safe JSON parse
|
||||
export const safeJsonParse = <T>(str: string, fallback: T): T => {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
export const formatDate = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
export const formatRelativeTime = (date: Date): string => {
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) return 'just now'
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||
|
||||
return formatDate(date)
|
||||
// Enhanced impact calculation utilities
|
||||
export interface ImpactCalculation {
|
||||
students: number
|
||||
families: number
|
||||
backpacks: number
|
||||
clothing: number
|
||||
emergency: number
|
||||
annual: {
|
||||
students: number
|
||||
families: number
|
||||
totalImpact: string
|
||||
}
|
||||
}
|
||||
|
||||
export const calculateDonationImpact = (amount: number): ImpactCalculation => {
|
||||
const students = Math.floor(amount / 25) // $25 per student for basic supplies
|
||||
const families = Math.floor(amount / 50) // $50 per family for comprehensive support
|
||||
const backpacks = Math.floor(amount / 30) // $30 for complete backpack kit
|
||||
const clothing = Math.floor(amount / 45) // $45 for clothing items
|
||||
const emergency = Math.floor(amount / 75) // $75 for emergency assistance
|
||||
|
||||
return {
|
||||
students,
|
||||
families,
|
||||
backpacks,
|
||||
clothing,
|
||||
emergency,
|
||||
annual: {
|
||||
students: Math.floor((amount * 12) / 25),
|
||||
families: Math.floor((amount * 12) / 50),
|
||||
totalImpact: `${Math.floor((amount * 12) / 25)} students supported annually`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format currency
|
||||
export const formatCurrency = (amount: number): string => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD'
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
// Format numbers with commas
|
||||
export const formatNumber = (num: number): string => {
|
||||
return new Intl.NumberFormat('en-US').format(num)
|
||||
}
|
||||
|
||||
// Validate email
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
// Validate phone number
|
||||
export const validatePhone = (phone: string): boolean => {
|
||||
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/
|
||||
return phoneRegex.test(phone.replace(/\D/g, ''))
|
||||
}
|
||||
|
||||
// Generate unique ID
|
||||
export const generateId = (): string => {
|
||||
return Math.random().toString(36).substr(2, 9)
|
||||
}
|
||||
|
||||
// Debounce function
|
||||
export const debounce = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
delay: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let timeoutId: NodeJS.Timeout
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => func(...args), delay)
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle function
|
||||
export const throttle = <T extends (...args: any[]) => any>(
|
||||
func: T,
|
||||
limit: number
|
||||
): ((...args: Parameters<T>) => void) => {
|
||||
let inThrottle: boolean
|
||||
return (...args: Parameters<T>) => {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safe JSON parse
|
||||
export const safeJsonParse = <T>(str: string, fallback: T): T => {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
// Format date
|
||||
export const formatDate = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// Format relative time
|
||||
export const formatRelativeTime = (date: Date): string => {
|
||||
const now = new Date()
|
||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||
|
||||
if (diffInSeconds < 60) return 'just now'
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`
|
||||
if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`
|
||||
|
||||
return formatDate(date)
|
||||
}
|
||||
Reference in New Issue
Block a user