P1: path routing, prerender shells, E2E, and HelmetProvider donate fix.
Replace hash URLs with path-based routing, post-build prerender for money pages, Playwright smoke tests, and wrap the app in HelmetProvider so /donate mounts correctly. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('Assistance intake form', () => {
|
||||||
|
test('shows validation errors when required fields empty', async ({ page }) => {
|
||||||
|
await page.goto('/request-assistance/')
|
||||||
|
await page.getByRole('button', { name: /submit request/i }).click()
|
||||||
|
await expect(page.getByText(/required/i).first()).toBeVisible({ timeout: 20000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test.describe('Donate page', () => {
|
||||||
|
test('shows donate UI without mock payment success', async ({ page }) => {
|
||||||
|
page.on('dialog', () => {
|
||||||
|
throw new Error('Unexpected alert() on donate page')
|
||||||
|
})
|
||||||
|
await page.goto('/donate/')
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: 'Donate' })).toBeVisible({ timeout: 25000 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test.describe('MIM4U public navigation', () => {
|
||||||
|
test('home loads with brand heading', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await expect(page.getByRole('navigation', { name: 'Main navigation' })).toBeVisible()
|
||||||
|
await expect(page.getByRole('heading', { level: 1 })).toContainText(/Restoring hope/i, { timeout: 20000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('path routes work without hash', async ({ page }) => {
|
||||||
|
await page.goto('/donate/')
|
||||||
|
await expect(page).toHaveURL(/\/donate\/?/)
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: 'Donate' })).toBeVisible({ timeout: 25000 })
|
||||||
|
|
||||||
|
await page.goto('/request-assistance/')
|
||||||
|
await expect(page).toHaveURL(/\/request-assistance\/?/)
|
||||||
|
await expect(page.getByRole('heading', { level: 1, name: 'Request Assistance' })).toBeVisible({
|
||||||
|
timeout: 25000,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy hash URLs redirect to paths', async ({ page }) => {
|
||||||
|
await page.goto('/')
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.location.hash = '#/donate'
|
||||||
|
window.dispatchEvent(new HashChangeEvent('hashchange'))
|
||||||
|
})
|
||||||
|
await expect(page).toHaveURL(/\/donate\/?/, { timeout: 15000 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('api health via nginx proxy', async ({ request }) => {
|
||||||
|
const res = await request.get('/api/health')
|
||||||
|
expect(res.ok()).toBeTruthy()
|
||||||
|
const body = await res.json()
|
||||||
|
expect(body.ok).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<base href="/" />
|
||||||
<!-- CSP is applied at nginx/reverse-proxy in production (see MIM4U_502_ERROR_RESOLUTION.md). Meta CSP omitted to avoid breaking dev/build. -->
|
<!-- 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" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
|
|||||||
@@ -198,8 +198,8 @@ app.post('/api/create-checkout-session', requireStripe, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: recurring ? 'subscription' : 'payment',
|
mode: recurring ? 'subscription' : 'payment',
|
||||||
success_url: `${PUBLIC_URL}/#/donate?status=success&session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${PUBLIC_URL}/donate?status=success&session_id={CHECKOUT_SESSION_ID}`,
|
||||||
cancel_url: `${PUBLIC_URL}/#/donate?status=cancelled`,
|
cancel_url: `${PUBLIC_URL}/donate?status=cancelled`,
|
||||||
customer_email: email || undefined,
|
customer_email: email || undefined,
|
||||||
line_items: [
|
line_items: [
|
||||||
recurring
|
recurring
|
||||||
|
|||||||
Generated
+17
@@ -35,6 +35,7 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
@@ -3236,6 +3237,22 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.61.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||||
|
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.61.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@polka/url": {
|
"node_modules/@polka/url": {
|
||||||
"version": "1.0.0-next.29",
|
"version": "1.0.0-next.29",
|
||||||
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
|
||||||
|
|||||||
@@ -10,9 +10,12 @@
|
|||||||
"photos:optimize": "bash scripts/optimize-community-photos.sh",
|
"photos:optimize": "bash scripts/optimize-community-photos.sh",
|
||||||
"prebuild": "npm run brand:export && npm run photos:optimize",
|
"prebuild": "npm run brand:export && npm run photos:optimize",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
"postbuild": "node scripts/prerender-money-pages.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:ci": "vitest run --reporter=verbose && npm run test:api",
|
"test:ci": "vitest run --reporter=verbose && npm run test:api",
|
||||||
"test:api": "npm --prefix mim-api test",
|
"test:api": "npm --prefix mim-api test",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:lan": "MIM_E2E_BASE_URL=http://192.168.11.37 playwright test",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"type-check": "tsc --noEmit",
|
"type-check": "tsc --noEmit",
|
||||||
"validate:ci": "npm run type-check && npm run test:ci && npm run a11y:header-audit",
|
"validate:ci": "npm run type-check && npm run test:ci && npm run a11y:header-audit",
|
||||||
@@ -82,6 +85,7 @@
|
|||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.61.0",
|
||||||
"@resvg/resvg-js": "^2.6.2",
|
"@resvg/resvg-js": "^2.6.2",
|
||||||
"@tailwindcss/typography": "^0.5.10",
|
"@tailwindcss/typography": "^0.5.10",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
reporter: [['list']],
|
||||||
|
use: {
|
||||||
|
baseURL: process.env.MIM_E2E_BASE_URL || 'https://mim4u.org',
|
||||||
|
extraHTTPHeaders:
|
||||||
|
process.env.MIM_E2E_BASE_URL?.includes('192.168') ? { Host: 'mim4u.org' } : {},
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||||
|
})
|
||||||
+15
-15
@@ -6,77 +6,77 @@
|
|||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/about</loc>
|
<loc>https://mim4u.org/about</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/mission</loc>
|
<loc>https://mim4u.org/mission</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/what-we-do</loc>
|
<loc>https://mim4u.org/what-we-do</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/stories</loc>
|
<loc>https://mim4u.org/stories</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/testimonies</loc>
|
<loc>https://mim4u.org/testimonies</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/events</loc>
|
<loc>https://mim4u.org/events</loc>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/volunteers</loc>
|
<loc>https://mim4u.org/volunteers</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/donate</loc>
|
<loc>https://mim4u.org/donate</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/contact</loc>
|
<loc>https://mim4u.org/contact</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/request-assistance</loc>
|
<loc>https://mim4u.org/request-assistance</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.9</priority>
|
<priority>0.9</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/sponsors</loc>
|
<loc>https://mim4u.org/sponsors</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.7</priority>
|
<priority>0.7</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/impact</loc>
|
<loc>https://mim4u.org/impact</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.8</priority>
|
<priority>0.8</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/legal</loc>
|
<loc>https://mim4u.org/legal</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.5</priority>
|
<priority>0.5</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/brand</loc>
|
<loc>https://mim4u.org/brand</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
</url>
|
</url>
|
||||||
<url>
|
<url>
|
||||||
<loc>https://mim4u.org/#/portals</loc>
|
<loc>https://mim4u.org/portals</loc>
|
||||||
<changefreq>monthly</changefreq>
|
<changefreq>monthly</changefreq>
|
||||||
<priority>0.6</priority>
|
<priority>0.6</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -6,9 +6,9 @@
|
|||||||
"performance": 72,
|
"performance": 72,
|
||||||
"accessibility": 100,
|
"accessibility": 100,
|
||||||
"best-practices": 100,
|
"best-practices": 100,
|
||||||
"seo": 100
|
"seo": 92
|
||||||
},
|
},
|
||||||
"lcpMs": 2558,
|
"lcpMs": 2557,
|
||||||
"accessibilityFailingAudits": [],
|
"accessibilityFailingAudits": [],
|
||||||
"reportPath": "/home/intlc/projects/miracles_in_motion/reports/status/lighthouse-site-a11y-latest.json"
|
"reportPath": "/home/intlc/projects/miracles_in_motion/reports/status/lighthouse-site-a11y-latest.json"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,26 +4,30 @@
|
|||||||
"subject": "mim4u.org — world-class AAA+ readiness (honest composite)",
|
"subject": "mim4u.org — world-class AAA+ readiness (honest composite)",
|
||||||
"disclaimer": "Composite against external world-class bar (CWV, real conversion, SSR/SEO, security, ops) — not the narrow internal remediation rubrics used for header/motion tickets.",
|
"disclaimer": "Composite against external world-class bar (CWV, real conversion, SSR/SEO, security, ops) — not the narrow internal remediation rubrics used for header/motion tickets.",
|
||||||
"p0SprintStatus": {
|
"p0SprintStatus": {
|
||||||
"label": "P0 parallel sprint — deployed 2026-06-16",
|
"label": "P0 + P1 routing/E2E — deployed 2026-06-16",
|
||||||
"deployed": true,
|
"deployed": true,
|
||||||
"itemsComplete": [
|
"itemsComplete": [
|
||||||
"mim-api VMID 7811 — health, assistance, contact, Stripe checkout + donor receipt email on webhook",
|
"mim-api VMID 7811 — health, assistance, contact, Stripe checkout + donor receipt email on webhook",
|
||||||
"Donate StripeDonatePanel; mock fail-closed; assistance + contact forms → API + FormSuccess",
|
"Donate StripeDonatePanel; mock fail-closed; assistance + contact forms → API + FormSuccess",
|
||||||
"Static LCP shell + idle-deferred React mount; i18n en-only initial bundle; trimmed PWA precache",
|
"Static LCP shell + idle-deferred React mount; i18n en-only initial bundle; trimmed PWA precache",
|
||||||
"perf:gate P0 (≥72 perf, LCP ≤2600ms); perf:gate:world-class (≥85, LCP ≤2500) for P1",
|
"perf:gate P0 (≥72 perf, LCP ≤2600ms) — passing post-deploy",
|
||||||
|
"Path-based routing + hash migration; prerender shells for donate/assistance/about",
|
||||||
|
"Playwright E2E 6/6 (public routes, forms, API health)",
|
||||||
|
"HelmetProvider fix — /donate SPA mount",
|
||||||
"sync-mim-api-env-7811.sh operator path for Stripe/SMTP secrets",
|
"sync-mim-api-env-7811.sh operator path for Stripe/SMTP secrets",
|
||||||
"nginx CSP Stripe domains; smoke /api/health"
|
"nginx CSP Stripe domains; smoke /api/health + path routes"
|
||||||
],
|
],
|
||||||
"itemsOpen": [
|
"itemsOpen": [
|
||||||
"Operator: set STRIPE_SECRET_KEY + VITE_STRIPE_PUBLISHABLE_KEY (sync-mim-api-env-7811.sh)",
|
"Operator: set STRIPE_SECRET_KEY + VITE_STRIPE_PUBLISHABLE_KEY (sync-mim-api-env-7811.sh)",
|
||||||
"World-class perf ≥85 requires P1 prerender/SSR (current lab 72–73)",
|
"World-class perf ≥85 (perf:gate:world-class) — lab 72, SEO 92",
|
||||||
"Playwright E2E donate + assistance (P1)"
|
"CSP/HSTS on LAN direct IP (NPM/Cloudflare on public)",
|
||||||
|
"Further App.tsx route extraction; portal auth on 7811"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"composite": {
|
"composite": {
|
||||||
"score": 71,
|
"score": 74,
|
||||||
"letterGrade": "C-",
|
"letterGrade": "C",
|
||||||
"tier": "P0 conversion + API live; CWV materially improved (LCP ~2.56s); world-class perf bar still P1",
|
"tier": "P0+P1 routing/E2E live; CWV P0 gate passing; world-class perf bar still open",
|
||||||
"productionUrl": "https://mim4u.org/",
|
"productionUrl": "https://mim4u.org/",
|
||||||
"targetTier": "AAA+ (97+ composite, all pillars ≥90, no P0 blockers)"
|
"targetTier": "AAA+ (97+ composite, all pillars ≥90, no P0 blockers)"
|
||||||
},
|
},
|
||||||
@@ -31,16 +35,18 @@
|
|||||||
"lighthouse": "reports/status/mim4u-site-lighthouse-latest.json",
|
"lighthouse": "reports/status/mim4u-site-lighthouse-latest.json",
|
||||||
"lighthousePerformance": 72,
|
"lighthousePerformance": 72,
|
||||||
"lighthouseAccessibility": 100,
|
"lighthouseAccessibility": 100,
|
||||||
"lcpMsLab": 2558,
|
"lighthouseSeo": 92,
|
||||||
|
"lcpMsLab": 2557,
|
||||||
"cwvTargets": { "lcpMs": 2500, "cls": 0.1, "inpMs": 200 },
|
"cwvTargets": { "lcpMs": 2500, "cls": 0.1, "inpMs": 200 },
|
||||||
"mainBundleGzipKb": 33,
|
"e2eTestsPassing": 6,
|
||||||
|
"mainBundleGzipKb": 31,
|
||||||
"motionChunkGzipKb": 37,
|
"motionChunkGzipKb": 37,
|
||||||
"apiHealth": "https://mim4u.org/api/health — ok, stripe:false until operator keys"
|
"apiHealth": "https://mim4u.org/api/health — ok, stripe:false until operator keys"
|
||||||
},
|
},
|
||||||
"internalGradesVsReality": {
|
"internalGradesVsReality": {
|
||||||
"headerLogoRemediation": { "score": 99, "scope": "Nav SVG, WCAG header tokens only" },
|
"headerLogoRemediation": { "score": 99, "scope": "Nav SVG, WCAG header tokens only" },
|
||||||
"visualEffectsRemediation": { "score": 94, "scope": "Motion debt reduction only" },
|
"visualEffectsRemediation": { "score": 94, "scope": "Motion debt reduction only" },
|
||||||
"worldClassComposite": { "score": 71, "scope": "Full product vs external bar" }
|
"worldClassComposite": { "score": 74, "scope": "Full product vs external bar" }
|
||||||
},
|
},
|
||||||
"residualGaps": "Stripe operator keys + P1 prerender for perf≥85 — see p0SprintStatus.itemsOpen"
|
"residualGaps": "Stripe operator keys + perf≥85 + SEO 100 — see p0SprintStatus.itemsOpen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Post-build prerender shells for money/SEO pages (crawlable HTML + meta).
|
||||||
|
* Usage: node scripts/prerender-money-pages.mjs [--dist=dist]
|
||||||
|
*/
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const repoRoot = join(__dirname, '..')
|
||||||
|
const distArg = process.argv.find((a) => a.startsWith('--dist='))
|
||||||
|
const distDir = distArg ? distArg.split('=')[1] : join(repoRoot, 'dist')
|
||||||
|
|
||||||
|
const PAGES = [
|
||||||
|
{
|
||||||
|
path: 'donate',
|
||||||
|
title: 'Donate | Miracles in Motion Foundation',
|
||||||
|
description:
|
||||||
|
'Give with confidence to Miracles in Motion Foundation. Secure online donations support outreach and emergency assistance in Los Angeles County.',
|
||||||
|
h1: 'Donate',
|
||||||
|
lead: 'Your gift restores hope through outreach, emergency assistance, and compassionate care.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'request-assistance',
|
||||||
|
title: 'Request Assistance | Miracles in Motion Foundation',
|
||||||
|
description:
|
||||||
|
'Request non-emergency assistance from Miracles in Motion Foundation. We respond within 24–48 hours. For urgent needs call (818) 491-6884.',
|
||||||
|
h1: 'Request Assistance',
|
||||||
|
lead: 'Compassionate support for individuals and families in crisis throughout Los Angeles County.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'about',
|
||||||
|
title: 'About Us | Miracles in Motion Foundation',
|
||||||
|
description:
|
||||||
|
'Miracles in Motion Foundation is a California nonprofit serving Los Angeles County with faith, hope, and community restoration.',
|
||||||
|
h1: 'About Miracles in Motion',
|
||||||
|
lead: 'Faith • Hope • Community • Restoration',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const indexPath = join(distDir, 'index.html')
|
||||||
|
let shell
|
||||||
|
try {
|
||||||
|
shell = readFileSync(indexPath, 'utf8')
|
||||||
|
} catch {
|
||||||
|
console.error(`prerender: missing ${indexPath} — run vite build first`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const page of PAGES) {
|
||||||
|
const dir = join(distDir, page.path)
|
||||||
|
mkdirSync(dir, { recursive: true })
|
||||||
|
|
||||||
|
const noscript = `<noscript data-prerender="${page.path}"><article style="max-width:48rem;margin:2rem auto;padding:0 1rem;font-family:system-ui,sans-serif"><h1>${page.h1}</h1><p>${page.lead}</p><p><a href="/">Home</a> · <a href="/donate">Donate</a> · <a href="/request-assistance">Request assistance</a></p></article></noscript>`
|
||||||
|
|
||||||
|
let html = shell
|
||||||
|
.replace(/<title>[^<]*<\/title>/, `<title>${page.title}</title>`)
|
||||||
|
.replace(
|
||||||
|
/<meta name="description" content="[^"]*"/,
|
||||||
|
`<meta name="description" content="${page.description}"`,
|
||||||
|
)
|
||||||
|
.replace(/<meta property="og:title" content="[^"]*"/, `<meta property="og:title" content="${page.title}"`)
|
||||||
|
.replace(
|
||||||
|
/<meta property="og:description" content="[^"]*"/,
|
||||||
|
`<meta property="og:description" content="${page.description}"`,
|
||||||
|
)
|
||||||
|
.replace(/<meta property="og:url" content="[^"]*"/, `<meta property="og:url" content="https://mim4u.org/${page.path}"`)
|
||||||
|
.replace('</body>', `${noscript}\n</body>`)
|
||||||
|
|
||||||
|
writeFileSync(join(dir, 'index.html'), html)
|
||||||
|
console.log(`prerender: ${page.path}/index.html`)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('prerender: done')
|
||||||
+27
-277
@@ -61,6 +61,9 @@ import { NotificationProvider, useNotifications } from './contexts/NotificationC
|
|||||||
const HomePage = lazy(() => import('./routes/HomePageRoute'))
|
const HomePage = lazy(() => import('./routes/HomePageRoute'))
|
||||||
const DonatePage = lazy(() => import('./routes/DonatePageRoute'))
|
const DonatePage = lazy(() => import('./routes/DonatePageRoute'))
|
||||||
const AssistanceRequestPage = lazy(() => import('./routes/AssistanceRequestPageRoute'))
|
const AssistanceRequestPage = lazy(() => import('./routes/AssistanceRequestPageRoute'))
|
||||||
|
const VolunteerPage = lazy(() => import('./routes/VolunteerPageRoute'))
|
||||||
|
const SponsorsPage = lazy(() => import('./routes/SponsorsPageRoute'))
|
||||||
|
const StoriesPage = lazy(() => import('./routes/StoriesPageRoute'))
|
||||||
|
|
||||||
// Phase 4: Extracted Components
|
// Phase 4: Extracted Components
|
||||||
import { Navigation } from './components/Navigation'
|
import { Navigation } from './components/Navigation'
|
||||||
@@ -72,13 +75,10 @@ import {
|
|||||||
ContactPage,
|
ContactPage,
|
||||||
EventsPage,
|
EventsPage,
|
||||||
MissionPage,
|
MissionPage,
|
||||||
StoriesIntroBlock,
|
|
||||||
WhatWeDoPage,
|
WhatWeDoPage,
|
||||||
} from './pages/foundation'
|
} from './pages/foundation'
|
||||||
|
import { useAppRoute } from './lib/routing'
|
||||||
import { EMAILS, SITE, mailto } from './content/siteContent'
|
import { EMAILS, SITE, mailto } from './content/siteContent'
|
||||||
import { FormSuccess } from './components/ui/FormSuccess'
|
|
||||||
import { submitContactFromForm } from './lib/submitContactFromForm'
|
|
||||||
import { MimApiError } from './lib/mimApi'
|
|
||||||
|
|
||||||
function RouteFallback({ label = 'Loading…' }: { label?: string }) {
|
function RouteFallback({ label = 'Loading…' }: { label?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -242,10 +242,6 @@ function SEOHead({ title, description, image }: { title?: string, description?:
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===================== Types ===================== */
|
/* ===================== Types ===================== */
|
||||||
interface IconProps {
|
|
||||||
className?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AnalyticsData {
|
interface AnalyticsData {
|
||||||
pageViews: { page: string; views: number; trend: number }[]
|
pageViews: { page: string; views: number; trend: number }[]
|
||||||
donationMetrics: { amount: number; count: number; recurring: number }
|
donationMetrics: { amount: number; count: number; recurring: number }
|
||||||
@@ -253,12 +249,6 @@ interface AnalyticsData {
|
|||||||
conversionRates: { donation: number; volunteer: number; contact: number }
|
conversionRates: { donation: number; volunteer: number; contact: number }
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CardProps {
|
|
||||||
title: string
|
|
||||||
icon: React.ComponentType<IconProps>
|
|
||||||
children: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PolicySectionProps {
|
interface PolicySectionProps {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
@@ -298,226 +288,7 @@ function SkipToContent() {
|
|||||||
|
|
||||||
// LogoMark component has been extracted to ./components/ui/LogoMark.tsx
|
// LogoMark component has been extracted to ./components/ui/LogoMark.tsx
|
||||||
|
|
||||||
/* ===================== Home Page ===================== */
|
/* ===================== Pages (lazy routes in ./routes/) ===================== */
|
||||||
/* ===================== Pages ===================== */
|
|
||||||
function VolunteerPage() {
|
|
||||||
const [submitted, setSubmitted] = useState<string | null>(null)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const onVolunteerSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true)
|
|
||||||
setFormError(null)
|
|
||||||
try {
|
|
||||||
const res = await submitContactFromForm(e.currentTarget, 'volunteer')
|
|
||||||
setSubmitted(res.message)
|
|
||||||
e.currentTarget.reset()
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err instanceof MimApiError ? err.message : 'Could not submit. Please email [email protected].')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Volunteer" icon={Users} eyebrow="Serve locally">
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="card">
|
|
||||||
<div className="font-medium">Sign-up form</div>
|
|
||||||
{submitted ? (
|
|
||||||
<FormSuccess title="Thank you" message={submitted} onDismiss={() => setSubmitted(null)} />
|
|
||||||
) : (
|
|
||||||
<form className="mt-4 grid grid-cols-1 gap-4" onSubmit={onVolunteerSubmit}>
|
|
||||||
<input type="text" name="website" tabIndex={-1} autoComplete="off" className="sr-only" aria-hidden="true" />
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<input required name="firstName" aria-label="First name" placeholder="First name" className="input" />
|
|
||||||
<input required name="lastName" aria-label="Last name" placeholder="Last name" className="input" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<input required type="email" name="email" aria-label="Email" placeholder="Email" className="input" />
|
|
||||||
<input name="phone" aria-label="Phone" placeholder="Phone" className="input" />
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<select name="interest" aria-label="Interests" className="input">
|
|
||||||
<option>Assemble kits</option>
|
|
||||||
<option>Delivery driver</option>
|
|
||||||
<option>Community partner</option>
|
|
||||||
<option>Admin support</option>
|
|
||||||
</select>
|
|
||||||
<select name="availability" aria-label="Availability" className="input">
|
|
||||||
<option>Weekdays</option>
|
|
||||||
<option>Weeknights</option>
|
|
||||||
<option>Weekends</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<label className="flex items-start gap-3 text-sm">
|
|
||||||
<input required type="checkbox" className="mt-1 h-4 w-4" />
|
|
||||||
<span>I have read and agree to the <a className="underline" href="#/legal#volunteer-waiver">Volunteer Waiver & Liability Release</a>, including background check and child-safety requirements.</span>
|
|
||||||
</label>
|
|
||||||
{formError && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{formError}</p>}
|
|
||||||
<button className="btn-primary w-full justify-center" type="submit" disabled={submitting}>
|
|
||||||
{submitting ? 'Sending…' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<aside className="space-y-6">
|
|
||||||
<Card title="What to expect" icon={Backpack}>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">Shifts are 2–3 hours. Training provided. Youth (14+) welcome with guardian consent.</p>
|
|
||||||
</Card>
|
|
||||||
<Card title="Group volunteering" icon={Users}>
|
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">We host teams of 5–25 for corporate/service groups. <a className="underline" href="#/sponsors">Contact us</a> for dates.</p>
|
|
||||||
</Card>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function SponsorsPage() {
|
|
||||||
const [submitted, setSubmitted] = useState<string | null>(null)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const onSponsorSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true)
|
|
||||||
setFormError(null)
|
|
||||||
try {
|
|
||||||
const res = await submitContactFromForm(e.currentTarget, 'sponsor')
|
|
||||||
setSubmitted(res.message)
|
|
||||||
e.currentTarget.reset()
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err instanceof MimApiError ? err.message : 'Could not submit. Please email [email protected].')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tiers = [
|
|
||||||
{ name: "Bronze", amt: "$2,500", perks: ["Logo on website", "Social thank-you", "Quarterly impact note"] },
|
|
||||||
{ name: "Silver", amt: "$5,000", perks: ["All Bronze", "Logo on event signage", "Volunteer day for your team"] },
|
|
||||||
{ name: "Gold", amt: "$10,000", perks: ["All Silver", "Co-branded kit drive", "Annual report feature"] },
|
|
||||||
{ name: "Platinum", amt: "$25,000+", perks: ["All Gold", "Program naming opportunity", "Custom partnership plan"] },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Corporate Sponsorship" icon={Building2} eyebrow="Partner with purpose" cta={<a className="btn-secondary" href="#/brand">Brand assets</a>}>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-6">
|
|
||||||
{tiers.map((t) => (
|
|
||||||
<div key={t.name} className="card">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="text-xl font-semibold">{t.name}</div>
|
|
||||||
<div className="text-lg">{t.amt}</div>
|
|
||||||
</div>
|
|
||||||
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-neutral-700 dark:text-neutral-300">
|
|
||||||
{t.perks.map((p, i) => (<li key={i}>{p}</li>))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="font-medium">Start a conversation</div>
|
|
||||||
{submitted ? (
|
|
||||||
<FormSuccess title="Thank you" message={submitted} onDismiss={() => setSubmitted(null)} />
|
|
||||||
) : (
|
|
||||||
<form className="mt-4 grid gap-4" onSubmit={onSponsorSubmit}>
|
|
||||||
<input type="text" name="website" tabIndex={-1} autoComplete="off" className="sr-only" aria-hidden="true" />
|
|
||||||
<input className="input" name="companyName" placeholder="Company name" aria-label="Company name" required />
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<input className="input" name="contactName" placeholder="Contact name" aria-label="Contact name" required />
|
|
||||||
<input type="email" name="email" className="input" placeholder="Email" aria-label="Email" required />
|
|
||||||
</div>
|
|
||||||
<select className="input" name="tier" aria-label="Tier interest">
|
|
||||||
<option>Bronze</option>
|
|
||||||
<option>Silver</option>
|
|
||||||
<option>Gold</option>
|
|
||||||
<option>Platinum</option>
|
|
||||||
<option>Custom</option>
|
|
||||||
</select>
|
|
||||||
<textarea className="input min-h-28 resize-none" name="message" placeholder="Tell us about your goals" aria-label="Message"></textarea>
|
|
||||||
<label className="flex items-start gap-3 text-xs opacity-80">
|
|
||||||
<input type="checkbox" className="mt-1 h-4 w-4"/>
|
|
||||||
I agree to logo-use guidelines and brand approvals per our <a className="underline" href="#/legal#sponsorship-terms">Sponsorship Terms</a>.
|
|
||||||
</label>
|
|
||||||
{formError && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{formError}</p>}
|
|
||||||
<button className="btn-primary w-full justify-center" type="submit" disabled={submitting}>
|
|
||||||
{submitting ? 'Sending…' : 'Send'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StoriesPage() {
|
|
||||||
const [submitted, setSubmitted] = useState<string | null>(null)
|
|
||||||
const [submitting, setSubmitting] = useState(false)
|
|
||||||
const [formError, setFormError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const onStorySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setSubmitting(true)
|
|
||||||
setFormError(null)
|
|
||||||
try {
|
|
||||||
const res = await submitContactFromForm(e.currentTarget, 'story')
|
|
||||||
setSubmitted(res.message)
|
|
||||||
e.currentTarget.reset()
|
|
||||||
} catch (err) {
|
|
||||||
setFormError(err instanceof MimApiError ? err.message : 'Could not submit. Please email [email protected].')
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stories = [
|
|
||||||
{ title: "A family found hope again", tag: "Outreach", body: "After a fire displaced their home, a mother and children received emergency support and compassionate guidance through recovery.", by: "Community partner" },
|
|
||||||
{ title: "Strength after hardship", tag: "Advocacy", body: "A survivor connected with resources and encouragement — reminded that she matters and brighter days are ahead.", by: "Volunteer" },
|
|
||||||
{ title: "Community showed up", tag: "Events", body: "Neighbors and volunteers came together at an outreach event to serve families with supplies, prayer, and heartfelt support.", by: "Event volunteer" },
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageShell title="Stories of Hope" icon={BookOpenText} eyebrow="Hope in motion" cta={<a href="#/testimonies" className="btn-secondary">Read testimonies</a>}>
|
|
||||||
<StoriesIntroBlock />
|
|
||||||
<div className="grid gap-6 md:grid-cols-3">
|
|
||||||
{stories.map((s, i) => (
|
|
||||||
<article key={i} className="card">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-neutral-500">{s.tag}</div>
|
|
||||||
<h3 className="mt-1 text-lg font-semibold">{s.title}</h3>
|
|
||||||
<p className="mt-2 text-sm text-neutral-700 dark:text-neutral-300">{s.body}</p>
|
|
||||||
<div className="mt-3 text-xs text-neutral-500">— {s.by}</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="card">
|
|
||||||
<div className="font-medium">Submit your story</div>
|
|
||||||
{submitted ? (
|
|
||||||
<FormSuccess title="Thank you" message={submitted} onDismiss={() => setSubmitted(null)} />
|
|
||||||
) : (
|
|
||||||
<form className="mt-3 grid gap-3" onSubmit={onStorySubmit}>
|
|
||||||
<input type="text" name="website" tabIndex={-1} autoComplete="off" className="sr-only" aria-hidden="true" />
|
|
||||||
<input className="input" name="name" placeholder="Your name (or Anonymous)" aria-label="Name" />
|
|
||||||
<input type="email" className="input" name="email" placeholder="Email (optional, for follow-up)" aria-label="Email" />
|
|
||||||
<textarea className="input min-h-28 resize-none" name="story" placeholder="Your story (please omit private identifying details)" aria-label="Story" required />
|
|
||||||
<label className="flex items-start gap-3 text-xs opacity-80">
|
|
||||||
<input type="checkbox" className="mt-1 h-4 w-4" required />
|
|
||||||
I grant permission to publish this story (edited for length/clarity). I have removed personally identifying information.
|
|
||||||
</label>
|
|
||||||
{formError && <p className="text-sm text-red-600 dark:text-red-400" role="alert">{formError}</p>}
|
|
||||||
<button className="btn-primary w-full justify-center" type="submit" disabled={submitting}>
|
|
||||||
{submitting ? 'Sending…' : 'Submit'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PageShell>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function TestimoniesPage() {
|
function TestimoniesPage() {
|
||||||
const items = [
|
const items = [
|
||||||
{ who: "Parent", quote: "They reminded us we were not alone — hope came when we needed it most." },
|
{ who: "Parent", quote: "They reminded us we were not alone — hope came when we needed it most." },
|
||||||
@@ -541,7 +312,7 @@ function TestimoniesPage() {
|
|||||||
|
|
||||||
function ImpactReportPage() {
|
function ImpactReportPage() {
|
||||||
return (
|
return (
|
||||||
<PageShell title="Impact Report" icon={BarChart3} eyebrow="Our impact in the community" cta={<a href="#/donate" className="btn-primary">Donate to support more families</a>}>
|
<PageShell title="Impact Report" icon={BarChart3} eyebrow="Our impact in the community" cta={<a href="/donate" className="btn-primary">Donate to support more families</a>}>
|
||||||
<div className="max-w-4xl mx-auto space-y-8">
|
<div className="max-w-4xl mx-auto space-y-8">
|
||||||
<p className="text-lg text-neutral-700 dark:text-neutral-300">
|
<p className="text-lg text-neutral-700 dark:text-neutral-300">
|
||||||
Miracles in Motion Foundation serves vulnerable individuals and families across Los Angeles County. For impact updates or data requests, contact <a className="underline" href={SITE.emailHref}>{SITE.email}</a>.
|
Miracles in Motion Foundation serves vulnerable individuals and families across Los Angeles County. For impact updates or data requests, contact <a className="underline" href={SITE.emailHref}>{SITE.email}</a>.
|
||||||
@@ -567,7 +338,7 @@ function ImpactReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="font-semibold text-lg mb-2">How we confirm support</h3>
|
<h3 className="font-semibold text-lg mb-2">How we confirm support</h3>
|
||||||
<p className="text-neutral-700 dark:text-neutral-300 text-sm">
|
<p className="text-neutral-700 dark:text-neutral-300 text-sm">
|
||||||
When you submit a request for assistance, we review your situation with care and respond as quickly as possible. All information is handled in line with our <a className="underline" href="#/legal#privacy">Privacy Policy</a> and is never shared for marketing.
|
When you submit a request for assistance, we review your situation with care and respond as quickly as possible. All information is handled in line with our <a className="underline" href="/legal#privacy">Privacy Policy</a> and is never shared for marketing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -577,7 +348,7 @@ function ImpactReportPage() {
|
|||||||
|
|
||||||
function LegalPage() {
|
function LegalPage() {
|
||||||
return (
|
return (
|
||||||
<PageShell title="Legal & Policies" icon={FileText} eyebrow="Trust & compliance" cta={<a href="#/donate" className="btn-secondary">Donate</a>}>
|
<PageShell title="Legal & Policies" icon={FileText} eyebrow="Trust & compliance" cta={<a href="/donate" className="btn-secondary">Donate</a>}>
|
||||||
<PolicySection id="privacy" title="Privacy Policy">
|
<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. Assistance requests are used only to verify and deliver support; we do not share this data for marketing. You may request access, correction, or deletion at <a className="underline" href={mailto(EMAILS.privacy)}>{EMAILS.privacy}</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 are used only to verify and deliver support; we do not share this data for marketing. You may request access, correction, or deletion at <a className="underline" href={mailto(EMAILS.privacy)}>{EMAILS.privacy}</a>.</p>
|
||||||
</PolicySection>
|
</PolicySection>
|
||||||
@@ -624,7 +395,7 @@ function LegalPage() {
|
|||||||
</PolicySection>
|
</PolicySection>
|
||||||
<PolicySection id="sponsorship-terms" title="Sponsorship Terms & Logo Use">
|
<PolicySection id="sponsorship-terms" title="Sponsorship Terms & Logo Use">
|
||||||
<ul className="list-disc pl-5 space-y-2">
|
<ul className="list-disc pl-5 space-y-2">
|
||||||
<li>Logo usage requires prior written approval and must follow our <a className="underline" href="#/brand">brand assets and guidelines</a>.</li>
|
<li>Logo usage requires prior written approval and must follow our <a className="underline" href="/brand">brand assets and guidelines</a>.</li>
|
||||||
<li>Sponsorship does not imply endorsement of products or services.</li>
|
<li>Sponsorship does not imply endorsement of products or services.</li>
|
||||||
<li>Benefits may be adjusted for equivalency based on availability.</li>
|
<li>Benefits may be adjusted for equivalency based on availability.</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -646,21 +417,6 @@ function PolicySection({ id, title, children }: PolicySectionProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Card({ title, icon: Icon, children }: CardProps) {
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-primary-500 to-secondary-600 text-white shadow">
|
|
||||||
<Icon className="h-5 w-5" />
|
|
||||||
</div>
|
|
||||||
<div className="font-semibold tracking-tight">{title}</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3">{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Portals Overview Page
|
|
||||||
function PortalsPage() {
|
function PortalsPage() {
|
||||||
return (
|
return (
|
||||||
<PageShell title="Staff & Partner Portals" icon={Building2} eyebrow="Secure access for team members">
|
<PageShell title="Staff & Partner Portals" icon={Building2} eyebrow="Secure access for team members">
|
||||||
@@ -699,7 +455,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/admin-portal"
|
href="/admin-portal"
|
||||||
className="block w-full text-center bg-red-600 text-white py-3 px-4 rounded-lg hover:bg-red-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
className="block w-full text-center bg-red-600 text-white py-3 px-4 rounded-lg hover:bg-red-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Access Admin Portal
|
Access Admin Portal
|
||||||
@@ -739,7 +495,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/volunteer-portal"
|
href="/volunteer-portal"
|
||||||
className="block w-full text-center bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
className="block w-full text-center bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Access Volunteer Portal
|
Access Volunteer Portal
|
||||||
@@ -779,7 +535,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/resource-portal"
|
href="/resource-portal"
|
||||||
className="block w-full text-center bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
className="block w-full text-center bg-green-600 text-white py-3 px-4 rounded-lg hover:bg-green-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Access Resource Portal
|
Access Resource Portal
|
||||||
@@ -819,7 +575,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/ai-portal"
|
href="/ai-portal"
|
||||||
className="block w-full text-center bg-primary-600 text-white py-3 px-4 rounded-lg hover:bg-primary-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
className="block w-full text-center bg-primary-600 text-white py-3 px-4 rounded-lg hover:bg-primary-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Access AI Portal
|
Access AI Portal
|
||||||
@@ -868,7 +624,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/advanced-analytics"
|
href="/advanced-analytics"
|
||||||
className="block w-full text-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
className="block w-full text-center bg-indigo-600 text-white py-3 px-4 rounded-lg hover:bg-indigo-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
View Analytics
|
View Analytics
|
||||||
@@ -908,7 +664,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/mobile-volunteer"
|
href="/mobile-volunteer"
|
||||||
className="block w-full text-center bg-emerald-600 text-white py-3 px-4 rounded-lg hover:bg-emerald-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
className="block w-full text-center bg-emerald-600 text-white py-3 px-4 rounded-lg hover:bg-emerald-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Launch Mobile App
|
Launch Mobile App
|
||||||
@@ -948,7 +704,7 @@ function PortalsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#/staff-training"
|
href="/staff-training"
|
||||||
className="block w-full text-center bg-orange-600 text-white py-3 px-4 rounded-lg hover:bg-orange-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
|
className="block w-full text-center bg-orange-600 text-white py-3 px-4 rounded-lg hover:bg-orange-700 transition-colors font-medium focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Access Training
|
Access Training
|
||||||
@@ -1350,7 +1106,7 @@ function LoginForm({ requiredRole }: { requiredRole?: 'admin' | 'volunteer' | 'r
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<a href="#/" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
<a href="/" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
|
||||||
← Back to Main Site
|
← Back to Main Site
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -1377,7 +1133,7 @@ function PortalWrapper({ children, requiredRole }: { children: React.ReactNode,
|
|||||||
You don't have permission to access the {requiredRole} portal.
|
You don't have permission to access the {requiredRole} portal.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<a href="#/" className="btn-primary">
|
<a href="/" className="btn-primary">
|
||||||
Return to Main Site
|
Return to Main Site
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
@@ -1694,7 +1450,7 @@ function ResourcePortalPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4">
|
<div className="flex flex-col sm:flex-row gap-4">
|
||||||
<a href="#/request-assistance" className="flex-1 btn-primary">
|
<a href="/request-assistance" className="flex-1 btn-primary">
|
||||||
<ClipboardList className="mr-2 h-4 w-4" />
|
<ClipboardList className="mr-2 h-4 w-4" />
|
||||||
New Assistance Request
|
New Assistance Request
|
||||||
</a>
|
</a>
|
||||||
@@ -1773,7 +1529,7 @@ function ResourcePortalPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
|
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<a href="#/request-assistance" className="block w-full btn-secondary text-left justify-start text-sm">
|
<a href="/request-assistance" className="block w-full btn-secondary text-left justify-start text-sm">
|
||||||
<ClipboardList className="mr-2 h-4 w-4" />
|
<ClipboardList className="mr-2 h-4 w-4" />
|
||||||
Submit Request
|
Submit Request
|
||||||
</a>
|
</a>
|
||||||
@@ -2124,7 +1880,7 @@ function NotFoundPage() {
|
|||||||
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
<p className="mt-2 text-neutral-600 dark:text-neutral-400">
|
||||||
The page you're looking for doesn't exist.
|
The page you're looking for doesn't exist.
|
||||||
</p>
|
</p>
|
||||||
<a href="#/" className="btn-primary mt-6">
|
<a href="/" className="btn-primary mt-6">
|
||||||
Go home
|
Go home
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -2147,7 +1903,7 @@ function StickyDonate() {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed bottom-4 left-4 z-40 lg:hidden">
|
<div className="fixed bottom-4 left-4 z-40 lg:hidden">
|
||||||
<a
|
<a
|
||||||
href="#/donate"
|
href="/donate"
|
||||||
className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary-500 to-secondary-600 text-white shadow-lg shadow-primary-500/25 transition hover:scale-105"
|
className="flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br from-primary-500 to-secondary-600 text-white shadow-lg shadow-primary-500/25 transition hover:scale-105"
|
||||||
aria-label="Donate"
|
aria-label="Donate"
|
||||||
>
|
>
|
||||||
@@ -2178,7 +1934,7 @@ function CookieBanner() {
|
|||||||
<div className="fixed bottom-20 left-4 right-4 z-40 card p-4 sm:bottom-4 md:max-w-md md:right-auto lg:bottom-4" role="dialog" aria-label="Cookie consent">
|
<div className="fixed bottom-20 left-4 right-4 z-40 card p-4 sm:bottom-4 md:max-w-md md:right-auto lg:bottom-4" role="dialog" aria-label="Cookie consent">
|
||||||
<p className="text-sm text-neutral-700 dark:text-neutral-200">
|
<p className="text-sm text-neutral-700 dark:text-neutral-200">
|
||||||
We use cookies to improve your experience. By continuing, you agree to our{' '}
|
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>.
|
<a href="/legal" className="underline focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded">cookie policy</a>.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -2221,7 +1977,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppContent() {
|
function AppContent() {
|
||||||
const [currentPath, setCurrentPath] = useState(window.location.hash.slice(1) || '/')
|
const currentPath = useAppRoute()
|
||||||
const [darkMode, setDarkMode] = useState(() => {
|
const [darkMode, setDarkMode] = useState(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
return localStorage.getItem('darkMode') === 'true' ||
|
return localStorage.getItem('darkMode') === 'true' ||
|
||||||
@@ -2237,12 +1993,6 @@ function AppContent() {
|
|||||||
document.getElementById('mim-static-hero')?.classList.add('is-hidden')
|
document.getElementById('mim-static-hero')?.classList.add('is-hidden')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleHashChange = () => setCurrentPath(window.location.hash.slice(1) || '/')
|
|
||||||
window.addEventListener('hashchange', handleHashChange)
|
|
||||||
return () => window.removeEventListener('hashchange', handleHashChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.documentElement.classList.toggle('dark', darkMode)
|
document.documentElement.classList.toggle('dark', darkMode)
|
||||||
localStorage.setItem('darkMode', darkMode.toString())
|
localStorage.setItem('darkMode', darkMode.toString())
|
||||||
@@ -2293,11 +2043,11 @@ function AppContent() {
|
|||||||
case '/donate':
|
case '/donate':
|
||||||
return <LazyRoute label="Loading donate…"><DonatePage /></LazyRoute>
|
return <LazyRoute label="Loading donate…"><DonatePage /></LazyRoute>
|
||||||
case '/volunteers':
|
case '/volunteers':
|
||||||
return <VolunteerPage />
|
return <LazyRoute label="Loading volunteer…"><VolunteerPage /></LazyRoute>
|
||||||
case '/sponsors':
|
case '/sponsors':
|
||||||
return <SponsorsPage />
|
return <LazyRoute label="Loading sponsors…"><SponsorsPage /></LazyRoute>
|
||||||
case '/stories':
|
case '/stories':
|
||||||
return <StoriesPage />
|
return <LazyRoute label="Loading stories…"><StoriesPage /></LazyRoute>
|
||||||
case '/testimonies':
|
case '/testimonies':
|
||||||
return <TestimoniesPage />
|
return <TestimoniesPage />
|
||||||
case '/legal':
|
case '/legal':
|
||||||
|
|||||||
+5
-5
@@ -129,7 +129,7 @@ const Navigation: React.FC<NavigationProps> = ({
|
|||||||
<div className="flex justify-between items-center h-16">
|
<div className="flex justify-between items-center h-16">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<a href="#/" className="text-2xl font-bold text-primary-600 hover:text-primary-700 transition-colors">
|
<a href="/" className="text-2xl font-bold text-primary-600 hover:text-primary-700 transition-colors">
|
||||||
Miracles in Motion
|
Miracles in Motion
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,16 +230,16 @@ const Footer: React.FC = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-4">Quick Links</h4>
|
<h4 className="font-semibold mb-4">Quick Links</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<a href="#/donate" className="block text-gray-300 hover:text-white transition-colors">
|
<a href="/donate" className="block text-gray-300 hover:text-white transition-colors">
|
||||||
Donate Now
|
Donate Now
|
||||||
</a>
|
</a>
|
||||||
<a href="#/volunteer" className="block text-gray-300 hover:text-white transition-colors">
|
<a href="/volunteer" className="block text-gray-300 hover:text-white transition-colors">
|
||||||
Volunteer
|
Volunteer
|
||||||
</a>
|
</a>
|
||||||
<a href="#/about" className="block text-gray-300 hover:text-white transition-colors">
|
<a href="/about" className="block text-gray-300 hover:text-white transition-colors">
|
||||||
About Us
|
About Us
|
||||||
</a>
|
</a>
|
||||||
<a href="#/impact" className="block text-gray-300 hover:text-white transition-colors">
|
<a href="/impact" className="block text-gray-300 hover:text-white transition-colors">
|
||||||
Our Impact
|
Our Impact
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-13
@@ -36,22 +36,22 @@ export function Footer() {
|
|||||||
<h3 className="font-semibold">Get Involved</h3>
|
<h3 className="font-semibold">Get Involved</h3>
|
||||||
<ul className="mt-4 space-y-2 text-sm">
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
<li>
|
<li>
|
||||||
<a href="#/donate" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/donate" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Donate
|
Donate
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/volunteers" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/volunteers" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Volunteer
|
Volunteer
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/events" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/events" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Community Events
|
Community Events
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/stories" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/stories" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Stories of Hope
|
Stories of Hope
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -62,22 +62,22 @@ export function Footer() {
|
|||||||
<h3 className="font-semibold">About</h3>
|
<h3 className="font-semibold">About</h3>
|
||||||
<ul className="mt-4 space-y-2 text-sm">
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
<li>
|
<li>
|
||||||
<a href="#/about" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/about" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
About Us
|
About Us
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/mission" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/mission" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Our Mission
|
Our Mission
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/what-we-do" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/what-we-do" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
What We Do
|
What We Do
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/contact" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/contact" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Contact Us
|
Contact Us
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -88,27 +88,27 @@ export function Footer() {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/request-assistance" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/request-assistance" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Request assistance
|
Request assistance
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/legal" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/legal" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Legal & Policies
|
Legal & Policies
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/brand" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/brand" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Brand assets
|
Brand assets
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/portals" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/portals" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Staff & partner portals
|
Staff & partner portals
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="#/sponsors" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
<a href="/sponsors" className="navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300">
|
||||||
Corporate partnerships
|
Corporate partnerships
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect } from 'react'
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Heart, Menu, Moon, SunMedium, X } from 'lucide-react'
|
import { Heart, Menu, Moon, SunMedium, X } from 'lucide-react'
|
||||||
import { NAV_LINKS } from '../content/siteContent'
|
import { NAV_LINKS } from '../content/siteContent'
|
||||||
|
import { useAppRoute } from '../lib/routing'
|
||||||
import { trackEvent } from '../utils/analytics'
|
import { trackEvent } from '../utils/analytics'
|
||||||
import { Magnetic, LogoMark } from './ui'
|
import { Magnetic, LogoMark } from './ui'
|
||||||
|
|
||||||
@@ -13,9 +14,11 @@ interface NavProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMenuOpen }: NavProps) {
|
export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMenuOpen }: NavProps) {
|
||||||
|
const currentPath = useAppRoute()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMobileMenuOpen(false)
|
setMobileMenuOpen(false)
|
||||||
}, [window.location.hash, setMobileMenuOpen])
|
}, [currentPath, setMobileMenuOpen])
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
const handleKeyDown = (e: React.KeyboardEvent, action: () => void) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
@@ -35,7 +38,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
|
|||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#/"
|
href="/"
|
||||||
className="nav-brand-link shrink-0 focus:outline-none"
|
className="nav-brand-link shrink-0 focus:outline-none"
|
||||||
aria-label="Miracles in Motion Foundation — Home"
|
aria-label="Miracles in Motion Foundation — Home"
|
||||||
>
|
>
|
||||||
@@ -50,7 +53,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
|
|||||||
href={link.href}
|
href={link.href}
|
||||||
aria-label={link.ariaLabel}
|
aria-label={link.ariaLabel}
|
||||||
onClick={
|
onClick={
|
||||||
link.href === '#/volunteers'
|
link.href === '/volunteers'
|
||||||
? () => trackEvent('cta_clicked', { button: 'volunteer', location: 'nav' })
|
? () => trackEvent('cta_clicked', { button: 'volunteer', location: 'nav' })
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
|
|||||||
<div className="hidden items-center gap-3 lg:flex">
|
<div className="hidden items-center gap-3 lg:flex">
|
||||||
<Magnetic>
|
<Magnetic>
|
||||||
<a
|
<a
|
||||||
href="#/donate"
|
href="/donate"
|
||||||
className="btn-primary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
className="btn-primary focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||||
aria-label="Make a donation"
|
aria-label="Make a donation"
|
||||||
>
|
>
|
||||||
@@ -88,7 +91,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
|
|||||||
<div className="flex items-center gap-2 lg:hidden">
|
<div className="flex items-center gap-2 lg:hidden">
|
||||||
<Magnetic>
|
<Magnetic>
|
||||||
<a
|
<a
|
||||||
href="#/donate"
|
href="/donate"
|
||||||
className="btn-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
className="btn-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||||
aria-label="Make a donation"
|
aria-label="Make a donation"
|
||||||
>
|
>
|
||||||
@@ -124,7 +127,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
|
|||||||
>
|
>
|
||||||
<div className="border-t border-secondary-200/50 bg-[var(--mim-cream)]/95 px-4 py-3 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
|
<div className="border-t border-secondary-200/50 bg-[var(--mim-cream)]/95 px-4 py-3 backdrop-blur dark:border-white/10 dark:bg-neutral-950/95">
|
||||||
<div className="max-h-[min(70vh,28rem)] space-y-1 overflow-y-auto overscroll-contain">
|
<div className="max-h-[min(70vh,28rem)] space-y-1 overflow-y-auto overscroll-contain">
|
||||||
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="#/">
|
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="/">
|
||||||
Home
|
Home
|
||||||
</a>
|
</a>
|
||||||
{NAV_LINKS.map((link) => (
|
{NAV_LINKS.map((link) => (
|
||||||
@@ -136,16 +139,16 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
|
|||||||
{link.label}
|
{link.label}
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="#/donate">
|
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="/donate">
|
||||||
Donate
|
Donate
|
||||||
</a>
|
</a>
|
||||||
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="#/request-assistance">
|
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="/request-assistance">
|
||||||
Request assistance
|
Request assistance
|
||||||
</a>
|
</a>
|
||||||
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="#/portals">
|
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="/portals">
|
||||||
Portals
|
Portals
|
||||||
</a>
|
</a>
|
||||||
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="#/sponsors">
|
<a className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100" href="/sponsors">
|
||||||
Partner with us
|
Partner with us
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ export function SEOHead({
|
|||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "DonateAction",
|
"@type": "DonateAction",
|
||||||
"name": "Donate to Miracles in Motion",
|
"name": "Donate to Miracles in Motion",
|
||||||
"url": "https://mim4u.org/#/donate",
|
"url": "https://mim4u.org/donate",
|
||||||
"description": "Donate to support outreach, emergency assistance, and compassionate care for families in Los Angeles County.",
|
"description": "Donate to support outreach, emergency assistance, and compassionate care for families in Los Angeles County.",
|
||||||
"target": {
|
"target": {
|
||||||
"@type": "EntryPoint",
|
"@type": "EntryPoint",
|
||||||
"urlTemplate": "https://mim4u.org/#/donate",
|
"urlTemplate": "https://mim4u.org/donate",
|
||||||
"actionPlatform": ["http://schema.org/DesktopWebPlatform", "http://schema.org/MobileWebPlatform"]
|
"actionPlatform": ["http://schema.org/DesktopWebPlatform", "http://schema.org/MobileWebPlatform"]
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ describe('Footer Component', () => {
|
|||||||
render(<Footer />)
|
render(<Footer />)
|
||||||
|
|
||||||
expect(screen.getByText('Get Involved')).toBeInTheDocument()
|
expect(screen.getByText('Get Involved')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('link', { name: 'Donate' })).toHaveAttribute('href', '#/donate')
|
expect(screen.getByRole('link', { name: 'Donate' })).toHaveAttribute('href', '/donate')
|
||||||
expect(screen.getByRole('link', { name: 'Volunteer' })).toHaveAttribute('href', '#/volunteers')
|
expect(screen.getByRole('link', { name: 'Volunteer' })).toHaveAttribute('href', '/volunteers')
|
||||||
expect(screen.getByRole('link', { name: 'Community Events' })).toHaveAttribute('href', '#/events')
|
expect(screen.getByRole('link', { name: 'Community Events' })).toHaveAttribute('href', '/events')
|
||||||
expect(screen.getByRole('link', { name: 'Stories of Hope' })).toHaveAttribute('href', '#/stories')
|
expect(screen.getByRole('link', { name: 'Stories of Hope' })).toHaveAttribute('href', '/stories')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders About section with contact email', () => {
|
it('renders About section with contact email', () => {
|
||||||
@@ -43,8 +43,8 @@ describe('Footer Component', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('About')).toBeInTheDocument()
|
expect(screen.getByText('About')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('link', { name: SITE.email })).toHaveAttribute('href', SITE.emailHref)
|
expect(screen.getByRole('link', { name: SITE.email })).toHaveAttribute('href', SITE.emailHref)
|
||||||
expect(screen.getByRole('link', { name: 'About Us' })).toHaveAttribute('href', '#/about')
|
expect(screen.getByRole('link', { name: 'About Us' })).toHaveAttribute('href', '/about')
|
||||||
expect(screen.getByRole('link', { name: 'Our Mission' })).toHaveAttribute('href', '#/mission')
|
expect(screen.getByRole('link', { name: 'Our Mission' })).toHaveAttribute('href', '/mission')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders legal and EIN', () => {
|
it('renders legal and EIN', () => {
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ export function HomeHero() {
|
|||||||
Bringing hope, compassion, and support to individuals and families facing life’s most difficult moments — through faith, community outreach, and compassionate care in Los Angeles County.
|
Bringing hope, compassion, and support to individuals and families facing life’s most difficult moments — through faith, community outreach, and compassionate care in Los Angeles County.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-8 flex flex-wrap items-center gap-3">
|
<div className="mt-8 flex flex-wrap items-center gap-3">
|
||||||
<a href="#/donate" className="btn-primary">
|
<a href="/donate" className="btn-primary">
|
||||||
<Heart className="mr-2 h-4 w-4" aria-hidden="true" /> Donate now
|
<Heart className="mr-2 h-4 w-4" aria-hidden="true" /> Donate now
|
||||||
</a>
|
</a>
|
||||||
<a href="#/volunteers" className="btn-secondary">
|
<a href="/volunteers" className="btn-secondary">
|
||||||
Volunteer <ArrowRight className="ml-1 h-4 w-4" aria-hidden="true" />
|
Volunteer <ArrowRight className="ml-1 h-4 w-4" aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#/about"
|
href="/about"
|
||||||
className="rounded-full border border-transparent px-4 py-2 text-sm text-neutral-700 underline-offset-4 hover:underline dark:text-neutral-300"
|
className="rounded-full border border-transparent px-4 py-2 text-sm text-neutral-700 underline-offset-4 hover:underline dark:text-neutral-300"
|
||||||
>
|
>
|
||||||
About us
|
About us
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function ProgramsSection() {
|
|||||||
className="mt-16 text-center"
|
className="mt-16 text-center"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="#/request-assistance"
|
href="/request-assistance"
|
||||||
className="btn-primary inline-flex items-center justify-center"
|
className="btn-primary inline-flex items-center justify-center"
|
||||||
>
|
>
|
||||||
Request Program Support
|
Request Program Support
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ describe('HeroSection Component', () => {
|
|||||||
|
|
||||||
it('renders call-to-action links', () => {
|
it('renders call-to-action links', () => {
|
||||||
render(<HeroSection />)
|
render(<HeroSection />)
|
||||||
expect(screen.getByRole('link', { name: /Donate now/i })).toHaveAttribute('href', '#/donate')
|
expect(screen.getByRole('link', { name: /Donate now/i })).toHaveAttribute('href', '/donate')
|
||||||
expect(screen.getByRole('link', { name: /Volunteer/i })).toHaveAttribute('href', '#/volunteers')
|
expect(screen.getByRole('link', { name: /Volunteer/i })).toHaveAttribute('href', '/volunteers')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows legal trust signals', () => {
|
it('shows legal trust signals', () => {
|
||||||
|
|||||||
@@ -122,13 +122,13 @@ export const COMMUNITY_VIDEO = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const NAV_LINKS = [
|
export const NAV_LINKS = [
|
||||||
{ href: '#/about', label: 'About', ariaLabel: 'About us' },
|
{ href: '/about', label: 'About', ariaLabel: 'About us' },
|
||||||
{ href: '#/what-we-do', label: 'What We Do', ariaLabel: 'Our programs and services' },
|
{ href: '/what-we-do', label: 'What We Do', ariaLabel: 'Our programs and services' },
|
||||||
{ href: '#/stories', label: 'Stories', ariaLabel: 'Stories of hope' },
|
{ href: '/stories', label: 'Stories', ariaLabel: 'Stories of hope' },
|
||||||
{ href: '#/events', label: 'Events', ariaLabel: 'Community events' },
|
{ href: '/events', label: 'Events', ariaLabel: 'Community events' },
|
||||||
{ href: '#/volunteers', label: 'Volunteer', ariaLabel: 'Volunteer opportunities' },
|
{ href: '/volunteers', label: 'Volunteer', ariaLabel: 'Volunteer opportunities' },
|
||||||
{ href: '#/contact', label: 'Contact', ariaLabel: 'Contact us' },
|
{ href: '/contact', label: 'Contact', ariaLabel: 'Contact us' },
|
||||||
{ href: '#/portals', label: 'Portals', ariaLabel: 'Staff and partner portals' },
|
{ href: '/portals', label: 'Portals', ariaLabel: 'Staff and partner portals' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** Public hash routes for audits and sitemap alignment */
|
/** Public hash routes for audits and sitemap alignment */
|
||||||
@@ -273,6 +273,6 @@ export const EVENTS = {
|
|||||||
'Community events are where compassion meets action — outreach gatherings, resource fairs, and seasonal support drives throughout Los Angeles County.',
|
'Community events are where compassion meets action — outreach gatherings, resource fairs, and seasonal support drives throughout Los Angeles County.',
|
||||||
body:
|
body:
|
||||||
'Check back for upcoming dates, or contact us to partner on an outreach event or volunteer day.',
|
'Check back for upcoming dates, or contact us to partner on an outreach event or volunteer day.',
|
||||||
ctaVolunteer: '#/volunteers',
|
ctaVolunteer: '/volunteers',
|
||||||
ctaContact: '#/contact',
|
ctaContact: '/contact',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** Normalize pathname; supports legacy hash URLs via migrateHashToPath(). */
|
||||||
|
export function parseAppPath(): string {
|
||||||
|
const raw = window.location.pathname || '/'
|
||||||
|
const path = raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw
|
||||||
|
return path || '/'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect #/donate → /donate (legacy bookmarks). Call once before React mount. */
|
||||||
|
export function migrateHashToPath(): void {
|
||||||
|
const hash = window.location.hash
|
||||||
|
if (!hash.startsWith('#/')) return
|
||||||
|
const withoutLeading = hash.slice(1)
|
||||||
|
const anchorIdx = withoutLeading.indexOf('#', 1)
|
||||||
|
const pathPart = anchorIdx > 0 ? withoutLeading.slice(0, anchorIdx) : withoutLeading
|
||||||
|
const anchor = anchorIdx > 0 ? withoutLeading.slice(anchorIdx) : ''
|
||||||
|
const url = `${pathPart}${window.location.search}${anchor}`
|
||||||
|
window.history.replaceState(null, '', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeHref(path: string): string {
|
||||||
|
if (!path || path === '/') return '/'
|
||||||
|
return path.startsWith('/') ? path : `/${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function navigate(path: string): void {
|
||||||
|
const href = routeHref(path)
|
||||||
|
if (window.location.pathname !== href) {
|
||||||
|
window.history.pushState(null, '', href)
|
||||||
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppRoute(): string {
|
||||||
|
const [path, setPath] = useState(parseAppPath)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPop = () => setPath(parseAppPath())
|
||||||
|
const onHash = () => {
|
||||||
|
if (window.location.hash.startsWith('#/')) {
|
||||||
|
migrateHashToPath()
|
||||||
|
setPath(parseAppPath())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('popstate', onPop)
|
||||||
|
window.addEventListener('hashchange', onHash)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('popstate', onPop)
|
||||||
|
window.removeEventListener('hashchange', onHash)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
+10
-3
@@ -1,10 +1,15 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { HelmetProvider } from 'react-helmet-async'
|
||||||
import { I18nextProvider } from 'react-i18next'
|
import { I18nextProvider } from 'react-i18next'
|
||||||
import i18n from './i18n/config'
|
import i18n from './i18n/config'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
import { migrateHashToPath } from './lib/routing'
|
||||||
|
|
||||||
|
migrateHashToPath()
|
||||||
|
|
||||||
const rootEl = document.getElementById('root')
|
const rootEl = document.getElementById('root')
|
||||||
if (!rootEl) {
|
if (!rootEl) {
|
||||||
throw new Error('#root not found')
|
throw new Error('#root not found')
|
||||||
@@ -13,9 +18,11 @@ if (!rootEl) {
|
|||||||
function mount() {
|
function mount() {
|
||||||
ReactDOM.createRoot(rootEl as HTMLElement).render(
|
ReactDOM.createRoot(rootEl as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<HelmetProvider>
|
||||||
<App />
|
<I18nextProvider i18n={i18n}>
|
||||||
</I18nextProvider>
|
<App />
|
||||||
|
</I18nextProvider>
|
||||||
|
</HelmetProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function BrandAssetsPage() {
|
|||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Sponsor and co-branding use requires approval — see{' '}
|
Sponsor and co-branding use requires approval — see{' '}
|
||||||
<a className="underline text-primary-600 dark:text-secondary-400" href="#/legal#sponsorship-terms">
|
<a className="underline text-primary-600 dark:text-secondary-400" href="/legal#sponsorship-terms">
|
||||||
Sponsorship Terms
|
Sponsorship Terms
|
||||||
</a>
|
</a>
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -79,10 +79,10 @@ export function AboutPage() {
|
|||||||
</ContentCard>
|
</ContentCard>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<a href="#/mission" className="btn-primary">
|
<a href="/mission" className="btn-primary">
|
||||||
Our mission <ArrowRight className="ml-1 h-4 w-4" />
|
Our mission <ArrowRight className="ml-1 h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
<a href="#/what-we-do" className="btn-secondary">
|
<a href="/what-we-do" className="btn-secondary">
|
||||||
What we do
|
What we do
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +107,7 @@ export function MissionPage() {
|
|||||||
<p className="text-center font-medium text-primary-800 dark:text-secondary-300">
|
<p className="text-center font-medium text-primary-800 dark:text-secondary-300">
|
||||||
{SITE.wellnessTagline}
|
{SITE.wellnessTagline}
|
||||||
</p>
|
</p>
|
||||||
<a href="#/donate" className="btn-primary inline-flex">
|
<a href="/donate" className="btn-primary inline-flex">
|
||||||
<Heart className="mr-2 h-4 w-4" /> Support our mission
|
<Heart className="mr-2 h-4 w-4" /> Support our mission
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,10 +141,10 @@ export function WhatWeDoPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto mt-10 max-w-4xl px-4 text-center sm:px-6 lg:px-8">
|
<div className="mx-auto mt-10 max-w-4xl px-4 text-center sm:px-6 lg:px-8">
|
||||||
<a href="#/request-assistance" className="btn-secondary mr-3">
|
<a href="/request-assistance" className="btn-secondary mr-3">
|
||||||
Request assistance
|
Request assistance
|
||||||
</a>
|
</a>
|
||||||
<a href="#/volunteers" className="btn-primary">
|
<a href="/volunteers" className="btn-primary">
|
||||||
<Users className="mr-2 inline h-4 w-4" /> Volunteer
|
<Users className="mr-2 inline h-4 w-4" /> Volunteer
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,10 +227,10 @@ export function ContactPage() {
|
|||||||
<a href={SITE.emailHref} className="btn-primary">
|
<a href={SITE.emailHref} className="btn-primary">
|
||||||
<Mail className="mr-2 h-4 w-4" /> Send email
|
<Mail className="mr-2 h-4 w-4" /> Send email
|
||||||
</a>
|
</a>
|
||||||
<a href="#/request-assistance" className="btn-secondary">
|
<a href="/request-assistance" className="btn-secondary">
|
||||||
Request assistance
|
Request assistance
|
||||||
</a>
|
</a>
|
||||||
<a href="#/donate" className="btn-secondary">
|
<a href="/donate" className="btn-secondary">
|
||||||
Donate
|
Donate
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ export default function DonatePageRoute() {
|
|||||||
title="Donate Now — Miracles in Motion Foundation"
|
title="Donate Now — Miracles in Motion Foundation"
|
||||||
description="Make a secure donation to support outreach, emergency assistance, and resource navigation for families in crisis throughout Los Angeles County."
|
description="Make a secure donation to support outreach, emergency assistance, and resource navigation for families in crisis throughout Los Angeles County."
|
||||||
/>
|
/>
|
||||||
<AppPageShell 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>}>
|
<AppPageShell 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">
|
||||||
{/* Enhanced Impact Calculator */}
|
{/* Enhanced Impact Calculator */}
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ function Programs() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 text-center">
|
<div className="mt-10 text-center">
|
||||||
<a href="#/what-we-do" className="btn-secondary">
|
<a href="/what-we-do" className="btn-secondary">
|
||||||
See all programs <ArrowRight className="ml-1 inline h-4 w-4" />
|
See all programs <ArrowRight className="ml-1 inline h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,7 +131,7 @@ function FeatureCard({ icon: Icon, title, body }: FeatureCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm leading-6 text-neutral-700 dark:text-neutral-300">{body}</p>
|
<p className="mt-3 text-sm leading-6 text-neutral-700 dark:text-neutral-300">{body}</p>
|
||||||
<a
|
<a
|
||||||
href="#/what-we-do"
|
href="/what-we-do"
|
||||||
className="mt-5 inline-flex items-center text-sm text-primary-700 transition hover:underline dark:text-primary-300"
|
className="mt-5 inline-flex items-center text-sm text-primary-700 transition hover:underline dark:text-primary-300"
|
||||||
>
|
>
|
||||||
Learn more <ArrowRight className="ml-1 h-4 w-4" />
|
Learn more <ArrowRight className="ml-1 h-4 w-4" />
|
||||||
@@ -174,10 +174,10 @@ function Impact() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center justify-end gap-3">
|
<div className="flex flex-wrap items-center justify-end gap-3">
|
||||||
<a href="#/donate" className="btn-primary">
|
<a href="/donate" className="btn-primary">
|
||||||
Donate <ArrowRight className="ml-2 h-4 w-4" />
|
Donate <ArrowRight className="ml-2 h-4 w-4" />
|
||||||
</a>
|
</a>
|
||||||
<a href="#/stories" className="btn-secondary">
|
<a href="/stories" className="btn-secondary">
|
||||||
Stories of hope
|
Stories of hope
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,21 +269,21 @@ function GetInvolved() {
|
|||||||
{
|
{
|
||||||
title: 'Donate',
|
title: 'Donate',
|
||||||
body: 'Restore hope through outreach, emergency aid, and compassionate care.',
|
body: 'Restore hope through outreach, emergency aid, and compassionate care.',
|
||||||
href: '#/donate',
|
href: '/donate',
|
||||||
accent: 'from-secondary-500 to-primary-500',
|
accent: 'from-secondary-500 to-primary-500',
|
||||||
icon: Heart,
|
icon: Heart,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Volunteer',
|
title: 'Volunteer',
|
||||||
body: 'Serve at outreach events and support families in your community.',
|
body: 'Serve at outreach events and support families in your community.',
|
||||||
href: '#/volunteers',
|
href: '/volunteers',
|
||||||
accent: 'from-sky-500 to-secondary-500',
|
accent: 'from-sky-500 to-secondary-500',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Partner with us',
|
title: 'Partner with us',
|
||||||
body: "Churches, businesses, and organizations — let's serve together.",
|
body: "Churches, businesses, and organizations — let's serve together.",
|
||||||
href: '#/contact',
|
href: '/contact',
|
||||||
accent: 'from-primary-500 to-secondary-500',
|
accent: 'from-primary-500 to-secondary-500',
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
},
|
},
|
||||||
@@ -369,11 +369,11 @@ function CTA() {
|
|||||||
crisis throughout Los Angeles County.
|
crisis throughout Los Angeles County.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6 flex flex-wrap items-center gap-3">
|
<div className="mt-6 flex flex-wrap items-center gap-3">
|
||||||
<a href="#/donate" className="btn-white">
|
<a href="/donate" className="btn-white">
|
||||||
Donate now
|
Donate now
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="#/request-assistance"
|
href="/request-assistance"
|
||||||
className="inline-flex items-center gap-2 rounded-full border-2 border-white/40 px-6 py-3 text-sm font-medium text-white backdrop-blur transition hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2"
|
className="inline-flex items-center gap-2 rounded-full border-2 border-white/40 px-6 py-3 text-sm font-medium text-white backdrop-blur transition hover:bg-white/10 focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-2"
|
||||||
>
|
>
|
||||||
Request assistance <ArrowRight className="ml-1 h-4 w-4" />
|
Request assistance <ArrowRight className="ml-1 h-4 w-4" />
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Building2 } from 'lucide-react'
|
||||||
|
import { AppPageShell as PageShell } from '../components/layout/AppPageShell'
|
||||||
|
import { FormSuccess } from '../components/ui/FormSuccess'
|
||||||
|
import { submitContactFromForm } from '../lib/submitContactFromForm'
|
||||||
|
import { MimApiError } from '../lib/mimApi'
|
||||||
|
|
||||||
|
export default function SponsorsPageRoute() {
|
||||||
|
const [submitted, setSubmitted] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const onSponsorSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
const res = await submitContactFromForm(e.currentTarget, 'sponsor')
|
||||||
|
setSubmitted(res.message)
|
||||||
|
e.currentTarget.reset()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof MimApiError ? err.message : 'Could not submit. Please email [email protected].')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tiers = [
|
||||||
|
{ name: 'Bronze', amt: '$2,500', perks: ['Logo on website', 'Social thank-you', 'Quarterly impact note'] },
|
||||||
|
{ name: 'Silver', amt: '$5,000', perks: ['All Bronze', 'Logo on event signage', 'Volunteer day for your team'] },
|
||||||
|
{ name: 'Gold', amt: '$10,000', perks: ['All Silver', 'Co-branded kit drive', 'Annual report feature'] },
|
||||||
|
{ name: 'Platinum', amt: '$25,000+', perks: ['All Gold', 'Program naming opportunity', 'Custom partnership plan'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="Corporate Sponsorship"
|
||||||
|
icon={Building2}
|
||||||
|
eyebrow="Partner with purpose"
|
||||||
|
cta={
|
||||||
|
<a className="btn-secondary" href="/brand">
|
||||||
|
Brand assets
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{tiers.map((t) => (
|
||||||
|
<div key={t.name} className="card">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="text-xl font-semibold">{t.name}</div>
|
||||||
|
<div className="text-lg">{t.amt}</div>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 list-disc space-y-1 pl-5 text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
{t.perks.map((p, i) => (
|
||||||
|
<li key={i}>{p}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="font-medium">Start a conversation</div>
|
||||||
|
{submitted ? (
|
||||||
|
<FormSuccess title="Thank you" message={submitted} onDismiss={() => setSubmitted(null)} />
|
||||||
|
) : (
|
||||||
|
<form className="mt-4 grid gap-4" onSubmit={onSponsorSubmit}>
|
||||||
|
<input type="text" name="website" tabIndex={-1} autoComplete="off" className="sr-only" aria-hidden="true" />
|
||||||
|
<input className="input" name="companyName" placeholder="Company name" aria-label="Company name" required />
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<input className="input" name="contactName" placeholder="Contact name" aria-label="Contact name" required />
|
||||||
|
<input type="email" name="email" className="input" placeholder="Email" aria-label="Email" required />
|
||||||
|
</div>
|
||||||
|
<select className="input" name="tier" aria-label="Tier interest">
|
||||||
|
<option>Bronze</option>
|
||||||
|
<option>Silver</option>
|
||||||
|
<option>Gold</option>
|
||||||
|
<option>Platinum</option>
|
||||||
|
<option>Custom</option>
|
||||||
|
</select>
|
||||||
|
<textarea
|
||||||
|
className="input min-h-28 resize-none"
|
||||||
|
name="message"
|
||||||
|
placeholder="Tell us about your goals"
|
||||||
|
aria-label="Message"
|
||||||
|
/>
|
||||||
|
<label className="flex items-start gap-3 text-xs opacity-80">
|
||||||
|
<input type="checkbox" className="mt-1 h-4 w-4" />
|
||||||
|
I agree to logo-use guidelines and brand approvals per our{' '}
|
||||||
|
<a className="underline" href="/legal#sponsorship-terms">
|
||||||
|
Sponsorship Terms
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</label>
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary w-full justify-center" type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Sending…' : 'Send'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { BookOpenText } from 'lucide-react'
|
||||||
|
import { AppPageShell as PageShell } from '../components/layout/AppPageShell'
|
||||||
|
import { FormSuccess } from '../components/ui/FormSuccess'
|
||||||
|
import { StoriesIntroBlock } from '../pages/foundation'
|
||||||
|
import { submitContactFromForm } from '../lib/submitContactFromForm'
|
||||||
|
import { MimApiError } from '../lib/mimApi'
|
||||||
|
|
||||||
|
export default function StoriesPageRoute() {
|
||||||
|
const [submitted, setSubmitted] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const onStorySubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
const res = await submitContactFromForm(e.currentTarget, 'story')
|
||||||
|
setSubmitted(res.message)
|
||||||
|
e.currentTarget.reset()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof MimApiError ? err.message : 'Could not submit. Please email [email protected].')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stories = [
|
||||||
|
{
|
||||||
|
title: 'A family found hope again',
|
||||||
|
tag: 'Outreach',
|
||||||
|
body: 'After a fire displaced their home, a mother and children received emergency support and compassionate guidance through recovery.',
|
||||||
|
by: 'Community partner',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Strength after hardship',
|
||||||
|
tag: 'Advocacy',
|
||||||
|
body: 'A survivor connected with resources and encouragement — reminded that she matters and brighter days are ahead.',
|
||||||
|
by: 'Volunteer',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Community showed up',
|
||||||
|
tag: 'Events',
|
||||||
|
body: 'Neighbors and volunteers came together at an outreach event to serve families with supplies, prayer, and heartfelt support.',
|
||||||
|
by: 'Event volunteer',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell
|
||||||
|
title="Stories of Hope"
|
||||||
|
icon={BookOpenText}
|
||||||
|
eyebrow="Hope in motion"
|
||||||
|
cta={
|
||||||
|
<a href="/testimonies" className="btn-secondary">
|
||||||
|
Read testimonies
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StoriesIntroBlock />
|
||||||
|
<div className="grid gap-6 md:grid-cols-3">
|
||||||
|
{stories.map((s, i) => (
|
||||||
|
<article key={i} className="card">
|
||||||
|
<div className="text-xs uppercase tracking-wide text-neutral-500">{s.tag}</div>
|
||||||
|
<h3 className="mt-1 text-lg font-semibold">{s.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-neutral-700 dark:text-neutral-300">{s.body}</p>
|
||||||
|
<div className="mt-3 text-xs text-neutral-500">— {s.by}</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="card">
|
||||||
|
<div className="font-medium">Submit your story</div>
|
||||||
|
{submitted ? (
|
||||||
|
<FormSuccess title="Thank you" message={submitted} onDismiss={() => setSubmitted(null)} />
|
||||||
|
) : (
|
||||||
|
<form className="mt-3 grid gap-3" onSubmit={onStorySubmit}>
|
||||||
|
<input type="text" name="website" tabIndex={-1} autoComplete="off" className="sr-only" aria-hidden="true" />
|
||||||
|
<input className="input" name="name" placeholder="Your name (or Anonymous)" aria-label="Name" />
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
name="email"
|
||||||
|
placeholder="Email (optional, for follow-up)"
|
||||||
|
aria-label="Email"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="input min-h-28 resize-none"
|
||||||
|
name="story"
|
||||||
|
placeholder="Your story (please omit private identifying details)"
|
||||||
|
aria-label="Story"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label className="flex items-start gap-3 text-xs opacity-80">
|
||||||
|
<input type="checkbox" className="mt-1 h-4 w-4" required />
|
||||||
|
I grant permission to publish this story (edited for length/clarity). I have removed personally identifying
|
||||||
|
information.
|
||||||
|
</label>
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary w-full justify-center" type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Sending…' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState, type ComponentType } from 'react'
|
||||||
|
import { Backpack, Users } from 'lucide-react'
|
||||||
|
import { AppPageShell as PageShell } from '../components/layout/AppPageShell'
|
||||||
|
import { FormSuccess } from '../components/ui/FormSuccess'
|
||||||
|
import { submitContactFromForm } from '../lib/submitContactFromForm'
|
||||||
|
import { MimApiError } from '../lib/mimApi'
|
||||||
|
|
||||||
|
function InfoCard({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: ComponentType<{ className?: string }>
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="grid h-10 w-10 place-items-center rounded-xl bg-gradient-to-br from-primary-500 to-secondary-600 text-white shadow">
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div className="font-semibold tracking-tight">{title}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VolunteerPageRoute() {
|
||||||
|
const [submitted, setSubmitted] = useState<string | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [formError, setFormError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const onVolunteerSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSubmitting(true)
|
||||||
|
setFormError(null)
|
||||||
|
try {
|
||||||
|
const res = await submitContactFromForm(e.currentTarget, 'volunteer')
|
||||||
|
setSubmitted(res.message)
|
||||||
|
e.currentTarget.reset()
|
||||||
|
} catch (err) {
|
||||||
|
setFormError(err instanceof MimApiError ? err.message : 'Could not submit. Please email [email protected].')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell title="Volunteer" icon={Users} eyebrow="Serve locally">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="card">
|
||||||
|
<div className="font-medium">Sign-up form</div>
|
||||||
|
{submitted ? (
|
||||||
|
<FormSuccess title="Thank you" message={submitted} onDismiss={() => setSubmitted(null)} />
|
||||||
|
) : (
|
||||||
|
<form className="mt-4 grid grid-cols-1 gap-4" onSubmit={onVolunteerSubmit}>
|
||||||
|
<input type="text" name="website" tabIndex={-1} autoComplete="off" className="sr-only" aria-hidden="true" />
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<input required name="firstName" aria-label="First name" placeholder="First name" className="input" />
|
||||||
|
<input required name="lastName" aria-label="Last name" placeholder="Last name" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<input required type="email" name="email" aria-label="Email" placeholder="Email" className="input" />
|
||||||
|
<input name="phone" aria-label="Phone" placeholder="Phone" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<select name="interest" aria-label="Interests" className="input">
|
||||||
|
<option>Assemble kits</option>
|
||||||
|
<option>Delivery driver</option>
|
||||||
|
<option>Community partner</option>
|
||||||
|
<option>Admin support</option>
|
||||||
|
</select>
|
||||||
|
<select name="availability" aria-label="Availability" className="input">
|
||||||
|
<option>Weekdays</option>
|
||||||
|
<option>Weeknights</option>
|
||||||
|
<option>Weekends</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-start gap-3 text-sm">
|
||||||
|
<input required type="checkbox" className="mt-1 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
I have read and agree to the{' '}
|
||||||
|
<a className="underline" href="/legal#volunteer-waiver">
|
||||||
|
Volunteer Waiver & Liability Release
|
||||||
|
</a>
|
||||||
|
, including background check and child-safety requirements.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{formError && (
|
||||||
|
<p className="text-sm text-red-600 dark:text-red-400" role="alert">
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button className="btn-primary w-full justify-center" type="submit" disabled={submitting}>
|
||||||
|
{submitting ? 'Sending…' : 'Submit'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<aside className="space-y-6">
|
||||||
|
<InfoCard title="What to expect" icon={Backpack}>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
Shifts are 2–3 hours. Training provided. Youth (14+) welcome with guardian consent.
|
||||||
|
</p>
|
||||||
|
</InfoCard>
|
||||||
|
<InfoCard title="Group volunteering" icon={Users}>
|
||||||
|
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
|
We host teams of 5–25 for corporate/service groups.{' '}
|
||||||
|
<a className="underline" href="/sponsors">
|
||||||
|
Contact us
|
||||||
|
</a>{' '}
|
||||||
|
for dates.
|
||||||
|
</p>
|
||||||
|
</InfoCard>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1
-1
@@ -59,6 +59,6 @@ Object.defineProperty(window, 'localStorage', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Hash-router default — avoid deleting window.location (breaks Vitest jsdom teardown)
|
// Hash-router default — avoid deleting window.location (breaks Vitest jsdom teardown)
|
||||||
if (window.location.hash !== '#/') {
|
if (window.location.hash !== '/') {
|
||||||
window.history.replaceState({}, '', '/#/')
|
window.history.replaceState({}, '', '/#/')
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -10,10 +10,10 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
setupFiles: ['./src/test/setup.ts'],
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
css: true,
|
css: true,
|
||||||
exclude: ['**/node_modules/**', '**/dist/**', 'mim-api/**'],
|
exclude: ['**/node_modules/**', '**/dist/**', 'mim-api/**', 'e2e/**'],
|
||||||
environmentOptions: {
|
environmentOptions: {
|
||||||
jsdom: {
|
jsdom: {
|
||||||
url: 'http://localhost:3000/#/',
|
url: 'http://localhost:3000/',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
Reference in New Issue
Block a user