feat: implement authentication context and user session management; enhance donation impact calculator and UI interactions
This commit is contained in:
605
src/App.tsx
605
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 {
|
||||
ArrowRight,
|
||||
@@ -51,11 +51,178 @@ import {
|
||||
* 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 ===================== */
|
||||
interface IconProps {
|
||||
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 {
|
||||
icon: React.ComponentType<IconProps>
|
||||
title: string
|
||||
@@ -1331,6 +1498,7 @@ function DonatePage() {
|
||||
const [selectedAmount, setSelectedAmount] = useState(50)
|
||||
const [customAmount, setCustomAmount] = useState('')
|
||||
const [isRecurring, setIsRecurring] = useState(false)
|
||||
const [donorInfo, setDonorInfo] = useState({ email: '', name: '', anonymous: false })
|
||||
|
||||
const suggestedAmounts = [
|
||||
{ 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 }
|
||||
]
|
||||
|
||||
const getImpactText = (amount: number) => {
|
||||
if (amount >= 250) return `Emergency support for ${Math.floor(amount / 50)} families`
|
||||
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' : ''}`
|
||||
if (amount >= 25) return `School supplies for ${Math.floor(amount / 25)} student${Math.floor(amount / 25) > 1 ? 's' : ''}`
|
||||
return "Every dollar helps a student in need"
|
||||
const finalAmount = customAmount ? parseInt(customAmount) || 0 : selectedAmount
|
||||
const impactData = calculateDonationImpact(finalAmount)
|
||||
|
||||
// Enhanced impact text with real calculations
|
||||
const getDetailedImpactText = (amount: number) => {
|
||||
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 (
|
||||
<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="md:col-span-2 space-y-8">
|
||||
{/* Impact Calculator */}
|
||||
{/* Enhanced Impact Calculator */}
|
||||
<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"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
whileHover={{ scale: 1.01, y: -2 }}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="p-3 bg-primary-600 text-white rounded-xl">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<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" />
|
||||
</div>
|
||||
</motion.div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg mb-2">Your Impact: ${finalAmount}</h3>
|
||||
<p className="text-primary-700 dark:text-primary-300 font-medium">
|
||||
{getImpactText(finalAmount)}
|
||||
<p className="text-primary-700 dark:text-primary-300 font-medium mb-3">
|
||||
{getDetailedImpactText(finalAmount)}
|
||||
</p>
|
||||
{isRecurring && (
|
||||
<p className="text-sm text-primary-600 dark:text-primary-400 mt-1">
|
||||
That's {getImpactText(finalAmount * 12).replace(/\d+/, String(Math.floor((finalAmount * 12) / 25)))} annually!
|
||||
</p>
|
||||
|
||||
{/* Real-time impact breakdown */}
|
||||
{finalAmount > 0 && (
|
||||
<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>
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
{/* Suggested Amounts */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 mb-6">
|
||||
{/* Suggested Amounts - Mobile Optimized */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4 mb-6">
|
||||
{suggestedAmounts.map((tier) => (
|
||||
<motion.button
|
||||
key={tier.amount}
|
||||
onClick={() => {
|
||||
setSelectedAmount(tier.amount)
|
||||
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 ${
|
||||
selectedAmount === tier.amount && !customAmount
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
|
||||
: '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 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
aria-label={`Donate $${tier.amount} - ${tier.impact}`}
|
||||
@@ -1443,53 +1671,103 @@ function DonatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recurring Option */}
|
||||
<div className="mb-6 p-4 bg-secondary-50 dark:bg-secondary-900/20 rounded-xl">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecurring}
|
||||
onChange={(e) => setIsRecurring(e.target.checked)}
|
||||
className="mt-1 h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Make this a monthly donation</div>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Recurring donations help us plan better and have more impact
|
||||
</div>
|
||||
{isRecurring && finalAmount > 0 && (
|
||||
<div className="text-sm text-secondary-600 dark:text-secondary-400 mt-1 font-medium">
|
||||
Annual Impact: {getImpactText(finalAmount * 12)}
|
||||
{/* Recurring Option & Donor Info */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="p-4 bg-secondary-50 dark:bg-secondary-900/20 rounded-xl">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecurring}
|
||||
onChange={(e) => {
|
||||
setIsRecurring(e.target.checked)
|
||||
trackEvent('donation_recurring_toggle', { recurring: e.target.checked, amount: finalAmount })
|
||||
}}
|
||||
className="mt-1 h-5 w-5 text-primary-600 focus:ring-primary-500 border-gray-300 rounded" // Enhanced for mobile
|
||||
style={{ minHeight: '20px', minWidth: '20px' }}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Make this a monthly donation</div>
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
Recurring donations help us plan better and have more impact
|
||||
</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>
|
||||
</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>
|
||||
|
||||
{/* Donation Buttons */}
|
||||
{/* Enhanced Donation Buttons */}
|
||||
<div className="space-y-3">
|
||||
<motion.button
|
||||
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"
|
||||
style={{ minHeight: '56px' }} // Enhanced mobile touch target
|
||||
whileHover={finalAmount > 0 ? { scale: 1.02 } : {}}
|
||||
whileTap={finalAmount > 0 ? { scale: 0.98 } : {}}
|
||||
onClick={() => {
|
||||
// This would integrate with Stripe or another payment processor
|
||||
alert(`Processing ${isRecurring ? 'monthly ' : ''}donation of $${finalAmount}`)
|
||||
}}
|
||||
onClick={handleDonationSubmit}
|
||||
aria-label={`Donate $${finalAmount} ${isRecurring ? 'monthly' : 'one-time'} via credit card`}
|
||||
>
|
||||
<Heart className="mr-2 h-5 w-5" />
|
||||
Donate ${finalAmount} {isRecurring ? 'Monthly' : 'Securely'}
|
||||
</motion.button>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<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">
|
||||
PayPal
|
||||
</button>
|
||||
<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">
|
||||
Venmo
|
||||
</button>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<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: '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>
|
||||
|
||||
@@ -1628,6 +1906,7 @@ function DonatePage() {
|
||||
</div>
|
||||
</div>
|
||||
</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
|
||||
function AdminPortalPage() {
|
||||
const { user, logout } = useAuth()
|
||||
const [stats] = useState({
|
||||
pendingRequests: 23,
|
||||
activeVolunteers: 47,
|
||||
@@ -2402,8 +2841,23 @@ function AdminPortalPage() {
|
||||
monthlySpent: 8250
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('admin_portal_view', { user_id: user?.id, user_role: user?.role })
|
||||
}, [])
|
||||
|
||||
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">
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
@@ -2514,13 +2968,31 @@ function AdminPortalPage() {
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
</PortalWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// Volunteer Portal Dashboard
|
||||
// Volunteer Portal Dashboard
|
||||
function VolunteerPortalPage() {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('volunteer_portal_view', { user_id: user?.id, user_role: user?.role })
|
||||
}, [])
|
||||
|
||||
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">
|
||||
{/* Today's Tasks */}
|
||||
<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>
|
||||
</PageShell>
|
||||
</PortalWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
// Resource Center Portal Dashboard
|
||||
function ResourcePortalPage() {
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('resource_portal_view', { user_id: user?.id, user_role: user?.role })
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageShell title="Resource Center Portal" icon={School} eyebrow="Submit and track assistance requests">
|
||||
<div className="space-y-8">
|
||||
<PortalWrapper requiredRole="resource">
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -2740,6 +3230,7 @@ function ResourcePortalPage() {
|
||||
</div>
|
||||
</div>
|
||||
</PageShell>
|
||||
</PortalWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user