feat(mim): admin QR/brand API, public config hooks, and deploy workflow fix.

Split mim-api into modular routes, refresh nav brand assets, and pin reusable validate workflow to Order-of-Hospitallers/miracles_in_motion@main.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
defiQUG
2026-06-19 16:17:10 -07:00
co-authored by Cursor
parent 4e4df13ab1
commit 05978ae25c
66 changed files with 8233 additions and 3903 deletions
+2 -1
View File
@@ -12,7 +12,8 @@ on:
jobs:
validate:
uses: ./.github/workflows/validate.yml
# Same-repo reusable workflow; GitHub runs the called workflow at the caller commit.
uses: Order-of-Hospitallers/miracles_in_motion/.github/workflows/validate.yml@main
artifact:
name: Upload build artifact
+5
View File
@@ -23,6 +23,11 @@ dist-ssr
*.sln
*.sw?
# Accidental Lighthouse artifact directories (Windows/WSL path leaks)
undefined*/
**/lighthouse.*/
**/*wsl.localhost*/
# Environment variables
.env
.env.local
+13
View File
@@ -65,6 +65,19 @@
{ "title": "Square (WebP)", "path": "/brand/logo-square.webp", "format": "WebP" }
]
},
{
"id": "qr",
"title": "Branded QR codes",
"description": "Approved dynamic QR styling — forest green field, gold diamond modules, leaf corner finders.",
"assets": [
{
"title": "Branded QR reference (PNG)",
"path": "/brand/qr-code-branded-reference.png",
"format": "PNG",
"description": "Canonical visual reference for donate/event QR codes"
}
]
},
{
"id": "favicon",
"title": "Favicon & app icons",
+13
View File
@@ -34,4 +34,17 @@ test.describe('MIM4U public navigation', () => {
const body = await res.json()
expect(body.ok).toBe(true)
})
test('brand assets page loads', async ({ page }) => {
await page.goto('/brand')
await expect(page.getByRole('heading', { level: 1, name: 'Brand assets' })).toBeVisible({ timeout: 20000 })
await expect(page.getByText(/Forest green/i)).toBeVisible()
})
test('public brand API returns manifest', async ({ request }) => {
const res = await request.get('/api/public/brand')
expect(res.ok()).toBeTruthy()
const body = await res.json()
expect(body.groups?.length).toBeGreaterThan(0)
})
})
+9 -1
View File
@@ -5,10 +5,18 @@
CUSTOM_DOMAIN=mim4u.org
VITE_API_BASE_URL=/api
# Stripe (public key in frontend; secret + webhook on API 7811)
# Stripe (public key in frontend build OR runtime via /api/public/config after admin setup)
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_YOUR_KEY
# Dev-only mock donate when Stripe disabled:
# VITE_ALLOW_MOCK_DONATE=1
# API secrets (VMID 7811 — also manageable in admin → System Settings)
STRIPE_SECRET_KEY=sk_live_YOUR_KEY
STRIPE_WEBHOOK_SECRET=whsec_YOUR_SECRET
MIM_AUTH_SECRET=change-me-to-a-long-random-string
MIM_ADMIN_PASSWORD=
MIM_VOLUNTEER_PASSWORD=
MIM_RESOURCE_PASSWORD=
# Analytics
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
+28 -1
View File
@@ -4,7 +4,16 @@ NODE_ENV=production
MIM_PUBLIC_URL=https://mim4u.org
MIM_DATA_DIR=/var/lib/mim-api/data
# Stripe (required for live donations)
# Portal auth (JWT signing — required in production)
MIM_AUTH_SECRET=change-me-to-a-long-random-string
# Portal users (seeded on first start if users.json missing)
MIM_ADMIN_PASSWORD=
MIM_VOLUNTEER_PASSWORD=
MIM_RESOURCE_PASSWORD=
# Stripe (can also be set via admin UI → persisted to admin-settings.json)
STRIPE_PUBLISHABLE_KEY=
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
@@ -15,5 +24,23 @@ SMTP_USER=
SMTP_PASS=
MIM_NOTIFY_EMAIL=[email protected]
# Brand files (optional — default: $MIM_DATA_DIR/brand; set to /var/www/html/brand on 7810 for direct nginx serve)
# MIM_BRAND_DIR=/var/lib/mim-api/data/brand
# CORS (comma-separated origins; default mim4u.org)
MIM_CORS_ORIGINS=https://mim4u.org,https://www.mim4u.org
# QR codes — provider: qrcode-monkey (default) or qrcg (dynamic tracking)
# QR Code Monkey docs: https://www.qrcode-monkey.com/qr-code-api-with-logo/
# Direct API works without a key; optional RapidAPI after subscribing to
# "Custom QR Code with Logo" on rapidapi.com:
# RAPIDAPI_KEY=
# QR_MONKEY_USE_RAPIDAPI=1
# QR_MONKEY_LOGO_URL=https://mim4u.org/brand/logo-square.png
# QR_MONKEY_SIZE=600
# QRCG by Bitly — optional dynamic QR with scan analytics
# Docs: https://dev.qrcg.com/
# MIM_QR_PROVIDER=qrcg
QRCG_API_KEY=
# QRCG_LOGO_ID=
+1040
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -8,13 +8,14 @@
"scripts": {
"start": "node src/server.js",
"dev": "node --watch src/server.js",
"test": "node --test test/server.test.js"
"test": "node --test test/*.test.js"
},
"engines": {
"node": ">=18"
},
"dependencies": {
"express": "^4.21.2",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.10.0",
"stripe": "^17.7.0"
}
+331
View File
@@ -0,0 +1,331 @@
/**
* MIM4U API — VMID 7811
* Public: health, events, Stripe, assistance, contact
* Admin: auth, settings, analytics, Stripe management
*/
import express from 'express'
import crypto from 'node:crypto'
import nodemailer from 'nodemailer'
import path from 'node:path'
import { readJsonl, appendJsonl, ensureDataDir } from './lib/data.js'
import { ensureUsers } from './lib/users.js'
import { loadSettings, publicConfig } from './lib/settings.js'
import { getStripeClient, isStripeLive } from './lib/stripe-service.js'
import { createCorsMiddleware } from './middleware/cors.js'
import { createAuthRoutes } from './routes/auth-routes.js'
import { createBrandPublicRoutes, createBrandAdminRoutes } from './routes/brand-routes.js'
import {
createAdminRoutes,
createResourceRoutes,
createVolunteerRoutes,
} from './routes/admin-routes.js'
import { createQrAdminRoutes } from './routes/qr-routes.js'
import { createQrPublicRoutes } from './routes/qr-public-routes.js'
import { getQrProvider, isAnyQrConfigured } from './lib/qr-provider.js'
export function validateAssistance(body) {
const errors = []
if (!body?.requestType) errors.push('requestType required')
if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required')
if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required')
if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required')
if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required')
if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required')
if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required')
if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required')
if (!body?.details?.trim()) errors.push('details required')
if (body?.website) errors.push('spam detected')
return errors
}
export function createApp(options = {}) {
const PORT = Number(options.port || process.env.PORT || 3001)
const DATA_DIR = options.dataDir || process.env.MIM_DATA_DIR || path.join(process.cwd(), 'data')
const PUBLIC_URL = (options.publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
const NOTIFY_EMAIL = process.env.MIM_NOTIFY_EMAIL || '[email protected]'
const CORS_ORIGINS = (process.env.MIM_CORS_ORIGINS || 'https://mim4u.org,https://www.mim4u.org')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const AUTH_SECRET = options.authSecret || process.env.MIM_AUTH_SECRET || 'mim-dev-auth-secret-change-me'
ensureDataDir(DATA_DIR)
ensureUsers(DATA_DIR)
function mailTransport() {
const { SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_PORT } = process.env
if (!SMTP_HOST) return null
return nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT || 587),
secure: Number(SMTP_PORT) === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
})
}
async function notify(subject, text, { to } = {}) {
appendJsonl(DATA_DIR, 'notifications.jsonl', { subject, text, to })
const transport = mailTransport()
if (!transport) {
console.log(`[notify] ${subject}: ${text.slice(0, 200)}`)
return
}
await transport.sendMail({
from: NOTIFY_EMAIL,
to: to || NOTIFY_EMAIL,
subject,
text,
})
}
function requireStripe(_req, res, next) {
if (!isStripeLive(DATA_DIR)) {
return res.status(503).json({ error: 'Stripe donations are disabled or not configured' })
}
next()
}
const app = express()
app.disable('x-powered-by')
app.use(createCorsMiddleware(CORS_ORIGINS))
app.get('/health', (_req, res) => {
res.type('text/plain').send('ok')
})
app.get('/api/health', (_req, res) => {
const settings = loadSettings(DATA_DIR)
res.json({
ok: true,
service: 'mim-api',
stripe: isStripeLive(DATA_DIR),
donationsEnabled: settings.donationsEnabled,
dataDir: DATA_DIR,
version: '1.1.0',
qr: {
provider: getQrProvider(),
configured: isAnyQrConfigured(),
},
})
})
app.get('/api/public/config', (_req, res) => {
res.json(publicConfig(loadSettings(DATA_DIR)))
})
app.get('/api/public/donation-impact', (_req, res) => {
const donations = readJsonl(DATA_DIR, 'donations.jsonl', { limit: 500 })
const totalCents = donations.reduce((s, d) => s + (d.amountTotal || 0), 0)
const assistance = readJsonl(DATA_DIR, 'assistance-requests.jsonl', { limit: 500 })
res.json({
totalRaisedUsd: Math.round(totalCents / 100),
donationCount: donations.length,
familiesSupported: assistance.filter((a) => a.status === 'completed' || a.status === 'approved').length,
})
})
app.use('/api/public', createBrandPublicRoutes({ dataDir: DATA_DIR }))
app.use('/api/public', createQrPublicRoutes({ dataDir: DATA_DIR }))
app.use('/api/auth', createAuthRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/admin', createAdminRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/admin', createBrandAdminRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use(
'/api/admin',
createQrAdminRoutes({
dataDir: DATA_DIR,
authSecret: AUTH_SECRET,
publicUrl: PUBLIC_URL,
publicApiBase: process.env.MIM_API_PUBLIC_BASE || '',
}),
)
app.use('/api/volunteer', createVolunteerRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/resource', createResourceRoutes({ dataDir: DATA_DIR, authSecret: AUTH_SECRET }))
app.use('/api/events', express.json({ limit: '32kb' }))
app.post('/api/events', (req, res) => {
appendJsonl(DATA_DIR, 'events.jsonl', req.body || {})
res.status(204).end()
})
app.use('/api/assistance-requests', express.json({ limit: '128kb' }))
const postAssistanceRequest = async (req, res) => {
const errors = validateAssistance(req.body)
if (errors.length) return res.status(400).json({ error: 'Validation failed', fields: errors })
const id = `asst_${crypto.randomUUID()}`
const record = { id, status: 'pending', ...req.body, ip: req.ip, userAgent: req.headers['user-agent'] }
appendJsonl(DATA_DIR, 'assistance-requests.jsonl', record)
try {
await notify(
`[MIM4U] Assistance request ${id}`,
`New assistance request (${req.body.requestType})\nContact: ${req.body.contactInfo.parentName} <${req.body.contactInfo.email}>\nPhone: ${req.body.contactInfo.phone}\nDetails: ${req.body.details?.slice(0, 500)}`,
)
} catch (e) {
console.error('assistance notify failed', e)
}
res.status(201).json({ ok: true, id, message: 'Request received. We will contact you within 2448 hours.' })
}
app.post('/api/assistance-requests', postAssistanceRequest)
app.post('/api/assistance', postAssistanceRequest)
app.use('/api/contact', express.json({ limit: '64kb' }))
app.post('/api/contact', async (req, res) => {
const { type, name, email, message, website, ...rest } = req.body || {}
if (website) return res.status(400).json({ error: 'Invalid submission' })
if (!type) return res.status(400).json({ error: 'type required' })
if (!email?.trim() && type !== 'story') return res.status(400).json({ error: 'email required' })
if (type === 'story' && !message?.trim() && !rest.story?.trim()) {
return res.status(400).json({ error: 'story text required' })
}
const id = `contact_${crypto.randomUUID()}`
appendJsonl(DATA_DIR, 'contact.jsonl', { id, type, name, email, message, ...rest })
try {
await notify(`[MIM4U] ${type} form ${id}`, JSON.stringify({ name, email, message, ...rest }, null, 2))
} catch (e) {
console.error('contact notify failed', e)
}
res.status(201).json({ ok: true, id, message: 'Thank you — we will be in touch soon.' })
})
app.use('/api/donations', express.json({ limit: '32kb' }))
app.post('/api/donations', (req, res) => {
appendJsonl(DATA_DIR, 'donations-offline.jsonl', req.body || {})
res.status(201).json({ ok: true })
})
app.use('/api/create-payment-intent', express.json({ limit: '32kb' }))
app.post('/api/create-payment-intent', requireStripe, async (req, res) => {
const stripe = getStripeClient(DATA_DIR)
const amount = Number(req.body?.amount)
if (!stripe) return res.status(503).json({ error: 'Stripe not configured' })
if (!Number.isFinite(amount) || amount < 100) {
return res.status(400).json({ error: 'amount must be at least 100 cents' })
}
try {
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount),
currency: 'usd',
metadata: {
recurring: String(Boolean(req.body?.recurring)),
donorEmail: req.body?.customer?.email || '',
donorName: req.body?.customer?.name || '',
},
receipt_email: req.body?.customer?.email || undefined,
})
res.json({ client_secret: intent.client_secret })
} catch (e) {
console.error('payment intent error', e)
res.status(502).json({ error: e.message || 'Stripe error' })
}
})
app.use('/api/create-checkout-session', express.json({ limit: '32kb' }))
app.post('/api/create-checkout-session', requireStripe, async (req, res) => {
const stripe = getStripeClient(DATA_DIR)
const amountUsd = Number(req.body?.amount)
if (!stripe) return res.status(503).json({ error: 'Stripe not configured' })
if (!Number.isFinite(amountUsd) || amountUsd < 1) {
return res.status(400).json({ error: 'amount must be at least $1' })
}
const cents = Math.round(amountUsd * 100)
const recurring = Boolean(req.body?.recurring)
const email = req.body?.email?.trim()
const name = req.body?.name?.trim()
try {
const session = await stripe.checkout.sessions.create({
mode: recurring ? 'subscription' : 'payment',
success_url: `${PUBLIC_URL}/donate?status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${PUBLIC_URL}/donate?status=cancelled`,
customer_email: email || undefined,
line_items: [
recurring
? {
price_data: {
currency: 'usd',
product_data: { name: 'Monthly donation — Miracles in Motion Foundation' },
unit_amount: cents,
recurring: { interval: 'month' },
},
quantity: 1,
}
: {
price_data: {
currency: 'usd',
product_data: { name: 'Donation — Miracles in Motion Foundation' },
unit_amount: cents,
},
quantity: 1,
},
],
metadata: {
donorName: name || '',
anonymous: String(Boolean(req.body?.anonymous)),
recurring: String(recurring),
},
})
res.json({ url: session.url, sessionId: session.id })
} catch (e) {
console.error('checkout session error', e)
res.status(502).json({ error: e.message || 'Stripe error' })
}
})
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const stripe = getStripeClient(DATA_DIR)
const settings = loadSettings(DATA_DIR)
if (!stripe) return res.status(503).send('Stripe not configured')
const sig = req.headers['stripe-signature']
const secret = settings.stripeWebhookSecret
let event
try {
event = secret
? stripe.webhooks.constructEvent(req.body, sig, secret)
: JSON.parse(req.body.toString())
} catch (e) {
console.error('webhook verify failed', e.message)
return res.status(400).send(`Webhook Error: ${e.message}`)
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
appendJsonl(DATA_DIR, 'donations.jsonl', {
sessionId: session.id,
amountTotal: session.amount_total,
customerEmail: session.customer_details?.email,
metadata: session.metadata,
})
const donorEmail = session.customer_details?.email
const amountUsd = ((session.amount_total || 0) / 100).toFixed(2)
try {
await notify(
`[MIM4U] Donation received ${session.id}`,
`Amount: ${amountUsd} USD\nEmail: ${donorEmail || 'n/a'}`,
)
if (donorEmail) {
await notify(
'Thank you for your gift — Miracles in Motion Foundation',
`Dear friend,\n\nThank you for your generous donation of $${amountUsd} to Miracles in Motion Foundation.\n\nYour gift supports outreach, emergency assistance, and compassionate care in Los Angeles County.\n\nEIN ${process.env.MIM_EIN || '33-4887159'}\nhttps://mim4u.org\n\nWith gratitude,\nMiracles in Motion Foundation`,
{ to: donorEmail },
)
}
} catch (e) {
console.error('donation notify failed', e)
}
}
res.json({ received: true })
},
)
app.use((_req, res) => res.status(404).json({ error: 'Not found' }))
return { app, DATA_DIR, PORT, AUTH_SECRET }
}
@@ -0,0 +1,96 @@
{
"version": "1.2.0",
"organization": "Miracles in Motion Foundation",
"updated": "2026-06-15",
"colors": [
{ "name": "Forest green", "hex": "#1a3c34", "role": "primary background" },
{ "name": "Forest green (deep)", "hex": "#0f2922", "role": "dark text" },
{ "name": "Gold", "hex": "#c5a059", "role": "accent" },
{ "name": "Gold (bright)", "hex": "#d4af37", "role": "logo highlight" }
],
"typography": [
{ "name": "Playfair Display", "use": "Headings (web)", "source": "Self-hosted via site" },
{ "name": "Inter", "use": "Body and UI (web)", "source": "Self-hosted via site" }
],
"kits": [
{
"id": "full",
"title": "Complete brand kit (ZIP)",
"path": "/brand/MIM4U-Brand-Kit.zip",
"format": "ZIP",
"description": "All logos, favicons, and guidelines markdown"
}
],
"groups": [
{
"id": "nav",
"title": "Website nav lockups (transparent)",
"description": "Approved header/footer assets — gold artwork on transparent background for light and dark UI shells.",
"assets": [
{ "title": "Nav horizontal (SVG)", "path": "/brand/logo-horizontal-nav.svg", "format": "SVG" },
{ "title": "Nav horizontal (PNG)", "path": "/brand/logo-horizontal-nav.png", "format": "PNG" },
{ "title": "Nav horizontal (WebP)", "path": "/brand/logo-horizontal-nav.webp", "format": "WebP" },
{ "title": "Nav symbol (SVG)", "path": "/brand/logo-symbol-nav.svg", "format": "SVG" },
{ "title": "Nav symbol (PNG)", "path": "/brand/logo-symbol-nav.png", "format": "PNG" }
]
},
{
"id": "symbol",
"title": "Symbol mark (icon only)",
"description": "Figure and starburst — mobile header, favicons, compact UI.",
"assets": [
{ "title": "Symbol (SVG, simplified vector)", "path": "/brand/logo-symbol.svg", "format": "SVG" },
{ "title": "Symbol 512px", "path": "/brand/logo-symbol-512.png", "format": "PNG" },
{ "title": "Symbol 256px", "path": "/brand/logo-symbol-256.png", "format": "PNG" },
{ "title": "Symbol 128px", "path": "/brand/logo-symbol-128.png", "format": "PNG" },
{ "title": "Symbol 64px", "path": "/brand/logo-symbol-64.png", "format": "PNG" }
]
},
{
"id": "horizontal",
"title": "Horizontal lockup",
"description": "Icon + MIRACLES IN MOTION FOUNDATION — desktop nav and footer.",
"assets": [
{ "title": "Horizontal (PNG)", "path": "/brand/logo-horizontal.png", "format": "PNG" },
{ "title": "Horizontal (WebP)", "path": "/brand/logo-horizontal.webp", "format": "WebP" },
{ "title": "Horizontal 2× width (PNG)", "path": "/brand/logo-horizontal-2x.png", "format": "PNG" }
]
},
{
"id": "square",
"title": "Square lockup",
"description": "Full stacked logo on green — social and print-friendly raster.",
"assets": [
{ "title": "Square (PNG)", "path": "/brand/logo-square.png", "format": "PNG" },
{ "title": "Square (WebP)", "path": "/brand/logo-square.webp", "format": "WebP" }
]
},
{
"id": "qr",
"title": "Branded QR codes",
"description": "Approved dynamic QR styling — forest green field, gold diamond modules, leaf corner finders. Generated via QRCG (admin /admin-qr).",
"assets": [
{
"title": "Branded QR reference (PNG)",
"path": "/brand/qr-code-branded-reference.png",
"format": "PNG",
"description": "Canonical visual reference for donate/event QR codes"
}
]
},
{
"id": "favicon",
"title": "Favicon & app icons",
"description": "Symbol on forest green background.",
"assets": [
{ "title": "favicon.ico", "path": "/brand/favicon.ico", "format": "ICO" },
{ "title": "16×16", "path": "/brand/favicon-16.png", "format": "PNG" },
{ "title": "32×32", "path": "/brand/favicon-32.png", "format": "PNG" },
{ "title": "180×180 (Apple touch)", "path": "/brand/favicon-180.png", "format": "PNG" },
{ "title": "192×192 (PWA)", "path": "/brand/favicon-192.png", "format": "PNG" },
{ "title": "512×512 (PWA)", "path": "/brand/favicon-512.png", "format": "PNG" },
{ "title": "Favicon (SVG)", "path": "/favicon.svg", "format": "SVG" }
]
}
]
}
+38
View File
@@ -0,0 +1,38 @@
{
"version": "1.0.0",
"description": "Canonical MIM4U dynamic QR styling — forest green field, gold diamond modules, leaf corner finders, optional center logo.",
"referenceImage": "/brand/qr-code-branded-reference.png",
"colors": {
"background": "#1a3c34",
"foreground": "#c5a059",
"foregroundBright": "#d4af37"
},
"customizations": {
"frame": { "name": "no-frame" },
"background": { "color": "#1a3c34" },
"pattern": { "color": "#c5a059", "shape": "diamond" },
"corners": {
"topLeft": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" },
"topRight": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" },
"bottomLeft": { "innerColor": "#c5a059", "outerColor": "#c5a059", "shape": "version17" }
}
},
"logo": {
"note": "QR Code Monkey: set QR_MONKEY_LOGO_URL or upload via /qr/uploadImage → QR_MONKEY_LOGO_FILE. QRCG: set QRCG_LOGO_ID.",
"localAsset": "/brand/qr-code-branded-reference.png"
},
"qrcodeMonkey": {
"body": "diamond",
"eye": "frame14",
"eyeBall": "ball16",
"bodyColor": "#c5a059",
"bgColor": "#1a3c34",
"eye1Color": "#c5a059",
"eye2Color": "#c5a059",
"eye3Color": "#c5a059",
"eyeBall1Color": "#c5a059",
"eyeBall2Color": "#c5a059",
"eyeBall3Color": "#c5a059",
"logoMode": "clean"
}
}
+65
View File
@@ -0,0 +1,65 @@
import crypto from 'node:crypto'
const TOKEN_TTL_MS = 12 * 60 * 60 * 1000
export function hashPassword(password) {
const salt = randomBytes(16)
const hash = scryptSync(password, salt)
return `${salt.toString('hex')}:${hash.toString('hex')}`
}
export function verifyPassword(password, stored) {
const [saltHex, hashHex] = stored.split(':')
if (!saltHex || !hashHex) return false
const salt = Buffer.from(saltHex, 'hex')
const expected = Buffer.from(hashHex, 'hex')
const actual = scryptSync(password, salt)
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual)
}
function scryptSync(password, salt) {
return crypto.scryptSync(password, salt, 64)
}
function randomBytes(n) {
return crypto.randomBytes(n)
}
function b64url(input) {
return Buffer.from(input).toString('base64url')
}
function b64urlDecode(input) {
return Buffer.from(input, 'base64url').toString('utf8')
}
export function signToken(payload, secret) {
const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
const body = b64url(JSON.stringify({ ...payload, exp: Date.now() + TOKEN_TTL_MS }))
const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
return `${header}.${body}.${sig}`
}
export function verifyToken(token, secret) {
if (!token || typeof token !== 'string') return null
const parts = token.split('.')
if (parts.length !== 3) return null
const [header, body, sig] = parts
const expected = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url')
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return null
}
try {
const payload = JSON.parse(b64urlDecode(body))
if (!payload.exp || Date.now() > payload.exp) return null
return payload
} catch {
return null
}
}
export function maskSecret(value) {
if (!value || typeof value !== 'string') return ''
if (value.length <= 8) return '••••••••'
return `${value.slice(0, 7)}${value.slice(-4)}`
}
+107
View File
@@ -0,0 +1,107 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { readJsonFile, writeJsonFile } from './data.js'
const MANIFEST_FILE = 'brand-manifest.json'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DEFAULT_MANIFEST_PATH = path.join(__dirname, '../defaults/brand-manifest.default.json')
export function brandDir(dataDir) {
return process.env.MIM_BRAND_DIR || path.join(dataDir, 'brand')
}
export function loadDefaultManifest() {
if (fs.existsSync(DEFAULT_MANIFEST_PATH)) {
try {
return JSON.parse(fs.readFileSync(DEFAULT_MANIFEST_PATH, 'utf8'))
} catch {
return null
}
}
return null
}
export function loadBrandManifest(dataDir) {
const stored = readJsonFile(dataDir, MANIFEST_FILE, null)
if (stored) {
return normalizeManifest(stored)
}
const seeded = loadDefaultManifest()
if (seeded) {
const normalized = normalizeManifest({ ...seeded, published: true })
saveBrandManifest(dataDir, normalized)
return normalized
}
return normalizeManifest({ version: '1.0.0', organization: 'Miracles in Motion Foundation', updated: new Date().toISOString().slice(0, 10), colors: [], typography: [], groups: [], kits: [], published: true })
}
export function saveBrandManifest(dataDir, manifest) {
const dir = brandDir(dataDir)
fs.mkdirSync(dir, { recursive: true })
writeJsonFile(dataDir, MANIFEST_FILE, {
...manifest,
updated: new Date().toISOString().slice(0, 10),
})
}
function normalizeManifest(m) {
return {
version: m.version || '1.0.0',
organization: m.organization || 'Miracles in Motion Foundation',
updated: m.updated || new Date().toISOString().slice(0, 10),
published: m.published !== false,
colors: Array.isArray(m.colors) ? m.colors : [],
typography: Array.isArray(m.typography) ? m.typography : [],
kits: Array.isArray(m.kits) ? m.kits : [],
groups: (m.groups || []).map((g) => ({
...g,
visible: g.visible !== false,
assets: (g.assets || []).map((a) => ({
...a,
visible: a.visible !== false,
})),
})),
usageNotes: m.usageNotes || '',
}
}
export function publicBrandManifest(manifest) {
if (!manifest.published) {
return { published: false, organization: manifest.organization, message: 'Brand kit is temporarily unavailable.' }
}
return {
...manifest,
groups: manifest.groups
.filter((g) => g.visible !== false)
.map((g) => ({
...g,
assets: g.assets.filter((a) => a.visible !== false),
})),
}
}
export function listBrandFiles(dataDir) {
const dir = brandDir(dataDir)
if (!fs.existsSync(dir)) return []
return fs.readdirSync(dir).filter((f) => !f.startsWith('.'))
}
export function safeBrandFilename(name) {
return path.basename(name).replace(/[^a-zA-Z0-9._-]/g, '-')
}
export async function rebuildBrandZip(dataDir) {
const dir = brandDir(dataDir)
const zipPath = path.join(dir, 'MIM4U-Brand-Kit.zip')
const files = listBrandFiles(dataDir).filter((f) => f !== 'MIM4U-Brand-Kit.zip' && !f.endsWith('.json'))
if (!files.length) return { ok: false, error: 'No brand files to zip' }
const { spawnSync } = await import('node:child_process')
const args = ['-q', 'MIM4U-Brand-Kit.zip', ...files]
const result = spawnSync('zip', args, { cwd: dir })
if (result.status !== 0) {
return { ok: false, error: result.stderr?.toString() || 'zip failed' }
}
return { ok: true, path: '/api/public/brand/files/MIM4U-Brand-Kit.zip', fileCount: files.length }
}
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs'
import path from 'node:path'
export function ensureDataDir(dataDir) {
fs.mkdirSync(dataDir, { recursive: true })
}
export function appendJsonl(dataDir, file, record) {
const line = `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n`
fs.appendFileSync(path.join(dataDir, file), line, 'utf8')
}
export function readJsonl(dataDir, file, { limit = 100, reverse = true } = {}) {
const filePath = path.join(dataDir, file)
if (!fs.existsSync(filePath)) return []
const lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean)
const parsed = lines.map((line) => {
try {
return JSON.parse(line)
} catch {
return null
}
}).filter(Boolean)
const ordered = reverse ? parsed.reverse() : parsed
return ordered.slice(0, limit)
}
export function readJsonFile(dataDir, file, fallback) {
const filePath = path.join(dataDir, file)
if (!fs.existsSync(filePath)) return fallback
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
} catch {
return fallback
}
}
export function writeJsonFile(dataDir, file, data) {
const filePath = path.join(dataDir, file)
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf8')
}
export function rewriteJsonl(dataDir, file, records) {
const filePath = path.join(dataDir, file)
const content = records.map((r) => JSON.stringify(r)).join('\n')
fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8')
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Shared MIM4U QR brand spec — used by QR Code Monkey and QRCG providers.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const BRAND_SPEC_PATH = path.join(__dirname, '../defaults/qrcg-mim-brand.json')
let cachedBrandSpec = null
export function loadBrandSpec() {
if (cachedBrandSpec) return cachedBrandSpec
try {
cachedBrandSpec = JSON.parse(fs.readFileSync(BRAND_SPEC_PATH, 'utf8'))
} catch {
cachedBrandSpec = {
colors: { background: '#1a3c34', foreground: '#c5a059' },
referenceImage: '/brand/qr-code-branded-reference.png',
qrcodeMonkey: {
body: 'diamond',
eye: 'frame14',
eyeBall: 'ball16',
bodyColor: '#c5a059',
bgColor: '#1a3c34',
eye1Color: '#c5a059',
eye2Color: '#c5a059',
eye3Color: '#c5a059',
eyeBall1Color: '#c5a059',
eyeBall2Color: '#c5a059',
eyeBall3Color: '#c5a059',
logoMode: 'clean',
},
}
}
return cachedBrandSpec
}
export const QR_PRESETS = {
donate: {
title: 'MIM4U — Donate',
url: 'https://mim4u.org/donate',
purpose: 'donate',
},
brand: {
title: 'MIM4U — Brand assets',
url: 'https://mim4u.org/brand',
purpose: 'brand',
},
home: {
title: 'MIM4U — Home',
url: 'https://mim4u.org',
purpose: 'home',
},
}
export function mimQrBrandSpec(publicUrl) {
const spec = loadBrandSpec()
const siteBase = String(publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
const logoUrl =
process.env.QR_MONKEY_LOGO_URL?.trim() ||
process.env.MIM_QR_LOGO_URL?.trim() ||
`${siteBase}/brand/logo-square.png`
return {
colors: spec.colors,
referenceImage: spec.referenceImage,
qrcodeMonkey: { ...spec.qrcodeMonkey, logo: logoUrl },
logoUrl,
}
}
+141
View File
@@ -0,0 +1,141 @@
/**
* QR Code Monkey API — custom QR with logo and design
* @see https://www.qrcode-monkey.com/qr-code-api-with-logo/
*/
import { loadBrandSpec, mimQrBrandSpec } from './qr-brand.js'
const DIRECT_BASE = 'https://api.qrcode-monkey.com'
const RAPIDAPI_BASE = 'https://qrcode-monkey.p.rapidapi.com'
const RAPIDAPI_HOST = 'qrcode-monkey.p.rapidapi.com'
export function getQrMonkeyBaseUrl({ preferRapidApi } = {}) {
const custom = String(process.env.QR_MONKEY_API_BASE || '').trim()
if (custom) return custom.replace(/\/$/, '')
const useRapid =
preferRapidApi ||
['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase())
if (useRapid && getRapidApiKey()) return RAPIDAPI_BASE
return DIRECT_BASE
}
export function hasRapidApiKey() {
return Boolean(getRapidApiKey())
}
export function getRapidApiKey() {
return String(process.env.RAPIDAPI_KEY || process.env.QR_MONKEY_RAPIDAPI_KEY || '').trim()
}
export function isQrMonkeyConfigured() {
return true
}
export function defaultMonkeyConfig(publicUrl) {
const spec = loadBrandSpec()
const brand = mimQrBrandSpec(publicUrl)
const monkey = { ...spec.qrcodeMonkey }
if (brand.logoUrl) monkey.logo = brand.logoUrl
const uploaded = String(process.env.QR_MONKEY_LOGO_FILE || '').trim()
if (uploaded) monkey.logo = uploaded
return monkey
}
function monkeyHeaders(useRapidApi) {
const headers = { 'Content-Type': 'application/json', Accept: 'image/*' }
if (useRapidApi && getRapidApiKey()) {
headers['x-rapidapi-key'] = getRapidApiKey()
headers['x-rapidapi-host'] = RAPIDAPI_HOST
}
return headers
}
function rapidSubscriptionError(text) {
const msg = String(text || '').toLowerCase()
return msg.includes('not subscribed') || msg.includes('subscription')
}
async function postMonkeyCustom(baseUrl, useRapidApi, payload) {
const res = await fetch(`${baseUrl}/qr/custom`, {
method: 'POST',
headers: monkeyHeaders(useRapidApi),
body: JSON.stringify(payload),
})
const contentType = res.headers.get('content-type') || ''
if (!res.ok) {
const text = await res.text().catch(() => '')
return { res, text, buffer: null, contentType }
}
const buffer = Buffer.from(await res.arrayBuffer())
return { res, text: '', buffer, contentType }
}
export async function generateMonkeyQr({
data,
config,
size,
file = 'png',
publicUrl,
}) {
const payload = {
data,
config: config || defaultMonkeyConfig(publicUrl),
size: Number(size || process.env.QR_MONKEY_SIZE || 600),
download: false,
file,
}
const useRapid = ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase())
let baseUrl = getQrMonkeyBaseUrl({ preferRapidApi: useRapid })
let attempt = await postMonkeyCustom(baseUrl, baseUrl === RAPIDAPI_BASE, payload)
if (
!attempt.res.ok &&
baseUrl === RAPIDAPI_BASE &&
(attempt.res.status === 403 || attempt.res.status === 401) &&
rapidSubscriptionError(attempt.text)
) {
baseUrl = DIRECT_BASE
attempt = await postMonkeyCustom(baseUrl, false, payload)
}
if (!attempt.res.ok) {
const err = new Error(
`QR Code Monkey error (${attempt.res.status})${attempt.text ? `: ${attempt.text.slice(0, 200)}` : ''}`,
)
err.status = attempt.res.status
throw err
}
return {
buffer: attempt.buffer,
contentType: attempt.contentType || 'image/png',
file,
apiBase: baseUrl,
}
}
export async function uploadMonkeyLogo(buffer, filename, mimeType = 'image/png') {
const form = new FormData()
form.append('file', new Blob([buffer], { type: mimeType }), filename)
const headers = {}
const rapidKey = getRapidApiKey()
if (rapidKey) {
headers['x-rapidapi-key'] = rapidKey
headers['x-rapidapi-host'] = RAPIDAPI_HOST
}
const res = await fetch(`${getQrMonkeyBaseUrl()}/qr/uploadImage`, {
method: 'POST',
headers,
body: form,
})
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data?.message || `QR Code Monkey upload failed (${res.status})`)
err.status = res.status
throw err
}
return data.file
}
+284
View File
@@ -0,0 +1,284 @@
/**
* QR provider orchestration — QR Code Monkey (default) or QRCG (dynamic tracking).
*/
import crypto from 'node:crypto'
import fs from 'node:fs'
import path from 'node:path'
import { appendJsonl, readJsonl, rewriteJsonl } from './data.js'
import { QR_PRESETS, mimQrBrandSpec } from './qr-brand.js'
import { defaultMonkeyConfig, generateMonkeyQr, getQrMonkeyBaseUrl, hasRapidApiKey, isQrMonkeyConfigured } from './qr-monkey.js'
import {
createQrcode as createQrcgQrcode,
defaultMimCustomizations,
fetchQrcgAccount,
getQrcode as getQrcgQrcode,
getQrcodeScanTotals,
isQrcgConfigured,
listQrcodes as listQrcgQrcodes,
updateQrcode as updateQrcgQrcode,
} from './qrcg.js'
export { QR_PRESETS, mimQrBrandSpec, defaultMimCustomizations }
export function getQrProvider() {
const forced = String(process.env.MIM_QR_PROVIDER || '').trim().toLowerCase()
if (forced === 'qrcg' && isQrcgConfigured()) return 'qrcg'
if (forced === 'qrcode-monkey' || forced === 'monkey') return 'qrcode-monkey'
if (isQrcgConfigured() && forced !== 'qrcode-monkey' && forced !== 'monkey') {
return isQrMonkeyConfigured() ? 'qrcode-monkey' : 'qrcg'
}
return 'qrcode-monkey'
}
export function qrCodesDir(dataDir) {
const dir = path.join(dataDir, 'qr-codes')
fs.mkdirSync(dir, { recursive: true })
return dir
}
function mapLocalRow(row, publicApiBase, publicSiteBase) {
const publicPath = `/api/public/qr/${row.id}`
const imagePath = row.imageFile ? publicPath : null
const site = String(publicSiteBase || '').replace(/\/$/, '')
return {
id: row.id,
provider: row.provider || 'qrcode-monkey',
type: 'url',
title: row.title,
status: row.status || 'active',
url: row.url,
shortUrl: row.shortUrl || null,
previewUrl: imagePath ? (site ? `${site}${imagePath}` : imagePath) : null,
imageUrl: imagePath,
createdAt: row.ts,
purpose: row.purpose || null,
scans: row.scans || null,
}
}
export async function getQrStatus(publicUrl) {
const provider = getQrProvider()
const brand = mimQrBrandSpec(publicUrl)
if (provider === 'qrcode-monkey') {
const rapidKey = hasRapidApiKey()
const useRapid = ['1', 'true', 'yes'].includes(String(process.env.QR_MONKEY_USE_RAPIDAPI || '').toLowerCase())
return {
provider,
configured: true,
dynamicTracking: false,
apiBase: getQrMonkeyBaseUrl({ preferRapidApi: useRapid }),
rapidApiKeySet: rapidKey,
rapidApiSubscribed: useRapid && rapidKey,
rapidApiNote: rapidKey && !useRapid
? 'RAPIDAPI_KEY is set; using direct API until you subscribe at rapidapi.com and set QR_MONKEY_USE_RAPIDAPI=1'
: undefined,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://www.qrcode-monkey.com/qr-code-api-with-logo/',
}
}
if (!isQrcgConfigured()) {
return {
provider: 'qrcg',
configured: false,
dynamicTracking: true,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://dev.qrcg.com/',
}
}
try {
const account = await fetchQrcgAccount()
return {
provider: 'qrcg',
configured: true,
dynamicTracking: true,
account,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://dev.qrcg.com/',
}
} catch (e) {
return {
provider: 'qrcg',
configured: true,
dynamicTracking: true,
accountError: e.message,
presets: Object.keys(QR_PRESETS),
brand,
docsUrl: 'https://dev.qrcg.com/',
}
}
}
export async function listQrcodes(dataDir, { cursor, limit = 20, publicApiBase, publicSiteBase } = {}) {
const provider = getQrProvider()
const local = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 500 })
if (provider === 'qrcode-monkey') {
const rows = local
.filter((r) => !r.provider || r.provider === 'qrcode-monkey')
.slice(0, limit)
.map((r) => mapLocalRow(r, publicApiBase, publicSiteBase))
return { data: rows, pagination: { hasMore: false } }
}
const remote = await listQrcgQrcodes({ cursor, limit })
const localById = Object.fromEntries(local.map((r) => [String(r.qrcodeId || r.id), r]))
const data = (remote.data || []).map((row) => ({
id: row.id,
provider: 'qrcg',
type: row.type,
title: row.title,
status: row.status,
url: row.url,
shortUrl: row.shortUrl,
previewUrl: row.previewUrl,
createdAt: row.createdAt,
purpose: localById[String(row.id)]?.purpose || null,
scans: row.scans,
}))
return { ...remote, data }
}
export async function createQrcode(dataDir, { url, title, purpose, preset, customizations, publicUrl, publicApiBase, publicSiteBase, createdBy }) {
const provider = getQrProvider()
if (provider === 'qrcode-monkey') {
const id = `qr_${crypto.randomUUID()}`
const { buffer, contentType } = await generateMonkeyQr({
data: url,
config: customizations || defaultMonkeyConfig(publicUrl),
publicUrl,
})
const ext = contentType.includes('svg') ? 'svg' : 'png'
const imageFile = `${id}.${ext}`
fs.writeFileSync(path.join(qrCodesDir(dataDir), imageFile), buffer)
const record = {
id,
provider: 'qrcode-monkey',
qrcodeId: id,
url,
title,
purpose: purpose || null,
preset: preset || null,
imageFile,
status: 'active',
createdBy,
ts: new Date().toISOString(),
}
appendJsonl(dataDir, 'qr-codes.jsonl', record)
return mapLocalRow(record, publicApiBase, publicSiteBase || publicUrl)
}
const created = await createQrcgQrcode({
url,
title,
customizations,
})
appendJsonl(dataDir, 'qr-codes.jsonl', {
qrcodeId: created.id,
provider: 'qrcg',
purpose: purpose || null,
preset: preset || null,
url,
title,
createdBy,
ts: new Date().toISOString(),
})
return {
id: created.id,
provider: 'qrcg',
type: created.type,
title: created.title,
status: created.status,
url: created.url,
shortUrl: created.shortUrl,
previewUrl: created.previewUrl,
createdAt: created.createdAt,
scans: created.scans,
}
}
export function getLocalQrcode(dataDir, id) {
const rows = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 10000, reverse: false })
return rows.find((r) => r.id === id || r.qrcodeId === id) || null
}
export function getQrcodeImagePath(dataDir, id) {
const row = getLocalQrcode(dataDir, id)
if (!row?.imageFile) return null
const filePath = path.join(qrCodesDir(dataDir), row.imageFile)
return fs.existsSync(filePath) ? filePath : null
}
export async function getQrcode(dataDir, id, publicApiBase, publicSiteBase) {
const local = getLocalQrcode(dataDir, id)
if (local?.provider === 'qrcode-monkey' || local?.imageFile) {
return mapLocalRow(local, publicApiBase, publicSiteBase)
}
if (getQrProvider() === 'qrcg') {
const row = await getQrcgQrcode(id)
return {
id: row.id,
provider: 'qrcg',
title: row.title,
status: row.status,
url: row.url,
shortUrl: row.shortUrl,
previewUrl: row.previewUrl,
createdAt: row.createdAt,
scans: row.scans,
}
}
return null
}
export async function updateQrcode(dataDir, id, patch, publicApiBase, publicSiteBase) {
const local = getLocalQrcode(dataDir, id)
if (local?.provider === 'qrcode-monkey') {
const rows = readJsonl(dataDir, 'qr-codes.jsonl', { limit: 10000, reverse: false })
const idx = rows.findIndex((r) => r.id === id)
if (idx < 0) {
const err = new Error('QR code not found')
err.status = 404
throw err
}
if (patch.status) rows[idx].status = patch.status
if (patch.title) rows[idx].title = patch.title
if (patch.url) rows[idx].url = patch.url
rows[idx].updatedAt = new Date().toISOString()
rewriteJsonl(dataDir, 'qr-codes.jsonl', rows)
return mapLocalRow(rows[idx], publicApiBase, publicSiteBase)
}
const updated = await updateQrcgQrcode(id, patch)
return {
id: updated.id,
provider: 'qrcg',
title: updated.title,
status: updated.status,
url: updated.url,
shortUrl: updated.shortUrl,
previewUrl: updated.previewUrl,
createdAt: updated.createdAt,
scans: updated.scans,
}
}
export async function getQrcodeScans(id) {
if (getQrProvider() !== 'qrcg') {
const err = new Error('Scan analytics require QRCG dynamic QR provider')
err.status = 400
throw err
}
return getQrcodeScanTotals(id)
}
export function isAnyQrConfigured() {
return isQrMonkeyConfigured() || isQrcgConfigured()
}
+109
View File
@@ -0,0 +1,109 @@
/**
* QRCG by Bitly — Public API (preview) — dynamic QR with scan analytics
* @see https://dev.qrcg.com/
*/
import { loadBrandSpec } from './qr-brand.js'
const DEFAULT_BASE = 'https://dev.qrcg.com/v3-preview'
export function getQrcgApiKey() {
return String(process.env.QRCG_API_KEY || '').trim()
}
export function getQrcgBaseUrl() {
return String(process.env.QRCG_API_BASE_URL || DEFAULT_BASE).replace(/\/$/, '')
}
export function isQrcgConfigured() {
return Boolean(getQrcgApiKey())
}
export function getQrcgLogoId() {
const raw = String(process.env.QRCG_LOGO_ID || '').trim()
if (!raw) return null
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : null
}
/** Branded QR styling for QRCG API. */
export function defaultMimCustomizations() {
const spec = loadBrandSpec()
const customizations = structuredClone(spec.customizations)
const logoId = getQrcgLogoId()
if (logoId) customizations.logo = { id: logoId }
return customizations
}
async function parseResponse(res) {
const text = await res.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return { raw: text }
}
}
export async function qrcgRequest(method, path, body) {
const key = getQrcgApiKey()
if (!key) {
const err = new Error('QRCG API key not configured (set QRCG_API_KEY)')
err.status = 503
throw err
}
const url = `${getQrcgBaseUrl()}${path.startsWith('/') ? path : `/${path}`}`
const res = await fetch(url, {
method,
headers: {
Authorization: `Key ${key}`,
Accept: 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
},
body: body ? JSON.stringify(body) : undefined,
})
const data = await parseResponse(res)
if (!res.ok) {
const err = new Error(data?.message || data?.error || `QRCG API error (${res.status})`)
err.status = res.status
err.body = data
throw err
}
return data
}
export async function fetchQrcgAccount() {
return qrcgRequest('GET', '/user/me')
}
export async function listQrcodes({ cursor, limit = 20 } = {}) {
const params = new URLSearchParams()
if (cursor) params.set('cursor', cursor)
if (limit) params.set('limit', String(limit))
const qs = params.toString()
return qrcgRequest('GET', `/qrcodes${qs ? `?${qs}` : ''}`)
}
export async function getQrcode(id) {
return qrcgRequest('GET', `/qrcodes/${encodeURIComponent(id)}`)
}
export async function createQrcode({ url, title, status = 'active', customizations, folderId }) {
return qrcgRequest('POST', '/qrcodes', {
type: 'url',
url,
title,
status,
customizations: customizations || defaultMimCustomizations(),
...(folderId != null ? { folderId } : {}),
})
}
export async function updateQrcode(id, patch) {
return qrcgRequest('PATCH', `/qrcodes/${encodeURIComponent(id)}`, patch)
}
export async function getQrcodeScanTotals(id) {
return qrcgRequest('GET', `/qrcodes/${encodeURIComponent(id)}/scans/total`)
}
+84
View File
@@ -0,0 +1,84 @@
import { maskSecret } from './auth.js'
import { readJsonFile, writeJsonFile } from './data.js'
const SETTINGS_FILE = 'admin-settings.json'
const DEFAULT_SETTINGS = {
donationsEnabled: true,
stripePublishableKey: '',
stripeSecretKey: '',
stripeWebhookSecret: '',
updatedAt: null,
updatedBy: null,
}
function envFallback() {
return {
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.VITE_STRIPE_PUBLISHABLE_KEY || '',
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
}
}
export function loadSettings(dataDir) {
const stored = readJsonFile(dataDir, SETTINGS_FILE, {})
const env = envFallback()
return {
...DEFAULT_SETTINGS,
donationsEnabled: stored.donationsEnabled ?? DEFAULT_SETTINGS.donationsEnabled,
stripePublishableKey: stored.stripePublishableKey || env.stripePublishableKey,
stripeSecretKey: stored.stripeSecretKey || env.stripeSecretKey,
stripeWebhookSecret: stored.stripeWebhookSecret || env.stripeWebhookSecret,
updatedAt: stored.updatedAt || null,
updatedBy: stored.updatedBy || null,
}
}
export function saveSettings(dataDir, patch, updatedBy) {
const current = loadSettings(dataDir)
const next = { ...current }
if (typeof patch.donationsEnabled === 'boolean') next.donationsEnabled = patch.donationsEnabled
for (const key of ['stripePublishableKey', 'stripeSecretKey', 'stripeWebhookSecret']) {
const value = patch[key]
if (typeof value === 'string' && value.trim() && !value.includes('…')) {
next[key] = value.trim()
}
}
next.updatedAt = new Date().toISOString()
next.updatedBy = updatedBy || null
writeJsonFile(dataDir, SETTINGS_FILE, {
donationsEnabled: next.donationsEnabled,
stripePublishableKey: next.stripePublishableKey,
stripeSecretKey: next.stripeSecretKey,
stripeWebhookSecret: next.stripeWebhookSecret,
updatedAt: next.updatedAt,
updatedBy: next.updatedBy,
})
return next
}
export function publicConfig(settings) {
const stripeReady = Boolean(settings.donationsEnabled && settings.stripePublishableKey && settings.stripeSecretKey)
return {
donationsEnabled: Boolean(settings.donationsEnabled),
stripeConfigured: stripeReady,
stripePublishableKey: stripeReady ? settings.stripePublishableKey : '',
}
}
export function adminSettingsView(settings) {
return {
donationsEnabled: settings.donationsEnabled,
stripePublishableKey: settings.stripePublishableKey,
stripeSecretKey: maskSecret(settings.stripeSecretKey),
stripeWebhookSecret: maskSecret(settings.stripeWebhookSecret),
stripeConfigured: Boolean(settings.stripeSecretKey && settings.stripePublishableKey),
updatedAt: settings.updatedAt,
updatedBy: settings.updatedBy,
}
}
+45
View File
@@ -0,0 +1,45 @@
import Stripe from 'stripe'
import { loadSettings } from './settings.js'
export function getStripeClient(dataDir) {
const settings = loadSettings(dataDir)
if (!settings.stripeSecretKey) return null
return new Stripe(settings.stripeSecretKey)
}
export function isStripeLive(dataDir) {
const settings = loadSettings(dataDir)
return Boolean(settings.donationsEnabled && settings.stripeSecretKey && settings.stripePublishableKey)
}
export async function fetchStripeAccountStatus(dataDir) {
const stripe = getStripeClient(dataDir)
if (!stripe) {
return { configured: false, livemode: false, balance: null, recentCharges: [] }
}
const [balance, charges] = await Promise.all([
stripe.balance.retrieve().catch(() => null),
stripe.charges.list({ limit: 10 }).catch(() => ({ data: [] })),
])
return {
configured: true,
livemode: Boolean(charges.data[0]?.livemode),
balance: balance
? {
available: balance.available,
pending: balance.pending,
}
: null,
recentCharges: (charges.data || []).map((c) => ({
id: c.id,
amount: c.amount,
currency: c.currency,
status: c.status,
created: c.created,
receiptEmail: c.receipt_email || c.billing_details?.email || null,
description: c.description,
})),
}
}
+85
View File
@@ -0,0 +1,85 @@
import { hashPassword, verifyPassword } from './auth.js'
import { readJsonFile, writeJsonFile } from './data.js'
const USERS_FILE = 'users.json'
const DEFAULT_USERS = [
{
id: 'admin-1',
email: '[email protected]',
role: 'admin',
name: 'MIM Administrator',
permissions: ['all'],
passwordEnv: 'MIM_ADMIN_PASSWORD',
defaultPassword: 'demo123',
},
{
id: 'volunteer-1',
email: '[email protected]',
role: 'volunteer',
name: 'MIM Volunteer',
permissions: ['view_requests', 'update_assignments'],
passwordEnv: 'MIM_VOLUNTEER_PASSWORD',
defaultPassword: 'demo123',
},
{
id: 'resource-1',
email: '[email protected]',
role: 'resource',
name: 'MIM Resource Partner',
permissions: ['submit_requests', 'view_own_requests'],
passwordEnv: 'MIM_RESOURCE_PASSWORD',
defaultPassword: 'demo123',
},
]
function sanitizeUser(user) {
const { passwordHash, ...rest } = user
return rest
}
export function ensureUsers(dataDir) {
let users = readJsonFile(dataDir, USERS_FILE, null)
if (!users?.length) {
const isProd = process.env.NODE_ENV === 'production'
users = DEFAULT_USERS.map((seed) => {
const envPassword = process.env[seed.passwordEnv]
const password = envPassword || (!isProd ? seed.defaultPassword : null)
if (!password) {
console.warn(`[mim-api] No password for ${seed.email} — set ${seed.passwordEnv}`)
return null
}
return {
id: seed.id,
email: seed.email,
role: seed.role,
name: seed.name,
permissions: seed.permissions,
passwordHash: hashPassword(password),
createdAt: new Date().toISOString(),
}
}).filter(Boolean)
if (users.length) writeJsonFile(dataDir, USERS_FILE, users)
}
return users || []
}
export function findUserByEmail(dataDir, email) {
const users = ensureUsers(dataDir)
return users.find((u) => u.email.toLowerCase() === email.toLowerCase()) || null
}
export function authenticateUser(dataDir, email, password) {
const user = findUserByEmail(dataDir, email)
if (!user || !verifyPassword(password, user.passwordHash)) return null
return sanitizeUser({ ...user, lastLogin: new Date().toISOString() })
}
export function listUsers(dataDir) {
return ensureUsers(dataDir).map(sanitizeUser)
}
export function getUserById(dataDir, id) {
const user = ensureUsers(dataDir).find((u) => u.id === id)
return user ? sanitizeUser(user) : null
}
+31
View File
@@ -0,0 +1,31 @@
import { verifyToken } from '../lib/auth.js'
import { getUserById } from '../lib/users.js'
export function createAuthMiddleware({ dataDir, authSecret }) {
return function authMiddleware(req, res, next) {
const header = req.headers.authorization || ''
const token = header.startsWith('Bearer ') ? header.slice(7) : null
if (!token) {
return res.status(401).json({ error: 'Authentication required' })
}
const payload = verifyToken(token, authSecret)
if (!payload?.sub) {
return res.status(401).json({ error: 'Invalid or expired session' })
}
const user = getUserById(dataDir, payload.sub)
if (!user) {
return res.status(401).json({ error: 'User not found' })
}
req.user = user
next()
}
}
export function requireRole(...roles) {
return (req, res, next) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' })
}
next()
}
}
+12
View File
@@ -0,0 +1,12 @@
export function createCorsMiddleware(corsOrigins) {
return function cors(req, res, next) {
const origin = req.headers.origin
if (origin && (corsOrigins.includes(origin) || corsOrigins.includes('*'))) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,PUT,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Stripe-Signature')
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
}
}
+429
View File
@@ -0,0 +1,429 @@
import express from 'express'
import { readJsonl, appendJsonl, rewriteJsonl } from '../lib/data.js'
import { adminSettingsView, loadSettings, publicConfig, saveSettings } from '../lib/settings.js'
import { fetchStripeAccountStatus, getStripeClient, isStripeLive } from '../lib/stripe-service.js'
import { listUsers } from '../lib/users.js'
import { createAuthMiddleware, requireRole } from '../middleware/auth.js'
function priorityLabel(requestType) {
const urgent = ['emergency', 'crisis', 'urgent']
const type = String(requestType || '').toLowerCase()
if (urgent.some((u) => type.includes(u))) return 'High'
if (type.includes('clothing') || type.includes('food')) return 'Medium'
return 'Low'
}
function timeAgo(iso) {
if (!iso) return 'unknown'
const diff = Date.now() - new Date(iso).getTime()
const hours = Math.floor(diff / 3600000)
if (hours < 1) return 'just now'
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
const days = Math.floor(hours / 24)
return `${days} day${days === 1 ? '' : 's'} ago`
}
export function createAdminRoutes({ dataDir, authSecret }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
router.use(auth)
router.use(requireRole('admin'))
router.get('/dashboard', (_req, res) => {
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 })
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 500 })
const assignments = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 500 })
const pending = assistance.filter((r) => !r.status || r.status === 'pending')
const today = new Date().toISOString().slice(0, 10)
const deliveriesToday = assignments.filter((a) => a.scheduledDate?.startsWith(today)).length
const monthlyDonations = donations.reduce((sum, d) => sum + (d.amountTotal || 0), 0)
res.json({
pendingRequests: pending.length,
activeVolunteers: assignments.filter((a) => a.status === 'active').length || listUsers(dataDir).filter((u) => u.role === 'volunteer').length,
deliveriesToday,
monthlyBudget: 15000,
monthlySpent: Math.round(monthlyDonations / 100),
monthlyDonationCents: monthlyDonations,
donationCount: donations.length,
})
})
router.get('/assistance-requests', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200)
const status = req.query.status
let rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 })
if (status) rows = rows.filter((r) => (r.status || 'pending') === status)
res.json({
data: rows.slice(0, limit).map((r) => ({
id: r.id,
requestType: r.requestType,
student: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(),
school: r.studentInfo?.school,
need: r.details?.slice(0, 120) || r.requestType,
priority: priorityLabel(r.requestType),
status: r.status || 'pending',
contactName: r.contactInfo?.parentName,
contactEmail: r.contactInfo?.email,
contactPhone: r.contactInfo?.phone,
timeAgo: timeAgo(r.ts),
ts: r.ts,
})),
total: rows.length,
})
})
router.patch('/assistance-requests/:id', express.json({ limit: '32kb' }), (req, res) => {
const { id } = req.params
const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 10000, reverse: false })
const idx = rows.findIndex((r) => r.id === id)
if (idx < 0) return res.status(404).json({ error: 'Request not found' })
const patch = req.body || {}
rows[idx] = {
...rows[idx],
status: patch.status || rows[idx].status || 'pending',
assignedTo: patch.assignedTo ?? rows[idx].assignedTo,
adminNotes: patch.adminNotes ?? rows[idx].adminNotes,
updatedAt: new Date().toISOString(),
updatedBy: req.user.email,
}
rewriteJsonl(dataDir, 'assistance-requests.jsonl', rows)
res.json({ ok: true, request: rows[idx] })
})
router.get('/donations', (req, res) => {
const limit = Math.min(Number(req.query.limit) || 50, 200)
const rows = readJsonl(dataDir, 'donations.jsonl', { limit })
res.json({
data: rows.map((d) => ({
id: d.sessionId || d.id,
amountCents: d.amountTotal || 0,
amountUsd: ((d.amountTotal || 0) / 100).toFixed(2),
email: d.customerEmail || d.metadata?.donorEmail,
donorName: d.metadata?.donorName,
anonymous: d.metadata?.anonymous === 'true',
ts: d.ts,
timeAgo: timeAgo(d.ts),
})),
total: rows.length,
})
})
router.get('/analytics/summary', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 1000 })
const events = readJsonl(dataDir, 'events.jsonl', { limit: 5000 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 1000 })
const donationAmount = donations.reduce((s, d) => s + (d.amountTotal || 0), 0) / 100
const donationEvents = events.filter((e) => String(e.event || e.eventName || '').includes('donation'))
const pageViews = aggregatePageViews(events)
res.json({
donationMetrics: {
amount: Math.round(donationAmount),
count: donations.length,
recurring: donations.filter((d) => d.metadata?.recurring === 'true').length,
},
pageViews,
userEngagement: {
sessions: events.filter((e) => e.event === 'page_view' || e.eventName === 'page_view').length || events.length,
avgDuration: 185,
bounceRate: 0.34,
},
conversionRates: {
donation: donations.length / Math.max(pageViews.find((p) => p.page === 'Donate')?.views || 1, 1),
volunteer: assistance.length / Math.max(events.length, 1),
contact: readJsonl(dataDir, 'contact.jsonl', { limit: 100 }).length / Math.max(events.length, 1),
},
familiesHelped: assistance.filter((a) => a.status === 'completed').length,
activeVolunteers: listUsers(dataDir).filter((u) => u.role === 'volunteer').length,
})
})
router.get('/analytics/activity', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 15 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 15 })
const events = readJsonl(dataDir, 'events.jsonl', { limit: 15 })
const feed = [
...donations.map((d) => ({
type: 'donation',
title: `Donation received — $${((d.amountTotal || 0) / 100).toFixed(2)}`,
detail: d.customerEmail || 'Anonymous donor',
ts: d.ts,
timeAgo: timeAgo(d.ts),
})),
...assistance.map((a) => ({
type: 'assistance',
title: `Assistance request — ${a.requestType}`,
detail: `${a.studentInfo?.firstName || ''} ${a.studentInfo?.lastName || ''}`.trim(),
ts: a.ts,
timeAgo: timeAgo(a.ts),
})),
...events.map((e) => ({
type: 'event',
title: String(e.event || e.eventName || 'Site event'),
detail: e.page || e.path || '',
ts: e.ts,
timeAgo: timeAgo(e.ts),
})),
]
.sort((a, b) => new Date(b.ts).getTime() - new Date(a.ts).getTime())
.slice(0, 30)
res.json({ feed })
})
router.get('/analytics/advanced', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 2000 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 2000 })
const monthly = buildMonthlySeries(donations, assistance)
res.json({
impactMetrics: monthly,
predictions: {
nextMonthDonations: monthly.length ? Math.round(monthly[monthly.length - 1].donations * 1.08) : 0,
studentsServedTrend: monthly.length ? monthly[monthly.length - 1].studentsServed : 0,
},
geographic: aggregateSchools(assistance),
})
})
router.get('/feeds/recent', (_req, res) => {
const donations = readJsonl(dataDir, 'donations.jsonl', { limit: 20 })
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 20 })
res.json({
donations: donations.map((d) => ({
id: d.sessionId,
amountUsd: ((d.amountTotal || 0) / 100).toFixed(2),
email: d.customerEmail,
ts: d.ts,
timeAgo: timeAgo(d.ts),
})),
assistance: assistance.map((a) => ({
id: a.id,
type: a.requestType,
student: `${a.studentInfo?.firstName || ''} ${a.studentInfo?.lastName || ''}`.trim(),
school: a.studentInfo?.school,
status: a.status || 'pending',
ts: a.ts,
timeAgo: timeAgo(a.ts),
})),
generatedAt: new Date().toISOString(),
})
})
router.get('/settings', (_req, res) => {
res.json(adminSettingsView(loadSettings(dataDir)))
})
router.patch('/settings', express.json({ limit: '32kb' }), (req, res) => {
const next = saveSettings(dataDir, req.body || {}, req.user.email)
res.json(adminSettingsView(next))
})
router.get('/stripe/status', async (_req, res) => {
try {
const status = await fetchStripeAccountStatus(dataDir)
const settings = loadSettings(dataDir)
res.json({
...status,
donationsEnabled: settings.donationsEnabled,
publishableKeySet: Boolean(settings.stripePublishableKey),
})
} catch (e) {
res.status(502).json({ error: e.message || 'Stripe status unavailable' })
}
})
router.get('/volunteers/assignments', (_req, res) => {
const rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 100 })
if (!rows.length) {
return res.json({ data: seedAssignmentsFromAssistance(dataDir), seeded: true })
}
res.json({ data: rows })
})
router.patch('/volunteers/assignments/:id', express.json({ limit: '16kb' }), (req, res) => {
const rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 10000, reverse: false })
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return res.status(404).json({ error: 'Assignment not found' })
rows[idx] = { ...rows[idx], ...req.body, updatedAt: new Date().toISOString() }
rewriteJsonl(dataDir, 'volunteer-assignments.jsonl', rows)
res.json({ ok: true, assignment: rows[idx] })
})
router.get('/partners/requests', (_req, res) => {
const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 100 })
res.json({
data: rows.map((r) => ({
id: r.id,
partner: r.studentInfo?.school || 'Partner org',
student: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(),
status: r.status || 'pending',
submitted: timeAgo(r.ts),
type: r.requestType,
})),
})
})
router.get('/training/modules', (_req, res) => {
res.json({
modules: [
{ id: 'onboarding', title: 'Volunteer Onboarding', progress: 100, durationMin: 45 },
{ id: 'safety', title: 'Safety & Confidentiality', progress: 85, durationMin: 30 },
{ id: 'assistance', title: 'Assistance Intake Workflow', progress: 60, durationMin: 25 },
{ id: 'donor-care', title: 'Donor Stewardship Basics', progress: 40, durationMin: 20 },
],
})
})
return router
}
function aggregatePageViews(events) {
const counts = {}
for (const e of events) {
const page = e.page || e.path || e.properties?.page
if (!page) continue
const label = String(page).replace(/^\//, '').split('/')[0] || 'Home'
const key = label.charAt(0).toUpperCase() + label.slice(1)
counts[key] = (counts[key] || 0) + 1
}
if (!Object.keys(counts).length) {
return [
{ page: 'Home', views: 0, trend: 0 },
{ page: 'Donate', views: 0, trend: 0 },
]
}
return Object.entries(counts).map(([page, views]) => ({ page, views, trend: 0 }))
}
function buildMonthlySeries(donations, assistance) {
const buckets = {}
for (const d of donations) {
const month = (d.ts || '').slice(0, 7)
if (!month) continue
buckets[month] = buckets[month] || { month, donations: 0, studentsServed: 0, resourcesAllocated: 0, efficiency: 0.85 }
buckets[month].donations += (d.amountTotal || 0) / 100
}
for (const a of assistance) {
const month = (a.ts || '').slice(0, 7)
if (!month) continue
buckets[month] = buckets[month] || { month, donations: 0, studentsServed: 0, resourcesAllocated: 0, efficiency: 0.85 }
buckets[month].studentsServed += 1
buckets[month].resourcesAllocated += 1
}
return Object.values(buckets)
.sort((a, b) => a.month.localeCompare(b.month))
.slice(-6)
.map((b) => ({ ...b, donations: Math.round(b.donations), efficiency: 0.85 + Math.min(b.studentsServed / 400, 0.1) }))
}
function aggregateSchools(assistance) {
const schools = {}
for (const a of assistance) {
const school = a.studentInfo?.school || 'Unknown'
schools[school] = (schools[school] || 0) + 1
}
return Object.entries(schools)
.map(([region, count]) => ({ region, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 8)
}
function seedAssignmentsFromAssistance(dataDir) {
const assistance = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 5 })
return assistance.map((a, i) => ({
id: `asgn_${a.id || i}`,
student: `${a.studentInfo?.firstName || 'Student'} ${a.studentInfo?.lastName || ''}`.trim(),
items: a.requestType || 'Essentials kit',
school: a.studentInfo?.school || 'Los Angeles County',
deadline: i === 0 ? 'Tomorrow' : i === 1 ? 'Friday' : 'Next week',
status: 'pending',
scheduledDate: new Date().toISOString().slice(0, 10),
assistanceRequestId: a.id,
}))
}
export function createVolunteerRoutes({ dataDir, authSecret }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
router.use(auth)
router.use(requireRole('volunteer', 'admin'))
router.get('/assignments', (_req, res) => {
let rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 100 })
if (!rows.length) rows = seedAssignmentsFromAssistance(dataDir)
res.json({ data: rows })
})
router.patch('/assignments/:id', express.json({ limit: '16kb' }), (req, res) => {
let rows = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 10000, reverse: false })
if (!rows.length) rows = seedAssignmentsFromAssistance(dataDir)
const idx = rows.findIndex((r) => r.id === req.params.id)
if (idx < 0) return res.status(404).json({ error: 'Assignment not found' })
rows[idx] = { ...rows[idx], status: req.body?.status || rows[idx].status, updatedAt: new Date().toISOString() }
rewriteJsonl(dataDir, 'volunteer-assignments.jsonl', rows)
res.json({ ok: true, assignment: rows[idx] })
})
router.get('/schedule', (_req, res) => {
const assignments = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 50 })
const today = new Date()
res.json({
date: today.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' }),
tasks: (assignments.length ? assignments : seedAssignmentsFromAssistance(dataDir)).slice(0, 5).map((a, i) => ({
id: a.id,
time: ['9:00 AM', '1:00 PM', '3:30 PM'][i % 3],
task: `Delivery — ${a.items || a.student}`,
location: a.school || 'Los Angeles County',
students: a.student ? 1 : null,
status: a.status || 'pending',
})),
})
})
router.get('/stats', (_req, res) => {
const completed = readJsonl(dataDir, 'volunteer-assignments.jsonl', { limit: 500 }).filter((a) => a.status === 'completed')
res.json({
familiesHelped: completed.length || readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 500 }).filter((a) => a.status === 'completed').length,
kitsAssembled: completed.length,
deliveries: completed.length,
hoursVolunteered: completed.length * 2,
})
})
return router
}
export function createResourceRoutes({ dataDir, authSecret }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
router.use(auth)
router.use(requireRole('resource', 'admin'))
router.get('/requests', (_req, res) => {
const rows = readJsonl(dataDir, 'assistance-requests.jsonl', { limit: 100 })
res.json({
data: rows.map((r) => ({
id: r.id,
title: `${r.studentInfo?.firstName || ''} ${r.studentInfo?.lastName || ''}`.trim(),
type: r.requestType,
status: r.status || 'pending',
submitted: timeAgo(r.ts),
school: r.studentInfo?.school,
})),
summary: {
pending: rows.filter((r) => !r.status || r.status === 'pending').length,
approved: rows.filter((r) => r.status === 'approved').length,
completed: rows.filter((r) => r.status === 'completed').length,
},
})
})
return router
}
export { isStripeLive, getStripeClient, publicConfig }
+34
View File
@@ -0,0 +1,34 @@
import express from 'express'
import { signToken, verifyToken } from '../lib/auth.js'
import { authenticateUser, getUserById } from '../lib/users.js'
export function createAuthRoutes({ dataDir, authSecret }) {
const router = express.Router()
router.post('/login', express.json({ limit: '16kb' }), (req, res) => {
const email = req.body?.email?.trim()
const password = req.body?.password
if (!email || !password) {
return res.status(400).json({ error: 'email and password required' })
}
const user = authenticateUser(dataDir, email, password)
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' })
}
const token = signToken({ sub: user.id, role: user.role, email: user.email }, authSecret)
res.json({ token, user })
})
router.get('/me', (req, res) => {
const header = req.headers.authorization || ''
const token = header.startsWith('Bearer ') ? header.slice(7) : null
if (!token) return res.status(401).json({ error: 'Authentication required' })
const payload = verifyToken(token, authSecret)
if (!payload?.sub) return res.status(401).json({ error: 'Invalid or expired session' })
const user = getUserById(dataDir, payload.sub)
if (!user) return res.status(401).json({ error: 'User not found' })
res.json({ user })
})
return router
}
+126
View File
@@ -0,0 +1,126 @@
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import multer from 'multer'
import {
brandDir,
loadBrandManifest,
publicBrandManifest,
rebuildBrandZip,
safeBrandFilename,
saveBrandManifest,
} from '../lib/brand.js'
import { createAuthMiddleware, requireRole } from '../middleware/auth.js'
export function createBrandPublicRoutes({ dataDir }) {
const router = express.Router()
const filesDir = brandDir(dataDir)
fs.mkdirSync(filesDir, { recursive: true })
router.get('/brand', (_req, res) => {
res.json(publicBrandManifest(loadBrandManifest(dataDir)))
})
router.use('/brand/files', express.static(filesDir, {
maxAge: '1h',
setHeaders(res, filePath) {
if (filePath.endsWith('.svg')) res.setHeader('Content-Type', 'image/svg+xml')
},
}))
return router
}
export function createBrandAdminRoutes({ dataDir, authSecret }) {
const router = express.Router()
const filesDir = brandDir(dataDir)
fs.mkdirSync(filesDir, { recursive: true })
const auth = createAuthMiddleware({ dataDir, authSecret })
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, filesDir),
filename: (_req, file, cb) => cb(null, safeBrandFilename(file.originalname)),
})
const upload = multer({
storage,
limits: { fileSize: 25 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const ok = /\.(png|jpe?g|webp|svg|ico|zip|pdf|md)$/i.test(file.originalname)
cb(ok ? null : new Error('File type not allowed'), ok)
},
})
router.use(auth)
router.use(requireRole('admin'))
router.get('/brand', (_req, res) => {
const manifest = loadBrandManifest(dataDir)
res.json({
manifest,
files: fs.existsSync(filesDir)
? fs.readdirSync(filesDir).map((name) => {
const stat = fs.statSync(path.join(filesDir, name))
return { name, size: stat.size, mtime: stat.mtime.toISOString() }
})
: [],
brandDir: filesDir,
})
})
router.put('/brand', express.json({ limit: '512kb' }), (req, res) => {
const next = saveBrandManifest(dataDir, req.body)
res.json({ ok: true, manifest: next })
})
router.patch('/brand', express.json({ limit: '128kb' }), (req, res) => {
const current = loadBrandManifest(dataDir)
const patch = req.body || {}
const next = saveBrandManifest(dataDir, {
...current,
...patch,
colors: patch.colors ?? current.colors,
typography: patch.typography ?? current.typography,
groups: patch.groups ?? current.groups,
kits: patch.kits ?? current.kits,
})
res.json({ ok: true, manifest: next })
})
router.post('/brand/upload', (req, res) => {
upload.single('file')(req, res, (err) => {
if (err) return res.status(400).json({ error: err.message || 'Upload failed' })
if (!req.file) return res.status(400).json({ error: 'No file uploaded' })
const publicPath = `/api/public/brand/files/${req.file.filename}`
const legacyPath = `/brand/${req.file.filename}`
res.status(201).json({
ok: true,
filename: req.file.filename,
size: req.file.size,
path: publicPath,
legacyPath,
url: publicPath,
})
})
})
router.delete('/brand/files/:filename', (req, res) => {
const filename = safeBrandFilename(req.params.filename)
const filePath = path.join(filesDir, filename)
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
fs.unlinkSync(filePath)
res.json({ ok: true })
})
router.post('/brand/rebuild-zip', async (_req, res) => {
const result = await rebuildBrandZip(dataDir)
if (!result.ok) return res.status(500).json(result)
const manifest = loadBrandManifest(dataDir)
const kits = manifest.kits?.length
? manifest.kits.map((k) => (k.id === 'full' ? { ...k, path: result.path } : k))
: [{ id: 'full', title: 'Complete brand kit (ZIP)', path: result.path, format: 'ZIP', description: 'All logos and guidelines' }]
saveBrandManifest(dataDir, { ...manifest, kits })
res.json(result)
})
return router
}
+16
View File
@@ -0,0 +1,16 @@
import express from 'express'
import { getQrcodeImagePath } from '../lib/qr-provider.js'
/** Public serve for generated QR PNGs (opaque UUID ids). */
export function createQrPublicRoutes({ dataDir }) {
const router = express.Router()
router.get('/qr/:id', (req, res) => {
const filePath = getQrcodeImagePath(dataDir, req.params.id)
if (!filePath) return res.status(404).json({ error: 'QR image not found' })
res.setHeader('Cache-Control', 'public, max-age=3600')
res.sendFile(filePath)
})
return router
}
+164
View File
@@ -0,0 +1,164 @@
import express from 'express'
import {
QR_PRESETS,
createQrcode,
defaultMimCustomizations,
getLocalQrcode,
getQrcode,
getQrcodeImagePath,
getQrcodeScans,
getQrStatus,
listQrcodes,
mimQrBrandSpec,
updateQrcode,
} from '../lib/qr-provider.js'
import { createAuthMiddleware, requireRole } from '../middleware/auth.js'
export function createQrAdminRoutes({ dataDir, authSecret, publicUrl, publicApiBase }) {
const router = express.Router()
const auth = createAuthMiddleware({ dataDir, authSecret })
const siteBase = String(publicUrl || process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
const apiBase = publicApiBase || ''
const listOpts = { publicApiBase: apiBase, publicSiteBase: siteBase }
router.use(auth)
router.use(requireRole('admin'))
router.get('/qrcodes/status', async (_req, res) => {
try {
res.json(await getQrStatus(siteBase))
} catch (e) {
res.status(502).json({ error: e.message || 'Failed to load QR status' })
}
})
router.get('/qrcodes/presets', (_req, res) => {
const presets = Object.fromEntries(
Object.entries(QR_PRESETS).map(([key, p]) => [
key,
{ ...p, url: p.url.replace('https://mim4u.org', siteBase) },
]),
)
res.json({
presets,
brand: mimQrBrandSpec(siteBase),
defaultCustomizations: defaultMimCustomizations(),
})
})
router.get('/qrcodes', async (req, res) => {
try {
const result = await listQrcodes(dataDir, {
cursor: req.query.cursor,
limit: Math.min(Number(req.query.limit) || 20, 50),
...listOpts,
})
res.json(result)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to list QR codes' })
}
})
router.post('/qrcodes', express.json({ limit: '32kb' }), async (req, res) => {
const url = String(req.body?.url || '').trim()
const title = String(req.body?.title || 'MIM4U QR Code').trim().slice(0, 150)
if (!url || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: 'url must be a valid http(s) URL' })
}
try {
const created = await createQrcode(dataDir, {
url,
title,
purpose: req.body?.purpose || null,
customizations: req.body?.customizations,
publicUrl: siteBase,
...listOpts,
createdBy: req.user?.email,
})
res.status(201).json(created)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to create QR code', details: e.body })
}
})
router.post('/qrcodes/presets/:preset', express.json({ limit: '8kb' }), async (req, res) => {
const preset = QR_PRESETS[req.params.preset]
if (!preset) return res.status(404).json({ error: 'Unknown preset' })
const url = String(req.body?.url || preset.url).replace('https://mim4u.org', siteBase)
const title = String(req.body?.title || preset.title).trim().slice(0, 150)
try {
const created = await createQrcode(dataDir, {
url,
title,
purpose: preset.purpose,
preset: req.params.preset,
customizations: req.body?.customizations,
publicUrl: siteBase,
...listOpts,
createdBy: req.user?.email,
})
res.status(201).json(created)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to create preset QR code', details: e.body })
}
})
router.get('/qrcodes/:id/image', (req, res) => {
const filePath = getQrcodeImagePath(dataDir, req.params.id)
if (!filePath) return res.status(404).json({ error: 'QR image not found' })
res.sendFile(filePath)
})
router.get('/qrcodes/:id', async (req, res) => {
try {
const row = await getQrcode(dataDir, req.params.id, apiBase, siteBase)
if (!row) return res.status(404).json({ error: 'QR code not found' })
res.json(row)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to fetch QR code' })
}
})
router.patch('/qrcodes/:id', express.json({ limit: '32kb' }), async (req, res) => {
const patch = {}
if (req.body?.status === 'active' || req.body?.status === 'paused') patch.status = req.body.status
if (req.body?.url) patch.url = req.body.url
if (req.body?.title) patch.title = String(req.body.title).slice(0, 150)
if (req.body?.customizations) patch.customizations = req.body.customizations
if (!Object.keys(patch).length) {
return res.status(400).json({ error: 'No valid fields to update' })
}
const local = getLocalQrcode(dataDir, req.params.id)
if (local?.provider === 'qrcode-monkey' && (patch.url || patch.customizations)) {
return res.status(400).json({
error: 'QR Code Monkey codes are static images — create a new code to change URL or design',
})
}
try {
const updated = await updateQrcode(dataDir, req.params.id, patch, apiBase, siteBase)
res.json(updated)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to update QR code' })
}
})
router.get('/qrcodes/:id/scans', async (req, res) => {
try {
const totals = await getQrcodeScans(req.params.id)
res.json(totals)
} catch (e) {
res.status(e.status || 502).json({ error: e.message || 'Failed to fetch scan totals' })
}
})
return router
}
/** @deprecated use createQrAdminRoutes */
export const createQrcgAdminRoutes = createQrAdminRoutes
+3 -284
View File
@@ -1,288 +1,7 @@
/**
* MIM4U API — VMID 7811
* Routes: health, events, Stripe checkout/intent/webhook, assistance, contact, donations log
*/
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import nodemailer from 'nodemailer'
import Stripe from 'stripe'
import { createApp } from './app.js'
const PORT = Number(process.env.PORT || 3001)
const DATA_DIR = process.env.MIM_DATA_DIR || path.join(process.cwd(), 'data')
const PUBLIC_URL = (process.env.MIM_PUBLIC_URL || 'https://mim4u.org').replace(/\/$/, '')
const NOTIFY_EMAIL = process.env.MIM_NOTIFY_EMAIL || '[email protected]'
const CORS_ORIGINS = (process.env.MIM_CORS_ORIGINS || 'https://mim4u.org,https://www.mim4u.org')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const stripeKey = process.env.STRIPE_SECRET_KEY
const stripe = stripeKey ? new Stripe(stripeKey) : null
fs.mkdirSync(DATA_DIR, { recursive: true })
function appendJsonl(file, record) {
const line = `${JSON.stringify({ ...record, ts: new Date().toISOString() })}\n`
fs.appendFileSync(path.join(DATA_DIR, file), line, 'utf8')
}
function mailTransport() {
const { SMTP_HOST, SMTP_USER, SMTP_PASS, SMTP_PORT } = process.env
if (!SMTP_HOST) return null
return nodemailer.createTransport({
host: SMTP_HOST,
port: Number(SMTP_PORT || 587),
secure: Number(SMTP_PORT) === 465,
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
})
}
async function notify(subject, text, { to } = {}) {
appendJsonl('notifications.jsonl', { subject, text, to })
const transport = mailTransport()
if (!transport) {
console.log(`[notify] ${subject}: ${text.slice(0, 200)}`)
return
}
await transport.sendMail({
from: NOTIFY_EMAIL,
to: to || NOTIFY_EMAIL,
subject,
text,
})
}
function cors(req, res, next) {
const origin = req.headers.origin
if (origin && (CORS_ORIGINS.includes(origin) || CORS_ORIGINS.includes('*'))) {
res.setHeader('Access-Control-Allow-Origin', origin)
}
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Stripe-Signature')
if (req.method === 'OPTIONS') return res.sendStatus(204)
next()
}
function requireStripe(_req, res, next) {
if (!stripe) {
return res.status(503).json({ error: 'Stripe not configured on API host (STRIPE_SECRET_KEY)' })
}
next()
}
function validateAssistance(body) {
const errors = []
if (!body?.requestType) errors.push('requestType required')
if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required')
if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required')
if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required')
if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required')
if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required')
if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required')
if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required')
if (!body?.details?.trim()) errors.push('details required')
if (body?.website) errors.push('spam detected')
return errors
}
const app = express()
app.disable('x-powered-by')
app.use(cors)
app.get('/health', (_req, res) => {
res.type('text/plain').send('ok')
})
app.get('/api/health', (_req, res) => {
res.json({
ok: true,
service: 'mim-api',
stripe: Boolean(stripe),
dataDir: DATA_DIR,
version: '1.0.0',
})
})
app.use('/api/events', express.json({ limit: '32kb' }))
app.post('/api/events', (req, res) => {
appendJsonl('events.jsonl', req.body || {})
res.status(204).end()
})
app.use('/api/assistance-requests', express.json({ limit: '128kb' }))
const postAssistanceRequest = async (req, res) => {
const errors = validateAssistance(req.body)
if (errors.length) return res.status(400).json({ error: 'Validation failed', fields: errors })
const id = `asst_${crypto.randomUUID()}`
const record = { id, ...req.body, ip: req.ip, userAgent: req.headers['user-agent'] }
appendJsonl('assistance-requests.jsonl', record)
try {
await notify(
`[MIM4U] Assistance request ${id}`,
`New assistance request (${req.body.requestType})\nContact: ${req.body.contactInfo.parentName} <${req.body.contactInfo.email}>\nPhone: ${req.body.contactInfo.phone}\nDetails: ${req.body.details?.slice(0, 500)}`,
)
} catch (e) {
console.error('assistance notify failed', e)
}
res.status(201).json({ ok: true, id, message: 'Request received. We will contact you within 2448 hours.' })
}
app.post('/api/assistance-requests', postAssistanceRequest)
app.post('/api/assistance', postAssistanceRequest)
app.use('/api/contact', express.json({ limit: '64kb' }))
app.post('/api/contact', async (req, res) => {
const { type, name, email, message, website, ...rest } = req.body || {}
if (website) return res.status(400).json({ error: 'Invalid submission' })
if (!type) return res.status(400).json({ error: 'type required' })
if (!email?.trim() && type !== 'story') return res.status(400).json({ error: 'email required' })
if (type === 'story' && !message?.trim() && !rest.story?.trim()) {
return res.status(400).json({ error: 'story text required' })
}
const id = `contact_${crypto.randomUUID()}`
appendJsonl('contact.jsonl', { id, type, name, email, message, ...rest })
try {
await notify(`[MIM4U] ${type} form ${id}`, JSON.stringify({ name, email, message, ...rest }, null, 2))
} catch (e) {
console.error('contact notify failed', e)
}
res.status(201).json({ ok: true, id, message: 'Thank you — we will be in touch soon.' })
})
app.use('/api/donations', express.json({ limit: '32kb' }))
app.post('/api/donations', (req, res) => {
appendJsonl('donations-offline.jsonl', req.body || {})
res.status(201).json({ ok: true })
})
app.use('/api/create-payment-intent', express.json({ limit: '32kb' }))
app.post('/api/create-payment-intent', requireStripe, async (req, res) => {
const amount = Number(req.body?.amount)
if (!Number.isFinite(amount) || amount < 100) {
return res.status(400).json({ error: 'amount must be at least 100 cents' })
}
try {
const intent = await stripe.paymentIntents.create({
amount: Math.round(amount),
currency: 'usd',
metadata: {
recurring: String(Boolean(req.body?.recurring)),
donorEmail: req.body?.customer?.email || '',
donorName: req.body?.customer?.name || '',
},
receipt_email: req.body?.customer?.email || undefined,
})
res.json({ client_secret: intent.client_secret })
} catch (e) {
console.error('payment intent error', e)
res.status(502).json({ error: e.message || 'Stripe error' })
}
})
app.use('/api/create-checkout-session', express.json({ limit: '32kb' }))
app.post('/api/create-checkout-session', requireStripe, async (req, res) => {
const amountUsd = Number(req.body?.amount)
if (!Number.isFinite(amountUsd) || amountUsd < 1) {
return res.status(400).json({ error: 'amount must be at least $1' })
}
const cents = Math.round(amountUsd * 100)
const recurring = Boolean(req.body?.recurring)
const email = req.body?.email?.trim()
const name = req.body?.name?.trim()
try {
const session = await stripe.checkout.sessions.create({
mode: recurring ? 'subscription' : 'payment',
success_url: `${PUBLIC_URL}/donate?status=success&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${PUBLIC_URL}/donate?status=cancelled`,
customer_email: email || undefined,
line_items: [
recurring
? {
price_data: {
currency: 'usd',
product_data: { name: 'Monthly donation — Miracles in Motion Foundation' },
unit_amount: cents,
recurring: { interval: 'month' },
},
quantity: 1,
}
: {
price_data: {
currency: 'usd',
product_data: { name: 'Donation — Miracles in Motion Foundation' },
unit_amount: cents,
},
quantity: 1,
},
],
metadata: {
donorName: name || '',
anonymous: String(Boolean(req.body?.anonymous)),
},
})
res.json({ url: session.url, sessionId: session.id })
} catch (e) {
console.error('checkout session error', e)
res.status(502).json({ error: e.message || 'Stripe error' })
}
})
app.post(
'/api/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
if (!stripe) return res.status(503).send('Stripe not configured')
const sig = req.headers['stripe-signature']
const secret = process.env.STRIPE_WEBHOOK_SECRET
let event
try {
event = secret
? stripe.webhooks.constructEvent(req.body, sig, secret)
: JSON.parse(req.body.toString())
} catch (e) {
console.error('webhook verify failed', e.message)
return res.status(400).send(`Webhook Error: ${e.message}`)
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object
appendJsonl('donations.jsonl', {
sessionId: session.id,
amountTotal: session.amount_total,
customerEmail: session.customer_details?.email,
metadata: session.metadata,
})
const donorEmail = session.customer_details?.email
const amountUsd = ((session.amount_total || 0) / 100).toFixed(2)
try {
await notify(
`[MIM4U] Donation received ${session.id}`,
`Amount: ${amountUsd} USD\nEmail: ${donorEmail || 'n/a'}`,
)
if (donorEmail) {
await notify(
'Thank you for your gift — Miracles in Motion Foundation',
`Dear friend,\n\nThank you for your generous donation of $${amountUsd} to Miracles in Motion Foundation.\n\nYour gift supports outreach, emergency assistance, and compassionate care in Los Angeles County.\n\nEIN ${process.env.MIM_EIN || '33-4887159'}\nhttps://mim4u.org\n\nWith gratitude,\nMiracles in Motion Foundation`,
{ to: donorEmail },
)
}
} catch (e) {
console.error('donation notify failed', e)
}
}
res.json({ received: true })
},
)
app.use((_req, res) => res.status(404).json({ error: 'Not found' }))
const { app, PORT, DATA_DIR } = createApp()
app.listen(PORT, '0.0.0.0', () => {
console.log(`mim-api listening on :${PORT} (stripe=${Boolean(stripe)}) data=${DATA_DIR}`)
console.log(`mim-api listening on :${PORT} data=${DATA_DIR}`)
})
+37
View File
@@ -0,0 +1,37 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { defaultMimCustomizations } from '../src/lib/qrcg.js'
import { defaultMonkeyConfig } from '../src/lib/qr-monkey.js'
import { mimQrBrandSpec } from '../src/lib/qr-brand.js'
import { getQrProvider } from '../src/lib/qr-provider.js'
describe('qr brand customizations', () => {
it('qrcg defaults use forest green / gold diamond styling', () => {
const c = defaultMimCustomizations()
assert.equal(c.background.color, '#1a3c34')
assert.equal(c.pattern.color, '#c5a059')
assert.equal(c.pattern.shape, 'diamond')
})
it('qrcode monkey defaults match approved reference', () => {
const c = defaultMonkeyConfig('https://mim4u.org')
assert.equal(c.bgColor, '#1a3c34')
assert.equal(c.bodyColor, '#c5a059')
assert.equal(c.body, 'diamond')
assert.equal(c.eye, 'frame14')
assert.match(c.logo, /logo-square/)
})
it('exposes brand spec with reference image path', () => {
const spec = mimQrBrandSpec('https://mim4u.org')
assert.equal(spec.referenceImage, '/brand/qr-code-branded-reference.png')
assert.equal(spec.colors.background, '#1a3c34')
})
it('defaults to qrcode-monkey provider', () => {
const prev = process.env.MIM_QR_PROVIDER
delete process.env.MIM_QR_PROVIDER
assert.equal(getQrProvider(), 'qrcode-monkey')
if (prev) process.env.MIM_QR_PROVIDER = prev
})
})
+114 -16
View File
@@ -1,21 +1,9 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
/** Inline validation mirror — keep in sync with server.js validateAssistance */
function validateAssistance(body) {
const errors = []
if (!body?.requestType) errors.push('requestType required')
if (!body?.studentInfo?.firstName?.trim()) errors.push('studentInfo.firstName required')
if (!body?.studentInfo?.lastName?.trim()) errors.push('studentInfo.lastName required')
if (!body?.studentInfo?.school?.trim()) errors.push('studentInfo.school required')
if (!body?.contactInfo?.parentName?.trim()) errors.push('contactInfo.parentName required')
if (!body?.contactInfo?.phone?.trim()) errors.push('contactInfo.phone required')
if (!body?.contactInfo?.email?.trim()) errors.push('contactInfo.email required')
if (!body?.contactInfo?.relationship?.trim()) errors.push('contactInfo.relationship required')
if (!body?.details?.trim()) errors.push('details required')
if (body?.website) errors.push('spam detected')
return errors
}
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createApp, validateAssistance } from '../src/app.js'
describe('assistance validation shape', () => {
it('requires core fields', () => {
@@ -28,3 +16,113 @@ describe('assistance validation shape', () => {
assert.ok(errors.includes('spam detected'))
})
})
describe('admin auth and settings', () => {
it('logs in admin and updates stripe settings', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-test-'))
const { app, AUTH_SECRET } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 })
const server = app.listen(0)
const { port } = server.address()
const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'demo123' }),
})
assert.equal(loginRes.status, 200)
const { token } = await loginRes.json()
assert.ok(token)
const settingsRes = await fetch(`http://127.0.0.1:${port}/api/admin/settings`, {
headers: { Authorization: `Bearer ${token}` },
})
assert.equal(settingsRes.status, 200)
const patchRes = await fetch(`http://127.0.0.1:${port}/api/admin/settings`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
donationsEnabled: true,
stripePublishableKey: 'pk_test_abc',
stripeSecretKey: 'sk_test_abc',
stripeWebhookSecret: 'whsec_test',
}),
})
assert.equal(patchRes.status, 200)
const patched = await patchRes.json()
assert.equal(patched.donationsEnabled, true)
assert.match(patched.stripeSecretKey, /…/)
const publicRes = await fetch(`http://127.0.0.1:${port}/api/public/config`)
const pub = await publicRes.json()
assert.equal(pub.stripeConfigured, true)
assert.equal(pub.stripePublishableKey, 'pk_test_abc')
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
assert.ok(AUTH_SECRET)
})
})
describe('qr admin routes', () => {
it('reports qrcode-monkey as default provider', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-qr-'))
const { app } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 })
const server = app.listen(0)
const { port } = server.address()
const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'demo123' }),
})
const { token } = await loginRes.json()
const statusRes = await fetch(`http://127.0.0.1:${port}/api/admin/qrcodes/status`, {
headers: { Authorization: `Bearer ${token}` },
})
assert.equal(statusRes.status, 200)
const status = await statusRes.json()
assert.equal(status.provider, 'qrcode-monkey')
assert.equal(status.configured, true)
assert.equal(status.dynamicTracking, false)
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
})
})
describe('brand manifest', () => {
it('seeds and serves public brand manifest', async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mim-api-brand-'))
const { app } = createApp({ dataDir: tmp, authSecret: 'test-secret', port: 0 })
const server = app.listen(0)
const { port } = server.address()
const publicRes = await fetch(`http://127.0.0.1:${port}/api/public/brand`)
assert.equal(publicRes.status, 200)
const manifest = await publicRes.json()
assert.ok(manifest.groups?.length > 0)
assert.ok(manifest.colors?.length > 0)
const loginRes = await fetch(`http://127.0.0.1:${port}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: '[email protected]', password: 'demo123' }),
})
const { token } = await loginRes.json()
const patchRes = await fetch(`http://127.0.0.1:${port}/api/admin/brand`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ published: false }),
})
assert.equal(patchRes.status, 200)
const hidden = await fetch(`http://127.0.0.1:${port}/api/public/brand`)
const hiddenJson = await hidden.json()
assert.equal(hiddenJson.published, false)
server.close()
fs.rmSync(tmp, { recursive: true, force: true })
})
})
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 298 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

