feat: implement authentication context and user session management; enhance donation impact calculator and UI interactions
This commit is contained in:
603
src/App.tsx
603
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef, useContext, createContext } from 'react'
|
||||||
import { motion, useMotionValue, useSpring, useTransform, useScroll } from 'framer-motion'
|
import { motion, useMotionValue, useSpring, useTransform, useScroll } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
@@ -51,11 +51,178 @@ import {
|
|||||||
* donation processing, volunteer management, and impact tracking.
|
* donation processing, volunteer management, and impact tracking.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ===================== Authentication Context ===================== */
|
||||||
|
const AuthContext = createContext<AuthContextType | null>(null)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
const login = async (email: string, password: string): Promise<boolean> => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
// Simulate authentication - in production, this would call your API
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
|
|
||||||
|
// Simple demo validation (in production, validate against secure backend)
|
||||||
|
if (password.length < 3) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock user data based on email domain
|
||||||
|
const mockUser: AuthUser = {
|
||||||
|
id: Math.random().toString(36),
|
||||||
|
email,
|
||||||
|
role: email.includes('admin') ? 'admin' : email.includes('volunteer') ? 'volunteer' : 'resource',
|
||||||
|
name: email.split('@')[0].replace('.', ' ').replace(/\b\w/g, l => l.toUpperCase()),
|
||||||
|
lastLogin: new Date(),
|
||||||
|
permissions: email.includes('admin') ? ['read', 'write', 'delete', 'manage'] : ['read', 'write']
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(mockUser)
|
||||||
|
localStorage.setItem('mim_user', JSON.stringify(mockUser))
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
setUser(null)
|
||||||
|
localStorage.removeItem('mim_user')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore user session on app load
|
||||||
|
useEffect(() => {
|
||||||
|
const savedUser = localStorage.getItem('mim_user')
|
||||||
|
if (savedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser))
|
||||||
|
} catch (error) {
|
||||||
|
localStorage.removeItem('mim_user')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, login, logout, isLoading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* ===================== Enhanced Impact Calculator ===================== */
|
||||||
|
function 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`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== Analytics Tracking ===================== */
|
||||||
|
function trackEvent(eventName: string, properties: Record<string, any> = {}) {
|
||||||
|
// In production, integrate with Google Analytics, Mixpanel, or similar
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('event', eventName, properties)
|
||||||
|
}
|
||||||
|
console.log(`Analytics: ${eventName}`, properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================== SEO Meta Tags Component ===================== */
|
||||||
|
function SEOHead({ title, description, image }: { title?: string, description?: string, image?: string }) {
|
||||||
|
useEffect(() => {
|
||||||
|
// Update document title
|
||||||
|
if (title) {
|
||||||
|
document.title = `${title} | Miracles in Motion`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update meta description
|
||||||
|
const metaDescription = document.querySelector('meta[name="description"]')
|
||||||
|
if (description && metaDescription) {
|
||||||
|
metaDescription.setAttribute('content', description)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Open Graph tags
|
||||||
|
const updateOGTag = (property: string, content: string) => {
|
||||||
|
let tag = document.querySelector(`meta[property="${property}"]`)
|
||||||
|
if (!tag) {
|
||||||
|
tag = document.createElement('meta')
|
||||||
|
tag.setAttribute('property', property)
|
||||||
|
document.head.appendChild(tag)
|
||||||
|
}
|
||||||
|
tag.setAttribute('content', content)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateOGTag('og:title', title || 'Miracles in Motion - Equipping Students for Success')
|
||||||
|
updateOGTag('og:description', description || 'Nonprofit providing students with school supplies, clothing, and emergency assistance to thrive in their education.')
|
||||||
|
updateOGTag('og:image', image || '/og-image.jpg')
|
||||||
|
updateOGTag('og:type', 'website')
|
||||||
|
}, [title, description, image])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/* ===================== Types ===================== */
|
/* ===================== Types ===================== */
|
||||||
interface IconProps {
|
interface IconProps {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AuthUser {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
role: 'admin' | 'volunteer' | 'resource'
|
||||||
|
name: string
|
||||||
|
lastLogin: Date
|
||||||
|
permissions: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: AuthUser | null
|
||||||
|
login: (email: string, password: string) => Promise<boolean>
|
||||||
|
logout: () => void
|
||||||
|
isLoading: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImpactCalculation {
|
||||||
|
students: number
|
||||||
|
families: number
|
||||||
|
backpacks: number
|
||||||
|
clothing: number
|
||||||
|
emergency: number
|
||||||
|
annual: {
|
||||||
|
students: number
|
||||||
|
families: number
|
||||||
|
totalImpact: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface TiltCardProps {
|
interface TiltCardProps {
|
||||||
icon: React.ComponentType<IconProps>
|
icon: React.ComponentType<IconProps>
|
||||||
title: string
|
title: string
|
||||||
@@ -1331,6 +1498,7 @@ function DonatePage() {
|
|||||||
const [selectedAmount, setSelectedAmount] = useState(50)
|
const [selectedAmount, setSelectedAmount] = useState(50)
|
||||||
const [customAmount, setCustomAmount] = useState('')
|
const [customAmount, setCustomAmount] = useState('')
|
||||||
const [isRecurring, setIsRecurring] = useState(false)
|
const [isRecurring, setIsRecurring] = useState(false)
|
||||||
|
const [donorInfo, setDonorInfo] = useState({ email: '', name: '', anonymous: false })
|
||||||
|
|
||||||
const suggestedAmounts = [
|
const suggestedAmounts = [
|
||||||
{ amount: 25, impact: "School supplies for 1 student", popular: false },
|
{ amount: 25, impact: "School supplies for 1 student", popular: false },
|
||||||
@@ -1339,40 +1507,98 @@ function DonatePage() {
|
|||||||
{ amount: 250, impact: "Emergency fund for 5 families", popular: false }
|
{ amount: 250, impact: "Emergency fund for 5 families", popular: false }
|
||||||
]
|
]
|
||||||
|
|
||||||
const getImpactText = (amount: number) => {
|
const finalAmount = customAmount ? parseInt(customAmount) || 0 : selectedAmount
|
||||||
if (amount >= 250) return `Emergency support for ${Math.floor(amount / 50)} families`
|
const impactData = calculateDonationImpact(finalAmount)
|
||||||
if (amount >= 100) return `School clothing for ${Math.floor(amount / 50)} students`
|
|
||||||
if (amount >= 50) return `Complete support for ${Math.floor(amount / 50)} student${Math.floor(amount / 50) > 1 ? 's' : ''}`
|
// Enhanced impact text with real calculations
|
||||||
if (amount >= 25) return `School supplies for ${Math.floor(amount / 25)} student${Math.floor(amount / 25) > 1 ? 's' : ''}`
|
const getDetailedImpactText = (amount: number) => {
|
||||||
return "Every dollar helps a student in need"
|
const impact = calculateDonationImpact(amount)
|
||||||
|
const impactItems = []
|
||||||
|
|
||||||
|
if (impact.students > 0) impactItems.push(`${impact.students} student${impact.students > 1 ? 's' : ''} with supplies`)
|
||||||
|
if (impact.backpacks > 0) impactItems.push(`${impact.backpacks} backpack kit${impact.backpacks > 1 ? 's' : ''}`)
|
||||||
|
if (impact.clothing > 0) impactItems.push(`${impact.clothing} clothing item${impact.clothing > 1 ? 's' : ''}`)
|
||||||
|
if (impact.emergency > 0) impactItems.push(`${impact.emergency} emergency response${impact.emergency > 1 ? 's' : ''}`)
|
||||||
|
|
||||||
|
return impactItems.length > 0 ? impactItems.join(', ') : "Every dollar helps a student in need"
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalAmount = customAmount ? parseInt(customAmount) || 0 : selectedAmount
|
// Track donation interactions
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent('donation_page_view', { amount: finalAmount, recurring: isRecurring })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDonationSubmit = () => {
|
||||||
|
trackEvent('donation_initiated', {
|
||||||
|
amount: finalAmount,
|
||||||
|
recurring: isRecurring,
|
||||||
|
anonymous: donorInfo.anonymous
|
||||||
|
})
|
||||||
|
// This would integrate with Stripe or another payment processor
|
||||||
|
alert(`Processing ${isRecurring ? 'monthly ' : ''}donation of $${finalAmount}`)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Donate" icon={Heart} eyebrow="Give with confidence" cta={<a href="#/legal" className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" aria-label="View donation policies">Policies</a>}>
|
<>
|
||||||
|
<SEOHead
|
||||||
|
title="Donate Now - Help Students in Need"
|
||||||
|
description="Make a secure donation to Miracles in Motion. Your gift provides school supplies, clothing, and emergency assistance directly to students who need it most."
|
||||||
|
/>
|
||||||
|
<PageShell title="Donate" icon={Heart} eyebrow="Give with confidence" cta={<a href="#/legal" className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" aria-label="View donation policies">Policies</a>}>
|
||||||
<div className="grid gap-8 md:grid-cols-3">
|
<div className="grid gap-8 md:grid-cols-3">
|
||||||
<div className="md:col-span-2 space-y-8">
|
<div className="md:col-span-2 space-y-8">
|
||||||
{/* Impact Calculator */}
|
{/* Enhanced Impact Calculator */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="card bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-primary-900/20 dark:to-secondary-900/20 border-primary-200/50 dark:border-primary-800/50"
|
className="card bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-primary-900/20 dark:to-secondary-900/20 border-primary-200/50 dark:border-primary-800/50"
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.6 }}
|
transition={{ duration: 0.6 }}
|
||||||
|
whileHover={{ scale: 1.01, y: -2 }}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4 mb-4">
|
||||||
<div className="p-3 bg-primary-600 text-white rounded-xl">
|
<motion.div
|
||||||
|
className="p-3 bg-primary-600 text-white rounded-xl"
|
||||||
|
whileHover={{ scale: 1.1, rotateY: 180 }}
|
||||||
|
style={{ transformStyle: 'preserve-3d' }}
|
||||||
|
>
|
||||||
<Heart className="h-6 w-6" />
|
<Heart className="h-6 w-6" />
|
||||||
</div>
|
</motion.div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="font-semibold text-lg mb-2">Your Impact: ${finalAmount}</h3>
|
<h3 className="font-semibold text-lg mb-2">Your Impact: ${finalAmount}</h3>
|
||||||
<p className="text-primary-700 dark:text-primary-300 font-medium">
|
<p className="text-primary-700 dark:text-primary-300 font-medium mb-3">
|
||||||
{getImpactText(finalAmount)}
|
{getDetailedImpactText(finalAmount)}
|
||||||
</p>
|
</p>
|
||||||
{isRecurring && (
|
|
||||||
<p className="text-sm text-primary-600 dark:text-primary-400 mt-1">
|
{/* Real-time impact breakdown */}
|
||||||
That's {getImpactText(finalAmount * 12).replace(/\d+/, String(Math.floor((finalAmount * 12) / 25)))} annually!
|
{finalAmount > 0 && (
|
||||||
</p>
|
<div className="grid gap-2 text-sm">
|
||||||
|
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Backpack className="h-4 w-4" /> Students Supported
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{impactData.students}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Package className="h-4 w-4" /> Backpack Kits
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{impactData.backpacks}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 bg-white/50 dark:bg-gray-800/50 rounded">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Shirt className="h-4 w-4" /> Clothing Items
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold">{impactData.clothing}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecurring && finalAmount > 0 && (
|
||||||
|
<div className="mt-3 p-3 bg-secondary-50 dark:bg-secondary-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-secondary-700 dark:text-secondary-300 font-medium">
|
||||||
|
🎉 Annual Impact: {impactData.annual.totalImpact}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1385,20 +1611,22 @@ function DonatePage() {
|
|||||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">Every dollar directly supports students in need</p>
|
<p className="text-sm text-neutral-600 dark:text-neutral-400">Every dollar directly supports students in need</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Suggested Amounts */}
|
{/* Suggested Amounts - Mobile Optimized */}
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 mb-6">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||||
{suggestedAmounts.map((tier) => (
|
{suggestedAmounts.map((tier) => (
|
||||||
<motion.button
|
<motion.button
|
||||||
key={tier.amount}
|
key={tier.amount}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedAmount(tier.amount)
|
setSelectedAmount(tier.amount)
|
||||||
setCustomAmount('')
|
setCustomAmount('')
|
||||||
|
trackEvent('donation_amount_selected', { amount: tier.amount, method: 'suggested' })
|
||||||
}}
|
}}
|
||||||
className={`relative p-4 rounded-xl border-2 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
className={`relative p-4 rounded-xl border-2 transition-all focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||||
selectedAmount === tier.amount && !customAmount
|
selectedAmount === tier.amount && !customAmount
|
||||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-primary-300'
|
: 'border-neutral-200 dark:border-neutral-700 hover:border-primary-300'
|
||||||
}`}
|
}`}
|
||||||
|
style={{ minHeight: '88px', minWidth: '120px' }} // Enhanced touch targets for mobile
|
||||||
whileHover={{ scale: 1.02 }}
|
whileHover={{ scale: 1.02 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
aria-label={`Donate $${tier.amount} - ${tier.impact}`}
|
aria-label={`Donate $${tier.amount} - ${tier.impact}`}
|
||||||
@@ -1443,53 +1671,103 @@ function DonatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recurring Option */}
|
{/* Recurring Option & Donor Info */}
|
||||||
<div className="mb-6 p-4 bg-secondary-50 dark:bg-secondary-900/20 rounded-xl">
|
<div className="space-y-4 mb-6">
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<div className="p-4 bg-secondary-50 dark:bg-secondary-900/20 rounded-xl">
|
||||||
<input
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={isRecurring}
|
type="checkbox"
|
||||||
onChange={(e) => setIsRecurring(e.target.checked)}
|
checked={isRecurring}
|
||||||
className="mt-1 h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
onChange={(e) => {
|
||||||
/>
|
setIsRecurring(e.target.checked)
|
||||||
<div className="flex-1">
|
trackEvent('donation_recurring_toggle', { recurring: e.target.checked, amount: finalAmount })
|
||||||
<div className="font-medium">Make this a monthly donation</div>
|
}}
|
||||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
className="mt-1 h-5 w-5 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" // Enhanced for mobile
|
||||||
Recurring donations help us plan better and have more impact
|
style={{ minHeight: '20px', minWidth: '20px' }}
|
||||||
</div>
|
/>
|
||||||
{isRecurring && finalAmount > 0 && (
|
<div className="flex-1">
|
||||||
<div className="text-sm text-secondary-600 dark:text-secondary-400 mt-1 font-medium">
|
<div className="font-medium">Make this a monthly donation</div>
|
||||||
Annual Impact: {getImpactText(finalAmount * 12)}
|
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
Recurring donations help us plan better and have more impact
|
||||||
</div>
|
</div>
|
||||||
)}
|
{isRecurring && finalAmount > 0 && (
|
||||||
|
<div className="text-sm text-secondary-600 dark:text-secondary-400 mt-2 p-2 bg-white/50 dark:bg-gray-800/50 rounded font-medium">
|
||||||
|
🎯 Annual Impact: {impactData.annual.totalImpact}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Donor Information */}
|
||||||
|
<div className="grid gap-4 p-4 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-gray-100">Donor Information (Optional)</h4>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Your Name"
|
||||||
|
value={donorInfo.name}
|
||||||
|
onChange={(e) => setDonorInfo({ ...donorInfo, name: e.target.value })}
|
||||||
|
className="input focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email (for receipt)"
|
||||||
|
value={donorInfo.email}
|
||||||
|
onChange={(e) => setDonorInfo({ ...donorInfo, email: e.target.value })}
|
||||||
|
className="input focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
style={{ minHeight: '44px' }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={donorInfo.anonymous}
|
||||||
|
onChange={(e) => setDonorInfo({ ...donorInfo, anonymous: e.target.checked })}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span>Make my donation anonymous</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Donation Buttons */}
|
{/* Enhanced Donation Buttons */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<motion.button
|
<motion.button
|
||||||
disabled={finalAmount <= 0}
|
disabled={finalAmount <= 0}
|
||||||
className="w-full btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
className="w-full btn-primary disabled:opacity-50 disabled:cursor-not-allowed focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
style={{ minHeight: '56px' }} // Enhanced mobile touch target
|
||||||
whileHover={finalAmount > 0 ? { scale: 1.02 } : {}}
|
whileHover={finalAmount > 0 ? { scale: 1.02 } : {}}
|
||||||
whileTap={finalAmount > 0 ? { scale: 0.98 } : {}}
|
whileTap={finalAmount > 0 ? { scale: 0.98 } : {}}
|
||||||
onClick={() => {
|
onClick={handleDonationSubmit}
|
||||||
// This would integrate with Stripe or another payment processor
|
|
||||||
alert(`Processing ${isRecurring ? 'monthly ' : ''}donation of $${finalAmount}`)
|
|
||||||
}}
|
|
||||||
aria-label={`Donate $${finalAmount} ${isRecurring ? 'monthly' : 'one-time'} via credit card`}
|
aria-label={`Donate $${finalAmount} ${isRecurring ? 'monthly' : 'one-time'} via credit card`}
|
||||||
>
|
>
|
||||||
<Heart className="mr-2 h-5 w-5" />
|
<Heart className="mr-2 h-5 w-5" />
|
||||||
Donate ${finalAmount} {isRecurring ? 'Monthly' : 'Securely'}
|
Donate ${finalAmount} {isRecurring ? 'Monthly' : 'Securely'}
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<button className="flex-1 btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" aria-label="Donate via PayPal">
|
<motion.button
|
||||||
PayPal
|
className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
</button>
|
style={{ minHeight: '48px' }}
|
||||||
<button className="flex-1 btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2" aria-label="Donate via Venmo">
|
whileHover={{ scale: 1.02 }}
|
||||||
Venmo
|
whileTap={{ scale: 0.98 }}
|
||||||
</button>
|
onClick={() => trackEvent('donation_method_selected', { method: 'paypal', amount: finalAmount })}
|
||||||
|
aria-label="Donate via PayPal"
|
||||||
|
>
|
||||||
|
💳 PayPal
|
||||||
|
</motion.button>
|
||||||
|
<motion.button
|
||||||
|
className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
|
style={{ minHeight: '48px' }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
onClick={() => trackEvent('donation_method_selected', { method: 'venmo', amount: finalAmount })}
|
||||||
|
aria-label="Donate via Venmo"
|
||||||
|
>
|
||||||
|
📱 Venmo
|
||||||
|
</motion.button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1628,6 +1906,7 @@ function DonatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2392,8 +2671,168 @@ function PortalsPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== Authentication Components ===================== */
|
||||||
|
function LoginForm({ requiredRole }: { requiredRole?: 'admin' | 'volunteer' | 'resource' }) {
|
||||||
|
const { login, isLoading } = useAuth()
|
||||||
|
const [formData, setFormData] = useState({ email: '', password: '' })
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const success = await login(formData.email, formData.password)
|
||||||
|
if (!success) {
|
||||||
|
setError('Invalid credentials. Please try again.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleHint = () => {
|
||||||
|
switch (requiredRole) {
|
||||||
|
case 'admin': return 'Use an email containing "admin" to access admin features'
|
||||||
|
case 'volunteer': return 'Use an email containing "volunteer" for volunteer access'
|
||||||
|
case 'resource': return 'Use any other email for resource center access'
|
||||||
|
default: return 'Enter your credentials to access the portal'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-gray-900 dark:to-gray-800 px-4">
|
||||||
|
<motion.div
|
||||||
|
className="w-full max-w-md"
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.6 }}
|
||||||
|
>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<motion.div
|
||||||
|
className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 text-white rounded-full mb-4"
|
||||||
|
whileHover={{ scale: 1.05, rotateY: 180 }}
|
||||||
|
style={{ transformStyle: 'preserve-3d' }}
|
||||||
|
>
|
||||||
|
<Lock className="w-8 h-8" />
|
||||||
|
</motion.div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{requiredRole ? `${requiredRole.charAt(0).toUpperCase() + requiredRole.slice(1)} Portal` : 'Portal Access'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-2">
|
||||||
|
Sign in to access your dashboard
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium mb-2">
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="input w-full focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="your.email@example.com"
|
||||||
|
style={{ minHeight: '44px' }} // Mobile touch optimization
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium mb-2">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
className="input w-full focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
style={{ minHeight: '44px' }} // Mobile touch optimization
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
className="text-red-600 dark:text-red-400 text-sm p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
|
||||||
|
<strong>Demo Access:</strong> {getRoleHint()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className="w-full btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
style={{ minHeight: '44px' }} // Mobile touch optimization
|
||||||
|
whileHover={{ scale: isLoading ? 1 : 1.02 }}
|
||||||
|
whileTap={{ scale: isLoading ? 1 : 0.98 }}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<><Clock className="w-4 h-4 mr-2 animate-spin" /> Signing In...</>
|
||||||
|
) : (
|
||||||
|
<>Sign In</>
|
||||||
|
)}
|
||||||
|
</motion.button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<a href="#/" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||||
|
← Back to Main Site
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PortalWrapper({ children, requiredRole }: { children: React.ReactNode, requiredRole?: 'admin' | 'volunteer' | 'resource' }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <LoginForm requiredRole={requiredRole} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredRole && user.role !== requiredRole) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4">
|
||||||
|
<div className="card max-w-md w-full text-center">
|
||||||
|
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
You don't have permission to access the {requiredRole} portal.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<a href="#/" className="btn-primary">
|
||||||
|
Return to Main Site
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
onClick={() => useAuth().logout()}
|
||||||
|
className="btn-secondary w-full"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
// Admin Portal Dashboard
|
// Admin Portal Dashboard
|
||||||
function AdminPortalPage() {
|
function AdminPortalPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
const [stats] = useState({
|
const [stats] = useState({
|
||||||
pendingRequests: 23,
|
pendingRequests: 23,
|
||||||
activeVolunteers: 47,
|
activeVolunteers: 47,
|
||||||
@@ -2402,8 +2841,23 @@ function AdminPortalPage() {
|
|||||||
monthlySpent: 8250
|
monthlySpent: 8250
|
||||||
})
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent('admin_portal_view', { user_id: user?.id, user_role: user?.role })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Administration Dashboard" icon={Settings} eyebrow="Full system access">
|
<PortalWrapper requiredRole="admin">
|
||||||
|
<SEOHead title="Admin Dashboard" description="Administrative portal for Miracles in Motion staff and administrators." />
|
||||||
|
<PageShell
|
||||||
|
title="Administration Dashboard"
|
||||||
|
icon={Settings}
|
||||||
|
eyebrow={`Welcome back, ${user?.name}`}
|
||||||
|
cta={
|
||||||
|
<button onClick={logout} className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Quick Stats */}
|
{/* Quick Stats */}
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
@@ -2514,13 +2968,31 @@ function AdminPortalPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
</PortalWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Volunteer Portal Dashboard
|
// Volunteer Portal Dashboard
|
||||||
function VolunteerPortalPage() {
|
function VolunteerPortalPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent('volunteer_portal_view', { user_id: user?.id, user_role: user?.role })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Volunteer Dashboard" icon={UserCheck} eyebrow="Your assignments and schedule">
|
<PortalWrapper requiredRole="volunteer">
|
||||||
|
<SEOHead title="Volunteer Dashboard" description="Volunteer portal for Miracles in Motion volunteers to manage assignments and schedules." />
|
||||||
|
<PageShell
|
||||||
|
title="Volunteer Dashboard"
|
||||||
|
icon={UserCheck}
|
||||||
|
eyebrow={`Hello, ${user?.name}`}
|
||||||
|
cta={
|
||||||
|
<button onClick={logout} className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Today's Tasks */}
|
{/* Today's Tasks */}
|
||||||
<div className="card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
<div className="card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
|
||||||
@@ -2620,14 +3092,32 @@ function VolunteerPortalPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
</PortalWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resource Center Portal Dashboard
|
// Resource Center Portal Dashboard
|
||||||
function ResourcePortalPage() {
|
function ResourcePortalPage() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent('resource_portal_view', { user_id: user?.id, user_role: user?.role })
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell title="Resource Center Portal" icon={School} eyebrow="Submit and track assistance requests">
|
<PortalWrapper requiredRole="resource">
|
||||||
<div className="space-y-8">
|
<SEOHead title="Resource Portal" description="Resource center portal for submitting and tracking student assistance requests." />
|
||||||
|
<PageShell
|
||||||
|
title="Resource Center Portal"
|
||||||
|
icon={School}
|
||||||
|
eyebrow={`Welcome, ${user?.name}`}
|
||||||
|
cta={
|
||||||
|
<button onClick={logout} className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-8">
|
||||||
{/* Quick Submit */}
|
{/* Quick Submit */}
|
||||||
<div className="card bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
<div className="card bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
|
||||||
<div className="flex items-center gap-4 mb-6">
|
<div className="flex items-center gap-4 mb-6">
|
||||||
@@ -2740,6 +3230,7 @@ function ResourcePortalPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
</PortalWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user