diff --git a/env.production.template b/env.production.example similarity index 100% rename from env.production.template rename to env.production.example diff --git a/index.html b/index.html index 5162cda..2c5521c 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,7 @@ + Miracles In Motion | 501(c)3 Non-Profit Organization @@ -12,7 +13,7 @@ - + @@ -30,12 +31,7 @@ - - - - - - +
diff --git a/package-lock.json b/package-lock.json index 671c20c..80d6102 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 136b770..acb506e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/fonts/README.md b/public/fonts/README.md new file mode 100644 index 0000000..d8b26bd --- /dev/null +++ b/public/fonts/README.md @@ -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. diff --git a/public/health b/public/health new file mode 100644 index 0000000..9766475 --- /dev/null +++ b/public/health @@ -0,0 +1 @@ +ok diff --git a/public/robots.txt b/public/robots.txt index 29cb047..b2435fe 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -8,8 +8,5 @@ Disallow: /node_modules/ Disallow: /dist/ Disallow: /*.log -# Sitemap -Sitemap: https://miraclesinmotion.org/sitemap.xml - -# Crawl delay (optional) -Crawl-delay: 1 \ No newline at end of file +# Sitemap (canonical domain) +Sitemap: https://mim4u.org/sitemap.xml \ No newline at end of file diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 0000000..10539fd --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,38 @@ + + + + https://mim4u.org/ + weekly + 1.0 + + + https://mim4u.org/#/donate + monthly + 0.9 + + + https://mim4u.org/#/request-assistance + monthly + 0.9 + + + https://mim4u.org/#/volunteers + monthly + 0.8 + + + https://mim4u.org/#/stories + weekly + 0.8 + + + https://mim4u.org/#/impact + monthly + 0.8 + + + https://mim4u.org/#/legal + monthly + 0.5 + + diff --git a/src/App.tsx b/src/App.tsx index 8b6ce89..b4d6f4d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1282,7 +1282,7 @@ function Impact() {
- + See the impact report
@@ -2282,11 +2282,47 @@ function TestimoniesPage() { ) } +function ImpactReportPage() { + return ( + Donate to support more students}> +
+

+ 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 contact@mim4u.org. +

+
+
+
85%
+
of donations go directly to student support
+
+
+
10%
+
program operations
+
+
+
5%
+
administrative
+
+
+
$48 / $72
+
average grant (supplies / clothing)
+
+
+
+

How we confirm support

+

+ 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 Privacy Policy and is never shared for marketing. +

+
+
+
+ ) +} + function LegalPage() { return ( Donate}> -

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 privacy@mim4u.org.

+

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 privacy@mim4u.org.

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.

@@ -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 + return { ...initialAssistanceFormState(), ...parsed } + } + } catch (_) { /* ignore */ } + return initialAssistanceFormState() + }) + const [errors, setErrors] = useState>({}) + const [honeypot, setHoneypot] = useState('') + const [draftSaved, setDraftSaved] = useState(false) + const [submitError, setSubmitError] = useState(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 = {} + 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() {
+ {/* Honeypot: leave empty; bots that fill it are ignored */} + {/* Request Type */}
-

Type of Request

+

Type of Request *

+ {errors.requestType &&

{errors.requestType}

}
{[ { 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.label}
@@ -2480,41 +2628,55 @@ function AssistanceRequestPage() {

Student Information

- + 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'] && }
- + 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'] && }
- + + {errors['studentInfo.grade'] && }
- + 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'] && }
@@ -2546,55 +2714,74 @@ function AssistanceRequestPage() {

Contact Information

- + 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'] && }
- + 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'] && }
- + 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'] && }
- + + {errors['contactInfo.relationship'] && }
@@ -2647,17 +2835,25 @@ function AssistanceRequestPage() {

Additional Information

-