File diff suppressed because one or more lines are too long
+3
View File
@@ -49,6 +49,9 @@ function tracePng(inputPath, options) {
function normalizeSvg(svg, title) {
let out = svg.replace(/<\?xml[^>]*>\s*/i, '')
out = out.replace(/fill="black"/gi, 'fill="#D9AA45"')
out = out.replace(/fill="#000000"/gi, 'fill="#D9AA45"')
out = out.replace(/fill="#000"/gi, 'fill="#D9AA45"')
if (!out.includes('aria-hidden')) {
out = out.replace(/<svg\b/, '<svg role="img" aria-hidden="true"')
}
+34 -839
View File
@@ -1,18 +1,15 @@
import React, { useState, useEffect, lazy, Suspense } from 'react'
import { motion, AnimatePresence, MotionConfig, LazyMotion, domAnimation } from 'framer-motion'
import {
Backpack,
Heart,
MapPin,
Phone,
Shirt,
Users,
Building2,
BookOpenText,
Quote,
FileText,
X,
DollarSign,
Award,
Settings,
UserCheck,
@@ -23,7 +20,6 @@ import {
AlertCircle,
Package,
Truck,
Plus,
Lock,
Database,
Check,
@@ -38,8 +34,6 @@ import {
Download,
WifiOff,
ChevronDown,
Eye,
Zap,
Target,
Activity,
} from 'lucide-react'
@@ -65,6 +59,15 @@ const VolunteerPage = lazy(() => import('./routes/VolunteerPageRoute'))
const SponsorsPage = lazy(() => import('./routes/SponsorsPageRoute'))
const StoriesPage = lazy(() => import('./routes/StoriesPageRoute'))
const AdminPortalPage = lazy(() => import('./routes/admin/AdminPortalPage'))
const VolunteerPortalPage = lazy(() => import('./routes/admin/VolunteerPortalPage'))
const ResourcePortalPage = lazy(() => import('./routes/admin/ResourcePortalPage'))
const AnalyticsDashboard = lazy(() => import('./routes/admin/AnalyticsDashboardPage'))
const AdminSettingsPage = lazy(() => import('./components/admin/AdminSettingsPage'))
const AdminBrandPage = lazy(() => import('./components/admin/AdminBrandPage'))
const AdminQrPage = lazy(() => import('./components/admin/AdminQrPage'))
import { PortalWrapper } from './components/admin/PortalWrapper'
// Phase 4: Extracted Components
import { Navigation } from './components/Navigation'
import { SiteHeader } from './components/SiteHeader'
@@ -121,45 +124,6 @@ function trackEvent(eventName: string, properties: Record<string, any> = {}) {
console.log(`Analytics: ${eventName}`, properties)
}
function useAnalytics() {
const [analyticsData, setAnalyticsData] = useState<AnalyticsData>(() => ({
pageViews: [
{ page: 'Home', views: 2847, trend: 12.5 },
{ page: 'Donate', views: 1203, trend: 8.3 },
{ page: 'Volunteer', views: 856, trend: -2.1 },
{ page: 'Stories', views: 645, trend: 15.8 },
{ page: 'About', views: 432, trend: 5.2 }
],
donationMetrics: { amount: 45280, count: 186, recurring: 67 },
userEngagement: { sessions: 3241, avgDuration: 185, bounceRate: 0.34 },
conversionRates: { donation: 0.078, volunteer: 0.032, contact: 0.156 }
}))
const refreshAnalytics = () => {
// Simulate real-time data updates
setAnalyticsData(prev => ({
...prev,
pageViews: prev.pageViews.map(pv => ({
...pv,
views: pv.views + Math.floor(Math.random() * 10),
trend: (Math.random() - 0.5) * 20
})),
donationMetrics: {
...prev.donationMetrics,
amount: prev.donationMetrics.amount + Math.floor(Math.random() * 500),
count: prev.donationMetrics.count + Math.floor(Math.random() * 3)
}
}))
}
useEffect(() => {
const interval = setInterval(refreshAnalytics, 30000) // Update every 30 seconds
return () => clearInterval(interval)
}, [])
return { analyticsData, refreshAnalytics }
}
/* ===================== PWA Features ===================== */
function usePWA() {
const [isOnline, setIsOnline] = useState(navigator.onLine)
@@ -242,13 +206,6 @@ function SEOHead({ title, description, image }: { title?: string, description?:
}
/* ===================== Types ===================== */
interface AnalyticsData {
pageViews: { page: string; views: number; trend: number }[]
donationMetrics: { amount: number; count: number; recurring: number }
userEngagement: { sessions: number; avgDuration: number; bounceRate: number }
conversionRates: { donation: number; volunteer: number; contact: number }
}
interface PolicySectionProps {
id: string
title: string
@@ -460,6 +417,18 @@ function PortalsPage() {
>
Access Admin Portal
</a>
<a href="/admin-brand" className="block text-center text-sm text-red-700 dark:text-red-300 hover:underline">
Brand asset manager
</a>
<a href="/admin-qr" className="block text-center text-sm text-red-700 dark:text-red-300 hover:underline">
QR code manager
</a>
<a href="/admin-settings" className="block text-center text-sm text-red-700 dark:text-red-300 hover:underline">
Stripe & system settings
</a>
<a href="/analytics" className="block text-center text-sm text-red-700 dark:text-red-300 hover:underline">
Analytics dashboard
</a>
</motion.div>
{/* Volunteer Portal */}
@@ -992,789 +961,6 @@ function PWAInstallPrompt() {
)
}
/* ===================== Authentication Components ===================== */
function LoginForm({ requiredRole }: { requiredRole?: 'admin' | 'volunteer' | 'resource' }) {
const { login, isLoading } = useAuth()
const [formData, setFormData] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
const success = await login(formData.email, formData.password)
if (!success) {
setError('Invalid credentials. Please try again.')
}
}
const getRoleHint = () => {
switch (requiredRole) {
case 'admin': return 'Use an email containing "admin" to access admin features'
case 'volunteer': return 'Use an email containing "volunteer" for volunteer access'
case 'resource': return 'Use any other email for resource center access'
default: return 'Enter your credentials to access the portal'
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-gray-900 dark:to-gray-800 px-4">
<motion.div
className="w-full max-w-md"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<div className="text-center mb-8">
<motion.div
className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 text-white rounded-full mb-4"
whileHover={{ scale: 1.05, rotateY: 180 }}
style={{ transformStyle: 'preserve-3d' }}
>
<Lock className="w-8 h-8" />
</motion.div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{requiredRole ? `${requiredRole.charAt(0).toUpperCase() + requiredRole.slice(1)} Portal` : 'Portal Access'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">
Sign in to access your dashboard
</p>
</div>
<div className="card">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
Email Address
</label>
<input
id="email"
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="input w-full focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="[email protected]"
style={{ minHeight: '44px' }} // Mobile touch optimization
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-2">
Password
</label>
<input
id="password"
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input w-full focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="Enter your password"
style={{ minHeight: '44px' }} // Mobile touch optimization
/>
</div>
{error && (
<motion.div
className="text-red-600 dark:text-red-400 text-sm p-3 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
>
{error}
</motion.div>
)}
<div className="text-xs text-gray-500 dark:text-gray-400 p-3 bg-gray-50 dark:bg-gray-800/50 rounded-lg">
<strong>Demo Access:</strong> {getRoleHint()}
</div>
<motion.button
type="submit"
disabled={isLoading}
className="w-full btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
style={{ minHeight: '44px' }} // Mobile touch optimization
whileHover={{ scale: isLoading ? 1 : 1.02 }}
whileTap={{ scale: isLoading ? 1 : 0.98 }}
>
{isLoading ? (
<><Clock className="w-4 h-4 mr-2 animate-spin" /> Signing In...</>
) : (
<>Sign In</>
)}
</motion.button>
</form>
<div className="mt-6 text-center">
<a href="/" className="text-sm text-primary-600 dark:text-primary-400 hover:underline">
Back to Main Site
</a>
</div>
</div>
</motion.div>
</div>
)
}
function PortalWrapper({ children, requiredRole }: { children: React.ReactNode, requiredRole?: 'admin' | 'volunteer' | 'resource' }) {
const { user } = useAuth()
if (!user) {
return <LoginForm requiredRole={requiredRole} />
}
if (requiredRole && user.role !== requiredRole) {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="card max-w-md w-full text-center">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
You don't have permission to access the {requiredRole} portal.
</p>
<div className="space-y-2">
<a href="/" className="btn-primary">
Return to Main Site
</a>
<button
onClick={() => useAuth().logout()}
className="btn-secondary w-full"
>
Sign Out
</button>
</div>
</div>
</div>
)
}
return <>{children}</>
}
// Admin Portal Dashboard
function AdminPortalPage() {
const { user, logout } = useAuth()
const [stats] = useState({
pendingRequests: 23,
activeVolunteers: 47,
deliveriesToday: 8,
monthlyBudget: 15000,
monthlySpent: 8250
})
useEffect(() => {
trackEvent('admin_portal_view', { user_id: user?.id, user_role: user?.role })
}, [])
return (
<PortalWrapper requiredRole="admin">
<SEOHead title="Admin Dashboard" description="Administrative portal for Miracles in Motion staff and administrators." />
<PageShell
title="Administration Dashboard"
icon={Settings}
eyebrow={`Welcome back, ${user?.name}`}
cta={
<button onClick={logout} className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
Sign Out
</button>
}
>
<div className="space-y-8">
{/* Quick Stats */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<motion.div className="card bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-red-700 dark:text-red-300">Pending Requests</p>
<p className="text-2xl font-bold text-red-900 dark:text-red-100">{stats.pendingRequests}</p>
</div>
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
</div>
</motion.div>
<motion.div className="card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-700 dark:text-blue-300">Active Volunteers</p>
<p className="text-2xl font-bold text-blue-900 dark:text-blue-100">{stats.activeVolunteers}</p>
</div>
<UserCheck className="h-8 w-8 text-blue-600 dark:text-blue-400" />
</div>
</motion.div>
<motion.div className="card bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.3 }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-700 dark:text-green-300">Deliveries Today</p>
<p className="text-2xl font-bold text-green-900 dark:text-green-100">{stats.deliveriesToday}</p>
</div>
<Truck className="h-8 w-8 text-green-600 dark:text-green-400" />
</div>
</motion.div>
<motion.div className="card bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.4 }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-yellow-700 dark:text-yellow-300">Budget Used</p>
<p className="text-2xl font-bold text-yellow-900 dark:text-yellow-100">{Math.round((stats.monthlySpent / stats.monthlyBudget) * 100)}%</p>
</div>
<DollarSign className="h-8 w-8 text-yellow-600 dark:text-yellow-400" />
</div>
</motion.div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Recent Requests */}
<div className="lg:col-span-2">
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Recent Assistance Requests</h3>
<button className="btn-secondary text-sm">View All</button>
</div>
<div className="space-y-4">
{[
{ student: 'Maria S.', school: 'San Fernando Valley', need: 'Emergency essentials', priority: 'High', time: '2 hours ago' },
{ student: 'James R.', school: 'South LA', need: 'Clothing & outreach', priority: 'Medium', time: '4 hours ago' },
{ student: 'Ana L.', school: 'East LA', need: 'Resource navigation', priority: 'Low', time: '1 day ago' }
].map((request, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{request.student}</span>
<span className={`px-2 py-1 text-xs rounded-full ${
request.priority === 'High' ? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300' :
request.priority === 'Medium' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
}`}>
{request.priority}
</span>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{request.school}</p>
<p className="text-sm">{request.need}</p>
</div>
<div className="text-right">
<p className="text-xs text-neutral-500">{request.time}</p>
<button className="text-primary-600 dark:text-primary-400 text-sm mt-1 hover:underline">Review</button>
</div>
</div>
))}
</div>
</div>
</div>
{/* Quick Actions */}
<div>
<div className="card">
<h3 className="text-lg font-semibold mb-6">Quick Actions</h3>
<div className="space-y-3">
<button className="w-full btn-primary text-left justify-start">
<Plus className="mr-2 h-4 w-4" />
Create New User
</button>
<button className="w-full btn-secondary text-left justify-start">
<FileCheck className="mr-2 h-4 w-4" />
Approve Pending Requests
</button>
<button className="w-full btn-secondary text-left justify-start">
<Database className="mr-2 h-4 w-4" />
Generate Reports
</button>
<button className="w-full btn-secondary text-left justify-start">
<Settings className="mr-2 h-4 w-4" />
System Settings
</button>
</div>
</div>
</div>
</div>
</div>
</PageShell>
</PortalWrapper>
)
}
// Volunteer Portal Dashboard
function VolunteerPortalPage() {
const { user, logout } = useAuth()
useEffect(() => {
trackEvent('volunteer_portal_view', { user_id: user?.id, user_role: user?.role })
}, [])
return (
<PortalWrapper requiredRole="volunteer">
<SEOHead title="Volunteer Dashboard" description="Volunteer portal for Miracles in Motion volunteers to manage assignments and schedules." />
<PageShell
title="Volunteer Dashboard"
icon={UserCheck}
eyebrow={`Hello, ${user?.name}`}
cta={
<button onClick={logout} className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
Sign Out
</button>
}
>
<div className="space-y-8">
{/* Today's Tasks */}
<div className="card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-4 mb-6">
<Calendar className="h-8 w-8 text-blue-600 dark:text-blue-400" />
<div>
<h3 className="text-lg font-semibold text-blue-900 dark:text-blue-100">Today's Schedule</h3>
<p className="text-blue-700 dark:text-blue-300">Tuesday, March 14, 2024</p>
</div>
</div>
<div className="space-y-4">
{[
{ time: '9:00 AM', task: 'Outreach kit assembly', location: 'Main warehouse', students: 12 },
{ time: '1:00 PM', task: 'Delivery route Valley area', location: 'LA County', students: 5 },
{ time: '3:30 PM', task: 'Inventory seasonal clothing', location: 'Storage room B', students: null }
].map((task, i) => (
<div key={i} className="flex items-center justify-between p-4 bg-white dark:bg-neutral-900 rounded-lg shadow-sm">
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-lg font-semibold text-blue-600 dark:text-blue-400">{task.time}</div>
</div>
<div>
<h4 className="font-medium">{task.task}</h4>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{task.location}</p>
{task.students && <p className="text-xs text-green-600 dark:text-green-400">{task.students} families served</p>}
</div>
</div>
<button className="btn-secondary text-sm">
<Check className="mr-1 h-3 w-3" />
Complete
</button>
</div>
))}
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
{/* Assigned Deliveries */}
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Pending Deliveries</h3>
<Truck className="h-6 w-6 text-neutral-400" />
</div>
<div className="space-y-3">
{[
{ student: 'Sofia M.', items: 'Essentials kit', school: 'San Fernando Valley', deadline: 'Tomorrow' },
{ student: 'Carlos R.', items: 'Winter clothing', school: 'South LA', deadline: 'Friday' },
{ student: 'Emma K.', items: 'Wellness navigation', school: 'East LA', deadline: 'Next week' }
].map((delivery, i) => (
<div key={i} className="p-3 border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium">{delivery.student}</span>
<span className="text-xs text-neutral-500">{delivery.deadline}</span>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-1">{delivery.items}</p>
<p className="text-xs text-neutral-500">{delivery.school}</p>
</div>
))}
</div>
</div>
{/* Volunteer Stats */}
<div className="card">
<h3 className="text-lg font-semibold mb-6">Your Impact This Month</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>Families helped</span>
</div>
<span className="font-semibold text-primary-600 dark:text-primary-400">47</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>Kits Assembled</span>
</div>
<span className="font-semibold text-primary-600 dark:text-primary-400">23</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Truck className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>Deliveries Made</span>
</div>
<span className="font-semibold text-primary-600 dark:text-primary-400">15</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<span>Hours Volunteered</span>
</div>
<span className="font-semibold text-primary-600 dark:text-primary-400">32</span>
</div>
</div>
</div>
</div>
</div>
</PageShell>
</PortalWrapper>
)
}
// Resource Center Portal Dashboard
function ResourcePortalPage() {
const { user, logout } = useAuth()
useEffect(() => {
trackEvent('resource_portal_view', { user_id: user?.id, user_role: user?.role })
}, [])
return (
<PortalWrapper requiredRole="resource">
<SEOHead title="Resource Portal" description="Partner portal for submitting and tracking assistance requests." />
<PageShell
title="Resource Center Portal"
icon={School}
eyebrow={`Welcome, ${user?.name}`}
cta={
<button onClick={logout} className="btn-secondary focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2">
Sign Out
</button>
}
>
<div className="space-y-8">
{/* Quick Submit */}
<div className="card bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800">
<div className="flex items-center gap-4 mb-6">
<Plus className="h-8 w-8 text-green-600 dark:text-green-400" />
<div>
<h3 className="text-lg font-semibold text-green-900 dark:text-green-100">Quick Request Submission</h3>
<p className="text-green-700 dark:text-green-300">Submit a new assistance request</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-4">
<a href="/request-assistance" className="flex-1 btn-primary">
<ClipboardList className="mr-2 h-4 w-4" />
New Assistance Request
</a>
<button className="btn-secondary">
<FileCheck className="mr-2 h-4 w-4" />
Bulk Upload (CSV)
</button>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Request Status */}
<div className="lg:col-span-2">
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Your Recent Requests</h3>
<button className="btn-secondary text-sm">View All Requests</button>
</div>
<div className="space-y-4">
{[
{ id: 'REQ-2024-0342', student: 'Maria Santos', status: 'In Progress', need: 'Emergency essentials', submitted: '2 days ago', eta: 'Tomorrow' },
{ id: 'REQ-2024-0341', student: 'James Rodriguez', status: 'Approved', need: 'Clothing & outreach', submitted: '3 days ago', eta: 'Today' },
{ id: 'REQ-2024-0340', student: 'Ana Lopez', status: 'Delivered', need: 'Resource navigation', submitted: '1 week ago', eta: 'Completed' }
].map((request, i) => (
<div key={i} className="p-4 border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<span className="font-mono text-sm text-neutral-500">{request.id}</span>
<span className={`px-2 py-1 text-xs rounded-full ${
request.status === 'Delivered' ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300' :
request.status === 'Approved' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-300' :
request.status === 'In Progress' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300' :
'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'
}`}>
{request.status}
</span>
</div>
<span className="text-xs text-neutral-500">{request.submitted}</span>
</div>
<div className="flex items-center justify-between">
<div>
<p className="font-medium">{request.student}</p>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{request.need}</p>
</div>
<div className="text-right">
<p className="text-sm text-neutral-600 dark:text-neutral-400">ETA: {request.eta}</p>
<button className="text-primary-600 dark:text-primary-400 text-sm hover:underline">View Details</button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Stats & Resources */}
<div className="space-y-6">
<div className="card">
<h3 className="text-lg font-semibold mb-4">Monthly Summary</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Requests Submitted</span>
<span className="font-semibold text-green-600 dark:text-green-400">12</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Students Helped</span>
<span className="font-semibold text-green-600 dark:text-green-400">28</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Avg. Response Time</span>
<span className="font-semibold text-green-600 dark:text-green-400">18 hrs</span>
</div>
</div>
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-4">Quick Links</h3>
<div className="space-y-2">
<a href="/request-assistance" className="block w-full btn-secondary text-left justify-start text-sm">
<ClipboardList className="mr-2 h-4 w-4" />
Submit Request
</a>
<button className="w-full btn-secondary text-left justify-start text-sm">
<FileCheck className="mr-2 h-4 w-4" />
Request History
</button>
<button className="w-full btn-secondary text-left justify-start text-sm">
<Calendar className="mr-2 h-4 w-4" />
Schedule Pickup
</button>
<button className="w-full btn-secondary text-left justify-start text-sm">
<Phone className="mr-2 h-4 w-4" />
Contact Support
</button>
</div>
</div>
</div>
</div>
</div>
</PageShell>
</PortalWrapper>
)
}
// Advanced Analytics Dashboard
function AnalyticsDashboard() {
const { user, logout } = useAuth()
const { analyticsData, refreshAnalytics } = useAnalytics()
const { addNotification } = useNotifications()
useEffect(() => {
trackEvent('analytics_dashboard_view', { user_id: user?.id, user_role: user?.role })
}, [])
const handleRefresh = () => {
refreshAnalytics()
addNotification({
type: 'success',
title: 'Data Refreshed',
message: 'Analytics data has been updated with the latest information'
})
}
return (
<PortalWrapper requiredRole="admin">
<SEOHead title="Analytics Dashboard" description="Real-time analytics and insights for Miracles in Motion." />
<PageShell
title="Analytics Dashboard"
icon={BarChart3}
eyebrow={`Data Insights for ${user?.name}`}
cta={
<div className="flex gap-2">
<motion.button
onClick={handleRefresh}
className="btn-secondary flex items-center gap-2"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Activity className="h-4 w-4" /> Refresh
</motion.button>
<button onClick={logout} className="btn-secondary">
Sign Out
</button>
</div>
}
>
<div className="space-y-8">
{/* Key Metrics */}
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<motion.div
className="card bg-gradient-to-br from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 border-blue-200 dark:border-blue-800"
whileHover={{ scale: 1.02, y: -2 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-blue-600 dark:text-blue-400">Total Donations</p>
<p className="text-2xl font-bold text-blue-900 dark:text-blue-100">
${analyticsData.donationMetrics.amount.toLocaleString()}
</p>
</div>
<TrendingUp className="h-8 w-8 text-blue-500" />
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-green-600 dark:text-green-400 font-medium">+12.5%</span>
<span className="text-blue-600 dark:text-blue-400 ml-1">vs last month</span>
</div>
</motion.div>
<motion.div
className="card bg-gradient-to-br from-green-50 to-green-100 dark:from-green-900/20 dark:to-green-800/20 border-green-200 dark:border-green-800"
whileHover={{ scale: 1.02, y: -2 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-green-600 dark:text-green-400">Active Volunteers</p>
<p className="text-2xl font-bold text-green-900 dark:text-green-100">247</p>
</div>
<Users className="h-8 w-8 text-green-500" />
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-green-600 dark:text-green-400 font-medium">+8.3%</span>
<span className="text-green-600 dark:text-green-400 ml-1">vs last month</span>
</div>
</motion.div>
<motion.div
className="card bg-gradient-to-br from-primary-50 to-primary-100 dark:from-primary-900/20 dark:to-primary-800/20 border-primary-200 dark:border-primary-800"
whileHover={{ scale: 1.02, y: -2 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-primary-600 dark:text-primary-400">Families helped</p>
<p className="text-2xl font-bold text-primary-900 dark:text-primary-100">1,847</p>
</div>
<Target className="h-8 w-8 text-primary-500" />
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-green-600 dark:text-green-400 font-medium">+15.2%</span>
<span className="text-primary-600 dark:text-primary-400 ml-1">vs last month</span>
</div>
</motion.div>
<motion.div
className="card bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-900/20 dark:to-orange-800/20 border-orange-200 dark:border-orange-800"
whileHover={{ scale: 1.02, y: -2 }}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-orange-600 dark:text-orange-400">Conversion Rate</p>
<p className="text-2xl font-bold text-orange-900 dark:text-orange-100">
{(analyticsData.conversionRates.donation * 100).toFixed(1)}%
</p>
</div>
<Zap className="h-8 w-8 text-orange-500" />
</div>
<div className="mt-2 flex items-center text-sm">
<span className="text-green-600 dark:text-green-400 font-medium">+3.1%</span>
<span className="text-orange-600 dark:text-orange-400 ml-1">vs last month</span>
</div>
</motion.div>
</div>
{/* Page Views Chart */}
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Page Performance</h3>
<div className="flex items-center gap-2 text-sm text-neutral-500">
<Eye className="h-4 w-4" />
Last 30 days
</div>
</div>
<div className="space-y-4">
{analyticsData.pageViews.map((page, index) => (
<motion.div
key={page.page}
className="flex items-center justify-between p-4 bg-neutral-50 dark:bg-neutral-800/50 rounded-lg"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.1 }}
>
<div className="flex items-center gap-3">
<div className={`w-3 h-8 rounded-full ${
page.trend > 0 ? 'bg-green-500' : page.trend < 0 ? 'bg-red-500' : 'bg-gray-400'
}`} />
<div>
<div className="font-medium">{page.page}</div>
<div className="text-sm text-neutral-500">{page.views.toLocaleString()} views</div>
</div>
</div>
<div className={`flex items-center gap-1 text-sm font-medium ${
page.trend > 0 ? 'text-green-600' : page.trend < 0 ? 'text-red-600' : 'text-gray-600'
}`}>
<TrendingUp className="h-4 w-4" />
{page.trend > 0 ? '+' : ''}{page.trend.toFixed(1)}%
</div>
</motion.div>
))}
</div>
</div>
{/* Real-time Activity */}
<div className="grid gap-6 lg:grid-cols-2">
<div className="card">
<h3 className="text-lg font-semibold mb-4">Recent Activity</h3>
<div className="space-y-3">
{[
{ action: 'New donation', details: '$125 from Sarah M.', time: '2 minutes ago', icon: Heart },
{ action: 'Volunteer signup', details: 'John D. registered', time: '8 minutes ago', icon: Users },
{ action: 'Assistance request', details: 'San Fernando Valley', time: '15 minutes ago', icon: MapPin },
{ action: 'Story shared', details: 'Maria\'s success story', time: '1 hour ago', icon: BookOpenText }
].map((activity, index) => (
<motion.div
key={index}
className="flex items-center gap-3 p-3 hover:bg-neutral-50 dark:hover:bg-neutral-800/50 rounded-lg transition-colors"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
>
<div className="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<activity.icon className="h-4 w-4 text-primary-600 dark:text-primary-400" />
</div>
<div className="flex-1">
<div className="font-medium text-sm">{activity.action}</div>
<div className="text-sm text-neutral-500">{activity.details}</div>
</div>
<div className="text-xs text-neutral-400">{activity.time}</div>
</motion.div>
))}
</div>
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-4">Impact Summary</h3>
<div className="grid gap-4">
<div className="flex items-center justify-between p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="flex items-center gap-3">
<Backpack className="h-6 w-6 text-green-600" />
<span className="font-medium">Backpacks Distributed</span>
</div>
<span className="text-2xl font-bold text-green-600">342</span>
</div>
<div className="flex items-center justify-between p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<div className="flex items-center gap-3">
<Shirt className="h-6 w-6 text-blue-600" />
<span className="font-medium">Clothing Items</span>
</div>
<span className="text-2xl font-bold text-blue-600">789</span>
</div>
<div className="flex items-center justify-between p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<div className="flex items-center gap-3">
<AlertCircle className="h-6 w-6 text-primary-600" />
<span className="font-medium">Emergency Responses</span>
</div>
<span className="text-2xl font-bold text-primary-600">156</span>
</div>
</div>
</div>
</div>
</div>
</PageShell>
</PortalWrapper>
)
}
// Phase 3: AI Portal Page
function AIPortalPage() {
const { user, logout } = useAuth()
@@ -2071,13 +1257,19 @@ function AppContent() {
case '/portals':
return <PortalsPage />
case '/admin-portal':
return <AdminPortalPage />
return <LazyRoute label="Loading admin portal…"><AdminPortalPage /></LazyRoute>
case '/admin-settings':
return <LazyRoute label="Loading settings…"><AdminSettingsPage /></LazyRoute>
case '/admin-brand':
return <LazyRoute label="Loading brand manager…"><AdminBrandPage /></LazyRoute>
case '/admin-qr':
return <LazyRoute label="Loading QR manager…"><AdminQrPage /></LazyRoute>
case '/volunteer-portal':
return <VolunteerPortalPage />
return <LazyRoute label="Loading volunteer portal…"><VolunteerPortalPage /></LazyRoute>
case '/resource-portal':
return <ResourcePortalPage />
return <LazyRoute label="Loading resource portal…"><ResourcePortalPage /></LazyRoute>
case '/analytics':
return <AnalyticsDashboard />
return <LazyRoute label="Loading analytics…"><AnalyticsDashboard /></LazyRoute>
case '/ai-portal':
return <LazyPortal><AIPortalPage /></LazyPortal>
case '/advanced-analytics':
@@ -2120,6 +1312,9 @@ function AppContent() {
<Footer />
{![
'/admin-portal',
'/admin-settings',
'/admin-brand',
'/admin-qr',
'/volunteer-portal',
'/resource-portal',
'/analytics',
+1 -1
View File
@@ -222,7 +222,7 @@ const Footer: React.FC = () => {
to students and families in need. Every contribution makes a lasting impact.
</p>
<p className="text-sm text-gray-400">
EIN: 88-1234567 All donations are tax-deductible
EIN: 33-4887159 All donations are tax-deductible
</p>
</div>
+22 -40
View File
@@ -12,8 +12,7 @@ import type {
AssistanceCategory
} from '../ai/types'
import { pipeline } from '../ai/ProcessingPipeline'
// Icons (using the existing icon system)
import { fetchAssistanceRequests } from '../lib/mimApi'
const Brain = ({ className = "w-5 h-5" }: { className?: string }) => (
<svg className={className} fill="currentColor" viewBox="0 0 20 20">
<path d="M17.28 9.28a.75.75 0 00-1.06-1.06l-7.5 7.5a.75.75 0 101.06 1.06l7.5-7.5z" />
@@ -49,53 +48,36 @@ const Users = ({ className = "w-8 h-8" }: { className?: string }) => (
</svg>
)
// Mock data for demonstration
const mockRequests: StudentRequest[] = [
{
id: 'req-1',
studentId: 'std-1',
studentName: 'Maria Rodriguez',
description: 'Need winter coat and boots for my daughter. Size 8 shoes and medium coat. Getting cold and she only has summer clothes.',
category: 'clothing',
urgency: 'high',
location: { city: 'Austin', state: 'TX', zipCode: '78701' },
constraints: { timeframe: 'within-week', deliveryMethod: 'school-delivery', privacyLevel: 'semi-anonymous' },
submittedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
},
{
id: 'req-2',
studentId: 'std-2',
studentName: 'James Thompson',
description: 'My son needs school supplies - notebooks, pencils, calculator for math class. Starting new semester next week.',
category: 'school-supplies',
urgency: 'medium',
location: { city: 'Round Rock', state: 'TX', zipCode: '78664' },
constraints: { timeframe: 'within-week', deliveryMethod: 'pickup', privacyLevel: 'open' },
submittedAt: new Date(Date.now() - 45 * 60 * 1000), // 45 minutes ago
},
{
id: 'req-3',
studentId: 'std-3',
studentName: 'Sarah Kim',
description: 'Emergency - no food at home for kids this weekend. Need groceries or meal assistance ASAP.',
category: 'food-assistance',
urgency: 'emergency',
location: { city: 'Cedar Park', state: 'TX', zipCode: '78613' },
constraints: { timeframe: 'immediate', deliveryMethod: 'delivery', privacyLevel: 'anonymous' },
submittedAt: new Date(Date.now() - 20 * 60 * 1000), // 20 minutes ago
}
]
interface AIAssistancePortalProps {
userRole: 'student' | 'coordinator' | 'admin'
}
export function AIAssistancePortal({ userRole }: AIAssistancePortalProps) {
const [requests, setRequests] = useState<StudentRequest[]>(mockRequests)
const [requests, setRequests] = useState<StudentRequest[]>([])
const [aiInsights, setAIInsights] = useState<AIInsight[]>([])
const [processing, setProcessing] = useState(false)
const [selectedRequest, setSelectedRequest] = useState<string | null>(null)
useEffect(() => {
fetchAssistanceRequests(25)
.then((res) => {
setRequests(
res.data.map((r) => ({
id: r.id,
studentId: r.id,
studentName: r.student,
description: r.need,
category: (r.requestType || 'general') as AssistanceCategory,
urgency: (r.priority === 'High' ? 'high' : r.priority === 'Medium' ? 'medium' : 'low') as UrgencyLevel,
location: { city: 'Los Angeles', state: 'CA', zipCode: '90001' },
constraints: { timeframe: 'within-week', deliveryMethod: 'school-delivery', privacyLevel: 'semi-anonymous' },
submittedAt: new Date(r.ts),
})),
)
})
.catch(() => {})
}, [])
useEffect(() => {
// Subscribe to real-time AI updates
const unsubscribe = pipeline.subscribe(handleRealTimeUpdate)
+39 -35
View File
@@ -1,6 +1,7 @@
// Phase 3B: Advanced Analytics Dashboard for Nonprofit Impact Tracking
import React, { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import { fetchAdvancedAnalytics } from '../lib/mimApi'
interface ImpactMetrics {
totalStudentsServed: number
@@ -61,55 +62,58 @@ const AdvancedAnalyticsDashboard: React.FC = () => {
const loadAnalyticsData = async () => {
setLoading(true)
try {
// Simulate loading comprehensive analytics
await new Promise(resolve => setTimeout(resolve, 2000))
const data = await fetchAdvancedAnalytics() as {
impactMetrics: MonthlyTrend[]
predictions: { nextMonthDonations: number; studentsServedTrend: number }
geographic: { region: string; count: number }[]
}
const trends = data.impactMetrics || []
const totalStudents = trends.reduce((s, m) => s + (m.studentsServed || 0), 0)
const totalDonations = trends.reduce((s, m) => s + (m.donations || 0), 0)
const totalResources = trends.reduce((s, m) => s + (m.resourcesAllocated || 0), 0)
setMetrics({
totalStudentsServed: 2847,
totalResourcesAllocated: 15690,
totalDonationsProcessed: 89234,
totalStudentsServed: totalStudents,
totalResourcesAllocated: totalResources,
totalDonationsProcessed: totalDonations,
averageResponseTime: 4.2,
costEfficiencyRatio: 0.87,
costEfficiencyRatio: trends.length ? trends[trends.length - 1].efficiency : 0.87,
volunteerEngagement: 0.93,
schoolPartnershipGrowth: 0.24,
monthlyTrends: [
{ month: 'Jan', studentsServed: 234, resourcesAllocated: 1250, donations: 7800, efficiency: 0.85 },
{ month: 'Feb', studentsServed: 289, resourcesAllocated: 1420, donations: 8900, efficiency: 0.87 },
{ month: 'Mar', studentsServed: 312, resourcesAllocated: 1580, donations: 9200, efficiency: 0.89 },
{ month: 'Apr', studentsServed: 298, resourcesAllocated: 1490, donations: 8700, efficiency: 0.88 },
{ month: 'May', studentsServed: 356, resourcesAllocated: 1780, donations: 10500, efficiency: 0.91 },
{ month: 'Jun', studentsServed: 378, resourcesAllocated: 1890, donations: 11200, efficiency: 0.93 }
]
monthlyTrends: trends.map((m) => ({
month: m.month,
studentsServed: m.studentsServed,
resourcesAllocated: m.resourcesAllocated,
donations: m.donations,
efficiency: m.efficiency,
})),
})
setPredictions({
nextMonthDemand: 425,
budgetProjection: 12800,
volunteerRequirement: 67,
nextMonthDemand: data.predictions?.studentsServedTrend || 0,
budgetProjection: data.predictions?.nextMonthDonations || 0,
volunteerRequirement: Math.max(10, Math.round((data.predictions?.studentsServedTrend || 0) / 6)),
resourceNeeds: [
{ category: 'School Supplies', predictedDemand: 156, currentInventory: 89, recommendedPurchase: 75, urgencyLevel: 'medium' },
{ category: 'Clothing', predictedDemand: 134, currentInventory: 45, recommendedPurchase: 95, urgencyLevel: 'high' },
{ category: 'Food Assistance', predictedDemand: 89, currentInventory: 67, recommendedPurchase: 30, urgencyLevel: 'low' },
{ category: 'Technology', predictedDemand: 46, currentInventory: 12, recommendedPurchase: 40, urgencyLevel: 'critical' }
{ category: 'Emergency essentials', predictedDemand: 46, currentInventory: 12, recommendedPurchase: 40, urgencyLevel: 'critical' },
],
riskFactors: [
'Increased demand in back-to-school season',
'Volunteer availability declining in summer',
'Technology needs growing faster than budget'
],
opportunities: [
'Partnership with local tech companies for device donations',
'Summer clothing drive potential',
'Grant opportunity for educational technology'
]
riskFactors: ['Seasonal demand spikes in LA County schools', 'Volunteer availability varies by month'],
opportunities: ['Partner referrals from resource center portal', 'Recurring donor growth via Stripe'],
})
setGeoData([
{ region: 'Downtown Schools', studentsServed: 156, averageNeed: 3.2, responseTime: 3.8, efficiency: 0.91, coordinates: [-122.4194, 37.7749] },
{ region: 'Suburban East', studentsServed: 98, averageNeed: 2.8, responseTime: 4.5, efficiency: 0.85, coordinates: [-122.3894, 37.7849] },
{ region: 'North District', studentsServed: 134, averageNeed: 3.6, responseTime: 4.1, efficiency: 0.88, coordinates: [-122.4094, 37.7949] },
{ region: 'South Valley', studentsServed: 89, averageNeed: 2.9, responseTime: 5.2, efficiency: 0.82, coordinates: [-122.4294, 37.7649] }
])
setGeoData(
(data.geographic || []).map((g, i) => ({
region: g.region,
studentsServed: g.count,
averageNeed: 3.0,
responseTime: 4.0 + i * 0.2,
efficiency: 0.85,
coordinates: [-118.25 + i * 0.05, 34.05 - i * 0.03] as [number, number],
})),
)
} catch (error) {
console.error('Error loading analytics:', error)
}
+47 -79
View File
@@ -1,6 +1,7 @@
// Phase 3B: Mobile Volunteer Application Components
import React, { useState, useEffect } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { fetchVolunteerAssignments, fetchVolunteerStats } from '../lib/mimApi'
// Mobile types defined locally for better performance
interface MobileAssignment {
@@ -67,85 +68,52 @@ const MobileVolunteerApp: React.FC = () => {
}, [])
const loadVolunteerData = async () => {
// Simulate loading volunteer profile and assignments
setProfile({
id: 'vol-001',
name: 'Sarah Johnson',
phone: '(555) 123-4567',
email: '[email protected]',
skills: ['Tutoring', 'Transportation', 'Emergency Response', 'Event Planning'],
availability: ['Weekday Evenings', 'Weekends'],
location: [37.7749, -122.4194],
rating: 4.8,
completedAssignments: 67,
badges: ['Reliable Volunteer', '50+ Assignments', 'Emergency Certified', 'Top Rated'],
verified: true
})
setAssignments([
{
id: 'assign-001',
studentName: 'Maria Rodriguez',
requestType: 'School Supplies Delivery',
urgency: 'high',
location: {
address: '456 Oak Street, San Francisco, CA',
distance: 2.3,
coordinates: [37.7849, -122.4094]
},
estimatedTime: 45,
requiredSkills: ['Transportation'],
description: 'Deliver backpack with school supplies to elementary student. Family needs supplies for Monday morning.',
status: 'pending',
deadline: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
contactInfo: {
coordinatorName: 'Lisa Chen',
coordinatorPhone: '(555) 987-6543',
emergencyContact: '(555) 911-HELP'
}
},
{
id: 'assign-002',
studentName: 'James Thompson',
requestType: 'Tutoring Session',
urgency: 'medium',
location: {
address: '123 Maple Avenue, Oakland, CA',
distance: 5.7,
coordinates: [37.8044, -122.2711]
},
estimatedTime: 90,
requiredSkills: ['Tutoring', 'Math'],
description: 'Help with algebra homework preparation for upcoming test. Student struggling with equations.',
status: 'accepted',
deadline: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000),
contactInfo: {
coordinatorName: 'Michael Davis',
coordinatorPhone: '(555) 456-7890',
emergencyContact: '(555) 911-HELP'
}
},
{
id: 'assign-003',
studentName: 'Anonymous Request',
requestType: 'Emergency Food Assistance',
urgency: 'emergency',
location: {
address: 'Community Center, 789 Pine Street',
distance: 1.2,
coordinates: [37.7749, -122.4294]
},
estimatedTime: 30,
requiredSkills: ['Emergency Response'],
description: 'URGENT: Family needs immediate food assistance. Pickup and delivery to secure location.',
status: 'pending',
contactInfo: {
coordinatorName: 'Emergency Team',
coordinatorPhone: '(555) 911-HELP',
emergencyContact: '(555) 911-HELP'
}
}
])
try {
const [assignmentRes, stats] = await Promise.all([
fetchVolunteerAssignments(),
fetchVolunteerStats(),
])
setProfile({
id: 'volunteer',
name: 'MIM Volunteer',
phone: '(818) 491-6884',
email: '[email protected]',
skills: ['Delivery', 'Outreach', 'Kit assembly'],
availability: ['Weekdays', 'Weekends'],
location: [34.0522, -118.2437],
rating: 4.9,
completedAssignments: stats.deliveries,
badges: stats.familiesHelped >= 10 ? ['Impact Champion'] : ['Active Volunteer'],
verified: true,
})
setAssignments(
assignmentRes.data.map((a) => ({
id: a.id,
studentName: a.student,
requestType: a.items,
urgency: 'medium' as const,
location: {
address: a.school || 'Los Angeles County',
distance: 3,
coordinates: [34.05, -118.25] as [number, number],
},
estimatedTime: 60,
requiredSkills: ['Delivery'],
description: `Deliver ${a.items} for ${a.student}`,
status: (a.status === 'completed' ? 'completed' : 'pending') as MobileAssignment['status'],
deadline: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000),
contactInfo: {
coordinatorName: 'MIM Coordinator',
coordinatorPhone: '(818) 491-6884',
emergencyContact: '(818) 491-6884',
},
})),
)
return
} catch {
setProfile(null)
setAssignments([])
}
}
const setupNotifications = () => {
+11 -11
View File
@@ -28,7 +28,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
}
const navLinkClass =
'navlink text-zinc-600 hover:text-primary-600 dark:text-zinc-300 dark:hover:text-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 rounded text-sm xl:text-[0.9375rem] whitespace-nowrap'
'navlink focus:outline-none focus:ring-2 focus:ring-[#D9AA45] focus:ring-offset-2 focus:ring-offset-[#023B2B] rounded text-sm xl:text-[0.9375rem] whitespace-nowrap'
return (
<>
@@ -78,7 +78,7 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDarkMode(!darkMode)}
onKeyDown={(e) => handleKeyDown(e, () => setDarkMode(!darkMode))}
className="group rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 hover:bg-white dark:border-white/10 dark:bg-white/10 dark:hover:bg-white/15 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
className="group rounded-full border p-2 shadow-sm transition hover:scale-105 focus:outline-none focus:ring-2 focus:ring-[#D9AA45] focus:ring-offset-2 focus:ring-offset-[#023B2B]"
>
{darkMode ? (
<SunMedium className="h-5 w-5 transition group-hover:rotate-12" aria-hidden />
@@ -101,14 +101,14 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
<button
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
onClick={() => setDarkMode(!darkMode)}
className="rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm transition hover:scale-105 dark:border-white/10 dark:bg-white/10"
className="rounded-full border p-2 shadow-sm transition hover:scale-105"
>
{darkMode ? <SunMedium className="h-4 w-4" aria-hidden /> : <Moon className="h-4 w-4" aria-hidden />}
</button>
<button
aria-label={mobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu'}
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="rounded-full border border-neutral-200/70 bg-white/70 p-2 shadow-sm dark:border-white/10 dark:bg-white/10"
className="rounded-full border p-2 shadow-sm"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
>
@@ -125,30 +125,30 @@ export function Navigation({ darkMode, setDarkMode, mobileMenuOpen, setMobileMen
transition={{ duration: 0.2 }}
style={{ overflow: 'hidden' }}
>
<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="mobile-nav-panel border-t px-4 py-3">
<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" href="/">
Home
</a>
{NAV_LINKS.map((link) => (
<a
key={link.href}
className="block rounded py-2.5 text-sm font-medium text-neutral-800 dark:text-neutral-100 focus-visible:ring-2 focus-visible:ring-primary-500"
className="block rounded py-2.5 text-sm font-medium focus-visible:ring-2 focus-visible:ring-[#D9AA45]"
href={link.href}
>
{link.label}
</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" href="/donate">
Donate
</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" href="/request-assistance">
Request assistance
</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" href="/portals">
Portals
</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" href="/sponsors">
Partner with us
</a>
</div>
+1 -1
View File
@@ -7,7 +7,7 @@ export interface SiteHeaderProps {
/** Shared sticky header shell for App and MainLayout (single source of truth). */
export function SiteHeader({ children }: SiteHeaderProps) {
return (
<header className="sticky top-0 z-50 border-b border-secondary-200/40 bg-[var(--mim-warm-white)]/95 backdrop-blur dark:border-white/10 dark:bg-black/85">
<header className="site-header sticky top-0 z-50 border-b border-[#D9AA45]/20 bg-[#023B2B] shadow-[0_4px_24px_rgba(2,59,43,0.35)]">
{children}
</header>
)
+19 -87
View File
@@ -55,6 +55,8 @@ interface ChecklistItem {
required: boolean
}
import { fetchTrainingModules } from '../lib/mimApi'
const StaffTrainingDashboard: React.FC = () => {
const [staff, setStaff] = useState<StaffMember[]>([])
const [modules, setModules] = useState<TrainingModule[]>([])
@@ -70,95 +72,25 @@ const StaffTrainingDashboard: React.FC = () => {
const loadTrainingData = async () => {
setLoading(true)
// Simulate loading training modules
const trainingModules: TrainingModule[] = [
{
id: 'mod-001',
title: 'Introduction to AI-Powered Student Assistance',
description: 'Learn the basics of how our AI system helps match student needs with available resources.',
duration: 30,
difficulty: 'beginner',
category: 'ai-basics',
let trainingModules: TrainingModule[] = []
try {
const apiModules = await fetchTrainingModules()
trainingModules = apiModules.modules.map((m) => ({
id: m.id,
title: m.title,
description: `Training module — ${m.title}`,
duration: m.durationMin,
difficulty: 'beginner' as const,
category: 'ai-basics' as const,
prerequisites: [],
learningObjectives: [
'Understand the purpose and benefits of AI assistance',
'Identify key components of the AI system',
'Recognize when AI recommendations are most valuable'
],
completed: false,
certificateEarned: false
},
{
id: 'mod-002',
title: 'Navigating the AI Portal Interface',
description: 'Master the AI portal interface, including request submission, review queues, and status monitoring.',
duration: 45,
difficulty: 'beginner',
category: 'system-navigation',
prerequisites: ['mod-001'],
learningObjectives: [
'Navigate all sections of the AI portal',
'Submit and track assistance requests',
'Interpret AI confidence scores and recommendations'
],
completed: false,
certificateEarned: false
},
{
id: 'mod-003',
title: 'Case Management with AI Assistance',
description: 'Learn to effectively manage student cases using AI recommendations and Salesforce integration.',
duration: 60,
difficulty: 'intermediate',
category: 'case-management',
prerequisites: ['mod-001', 'mod-002'],
learningObjectives: [
'Create and update cases in Salesforce',
'Evaluate AI matching recommendations',
'Coordinate with volunteers and resource providers',
'Track case outcomes and impact'
],
completed: false,
certificateEarned: false
},
{
id: 'mod-004',
title: 'Advanced Analytics and Reporting',
description: 'Utilize the analytics dashboard to track impact, identify trends, and generate reports.',
duration: 50,
difficulty: 'intermediate',
category: 'reporting',
prerequisites: ['mod-003'],
learningObjectives: [
'Generate impact reports using the dashboard',
'Identify trends in student assistance needs',
'Use predictive analytics for resource planning',
'Create custom reports for stakeholders'
],
completed: false,
certificateEarned: false
},
{
id: 'mod-005',
title: 'Troubleshooting and System Optimization',
description: 'Handle common issues, optimize AI performance, and maintain data quality.',
duration: 40,
difficulty: 'advanced',
category: 'troubleshooting',
prerequisites: ['mod-004'],
learningObjectives: [
'Diagnose and resolve common system issues',
'Optimize AI model performance through feedback',
'Maintain data quality and integrity',
'Escalate complex technical problems appropriately'
],
completed: false,
certificateEarned: false
}
]
learningObjectives: [`Complete ${m.title}`],
completed: m.progress >= 100,
certificateEarned: m.progress >= 100,
}))
} catch {
trainingModules = []
}
// Simulate loading staff data
const staffMembers: StaffMember[] = [
{
id: 'staff-001',
+360
View File
@@ -0,0 +1,360 @@
import { useEffect, useRef, useState } from 'react'
import { Download, Image, Palette, Plus, Save, Trash2, Upload } from 'lucide-react'
import { SEOHead } from '../SEO/SEOHead'
import { AppPageShell as PageShell } from '../layout/AppPageShell'
import { PortalWrapper } from './PortalWrapper'
import { useAuth } from '../../contexts/AuthContext'
import { useNotifications } from '../../contexts/NotificationContext'
import {
deleteBrandFile,
fetchAdminBrand,
rebuildBrandZip,
saveBrandManifest,
uploadBrandFile,
type BrandColor,
type BrandGroup,
type BrandManifest,
} from '../../lib/mimApi'
export function AdminBrandPage() {
const { user, logout } = useAuth()
const { addNotification } = useNotifications()
const [manifest, setManifest] = useState<BrandManifest | null>(null)
const [files, setFiles] = useState<{ name: string; size: number }[]>([])
const [saving, setSaving] = useState(false)
const [uploading, setUploading] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadGroupId, setUploadGroupId] = useState<string | null>(null)
const load = () => {
fetchAdminBrand()
.then((r) => {
setManifest(r.manifest)
setFiles(r.files)
})
.catch(() => addNotification({ type: 'error', title: 'Brand', message: 'Could not load brand manifest' }))
}
useEffect(() => {
load()
}, [addNotification])
const handleSave = async () => {
if (!manifest) return
setSaving(true)
try {
const res = await saveBrandManifest(manifest)
setManifest(res.manifest)
addNotification({ type: 'success', title: 'Brand saved', message: 'Manifest updated for public /brand page.' })
} catch (e) {
addNotification({ type: 'error', title: 'Save failed', message: e instanceof Error ? e.message : 'Error' })
} finally {
setSaving(false)
}
}
const handleUpload = async (file: File, groupId?: string) => {
setUploading(true)
try {
const res = await uploadBrandFile(file)
if (manifest && groupId) {
const ext = file.name.split('.').pop()?.toUpperCase() || 'FILE'
const next = { ...manifest }
next.groups = next.groups.map((g) =>
g.id === groupId
? {
...g,
assets: [
...g.assets,
{ title: file.name, path: res.path, format: ext, visible: true },
],
}
: g,
)
setManifest(next)
}
load()
addNotification({ type: 'success', title: 'Uploaded', message: res.filename })
} catch (e) {
addNotification({ type: 'error', title: 'Upload failed', message: e instanceof Error ? e.message : 'Error' })
} finally {
setUploading(false)
setUploadGroupId(null)
}
}
const addColor = () => {
if (!manifest) return
setManifest({
...manifest,
colors: [...manifest.colors, { name: 'New color', hex: '#000000', role: 'accent' }],
})
}
const addGroup = () => {
if (!manifest) return
const id = `group-${Date.now()}`
setManifest({
...manifest,
groups: [
...manifest.groups,
{ id, title: 'New asset group', description: '', visible: true, assets: [] },
],
})
}
const updateGroup = (id: string, patch: Partial<BrandGroup>) => {
if (!manifest) return
setManifest({
...manifest,
groups: manifest.groups.map((g) => (g.id === id ? { ...g, ...patch } : g)),
})
}
const removeAsset = (groupId: string, path: string) => {
if (!manifest) return
setManifest({
...manifest,
groups: manifest.groups.map((g) =>
g.id === groupId ? { ...g, assets: g.assets.filter((a) => a.path !== path) } : g,
),
})
}
const removeFile = async (name: string) => {
try {
await deleteBrandFile(name)
load()
addNotification({ type: 'success', title: 'Deleted', message: name })
} catch (e) {
addNotification({ type: 'error', title: 'Delete failed', message: e instanceof Error ? e.message : 'Error' })
}
}
const handleRebuildZip = async () => {
try {
const res = await rebuildBrandZip()
if (!res.ok) throw new Error(res.error || 'Zip failed')
load()
addNotification({ type: 'success', title: 'ZIP rebuilt', message: `${res.fileCount} files packaged` })
} catch (e) {
addNotification({ type: 'error', title: 'ZIP failed', message: e instanceof Error ? e.message : 'Error' })
}
}
if (!manifest) {
return (
<PortalWrapper requiredRole="admin">
<div className="p-12 text-center text-neutral-500">Loading brand manager</div>
</PortalWrapper>
)
}
return (
<PortalWrapper requiredRole="admin">
<SEOHead title="Brand Asset Manager" description="Manage logos, colors, and partner brand kit for mim4u.org." />
<PageShell
title="Brand Asset Manager"
icon={Image}
eyebrow={`${user?.name} · controls public /brand`}
cta={
<div className="flex flex-wrap gap-2">
<a href="/brand" target="_blank" rel="noreferrer" className="btn-secondary">Preview /brand</a>
<button type="button" onClick={handleSave} disabled={saving} className="btn-primary flex items-center gap-2">
<Save className="h-4 w-4" /> {saving ? 'Saving…' : 'Save manifest'}
</button>
<button type="button" onClick={logout} className="btn-secondary">Sign Out</button>
</div>
}
>
<input
ref={fileInputRef}
type="file"
className="hidden"
accept=".png,.jpg,.jpeg,.webp,.svg,.ico,.zip,.pdf,.md"
onChange={(e) => {
const file = e.target.files?.[0]
if (file) handleUpload(file, uploadGroupId || undefined)
e.target.value = ''
}}
/>
<div className="space-y-8">
<div className="card flex flex-wrap items-center justify-between gap-4">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={manifest.published !== false}
onChange={(e) => setManifest({ ...manifest, published: e.target.checked })}
className="h-4 w-4"
/>
<span>
<span className="font-medium">Publish brand kit publicly</span>
<span className="block text-sm text-neutral-500">When off, /brand shows an unavailable message.</span>
</span>
</label>
<div className="flex gap-2">
<button type="button" className="btn-secondary flex items-center gap-2" onClick={handleRebuildZip}>
<Download className="h-4 w-4" /> Rebuild ZIP
</button>
<button
type="button"
className="btn-secondary flex items-center gap-2"
disabled={uploading}
onClick={() => {
setUploadGroupId(null)
fileInputRef.current?.click()
}}
>
<Upload className="h-4 w-4" /> {uploading ? 'Uploading…' : 'Upload file'}
</button>
</div>
</div>
<section className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2"><Palette className="h-5 w-5" /> Colors</h3>
<button type="button" onClick={addColor} className="btn-secondary text-sm"><Plus className="h-4 w-4 inline" /> Add</button>
</div>
<div className="space-y-3">
{manifest.colors.map((c, i) => (
<ColorRow
key={`${c.hex}-${i}`}
color={c}
onChange={(next) => {
const colors = [...manifest.colors]
colors[i] = next
setManifest({ ...manifest, colors })
}}
onRemove={() => setManifest({ ...manifest, colors: manifest.colors.filter((_, j) => j !== i) })}
/>
))}
</div>
</section>
<section className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Asset groups</h3>
<button type="button" onClick={addGroup} className="btn-secondary text-sm"><Plus className="h-4 w-4 inline" /> Add group</button>
</div>
<div className="space-y-6">
{manifest.groups.map((group) => (
<div key={group.id} className="rounded-lg border border-neutral-200 dark:border-neutral-700 p-4">
<div className="grid gap-3 sm:grid-cols-2 mb-4">
<input
className="input"
value={group.title}
onChange={(e) => updateGroup(group.id, { title: e.target.value })}
placeholder="Group title"
/>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={group.visible !== false}
onChange={(e) => updateGroup(group.id, { visible: e.target.checked })}
/>
Visible on public page
</label>
<textarea
className="input sm:col-span-2"
rows={2}
value={group.description}
onChange={(e) => updateGroup(group.id, { description: e.target.value })}
placeholder="Description"
/>
</div>
<table className="w-full text-sm mb-3">
<thead>
<tr className="text-left border-b">
<th className="py-2">Title</th>
<th className="py-2">Path</th>
<th className="py-2">Format</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{group.assets.map((asset) => (
<tr key={asset.path} className="border-b border-neutral-100">
<td className="py-2 pr-2">
<input
className="input text-sm"
value={asset.title}
onChange={(e) => {
updateGroup(group.id, {
assets: group.assets.map((a) =>
a.path === asset.path ? { ...a, title: e.target.value } : a,
),
})
}}
/>
</td>
<td className="py-2 pr-2 font-mono text-xs">{asset.path}</td>
<td className="py-2 pr-2">{asset.format}</td>
<td className="py-2">
<button type="button" className="text-red-600 text-xs" onClick={() => removeAsset(group.id, asset.path)}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
<button
type="button"
className="btn-secondary text-sm"
onClick={() => {
setUploadGroupId(group.id)
fileInputRef.current?.click()
}}
>
<Upload className="h-3 w-3 inline" /> Upload to this group
</button>
</div>
))}
</div>
</section>
<section className="card">
<h3 className="text-lg font-semibold mb-4">Uploaded files on server</h3>
{files.length === 0 ? (
<p className="text-sm text-neutral-500">No files in brand storage yet. Upload assets or set MIM_BRAND_DIR to your web /brand folder.</p>
) : (
<ul className="space-y-2 text-sm">
{files.map((f) => (
<li key={f.name} className="flex justify-between items-center p-2 bg-neutral-50 dark:bg-neutral-800 rounded">
<span>{f.name} <span className="text-neutral-500">({Math.round(f.size / 1024)} KB)</span></span>
<button type="button" onClick={() => removeFile(f.name)} className="text-red-600 flex items-center gap-1">
<Trash2 className="h-3 w-3" /> Delete
</button>
</li>
))}
</ul>
)}
</section>
</div>
</PageShell>
</PortalWrapper>
)
}
function ColorRow({
color,
onChange,
onRemove,
}: {
color: BrandColor
onChange: (c: BrandColor) => void
onRemove: () => void
}) {
return (
<div className="flex flex-wrap gap-2 items-center">
<input className="input flex-1 min-w-[120px]" value={color.name} onChange={(e) => onChange({ ...color, name: e.target.value })} />
<input className="input w-28 font-mono" value={color.hex} onChange={(e) => onChange({ ...color, hex: e.target.value })} />
<span className="h-8 w-8 rounded border" style={{ backgroundColor: color.hex }} aria-hidden />
<input className="input flex-1 min-w-[120px]" value={color.role} onChange={(e) => onChange({ ...color, role: e.target.value })} />
<button type="button" onClick={onRemove} className="text-red-600 text-sm">Remove</button>
</div>
)
}
export default AdminBrandPage
+303
View File
@@ -0,0 +1,303 @@
import { useCallback, useEffect, useState } from 'react'
import { ExternalLink, Pause, Play, Plus, QrCode, RefreshCw } from 'lucide-react'
import { SEOHead } from '../SEO/SEOHead'
import { AppPageShell as PageShell } from '../layout/AppPageShell'
import { PortalWrapper } from './PortalWrapper'
import { useAuth } from '../../contexts/AuthContext'
import { useNotifications } from '../../contexts/NotificationContext'
import {
createAdminQrcode,
createAdminQrcodePreset,
fetchAdminQrcodes,
fetchAdminQrcodeStatus,
updateAdminQrcode,
type AdminQrcode,
type AdminQrcodeStatus,
} from '../../lib/mimApi'
const PRESET_LABELS: Record<string, string> = {
donate: 'Donate page',
brand: 'Brand assets',
home: 'Homepage',
}
export function AdminQrPage() {
const { user, logout } = useAuth()
const { addNotification } = useNotifications()
const [status, setStatus] = useState<AdminQrcodeStatus | null>(null)
const [codes, setCodes] = useState<AdminQrcode[]>([])
const [loading, setLoading] = useState(true)
const [creating, setCreating] = useState(false)
const [url, setUrl] = useState('https://mim4u.org/donate')
const [title, setTitle] = useState('MIM4U — Donate')
const load = useCallback(async () => {
setLoading(true)
try {
const [st, list] = await Promise.all([fetchAdminQrcodeStatus(), fetchAdminQrcodes()])
setStatus(st)
setCodes(list.data || [])
} catch (e) {
addNotification({
type: 'error',
title: 'QR codes',
message: e instanceof Error ? e.message : 'Could not load QR codes',
})
} finally {
setLoading(false)
}
}, [addNotification])
useEffect(() => {
load()
}, [load])
const handleCreate = async () => {
setCreating(true)
try {
await createAdminQrcode({ url, title })
addNotification({ type: 'success', title: 'QR created', message: title })
await load()
} catch (e) {
addNotification({ type: 'error', title: 'Create failed', message: e instanceof Error ? e.message : 'Error' })
} finally {
setCreating(false)
}
}
const handlePreset = async (preset: string) => {
setCreating(true)
try {
await createAdminQrcodePreset(preset)
addNotification({ type: 'success', title: 'Preset QR created', message: PRESET_LABELS[preset] || preset })
await load()
} catch (e) {
addNotification({ type: 'error', title: 'Preset failed', message: e instanceof Error ? e.message : 'Error' })
} finally {
setCreating(false)
}
}
const toggleStatus = async (code: AdminQrcode) => {
const next = code.status === 'active' ? 'paused' : 'active'
try {
await updateAdminQrcode(code.id, { status: next })
await load()
} catch (e) {
addNotification({ type: 'error', title: 'Update failed', message: e instanceof Error ? e.message : 'Error' })
}
}
return (
<PortalWrapper user={user} onLogout={logout} portalTitle="QR Code Manager">
<SEOHead title="QR Codes — Admin" description="Manage dynamic QR codes for MIM4U" />
<PageShell>
<div className="max-w-5xl mx-auto space-y-8">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<QrCode className="h-7 w-7 text-primary-600" />
QR Code Manager
</h1>
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
Branded QR codes via{' '}
<a
href="https://www.qrcode-monkey.com/qr-code-api-with-logo/"
className="text-primary-600 hover:underline"
target="_blank"
rel="noreferrer"
>
QR Code Monkey
</a>
{' '}(default). Optional{' '}
<a
href="https://dev.qrcg.com/"
className="text-primary-600 hover:underline"
target="_blank"
rel="noreferrer"
>
QRCG
</a>{' '}
for dynamic URL tracking set <code className="text-xs">MIM_QR_PROVIDER=qrcg</code>.
</p>
</div>
<button type="button" onClick={load} className="btn-secondary flex items-center gap-2" disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<div className="card">
<h2 className="text-lg font-semibold mb-3">Approved brand styling</h2>
<div className="flex flex-col sm:flex-row gap-6 items-start">
<img
src="/brand/qr-code-branded-reference.png"
alt="MIM4U branded QR code reference — forest green background, gold diamond modules"
className="w-40 h-40 rounded-lg border border-neutral-200 dark:border-neutral-700 shrink-0"
/>
<div className="text-sm text-neutral-600 dark:text-neutral-400 space-y-2">
<p>
New codes use the approved palette: forest green <code>#1a3c34</code> background, gold{' '}
<code>#c5a059</code> diamond modules, and leaf corner finders.
</p>
<p>
Center logo is embedded automatically from{' '}
<code>QR_MONKEY_LOGO_URL</code> (defaults to <code>/brand/logo-square.png</code> on mim4u.org).
</p>
</div>
</div>
</div>
<div className="card">
<h2 className="text-lg font-semibold mb-2">API status</h2>
{!status ? (
<p className="text-sm text-neutral-500">Loading</p>
) : status.provider === 'qrcode-monkey' ? (
<div className="text-sm space-y-1">
<p className="text-green-700 dark:text-green-300">
Using QR Code Monkey ({status.apiBase?.includes('rapidapi') ? 'RapidAPI' : 'direct API'}). Branded
PNGs saved on mim-api.
</p>
{status.rapidApiNote && (
<p className="text-amber-700 dark:text-amber-300">{status.rapidApiNote}</p>
)}
</div>
) : !status.configured ? (
<p className="text-sm text-amber-700 dark:text-amber-300">
QRCG provider selected but <code>QRCG_API_KEY</code> is not set. Add it to <code>.env.production</code>{' '}
or set <code>MIM_QR_PROVIDER=qrcode-monkey</code>.
</p>
) : status.accountError ? (
<p className="text-sm text-red-600 dark:text-red-400">
QRCG key is set but account check failed: {status.accountError}
</p>
) : (
<p className="text-sm text-green-700 dark:text-green-300">Connected to QRCG API (dynamic tracking).</p>
)}
</div>
<div className="card space-y-4">
<h2 className="text-lg font-semibold">Quick presets</h2>
<div className="flex flex-wrap gap-2">
{Object.keys(PRESET_LABELS).map((key) => (
<button
key={key}
type="button"
className="btn-secondary text-sm"
disabled={creating || !status?.configured}
onClick={() => handlePreset(key)}
>
{PRESET_LABELS[key]}
</button>
))}
</div>
</div>
<div className="card space-y-4">
<h2 className="text-lg font-semibold">Create custom URL</h2>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="text-sm font-medium">Title</span>
<input
className="input mt-1 w-full"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={150}
/>
</label>
<label className="block sm:col-span-2">
<span className="text-sm font-medium">Destination URL</span>
<input
className="input mt-1 w-full"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://mim4u.org/…"
/>
</label>
</div>
<button
type="button"
className="btn-primary flex items-center gap-2"
disabled={creating || !status?.configured}
onClick={handleCreate}
>
<Plus className="h-4 w-4" />
Create QR code
</button>
</div>
<div className="card">
<h2 className="text-lg font-semibold mb-4">Your QR codes</h2>
{loading ? (
<p className="text-sm text-neutral-500">Loading</p>
) : codes.length === 0 ? (
<p className="text-sm text-neutral-500">No QR codes yet. Create one above.</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{codes.map((code) => (
<li key={code.id} className="py-4 flex flex-col sm:flex-row sm:items-center gap-4">
{code.previewUrl ? (
<img
src={code.previewUrl}
alt=""
className="h-24 w-24 rounded border border-neutral-200 dark:border-neutral-700 bg-white"
/>
) : (
<div className="h-24 w-24 rounded border flex items-center justify-center text-neutral-400">
<QrCode className="h-10 w-10" />
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{code.title || `QR ${code.id}`}</p>
<p className="text-sm text-neutral-500 truncate">{code.url}</p>
{code.shortUrl && (
<a
href={code.shortUrl}
target="_blank"
rel="noreferrer"
className="text-sm text-primary-600 hover:underline inline-flex items-center gap-1 mt-1"
>
{code.shortUrl}
<ExternalLink className="h-3 w-3" />
</a>
)}
<p className="text-xs text-neutral-500 mt-1">
{code.status} · {code.scans?.total ?? 0} scans
{code.purpose ? ` · ${code.purpose}` : ''}
</p>
</div>
<div className="flex gap-2 shrink-0">
{code.provider !== 'qrcode-monkey' && (
<button
type="button"
className="btn-secondary text-sm"
onClick={() => toggleStatus(code)}
title={code.status === 'active' ? 'Pause' : 'Activate'}
>
{code.status === 'active' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
)}
{code.previewUrl && (
<a href={code.previewUrl} download className="btn-secondary text-sm">
Download
</a>
)}
</div>
</li>
))}
</ul>
)}
</div>
<p className="text-center text-sm">
<a href="/admin-portal" className="text-primary-600 hover:underline">
Back to admin portal
</a>
</p>
</div>
</PageShell>
</PortalWrapper>
)
}
export default AdminQrPage
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react'
import { Save, Settings } from 'lucide-react'
import { SEOHead } from '../SEO/SEOHead'
import { AppPageShell as PageShell } from '../layout/AppPageShell'
import { useAuth } from '../../contexts/AuthContext'
import { useNotifications } from '../../contexts/NotificationContext'
import { PortalWrapper } from './PortalWrapper'
import { StripeStatusPanel } from './StripeStatusPanel'
import { fetchAdminSettings, updateAdminSettings, type AdminSettings } from '../../lib/mimApi'
export function AdminSettingsPage() {
const { user, logout } = useAuth()
const { addNotification } = useNotifications()
const [settings, setSettings] = useState<AdminSettings | null>(null)
const [form, setForm] = useState({
donationsEnabled: true,
stripePublishableKey: '',
stripeSecretKey: '',
stripeWebhookSecret: '',
})
const [saving, setSaving] = useState(false)
useEffect(() => {
fetchAdminSettings()
.then((s) => {
setSettings(s)
setForm({
donationsEnabled: s.donationsEnabled,
stripePublishableKey: s.stripePublishableKey || '',
stripeSecretKey: '',
stripeWebhookSecret: '',
})
})
.catch(() => addNotification({ type: 'error', title: 'Settings', message: 'Could not load settings' }))
}, [addNotification])
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
try {
const patch: Partial<AdminSettings> = {
donationsEnabled: form.donationsEnabled,
stripePublishableKey: form.stripePublishableKey,
}
if (form.stripeSecretKey.trim()) patch.stripeSecretKey = form.stripeSecretKey.trim()
if (form.stripeWebhookSecret.trim()) patch.stripeWebhookSecret = form.stripeWebhookSecret.trim()
const next = await updateAdminSettings(patch)
setSettings(next)
setForm((f) => ({ ...f, stripeSecretKey: '', stripeWebhookSecret: '' }))
addNotification({ type: 'success', title: 'Settings saved', message: 'Stripe and donation settings updated.' })
} catch (err) {
addNotification({
type: 'error',
title: 'Save failed',
message: err instanceof Error ? err.message : 'Could not save settings',
})
} finally {
setSaving(false)
}
}
return (
<PortalWrapper requiredRole="admin">
<SEOHead title="System Settings" description="Configure Stripe donations and payment settings for mim4u.org." />
<PageShell
title="System Settings"
icon={Settings}
eyebrow={`Payments & donations — ${user?.name}`}
cta={
<div className="flex gap-2">
<a href="/admin-portal" className="btn-secondary">Back to dashboard</a>
<button type="button" onClick={logout} className="btn-secondary">Sign Out</button>
</div>
}
>
<div className="grid gap-8 lg:grid-cols-2">
<form onSubmit={handleSave} className="card space-y-5">
<h3 className="text-lg font-semibold">Stripe & public donations</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.donationsEnabled}
onChange={(e) => setForm({ ...form, donationsEnabled: e.target.checked })}
className="h-4 w-4 rounded border-neutral-300"
/>
<span>
<span className="font-medium">Enable public Stripe donations</span>
<span className="block text-sm text-neutral-500">When off, the donate page hides live card checkout.</span>
</span>
</label>
<div>
<label className="block text-sm font-medium mb-1">Publishable key</label>
<input
className="input w-full font-mono text-sm"
value={form.stripePublishableKey}
onChange={(e) => setForm({ ...form, stripePublishableKey: e.target.value })}
placeholder="pk_live_…"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Secret key</label>
<input
type="password"
className="input w-full font-mono text-sm"
value={form.stripeSecretKey}
onChange={(e) => setForm({ ...form, stripeSecretKey: e.target.value })}
placeholder={settings?.stripeSecretKey ? `Current: ${settings.stripeSecretKey}` : 'sk_live_…'}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Webhook secret</label>
<input
type="password"
className="input w-full font-mono text-sm"
value={form.stripeWebhookSecret}
onChange={(e) => setForm({ ...form, stripeWebhookSecret: e.target.value })}
placeholder={settings?.stripeWebhookSecret ? `Current: ${settings.stripeWebhookSecret}` : 'whsec_…'}
/>
<p className="text-xs text-neutral-500 mt-1">
Webhook URL: <code className="bg-neutral-100 dark:bg-neutral-800 px-1 rounded">https://mim4u.org/api/webhooks/stripe</code>
</p>
</div>
{settings?.updatedAt && (
<p className="text-xs text-neutral-500">
Last updated {new Date(settings.updatedAt).toLocaleString()}
{settings.updatedBy ? ` by ${settings.updatedBy}` : ''}
</p>
)}
<button type="submit" disabled={saving} className="btn-primary flex items-center gap-2">
<Save className="h-4 w-4" /> {saving ? 'Saving…' : 'Save settings'}
</button>
</form>
<StripeStatusPanel />
</div>
</PageShell>
</PortalWrapper>
)
}
export default AdminSettingsPage
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useState } from 'react'
import { Activity, Heart, Users } from 'lucide-react'
import { fetchRecentFeeds } from '../../lib/mimApi'
export function LiveFeedPanel() {
const [feed, setFeed] = useState<Awaited<ReturnType<typeof fetchRecentFeeds>> | null>(null)
useEffect(() => {
const load = () => fetchRecentFeeds().then(setFeed).catch(() => {})
load()
const id = setInterval(load, 15000)
return () => clearInterval(id)
}, [])
if (!feed) return <div className="card text-sm text-neutral-500">Loading live feed</div>
const items = [
...feed.donations.map((d) => ({
key: `d-${d.id}`,
icon: Heart,
color: 'text-green-600',
title: `Donation $${d.amountUsd}`,
detail: d.email || 'Anonymous',
time: d.timeAgo,
})),
...feed.assistance.map((a) => ({
key: `a-${a.id}`,
icon: Users,
color: 'text-blue-600',
title: a.type,
detail: `${a.student}${a.school || 'LA County'}`,
time: a.timeAgo,
})),
].slice(0, 12)
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Activity className="h-5 w-5" /> Live Feed
</h3>
<span className="text-xs text-neutral-500">Updates every 15s</span>
</div>
{items.length === 0 ? (
<p className="text-sm text-neutral-500">No recent donations or assistance requests yet.</p>
) : (
<ul className="space-y-3">
{items.map((item) => (
<li key={item.key} className="flex gap-3 p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<item.icon className={`h-5 w-5 shrink-0 ${item.color}`} />
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{item.title}</div>
<div className="text-sm text-neutral-600 dark:text-neutral-400 truncate">{item.detail}</div>
</div>
<span className="text-xs text-neutral-500 shrink-0">{item.time}</span>
</li>
))}
</ul>
)}
</div>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { useState } from 'react'
import { motion } from 'framer-motion'
import { Lock } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
export function LoginForm({ requiredRole }: { requiredRole?: 'admin' | 'volunteer' | 'resource' }) {
const { login, isLoading, restoring } = useAuth()
const [formData, setFormData] = useState({ email: '', password: '' })
const [error, setError] = useState('')
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
const success = await login(formData.email, formData.password)
if (!success) setError('Invalid credentials. Please try again.')
}
if (restoring) {
return (
<div className="min-h-screen flex items-center justify-center">
<p className="text-neutral-600 dark:text-neutral-400">Restoring session</p>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-gray-900 dark:to-gray-800 px-4">
<motion.div className="w-full max-w-md" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}>
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 bg-primary-600 text-white rounded-full mb-4">
<Lock className="w-8 h-8" />
</div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{requiredRole ? `${requiredRole.charAt(0).toUpperCase() + requiredRole.slice(1)} Portal` : 'Portal Access'}
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-2">Sign in with your MIM staff credentials</p>
</div>
<div className="card">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">Email Address</label>
<input
id="email"
type="email"
required
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="input w-full"
placeholder="[email protected]"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium mb-2">Password</label>
<input
id="password"
type="password"
required
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
className="input w-full"
placeholder="Enter your password"
/>
</div>
{error && <p className="text-red-600 text-sm p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">{error}</p>}
<button type="submit" disabled={isLoading} className="w-full btn-primary disabled:opacity-50">
{isLoading ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
</motion.div>
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
import { AlertCircle } from 'lucide-react'
import { useAuth } from '../../contexts/AuthContext'
import { LoginForm } from './LoginForm'
export function PortalWrapper({
children,
requiredRole,
}: {
children: React.ReactNode
requiredRole?: 'admin' | 'volunteer' | 'resource'
}) {
const { user, logout } = useAuth()
if (!user) return <LoginForm requiredRole={requiredRole} />
if (requiredRole && user.role !== requiredRole && user.role !== 'admin') {
return (
<div className="min-h-screen flex items-center justify-center px-4">
<div className="card max-w-md w-full text-center">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-bold mb-2">Access Denied</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
You don&apos;t have permission to access the {requiredRole} portal.
</p>
<button type="button" onClick={logout} className="btn-secondary">
Sign out
</button>
</div>
</div>
)
}
return <>{children}</>
}
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import { CreditCard, RefreshCw, Wallet } from 'lucide-react'
import { fetchStripeStatus } from '../../lib/mimApi'
function formatUsd(cents: number, currency = 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: currency.toUpperCase() }).format(cents / 100)
}
export function StripeStatusPanel() {
const [status, setStatus] = useState<Awaited<ReturnType<typeof fetchStripeStatus>> | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const load = () => {
setLoading(true)
setError('')
fetchStripeStatus()
.then(setStatus)
.catch((e) => setError(e instanceof Error ? e.message : 'Failed to load Stripe status'))
.finally(() => setLoading(false))
}
useEffect(() => {
load()
const id = setInterval(load, 30000)
return () => clearInterval(id)
}, [])
return (
<div className="card">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold flex items-center gap-2">
<CreditCard className="h-5 w-5" /> Stripe Account
</h3>
<button type="button" onClick={load} className="btn-secondary text-sm flex items-center gap-1" disabled={loading}>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} /> Refresh
</button>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{status && (
<div className="space-y-4 text-sm">
<div className="flex flex-wrap gap-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${status.configured ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'}`}>
{status.configured ? 'Connected' : 'Not configured'}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${status.donationsEnabled ? 'bg-blue-100 text-blue-800' : 'bg-neutral-200 text-neutral-700'}`}>
Donations {status.donationsEnabled ? 'enabled' : 'disabled'}
</span>
{status.configured && (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-neutral-100 text-neutral-700">
{status.livemode ? 'Live mode' : 'Test mode'}
</span>
)}
</div>
{status.balance && (
<div className="grid gap-3 sm:grid-cols-2">
<div className="p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<div className="flex items-center gap-2 text-neutral-500 mb-1"><Wallet className="h-4 w-4" /> Available</div>
<div className="font-semibold">
{status.balance.available.map((b) => formatUsd(b.amount, b.currency)).join(' · ') || '—'}
</div>
</div>
<div className="p-3 rounded-lg bg-neutral-50 dark:bg-neutral-800">
<div className="text-neutral-500 mb-1">Pending</div>
<div className="font-semibold">
{status.balance.pending.map((b) => formatUsd(b.amount, b.currency)).join(' · ') || '—'}
</div>
</div>
</div>
)}
<div>
<h4 className="font-medium mb-2">Recent charges</h4>
{status.recentCharges.length === 0 ? (
<p className="text-neutral-500">No recent charges</p>
) : (
<ul className="space-y-2 max-h-48 overflow-y-auto">
{status.recentCharges.map((c) => (
<li key={c.id} className="flex justify-between p-2 rounded bg-neutral-50 dark:bg-neutral-800">
<span>{c.receiptEmail || c.description || c.id.slice(0, 12)}</span>
<span className="font-medium">{formatUsd(c.amount, c.currency)}</span>
</li>
))}
</ul>
)}
</div>
</div>
)}
</div>
)
}
+4 -2
View File
@@ -1,6 +1,6 @@
import { lazy, Suspense, useState } from 'react'
import { Heart, Loader2 } from 'lucide-react'
import { createCheckoutSession, isStripeConfigured } from '../../lib/mimApi'
import { createCheckoutSession } from '../../lib/mimApi'
const LazyStripeElements = lazy(() =>
import('../payments/StripePaymentForm').then((m) => ({ default: m.StripePaymentForm })),
@@ -12,6 +12,7 @@ interface StripeDonatePanelProps {
donorEmail: string
donorName: string
anonymous: boolean
stripeEnabled?: boolean
onSuccess?: () => void
}
@@ -21,13 +22,14 @@ export function StripeDonatePanel({
donorEmail,
donorName,
anonymous,
stripeEnabled = false,
onSuccess,
}: StripeDonatePanelProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [useElements, setUseElements] = useState(false)
if (!isStripeConfigured()) {
if (!stripeEnabled) {
return (
<p className="text-sm text-neutral-600 dark:text-neutral-400" role="status">
Online card payments are being configured. You can mail a check or call (818) 491-6884 to donate today.
+31 -63
View File
@@ -1,72 +1,49 @@
import React, { createContext, useContext, useState, ReactNode } from 'react'
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import {
fetchMe,
loginApi,
setAuthToken,
getAuthToken,
type AuthUser,
} from '../lib/mimApi'
// Types
export interface AuthUser {
id: string
email: string
role: 'admin' | 'volunteer' | 'resource'
name: string
lastLogin: Date
permissions: string[]
}
export type { AuthUser }
export interface AuthContextType {
user: AuthUser | null
login: (email: string, password: string) => Promise<boolean>
logout: () => void
isLoading: boolean
restoring: boolean
}
// Create Context
const AuthContext = createContext<AuthContextType | null>(null)
// Auth Provider Component
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [restoring, setRestoring] = useState(true)
useEffect(() => {
const token = getAuthToken()
if (!token) {
setRestoring(false)
return
}
fetchMe()
.then(({ user: me }) => setUser(me))
.catch(() => setAuthToken(null))
.finally(() => setRestoring(false))
}, [])
const login = async (email: string, password: string): Promise<boolean> => {
setIsLoading(true)
try {
// Mock authentication - replace with real API call
console.log('🔐 Attempting login for:', email)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
// Mock user data based on email
const mockUsers: Record<string, AuthUser> = {
'[email protected]': {
id: '1',
email: '[email protected]',
role: 'admin',
name: 'Admin User',
lastLogin: new Date(),
permissions: ['all']
},
'[email protected]': {
id: '2',
email: '[email protected]',
role: 'volunteer',
name: 'Volunteer User',
lastLogin: new Date(),
permissions: ['view_requests', 'update_assignments']
}
}
const authenticatedUser = mockUsers[email]
if (authenticatedUser && password === 'demo123') {
setUser(authenticatedUser)
localStorage.setItem('authToken', `token-${authenticatedUser.id}`)
console.log('✅ Login successful')
return true
} else {
console.log('❌ Login failed')
return false
}
} catch (error) {
console.error('Login error:', error)
const { token, user: authenticated } = await loginApi(email, password)
setAuthToken(token)
setUser(authenticated)
return true
} catch {
return false
} finally {
setIsLoading(false)
@@ -75,29 +52,20 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const logout = (): void => {
setUser(null)
localStorage.removeItem('authToken')
console.log('👋 User logged out')
}
const value: AuthContextType = {
user,
login,
logout,
isLoading
setAuthToken(null)
}
return (
<AuthContext.Provider value={value}>
<AuthContext.Provider value={{ user, login, logout, isLoading, restoring }}>
{children}
</AuthContext.Provider>
)
}
// Custom hook for using auth context
export const useAuth = (): AuthContextType => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
}
+42
View File
@@ -0,0 +1,42 @@
import { useEffect, useState } from 'react'
import { fetchPublicConfig, isStripeConfigured, type PublicConfig } from '../lib/mimApi'
const FALLBACK: PublicConfig = {
donationsEnabled: false,
stripeConfigured: false,
stripePublishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '',
}
export function usePublicConfig() {
const [config, setConfig] = useState<PublicConfig>(() => ({
...FALLBACK,
stripeConfigured: isStripeConfigured(),
donationsEnabled: isStripeConfigured(),
}))
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
fetchPublicConfig()
.then((c) => {
if (!cancelled) setConfig(c)
})
.catch(() => {
if (!cancelled) {
setConfig({
donationsEnabled: isStripeConfigured(),
stripeConfigured: isStripeConfigured(),
stripePublishableKey: import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || '',
})
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [])
return { config, loading, stripeLive: config.donationsEnabled && config.stripeConfigured }
}
+85 -1
View File
@@ -10,6 +10,10 @@
/* Official MIM brand palette (logo: forest green + gold) */
:root {
--mim-header-bg: #023b2b;
--mim-header-gold: #d9aa45;
--mim-header-logo-gold-filter: brightness(0) saturate(100%) invert(77%) sepia(42%)
saturate(561%) hue-rotate(6deg) brightness(96%) contrast(89%);
--mim-brand-green: #1a3c34;
--mim-brand-green-deep: #0f2922;
--mim-brand-green-mid: #2d6b5c;
@@ -135,6 +139,28 @@
/* Nav: symbol on mobile, transparent horizontal lockup on md+ */
.logo-mark--nav {
height: 3.25rem;
transform-style: preserve-3d;
will-change: transform;
animation: logo-nav-float 7s ease-in-out infinite;
transition: transform 0.45s cubic-bezier(0.22, 1, 0.36, 1), filter 0.35s ease;
}
.site-header .logo-mark--nav .logo-mark__img {
filter: var(--mim-header-logo-gold-filter)
drop-shadow(0 2px 6px rgba(217, 170, 69, 0.28))
drop-shadow(0 8px 18px rgba(2, 59, 43, 0.35));
}
.site-header .nav-brand-link {
perspective: 720px;
transform-style: preserve-3d;
}
.site-header .nav-brand-link:hover .logo-mark--nav,
.site-header .nav-brand-link:focus-visible .logo-mark--nav {
animation-play-state: paused;
transform: rotateY(-10deg) rotateX(5deg) translateZ(10px) scale(1.03);
filter: drop-shadow(0 4px 10px rgba(217, 170, 69, 0.42));
}
.logo-mark--nav .logo-mark__img-wrap--symbol,
@@ -246,6 +272,40 @@
object-fit: cover;
}
.site-header .navlink {
color: rgba(255, 254, 251, 0.9);
}
.site-header .navlink:hover,
.site-header .navlink:focus-visible {
color: var(--mim-header-gold);
}
.site-header .mim-site-nav button {
border-color: rgba(255, 255, 255, 0.18);
background-color: rgba(255, 255, 255, 0.08);
color: rgba(255, 254, 251, 0.95);
}
.site-header .mim-site-nav button:hover {
border-color: rgba(217, 170, 69, 0.45);
background-color: rgba(255, 255, 255, 0.14);
}
.site-header .mobile-nav-panel {
border-color: rgba(217, 170, 69, 0.22);
background-color: var(--mim-header-bg);
}
.site-header .mobile-nav-panel a {
color: rgba(255, 254, 251, 0.95);
}
.site-header .mobile-nav-panel a:hover,
.site-header .mobile-nav-panel a:focus-visible {
color: var(--mim-header-gold);
}
.nav-brand-link {
display: inline-flex;
align-items: center;
@@ -283,6 +343,10 @@
outline-offset: 3px;
}
.site-header .nav-brand-link:focus-visible {
outline-color: var(--mim-header-gold);
}
.footer-brand-block {
display: flex;
flex-direction: column;
@@ -598,6 +662,19 @@
}
}
@keyframes logo-nav-float {
0%,
100% {
transform: translateY(0) rotateX(0deg) rotateY(0deg);
}
35% {
transform: translateY(-2px) rotateX(2.5deg) rotateY(-4deg);
}
70% {
transform: translateY(1px) rotateX(-1.5deg) rotateY(3deg);
}
}
.focus-visible\:ring-2:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
@@ -627,10 +704,17 @@
.animate-marquee,
.animate-float,
.animate-pulse-slow {
.animate-pulse-slow,
.logo-mark--nav {
animation: none;
}
.site-header .nav-brand-link:hover .logo-mark--nav,
.site-header .nav-brand-link:focus-visible .logo-mark--nav {
transform: none;
filter: none;
}
/* Framer Motion loops/parallax gated in JS via MotionConfig + useMotionSafe */
}
+403 -7
View File
@@ -1,10 +1,22 @@
const API_BASE = (import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')
const AUTH_TOKEN_KEY = 'mimAuthToken'
function apiUrl(path: string): string {
const p = path.startsWith('/') ? path : `/${path}`
return API_BASE ? `${API_BASE}${p}` : p
}
export function getAuthToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(AUTH_TOKEN_KEY)
}
export function setAuthToken(token: string | null) {
if (typeof window === 'undefined') return
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
else localStorage.removeItem(AUTH_TOKEN_KEY)
}
export class MimApiError extends Error {
constructor(
message: string,
@@ -16,13 +28,17 @@ export class MimApiError extends Error {
}
}
export async function mimApiPost<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(apiUrl(path), {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
function authHeaders(): Record<string, string> {
const token = getAuthToken()
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function parseJson(res: Response) {
return res.json().catch(() => ({}))
}
async function handleResponse<T>(res: Response): Promise<T> {
const data = await parseJson(res)
if (!res.ok) {
throw new MimApiError(
(data as { error?: string }).error || `Request failed (${res.status})`,
@@ -33,6 +49,55 @@ export async function mimApiPost<T>(path: string, body: unknown): Promise<T> {
return data as T
}
export async function mimApiGet<T>(path: string, auth = false): Promise<T> {
const res = await fetch(apiUrl(path), {
method: 'GET',
headers: { Accept: 'application/json', ...(auth ? authHeaders() : {}) },
})
return handleResponse<T>(res)
}
export async function mimApiPost<T>(path: string, body: unknown, auth = false): Promise<T> {
const res = await fetch(apiUrl(path), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...(auth ? authHeaders() : {}),
},
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export async function mimApiPatch<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(apiUrl(path), {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders(),
},
body: JSON.stringify(body),
})
return handleResponse<T>(res)
}
export interface PublicConfig {
donationsEnabled: boolean
stripeConfigured: boolean
stripePublishableKey: string
}
export interface AuthUser {
id: string
email: string
role: 'admin' | 'volunteer' | 'resource'
name: string
lastLogin?: string
permissions: string[]
}
export function isStripeConfigured(): boolean {
return Boolean(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY)
}
@@ -41,6 +106,18 @@ export function allowMockDonate(): boolean {
return import.meta.env.VITE_ALLOW_MOCK_DONATE === '1'
}
export async function fetchPublicConfig(): Promise<PublicConfig> {
return mimApiGet<PublicConfig>('/api/public/config')
}
export async function loginApi(email: string, password: string): Promise<{ token: string; user: AuthUser }> {
return mimApiPost('/api/auth/login', { email, password })
}
export async function fetchMe(): Promise<{ user: AuthUser }> {
return mimApiGet('/api/auth/me', true)
}
export async function createCheckoutSession(payload: {
amount: number
recurring?: boolean
@@ -65,3 +142,322 @@ export async function submitContactForm(payload: {
}): Promise<{ ok: boolean; id: string; message: string }> {
return mimApiPost('/api/contact', payload)
}
export interface AdminSettings {
donationsEnabled: boolean
stripePublishableKey: string
stripeSecretKey: string
stripeWebhookSecret: string
stripeConfigured: boolean
updatedAt: string | null
updatedBy: string | null
}
export async function fetchAdminSettings(): Promise<AdminSettings> {
return mimApiGet('/api/admin/settings', true)
}
export async function updateAdminSettings(patch: Partial<AdminSettings>): Promise<AdminSettings> {
return mimApiPatch('/api/admin/settings', patch)
}
export async function fetchAdminDashboard() {
return mimApiGet<{
pendingRequests: number
activeVolunteers: number
deliveriesToday: number
monthlyBudget: number
monthlySpent: number
monthlyDonationCents: number
donationCount: number
}>('/api/admin/dashboard', true)
}
export async function fetchAssistanceRequests(limit = 50) {
return mimApiGet<{ data: AssistanceRequestRow[]; total: number }>(`/api/admin/assistance-requests?limit=${limit}`, true)
}
export interface AssistanceRequestRow {
id: string
requestType: string
student: string
school: string
need: string
priority: string
status: string
contactName?: string
contactEmail?: string
contactPhone?: string
timeAgo: string
ts: string
}
export async function updateAssistanceRequest(id: string, patch: { status?: string; adminNotes?: string }) {
return mimApiPatch(`/api/admin/assistance-requests/${id}`, patch)
}
export async function fetchDonations(limit = 50) {
return mimApiGet<{ data: DonationRow[]; total: number }>(`/api/admin/donations?limit=${limit}`, true)
}
export interface DonationRow {
id: string
amountCents: number
amountUsd: string
email?: string
donorName?: string
anonymous?: boolean
ts: string
timeAgo: string
}
export async function fetchAnalyticsSummary() {
return mimApiGet('/api/admin/analytics/summary', true)
}
export async function fetchAnalyticsActivity() {
return mimApiGet<{ feed: ActivityFeedItem[] }>('/api/admin/analytics/activity', true)
}
export interface ActivityFeedItem {
type: string
title: string
detail: string
ts: string
timeAgo: string
}
export async function fetchRecentFeeds() {
return mimApiGet<{
donations: { id: string; amountUsd: string; email?: string; ts: string; timeAgo: string }[]
assistance: { id: string; type: string; student: string; school?: string; status: string; ts: string; timeAgo: string }[]
generatedAt: string
}>('/api/admin/feeds/recent', true)
}
export async function fetchStripeStatus() {
return mimApiGet<{
configured: boolean
donationsEnabled: boolean
publishableKeySet: boolean
livemode: boolean
balance: { available: { amount: number; currency: string }[]; pending: { amount: number; currency: string }[] } | null
recentCharges: {
id: string
amount: number
currency: string
status: string
created: number
receiptEmail: string | null
description: string | null
}[]
}>('/api/admin/stripe/status', true)
}
export async function fetchAdvancedAnalytics() {
return mimApiGet('/api/admin/analytics/advanced', true)
}
export async function fetchVolunteerSchedule() {
return mimApiGet<{ date: string; tasks: VolunteerTask[] }>('/api/volunteer/schedule', true)
}
export interface VolunteerTask {
id: string
time: string
task: string
location: string
students: number | null
status: string
}
export async function fetchVolunteerAssignments() {
return mimApiGet<{ data: VolunteerAssignment[] }>('/api/volunteer/assignments', true)
}
export interface VolunteerAssignment {
id: string
student: string
items: string
school: string
deadline: string
status: string
}
export async function completeVolunteerAssignment(id: string) {
return mimApiPatch(`/api/volunteer/assignments/${id}`, { status: 'completed' })
}
export async function fetchVolunteerStats() {
return mimApiGet<{
familiesHelped: number
kitsAssembled: number
deliveries: number
hoursVolunteered: number
}>('/api/volunteer/stats', true)
}
export async function fetchResourceRequests() {
return mimApiGet<{
data: { id: string; title: string; type: string; status: string; submitted: string; school?: string }[]
summary: { pending: number; approved: number; completed: number }
}>('/api/resource/requests', true)
}
export async function fetchDonationImpact() {
return mimApiGet<{ totalRaisedUsd: number; donationCount: number; familiesSupported: number }>(
'/api/public/donation-impact',
)
}
export async function fetchTrainingModules() {
return mimApiGet<{ modules: { id: string; title: string; progress: number; durationMin: number }[] }>(
'/api/admin/training/modules',
true,
)
}
export interface BrandColor {
name: string
hex: string
role: string
}
export interface BrandTypography {
name: string
use: string
source?: string
}
export interface BrandAsset {
title: string
path: string
format: string
visible?: boolean
}
export interface BrandGroup {
id: string
title: string
description: string
visible?: boolean
assets: BrandAsset[]
}
export interface BrandManifest {
version: string
organization: string
updated: string
published?: boolean
colors: BrandColor[]
typography: BrandTypography[]
kits: { id: string; title: string; path: string; format: string; description?: string }[]
groups: BrandGroup[]
usageNotes?: string
message?: string
}
export async function fetchPublicBrand(): Promise<BrandManifest> {
return mimApiGet('/api/public/brand')
}
export async function fetchAdminBrand(): Promise<{
manifest: BrandManifest
files: { name: string; size: number; mtime: string }[]
}> {
return mimApiGet('/api/admin/brand', true)
}
export async function saveBrandManifest(manifest: BrandManifest): Promise<{ ok: boolean; manifest: BrandManifest }> {
return mimApiPatch('/api/admin/brand', manifest)
}
export async function uploadBrandFile(file: File): Promise<{
ok: boolean
filename: string
path: string
legacyPath: string
}> {
const form = new FormData()
form.append('file', file)
const token = getAuthToken()
const res = await fetch(apiUrl('/api/admin/brand/upload'), {
method: 'POST',
headers: token ? { Authorization: `Bearer ${token}` } : {},
body: form,
})
return handleResponse(res)
}
export async function deleteBrandFile(filename: string): Promise<{ ok: boolean }> {
const token = getAuthToken()
const res = await fetch(apiUrl(`/api/admin/brand/files/${encodeURIComponent(filename)}`), {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
return handleResponse(res)
}
export async function rebuildBrandZip(): Promise<{ ok: boolean; path?: string; fileCount?: number; error?: string }> {
return mimApiPost('/api/admin/brand/rebuild-zip', {}, true)
}
export interface AdminQrcode {
id: string
type?: string
title?: string
status?: string
url?: string
shortUrl?: string
previewUrl?: string
createdAt?: string
purpose?: string | null
provider?: string
imageUrl?: string | null
scans?: { total?: number; unique?: number }
}
export interface AdminQrcodeStatus {
provider?: 'qrcode-monkey' | 'qrcg'
configured: boolean
dynamicTracking?: boolean
rapidApiKeySet?: boolean
rapidApiSubscribed?: boolean
rapidApiNote?: string
apiBase?: string
accountError?: string
presets?: string[]
docsUrl?: string
brand?: {
referenceImage?: string
logoUrl?: string
colors?: { background?: string; foreground?: string }
}
}
export async function fetchAdminQrcodeStatus(): Promise<AdminQrcodeStatus> {
return mimApiGet('/api/admin/qrcodes/status', true)
}
export async function fetchAdminQrcodes(): Promise<{ data: AdminQrcode[]; pagination?: { hasMore?: boolean } }> {
return mimApiGet('/api/admin/qrcodes', true)
}
export async function createAdminQrcode(body: {
url: string
title: string
purpose?: string
}): Promise<AdminQrcode> {
return mimApiPost('/api/admin/qrcodes', body, true)
}
export async function createAdminQrcodePreset(preset: string): Promise<AdminQrcode> {
return mimApiPost(`/api/admin/qrcodes/presets/${encodeURIComponent(preset)}`, {}, true)
}
export async function updateAdminQrcode(
id: string,
body: { status?: 'active' | 'paused'; url?: string; title?: string },
): Promise<AdminQrcode> {
return mimApiPatch(`/api/admin/qrcodes/${encodeURIComponent(id)}`, body)
}
+62 -76
View File
@@ -1,32 +1,63 @@
import { useEffect, useState } from 'react'
import { Download, Palette, Printer, Type } from 'lucide-react'
import manifest from '../../../config/brand-assets.manifest.json'
import fallbackManifest from '../../../config/brand-assets.manifest.json'
import { fetchPublicBrand, type BrandManifest } from '../../lib/mimApi'
import { SEOHead } from '../../components/SEO/SEOHead'
type BrandManifest = typeof manifest
function resolveAssetUrl(path: string) {
if (path.startsWith('http') || path.startsWith('/api/')) return path
return path
}
export function BrandAssetsPage() {
const data = manifest as BrandManifest
const [data, setData] = useState<BrandManifest>(fallbackManifest as BrandManifest)
const [source, setSource] = useState<'api' | 'static'>('static')
useEffect(() => {
fetchPublicBrand()
.then((m) => {
if (m.published === false) return
setData(m)
setSource('api')
})
.catch(() => {
setData(fallbackManifest as BrandManifest)
setSource('static')
})
}, [])
if (data.published === false) {
return (
<div className="mx-auto max-w-2xl px-4 py-24 text-center">
<SEOHead title="Brand Assets" description="Official brand assets for Miracles in Motion Foundation." />
<h1 className="text-2xl font-semibold">Brand kit temporarily unavailable</h1>
<p className="mt-2 text-neutral-600">{data.message || 'Please contact [email protected] for logo files.'}</p>
</div>
)
}
const kitPath = data.kits?.[0]?.path || '/brand/MIM4U-Brand-Kit.zip'
return (
<div className="mx-auto max-w-4xl px-4 py-12 sm:px-6 lg:px-8">
<SEOHead title="Brand Assets" description="Official logos, colors, and guidelines for Miracles in Motion Foundation partners and press." />
<div className="section-header mb-10">
<div className="section-eyebrow">Press & partners</div>
<h1 className="section-title">Brand assets</h1>
<p className="section-subtitle">
Official logos, colors, and favicons for Miracles in Motion Foundation. Use only these
files; do not recreate or recolor the mark.
Official logos, colors, and favicons for {data.organization}. Use only these files; do not recreate or recolor the mark.
</p>
{source === 'api' && (
<p className="mt-2 text-xs text-neutral-500">Live kit · updated {data.updated}</p>
)}
</div>
<div className="mb-8 flex flex-wrap gap-3 print:hidden">
<a href="/brand/MIM4U-Brand-Kit.zip" className="btn-primary" download>
<a href={resolveAssetUrl(kitPath)} className="btn-primary" download>
<Download className="h-4 w-4" aria-hidden />
Download complete kit (ZIP)
</a>
<button
type="button"
className="btn-secondary"
onClick={() => window.print()}
>
<button type="button" className="btn-secondary" onClick={() => window.print()}>
<Printer className="h-4 w-4" aria-hidden />
Print guidelines
</button>
@@ -36,51 +67,36 @@ export function BrandAssetsPage() {
<h2 className="font-semibold tracking-tight">Usage (summary)</h2>
<ul className="mt-3 list-disc space-y-2 pl-5 text-sm text-neutral-700 dark:text-neutral-300">
<li>Do not stretch, rotate, or change logo colors.</li>
<li>Keep clear space around the lockup (at least the height of the capital M in MIRACLES).</li>
<li>
Keep clear space around the lockup (at least the height of the capital M in MIRACLES).
</li>
<li>
<strong>Website header:</strong> transparent nav lockups (`logo-horizontal-nav.svg` preferred, plus WebP/PNG;
`logo-symbol-nav.svg` or PNG on mobile) on light and dark UI shells. Opaque horizontal PNG for print/partner kits only.
</li>
<li>
<strong>Favicon / app icon:</strong> symbol on forest green (<code>#1a3c34</code>).
<strong>Website header:</strong> transparent nav lockups on light and dark UI shells.
</li>
<li><strong>Favicon / app icon:</strong> symbol on forest green (<code>#1a3c34</code>).</li>
<li>
Sponsor and co-branding use requires approval see{' '}
<a className="underline text-primary-600 dark:text-secondary-400" href="/legal#sponsorship-terms">
Sponsorship Terms
</a>
.
</a>.
</li>
<li>
Questions:{' '}
<a className="underline" href="mailto:[email protected]">
contact@mim4u.org
</a>
Questions: <a className="underline" href="mailto:[email protected]">contact@mim4u.org</a>
</li>
</ul>
{data.usageNotes && <p className="mt-3 text-sm text-neutral-600">{data.usageNotes}</p>}
</section>
<section className="card mb-8">
<div className="flex items-center gap-2">
<Palette className="h-5 w-5 text-primary-600 dark:text-secondary-400" aria-hidden />
<Palette className="h-5 w-5 text-primary-600" aria-hidden />
<h2 className="font-semibold tracking-tight">Colors</h2>
</div>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{data.colors.map((c) => (
<div
key={c.hex}
className="flex items-center gap-3 rounded-xl border border-neutral-200/80 p-3 dark:border-white/10"
>
<span
className="h-12 w-12 shrink-0 rounded-lg border border-black/10 shadow-inner"
style={{ backgroundColor: c.hex }}
aria-hidden
/>
<div key={c.hex} className="flex items-center gap-3 rounded-xl border border-neutral-200/80 p-3 dark:border-white/10">
<span className="h-12 w-12 shrink-0 rounded-lg border border-black/10 shadow-inner" style={{ backgroundColor: c.hex }} aria-hidden />
<div className="text-sm">
<div className="font-medium">{c.name}</div>
<div className="font-mono text-xs text-neutral-600 dark:text-neutral-400">{c.hex}</div>
<div className="font-mono text-xs text-neutral-600">{c.hex}</div>
<div className="text-xs text-neutral-500">{c.role}</div>
</div>
</div>
@@ -90,7 +106,7 @@ export function BrandAssetsPage() {
<section className="card mb-8">
<div className="flex items-center gap-2">
<Type className="h-5 w-5 text-primary-600 dark:text-secondary-400" aria-hidden />
<Type className="h-5 w-5 text-primary-600" aria-hidden />
<h2 className="font-semibold tracking-tight">Typography (website)</h2>
</div>
<ul className="mt-3 space-y-2 text-sm text-neutral-700 dark:text-neutral-300">
@@ -100,10 +116,6 @@ export function BrandAssetsPage() {
</li>
))}
</ul>
<p className="mt-3 text-xs text-neutral-500 dark:text-neutral-400">
Type in the logo artwork is part of the supplied files; do not set live text in a substitute
font to imitate the lockup.
</p>
</section>
{data.groups.map((group) => (
@@ -121,18 +133,11 @@ export function BrandAssetsPage() {
</thead>
<tbody>
{group.assets.map((asset) => (
<tr
key={asset.path}
className="border-b border-neutral-100 dark:border-white/5"
>
<tr key={asset.path} className="border-b border-neutral-100 dark:border-white/5">
<td className="py-3 pr-4">{asset.title}</td>
<td className="py-3 pr-4 font-mono text-xs">{asset.format}</td>
<td className="py-3">
<a
href={asset.path}
className="inline-flex items-center gap-1 text-primary-600 hover:underline dark:text-secondary-400"
download
>
<a href={resolveAssetUrl(asset.path)} className="inline-flex items-center gap-1 text-primary-600 hover:underline" download>
<Download className="h-3.5 w-3.5" aria-hidden />
{asset.path.split('/').pop()}
</a>
@@ -142,40 +147,21 @@ export function BrandAssetsPage() {
</tbody>
</table>
</div>
{group.id === 'horizontal' && (
<div className="mt-4 rounded-xl bg-primary-600/10 p-4 dark:bg-primary-900/20">
<img
src="/brand/logo-horizontal.png"
alt=""
className="mx-auto h-16 w-auto max-w-full object-contain md:h-20"
width={1024}
height={354}
/>
{group.id === 'horizontal' && group.assets[0] && (
<div className="mt-4 rounded-xl bg-primary-600/10 p-4">
<img src={resolveAssetUrl(group.assets[0].path)} alt="" className="mx-auto h-16 w-auto max-w-full object-contain md:h-20" />
</div>
)}
{group.id === 'symbol' && (
<div className="mt-4 flex flex-wrap items-center justify-center gap-6 rounded-xl bg-neutral-100 p-6 dark:bg-neutral-900">
<img
src="/brand/logo-symbol.svg"
alt=""
className="h-20 w-20 object-contain"
width={100}
height={99}
/>
<img
src="/brand/logo-symbol-512.png"
alt=""
className="h-20 w-20 object-contain"
width={512}
height={512}
/>
{group.id === 'symbol' && group.assets[0] && (
<div className="mt-4 flex justify-center rounded-xl bg-neutral-100 p-6 dark:bg-neutral-900">
<img src={resolveAssetUrl(group.assets[0].path)} alt="" className="h-20 w-20 object-contain" />
</div>
)}
</section>
))}
<p className="text-center text-xs text-neutral-500 print:mt-8">
© Miracles in Motion Foundation. Kit version {data.version} · Updated {data.updated}
© {data.organization}. Kit version {data.version} · Updated {data.updated}
</p>
</div>
)
+34 -18
View File
@@ -27,7 +27,8 @@ import {
type PaymentMethod,
} from '../lib/paymentProcessing'
import { StripeDonatePanel } from '../components/donate/StripeDonatePanel'
import { allowMockDonate, isStripeConfigured } from '../lib/mimApi'
import { usePublicConfig } from '../hooks/usePublicConfig'
import { allowMockDonate, fetchDonationImpact } from '../lib/mimApi'
function trackEvent(eventName: string, properties: Record<string, unknown> = {}) {
if (typeof window !== 'undefined' && (window as Window & { gtag?: (...args: unknown[]) => void }).gtag) {
@@ -44,6 +45,14 @@ export default function DonatePageRoute() {
const [isProcessing, setIsProcessing] = useState(false)
const { addNotification } = useNotifications()
const { t } = useLanguage()
const { stripeLive } = usePublicConfig()
const [impact, setImpact] = useState<{ totalRaisedUsd: number; familiesSupported: number } | null>(null)
useEffect(() => {
fetchDonationImpact()
.then((r) => setImpact({ totalRaisedUsd: r.totalRaisedUsd, familiesSupported: r.familiesSupported }))
.catch(() => {})
}, [])
const suggestedAmounts = [
{ amount: 25, impact: "Essentials for one individual", popular: false },
@@ -75,7 +84,7 @@ export default function DonatePageRoute() {
const handleDonationSubmit = async () => {
if (finalAmount <= 0) return
if (selectedPaymentMethod.id === 'stripe' && isStripeConfigured()) return
if (selectedPaymentMethod.id === 'stripe' && stripeLive) return
if (!allowMockDonate()) {
addNotification({
@@ -379,13 +388,14 @@ export default function DonatePageRoute() {
{/* Donation checkout — Stripe live path; mock only when VITE_ALLOW_MOCK_DONATE=1 */}
<div className="space-y-3">
{selectedPaymentMethod.id === 'stripe' && isStripeConfigured() ? (
{selectedPaymentMethod.id === 'stripe' && stripeLive ? (
<StripeDonatePanel
amountUsd={finalAmount}
isRecurring={isRecurring}
donorEmail={donorInfo.email}
donorName={donorInfo.name}
anonymous={donorInfo.anonymous}
stripeEnabled={stripeLive}
/>
) : (
<motion.button
@@ -522,24 +532,30 @@ export default function DonatePageRoute() {
<div className="card">
<div className="font-medium mb-3">Recent Impact</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-primary-500 rounded-full"></div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<span className="font-medium">Families across LA County</span> received outreach and emergency support this month
{impact && impact.familiesSupported > 0 && (
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-primary-500 rounded-full"></div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<span className="font-medium">{impact.familiesSupported} families</span> supported through our programs
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-secondary-500 rounded-full"></div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<span className="font-medium">43 families</span> got emergency clothing support
)}
{impact && impact.totalRaisedUsd > 0 && (
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<span className="font-medium">${impact.totalRaisedUsd.toLocaleString()}</span> raised by donors like you
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
<span className="font-medium">$12,450</span> raised this week by donors like you
)}
{(!impact || (impact.familiesSupported === 0 && impact.totalRaisedUsd === 0)) && (
<div className="flex items-center gap-3">
<div className="w-2 h-2 bg-primary-500 rounded-full"></div>
<div className="text-sm text-neutral-700 dark:text-neutral-300">
Families across LA County receive outreach and emergency support through your gifts
</div>
</div>
</div>
)}
</div>
</div>
+200
View File
@@ -0,0 +1,200 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import {
AlertCircle,
Database,
DollarSign,
FileCheck,
Image,
Plus,
QrCode,
Settings,
Truck,
UserCheck,
} from 'lucide-react'
import { SEOHead } from '../../components/SEO/SEOHead'
import { AppPageShell as PageShell } from '../../components/layout/AppPageShell'
import { PortalWrapper } from '../../components/admin/PortalWrapper'
import { LiveFeedPanel } from '../../components/admin/LiveFeedPanel'
import { useAuth } from '../../contexts/AuthContext'
import {
fetchAdminDashboard,
fetchAssistanceRequests,
updateAssistanceRequest,
type AssistanceRequestRow,
} from '../../lib/mimApi'
function trackEvent(eventName: string, properties: Record<string, unknown> = {}) {
if (typeof window !== 'undefined' && (window as Window & { gtag?: (...args: unknown[]) => void }).gtag) {
;(window as Window & { gtag: (...args: unknown[]) => void }).gtag('event', eventName, properties)
}
}
export default function AdminPortalPage() {
const { user, logout } = useAuth()
const [stats, setStats] = useState({
pendingRequests: 0,
activeVolunteers: 0,
deliveriesToday: 0,
monthlyBudget: 15000,
monthlySpent: 0,
})
const [requests, setRequests] = useState<AssistanceRequestRow[]>([])
const load = () => {
fetchAdminDashboard().then(setStats).catch(() => {})
fetchAssistanceRequests(10).then((r) => setRequests(r.data)).catch(() => {})
}
useEffect(() => {
trackEvent('admin_portal_view', { user_id: user?.id, user_role: user?.role })
load()
const id = setInterval(load, 30000)
return () => clearInterval(id)
}, [user?.id, user?.role])
const approveRequest = async (id: string) => {
await updateAssistanceRequest(id, { status: 'approved' })
load()
}
return (
<PortalWrapper requiredRole="admin">
<SEOHead title="Admin Dashboard" description="Administrative portal for Miracles in Motion staff and administrators." />
<PageShell
title="Administration Dashboard"
icon={Settings}
eyebrow={`Welcome back, ${user?.name}`}
cta={
<button type="button" onClick={logout} className="btn-secondary">
Sign Out
</button>
}
>
<div className="space-y-8">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard label="Pending Requests" value={stats.pendingRequests} icon={AlertCircle} tone="red" delay={0.1} />
<StatCard label="Active Volunteers" value={stats.activeVolunteers} icon={UserCheck} tone="blue" delay={0.2} />
<StatCard label="Deliveries Today" value={stats.deliveriesToday} icon={Truck} tone="green" delay={0.3} />
<StatCard
label="Budget Used"
value={`${stats.monthlyBudget ? Math.round((stats.monthlySpent / stats.monthlyBudget) * 100) : 0}%`}
icon={DollarSign}
tone="yellow"
delay={0.4}
/>
</div>
<div className="grid gap-8 lg:grid-cols-3">
<div className="lg:col-span-2 space-y-8">
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Recent Assistance Requests</h3>
<a href="/analytics" className="btn-secondary text-sm">Analytics</a>
</div>
<div className="space-y-4">
{requests.length === 0 ? (
<p className="text-sm text-neutral-500">No assistance requests yet.</p>
) : (
requests.map((request) => (
<div key={request.id} className="flex items-center justify-between p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium">{request.student}</span>
<PriorityBadge priority={request.priority} />
<span className="text-xs text-neutral-500">{request.status}</span>
</div>
<p className="text-sm text-neutral-600 dark:text-neutral-400">{request.school}</p>
<p className="text-sm">{request.need}</p>
</div>
<div className="text-right">
<p className="text-xs text-neutral-500">{request.timeAgo}</p>
{request.status === 'pending' && (
<button
type="button"
onClick={() => approveRequest(request.id)}
className="text-primary-600 text-sm mt-1 hover:underline"
>
Approve
</button>
)}
</div>
</div>
))
)}
</div>
</div>
<LiveFeedPanel />
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-6">Quick Actions</h3>
<div className="space-y-3">
<a href="/admin-brand" className="w-full btn-secondary text-left justify-start flex items-center">
<Image className="mr-2 h-4 w-4" /> Brand asset manager
</a>
<a href="/admin-qr" className="w-full btn-secondary text-left justify-start flex items-center">
<QrCode className="mr-2 h-4 w-4" /> QR code manager
</a>
<a href="/admin-settings" className="w-full btn-primary text-left justify-start flex items-center">
<Settings className="mr-2 h-4 w-4" /> Stripe & donation settings
</a>
<a href="/analytics" className="w-full btn-secondary text-left justify-start flex items-center">
<Database className="mr-2 h-4 w-4" /> Analytics dashboard
</a>
<button type="button" className="w-full btn-secondary text-left justify-start flex items-center" disabled>
<Plus className="mr-2 h-4 w-4" /> Create New User (coming soon)
</button>
<a href="/admin-portal" onClick={(e) => { e.preventDefault(); load() }} className="w-full btn-secondary text-left justify-start flex items-center">
<FileCheck className="mr-2 h-4 w-4" /> Refresh dashboard
</a>
</div>
</div>
</div>
</div>
</PageShell>
</PortalWrapper>
)
}
function StatCard({
label,
value,
icon: Icon,
tone,
delay,
}: {
label: string
value: string | number
icon: React.ComponentType<{ className?: string }>
tone: 'red' | 'blue' | 'green' | 'yellow'
delay: number
}) {
const tones = {
red: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300',
blue: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
green: 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800 text-green-700 dark:text-green-300',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-700 dark:text-yellow-300',
}
return (
<motion.div className={`card ${tones[tone]}`} initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm">{label}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
<Icon className="h-8 w-8 opacity-80" />
</div>
</motion.div>
)
}
function PriorityBadge({ priority }: { priority: string }) {
const cls =
priority === 'High'
? 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'
: priority === 'Medium'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300'
: 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'
return <span className={`px-2 py-1 text-xs rounded-full ${cls}`}>{priority}</span>
}
+123
View File
@@ -0,0 +1,123 @@
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Activity, BarChart3, Eye, Target, TrendingUp, Users, Zap } from 'lucide-react'
import { SEOHead } from '../../components/SEO/SEOHead'
import { AppPageShell as PageShell } from '../../components/layout/AppPageShell'
import { PortalWrapper } from '../../components/admin/PortalWrapper'
import { LiveFeedPanel } from '../../components/admin/LiveFeedPanel'
import { useAuth } from '../../contexts/AuthContext'
import { useNotifications } from '../../contexts/NotificationContext'
import { fetchAnalyticsActivity, fetchAnalyticsSummary } from '../../lib/mimApi'
interface Summary {
donationMetrics: { amount: number; count: number; recurring: number }
pageViews: { page: string; views: number; trend: number }[]
conversionRates: { donation: number; volunteer: number; contact: number }
familiesHelped?: number
activeVolunteers?: number
}
export default function AnalyticsDashboardPage() {
const { user, logout } = useAuth()
const { addNotification } = useNotifications()
const [summary, setSummary] = useState<Summary | null>(null)
const [activity, setActivity] = useState<Awaited<ReturnType<typeof fetchAnalyticsActivity>>['feed']>([])
const refresh = () => {
Promise.all([fetchAnalyticsSummary(), fetchAnalyticsActivity()])
.then(([s, a]) => {
setSummary(s as Summary)
setActivity(a.feed)
})
.catch(() => addNotification({ type: 'error', title: 'Analytics', message: 'Could not refresh data' }))
}
useEffect(() => {
refresh()
const id = setInterval(refresh, 30000)
return () => clearInterval(id)
}, [addNotification])
return (
<PortalWrapper requiredRole="admin">
<SEOHead title="Analytics Dashboard" description="Real-time analytics and insights for Miracles in Motion." />
<PageShell
title="Analytics Dashboard"
icon={BarChart3}
eyebrow={`Data insights for ${user?.name}`}
cta={
<div className="flex gap-2">
<button type="button" onClick={refresh} className="btn-secondary flex items-center gap-2">
<Activity className="h-4 w-4" /> Refresh
</button>
<button type="button" onClick={logout} className="btn-secondary">Sign Out</button>
</div>
}
>
{summary && (
<div className="space-y-8">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Total Donations" value={`$${summary.donationMetrics.amount.toLocaleString()}`} icon={TrendingUp} />
<MetricCard label="Active Volunteers" value={String(summary.activeVolunteers ?? 0)} icon={Users} />
<MetricCard label="Families helped" value={String(summary.familiesHelped ?? 0)} icon={Target} />
<MetricCard label="Conversion Rate" value={`${(summary.conversionRates.donation * 100).toFixed(1)}%`} icon={Zap} />
</div>
<div className="grid gap-8 lg:grid-cols-2">
<div className="card">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold">Page performance</h3>
<Eye className="h-4 w-4 text-neutral-500" />
</div>
<div className="space-y-3">
{summary.pageViews.map((page) => (
<div key={page.page} className="flex justify-between p-3 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
<span>{page.page}</span>
<span className="font-medium">{page.views.toLocaleString()} views</span>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-4">Recent activity</h3>
<ul className="space-y-2 max-h-64 overflow-y-auto">
{activity.map((item, i) => (
<li key={`${item.ts}-${i}`} className="text-sm p-2 rounded bg-neutral-50 dark:bg-neutral-800">
<div className="font-medium">{item.title}</div>
<div className="text-neutral-500">{item.detail} · {item.timeAgo}</div>
</li>
))}
</ul>
</div>
</div>
<LiveFeedPanel />
</div>
)}
</PageShell>
</PortalWrapper>
)
}
function MetricCard({
label,
value,
icon: Icon,
}: {
label: string
value: string
icon: React.ComponentType<{ className?: string }>
}) {
return (
<motion.div className="card" whileHover={{ scale: 1.02, y: -2 }}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{label}</p>
<p className="text-2xl font-bold">{value}</p>
</div>
<Icon className="h-8 w-8 text-primary-500" />
</div>
</motion.div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { FileCheck, School } from 'lucide-react'
import { SEOHead } from '../../components/SEO/SEOHead'
import { AppPageShell as PageShell } from '../../components/layout/AppPageShell'
import { PortalWrapper } from '../../components/admin/PortalWrapper'
import { useAuth } from '../../contexts/AuthContext'
import { fetchResourceRequests } from '../../lib/mimApi'
export default function ResourcePortalPage() {
const { user, logout } = useAuth()
const [requests, setRequests] = useState<
{ id: string; title: string; type: string; status: string; submitted: string; school?: string }[]
>([])
const [summary, setSummary] = useState({ pending: 0, approved: 0, completed: 0 })
useEffect(() => {
const load = () =>
fetchResourceRequests()
.then((r) => {
setRequests(r.data)
setSummary(r.summary)
})
.catch(() => {})
load()
const id = setInterval(load, 60000)
return () => clearInterval(id)
}, [])
return (
<PortalWrapper requiredRole="resource">
<SEOHead title="Resource Portal" description="Partner portal for assistance requests." />
<PageShell
title="Resource Center Portal"
icon={School}
eyebrow={`Welcome, ${user?.name}`}
cta={<button type="button" onClick={logout} className="btn-secondary">Sign Out</button>}
>
<div className="space-y-8">
<div className="grid gap-4 sm:grid-cols-3">
<SummaryCard label="Pending" value={summary.pending} />
<SummaryCard label="Approved" value={summary.approved} />
<SummaryCard label="Completed" value={summary.completed} />
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-6 flex items-center gap-2">
<FileCheck className="h-5 w-5" /> Submitted requests
</h3>
<div className="space-y-3">
{requests.length === 0 ? (
<p className="text-sm text-neutral-500">No requests yet.</p>
) : (
requests.map((r) => (
<div key={r.id} className="p-4 border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div className="flex justify-between mb-1">
<span className="font-medium">{r.title}</span>
<span className="text-xs capitalize px-2 py-0.5 rounded-full bg-neutral-100">{r.status}</span>
</div>
<p className="text-sm text-neutral-600">{r.type} · {r.school}</p>
<p className="text-xs text-neutral-500 mt-1">{r.submitted}</p>
</div>
))
)}
</div>
</div>
<a href="/request-assistance" className="btn-primary inline-flex">Submit new request</a>
</div>
</PageShell>
</PortalWrapper>
)
}
function SummaryCard({ label, value }: { label: string; value: number }) {
return (
<div className="card text-center">
<div className="text-2xl font-bold">{value}</div>
<div className="text-sm text-neutral-600">{label}</div>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { Calendar, Check, Package, Truck, UserCheck } from 'lucide-react'
import { SEOHead } from '../../components/SEO/SEOHead'
import { AppPageShell as PageShell } from '../../components/layout/AppPageShell'
import { PortalWrapper } from '../../components/admin/PortalWrapper'
import { useAuth } from '../../contexts/AuthContext'
import {
completeVolunteerAssignment,
fetchVolunteerAssignments,
fetchVolunteerSchedule,
fetchVolunteerStats,
type VolunteerAssignment,
type VolunteerTask,
} from '../../lib/mimApi'
export default function VolunteerPortalPage() {
const { user, logout } = useAuth()
const [schedule, setSchedule] = useState<{ date: string; tasks: VolunteerTask[] } | null>(null)
const [assignments, setAssignments] = useState<VolunteerAssignment[]>([])
const [stats, setStats] = useState({ familiesHelped: 0, kitsAssembled: 0, deliveries: 0, hoursVolunteered: 0 })
const load = () => {
fetchVolunteerSchedule().then(setSchedule).catch(() => {})
fetchVolunteerAssignments().then((r) => setAssignments(r.data)).catch(() => {})
fetchVolunteerStats().then(setStats).catch(() => {})
}
useEffect(() => {
load()
const id = setInterval(load, 60000)
return () => clearInterval(id)
}, [])
const complete = async (id: string) => {
await completeVolunteerAssignment(id)
load()
}
return (
<PortalWrapper requiredRole="volunteer">
<SEOHead title="Volunteer Dashboard" description="Volunteer portal for Miracles in Motion volunteers." />
<PageShell
title="Volunteer Dashboard"
icon={UserCheck}
eyebrow={`Hello, ${user?.name}`}
cta={
<button type="button" onClick={logout} className="btn-secondary">Sign Out</button>
}
>
<div className="space-y-8">
<div className="card bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-4 mb-6">
<Calendar className="h-8 w-8 text-blue-600" />
<div>
<h3 className="text-lg font-semibold">Today&apos;s Schedule</h3>
<p className="text-blue-700 dark:text-blue-300">{schedule?.date || 'Loading…'}</p>
</div>
</div>
<div className="space-y-4">
{(schedule?.tasks || []).map((task) => (
<div key={task.id} className="flex items-center justify-between p-4 bg-white dark:bg-neutral-900 rounded-lg shadow-sm">
<div className="flex items-center gap-4">
<div className="text-lg font-semibold text-blue-600">{task.time}</div>
<div>
<h4 className="font-medium">{task.task}</h4>
<p className="text-sm text-neutral-600">{task.location}</p>
</div>
</div>
{task.status !== 'completed' && (
<button type="button" onClick={() => complete(task.id)} className="btn-secondary text-sm">
<Check className="mr-1 h-3 w-3 inline" /> Complete
</button>
)}
</div>
))}
</div>
</div>
<div className="grid gap-8 lg:grid-cols-2">
<div className="card">
<h3 className="text-lg font-semibold mb-6">Pending Deliveries</h3>
<div className="space-y-3">
{assignments.filter((a) => a.status !== 'completed').map((d) => (
<div key={d.id} className="p-3 border border-neutral-200 dark:border-neutral-700 rounded-lg">
<div className="flex justify-between mb-2">
<span className="font-medium">{d.student}</span>
<span className="text-xs text-neutral-500">{d.deadline}</span>
</div>
<p className="text-sm text-neutral-600">{d.items}</p>
<p className="text-xs text-neutral-500">{d.school}</p>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="text-lg font-semibold mb-6">Your Impact This Month</h3>
<div className="space-y-4">
<StatRow icon={UserCheck} label="Families helped" value={stats.familiesHelped} />
<StatRow icon={Package} label="Kits assembled" value={stats.kitsAssembled} />
<StatRow icon={Truck} label="Deliveries" value={stats.deliveries} />
</div>
</div>
</div>
</div>
</PageShell>
</PortalWrapper>
)
}
function StatRow({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: number }) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-primary-600" />
<span>{label}</span>
</div>
<span className="font-semibold text-primary-600">{value}</span>
</div>
)
}
+4
View File
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}