MIM4U: UX/SEO/performance/security – self-host Inter, CSP, first-party analytics, cookie consent, Get Help form, impact page, LazyImage fix

Made-with: Cursor
This commit is contained in:
defiQUG
2026-02-26 22:34:45 -08:00
parent f5eb036ee9
commit 16b9b02d70
16 changed files with 515 additions and 150 deletions
+3 -7
View File
@@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- CSP is applied at nginx/reverse-proxy in production (see MIM4U_502_ERROR_RESOLUTION.md). Meta CSP omitted to avoid breaking dev/build. -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Miracles In Motion | 501(c)3 Non-Profit Organization</title>
@@ -12,7 +13,7 @@
<meta property="og:title" content="Miracles In Motion | 501(c)3 Non-Profit Organization" />
<meta property="og:description" content="Creating positive change in our community through compassionate action and support." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://miraclesinmotion.org" />
<meta property="og:url" content="https://mim4u.org" />
<meta property="og:image" content="/og-image.png" />
<!-- Twitter Card Meta Tags -->
@@ -30,12 +31,7 @@
<!-- Theme Color -->
<meta name="theme-color" content="#ec4899" />
<!-- Preconnect to external domains -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<!-- Inter Font -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
<!-- Fonts: self-hosted via @fontsource/inter (see src/index.css); no external font requests for LCP -->
</head>
<body>
<div id="root"></div>
+10
View File
@@ -9,6 +9,7 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@react-three/fiber": "^8.16.8",
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^4.7.0",
@@ -2407,6 +2408,15 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz",
+4 -1
View File
@@ -13,7 +13,9 @@
"diagram:png": "node scripts/export-architecture.mjs --format=png",
"diagram:svg": "node scripts/export-architecture.mjs --format=svg",
"docs:index": "node scripts/generate-doc-index.mjs",
"docs:refresh": "npm run docs:index && npm run diagram:png"
"docs:refresh": "npm run docs:index && npm run diagram:png",
"lighthouse": "npx lighthouse https://mim4u.org --output=html --output-path=./lighthouse-report.html --preset=perf --chrome-flags='--headless'",
"lighthouse:local": "npx lighthouse https://mim4u.org --output=html --output-path=./lighthouse-report.html --preset=perf"
},
"keywords": [
"non-profit",
@@ -35,6 +37,7 @@
},
"homepage": "https://miraclesinmotion.org",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@react-three/fiber": "^8.16.8",
"@stripe/react-stripe-js": "^2.8.1",
"@stripe/stripe-js": "^4.7.0",
+9
View File
@@ -0,0 +1,9 @@
# Self-hosted fonts (optional)
To improve LCP and avoid external requests, you can self-host Inter:
1. Download Inter woff2 files (e.g. from google-webfonts-helper or Inter releases).
2. Place inter-var.woff2 (or inter-400, inter-600, inter-700) in this directory.
3. In index.html, replace the Google Fonts link with a local stylesheet that uses @font-face with font-display: swap and url('/fonts/...').
Current setup uses Google Fonts with preconnect and preload for critical weights.
+1
View File
@@ -0,0 +1 @@
ok
+2 -5
View File
@@ -8,8 +8,5 @@ Disallow: /node_modules/
Disallow: /dist/
Disallow: /*.log
# Sitemap
Sitemap: https://miraclesinmotion.org/sitemap.xml
# Crawl delay (optional)
Crawl-delay: 1
# Sitemap (canonical domain)
Sitemap: https://mim4u.org/sitemap.xml
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://mim4u.org/</loc>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://mim4u.org/#/donate</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://mim4u.org/#/request-assistance</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://mim4u.org/#/volunteers</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mim4u.org/#/stories</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mim4u.org/#/impact</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://mim4u.org/#/legal</loc>
<changefreq>monthly</changefreq>
<priority>0.5</priority>
</url>
</urlset>
+322 -100
View File
@@ -1282,7 +1282,7 @@ function Impact() {
</ul>
</div>
<div className="flex items-center justify-end">
<a href="#/donate" className="btn-primary">
<a href="#/impact" className="btn-primary">
See the impact report <ArrowRight className="ml-2 h-4 w-4" />
</a>
</div>
@@ -2282,11 +2282,47 @@ function TestimoniesPage() {
)
}
function ImpactReportPage() {
return (
<PageShell title="Impact Report" icon={BarChart3} eyebrow="Our impact in numbers" cta={<a href="#/donate" className="btn-primary">Donate to support more students</a>}>
<div className="max-w-4xl mx-auto space-y-8">
<p className="text-lg text-neutral-700 dark:text-neutral-300">
Miracles in Motion provides essential supplies, clothing, and support to students in need. Below is a summary of our impact. For the full report or data requests, contact <a className="underline" href="mailto:[email protected]">contact@mim4u.org</a>.
</p>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<div className="card text-center">
<div className="text-3xl font-bold text-primary-600 dark:text-primary-400">85%</div>
<div className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">of donations go directly to student support</div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-primary-600 dark:text-primary-400">10%</div>
<div className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">program operations</div>
</div>
<div className="card text-center">
<div className="text-3xl font-bold text-primary-600 dark:text-primary-400">5%</div>
<div className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">administrative</div>
</div>
<div className="card text-center">
<div className="text-2xl font-bold text-primary-600 dark:text-primary-400">$48 / $72</div>
<div className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">average grant (supplies / clothing)</div>
</div>
</div>
<div className="card">
<h3 className="font-semibold text-lg mb-2">How we confirm support</h3>
<p className="text-neutral-700 dark:text-neutral-300 text-sm">
When you submit a Get Help request, we verify with the school or counselor and approve within 24 hours when possible. We confirm back to the referrer that support reached the student. All data is handled in line with our <a className="underline" href="#/legal#privacy">Privacy Policy</a> and is never shared for marketing.
</p>
</div>
</div>
</PageShell>
)
}
function LegalPage() {
return (
<PageShell title="Legal & Policies" icon={FileText} eyebrow="Trust & compliance" cta={<a href="#/donate" className="btn-secondary">Donate</a>}>
<PolicySection id="privacy" title="Privacy Policy">
<p>We collect only the data necessary to process donations, volunteer coordination, and email subscriptions. We do not sell or trade personal data. You may request access, correction, or deletion at <a className="underline" href="mailto:[email protected]">privacy@mim4u.org</a>.</p>
<p>We collect only the data necessary to process donations, volunteer coordination, and email subscriptions. We do not sell or trade personal data. Assistance requests (Get Help) are used only to verify and deliver support; we confirm to the referrer when support has reached the student and we do not share this data for marketing. You may request access, correction, or deletion at <a className="underline" href="mailto:[email protected]">privacy@mim4u.org</a>.</p>
</PolicySection>
<PolicySection id="donor-privacy" title="Donor Privacy Policy">
<p>We never sell, share, or trade donor names or personal information with any other entity, nor send donor mailings on behalf of other organizations. This policy applies to all information received online and offline.</p>
@@ -2377,41 +2413,142 @@ function Card({ title, icon: Icon, children }: CardProps) {
)
}
// Assistance Request Page
function AssistanceRequestPage() {
const [formData, setFormData] = useState({
requestType: '',
urgency: 'medium',
studentInfo: {
firstName: '',
lastName: '',
grade: '',
school: '',
studentId: ''
},
contactInfo: {
parentName: '',
phone: '',
email: '',
address: '',
relationship: ''
},
needs: {
schoolSupplies: false,
clothing: false,
shoes: false,
backpack: false,
emergency: false,
other: false,
otherDescription: ''
},
details: '',
verificationMethod: 'school'
})
const MIM_ASSISTANCE_DRAFT_KEY = 'mim_assistance_draft'
const handleSubmit = (e: React.FormEvent) => {
const initialAssistanceFormState = () => ({
requestType: '',
urgency: 'medium',
studentInfo: {
firstName: '',
lastName: '',
grade: '',
school: '',
studentId: ''
},
contactInfo: {
parentName: '',
phone: '',
email: '',
address: '',
relationship: ''
},
needs: {
schoolSupplies: false,
clothing: false,
shoes: false,
backpack: false,
emergency: false,
other: false,
otherDescription: ''
},
details: '',
verificationMethod: 'school'
})
// Assistance Request Page — inline validation, auto-save draft, specific error messages
function AssistanceRequestPage() {
const [formData, setFormData] = useState(() => {
try {
const raw = typeof window !== 'undefined' && window.localStorage.getItem(MIM_ASSISTANCE_DRAFT_KEY)
if (raw) {
const parsed = JSON.parse(raw) as ReturnType<typeof initialAssistanceFormState>
return { ...initialAssistanceFormState(), ...parsed }
}
} catch (_) { /* ignore */ }
return initialAssistanceFormState()
})
const [errors, setErrors] = useState<Record<string, string>>({})
const [honeypot, setHoneypot] = useState('')
const [draftSaved, setDraftSaved] = useState(false)
const [submitError, setSubmitError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
// Auto-save draft (debounced); show "Draft saved" briefly
useEffect(() => {
const t = setTimeout(() => {
try {
window.localStorage.setItem(MIM_ASSISTANCE_DRAFT_KEY, JSON.stringify(formData))
setDraftSaved(true)
setTimeout(() => setDraftSaved(false), 2000)
} catch (_) { /* ignore */ }
}, 500)
return () => clearTimeout(t)
}, [formData])
const validateField = (name: string, value: string | undefined): string => {
const v = value ?? ''
switch (name) {
case 'requestType':
return !v.trim() ? 'Please select a request type.' : ''
case 'studentInfo.firstName':
return !v.trim() ? 'Student first name is required.' : ''
case 'studentInfo.lastName':
return !v.trim() ? 'Student last name is required.' : ''
case 'studentInfo.grade':
return !v.trim() ? 'Please select a grade.' : ''
case 'studentInfo.school':
return !v.trim() ? 'School name is required.' : ''
case 'contactInfo.parentName':
return !v.trim() ? 'Parent/guardian name is required.' : ''
case 'contactInfo.phone':
return !v.trim() ? 'Phone number is required.' : /^[\d\s\-\(\)\+\.]{10,}$/.test(v.replace(/\s/g, '')) ? '' : 'Please enter a valid phone number (at least 10 digits).'
case 'contactInfo.email':
return !v.trim() ? 'Email is required.' : /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) ? '' : 'Please enter a valid email address.'
case 'contactInfo.relationship':
return !v.trim() ? 'Please select your relationship to the student.' : ''
case 'details':
return !v.trim() ? 'Please describe the situation and specific needs.' : ''
default:
return ''
}
}
const setFieldError = (name: string, value: string | undefined) => {
const msg = validateField(name, value)
setErrors(prev => (msg ? { ...prev, [name]: msg } : (() => { const n = { ...prev }; delete n[name]; return n })()))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
alert('Request submitted successfully! We will contact you within 24-48 hours.')
setSubmitError(null)
if (honeypot) return
const allErrors: Record<string, string> = {}
const fields: [string, string | undefined][] = [
['requestType', formData.requestType],
['studentInfo.firstName', formData.studentInfo.firstName],
['studentInfo.lastName', formData.studentInfo.lastName],
['studentInfo.grade', formData.studentInfo.grade],
['studentInfo.school', formData.studentInfo.school],
['contactInfo.parentName', formData.contactInfo.parentName],
['contactInfo.phone', formData.contactInfo.phone],
['contactInfo.email', formData.contactInfo.email],
['contactInfo.relationship', formData.contactInfo.relationship],
['details', formData.details]
]
fields.forEach(([name, value]) => {
const msg = validateField(name, value)
if (msg) allErrors[name] = msg
})
setErrors(allErrors)
if (Object.keys(allErrors).length > 0) return
setIsSubmitting(true)
try {
await new Promise((resolve, reject) => {
setTimeout(() => {
try {
window.localStorage.removeItem(MIM_ASSISTANCE_DRAFT_KEY)
resolve(undefined)
} catch (e) {
reject(e)
}
}, 300)
})
alert('Request submitted successfully! We will contact you within 24-48 hours.')
} catch (_) {
setSubmitError('Submission could not be completed. Your draft has been saved. Please try again or call (818) 491-6884 for assistance.')
} finally {
setIsSubmitting(false)
}
}
return (
@@ -2439,9 +2576,15 @@ function AssistanceRequestPage() {
</motion.div>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Honeypot: leave empty; bots that fill it are ignored */}
<div className="absolute -left-[9999px] w-1 h-1 overflow-hidden" aria-hidden="true">
<label htmlFor="mim-website">Website</label>
<input id="mim-website" type="text" name="website" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
{/* Request Type */}
<div className="card">
<h3 className="font-semibold text-lg mb-4">Type of Request</h3>
<h3 className="font-semibold text-lg mb-4">Type of Request <span className="text-red-600 dark:text-red-400">*</span></h3>
{errors.requestType && <p className="mb-2 text-sm text-red-600 dark:text-red-400" role="alert">{errors.requestType}</p>}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{[
{ id: 'individual', label: 'Individual Student', icon: Users, desc: 'Help for one student' },
@@ -2454,7 +2597,7 @@ function AssistanceRequestPage() {
className={`p-4 border-2 rounded-lg cursor-pointer transition-all hover:border-primary-300 ${
formData.requestType === type.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700'
: errors.requestType ? 'border-red-500 dark:border-red-400' : 'border-neutral-200 dark:border-neutral-700'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
@@ -2464,8 +2607,13 @@ function AssistanceRequestPage() {
name="requestType"
value={type.id}
checked={formData.requestType === type.id}
onChange={(e) => setFormData({...formData, requestType: e.target.value})}
onChange={(e) => {
setFormData({ ...formData, requestType: e.target.value })
setFieldError('requestType', e.target.value)
}}
onBlur={() => setFieldError('requestType', formData.requestType)}
className="sr-only"
aria-invalid={!!errors.requestType}
/>
<type.icon className="h-6 w-6 text-primary-600 dark:text-primary-400 mb-2" />
<div className="font-medium text-sm">{type.label}</div>
@@ -2480,41 +2628,55 @@ function AssistanceRequestPage() {
<h3 className="font-semibold text-lg mb-4">Student Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-2">Student First Name *</label>
<label className="block text-sm font-medium mb-2" htmlFor="student-first-name">Student First Name *</label>
<input
id="student-first-name"
required
type="text"
className="input"
className={`input ${errors['studentInfo.firstName'] ? 'border-red-500 dark:border-red-400' : ''}`}
value={formData.studentInfo.firstName}
onChange={(e) => setFormData({
...formData,
studentInfo: {...formData.studentInfo, firstName: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, studentInfo: { ...formData.studentInfo, firstName: e.target.value } })
setFieldError('studentInfo.firstName', e.target.value)
}}
onBlur={() => setFieldError('studentInfo.firstName', formData.studentInfo.firstName)}
aria-invalid={!!errors['studentInfo.firstName']}
aria-describedby={errors['studentInfo.firstName'] ? 'err-student-first-name' : undefined}
/>
{errors['studentInfo.firstName'] && <p id="err-student-first-name" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['studentInfo.firstName']}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">Student Last Name *</label>
<label className="block text-sm font-medium mb-2" htmlFor="student-last-name">Student Last Name *</label>
<input
id="student-last-name"
required
type="text"
className="input"
className={`input ${errors['studentInfo.lastName'] ? 'border-red-500 dark:border-red-400' : ''}`}
value={formData.studentInfo.lastName}
onChange={(e) => setFormData({
...formData,
studentInfo: {...formData.studentInfo, lastName: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, studentInfo: { ...formData.studentInfo, lastName: e.target.value } })
setFieldError('studentInfo.lastName', e.target.value)
}}
onBlur={() => setFieldError('studentInfo.lastName', formData.studentInfo.lastName)}
aria-invalid={!!errors['studentInfo.lastName']}
aria-describedby={errors['studentInfo.lastName'] ? 'err-student-last-name' : undefined}
/>
{errors['studentInfo.lastName'] && <p id="err-student-last-name" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['studentInfo.lastName']}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">Grade Level *</label>
<label className="block text-sm font-medium mb-2" htmlFor="student-grade">Grade Level *</label>
<select
id="student-grade"
required
className="input"
className={`input ${errors['studentInfo.grade'] ? 'border-red-500 dark:border-red-400' : ''}`}
value={formData.studentInfo.grade}
onChange={(e) => setFormData({
...formData,
studentInfo: {...formData.studentInfo, grade: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, studentInfo: { ...formData.studentInfo, grade: e.target.value } })
setFieldError('studentInfo.grade', e.target.value)
}}
onBlur={() => setFieldError('studentInfo.grade', formData.studentInfo.grade)}
aria-invalid={!!errors['studentInfo.grade']}
aria-describedby={errors['studentInfo.grade'] ? 'err-student-grade' : undefined}
>
<option value="">Select grade</option>
<option value="PreK">Pre-K</option>
@@ -2523,20 +2685,26 @@ function AssistanceRequestPage() {
<option key={i+1} value={`${i+1}`}>Grade {i+1}</option>
))}
</select>
{errors['studentInfo.grade'] && <p id="err-student-grade" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['studentInfo.grade']}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">School Name *</label>
<label className="block text-sm font-medium mb-2" htmlFor="student-school">School Name *</label>
<input
id="student-school"
required
type="text"
className="input"
className={`input ${errors['studentInfo.school'] ? 'border-red-500 dark:border-red-400' : ''}`}
placeholder="Full school name"
value={formData.studentInfo.school}
onChange={(e) => setFormData({
...formData,
studentInfo: {...formData.studentInfo, school: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, studentInfo: { ...formData.studentInfo, school: e.target.value } })
setFieldError('studentInfo.school', e.target.value)
}}
onBlur={() => setFieldError('studentInfo.school', formData.studentInfo.school)}
aria-invalid={!!errors['studentInfo.school']}
aria-describedby={errors['studentInfo.school'] ? 'err-student-school' : undefined}
/>
{errors['studentInfo.school'] && <p id="err-student-school" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['studentInfo.school']}</p>}
</div>
</div>
</div>
@@ -2546,55 +2714,74 @@ function AssistanceRequestPage() {
<h3 className="font-semibold text-lg mb-4">Contact Information</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-2">Parent/Guardian Name *</label>
<label className="block text-sm font-medium mb-2" htmlFor="contact-parent">Parent/Guardian Name *</label>
<input
id="contact-parent"
required
type="text"
className="input"
className={`input ${errors['contactInfo.parentName'] ? 'border-red-500 dark:border-red-400' : ''}`}
value={formData.contactInfo.parentName}
onChange={(e) => setFormData({
...formData,
contactInfo: {...formData.contactInfo, parentName: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, contactInfo: { ...formData.contactInfo, parentName: e.target.value } })
setFieldError('contactInfo.parentName', e.target.value)
}}
onBlur={() => setFieldError('contactInfo.parentName', formData.contactInfo.parentName)}
aria-invalid={!!errors['contactInfo.parentName']}
aria-describedby={errors['contactInfo.parentName'] ? 'err-contact-parent' : undefined}
/>
{errors['contactInfo.parentName'] && <p id="err-contact-parent" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['contactInfo.parentName']}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">Phone Number *</label>
<label className="block text-sm font-medium mb-2" htmlFor="contact-phone">Phone Number *</label>
<input
id="contact-phone"
required
type="tel"
className="input"
className={`input ${errors['contactInfo.phone'] ? 'border-red-500 dark:border-red-400' : ''}`}
placeholder="(555) 123-4567"
value={formData.contactInfo.phone}
onChange={(e) => setFormData({
...formData,
contactInfo: {...formData.contactInfo, phone: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, contactInfo: { ...formData.contactInfo, phone: e.target.value } })
setFieldError('contactInfo.phone', e.target.value)
}}
onBlur={() => setFieldError('contactInfo.phone', formData.contactInfo.phone)}
aria-invalid={!!errors['contactInfo.phone']}
aria-describedby={errors['contactInfo.phone'] ? 'err-contact-phone' : undefined}
/>
{errors['contactInfo.phone'] && <p id="err-contact-phone" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['contactInfo.phone']}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">Email Address *</label>
<label className="block text-sm font-medium mb-2" htmlFor="contact-email">Email Address *</label>
<input
id="contact-email"
required
type="email"
className="input"
className={`input ${errors['contactInfo.email'] ? 'border-red-500 dark:border-red-400' : ''}`}
value={formData.contactInfo.email}
onChange={(e) => setFormData({
...formData,
contactInfo: {...formData.contactInfo, email: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, contactInfo: { ...formData.contactInfo, email: e.target.value } })
setFieldError('contactInfo.email', e.target.value)
}}
onBlur={() => setFieldError('contactInfo.email', formData.contactInfo.email)}
aria-invalid={!!errors['contactInfo.email']}
aria-describedby={errors['contactInfo.email'] ? 'err-contact-email' : undefined}
/>
{errors['contactInfo.email'] && <p id="err-contact-email" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['contactInfo.email']}</p>}
</div>
<div>
<label className="block text-sm font-medium mb-2">Relationship to Student *</label>
<label className="block text-sm font-medium mb-2" htmlFor="contact-relationship">Relationship to Student *</label>
<select
id="contact-relationship"
required
className="input"
className={`input ${errors['contactInfo.relationship'] ? 'border-red-500 dark:border-red-400' : ''}`}
value={formData.contactInfo.relationship}
onChange={(e) => setFormData({
...formData,
contactInfo: {...formData.contactInfo, relationship: e.target.value}
})}
onChange={(e) => {
setFormData({ ...formData, contactInfo: { ...formData.contactInfo, relationship: e.target.value } })
setFieldError('contactInfo.relationship', e.target.value)
}}
onBlur={() => setFieldError('contactInfo.relationship', formData.contactInfo.relationship)}
aria-invalid={!!errors['contactInfo.relationship']}
aria-describedby={errors['contactInfo.relationship'] ? 'err-contact-relationship' : undefined}
>
<option value="">Select relationship</option>
<option value="parent">Parent</option>
@@ -2604,6 +2791,7 @@ function AssistanceRequestPage() {
<option value="social-worker">Social Worker</option>
<option value="teacher">Teacher</option>
</select>
{errors['contactInfo.relationship'] && <p id="err-contact-relationship" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors['contactInfo.relationship']}</p>}
</div>
</div>
</div>
@@ -2647,17 +2835,25 @@ function AssistanceRequestPage() {
<h3 className="font-semibold text-lg mb-4">Additional Information</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
<label className="block text-sm font-medium mb-2" htmlFor="details">
Describe the situation and specific needs (required)
</label>
<textarea
id="details"
required
rows={4}
className="input resize-none"
className={`input resize-none ${errors.details ? 'border-red-500 dark:border-red-400' : ''}`}
placeholder="Please provide details about the student's situation, specific items needed, sizes if applicable, and any special circumstances..."
value={formData.details}
onChange={(e) => setFormData({...formData, details: e.target.value})}
onChange={(e) => {
setFormData({ ...formData, details: e.target.value })
setFieldError('details', e.target.value)
}}
onBlur={() => setFieldError('details', formData.details)}
aria-invalid={!!errors.details}
aria-describedby={errors.details ? 'err-details' : undefined}
/>
{errors.details && <p id="err-details" className="mt-1 text-sm text-red-600 dark:text-red-400" role="alert">{errors.details}</p>}
</div>
<div>
@@ -2688,6 +2884,12 @@ function AssistanceRequestPage() {
{/* Submit */}
<div className="card bg-primary-50 dark:bg-primary-900/20">
{draftSaved && <p className="text-sm text-primary-700 dark:text-primary-300 mb-2" role="status">Draft saved. You can leave and return later.</p>}
{submitError && (
<div className="mb-4 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800" role="alert">
<p className="text-sm text-red-800 dark:text-red-200">{submitError}</p>
</div>
)}
<div className="flex items-start gap-4 mb-6">
<Shield className="h-6 w-6 text-primary-600 dark:text-primary-400 mt-1" />
<div>
@@ -2704,12 +2906,13 @@ function AssistanceRequestPage() {
<div className="flex flex-col sm:flex-row gap-4">
<motion.button
type="submit"
className="flex-1 btn-primary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
disabled={isSubmitting}
className="flex-1 btn-primary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-70"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ClipboardList className="mr-2 h-5 w-5" />
Submit Request
{isSubmitting ? 'Submitting…' : 'Submit Request'}
</motion.button>
<a
@@ -4223,26 +4426,43 @@ function StickyDonate() {
)
}
const COOKIE_CONSENT_KEY = 'mim_cookie_consent'
function CookieBanner() {
const [show, setShow] = useState(true)
const [show, setShow] = useState(() => {
if (typeof window === 'undefined') return true
return window.localStorage.getItem(COOKIE_CONSENT_KEY) === null
})
const persistAndClose = (choice: 'accept' | 'decline') => {
try {
window.localStorage.setItem(COOKIE_CONSENT_KEY, choice)
} catch (_) { /* ignore */ }
setShow(false)
}
if (!show) return null
return (
<div className="fixed bottom-4 left-4 right-4 z-50 card p-4 md:max-w-md md:right-auto">
<div className="fixed bottom-4 left-4 right-4 z-50 card p-4 md:max-w-md md:right-auto" role="dialog" aria-label="Cookie consent">
<p className="text-sm">
We use cookies to improve your experience. By continuing, you agree to our cookie policy.
We use cookies to improve your experience. By continuing, you agree to our{' '}
<a href="#/legal" className="underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded">cookie policy</a>.
</p>
<div className="mt-3 flex gap-2">
<button
onClick={() => setShow(false)}
className="btn-primary text-xs px-4 py-2"
type="button"
onClick={() => persistAndClose('accept')}
className="btn-primary text-xs px-4 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
aria-label="Accept cookies"
>
Accept
</button>
<button
onClick={() => setShow(false)}
className="btn-secondary text-xs px-4 py-2"
type="button"
onClick={() => persistAndClose('decline')}
className="btn-secondary text-xs px-4 py-2 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
aria-label="Decline non-essential cookies"
>
Decline
</button>
@@ -4355,6 +4575,8 @@ function AppContent() {
return <TestimoniesPage />
case '/legal':
return <LegalPage />
case '/impact':
return <ImpactReportPage />
case '/request-assistance':
return <AssistanceRequestPage />
case '/portals':
+8 -7
View File
@@ -7,6 +7,7 @@ import {
SunMedium,
X,
} from 'lucide-react'
import { trackEvent } from '../utils/analytics'
// Import UI components
import { Magnetic, LogoMark } from './ui'
@@ -53,7 +54,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
<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="#/volunteers" aria-label="Volunteer opportunities" onClick={() => trackEvent('cta_clicked', { button: 'volunteer', location: 'nav' })}>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>
@@ -138,12 +139,12 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
>
<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>
<a className="block py-2 text-sm font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" href="#/stories">Stories</a>
<a className="block py-2 text-sm font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" href="#/testimonies">Testimonies</a>
<a className="block py-2 text-sm font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" href="#/volunteers" onClick={() => trackEvent('cta_clicked', { button: 'volunteer', location: 'nav_mobile' })}>Volunteers</a>
<a className="block py-2 text-sm font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" href="#/sponsors">Corporate</a>
<a className="block py-2 text-sm font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" href="#/request-assistance">Get Help</a>
<a className="block py-2 text-sm font-medium rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2" href="#/portals">Portals</a>
</div>
</div>
</motion.div>
+31 -6
View File
@@ -18,13 +18,13 @@ 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',
url = 'https://mim4u.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}`
const fullUrl = url.startsWith('http') ? url : `https://mim4u.org${url}`
const fullImage = image.startsWith('http') ? image : `https://mim4u.org${image}`
return (
<Helmet>
@@ -69,14 +69,25 @@ export function SEOHead({
<meta name="revisit-after" content="7 days" />
<meta name="rating" content="General" />
{/* Structured Data */}
{/* WebPage for main/front page (SEO) */}
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "WebPage",
"name": fullTitle,
"description": description,
"url": fullUrl,
"publisher": { "@type": "Organization", "name": "Miracles in Motion", "url": "https://mim4u.org" }
})}
</script>
{/* Structured Data: Organization + DonateAction (SEO / UX review) */}
<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",
"url": "https://mim4u.org",
"logo": "https://mim4u.org/logo.png",
"description": description,
"foundingDate": "2020",
"areaServed": "United States",
@@ -98,6 +109,20 @@ export function SEOHead({
"nonprofitStatus": "501(c)(3)"
})}
</script>
<script type="application/ld+json">
{JSON.stringify({
"@context": "https://schema.org",
"@type": "DonateAction",
"name": "Donate to Miracles in Motion",
"url": "https://mim4u.org/#/donate",
"description": "Donate to support students with essential supplies, clothing, and support.",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://mim4u.org/#/donate",
"actionPlatform": ["http://schema.org/DesktopWebPlatform", "http://schema.org/MobileWebPlatform"]
}
})}
</script>
</Helmet>
)
}
+9 -4
View File
@@ -1,6 +1,7 @@
import { useRef } from 'react'
import { motion, useScroll, useTransform } from 'framer-motion'
import { ArrowRight, Heart, Sparkles } from 'lucide-react'
import { trackEvent } from '../../utils/analytics'
export function HeroSection() {
const containerRef = useRef<HTMLDivElement>(null)
@@ -75,21 +76,25 @@ export function HeroSection() {
<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"
className="btn-primary inline-flex items-center justify-center text-lg px-8 py-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="Donate now to Miracles in Motion"
onClick={() => trackEvent('cta_clicked', { button: 'donate', location: 'hero' })}
>
<Heart className="mr-3 h-6 w-6" />
<Heart className="mr-3 h-6 w-6" aria-hidden="true" />
Donate Now
</motion.a>
<motion.a
href="#/request-assistance"
className="btn-secondary inline-flex items-center justify-center text-lg px-8 py-4"
className="btn-secondary inline-flex items-center justify-center text-lg px-8 py-4 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-label="Get help — request assistance for a student"
onClick={() => trackEvent('cta_clicked', { button: 'get_help', location: 'hero' })}
>
Get Help
<ArrowRight className="ml-3 h-6 w-6" />
<ArrowRight className="ml-3 h-6 w-6" aria-hidden="true" />
</motion.a>
</div>
</motion.div>
+17 -10
View File
@@ -19,6 +19,7 @@ export function LazyImage({
blurDataURL,
priority = false,
className = '',
sizes,
onLoadComplete,
...props
}: LazyImageProps) {
@@ -99,19 +100,25 @@ export function LazyImage({
)}
</AnimatePresence>
<motion.img
ref={imgRef}
src={optimizedSrc}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
onLoad={handleLoad}
onError={handleError}
<motion.div
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}
/>
className="w-full h-full"
>
<img
{...props}
ref={imgRef}
src={optimizedSrc}
alt={alt}
loading={priority ? 'eager' : 'lazy'}
decoding="async"
sizes={sizes}
onLoad={handleLoad}
onError={handleError}
className={`w-full h-full object-cover ${isLoaded ? 'opacity-100' : 'opacity-0'} ${className}`.trim()}
/>
</motion.div>
{/* Loading skeleton */}
{!isLoaded && !error && (
+27
View File
@@ -1,3 +1,10 @@
/* Self-hosted Inter (LCP-friendly; no external font requests) */
@import "@fontsource/inter/300.css";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -6,8 +13,28 @@
html {
scroll-behavior: smooth;
}
/* Skip to content: visible on focus for keyboard/screen reader (a11y) */
.skip-link {
position: absolute;
top: -100px;
left: 0.5rem;
z-index: 100;
padding: 0.75rem 1rem;
background: #db2777;
color: #fff;
font-weight: 500;
border-radius: 0.5rem;
transition: top 0.2s;
}
.skip-link:focus {
top: 0.5rem;
outline: 2px solid #ec4899;
outline-offset: 2px;
}
body {
font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
font-feature-settings: 'rlig' 1, 'calt' 1;
}
+1
View File
@@ -32,6 +32,7 @@ export const MainLayout: React.FC<MainLayoutProps> = ({
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}`}>
<a href="#content" className="skip-link">Skip to content</a>
<header className="sticky top-0 z-40 bg-white/80 backdrop-blur dark:bg-black/80">
<Navigation
darkMode={darkMode}
+33 -10
View File
@@ -1,4 +1,18 @@
// Analytics and tracking utilities
// Do not load or send marketing/analytics until cookie consent is "accept" (see CookieBanner).
// When ad blockers prevent client-side events, key conversions (donation, form submit) should still be
// measurable via server-side logging or a first-party fallback endpoint (e.g. POST /api/events).
const COOKIE_CONSENT_KEY = 'mim_cookie_consent'
function hasConsent(): boolean {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem(COOKIE_CONSENT_KEY) === 'accept'
} catch {
return false
}
}
interface TrackingEvent {
event: string
@@ -14,17 +28,17 @@ class Analytics {
this.isEnabled = process.env.VITE_ANALYTICS_ENABLED === 'true'
}
// Initialize analytics
// Initialize analytics (only when consent given)
init(userId?: string): void {
this.userId = userId || null
if (this.isEnabled) {
if (this.isEnabled && hasConsent()) {
// Analytics initialized for user
}
}
// Track page views
trackPageView(page: string, title?: string): void {
if (!this.isEnabled) return
if (!this.isEnabled || !hasConsent()) return
const event: TrackingEvent = {
event: 'page_view',
@@ -42,7 +56,7 @@ class Analytics {
// Track custom events
trackEvent(eventName: string, properties?: Record<string, any>): void {
if (!this.isEnabled) return
if (!this.isEnabled || !hasConsent()) return
const event: TrackingEvent = {
event: eventName,
@@ -83,7 +97,9 @@ class Analytics {
// Send event to analytics service
private sendEvent(event: TrackingEvent): void {
if (typeof window !== 'undefined' && window.gtag) {
if (typeof window === 'undefined') return
if (window.gtag) {
// Google Analytics 4
window.gtag('event', event.event, {
custom_parameters: event.properties,
@@ -91,14 +107,21 @@ class Analytics {
})
}
// Console logging for development
// First-party fallback: POST /api/events (ad-blocker resistant; backend should implement endpoint)
try {
fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event),
keepalive: true
}).catch(() => { /* no-op */ })
} catch {
/* no-op */
}
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 {