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]>
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 24–48 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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 24–48 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}`)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 184 KiB |
@@ -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"')
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||