Initial v2 commit: complete rebuild with unified API + React admin

Phase 1-14 complete:
- Unified Express.js API (TypeScript, Prisma ORM, PostgreSQL 16)
- React Admin GUI (Vite + Ant Design + Zustand)
- JWT auth with refresh tokens
- Influence: Campaigns, Representatives, Responses, Email Queue
- Map: Locations, Cuts, Shifts, Canvassing System
- NAR data import infrastructure (2025 format)
- Listmonk newsletter integration
- Landing page builder (GrapesJS)
- MkDocs + Code Server integration
- Volunteer portal with GPS tracking
- Monitoring stack (Prometheus, Grafana, Alertmanager)
- Pangolin tunnel integration

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
bunker-admin 2026-02-11 10:05:04 -07:00
commit a77306fac2
590 changed files with 159852 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# Node modules
node_modules/
*/node_modules/
**/node_modules/
/configs/code-server/.local/*
!/configs/code-server/.local/.gitkeep
/configs/code-server/.config/*
!/configs/code-server/.config/.gitkeep
# MkDocs cache and built site (created by containers)
/mkdocs/.cache/*
!/mkdocs/.cache/.gitkeep
/mkdocs/site/*
!/mkdocs/site/.gitkeep
# Homepage logs (created by container)
/configs/homepage/logs/*
!/configs/homepage/logs/.gitkeep
.env
.env*
/configs/cloudflare/*.json
/configs/cloudflare/*.yaml
/configs/cloudflare/*.yml
.excalidraw
/.VSCodeCounter
/influence/app/public/uploadsdata/
# NAR data directory (large voter registry files)
/data/

217
CLAUDE.md Normal file
View File

@ -0,0 +1,217 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
**Current state:** V2 rebuild in progress on the `v2` branch. See `V2_PLAN.md` for the full roadmap.
---
## V2 Architecture (Active Development)
### Stack
- **Single unified Express.js API** — TypeScript, port 4000, Prisma ORM + PostgreSQL 16
- **React Admin GUI** — Vite + Ant Design + Zustand, port 3000
- **Nginx reverse proxy** — subdomain routing (`*.cmlite.org`)
- **NocoDB v2** — read-only data browser on port 8091
- **JWT auth** — access tokens (15min) + refresh tokens (7 days, stored in DB)
- **BullMQ** — async email job queue, **Listmonk** for newsletters
- **Redis** — caching, rate limiting, BullMQ backend
### Directory Structure
```
changemaker.lite/
├── api/ # Unified Express.js API (TypeScript)
│ ├── prisma/ # Schema, migrations, seed
│ └── src/
│ ├── config/ # env.ts, database.ts, redis.ts
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
│ ├── modules/
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
│ │ ├── users/ # users.service, users.routes, users.schemas
│ │ ├── influence/ # campaigns, representatives, responses, postal-codes
│ │ └── map/ # locations, shifts, cuts
│ ├── types/ # express.d.ts (Request augmentation)
│ └── utils/ # logger.ts (Winston), metrics.ts (prom-client)
├── admin/ # React Admin (Vite + Ant Design + Zustand)
│ └── src/
│ ├── components/ # ProtectedRoute, AppLayout
│ ├── pages/ # LoginPage, DashboardPage, UsersPage
│ ├── stores/ # auth.store.ts (Zustand)
│ ├── lib/ # api.ts (axios instance + interceptors)
│ └── types/ # api.ts (TypeScript interfaces)
├── nginx/ # Reverse proxy config
├── public-web/ # Public landing pages
├── docker-compose.yml # V2 orchestration
├── docker-compose.v1.yml # V1 backup for reference
└── V2_PLAN.md # Full 14-phase roadmap
```
### Key Files
| File | Purpose |
|------|---------|
| `api/prisma/schema.prisma` | Full database schema (20+ models) |
| `api/src/server.ts` | API entry point, middleware stack, route wiring |
| `api/src/config/env.ts` | Zod-validated environment config |
| `api/src/modules/auth/` | JWT auth (login, register, refresh, logout) |
| `api/src/modules/users/` | User CRUD with pagination + search |
| `admin/src/App.tsx` | React admin shell with routing |
| `admin/src/stores/auth.store.ts` | Zustand auth state with token persistence |
| `admin/src/lib/api.ts` | Axios instance with 401 refresh interceptor |
| `docker-compose.yml` | V2 service orchestration |
| `.env.example` | All required environment variables |
### Auth Flow
- JWT-based: access tokens (15min) + refresh tokens (7 days, stored in DB)
- Login → verify bcrypt hash → generate token pair → return tokens + user
- Refresh → validate refresh token → rotate (invalidate old, issue new) → return new pair
- Roles: `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
- RBAC middleware: `requireRole(...roles)`, `requireNonTemp`
### Nginx Routing
| Subdomain | Target |
|-----------|--------|
| `app.cmlite.org` | Admin React app (port 3000) |
| `api.cmlite.org` | Express API (port 4000) |
| `data.cmlite.org` | NocoDB read-only (port 8091) |
| `docs.cmlite.org` | MkDocs (port 4001) |
| `cmlite.org` | Public landing pages |
---
## V2 Development Commands
### API Development
```bash
cd api && npm run dev # Dev server with tsx watch (auto-reload)
cd api && npx tsc --noEmit # Type-check without emitting
cd api && npx prisma migrate dev # Run/create migrations
cd api && npx prisma studio # Browse database in browser
cd api && npx prisma generate # Regenerate Prisma client
```
### Admin GUI Development
```bash
cd admin && npm run dev # Vite dev server (port 3000)
cd admin && npx tsc --noEmit # Type-check without emitting
cd admin && npm run build # Production build (tsc + vite)
```
### Docker (V2 Services)
```bash
docker compose up -d v2-postgres redis api # Start API + dependencies
docker compose up -d admin # Start admin GUI
docker compose up -d # Start all v2 services
docker compose logs -f api # Tail API logs
docker compose exec api npx prisma migrate dev # Run migrations in container
docker compose down # Stop all services
```
### Type Checking (Both Projects)
```bash
cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
```
---
## Port Reference (V2)
| Port | Service |
|------|---------|
| 3000 | Admin GUI (Vite dev / React) |
| 3001 | Grafana |
| 3010 | Homepage |
| 3030 | Gitea |
| 4000 | V2 API (Express.js) |
| 4001 | MkDocs (built static) |
| 5432 | Listmonk PostgreSQL |
| 5433 | V2 PostgreSQL (localhost) |
| 5678 | n8n |
| 6379 | Redis |
| 8025 | MailHog Web UI |
| 8080 | cAdvisor |
| 8089 | Mini QR |
| 8091 | NocoDB v2 (read-only) |
| 8888 | Code Server |
| 9001 | Listmonk |
| 9090 | Prometheus |
| 9093 | Alertmanager |
---
## V1 Reference (Legacy)
V1 code is preserved in `influence/` and `map/` directories and backed up in `docker-compose.v1.yml`.
### V1 Architecture
Two independent Express.js apps using NocoDB REST API as data layer:
- **Influence** (`influence/app/`, port 3333) — Postal code → representative lookup, email campaigns, response tracking
- **Map** (`map/app/`, port 3000) — Leaflet.js map, volunteer shifts, walk sheets, QR codes
Both apps use: session-based auth (Redis-backed), bcryptjs passwords, Bull job queues, NocoDB REST API (not direct DB).
### V1 Express App Structure
```
app/
├── server.js # Entry point, middleware stack
├── config/ # Environment-based configuration
├── routes/ # Express route definitions
├── controllers/ # Business logic
├── services/ # External integrations (nocodb.js, email.js, listmonk.js)
├── middleware/ # auth.js, csrf.js, rateLimiter.js
├── utils/ # logger.js, metrics.js, validators.js
├── public/ # Static assets
└── templates/ # Server-rendered HTML templates
```
### V1 Commands
```bash
cd influence && cp example.env .env
./scripts/build-nocodb.sh # Initialize NocoDB tables
docker compose up -d
docker compose exec influence-app npm test # Run Jest tests
cd map && cp example.env .env
./build-nocodb.sh # Initialize NocoDB tables
docker compose up -d
```
### V1 Build Scripts
- `config.sh` — Interactive wizard that generates `.env` with secure random passwords
- `start-production.sh` — Installs cloudflared, creates tunnel, configures DNS
- `map/build-nocodb.sh` and `influence/scripts/build-nocodb.sh` — Create NocoDB schema + seed data
- `reset-site.sh` — Resets MkDocs to baseline
### V1 Documentation
- `influence/README.MD` — Features, config, campaign management, email testing
- `influence/files-explainer.md` — File-by-file code documentation
- `map/README.md` — Features, config, setup instructions
- `map/files-explainer.md` — File-by-file code documentation
---
## Key Configuration Files
| File | Purpose |
|------|---------|
| `docker-compose.yml` | V2 orchestration (all services) |
| `docker-compose.v1.yml` | V1 backup |
| `.env` / `.env.example` | Environment variables (never committed) |
| `api/prisma/schema.prisma` | Database schema |
| `nginx/` | Reverse proxy configuration |
| `configs/prometheus/prometheus.yml` | Monitoring scrape targets |
| `configs/cloudflare/tunnel-config.yml` | Production ingress routing |
## Networking
All containers share the `changemaker-lite` bridge network and reference each other by container name. Production uses Cloudflare tunnel with ingress rules mapping `*.cmlite.org` subdomains.

95
Dockerfile.code-server Normal file
View File

@ -0,0 +1,95 @@
FROM codercom/code-server:latest
USER root
# Install Node.js 18+ and npm
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
&& apt-get install -y nodejs
# Install Claude Code globally as root
RUN npm install -g @anthropic-ai/claude-code
# Install Ollama (needs zstd for extraction)
RUN apt-get update && apt-get install -y zstd && rm -rf /var/lib/apt/lists/* \
&& curl -fsSL https://ollama.com/install.sh | sh
# Install Python and dependencies
RUN apt-get update && apt-get install -y \
python3 \
python3-pip \
python3-venv \
python3-full \
pipx \
# Dependencies for CairoSVG and Pillow (PIL)
libcairo2-dev \
libfreetype6-dev \
libffi-dev \
libjpeg-dev \
libpng-dev \
libz-dev \
python3-dev \
pkg-config \
# Additional dependencies for advanced image processing
libwebp-dev \
libtiff5-dev \
libopenjp2-7-dev \
liblcms2-dev \
libxml2-dev \
libxslt1-dev \
# PDF generation dependencies
weasyprint \
fonts-roboto \
# Git for git-based plugins
git \
# For lxml
zlib1g-dev \
# Required for some plugins
build-essential \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Switch to non-root user (coder)
USER coder
# Set up a virtual environment for mkdocs
RUN mkdir -p /home/coder/.venv
RUN python3 -m venv /home/coder/.venv/mkdocs
# Install mkdocs-material in the virtual environment with all extras
RUN /home/coder/.venv/mkdocs/bin/pip install "mkdocs-material[imaging,recommended,git]"
# Install additional useful MkDocs plugins
RUN /home/coder/.venv/mkdocs/bin/pip install \
mkdocs-minify-plugin \
mkdocs-git-revision-date-localized-plugin \
mkdocs-glightbox \
mkdocs-redirects \
mkdocs-awesome-pages-plugin \
mkdocs-blog-plugin \
mkdocs-rss-plugin \
mkdocs-meta-descriptions-plugin \
mkdocs-swagger-ui-tag \
mkdocs-macros-plugin \
mkdocs-material-extensions \
mkdocs-section-index \
mkdocs-table-reader-plugin \
mkdocs-pdf-export-plugin \
mkdocs-mermaid2-plugin \
pymdown-extensions \
pygments \
pillow \
cairosvg
# Add the virtual environment bin to PATH
ENV PATH="/home/coder/.venv/mkdocs/bin:${PATH}"
# Add shell configuration to activate the virtual environment in .bashrc
RUN echo 'export PATH="/home/coder/.venv/mkdocs/bin:$PATH"' >> ~/.bashrc
RUN echo 'export PATH="/home/coder/.local/bin:$PATH"' >> ~/.bashrc
# Create a convenience script to simplify running mkdocs commands
RUN mkdir -p /home/coder/.local/bin \
&& echo '#!/bin/bash\ncd /home/coder/mkdocs\nmkdocs "$@"' > /home/coder/.local/bin/run-mkdocs \
&& chmod +x /home/coder/.local/bin/run-mkdocs
WORKDIR /home/coder

140
README.md Normal file
View File

@ -0,0 +1,140 @@
# Changemaker Lite
Changemaker Lite is a streamlined documentation and development platform featuring essential self-hosted services for creating, managing, and automating political campaign workflows.
## Features
- **Homepage**: Modern dashboard for accessing all services
- **Code Server**: VS Code in your browser for remote development
- **MkDocs Material**: Beautiful documentation with live preview
- **Static Site Server**: High-performance hosting for built sites
- **Listmonk**: Self-hosted newsletter and email campaign management
- **PostgreSQL**: Reliable database backend
- **n8n**: Workflow automation and service integration
- **NocoDB**: No-code database platform and smart spreadsheet interface
- **Map**: Interactive map visualization for geographic data with real-time geolocation, walk sheet generation, and QR code integration
- **Influence**: Campaign tool for connecting Alberta residents with elected representatives at all government levels
## Quick Start
The whole system can be set up in minutes using Docker Compose. It is recommended to run this on a server with at least 8GB of RAM and 4 CPU cores for optimal performance. Instructions to build to production are available in the mkdocs/docs/build directory, at cmlite.org, or in the site preview.
```bash
# Clone the repository
git clone https://gitea.bnkops.com/admin/changemaker.lite
cd changemaker.lite
# Configure environment (creates .env file)
./config.sh
# Start all services
docker compose up -d
```
## Map
Instructions on how to build the map are available in the map directory.
Instructions on how to build for production are available in the mkdocs/docs/build directory or in the site preview.
### Quick Start for Map
Update the .env file in the map directory with your NocoDB URLs, and then run:
```bash
cd map
docker compose up -d
```
## Influence
The Influence Campaign Tool helps Alberta residents connect with elected representatives at federal, provincial, and municipal levels. Users can look up representatives by postal code and send advocacy emails through customizable campaigns.
Detailed setup and configuration instructions are available in the `influence/README.MD` file.
### Quick Start for Influence
Configure your environment and start the service:
```bash
cd influence
cp example.env .env
# Edit .env with your NocoDB and SMTP settings
./scripts/build-nocodb.sh # Set up database tables
docker compose up -d
```
## Service Access
After starting, access services at:
- **Homepage Dashboard**: http://localhost:3010
- **Documentation (Dev)**: http://localhost:4000
- **Documentation (Built)**: http://localhost:4001
- **Code Server**: http://localhost:8888
- **Listmonk**: http://localhost:9000
- **n8n**: http://localhost:5678
- **NocoDB**: http://localhost:8090
- **Map Viewer**: http://localhost:3000
- **Influence Campaign Tool**: http://localhost:3333
## Production Deployment
If you are deploying to production, using Cloudflare, you can use the included 'start-production.sh' script to set up a secure deployment with HTTPS. Ensure your domain and cloudflare settings are correctly configured in the root .env before running. More information on the required API tokens and settings can be found in the mkdocs/docs/build directory or at cmlite.org.
```bash
./start-production.sh
```
## Documentation
Complete documentation is available in the MkDocs site, including:
- Service configuration guides
- Integration examples
- Workflow automation tutorials
- Map application setup and usage
- Troubleshooting guides
Visit http://localhost:4000 after starting services to access the full documentation.
## Licensing
This project is licensed under the Apache License 2.0 - https://opensource.org/license/apache-2-0
## AI Disclaimer
This project used AI tools to assist in its creation and large amounts of the boilerplate code was reviewed using AI. AI tools (although not activated or connected) are pre-installed in the Coder docker image. See `docker.code-server` for more details.
While these tools can help generate code and documentation, they may also introduce errors or inaccuracies. Users should review and test all content to ensure it meets their requirements and standards.
## Troubleshooting
### Permission Denied Errors (EACCES)
If you see errors like `EACCES: permission denied` when starting containers, run the included fix script:
```bash
./fix-permissions.sh
```
This fixes permissions on directories that containers need to write to, such as:
- `configs/code-server/.config` and `.local` (Code Server)
- `mkdocs/.cache` (MkDocs social cards plugin)
- `mkdocs/site` (MkDocs built output)
If the script can't fix some directories (owned by a different container UID), it will prompt to use `sudo`.
### Manual Permission Fix
If you prefer to fix manually:
```bash
# Fix all permissions at once
sudo chown -R $(id -u):$(id -g) .
chmod -R 755 .
# Or fix specific directories
chmod -R 777 configs/code-server/.config configs/code-server/.local
chmod -R 777 mkdocs/.cache mkdocs/site
```

380
V2_PLAN.md Normal file
View File

@ -0,0 +1,380 @@
# V2 Roadmap — Changemaker Lite
This document is the full roadmap for the v2 rebuild of Changemaker Lite, a self-hosted political campaign platform. V1 consisted of two separate Express apps (Influence and Map) using NocoDB as a data layer. V2 consolidates everything into a single unified TypeScript API with a React admin interface.
---
## Architecture Overview
- **Single unified Express.js API** (TypeScript, port 4000) with Prisma ORM + PostgreSQL 16
- **React Admin GUI** (Vite + Ant Design + Zustand, port 3000)
- **Nginx reverse proxy** (subdomain routing: *.cmlite.org)
- **NocoDB v2** as read-only data browser on port 8091
- **JWT auth** (access + refresh tokens), bcrypt passwords
- **BullMQ** for Influence advocacy emails, Listmonk for newsletters
- **Redis** for caching, rate limiting, BullMQ
---
## Directory Structure
```
changemaker.lite/
├── api/ # Unified Express.js API (TypeScript)
│ ├── prisma/ # Schema, migrations, seed
│ └── src/
│ ├── config/ # env.ts, database.ts, redis.ts
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
│ ├── modules/
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
│ │ ├── users/ # users.service, users.routes, users.schemas
│ │ ├── influence/
│ │ │ ├── campaigns/
│ │ │ ├── representatives/
│ │ │ ├── responses/
│ │ │ └── postal-codes/
│ │ └── map/
│ │ ├── locations/
│ │ ├── shifts/
│ │ └── cuts/
│ ├── types/ # express.d.ts
│ └── utils/ # logger.ts, metrics.ts
├── admin/ # React Admin (Vite + Ant Design)
│ └── src/
│ ├── components/
│ ├── pages/
│ ├── stores/
│ └── services/
├── nginx/ # Reverse proxy config
├── public-web/ # Public landing pages
└── docker-compose.yml # V2 orchestration
```
---
## Schema Summary
27+ models organized into the following groups:
- **Auth & Users** — User, RefreshToken
- **Influence** — Campaign, CampaignEmail, Representative, RepresentativeResponse, ResponseUpvote, CustomRecipient, PostalCodeCache, EmailLog, EmailVerification, Call
- **Map** — Location, Shift, ShiftSignup, Cut, MapSettings
- **Canvassing** — CanvassSession, CanvassVisit, TrackingSession, TrackPoint
- **Landing Pages** — LandingPage, PageBlock
- **Settings** — SiteSettings
---
## Auth Flow
- JWT-based with access tokens (15min) and refresh tokens (7 days, stored in DB)
- **Login:** verify bcrypt hash, generate token pair, return tokens + user
- **Refresh:** validate refresh token, rotate (invalidate old, issue new), return new pair
- **Roles:** SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
---
## Email Architecture
- **BullMQ job queue** for Influence advocacy emails (async send via SMTP)
- **Listmonk** for newsletter/marketing emails
- **MailHog** for dev email capture (EMAIL_TEST_MODE=true)
---
## Nginx Routing
| Subdomain | Target |
|-----------------------|--------------------------------|
| app.cmlite.org | Admin React app (port 3000) |
| api.cmlite.org | Express API (port 4000) |
| db.cmlite.org | NocoDB read-only (port 8091) |
| docs.cmlite.org | MkDocs (port 4003) |
| code.cmlite.org | Code Server (port 8888) |
| n8n.cmlite.org | n8n Workflows (port 5678) |
| git.cmlite.org | Gitea (port 3030) |
| home.cmlite.org | Homepage (port 3010) |
| grafana.cmlite.org | Grafana (port 3001) |
| listmonk.cmlite.org | Listmonk (port 9001) |
| cmlite.org | Public static site (MkDocs) |
---
## Phase Checklist
### Phase 1: Foundation [x] COMPLETE
- [x] Initialize api/ with TypeScript, Express, Prisma
- [x] Create prisma/schema.prisma with all models
- [x] Set up config (env.ts, database.ts, redis.ts)
- [x] Create middleware (error-handler.ts, validate.ts, rate-limit.ts)
- [x] Set up utils (logger.ts, metrics.ts)
- [x] Create server.ts with health check and metrics
- [x] Initialize admin/ with Vite + React + Ant Design
- [x] Create docker-compose.yml for v2
- [x] Create .env.example
- [x] Backup v1 to docker-compose.v1.yml
> Note: DB migrations are pending until `docker compose up` runs PostgreSQL
---
### Phase 2: Auth + User Management [x] COMPLETE
- [x] Express Request type augmentation (express.d.ts)
- [x] Auth schemas (Zod validation)
- [x] Auth service (login, register, refresh, logout)
- [x] Auth middleware (JWT verification)
- [x] RBAC middleware (role-based access)
- [x] Auth routes (login, register, refresh, logout, me)
- [x] User schemas (Zod validation)
- [x] User service (CRUD + pagination)
- [x] User routes (list, get, create, update, delete)
- [x] Wire routes into server.ts
---
### Phase 3: Admin GUI Foundation [x] COMPLETE
- [x] Zustand auth store with token management
- [x] Login page
- [x] Protected route wrapper
- [x] App layout with sidebar navigation
- [x] User management page (CRUD table)
- [x] API client with interceptors (auto-refresh)
---
### Phase 4: Influence — Campaigns [x] COMPLETE
- [x] Campaign schemas (Zod validation)
- [x] Campaign service (CRUD, slug generation, highlight toggling)
- [x] Campaign routes with admin protection
- [x] Admin campaign management page (table, filters, create/edit/delete)
- [ ] Public campaign view page (deferred to later phase)
---
### Phase 5: Influence — Representatives + Postal Codes [x] COMPLETE
- [x] Postal code schemas (normalize, validate all Canadian postal codes)
- [x] Postal code cache service (upsert, find, paginate, delete)
- [x] Represent API client (typed HTTP client, in-memory rate limiter 55/min)
- [x] Representative schemas (list/filter validation)
- [x] Representative service (cache-first lookup, fire-and-forget cache write, admin CRUD, cache stats)
- [x] Representative routes (public: lookup + health check; admin: list, stats, detail, delete)
- [x] Admin representatives page (lookup, stats cards, table with filters, detail modal)
- [ ] Public postal code to representative lookup UI (deferred to later phase)
---
### Phase 6: Influence — Email Sending [x] COMPLETE
- [x] BullMQ email queue setup (email-queue.service.ts)
- [x] Email worker (SMTP send via nodemailer, email.service.ts)
- [x] Campaign email service (compose, queue, track)
- [x] Campaign email routes (public: send-email, track-mailto; admin: list, stats)
- [x] Email queue admin routes (stats, pause, resume, clean)
- [x] Admin email queue page (stats cards, pause/resume, clean)
- [x] Admin campaign emails drawer (stats + email list from CampaignsPage)
- [x] Email rate limiting (30 req/hour per IP)
- [ ] Public email sending UI (SMTP + mailto fallback) — deferred to later phase
---
### Phase 7: Influence — Response Wall + Public Campaign View [x]
- [x] Response service (submit, moderate, verify)
- [x] Response routes (3 routers: campaign-public, response-public, admin)
- [x] Email verification for responses (HTML templates, verify/report endpoints)
- [x] Admin moderation interface (ResponsesPage with filters, approve/reject/delete)
- [x] Public response wall display (ResponseWallPage with sort, filter, submit modal)
- [x] Upvoting system (IP + user dedup, optimistic UI)
- [x] Public campaign page (CampaignPage with postal code lookup, email sending)
- [x] PublicLayout with light theme for public pages
- [x] Campaign public details endpoint (findBySlugPublic)
---
### Phase 8: Map — Locations [x]
- [x] Multi-provider geocoding service (Nominatim, ArcGIS, Photon, Mapbox)
- [x] Location service (CRUD, geocoding, stats, bulk operations)
- [x] Location routes (admin + public)
- [x] Map settings service + routes (singleton config)
- [x] Admin LocationsPage (table, stats, create/edit, geocode button)
- [x] Admin MapSettingsPage (center/zoom, walk sheet config)
- [x] Public Leaflet.js map (circle markers, color-coded support levels, multi-unit grouping)
- [x] CSV import/export with flexible column mapping
---
### Phase 9: Map — Shifts [x] COMPLETE
- [x] Shift service (CRUD, signup management)
- [x] Shift routes (admin + public)
- [x] Admin shift management (ShiftsPage with signups drawer)
- [x] Public shift calendar + signup
- [x] Temp user creation on public signup
- [x] Confirmation emails for signups
---
### Phase 10: Walk Sheets & QR Codes [x] COMPLETE
- [x] QR code generation endpoint (GET /api/qr)
- [x] Walk sheet page (printable form with QR codes)
- [x] Cut export page (printable location report)
- [x] Sidebar navigation + route wiring
---
### Phase 11: Listmonk Integration [x] COMPLETE
- [x] Listmonk API client service (typed HTTP, basic auth)
- [x] Sync service (campaign participants, locations, users → subscriber lists)
- [x] Admin routes (status, stats, sync triggers, test connection, reinitialize)
- [x] Admin ListmonkPage (status, sync buttons, list stats)
- [x] Sidebar navigation + route wiring
- [x] Proton Mail SMTP configuration (listmonk-init auto-configures via SQL)
---
### Phase 12: Landing Page Builder [x] COMPLETE
- [x] Landing page service (CRUD, slug generation, MkDocs export)
- [x] Page block service (seed blocks, CRUD, library API)
- [x] GrapesJS editor integration (custom blocks, Ctrl+S save, error boundary)
- [x] Admin page builder UI (LandingPagesPage, PageEditorPage full-screen)
- [x] Public page rendering (/p/:slug)
- [x] MkDocs export (Jinja2 Material override template, themed + standalone modes)
---
### MkDocs + Code Server + Docs Editor [x] COMPLETE
- [x] Docker port fix (MkDocs → 4003, avoiding API conflict)
- [x] API mkdocs volume mount for override file sync
- [x] Nginx code.cmlite.org + X-Frame-Options per-server-block
- [x] Docs API routes (status, config health check for MkDocs/Code Server)
- [x] DocsEditorPage (split-view with Code Server + MkDocs iframes, draggable divider)
- [x] DocsPage (management — status cards, MkDocs export table)
---
### Phase 13: Settings + Branding [x] COMPLETE
- [x] SiteSettings Prisma model (singleton, organization/theme/feature toggles)
- [x] Settings API module (public GET, SUPER_ADMIN PUT)
- [x] Zustand settings store (fetch on app startup, public endpoint)
- [x] Admin SettingsPage with tabs (Organization, Theme, Email, Features)
- [x] Dynamic admin theme (colorPrimary, colorBgBase from settings)
- [x] Dynamic public theme (colors, gradient, footer from settings)
- [x] Dynamic branding (organization name, short name, login subtitle)
- [x] Feature toggle navigation (hide sidebar sections when disabled)
---
### Code Editor (Standalone) [x] COMPLETE
- [x] Docker volume mount for entire project directory
- [x] Admin CodeEditorPage with full-bleed Code Server iframe
- [x] Health status check (reuses /api/docs/status endpoint)
- [x] Sidebar navigation under Web submenu
- [x] SUPER_ADMIN access restriction
---
### Volunteer Canvassing System [x] COMPLETE
- [x] CanvassSession + CanvassVisit Prisma models (session lifecycle, visit outcomes)
- [x] TrackingSession + TrackPoint Prisma models (GPS trail recording)
- [x] Canvass API — volunteer routes (start/end session, record visits, walking route)
- [x] Canvass API — admin routes (dashboard stats, activity feed, cut progress, leaderboard)
- [x] Walking route algorithm (nearest-neighbor with haversine distance)
- [x] Canvass visit rate limiter (30/min per IP)
- [x] Abandoned session cleanup (startup + hourly interval, ACTIVE > 12h → ABANDONED)
- [x] GPS tracking routes (volunteer + admin)
- [x] Old tracking data cleanup (startup + daily, 30-day retention)
- [x] Stale tracking session cleanup (no data for 2h, hourly check)
- [x] VolunteerLayout (top-nav, dark theme, mobile hamburger menu)
- [x] Volunteer portal — full-screen canvass map (Leaflet, GPS, markers, route, bottom sheet visit recording)
- [x] Volunteer portal — activity page (visit history + outcome breakdown)
- [x] Admin CanvassDashboardPage (stats, activity feed, cut progress, leaderboard)
- [x] Admin WalkSheetPage enhancements
- [x] ShiftsPage cutId dropdown (link shifts to cuts)
- [x] Role-aware login redirect (ADMIN_ROLES → /app, USER/TEMP → /volunteer)
- [x] Shift.cutId optional relation — shifts without a cut don't appear in volunteer assignments
---
### Platform Service Integration [x] COMPLETE
- [x] Services API module (health check NocoDB, n8n, Gitea + config endpoint)
- [x] Admin NocoDBPage (iframe to db.cmlite.org, status badge, open in new tab)
- [x] Admin N8nPage (iframe to n8n.cmlite.org, status badge, open in new tab)
- [x] Admin GiteaPage (iframe to git.cmlite.org, status badge, open in new tab)
- [x] Sidebar "Services" submenu (Database, Workflows, Git)
- [x] Nginx CSP frame-ancestors for NocoDB, n8n, Gitea (iframe embedding from admin)
- [x] Embed proxy ports (8881-8883) for X-Frame-Options stripping
- [x] `buildServiceUrl()` helper for dynamic iframe URLs
---
### Phase 14: Monitoring + DevOps [x] COMPLETE
**Pangolin Tunnel (replaced Cloudflare Tunnel):**
- [x] Pangolin Integration API client (`api/src/services/pangolin.client.ts`)
- [x] Admin pangolin routes — status, config, sites, resources, setup, sync, delete
- [x] Admin PangolinPage — setup wizard + resource status dashboard
- [x] Newt container in docker-compose.yml
- [x] Env vars: PANGOLIN_API_URL, API_KEY, ORG_ID, SITE_ID, ENDPOINT, NEWT_ID, NEWT_SECRET
- [x] Retired Cloudflare scripts → `scripts/legacy/`
- [x] Sidebar "Tunnel" nav item under Services
**Prometheus Metrics:**
- [x] 12 domain-specific `cm_*` metrics in `api/src/utils/metrics.ts`
- [x] Email: cm_emails_sent_total, cm_emails_failed_total, cm_email_queue_size, cm_email_send_duration_seconds
- [x] Auth: cm_login_attempts_total, cm_active_sessions
- [x] Campaigns: cm_campaign_emails_total, cm_response_submissions_total
- [x] Canvass: cm_canvass_visits_total, cm_active_canvass_sessions, cm_shift_signups_total
- [x] Services: cm_external_service_up
- [x] Instrumented: email-queue, auth, campaign-emails, responses, canvass, shifts, services
**Monitoring Configs:**
- [x] Prometheus: V2 API scrape job (`changemaker-v2-api:4000`), removed V1 influence-app
- [x] Alerts: rewritten for V2 `cm_*` / `http_*` metric names
- [x] Alertmanager: Gotify webhook receiver (commented, ready to enable)
- [x] Grafana dashboards: system-health (updated), application-overview (new), api-performance (new)
**Docker Healthchecks:**
- [x] API (wget /api/health, 15s)
- [x] Admin (wget /, 30s)
- [x] Nginx (wget /, 30s)
- [x] NocoDB (wget /api/v1/health, 30s)
- [x] n8n (wget /healthz, 30s)
- [x] Gitea (curl /, 30s)
- [x] Listmonk (wget /, 30s)
**Backup:**
- [x] `scripts/backup.sh` — V2 PostgreSQL dump + Listmonk dump + uploads archive
- [x] Manifest with timestamps, sizes, sha256 checksums
- [x] Configurable retention (default 30 days)
- [x] Optional S3 upload (--s3 flag)
---
### Phase 15: Testing + Polish [ ]
- [ ] API integration tests (Jest/Vitest)
- [ ] Admin E2E tests
- [ ] Performance optimization
- [ ] Security audit
- [ ] Documentation updates
### PHASE 1: Extras
- [ ] Add apache answers
- [ ] Add geo-tracking and blocking
- [ ] Add in the video platform
- [ ] Add in excalidraw
- [ ] Add in chats - integrate into canvass application

23
admin/Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM node:22-alpine AS base
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
# Development stage
FROM base AS development
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]
# Build stage
FROM base AS build
COPY . .
RUN npm run build
# Production stage — serve with Nginx
FROM nginx:alpine AS production
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

12
admin/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Changemaker Lite - Admin</title>
</head>
<body style="margin:0;background:#1a1025">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
admin/nginx.conf Normal file
View File

@ -0,0 +1,17 @@
server {
listen 3000;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://changemaker-v2-api:4000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

3309
admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
admin/package.json Normal file
View File

@ -0,0 +1,46 @@
{
"name": "changemaker-v2-admin",
"private": true,
"version": "2.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@monaco-editor/react": "^4.7.0",
"antd": "^5.23.0",
"axios": "^1.7.9",
"dayjs": "^1.11.19",
"grapesjs": "^0.22.14",
"grapesjs-blocks-basic": "^1.0.2",
"grapesjs-component-countdown": "^1.0.2",
"grapesjs-custom-code": "^1.0.2",
"grapesjs-navbar": "^1.0.2",
"grapesjs-plugin-export": "^1.0.12",
"grapesjs-plugin-forms": "^2.0.6",
"grapesjs-preset-webpage": "^1.0.3",
"grapesjs-style-gradient": "^3.0.3",
"grapesjs-tabs": "^1.0.6",
"grapesjs-touch": "^0.1.1",
"grapesjs-typed": "^2.0.1",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.1.1",
"yaml": "^2.8.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@types/leaflet": "^1.9.21",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.7.3",
"vite": "^6.0.7"
}
}

359
admin/src/App.tsx Normal file
View File

@ -0,0 +1,359 @@
import { useEffect } from 'react';
import { App as AntApp, ConfigProvider, theme, Spin } from 'antd';
import { BrowserRouter, Routes, Route, Navigate, useParams } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import ProtectedRoute from '@/components/ProtectedRoute';
import FeatureGate from '@/components/FeatureGate';
import AppLayout from '@/components/AppLayout';
import PublicLayout from '@/components/PublicLayout';
import VolunteerLayout from '@/components/VolunteerLayout';
import LoginPage from '@/pages/LoginPage';
import DashboardPage from '@/pages/DashboardPage';
import UsersPage from '@/pages/UsersPage';
import CampaignsPage from '@/pages/CampaignsPage';
import RepresentativesPage from '@/pages/RepresentativesPage';
import EmailQueuePage from '@/pages/EmailQueuePage';
import ResponsesPage from '@/pages/ResponsesPage';
import LocationsPage from '@/pages/LocationsPage';
import CutsPage from '@/pages/CutsPage';
import ShiftsPage from '@/pages/ShiftsPage';
import MapSettingsPage from '@/pages/MapSettingsPage';
import CutExportPage from '@/pages/CutExportPage';
import CanvassDashboardPage from '@/pages/CanvassDashboardPage';
import ListmonkPage from '@/pages/ListmonkPage';
import LandingPagesPage from '@/pages/LandingPagesPage';
import PageEditorPage from '@/pages/PageEditorPage';
import DocsPage from '@/pages/DocsPage';
import MkDocsSettingsPage from '@/pages/MkDocsSettingsPage';
import CodeEditorPage from '@/pages/CodeEditorPage';
import NocoDBPage from '@/pages/NocoDBPage';
import N8nPage from '@/pages/N8nPage';
import GiteaPage from '@/pages/GiteaPage';
import MailHogPage from '@/pages/MailHogPage';
import SettingsPage from '@/pages/SettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import PublicLandingPage from '@/pages/public/LandingPage';
import CampaignsListPage from '@/pages/public/CampaignsListPage';
import CampaignPage from '@/pages/public/CampaignPage';
import ResponseWallPage from '@/pages/public/ResponseWallPage';
import MapPage from '@/pages/public/MapPage';
import PublicShiftsPage from '@/pages/public/ShiftsPage';
import MyActivityPage from '@/pages/volunteer/MyActivityPage';
import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage';
import MyRoutesPage from '@/pages/volunteer/MyRoutesPage';
import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage';
import { ADMIN_ROLES } from '@/types/api';
function RoleAwareRedirect() {
const { user, isAuthenticated } = useAuthStore();
if (!isAuthenticated) return <Navigate to="/login" replace />;
if (user && ADMIN_ROLES.includes(user.role)) return <Navigate to="/app" replace />;
return <Navigate to="/volunteer" replace />;
}
function NavigateToCutMap() {
const { cutId } = useParams<{ cutId: string }>();
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
}
export default function App() {
const { hydrate, isLoading } = useAuthStore();
const { settings, fetchSettings } = useSettingsStore();
useEffect(() => {
hydrate();
fetchSettings();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Dynamic document title + favicon from settings
useEffect(() => {
if (!settings) return;
document.title = `${settings.organizationName || 'Changemaker Lite'} — Admin`;
if (settings.organizationFaviconUrl) {
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = settings.organizationFaviconUrl;
}
}, [settings]);
if (isLoading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<Spin size="large" />
</div>
);
}
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.adminColorPrimary ?? '#9d4edd',
colorBgBase: settings?.adminColorBgBase ?? '#1a1025',
colorBorder: 'rgba(255,255,255,0.08)',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
borderRadius: 6,
},
}}
>
<AntApp>
<BrowserRouter>
<Routes>
{/* Public pages (no auth, dark blue theme) — feature-gated */}
<Route path="/campaigns" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route index element={<CampaignsListPage />} />
</Route>
<Route path="/campaign" element={<FeatureGate feature="enableInfluence"><PublicLayout /></FeatureGate>}>
<Route path=":slug" element={<CampaignPage />} />
<Route path=":slug/responses" element={<ResponseWallPage />} />
</Route>
<Route path="/shifts" element={<FeatureGate feature="enableMap"><PublicLayout /></FeatureGate>}>
<Route index element={<PublicShiftsPage />} />
</Route>
<Route path="/map" element={<FeatureGate feature="enableMap"><MapPage /></FeatureGate>} />
<Route path="/p/:slug" element={<FeatureGate feature="enableLandingPages"><PublicLandingPage /></FeatureGate>} />
{/* Volunteer map — full-screen, default landing page */}
<Route
path="/volunteer"
element={
<ProtectedRoute>
<VolunteerMapPage />
</ProtectedRoute>
}
/>
{/* Volunteer pages with VolunteerLayout */}
<Route
element={
<ProtectedRoute>
<VolunteerLayout />
</ProtectedRoute>
}
>
<Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
</Route>
{/* Redirect old canvass routes to map with query param */}
<Route
path="/volunteer/canvass/:cutId"
element={<NavigateToCutMap />}
/>
<Route path="/login" element={<LoginPage />} />
<Route
path="/app/pages/:id/edit"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<PageEditorPage />
</ProtectedRoute>
}
/>
<Route
path="/app"
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route
path="users"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<UsersPage />
</ProtectedRoute>
}
/>
<Route
path="campaigns"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CampaignsPage />
</ProtectedRoute>
}
/>
<Route
path="representatives"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<RepresentativesPage />
</ProtectedRoute>
}
/>
<Route
path="email-queue"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<EmailQueuePage />
</ProtectedRoute>
}
/>
<Route
path="responses"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ResponsesPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ListmonkPage />
</ProtectedRoute>
}
/>
<Route
path="pages"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<LandingPagesPage />
</ProtectedRoute>
}
/>
<Route
path="docs"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<DocsPage />
</ProtectedRoute>
}
/>
<Route
path="docs/settings"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<MkDocsSettingsPage />
</ProtectedRoute>
}
/>
<Route
path="code"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<CodeEditorPage />
</ProtectedRoute>
}
/>
<Route
path="services/nocodb"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<NocoDBPage />
</ProtectedRoute>
}
/>
<Route
path="services/n8n"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<N8nPage />
</ProtectedRoute>
}
/>
<Route
path="services/gitea"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<GiteaPage />
</ProtectedRoute>
}
/>
<Route
path="services/mailhog"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<MailHogPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route
path="tunnel"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<PangolinPage />
</ProtectedRoute>
}
/>
<Route
path="map"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<LocationsPage />
</ProtectedRoute>
}
/>
<Route
path="map/shifts"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ShiftsPage />
</ProtectedRoute>
}
/>
<Route
path="map/cuts"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CutsPage />
</ProtectedRoute>
}
/>
<Route
path="map/settings"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<MapSettingsPage />
</ProtectedRoute>
}
/>
<Route
path="map/cuts/:id/export"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CutExportPage />
</ProtectedRoute>
}
/>
<Route
path="map/canvass"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<CanvassDashboardPage />
</ProtectedRoute>
}
/>
</Route>
<Route path="*" element={<RoleAwareRedirect />} />
</Routes>
</BrowserRouter>
</AntApp>
</ConfigProvider>
);
}

View File

@ -0,0 +1,332 @@
import { useState, type ReactNode } from 'react';
import { useNavigate, useLocation, Outlet } from 'react-router-dom';
import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd';
import {
DashboardOutlined,
SendOutlined,
IdcardOutlined,
MailOutlined,
MessageOutlined,
EnvironmentOutlined,
TeamOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
MenuOutlined,
ScissorOutlined,
CalendarOutlined,
FileTextOutlined,
NotificationOutlined,
BookOutlined,
GlobalOutlined,
CodeOutlined,
DatabaseOutlined,
ApiOutlined,
BranchesOutlined,
CloudServerOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const { useBreakpoint } = Grid;
export interface PageHeaderConfig {
title?: string;
actions?: ReactNode;
fullBleed?: boolean;
}
export interface AppOutletContext {
setPageHeader: (config: PageHeaderConfig | null) => void;
}
function buildMenuItems(settings: import('@/types/api').SiteSettings | null): MenuProps['items'] {
const items: MenuProps['items'] = [
{
key: '/app',
icon: <DashboardOutlined />,
label: 'Dashboard',
},
];
if (settings?.enableInfluence !== false) {
items.push({
key: 'influence-submenu',
icon: <SendOutlined />,
label: 'Influence',
children: [
{ key: '/app/campaigns', icon: <SendOutlined />, label: 'Campaigns' },
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: 'Email Queue' },
{ key: '/app/responses', icon: <MessageOutlined />, label: 'Responses' },
],
});
}
if (settings?.enableNewsletter !== false) {
items.push({
key: '/app/listmonk',
icon: <NotificationOutlined />,
label: 'Newsletter',
});
}
// Web submenu — conditionally include Landing Pages
const webChildren: MenuProps['items'] = [];
if (settings?.enableLandingPages !== false) {
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
}
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
key: 'web-submenu',
icon: <GlobalOutlined />,
label: 'Web',
children: webChildren,
});
if (settings?.enableMap !== false) {
items.push({
key: 'map-submenu',
icon: <EnvironmentOutlined />,
label: 'Map',
children: [
{ key: '/app/map', label: 'Locations' },
{ key: '/app/map/shifts', icon: <CalendarOutlined />, label: 'Shifts' },
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Cuts' },
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
],
});
}
items.push({
key: 'services-submenu',
icon: <CloudServerOutlined />,
label: 'Services',
children: [
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
],
});
items.push(
{
key: '/app/users',
icon: <TeamOutlined />,
label: 'Users',
},
{
key: '/app/settings',
icon: <SettingOutlined />,
label: 'Settings',
},
);
return items;
}
export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [pageHeader, setPageHeader] = useState<PageHeaderConfig | null>(null);
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { settings } = useSettingsStore();
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const menuItems = buildMenuItems(settings);
const handleMenuClick: MenuProps['onClick'] = ({ key }) => {
navigate(key);
if (isMobile) setDrawerOpen(false);
};
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const userMenuItems: MenuProps['items'] = [
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Logout',
onClick: handleLogout,
},
];
// Match the current path to a menu key (supports submenus)
const selectedKey = (() => {
const path = location.pathname;
// Exact match first
if (path === '/app') return '/app';
// Check all items including children — longest match wins
let best = '';
for (const item of menuItems || []) {
if (!item || !('key' in item)) continue;
if ('children' in item && item.children) {
for (const child of item.children) {
if (!child || !('key' in child)) continue;
const k = child.key as string;
if (path === k || path.startsWith(k + '/')) {
if (k.length > best.length) best = k;
}
}
}
const k = item.key?.toString() || '';
if (k.startsWith('/') && k !== '/app' && (path === k || path.startsWith(k + '/'))) {
if (k.length > best.length) best = k;
}
}
return best || '/app';
})();
const fullBleed = pageHeader?.fullBleed === true;
const logoUrl = settings?.organizationLogoUrl;
const showLogo = logoUrl && !(collapsed && !isMobile);
const sidebarMenu = (
<>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontWeight: 600,
fontSize: collapsed && !isMobile ? 14 : 16,
borderBottom: '1px solid rgba(255,255,255,0.1)',
padding: '0 8px',
gap: 8,
}}
>
{showLogo ? (
<img
src={logoUrl}
alt={settings?.organizationName ?? 'Logo'}
style={{ maxHeight: 36, maxWidth: collapsed && !isMobile ? 48 : 160, objectFit: 'contain' }}
/>
) : (
collapsed && !isMobile
? (settings?.organizationShortName ?? 'CML')
: (settings?.organizationName ?? 'Changemaker Lite')
)}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['influence-submenu', 'map-submenu', 'web-submenu', 'services-submenu']}
items={menuItems}
onClick={handleMenuClick}
/>
</>
);
return (
<Layout style={{ minHeight: '100vh' }}>
{isMobile ? (
<Drawer
placement="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={256}
styles={{ body: { padding: 0, background: '#001529' }, header: { display: 'none' } }}
>
{sidebarMenu}
</Drawer>
) : (
<Sider
trigger={null}
collapsible
collapsed={collapsed}
style={{ overflow: 'auto', height: '100vh', position: 'sticky', top: 0, left: 0 }}
>
{sidebarMenu}
</Sider>
)}
<Layout>
<Header
style={{
padding: '0 24px',
background: 'transparent',
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Button
type="text"
icon={
isMobile
? <MenuOutlined />
: collapsed
? <MenuUnfoldOutlined />
: <MenuFoldOutlined />
}
onClick={() => (isMobile ? setDrawerOpen(true) : setCollapsed(!collapsed))}
/>
{pageHeader?.title && (
<Text strong style={{ fontSize: 16, whiteSpace: 'nowrap' }}>
{pageHeader.title}
</Text>
)}
<div style={{ flex: 1 }} />
{pageHeader?.actions}
<Button
type="text"
icon={<GlobalOutlined />}
onClick={() => window.open(`//${window.location.hostname}:4004`, '_blank')}
title="Open Static Site"
>
{!isMobile && 'Open Site'}
</Button>
<Button
type="text"
icon={<EnvironmentOutlined />}
onClick={() => navigate('/volunteer')}
title="Switch to Volunteer Portal"
>
{!isMobile && 'Canvass'}
</Button>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" icon={<UserOutlined />}>
{!isMobile && (
<Text style={{ marginLeft: 8 }}>
{user?.name || user?.email || 'User'}
</Text>
)}
</Button>
</Dropdown>
</Header>
<Content
style={{
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
background: 'transparent',
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
minHeight: 280,
overflow: fullBleed ? 'hidden' : undefined,
}}
>
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
</Content>
</Layout>
</Layout>
);
}

View File

@ -0,0 +1,29 @@
import type { ReactNode } from 'react';
import { Result } from 'antd';
import { useSettingsStore } from '@/stores/settings.store';
import type { SiteSettings } from '@/types/api';
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter'>;
children: ReactNode;
}
export default function FeatureGate({ feature, children }: FeatureGateProps) {
const { settings, loading } = useSettingsStore();
// While loading or if settings haven't arrived yet, render children (optimistic)
if (loading || !settings) return <>{children}</>;
if (settings[feature] === false) {
return (
<Result
status="404"
title="Not Available"
subTitle="This feature is currently disabled."
style={{ paddingTop: 80 }}
/>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,223 @@
import { useEffect, useRef, useImperativeHandle, forwardRef, useState } from 'react';
import grapesjs, { Editor } from 'grapesjs';
import 'grapesjs/dist/css/grapes.min.css';
import blocksBasicPlugin from 'grapesjs-blocks-basic';
import presetWebpagePlugin from 'grapesjs-preset-webpage';
import formsPlugin from 'grapesjs-plugin-forms';
import navbarPlugin from 'grapesjs-navbar';
import countdownPlugin from 'grapesjs-component-countdown';
import tabsPlugin from 'grapesjs-tabs';
import typedPlugin from 'grapesjs-typed';
import customCodePlugin from 'grapesjs-custom-code';
import exportPlugin from 'grapesjs-plugin-export';
import styleGradientPlugin from 'grapesjs-style-gradient';
import touchPlugin from 'grapesjs-touch';
import type { PageBlock } from '@/types/api';
interface GrapesJSEditorProps {
initialData?: Record<string, unknown>;
onSave: (data: { projectData: Record<string, unknown>; html: string; css: string }) => void;
customBlocks?: PageBlock[];
}
export interface GrapesJSEditorHandle {
triggerSave: () => void;
}
const GrapesJSEditor = forwardRef<GrapesJSEditorHandle, GrapesJSEditorProps>(
function GrapesJSEditor({ initialData, onSave, customBlocks }, ref) {
const editorRef = useRef<Editor | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const onSaveRef = useRef(onSave);
const [error, setError] = useState<string | null>(null);
onSaveRef.current = onSave;
useImperativeHandle(ref, () => ({
triggerSave() {
editorRef.current?.runCommand('save-page');
},
}));
useEffect(() => {
if (!containerRef.current) return;
let editor: Editor;
try {
editor = grapesjs.init({
container: containerRef.current,
height: '100%',
width: 'auto',
storageManager: false,
plugins: [
blocksBasicPlugin,
presetWebpagePlugin,
formsPlugin,
navbarPlugin,
countdownPlugin,
tabsPlugin,
typedPlugin,
customCodePlugin,
exportPlugin,
styleGradientPlugin,
touchPlugin,
],
pluginsOpts: {
[blocksBasicPlugin as unknown as string]: {
flexGrid: true,
},
[presetWebpagePlugin as unknown as string]: {},
[formsPlugin as unknown as string]: {},
[navbarPlugin as unknown as string]: {},
[countdownPlugin as unknown as string]: {},
[tabsPlugin as unknown as string]: {},
[typedPlugin as unknown as string]: {},
[customCodePlugin as unknown as string]: {},
[exportPlugin as unknown as string]: {},
[styleGradientPlugin as unknown as string]: {},
[touchPlugin as unknown as string]: {},
},
canvas: {
styles: [
'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap',
],
},
});
} catch (err) {
console.error('GrapesJS init error:', err);
setError('Failed to initialize the page editor. Please refresh the page.');
return;
}
// Register custom blocks from PageBlock library
if (customBlocks && customBlocks.length > 0) {
const blockManager = editor.Blocks;
for (const block of customBlocks) {
const defaults = block.defaults as Record<string, unknown>;
const html = generateBlockHtml(block.type, defaults);
blockManager.add(`custom-${block.type}`, {
label: block.label,
category: block.category || 'Campaign',
content: html,
});
}
}
// Register save command
editor.Commands.add('save-page', {
run(ed: Editor) {
const projectData = ed.getProjectData() as Record<string, unknown>;
const html = ed.getHtml();
const css = ed.getCss() || '';
onSaveRef.current({ projectData, html, css });
},
});
// Keyboard shortcut: Ctrl+S / Cmd+S
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
editor.runCommand('save-page');
}
};
document.addEventListener('keydown', handleKeyDown);
// Load initial project data
if (initialData && Object.keys(initialData).length > 0) {
editor.loadProjectData(initialData);
}
editorRef.current = editor;
return () => {
document.removeEventListener('keydown', handleKeyDown);
editor.destroy();
editorRef.current = null;
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>
{error}
</div>
);
}
return (
<div
ref={containerRef}
role="application"
aria-label="Page builder editor"
style={{ flex: 1, overflow: 'hidden' }}
/>
);
});
function generateBlockHtml(type: string, defaults: Record<string, unknown>): string {
switch (type) {
case 'hero':
return `
<section style="padding: 80px 40px; text-align: center; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #fff;">
<h1 style="font-size: 2.5rem; margin-bottom: 16px;">${defaults.title || 'Hero Title'}</h1>
<p style="font-size: 1.25rem; opacity: 0.85; margin-bottom: 32px;">${defaults.subtitle || 'Subtitle text here'}</p>
<a href="${defaults.ctaUrl || '#'}" style="display: inline-block; padding: 12px 32px; background: #9d4edd; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600;">${defaults.ctaText || 'Get Started'}</a>
</section>`;
case 'text':
return `
<section style="padding: 60px 40px; max-width: 800px; margin: 0 auto;">
<h2 style="font-size: 1.75rem; margin-bottom: 16px;">${defaults.heading || 'Heading'}</h2>
<p style="font-size: 1rem; line-height: 1.7; opacity: 0.85;">${defaults.body || 'Body text goes here.'}</p>
</section>`;
case 'features': {
const features = (defaults.features as Array<{ title: string; description: string }>) || [];
const featureHtml = features.map(f => `
<div style="flex: 1; min-width: 250px; padding: 24px; text-align: center;">
<h3 style="font-size: 1.25rem; margin-bottom: 8px;">${f.title}</h3>
<p style="opacity: 0.8;">${f.description}</p>
</div>`).join('');
return `
<section style="padding: 60px 40px;">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
${featureHtml}
</div>
</section>`;
}
case 'cta':
return `
<section style="padding: 60px 40px; text-align: center; background: linear-gradient(135deg, #9d4edd 0%, #7b2cbf 100%); color: #fff;">
<h2 style="font-size: 2rem; margin-bottom: 12px;">${defaults.heading || 'Call to Action'}</h2>
<p style="font-size: 1.1rem; margin-bottom: 24px; opacity: 0.9;">${defaults.description || 'Description here'}</p>
<a href="${defaults.buttonUrl || '#'}" style="display: inline-block; padding: 12px 32px; background: #fff; color: #9d4edd; text-decoration: none; border-radius: 6px; font-weight: 600;">${defaults.buttonText || 'Click Here'}</a>
</section>`;
case 'testimonials': {
const quotes = (defaults.quotes as Array<{ text: string; author: string; role: string }>) || [];
const quoteHtml = quotes.map(q => `
<div style="flex: 1; min-width: 280px; padding: 24px; background: rgba(255,255,255,0.05); border-radius: 8px;">
<p style="font-style: italic; margin-bottom: 12px;">"${q.text}"</p>
<p style="font-weight: 600; margin-bottom: 2px;">${q.author}</p>
<p style="font-size: 0.85rem; opacity: 0.7;">${q.role}</p>
</div>`).join('');
return `
<section style="padding: 60px 40px;">
<div style="display: flex; flex-wrap: wrap; gap: 24px; justify-content: center;">
${quoteHtml}
</div>
</section>`;
}
case 'contact-form':
return `
<section style="padding: 60px 40px; max-width: 600px; margin: 0 auto;">
<h2 style="text-align: center; margin-bottom: 24px;">${defaults.heading || 'Contact Us'}</h2>
<form style="display: flex; flex-direction: column; gap: 16px;">
<input type="text" placeholder="Name" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
<input type="email" placeholder="Email" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit;" />
<textarea placeholder="Message" rows="4" style="padding: 10px 14px; border: 1px solid rgba(255,255,255,0.2); border-radius: 6px; background: rgba(255,255,255,0.05); color: inherit; resize: vertical;"></textarea>
<button type="submit" style="padding: 12px 24px; background: #9d4edd; color: #fff; border: none; border-radius: 6px; font-weight: 600; cursor: pointer;">Send Message</button>
</form>
</section>`;
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}
}
export default GrapesJSEditor;

View File

@ -0,0 +1,47 @@
import { Navigate } from 'react-router-dom';
import { Spin, Result } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import type { UserRole } from '@/types/api';
interface ProtectedRouteProps {
children: React.ReactNode;
requiredRoles?: UserRole[];
}
export default function ProtectedRoute({
children,
requiredRoles,
}: ProtectedRouteProps) {
const { isAuthenticated, isLoading, user } = useAuthStore();
if (isLoading) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
<Spin size="large" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
return (
<Result
status="403"
title="403"
subTitle="You do not have permission to access this page."
/>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,104 @@
import { useEffect } from 'react';
import { ConfigProvider, Layout, Typography, theme } from 'antd';
import { Outlet, Link } from 'react-router-dom';
import { useSettingsStore } from '@/stores/settings.store';
const { Header, Content, Footer } = Layout;
export default function PublicLayout() {
const { settings } = useSettingsStore();
const colorPrimary = settings?.publicColorPrimary ?? '#3498db';
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
const headerGradient = settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)';
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
const logoUrl = settings?.organizationLogoUrl;
// Dynamic document title + favicon for public pages
useEffect(() => {
if (!settings) return;
document.title = settings.organizationName || 'Changemaker Lite';
if (settings.organizationFaviconUrl) {
let link = document.querySelector<HTMLLinkElement>("link[rel~='icon']");
if (!link) {
link = document.createElement('link');
link.rel = 'icon';
document.head.appendChild(link);
}
link.href = settings.organizationFaviconUrl;
}
}, [settings]);
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary,
colorBgBase,
colorBgContainer,
colorBgElevated: colorBgContainer,
colorBorder: 'rgba(255,255,255,0.1)',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
borderRadius: 8,
colorLink: colorPrimary,
},
}}
>
<Layout style={{ minHeight: '100vh', background: colorBgBase }}>
<Header
style={{
background: headerGradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 24px',
height: 56,
borderBottom: 'none',
}}
>
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 10 }}>
{logoUrl && (
<img
src={logoUrl}
alt={orgName}
style={{ maxHeight: 32, objectFit: 'contain' }}
/>
)}
<Typography.Text strong style={{ fontSize: 18, color: '#fff' }}>
{orgName}
</Typography.Text>
</Link>
</Header>
<Content
style={{
maxWidth: 960,
width: '100%',
margin: '0 auto',
padding: '24px 16px',
}}
>
<Outlet />
</Content>
<Footer
style={{
textAlign: 'center',
background: 'transparent',
color: 'rgba(255,255,255,0.35)',
fontSize: 13,
borderTop: '1px solid rgba(255,255,255,0.06)',
}}
>
<div>{footerText}</div>
<div style={{ marginTop: 8 }}>
<Link to="/campaigns" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>
Return to Main Page
</Link>
</div>
</Footer>
</Layout>
</ConfigProvider>
);
}

View File

@ -0,0 +1,77 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { theme } from 'antd';
import {
EnvironmentOutlined,
CalendarOutlined,
HistoryOutlined,
NodeIndexOutlined,
} from '@ant-design/icons';
const NAV_ITEMS = [
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer/shifts', icon: CalendarOutlined, label: 'Shifts' },
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
];
interface VolunteerFooterNavProps {
style?: React.CSSProperties;
}
export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) {
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
const activeKey = (() => {
const path = location.pathname;
if (path === '/volunteer') return '/volunteer';
for (const item of NAV_ITEMS) {
if (item.key !== '/volunteer' && path.startsWith(item.key)) return item.key;
}
return '/volunteer';
})();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-around',
minHeight: 56,
background: 'rgba(13, 27, 42, 0.95)',
borderTop: '1px solid rgba(255,255,255,0.1)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
paddingBottom: 'env(safe-area-inset-bottom)',
...style,
}}
>
{NAV_ITEMS.map(({ key, icon: Icon, label }) => {
const isActive = activeKey === key;
return (
<div
key={key}
onClick={() => navigate(key)}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
cursor: 'pointer',
padding: '6px 0',
color: isActive ? token.colorPrimary : 'rgba(255,255,255,0.5)',
transition: 'color 0.2s',
}}
>
<Icon style={{ fontSize: 22, marginBottom: 2 }} />
<span style={{ fontSize: 12, lineHeight: '16px', fontWeight: isActive ? 600 : 400 }}>
{label}
</span>
</div>
);
})}
</div>
);
}

View File

@ -0,0 +1,91 @@
import { useNavigate, Outlet } from 'react-router-dom';
import { ConfigProvider, Layout, Button, Typography, Dropdown, theme } from 'antd';
import { LogoutOutlined, UserOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
const { Header, Content, Footer } = Layout;
export default function VolunteerLayout() {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const { settings } = useSettingsStore();
const orgName = settings?.organizationName ?? 'Changemaker Lite';
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
const userMenuItems: MenuProps['items'] = [
{ key: 'logout', icon: <LogoutOutlined />, label: 'Logout', onClick: handleLogout },
];
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
colorBgElevated: settings?.publicColorBgContainer ?? '#1b2838',
colorBorder: 'rgba(255,255,255,0.1)',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
borderRadius: 8,
},
}}
>
<Layout style={{ minHeight: '100vh', background: settings?.publicColorBgBase ?? '#0d1b2a' }}>
<Header
style={{
background: settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
height: 48,
gap: 12,
}}
>
<Typography.Text strong style={{ fontSize: 16, color: '#fff', flex: 1 }}>
{orgName}
</Typography.Text>
<Dropdown menu={{ items: userMenuItems }} placement="bottomRight">
<Button type="text" size="small" icon={<UserOutlined style={{ color: '#fff' }} />}>
<Typography.Text style={{ marginLeft: 4, color: '#fff', fontSize: 13 }}>
{user?.name || user?.email || 'User'}
</Typography.Text>
</Button>
</Dropdown>
</Header>
<Content
style={{
maxWidth: 800,
width: '100%',
margin: '0 auto',
padding: '16px 12px max(72px, calc(56px + 16px + env(safe-area-inset-bottom))) 12px',
}}
>
<Outlet />
</Content>
<Footer
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
padding: 0,
zIndex: 100,
}}
>
<VolunteerFooterNav />
</Footer>
</Layout>
</ConfigProvider>
);
}

View File

@ -0,0 +1,293 @@
import { useEffect, useState } from 'react';
import { Drawer, Form, Input, Button, Spin, Typography, Space, App } from 'antd';
import type { SupportLevel, UserRole } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS, MAP_ADMIN_ROLES } from '@/types/api';
import type { VisitOutcome } from '@/types/canvass';
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
import { useCanvassStore } from '@/stores/canvass.store';
import { useTrackingStore } from '@/stores/tracking.store';
interface AddLocationDrawerProps {
open: boolean;
onClose: () => void;
lat: number | null;
lng: number | null;
userRole: UserRole;
sessionId?: string;
shiftId?: string;
}
const outcomeKeys: VisitOutcome[] = [
'SPOKE_WITH',
'NOT_HOME',
'REFUSED',
'LEFT_LITERATURE',
'COME_BACK_LATER',
'MOVED',
'ALREADY_VOTED',
];
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
export default function AddLocationDrawer({
open,
onClose,
lat,
lng,
userRole,
sessionId,
shiftId,
}: AddLocationDrawerProps) {
const [form] = Form.useForm();
const { message } = App.useApp();
const { addLocation, recordVisit, reverseGeocode } = useCanvassStore();
const [loading, setLoading] = useState(false);
const [geocoding, setGeocoding] = useState(false);
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
const [signRequested, setSignRequested] = useState(false);
const [signSize, setSignSize] = useState<string | undefined>(undefined);
const [notes, setNotes] = useState('');
const isTemp = userRole === 'TEMP';
const isAdmin = MAP_ADMIN_ROLES.includes(userRole);
const showDetailFields = !isTemp;
useEffect(() => {
if (!open || lat === null || lng === null) return;
form.resetFields();
form.setFieldsValue({ address: '' });
setOutcome(null);
setSupportLevel(undefined);
setSignRequested(false);
setSignSize(undefined);
setNotes('');
// Reverse geocode the coordinates
setGeocoding(true);
reverseGeocode(lat, lng)
.then((result) => {
form.setFieldsValue({ address: result.address });
})
.catch(() => {
// Non-critical — user can type address manually
})
.finally(() => setGeocoding(false));
}, [open, lat, lng, form, reverseGeocode]);
const handleSubmit = async () => {
if (lat === null || lng === null) return;
if (!outcome) {
message.warning('Select an outcome');
return;
}
try {
const values = await form.validateFields();
setLoading(true);
// Build location data — include support level + sign from visit fields
const locationData: Record<string, unknown> = {
...values,
latitude: lat,
longitude: lng,
};
if (showDetailFields && supportLevel) locationData.supportLevel = supportLevel;
if (showDetailFields && signRequested) {
locationData.sign = true;
if (signSize) locationData.signSize = signSize;
}
if (showDetailFields && notes) locationData.notes = notes;
// Create location
const newLoc = await addLocation(locationData);
// Track event point for location added
useTrackingStore.getState().addEventPoint(lat, lng, 'LOCATION_ADDED');
// Record visit on the new location
await recordVisit({
locationId: newLoc.id,
outcome,
supportLevel,
signRequested,
signSize,
notes: notes || undefined,
sessionId,
shiftId,
});
message.success('Location added & visit recorded');
onClose();
} catch {
message.error('Failed to add location');
} finally {
setLoading(false);
}
};
return (
<Drawer
placement="bottom"
open={open}
onClose={onClose}
height="auto"
forceRender
styles={{
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
}}
title="New Door"
extra={
<Button type="primary" size="small" onClick={handleSubmit} loading={loading} disabled={!outcome}>
Save
</Button>
}
>
{geocoding && (
<div style={{ textAlign: 'center', padding: 20 }}>
<Spin size="small" />
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 8, fontSize: 12 }}>
Looking up address...
</Typography.Text>
</div>
)}
<div style={{ display: geocoding ? 'none' : undefined }}>
<Form form={form} layout="vertical" size="small">
<Form.Item
name="address"
label="Address"
rules={[{ required: true, message: 'Address is required' }]}
>
<Input />
</Form.Item>
<Form.Item name="unitNumber" label="Unit / Apt #">
<Input />
</Form.Item>
{isAdmin && (
<>
<Form.Item name="firstName" label="First Name">
<Input />
</Form.Item>
<Form.Item name="lastName" label="Last Name">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input type="email" />
</Form.Item>
<Form.Item name="phone" label="Phone">
<Input />
</Form.Item>
</>
)}
</Form>
{/* Outcome — always visible */}
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Outcome
</Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{outcomeKeys.map((key) => {
const color = VISIT_OUTCOME_COLORS[key];
const selected = outcome === key;
return (
<Button
key={key}
size="middle"
type={selected ? 'primary' : 'default'}
style={{
borderColor: color,
background: selected ? color : 'transparent',
color: selected ? '#fff' : color,
fontSize: 12,
}}
onClick={() => setOutcome(key)}
>
{VISIT_OUTCOME_LABELS[key]}
</Button>
);
})}
</div>
{/* Support level — non-TEMP, only when SPOKE_WITH */}
{showDetailFields && outcome === 'SPOKE_WITH' && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Support Level
</Typography.Text>
<Space style={{ marginBottom: 12 }}>
{supportLevelKeys.map((key) => (
<Button
key={key}
shape="circle"
size="large"
type={supportLevel === key ? 'primary' : 'default'}
style={{
width: 44,
height: 44,
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
borderColor: SUPPORT_LEVEL_COLORS[key],
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
fontWeight: 700,
}}
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
>
{key.replace('LEVEL_', '')}
</Button>
))}
</Space>
{supportLevel && (
<div style={{ marginBottom: 4 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{SUPPORT_LEVEL_LABELS[supportLevel]}
</Typography.Text>
</div>
)}
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Sign
</Typography.Text>
<Space style={{ marginBottom: 12 }}>
<Button
type={signRequested ? 'primary' : 'default'}
onClick={() => { setSignRequested(!signRequested); if (signRequested) setSignSize(undefined); }}
>
{signRequested ? 'Yes' : 'No Sign'}
</Button>
{signRequested && (
<>
{(['Regular', 'Large', 'Unsure'] as const).map((size) => (
<Button
key={size}
type={signSize === size ? 'primary' : 'default'}
onClick={() => setSignSize(size)}
size="small"
>
{size}
</Button>
))}
</>
)}
</Space>
</>
)}
{/* Notes — non-TEMP */}
{showDetailFields && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Notes
</Typography.Text>
<Input.TextArea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Optional notes..."
style={{ marginBottom: 12 }}
/>
</>
)}
</div>
</Drawer>
);
}

View File

@ -0,0 +1,104 @@
import { useState, useRef } from 'react';
import { Input, Button, App } from 'antd';
import type { InputRef } from 'antd';
import { SearchOutlined, CloseOutlined } from '@ant-design/icons';
import { useCanvassStore } from '@/stores/canvass.store';
interface Props {
onFlyTo: (lat: number, lng: number) => void;
}
export default function AddressSearchOverlay({ onFlyTo }: Props) {
const [expanded, setExpanded] = useState(false);
const [searching, setSearching] = useState(false);
const [query, setQuery] = useState('');
const inputRef = useRef<InputRef>(null);
const { message } = App.useApp();
const { geocodeSearch } = useCanvassStore();
const handleSearch = async () => {
if (!query.trim()) return;
setSearching(true);
try {
const result = await geocodeSearch(query.trim());
onFlyTo(result.latitude, result.longitude);
setExpanded(false);
setQuery('');
} catch {
message.error('Address not found');
} finally {
setSearching(false);
}
};
if (!expanded) {
return (
<button
onClick={() => setExpanded(true)}
title="Search address"
style={{
position: 'absolute',
top: 12,
left: 60,
zIndex: 1000,
width: 40,
height: 40,
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.3)',
background: 'rgba(0,0,0,0.6)',
color: 'rgba(255,255,255,0.8)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
backdropFilter: 'blur(4px)',
}}
>
<SearchOutlined />
</button>
);
}
return (
<div
style={{
position: 'absolute',
top: 12,
left: 60,
zIndex: 1000,
display: 'flex',
gap: 4,
background: 'rgba(0,0,0,0.75)',
borderRadius: 8,
padding: '4px 6px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.2)',
}}
>
<Input
ref={inputRef}
placeholder="Search address..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onPressEnter={handleSearch}
size="small"
style={{ width: 200, background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }}
autoFocus
/>
<Button
type="primary"
size="small"
icon={<SearchOutlined />}
onClick={handleSearch}
loading={searching}
/>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => { setExpanded(false); setQuery(''); }}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }}
/>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { MapContainer, TileLayer } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { api } from '@/lib/api';
import type { LiveVolunteer } from '@/types/tracking';
import type { PublicCut, MapSettings } from '@/types/api';
import CutOverlays from '@/components/map/CutOverlays';
import VolunteerMarker from './VolunteerMarker';
const DARK_TILE = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
const DEFAULT_CENTER: [number, number] = [45.42, -75.69];
const DEFAULT_ZOOM = 13;
const POLL_INTERVAL = 15000;
interface AdminLiveMapProps {
cuts: PublicCut[];
mapSettings: MapSettings | null;
}
export default function AdminLiveMap({ cuts, mapSettings }: AdminLiveMapProps) {
const [volunteers, setVolunteers] = useState<LiveVolunteer[]>([]);
const mapRef = useRef<LeafletMap | null>(null);
const fetchLive = useCallback(async () => {
try {
const { data } = await api.get('/map/tracking/live');
setVolunteers(data);
} catch {
// Silently fail — will retry
}
}, []);
useEffect(() => {
fetchLive();
const interval = setInterval(fetchLive, POLL_INTERVAL);
return () => clearInterval(interval);
}, [fetchLive]);
const center: [number, number] = mapSettings?.latitude && mapSettings?.longitude
? [parseFloat(mapSettings.latitude), parseFloat(mapSettings.longitude)]
: DEFAULT_CENTER;
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
const visibleCutIds = new Set(cuts.map((c) => c.id));
return (
<MapContainer
center={center}
zoom={zoom}
style={{ width: '100%', height: '100%', borderRadius: 6 }}
zoomControl={true}
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://carto.com">CARTO</a>'
url={DARK_TILE}
/>
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
{volunteers.map((v, i) => (
<VolunteerMarker key={v.trackingSessionId} volunteer={v} index={i} />
))}
</MapContainer>
);
}

View File

@ -0,0 +1,130 @@
import { Button, Badge } from 'antd';
import {
AimOutlined,
NodeIndexOutlined,
ArrowRightOutlined,
PlusOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
MenuOutlined,
} from '@ant-design/icons';
interface CanvassBottomToolbarProps {
visitedCount: number;
totalCount: number;
routeVisible: boolean;
gpsFollowing: boolean;
onNextDoor: () => void;
onToggleRoute: () => void;
onToggleGps: () => void;
sessionActive?: boolean;
onAddAtCenter?: () => void;
fullscreen?: boolean;
onToggleFullscreen?: () => void;
onMenuOpen?: () => void;
bottomOffset?: number;
}
/**
* Floating toolbar above the footer nav bar.
*/
export default function CanvassBottomToolbar({
visitedCount,
totalCount,
routeVisible,
gpsFollowing,
onNextDoor,
onToggleRoute,
onToggleGps,
sessionActive = true,
onAddAtCenter,
fullscreen,
onToggleFullscreen,
onMenuOpen,
bottomOffset = 72,
}: CanvassBottomToolbarProps) {
return (
<div
style={{
position: 'absolute',
bottom: bottomOffset,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
background: 'rgba(0,0,0,0.8)',
borderRadius: 12,
padding: '6px 10px',
display: 'flex',
alignItems: 'center',
gap: 6,
}}
>
{onMenuOpen && (
<Button
type="default"
icon={<MenuOutlined />}
onClick={onMenuOpen}
size="middle"
aria-label="Open menu"
/>
)}
{sessionActive && (
<>
<Button
type="primary"
icon={<ArrowRightOutlined />}
onClick={onNextDoor}
size="middle"
aria-label="Next door"
>
Next
</Button>
<Button
type={routeVisible ? 'primary' : 'default'}
icon={<NodeIndexOutlined />}
onClick={onToggleRoute}
size="middle"
ghost={routeVisible}
aria-label="Toggle walking route"
/>
</>
)}
<Button
type={gpsFollowing ? 'primary' : 'default'}
icon={<AimOutlined />}
onClick={onToggleGps}
size="middle"
ghost={gpsFollowing}
aria-label="Toggle GPS following"
/>
{onToggleFullscreen && (
<Button
type={fullscreen ? 'primary' : 'default'}
icon={fullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={onToggleFullscreen}
size="middle"
aria-label={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
/>
)}
{onAddAtCenter && (
<Button
type="default"
icon={<PlusOutlined />}
onClick={onAddAtCenter}
size="middle"
aria-label="Add location at crosshair"
/>
)}
{sessionActive && (
<Badge
count={`${visitedCount}/${totalCount}`}
style={{
backgroundColor: visitedCount === totalCount && totalCount > 0 ? '#27ae60' : '#3498db',
fontSize: 12,
}}
overflowCount={99}
/>
)}
</div>
);
}

View File

@ -0,0 +1,60 @@
import { Button, Typography } from 'antd';
import { ArrowLeftOutlined, StopOutlined } from '@ant-design/icons';
import SessionTimer from './SessionTimer';
interface CanvassHeaderProps {
cutName: string;
startedAt: string;
onBack: () => void;
onEndSession: () => void;
endingSession: boolean;
}
export default function CanvassHeader({
cutName,
startedAt,
onBack,
onEndSession,
endingSession,
}: CanvassHeaderProps) {
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 48,
zIndex: 1000,
background: 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
padding: '0 8px',
gap: 8,
}}
>
<Button
type="text"
icon={<ArrowLeftOutlined style={{ color: '#fff' }} />}
onClick={onBack}
size="small"
/>
<Typography.Text
strong
style={{ color: '#fff', fontSize: 14, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{cutName}
</Typography.Text>
<SessionTimer startedAt={startedAt} />
<Button
danger
size="small"
icon={<StopOutlined />}
onClick={onEndSession}
loading={endingSession}
>
End
</Button>
</div>
);
}

View File

@ -0,0 +1,60 @@
import { VISIT_OUTCOME_COLORS, VISIT_OUTCOME_LABELS, type VisitOutcome } from '@/types/canvass';
const items: { key: string; label: string; color: string }[] = [
{ key: 'unvisited', label: 'Not Visited', color: '#95a5a6' },
...Object.entries(VISIT_OUTCOME_LABELS).map(([key, label]) => ({
key,
label,
color: VISIT_OUTCOME_COLORS[key as VisitOutcome],
})),
];
export default function CanvassLegend() {
return (
<div
style={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 1000,
background: 'rgba(0,0,0,0.75)',
borderRadius: 8,
padding: '8px 10px',
maxWidth: 180,
}}
>
{/* Icon type indicators */}
<div style={{ display: 'flex', gap: 12, marginBottom: 6, paddingBottom: 4, borderBottom: '1px solid rgba(255,255,255,0.15)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<svg width={14} height={14} viewBox="0 0 24 24">
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z" fill="rgba(255,255,255,0.7)" />
</svg>
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>House</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<svg width={14} height={14} viewBox="0 0 24 24">
<rect x="4" y="3" width="16" height="18" rx="1" fill="rgba(255,255,255,0.7)" />
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(0,0,0,0.3)" />
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(0,0,0,0.3)" />
</svg>
<span style={{ color: 'rgba(255,255,255,0.6)', fontSize: 10 }}>Apt</span>
</div>
</div>
{items.map((item) => (
<div key={item.key} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
<div
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: item.color,
flexShrink: 0,
}}
/>
<span style={{ color: '#fff', fontSize: 11 }}>{item.label}</span>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,78 @@
import { useMemo } from 'react';
import { Marker, Tooltip } from 'react-leaflet';
import L from 'leaflet';
import type { CanvassLocation } from '@/types/canvass';
import { VISIT_OUTCOME_COLORS } from '@/types/canvass';
interface CanvassMarkerProps {
location: CanvassLocation;
isSelected: boolean;
onClick: () => void;
}
function getMarkerColor(location: CanvassLocation): string {
if (!location.lastVisit) return '#95a5a6'; // gray — unvisited
return VISIT_OUTCOME_COLORS[location.lastVisit.outcome] ?? '#95a5a6';
}
// Inline SVG for house icon
function houseSvg(color: string, size: number, selected: boolean): string {
const glow = selected ? `<circle cx="${size / 2}" cy="${size / 2}" r="${size / 2}" fill="none" stroke="white" stroke-width="3" opacity="0.6"/>` : '';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
${glow ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
<path d="M12 3L4 10v10a1 1 0 001 1h4v-6h6v6h4a1 1 0 001-1V10z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
<path d="M12 3L4 10h16z" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
</svg>`;
}
// Inline SVG for apartment/building icon
function apartmentSvg(color: string, size: number, selected: boolean): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24">
${selected ? `<circle cx="12" cy="12" r="12" fill="none" stroke="white" stroke-width="2" opacity="0.5"/>` : ''}
<rect x="4" y="3" width="16" height="18" rx="1" fill="${color}" stroke="rgba(0,0,0,0.3)" stroke-width="0.5"/>
<rect x="7" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="14" y="6" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="7" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="14" y="11" width="3" height="2.5" rx="0.3" fill="rgba(255,255,255,0.5)"/>
<rect x="10" y="16" width="4" height="5" rx="0.3" fill="rgba(255,255,255,0.4)"/>
</svg>`;
}
export default function CanvassMarker({ location, isSelected, onClick }: CanvassMarkerProps) {
const color = getMarkerColor(location);
const isApartment = !!location.unitNumber;
const size = isSelected ? 34 : 26;
const icon = useMemo(() => {
const svgHtml = isApartment
? apartmentSvg(color, size, isSelected)
: houseSvg(color, size, isSelected);
return L.divIcon({
html: `<div style="width:44px;height:44px;display:flex;align-items:center;justify-content:center">${svgHtml}</div>`,
iconSize: [44, 44],
iconAnchor: [22, 22],
className: '',
});
}, [color, size, isSelected, isApartment]);
return (
<Marker
position={[location.latitude, location.longitude]}
icon={icon}
eventHandlers={{ click: onClick }}
>
<Tooltip direction="top" offset={[0, -16]}>
<div style={{ fontSize: 12 }}>
<strong>{location.address || 'Unknown'}</strong>
{location.unitNumber && <span> #{location.unitNumber}</span>}
{location.lastVisit && (
<div style={{ color: '#888' }}>
Last: {location.lastVisit.outcome.replace('_', ' ')}
</div>
)}
</div>
</Tooltip>
</Marker>
);
}

View File

@ -0,0 +1,111 @@
import { useState, useEffect, useRef } from 'react';
import { CircleMarker, useMap } from 'react-leaflet';
import { useTrackingStore } from '@/stores/tracking.store';
interface GPSTrackerProps {
following: boolean;
onPositionChange?: (lat: number, lng: number) => void;
}
export default function GPSTracker({ following, onPositionChange }: GPSTrackerProps) {
const map = useMap();
const [position, setPosition] = useState<[number, number] | null>(null);
const watchRef = useRef<number | null>(null);
const addPoint = useTrackingStore((s) => s.addPoint);
const flushPoints = useTrackingStore((s) => s.flushPoints);
const isTracking = useTrackingStore((s) => s.isTracking);
useEffect(() => {
if (!navigator.geolocation) return;
watchRef.current = navigator.geolocation.watchPosition(
(pos) => {
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setPosition(latlng);
onPositionChange?.(latlng[0], latlng[1]);
if (isTracking) {
addPoint({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
accuracy: pos.coords.accuracy ?? undefined,
recordedAt: new Date().toISOString(),
});
}
if (following) {
map.setView(latlng, map.getZoom(), { animate: true });
}
},
() => {},
{ enableHighAccuracy: true, maximumAge: 5000, timeout: 10000 },
);
return () => {
if (watchRef.current !== null) {
navigator.geolocation.clearWatch(watchRef.current);
}
};
}, [map, following, onPositionChange, isTracking, addPoint]);
// Flush GPS points every 30 seconds
useEffect(() => {
if (!isTracking) return;
const interval = setInterval(() => {
flushPoints();
}, 30000);
return () => {
clearInterval(interval);
// Final flush on unmount
flushPoints();
};
}, [isTracking, flushPoints]);
// Best-effort flush on page unload
useEffect(() => {
const handleBeforeUnload = () => {
const { trackingSessionId, pendingPoints } = useTrackingStore.getState();
if (trackingSessionId && pendingPoints.length > 0) {
const payload = JSON.stringify({ points: pendingPoints });
navigator.sendBeacon?.(
`/api/map/tracking/sessions/${trackingSessionId}/points`,
new Blob([payload], { type: 'application/json' }),
);
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, []);
if (!position) return null;
return (
<>
{/* Outer pulse ring */}
<CircleMarker
center={position}
radius={16}
pathOptions={{
color: '#3498db',
fillColor: '#3498db',
fillOpacity: 0.15,
weight: 1,
}}
/>
{/* Inner dot */}
<CircleMarker
center={position}
radius={6}
pathOptions={{
color: '#fff',
fillColor: '#3498db',
fillOpacity: 1,
weight: 2,
}}
/>
</>
);
}

View File

@ -0,0 +1,205 @@
import { useState, useCallback, useEffect } from 'react';
import { Drawer, Table, DatePicker, Select, Button, Space, Typography, Tag } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { HistoryOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { api } from '@/lib/api';
import type { TrackingSessionSummary, SessionRoute } from '@/types/tracking';
import type { PaginationMeta } from '@/types/api';
dayjs.extend(duration);
interface HistoricalRoutesDrawerProps {
open: boolean;
onClose: () => void;
onSelectRoute: (route: SessionRoute | null) => void;
volunteers: { userId: string; name: string | null; email: string }[];
}
export default function HistoricalRoutesDrawer({
open,
onClose,
onSelectRoute,
volunteers,
}: HistoricalRoutesDrawerProps) {
const [sessions, setSessions] = useState<TrackingSessionSummary[]>([]);
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
const [loading, setLoading] = useState(false);
const [routeLoading, setRouteLoading] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// Filters
const [userId, setUserId] = useState<string | undefined>(undefined);
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
const [page, setPage] = useState(1);
const fetchSessions = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string> = { page: page.toString(), limit: '15' };
if (userId) params.userId = userId;
if (dateRange?.[0]) params.from = dateRange[0].startOf('day').toISOString();
if (dateRange?.[1]) params.to = dateRange[1].endOf('day').toISOString();
const { data } = await api.get('/map/tracking/sessions', { params });
setSessions(data.sessions);
setPagination(data.pagination);
} catch {
// Silent
} finally {
setLoading(false);
}
}, [page, userId, dateRange]);
useEffect(() => {
if (open) fetchSessions();
}, [open, fetchSessions]);
const handleViewRoute = async (sessionId: string) => {
if (selectedId === sessionId) {
setSelectedId(null);
onSelectRoute(null);
return;
}
setRouteLoading(sessionId);
try {
const { data } = await api.get(`/map/tracking/sessions/${sessionId}/route`);
setSelectedId(sessionId);
onSelectRoute(data);
} catch {
// Silent
} finally {
setRouteLoading(null);
}
};
const formatDuration = (startedAt: string, endedAt: string | null) => {
if (!endedAt) return 'Active';
const dur = dayjs.duration(dayjs(endedAt).diff(dayjs(startedAt)));
const hours = Math.floor(dur.asHours());
const mins = dur.minutes();
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
};
const formatDistance = (meters: number) => {
if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`;
return `${Math.round(meters)} m`;
};
const columns: ColumnsType<TrackingSessionSummary> = [
{
title: 'Volunteer',
key: 'volunteer',
render: (_: unknown, r: TrackingSessionSummary) => r.userName || r.userEmail,
ellipsis: true,
},
{
title: 'Date',
dataIndex: 'startedAt',
key: 'date',
render: (val: string) => dayjs(val).format('MMM D, h:mm A'),
width: 140,
},
{
title: 'Duration',
key: 'duration',
render: (_: unknown, r: TrackingSessionSummary) => formatDuration(r.startedAt, r.endedAt),
width: 80,
},
{
title: 'Distance',
dataIndex: 'totalDistanceM',
key: 'distance',
render: (val: number) => formatDistance(val),
width: 80,
},
{
title: 'Points',
dataIndex: 'totalPoints',
key: 'points',
width: 60,
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, r: TrackingSessionSummary) => (
<Button
size="small"
type={selectedId === r.id ? 'primary' : 'default'}
loading={routeLoading === r.id}
onClick={() => handleViewRoute(r.id)}
>
{selectedId === r.id ? 'Hide' : 'View'}
</Button>
),
},
];
return (
<Drawer
title={<><HistoryOutlined /> Route History</>}
placement="right"
width={600}
open={open}
onClose={() => {
onClose();
onSelectRoute(null);
setSelectedId(null);
}}
>
<Space style={{ marginBottom: 16 }} wrap>
<Select
placeholder="All volunteers"
allowClear
style={{ width: 180 }}
value={userId}
onChange={(v) => { setUserId(v); setPage(1); }}
options={volunteers.map((v) => ({
label: v.name || v.email,
value: v.userId,
}))}
/>
<DatePicker.RangePicker
value={dateRange}
onChange={(val) => { setDateRange(val as [dayjs.Dayjs, dayjs.Dayjs] | null); setPage(1); }}
style={{ width: 240 }}
/>
</Space>
{selectedId && (
<Tag color="blue" closable onClose={() => { setSelectedId(null); onSelectRoute(null); }} style={{ marginBottom: 12 }}>
Showing route on map
</Tag>
)}
<Table
dataSource={sessions}
columns={columns}
rowKey="id"
size="small"
loading={loading}
pagination={
pagination
? {
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: false,
onChange: (p) => setPage(p),
}
: false
}
rowClassName={(r) => (r.id === selectedId ? 'ant-table-row-selected' : '')}
/>
{sessions.length === 0 && !loading && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', marginTop: 24 }}>
No tracking sessions found
</Typography.Text>
)}
</Drawer>
);
}

View File

@ -0,0 +1,108 @@
import { useEffect } from 'react';
import { Drawer, Form, Input, Select, Switch, Button, message } from 'antd';
import type { CanvassLocation } from '@/types/canvass';
import type { SupportLevel } from '@/types/api';
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
import { useCanvassStore } from '@/stores/canvass.store';
interface LocationEditDrawerProps {
open: boolean;
onClose: () => void;
location: CanvassLocation | null;
}
const supportLevelOptions = (['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'] as SupportLevel[]).map(
(k) => ({ label: SUPPORT_LEVEL_LABELS[k], value: k }),
);
export default function LocationEditDrawer({
open,
onClose,
location,
}: LocationEditDrawerProps) {
const [form] = Form.useForm();
const { updateLocationFields } = useCanvassStore();
useEffect(() => {
if (location && open) {
form.setFieldsValue({
firstName: location.firstName,
lastName: location.lastName,
address: location.address,
unitNumber: location.unitNumber,
supportLevel: location.supportLevel,
sign: location.sign,
signSize: location.signSize,
notes: location.notes,
});
}
}, [location, open, form]);
const handleSave = async () => {
if (!location) return;
try {
const values = await form.validateFields();
await updateLocationFields(location.id, values);
message.success('Location updated');
onClose();
} catch {
message.error('Failed to update location');
}
};
return (
<Drawer
placement="bottom"
open={open}
onClose={onClose}
height="auto"
styles={{
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
}}
title="Edit Location"
extra={
<Button type="primary" size="small" onClick={handleSave}>
Save
</Button>
}
>
<Form form={form} layout="vertical" size="small">
<Form.Item name="firstName" label="First Name">
<Input />
</Form.Item>
<Form.Item name="lastName" label="Last Name">
<Input />
</Form.Item>
<Form.Item name="address" label="Address">
<Input />
</Form.Item>
<Form.Item name="unitNumber" label="Unit">
<Input />
</Form.Item>
<Form.Item name="supportLevel" label="Support Level">
<Select
options={supportLevelOptions}
allowClear
placeholder="Select..."
/>
</Form.Item>
<Form.Item name="sign" label="Sign" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="signSize" label="Sign Size">
<Select
options={[
{ label: 'Regular', value: 'Regular' },
{ label: 'Large', value: 'Large' },
]}
allowClear
placeholder="Select..."
/>
</Form.Item>
<Form.Item name="notes" label="Notes">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Drawer>
);
}

View File

@ -0,0 +1,111 @@
interface MapCrosshairProps {
onClick?: () => void;
}
/**
* Persistent crosshair overlay at the dead center of the map.
* The center dot is tappable clicking it triggers `onClick` to add
* a new location at the crosshair position.
*
* Crosshair lines remain pointer-events: none so they don't block
* map dragging. Only the center tap target captures clicks.
*/
export default function MapCrosshair({ onClick }: MapCrosshairProps) {
return (
<>
<style>{`
@keyframes crosshair-pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
50% { transform: translate(-50%, -50%) scale(1.6); opacity: 0.4; }
}
`}</style>
{/* Crosshair lines — no pointer events so map drags work */}
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 999,
pointerEvents: 'none',
width: 44,
height: 44,
}}
>
{/* Vertical line */}
<div
style={{
position: 'absolute',
top: 0,
left: '50%',
transform: 'translateX(-50%)',
width: 2,
height: '100%',
background: 'rgba(255,255,255,0.6)',
borderRadius: 1,
}}
/>
{/* Horizontal line */}
<div
style={{
position: 'absolute',
top: '50%',
left: 0,
transform: 'translateY(-50%)',
width: '100%',
height: 2,
background: 'rgba(255,255,255,0.6)',
borderRadius: 1,
}}
/>
{/* Pulse ring */}
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
width: 14,
height: 14,
borderRadius: '50%',
border: '2px solid rgba(52, 152, 219, 0.5)',
animation: 'crosshair-pulse 2s ease-in-out infinite',
}}
/>
</div>
{/* Tappable center dot — separate layer with pointer events */}
<div
onClick={onClick}
role="button"
aria-label="Add location at crosshair"
tabIndex={0}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onClick?.(); }}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 1000,
pointerEvents: 'auto',
cursor: 'pointer',
width: 44,
height: 44,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
}}
>
<div
style={{
width: 12,
height: 12,
borderRadius: '50%',
background: '#3498db',
border: '2px solid #fff',
boxShadow: '0 0 6px rgba(0,0,0,0.5)',
}}
/>
</div>
</>
);
}

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import { Typography } from 'antd';
interface SessionTimerProps {
startedAt: string;
}
export default function SessionTimer({ startedAt }: SessionTimerProps) {
const [elapsed, setElapsed] = useState('00:00');
useEffect(() => {
const start = new Date(startedAt).getTime();
const update = () => {
const diff = Math.floor((Date.now() - start) / 1000);
const hours = Math.floor(diff / 3600);
const minutes = Math.floor((diff % 3600) / 60);
const seconds = diff % 60;
if (hours > 0) {
setElapsed(`${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
} else {
setElapsed(`${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`);
}
};
update();
const interval = setInterval(update, 1000);
return () => clearInterval(interval);
}, [startedAt]);
return (
<Typography.Text style={{ color: '#fff', fontFamily: 'monospace', fontSize: 14, textShadow: '0 1px 2px rgba(0,0,0,0.5)' }}>
{elapsed}
</Typography.Text>
);
}

View File

@ -0,0 +1,198 @@
import { useState } from 'react';
import { Button, Input, Space, Typography, message } from 'antd';
import type { VisitOutcome, RecordVisitPayload, CanvassLocation } from '@/types/canvass';
import type { SupportLevel, UserRole } from '@/types/api';
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
interface VisitRecordingFormProps {
location: CanvassLocation;
sessionId?: string;
shiftId?: string;
onRecord: (payload: RecordVisitPayload) => Promise<void>;
recording: boolean;
userRole?: UserRole;
}
const outcomeKeys: VisitOutcome[] = [
'SPOKE_WITH',
'NOT_HOME',
'REFUSED',
'LEFT_LITERATURE',
'COME_BACK_LATER',
'MOVED',
'ALREADY_VOTED',
];
const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'];
export default function VisitRecordingForm({
location,
sessionId,
shiftId,
onRecord,
recording,
userRole,
}: VisitRecordingFormProps) {
const [outcome, setOutcome] = useState<VisitOutcome | null>(null);
const [supportLevel, setSupportLevel] = useState<SupportLevel | undefined>(undefined);
const [signRequested, setSignRequested] = useState(false);
const [signSize, setSignSize] = useState<string | undefined>(undefined);
const [notes, setNotes] = useState('');
const showDetailFields = userRole !== 'TEMP';
const handleSubmit = async () => {
if (!outcome) {
message.warning('Select an outcome');
return;
}
await onRecord({
locationId: location.id,
outcome,
supportLevel,
signRequested,
signSize,
notes: notes || undefined,
sessionId,
shiftId,
});
// Reset form
setOutcome(null);
setSupportLevel(undefined);
setSignRequested(false);
setSignSize(undefined);
setNotes('');
};
return (
<div style={{ padding: '0 4px' }}>
<Typography.Text strong style={{ fontSize: 15, display: 'block', marginBottom: 8 }}>
{location.address || 'Unknown Address'}
{location.unitNumber && ` #${location.unitNumber}`}
</Typography.Text>
{location.firstName && (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
{location.firstName} {location.lastName}
</Typography.Text>
)}
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Outcome
</Typography.Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
{outcomeKeys.map((key) => {
const color = VISIT_OUTCOME_COLORS[key];
const selected = outcome === key;
return (
<Button
key={key}
size="middle"
type={selected ? 'primary' : 'default'}
style={{
borderColor: color,
background: selected ? color : 'transparent',
color: selected ? '#fff' : color,
fontSize: 12,
}}
onClick={() => setOutcome(key)}
>
{VISIT_OUTCOME_LABELS[key]}
</Button>
);
})}
</div>
{showDetailFields && outcome === 'SPOKE_WITH' && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Support Level
</Typography.Text>
<Space style={{ marginBottom: 12 }}>
{supportLevelKeys.map((key) => (
<Button
key={key}
shape="circle"
size="large"
type={supportLevel === key ? 'primary' : 'default'}
style={{
width: 44,
height: 44,
background: supportLevel === key ? SUPPORT_LEVEL_COLORS[key] : undefined,
borderColor: SUPPORT_LEVEL_COLORS[key],
color: supportLevel === key ? '#fff' : SUPPORT_LEVEL_COLORS[key],
fontWeight: 700,
}}
onClick={() => setSupportLevel(supportLevel === key ? undefined : key)}
>
{key.replace('LEVEL_', '')}
</Button>
))}
</Space>
<div style={{ marginBottom: 4 }}>
{supportLevel && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{SUPPORT_LEVEL_LABELS[supportLevel]}
</Typography.Text>
)}
</div>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Sign
</Typography.Text>
<Space style={{ marginBottom: 12 }}>
<Button
type={signRequested ? 'primary' : 'default'}
onClick={() => { setSignRequested(!signRequested); if (signRequested) setSignSize(undefined); }}
>
{signRequested ? 'Yes' : 'No Sign'}
</Button>
{signRequested && (
<>
{['Regular', 'Large', 'Unsure'].map((size) => (
<Button
key={size}
type={signSize === size ? 'primary' : 'default'}
onClick={() => setSignSize(size)}
size="small"
>
{size}
</Button>
))}
</>
)}
</Space>
</>
)}
{showDetailFields && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>
Notes
</Typography.Text>
<Input.TextArea
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={2}
placeholder="Optional notes..."
style={{ marginBottom: 12 }}
/>
</>
)}
<Button
type="primary"
block
size="large"
onClick={handleSubmit}
loading={recording}
disabled={!outcome}
>
Record Visit
</Button>
</div>
);
}

View File

@ -0,0 +1,163 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List } from 'antd';
import {
HistoryOutlined,
LogoutOutlined,
PlayCircleOutlined,
AimOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { MyAssignment, MyCanvassStats } from '@/types/canvass';
import type { PublicCut } from '@/types/api';
interface VolunteerMapDrawerProps {
open: boolean;
onClose: () => void;
cuts: PublicCut[];
onStartSession: (cutId: string, shiftId?: string) => void;
}
export default function VolunteerMapDrawer({
open,
onClose,
cuts,
onStartSession,
}: VolunteerMapDrawerProps) {
const navigate = useNavigate();
const { user, logout } = useAuthStore();
const [stats, setStats] = useState<MyCanvassStats | null>(null);
const [assignments, setAssignments] = useState<MyAssignment[]>([]);
const [freeCutId, setFreeCutId] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
// Load stats and assignments when drawer opens
api.get('/map/canvass/my/stats').then(({ data }) => setStats(data)).catch(() => {});
api.get('/map/canvass/my/assignments').then(({ data }) => setAssignments(data)).catch(() => {});
}, [open]);
const handleLogout = async () => {
await logout();
navigate('/login', { replace: true });
};
return (
<Drawer
placement="left"
open={open}
onClose={onClose}
width={300}
styles={{
body: { padding: '16px', display: 'flex', flexDirection: 'column' },
header: { display: 'none' },
}}
>
<Typography.Text strong style={{ fontSize: 16, display: 'block', marginBottom: 4 }}>
{user?.name || user?.email || 'Volunteer'}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 16 }}>
{user?.email}
</Typography.Text>
{/* Mini stats */}
{stats && (
<Space size="large" style={{ marginBottom: 16 }}>
<Statistic title="Today" value={stats.todayVisits} valueStyle={{ fontSize: 20 }} />
<Statistic title="Total" value={stats.totalVisits} valueStyle={{ fontSize: 20 }} />
</Space>
)}
<Divider style={{ margin: '8px 0' }} />
{/* Assignments */}
{assignments.length > 0 && (
<>
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
My Assignments
</Typography.Text>
<List
size="small"
dataSource={assignments}
style={{ marginBottom: 12, maxHeight: 200, overflowY: 'auto' }}
renderItem={(a) => (
<List.Item
style={{ padding: '6px 0' }}
actions={[
<Button
key="start"
type="primary"
size="small"
icon={<PlayCircleOutlined />}
onClick={() => { onStartSession(a.cutId, a.shiftId); onClose(); }}
>
Start
</Button>,
]}
>
<List.Item.Meta
title={<span style={{ fontSize: 13 }}>{a.cutName}</span>}
description={
<span style={{ fontSize: 11 }}>
{a.shiftTitle} &middot; {Math.round(a.completionPercentage)}%
</span>
}
/>
</List.Item>
)}
/>
<Divider style={{ margin: '8px 0' }} />
</>
)}
{/* Free session — pick a cut */}
<Typography.Text strong style={{ display: 'block', marginBottom: 8, fontSize: 13 }}>
Start Session (Any Cut)
</Typography.Text>
<Space.Compact style={{ width: '100%', marginBottom: 16 }}>
<Select
placeholder="Select a cut..."
style={{ flex: 1 }}
value={freeCutId}
onChange={setFreeCutId}
options={cuts.map((c) => ({ label: c.name, value: c.id }))}
allowClear
/>
<Button
type="primary"
icon={<AimOutlined />}
disabled={!freeCutId}
onClick={() => { if (freeCutId) { onStartSession(freeCutId); onClose(); } }}
>
Go
</Button>
</Space.Compact>
{/* Navigation links */}
<Button
type="text"
icon={<HistoryOutlined />}
block
style={{ textAlign: 'left', marginBottom: 4 }}
onClick={() => { navigate('/volunteer/activity'); onClose(); }}
>
My Activity
</Button>
<div style={{ flex: 1 }} />
<Divider style={{ margin: '8px 0' }} />
<Button
type="text"
danger
icon={<LogoutOutlined />}
block
style={{ textAlign: 'left' }}
onClick={handleLogout}
>
Logout
</Button>
</Drawer>
);
}

View File

@ -0,0 +1,68 @@
import { Button, Typography } from 'antd';
import { StopOutlined } from '@ant-design/icons';
import SessionTimer from './SessionTimer';
interface VolunteerSessionBarProps {
sessionCutName?: string;
sessionStartedAt?: string;
onEndSession: () => void;
endingSession: boolean;
headerGradient?: string;
}
/**
* Slim session info bar pinned above the footer nav.
* Only rendered when a canvass session is active.
* Shows cut name, timer, and end button.
*/
export default function VolunteerSessionBar({
sessionCutName,
sessionStartedAt,
onEndSession,
endingSession,
headerGradient,
}: VolunteerSessionBarProps) {
return (
<div
style={{
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
height: 40,
zIndex: 1000,
background: headerGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
padding: '0 12px',
gap: 8,
}}
>
<Typography.Text
strong
style={{
color: '#fff',
fontSize: 13,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
textShadow: '0 1px 2px rgba(0,0,0,0.3)',
}}
>
{sessionCutName ?? 'Session'}
</Typography.Text>
{sessionStartedAt && <SessionTimer startedAt={sessionStartedAt} />}
<Button
danger
size="middle"
icon={<StopOutlined />}
onClick={onEndSession}
loading={endingSession}
aria-label="End session"
>
End
</Button>
</div>
);
}

View File

@ -0,0 +1,93 @@
import { Marker, Popup, Tooltip, Polyline } from 'react-leaflet';
import L from 'leaflet';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { LiveVolunteer } from '@/types/tracking';
dayjs.extend(relativeTime);
// Color palette for distinguishing volunteers
const COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6', '#1abc9c', '#e67e22', '#e91e63'];
function getColor(index: number) {
return COLORS[index % COLORS.length]!;
}
function createVolunteerIcon(color: string) {
return L.divIcon({
className: '',
iconSize: [20, 20],
iconAnchor: [10, 10],
html: `
<div style="
width: 16px; height: 16px;
background: ${color};
border: 2px solid #fff;
border-radius: 50%;
box-shadow: 0 0 6px ${color}80;
animation: pulse 2s ease-in-out infinite;
"></div>
<style>
@keyframes pulse {
0%, 100% { box-shadow: 0 0 6px ${color}80; }
50% { box-shadow: 0 0 14px ${color}cc; }
}
</style>
`,
});
}
interface VolunteerMarkerProps {
volunteer: LiveVolunteer;
index: number;
}
export default function VolunteerMarker({ volunteer, index }: VolunteerMarkerProps) {
const color = getColor(index);
const position: [number, number] = [volunteer.latitude, volunteer.longitude];
const name = volunteer.name || volunteer.email.split('@')[0]!;
const distanceText = volunteer.recentTrail.length > 1
? `${volunteer.recentTrail.length} recent points`
: '';
return (
<>
{/* Recent trail polyline */}
{volunteer.recentTrail.length > 1 && (
<Polyline
positions={volunteer.recentTrail}
pathOptions={{
color,
weight: 2,
opacity: 0.6,
dashArray: '6 4',
}}
/>
)}
{/* Volunteer position marker */}
<Marker position={position} icon={createVolunteerIcon(color)}>
<Tooltip permanent direction="top" offset={[0, -12]}>
<span style={{ fontSize: 11, fontWeight: 600 }}>{name}</span>
</Tooltip>
<Popup>
<div style={{ minWidth: 160 }}>
<div style={{ fontWeight: 600, marginBottom: 4 }}>{name}</div>
<div style={{ fontSize: 12, color: '#888' }}>{volunteer.email}</div>
<div style={{ fontSize: 12, marginTop: 6 }}>
Last update: {dayjs(volunteer.lastRecordedAt).fromNow()}
</div>
{distanceText && (
<div style={{ fontSize: 12 }}>{distanceText}</div>
)}
{volunteer.canvassSessionId && (
<div style={{ fontSize: 12, marginTop: 4, color: '#2ecc71' }}>
In canvass session
</div>
)}
</div>
</Popup>
</Marker>
</>
);
}

View File

@ -0,0 +1,45 @@
import { Polyline, CircleMarker, Tooltip } from 'react-leaflet';
import type { WalkingRoute } from '@/types/canvass';
interface WalkingRouteLineProps {
route: WalkingRoute;
}
export default function WalkingRouteLine({ route }: WalkingRouteLineProps) {
if (route.orderedLocations.length === 0) return null;
const positions = route.orderedLocations.map(
(loc) => [loc.latitude, loc.longitude] as [number, number],
);
return (
<>
<Polyline
positions={positions}
pathOptions={{
color: '#3498db',
weight: 3,
dashArray: '8, 6',
opacity: 0.7,
}}
/>
{route.orderedLocations.map((loc, idx) => (
<CircleMarker
key={`route-${loc.id}`}
center={[loc.latitude, loc.longitude]}
radius={4}
pathOptions={{
color: '#3498db',
fillColor: '#fff',
fillOpacity: 1,
weight: 2,
}}
>
<Tooltip direction="top" offset={[0, -6]} permanent={false}>
Stop #{idx + 1}
</Tooltip>
</CircleMarker>
))}
</>
);
}

View File

@ -0,0 +1,38 @@
import { useEffect } from 'react';
import { useMap, useMapEvents } from 'react-leaflet';
import type { LeafletMouseEvent } from 'leaflet';
interface Props {
active: boolean;
onMapClick: (lat: number, lng: number) => void;
}
function AddLocationHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
useMapEvents({
click(e: LeafletMouseEvent) {
onMapClick(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
export default function AddLocationMode({ active, onMapClick }: Props) {
const map = useMap();
useEffect(() => {
if (active) {
map.getContainer().style.cursor = 'crosshair';
map.doubleClickZoom.disable();
} else {
map.getContainer().style.cursor = '';
map.doubleClickZoom.enable();
}
return () => {
map.getContainer().style.cursor = '';
map.doubleClickZoom.enable();
};
}, [active, map]);
if (!active) return null;
return <AddLocationHandler onMapClick={onMapClick} />;
}

View File

@ -0,0 +1,538 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { Spin, Checkbox, Button, Typography, message } from 'antd';
import { EditOutlined, DragOutlined } from '@ant-design/icons';
import { MapContainer, CircleMarker, Popup, Marker, useMap, useMapEvents } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import L from 'leaflet';
import { api } from '@/lib/api';
import type { Location, MapSettings, SupportLevel, Cut } from '@/types/api';
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
import { groupLocations, getMarkerColor } from './mapUtils';
import MapLegend from './MapLegend';
import MapControls from './MapControls';
import AddLocationMode from './AddLocationMode';
import MoveLocationMode from './MoveLocationMode';
import CutOverlays from './CutOverlays';
import CutOverlayControls from './CutOverlayControls';
import DynamicTileLayer from './DynamicTileLayer';
import TileLayerToggle from './TileLayerToggle';
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from './tileLayers';
import 'leaflet/dist/leaflet.css';
const { Text } = Typography;
const ALL_LEVELS: (SupportLevel | 'NONE')[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4', 'NONE'];
const LEVEL_FILTER_LABELS: Record<string, string> = {
LEVEL_1: 'Strong Support',
LEVEL_2: 'Likely Support',
LEVEL_3: 'Unsure',
LEVEL_4: 'Opposition',
NONE: 'No Level',
};
const homeIcon = L.divIcon({
html: '<div style="font-size:22px;text-align:center;line-height:30px">🏠</div>',
iconSize: [30, 30],
iconAnchor: [15, 15],
className: '',
});
interface Props {
locations: Location[];
loading: boolean;
onEditLocation: (location: Location) => void;
onAddLocationAtPoint?: (lat: number, lng: number) => void;
onMoveLocation?: (id: string, lat: number, lng: number) => void;
onRefresh?: () => void;
onMapMove?: (map: LeafletMap) => void;
visible: boolean;
}
function InvalidateSizeOnVisible({ visible }: { visible: boolean }) {
const map = useMap();
useEffect(() => {
if (visible) {
const timer = setTimeout(() => map.invalidateSize(), 100);
return () => clearTimeout(timer);
}
}, [visible, map]);
return null;
}
function FullscreenInvalidator() {
const map = useMap();
useEffect(() => {
const handler = () => {
setTimeout(() => map.invalidateSize(), 100);
};
document.addEventListener('fullscreenchange', handler);
document.addEventListener('webkitfullscreenchange', handler);
return () => {
document.removeEventListener('fullscreenchange', handler);
document.removeEventListener('webkitfullscreenchange', handler);
};
}, [map]);
return null;
}
function MapEventsHandler({ onMove }: { onMove?: (map: LeafletMap) => void }) {
const map = useMapEvents({
moveend: () => {
// Only trigger if not animating to prevent Leaflet state corruption
if (!map._animatingZoom && !map._moving) {
onMove?.(map);
}
},
zoomend: () => {
// Wait a tick for Leaflet to finish internal zoom state updates
setTimeout(() => {
if (!map._animatingZoom && !map._moving) {
onMove?.(map);
}
}, 100);
},
});
return null;
}
function FlyToPosition({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.flyTo(position, 17, { duration: 1.5 });
}, [map, position]);
return null;
}
export default function AdminMapView({
locations,
loading,
onEditLocation,
onAddLocationAtPoint,
onMoveLocation,
onRefresh,
onMapMove,
visible,
}: Props) {
const [settings, setSettings] = useState<MapSettings | null>(null);
const [visibleLevels, setVisibleLevels] = useState<Set<string>>(new Set(ALL_LEVELS));
const [addMode, setAddMode] = useState(false);
const [moveMode, setMoveMode] = useState<{ active: boolean; locationId: string | null }>({ active: false, locationId: null });
const [showStartLocation, setShowStartLocation] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
const [flyTo, setFlyTo] = useState<[number, number] | null>(null);
const [autoRefresh, setAutoRefresh] = useState(false);
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
const containerRef = useRef<HTMLDivElement>(null);
const autoRefreshRef = useRef<ReturnType<typeof setInterval>>(undefined);
// Cuts state
const [cuts, setCuts] = useState<Cut[]>([]);
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
useEffect(() => {
api.get<MapSettings>('/map/settings').then(({ data }) => setSettings(data)).catch(() => {});
}, []);
// Fetch cuts
useEffect(() => {
api.get<{ cuts: Cut[] }>('/map/cuts', { params: { limit: 100 } })
.then(({ data }) => {
setCuts(data.cuts);
setVisibleCutIds(new Set(data.cuts.map((c) => c.id)));
})
.catch(() => {});
}, []);
// Auto-refresh
useEffect(() => {
if (autoRefresh && onRefresh) {
autoRefreshRef.current = setInterval(onRefresh, 60000);
}
return () => clearInterval(autoRefreshRef.current);
}, [autoRefresh, onRefresh]);
// Clear user position after 30s
useEffect(() => {
if (userPosition) {
const timer = setTimeout(() => setUserPosition(null), 30000);
return () => clearTimeout(timer);
}
}, [userPosition]);
// Track fullscreen state
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
document.addEventListener('webkitfullscreenchange', handler);
return () => {
document.removeEventListener('fullscreenchange', handler);
document.removeEventListener('webkitfullscreenchange', handler);
};
}, []);
const groups = useMemo(() => groupLocations(locations), [locations]);
const filteredGroups = useMemo(() => {
return groups.filter((g) =>
g.locations.some((loc) => {
const level = loc.supportLevel || 'NONE';
return visibleLevels.has(level);
})
);
}, [groups, visibleLevels]);
const center: [number, number] = settings?.latitude && settings?.longitude
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
: [45.4215, -75.6972];
const zoom = settings?.zoom ?? 12;
const toggleLevel = (level: string, checked: boolean) => {
setVisibleLevels((prev) => {
const next = new Set(prev);
if (checked) next.add(level);
else next.delete(level);
return next;
});
};
const handleGeolocate = useCallback(() => {
if (!navigator.geolocation) {
message.error('Geolocation not supported');
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const p: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPosition(p);
setFlyTo(p);
// Clear flyTo after use
setTimeout(() => setFlyTo(null), 2000);
},
() => message.error('Could not get your location'),
{ enableHighAccuracy: true, timeout: 10000 }
);
}, []);
const handleFullscreen = useCallback(() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
const requestFs = el.requestFullscreen || (el as any).webkitRequestFullscreen;
if (requestFs) requestFs.call(el);
} else {
const exitFs = document.exitFullscreen || (document as any).webkitExitFullscreen;
if (exitFs) exitFs.call(document);
}
}, []);
const handleMapClick = useCallback(
(lat: number, lng: number) => {
if (onAddLocationAtPoint) {
onAddLocationAtPoint(lat, lng);
setAddMode(false);
}
},
[onAddLocationAtPoint]
);
const startMoveMode = useCallback((locationId: string) => {
setMoveMode({ active: true, locationId });
setAddMode(false);
}, []);
const handleConfirmMove = useCallback(
(lat: number, lng: number) => {
if (onMoveLocation && moveMode.locationId) {
onMoveLocation(moveMode.locationId, lat, lng);
}
setMoveMode({ active: false, locationId: null });
},
[onMoveLocation, moveMode.locationId]
);
const handleCancelMove = useCallback(() => {
setMoveMode({ active: false, locationId: null });
}, []);
const toggleCut = useCallback((id: string) => {
setVisibleCutIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: 400 }}>
<Spin size="large" />
</div>
);
}
return (
<div
ref={containerRef}
id="admin-map-container"
style={{ position: 'relative', width: '100%', height: 'calc(100vh - 340px)', minHeight: 500, background: '#1a1025' }}
>
{/* Support level filter overlay */}
<div
role="group"
aria-label="Support level filter"
style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 1000,
background: 'rgba(26, 16, 37, 0.92)',
borderRadius: 8,
padding: '8px 12px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', marginBottom: 4 }}>
Filter
</div>
{ALL_LEVELS.map((level) => (
<div key={level} style={{ marginBottom: 2 }}>
<Checkbox
checked={visibleLevels.has(level)}
onChange={(e) => toggleLevel(level, e.target.checked)}
style={{ fontSize: 12 }}
>
<span style={{ color: level === 'NONE' ? '#3498db' : SUPPORT_LEVEL_COLORS[level as SupportLevel] }}>
{LEVEL_FILTER_LABELS[level]}
</span>
</Checkbox>
</div>
))}
</div>
{/* Mode indicators */}
{(addMode || moveMode.active) && (
<div
style={{
position: 'absolute',
top: 10,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1001,
background: addMode ? 'rgba(157, 78, 221, 0.9)' : 'rgba(255, 77, 79, 0.9)',
borderRadius: 8,
padding: '6px 16px',
color: '#fff',
fontWeight: 600,
fontSize: 13,
}}
>
{addMode ? 'Click map to add location' : 'Click map to move location'}
</div>
)}
<MapControls
addMode={addMode}
onToggleAddMode={() => {
setAddMode((p) => !p);
if (!addMode) setMoveMode({ active: false, locationId: null });
}}
moveMode={moveMode.active}
onCancelMoveMode={handleCancelMove}
showStartLocation={showStartLocation}
onToggleStartLocation={() => setShowStartLocation((p) => !p)}
isFullscreen={isFullscreen}
onToggleFullscreen={handleFullscreen}
onGeolocate={handleGeolocate}
onRefresh={() => onRefresh?.()}
autoRefresh={autoRefresh}
onToggleAutoRefresh={() => setAutoRefresh((p) => !p)}
/>
<style>{`
.admin-map .leaflet-popup-content-wrapper {
background: #1a1025;
color: rgba(255,255,255,0.85);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
}
.admin-map .leaflet-popup-tip {
background: #1a1025;
}
.admin-map .leaflet-popup-content {
margin: 10px 14px;
}
`}</style>
<MapContainer
center={center}
zoom={zoom}
style={{ width: '100%', height: '100%', borderRadius: 8 }}
scrollWheelZoom={true}
className="admin-map"
>
<InvalidateSizeOnVisible visible={visible} />
<FullscreenInvalidator />
<MapEventsHandler onMove={onMapMove} />
{flyTo && <FlyToPosition position={flyTo} />}
<DynamicTileLayer config={getTileConfig(tileKey)} />
{/* Cut overlays (render beneath location markers) */}
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
{/* Add location mode */}
<AddLocationMode active={addMode && !moveMode.active} onMapClick={handleMapClick} />
{/* Move location mode */}
<MoveLocationMode
active={moveMode.active}
onConfirmMove={handleConfirmMove}
onCancel={handleCancelMove}
/>
{/* Start location marker */}
{showStartLocation && settings?.latitude && settings?.longitude && (
<Marker
position={[parseFloat(settings.latitude), parseFloat(settings.longitude)]}
icon={homeIcon}
/>
)}
{/* User position */}
{userPosition && (
<CircleMarker
center={userPosition}
radius={8}
pathOptions={{
fillColor: '#2196F3',
fillOpacity: 0.9,
color: '#fff',
weight: 2,
opacity: 1,
}}
/>
)}
{/* Location markers */}
{filteredGroups.map((group, idx) => {
const color = getMarkerColor(group.dominantLevel);
const radius = group.isMultiUnit ? 10 : 7;
return (
<CircleMarker
key={idx}
center={[group.latitude, group.longitude]}
radius={radius}
pathOptions={{
fillColor: color,
fillOpacity: 0.8,
color: '#fff',
weight: group.isMultiUnit ? 2 : 1,
opacity: 0.9,
}}
>
<Popup>
<div style={{ minWidth: 200, maxWidth: 280 }}>
{group.locations.map((loc, i) => {
const name = [loc.firstName, loc.lastName].filter(Boolean).join(' ');
return (
<div
key={loc.id}
style={{
marginBottom: i < group.locations.length - 1 ? 10 : 0,
paddingBottom: i < group.locations.length - 1 ? 10 : 0,
borderBottom: i < group.locations.length - 1 ? '1px solid rgba(255,255,255,0.1)' : 'none',
}}
>
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 4 }}>
{loc.address || 'Unknown address'}
{loc.unitNumber && <Text type="secondary" style={{ fontSize: 12 }}> Unit {loc.unitNumber}</Text>}
</div>
{name && <div style={{ fontSize: 12, marginBottom: 2 }}>{name}</div>}
{loc.email && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.email}</div>}
{loc.phone && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.55)' }}>{loc.phone}</div>}
{loc.supportLevel && (
<div style={{ fontSize: 12, marginTop: 4 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(loc.supportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[loc.supportLevel]}
</div>
)}
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)', marginTop: 2 }}>
{loc.sign && <>Sign{loc.signSize ? ` (${loc.signSize})` : ''} &middot; </>}
{loc.geocodeConfidence != null && <>Confidence: {loc.geocodeConfidence}%</>}
</div>
{loc.notes && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 4, fontStyle: 'italic' }}>
{loc.notes}
</div>
)}
<div style={{ marginTop: 4, display: 'flex', gap: 8 }}>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => {
e.stopPropagation();
onEditLocation(loc);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Edit
</Button>
{onMoveLocation && (
<Button
type="link"
size="small"
icon={<DragOutlined />}
onClick={(e) => {
e.stopPropagation();
startMoveMode(loc.id);
}}
style={{ padding: 0, height: 'auto', fontSize: 12 }}
>
Move
</Button>
)}
</div>
</div>
);
})}
</div>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
<MapLegend variant="admin" />
<TileLayerToggle
activeKey={tileKey}
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
position="bottom-right"
/>
{/* Cut overlay controls */}
{cuts.length > 0 && (
<CutOverlayControls
cuts={cuts}
visibleCutIds={visibleCutIds}
onToggleCut={toggleCut}
variant="admin"
/>
)}
</div>
);
}

View File

@ -0,0 +1,145 @@
import { useState, useEffect, useCallback } from 'react';
import { useMap, useMapEvents, CircleMarker, Polyline, Polygon, Tooltip } from 'react-leaflet';
import type { LatLng, LeafletMouseEvent } from 'leaflet';
interface Props {
active: boolean;
color: string;
opacity: number;
onFinish: (geojson: string, bounds: string) => void;
onCancel: () => void;
}
function DrawHandler({ onClickMap }: { onClickMap: (latlng: LatLng) => void }) {
useMapEvents({
click(e: LeafletMouseEvent) {
onClickMap(e.latlng);
},
});
return null;
}
export default function CutDrawingMode({ active, color, opacity, onFinish, onCancel }: Props) {
const map = useMap();
const [vertices, setVertices] = useState<LatLng[]>([]);
useEffect(() => {
if (active) {
map.getContainer().style.cursor = 'crosshair';
map.doubleClickZoom.disable();
setVertices([]);
} else {
map.getContainer().style.cursor = '';
map.doubleClickZoom.enable();
setVertices([]);
}
return () => {
map.getContainer().style.cursor = '';
map.doubleClickZoom.enable();
};
}, [active, map]);
// Keyboard shortcuts
useEffect(() => {
if (!active) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
} else if (e.ctrlKey && e.key === 'z') {
setVertices((prev) => prev.slice(0, -1));
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [active, onCancel]);
const handleClick = useCallback(
(latlng: LatLng) => {
if (vertices.length >= 3) {
// Check if close to first vertex
const firstPx = map.latLngToContainerPoint(vertices[0]!);
const clickPx = map.latLngToContainerPoint(latlng);
const dist = Math.sqrt(
Math.pow(firstPx.x - clickPx.x, 2) + Math.pow(firstPx.y - clickPx.y, 2)
);
if (dist < 15) {
// Close the polygon
const coords = vertices.map((v) => [v.lng, v.lat]);
// Close ring
coords.push(coords[0]!);
const geojson = JSON.stringify({
type: 'Polygon',
coordinates: [coords],
});
const lats = vertices.map((v) => v.lat);
const lngs = vertices.map((v) => v.lng);
const bounds = JSON.stringify({
minLat: Math.min(...lats),
maxLat: Math.max(...lats),
minLng: Math.min(...lngs),
maxLng: Math.max(...lngs),
});
onFinish(geojson, bounds);
return;
}
}
setVertices((prev) => [...prev, latlng]);
},
[vertices, map, onFinish]
);
if (!active) return null;
const positions = vertices.map((v) => [v.lat, v.lng] as [number, number]);
return (
<>
<DrawHandler onClickMap={handleClick} />
{/* Polygon preview (3+ vertices) */}
{vertices.length >= 3 && (
<Polygon
positions={positions}
pathOptions={{
color,
fillColor: color,
fillOpacity: opacity,
weight: 2,
dashArray: '6 4',
}}
/>
)}
{/* Line between vertices (2+ vertices) */}
{vertices.length >= 2 && vertices.length < 3 && (
<Polyline
positions={positions}
pathOptions={{ color, weight: 2, dashArray: '6 4' }}
/>
)}
{/* Vertex markers */}
{vertices.map((v, i) => (
<CircleMarker
key={i}
center={[v.lat, v.lng]}
radius={i === 0 ? 8 : 5}
pathOptions={{
fillColor: i === 0 ? '#fff' : color,
fillOpacity: 1,
color: i === 0 ? color : '#fff',
weight: 2,
}}
>
{i === 0 && vertices.length >= 3 && (
<Tooltip permanent direction="top" offset={[0, -10]}>
Click to close
</Tooltip>
)}
</CircleMarker>
))}
</>
);
}

View File

@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import { Button, Space, Typography } from 'antd';
import { MapContainer, useMap } from 'react-leaflet';
import { api } from '@/lib/api';
import type { Cut, MapSettings } from '@/types/api';
import CutDrawingMode from './CutDrawingMode';
import CutOverlays from './CutOverlays';
import DynamicTileLayer from './DynamicTileLayer';
import TileLayerToggle from './TileLayerToggle';
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from './tileLayers';
import 'leaflet/dist/leaflet.css';
interface Props {
cuts: Cut[];
onFinishDraw: (geojson: string, bounds: string) => void;
}
function InvalidateOnMount() {
const map = useMap();
useEffect(() => {
const timer = setTimeout(() => map.invalidateSize(), 100);
return () => clearTimeout(timer);
}, [map]);
return null;
}
export default function CutEditorMap({ cuts, onFinishDraw }: Props) {
const [settings, setSettings] = useState<MapSettings | null>(null);
const [drawing, setDrawing] = useState(false);
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
useEffect(() => {
api.get<MapSettings>('/map/settings').then(({ data }) => setSettings(data)).catch(() => {});
}, []);
const center: [number, number] = settings?.latitude && settings?.longitude
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
: [45.4215, -75.6972];
const zoom = settings?.zoom ?? 12;
const allCutIds = new Set(cuts.map((c) => c.id));
return (
<div style={{ position: 'relative', width: '100%', height: 'calc(100vh - 200px)', minHeight: 400 }}>
{/* Drawing toolbar */}
<div
style={{
position: 'absolute',
top: 10,
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1001,
background: 'rgba(26, 16, 37, 0.95)',
borderRadius: 8,
padding: '8px 16px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
{!drawing ? (
<Button type="primary" onClick={() => setDrawing(true)}>
Draw Cut
</Button>
) : (
<Space>
<Typography.Text style={{ color: 'rgba(255,255,255,0.85)', fontSize: 12 }}>
Click vertices on map. Click first vertex to close.
</Typography.Text>
<Button
size="small"
onClick={() => setDrawing(false)}
>
Cancel
</Button>
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
Esc: cancel | Ctrl+Z: undo
</Typography.Text>
</Space>
)}
</div>
<style>{`
.cut-editor-map .leaflet-popup-content-wrapper {
background: #1a1025;
color: rgba(255,255,255,0.85);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 8px;
}
.cut-editor-map .leaflet-popup-tip {
background: #1a1025;
}
`}</style>
<MapContainer
center={center}
zoom={zoom}
style={{ width: '100%', height: '100%', borderRadius: 8 }}
scrollWheelZoom={true}
className="cut-editor-map"
>
<InvalidateOnMount />
<DynamicTileLayer config={getTileConfig(tileKey)} />
<CutOverlays cuts={cuts} visibleCutIds={allCutIds} />
<CutDrawingMode
active={drawing}
color="#3388ff"
opacity={0.3}
onFinish={(geojson, bounds) => {
setDrawing(false);
onFinishDraw(geojson, bounds);
}}
onCancel={() => setDrawing(false)}
/>
</MapContainer>
<TileLayerToggle
activeKey={tileKey}
onChange={(key) => { setTileKey(key); persistTileLayer(key); }}
position="bottom-right"
/>
</div>
);
}

View File

@ -0,0 +1,89 @@
import { Checkbox, Button, Space } from 'antd';
import type { Cut, PublicCut } from '@/types/api';
const VARIANT_BG = {
public: 'rgba(27, 40, 56, 0.92)',
admin: 'rgba(26, 16, 37, 0.92)',
};
interface Props {
cuts: (Cut | PublicCut)[];
visibleCutIds: Set<string>;
onToggleCut: (id: string) => void;
variant?: 'admin' | 'public';
style?: React.CSSProperties;
}
export default function CutOverlayControls({ cuts, visibleCutIds, onToggleCut, variant = 'admin', style }: Props) {
const allVisible = cuts.every((c) => visibleCutIds.has(c.id));
const noneVisible = cuts.every((c) => !visibleCutIds.has(c.id));
return (
<div
style={{
position: 'absolute',
bottom: 24,
left: 12,
zIndex: 1000,
background: VARIANT_BG[variant],
borderRadius: 8,
padding: '10px 14px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
minWidth: 140,
maxHeight: 280,
overflowY: 'auto',
...style,
}}
>
<div style={{ fontSize: 12, fontWeight: 600, color: '#fff', marginBottom: 6, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>Cuts</span>
<Space size={4}>
<Button
type="link"
size="small"
disabled={allVisible}
onClick={() => cuts.forEach((c) => { if (!visibleCutIds.has(c.id)) onToggleCut(c.id); })}
style={{ padding: 0, height: 'auto', fontSize: 10 }}
>
All
</Button>
<Button
type="link"
size="small"
disabled={noneVisible}
onClick={() => cuts.forEach((c) => { if (visibleCutIds.has(c.id)) onToggleCut(c.id); })}
style={{ padding: 0, height: 'auto', fontSize: 10 }}
>
None
</Button>
</Space>
</div>
{cuts.map((cut) => (
<div key={cut.id} style={{ marginBottom: 3 }}>
<Checkbox
checked={visibleCutIds.has(cut.id)}
onChange={() => onToggleCut(cut.id)}
style={{ fontSize: 12 }}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: 2,
backgroundColor: cut.color,
opacity: parseFloat(String(cut.opacity)) || 0.3,
border: `1px solid ${cut.color}`,
flexShrink: 0,
}}
/>
<span style={{ color: 'rgba(255,255,255,0.85)' }}>{cut.name}</span>
</span>
</Checkbox>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useMemo } from 'react';
import { GeoJSON, Tooltip } from 'react-leaflet';
import type { Cut, PublicCut } from '@/types/api';
interface Props {
cuts: (Cut | PublicCut)[];
visibleCutIds: Set<string>;
}
function CutLayer({ cut }: { cut: Cut | PublicCut }) {
const geojsonData = useMemo(() => {
try {
return JSON.parse(cut.geojson) as GeoJSON.GeoJsonObject;
} catch {
return null;
}
}, [cut.geojson]);
const opacity = parseFloat(String(cut.opacity));
if (!geojsonData) return null;
return (
<GeoJSON
key={cut.id}
data={geojsonData}
style={{
color: cut.color,
fillColor: cut.color,
fillOpacity: isNaN(opacity) ? 0.3 : opacity,
weight: 2,
opacity: 0.8,
}}
>
<Tooltip sticky>{cut.name}</Tooltip>
</GeoJSON>
);
}
export default function CutOverlays({ cuts, visibleCutIds }: Props) {
const visibleCuts = useMemo(
() => cuts.filter((c) => visibleCutIds.has(c.id)),
[cuts, visibleCutIds]
);
return (
<>
{visibleCuts.map((cut) => (
<CutLayer key={cut.id} cut={cut} />
))}
</>
);
}

View File

@ -0,0 +1,17 @@
import { TileLayer } from 'react-leaflet';
import type { TileLayerConfig } from './tileLayers';
interface Props {
config: TileLayerConfig;
}
export default function DynamicTileLayer({ config }: Props) {
return (
<TileLayer
key={config.key}
url={config.url}
attribution={config.attribution}
maxZoom={config.maxZoom}
/>
);
}

View File

@ -0,0 +1,117 @@
import { Button, Space, Tooltip } from 'antd';
import {
PlusOutlined,
AimOutlined,
FullscreenOutlined,
FullscreenExitOutlined,
HomeOutlined,
ReloadOutlined,
DragOutlined,
} from '@ant-design/icons';
interface Props {
addMode: boolean;
onToggleAddMode: () => void;
moveMode: boolean;
onCancelMoveMode: () => void;
showStartLocation: boolean;
onToggleStartLocation: () => void;
isFullscreen: boolean;
onToggleFullscreen: () => void;
onGeolocate: () => void;
onRefresh: () => void;
autoRefresh: boolean;
onToggleAutoRefresh: () => void;
}
export default function MapControls({
addMode,
onToggleAddMode,
moveMode,
onCancelMoveMode,
showStartLocation,
onToggleStartLocation,
isFullscreen,
onToggleFullscreen,
onGeolocate,
onRefresh,
autoRefresh,
onToggleAutoRefresh,
}: Props) {
return (
<div
style={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 1000,
background: 'rgba(26, 16, 37, 0.92)',
borderRadius: 8,
padding: 6,
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
}}
>
<Space direction="vertical" size={4}>
<Tooltip title={addMode ? 'Cancel Add' : 'Add Location'} placement="left">
<Button
type={addMode ? 'primary' : 'text'}
icon={<PlusOutlined />}
size="small"
onClick={onToggleAddMode}
style={{ width: 32, height: 32 }}
/>
</Tooltip>
{moveMode && (
<Tooltip title="Cancel Move" placement="left">
<Button
type="primary"
danger
icon={<DragOutlined />}
size="small"
onClick={onCancelMoveMode}
style={{ width: 32, height: 32 }}
/>
</Tooltip>
)}
<Tooltip title="My Location" placement="left">
<Button
type="text"
icon={<AimOutlined />}
size="small"
onClick={onGeolocate}
style={{ width: 32, height: 32 }}
/>
</Tooltip>
<Tooltip title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} placement="left">
<Button
type="text"
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
size="small"
onClick={onToggleFullscreen}
style={{ width: 32, height: 32 }}
/>
</Tooltip>
<Tooltip title={showStartLocation ? 'Hide Start' : 'Show Start'} placement="left">
<Button
type={showStartLocation ? 'primary' : 'text'}
icon={<HomeOutlined />}
size="small"
onClick={onToggleStartLocation}
style={{ width: 32, height: 32 }}
/>
</Tooltip>
<Tooltip title={autoRefresh ? 'Auto-refresh ON' : 'Refresh'} placement="left">
<Button
type={autoRefresh ? 'primary' : 'text'}
icon={<ReloadOutlined />}
size="small"
onClick={onRefresh}
onDoubleClick={onToggleAutoRefresh}
style={{ width: 32, height: 32 }}
/>
</Tooltip>
</Space>
</div>
);
}

View File

@ -0,0 +1,72 @@
import { SUPPORT_LEVEL_LABELS, SUPPORT_LEVEL_COLORS } from '@/types/api';
import type { SupportLevel } from '@/types/api';
import { NO_LEVEL_COLOR } from './mapUtils';
const entries: { level: SupportLevel; label: string; color: string }[] = [
{ level: 'LEVEL_1', label: SUPPORT_LEVEL_LABELS.LEVEL_1, color: SUPPORT_LEVEL_COLORS.LEVEL_1 },
{ level: 'LEVEL_2', label: SUPPORT_LEVEL_LABELS.LEVEL_2, color: SUPPORT_LEVEL_COLORS.LEVEL_2 },
{ level: 'LEVEL_3', label: SUPPORT_LEVEL_LABELS.LEVEL_3, color: SUPPORT_LEVEL_COLORS.LEVEL_3 },
{ level: 'LEVEL_4', label: SUPPORT_LEVEL_LABELS.LEVEL_4, color: SUPPORT_LEVEL_COLORS.LEVEL_4 },
];
const VARIANT_BG = {
public: 'rgba(27, 40, 56, 0.92)',
admin: 'rgba(26, 16, 37, 0.92)',
};
interface Props {
variant?: 'admin' | 'public';
}
export default function MapLegend({ variant = 'public' }: Props) {
return (
<div
style={{
position: 'absolute',
bottom: 24,
right: 12,
zIndex: 1000,
background: VARIANT_BG[variant],
borderRadius: 8,
padding: '10px 14px',
backdropFilter: 'blur(8px)',
border: '1px solid rgba(255,255,255,0.12)',
minWidth: 140,
}}
>
<div style={{ fontSize: 12, fontWeight: 600, color: '#fff', marginBottom: 6 }}>
Support Level
</div>
{entries.map((e) => (
<div key={e.level} style={{ display: 'flex', alignItems: 'center', marginBottom: 3 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: e.color,
marginRight: 8,
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.85)' }}>{e.label}</span>
</div>
))}
<div style={{ display: 'flex', alignItems: 'center', marginTop: 2 }}>
<span
style={{
display: 'inline-block',
width: 12,
height: 12,
borderRadius: '50%',
backgroundColor: NO_LEVEL_COLOR,
marginRight: 8,
flexShrink: 0,
}}
/>
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.85)' }}>No Level</span>
</div>
</div>
);
}

View File

@ -0,0 +1,76 @@
import { useState, useEffect } from 'react';
import { useMap, useMapEvents, CircleMarker, Popup } from 'react-leaflet';
import { Button, Space } from 'antd';
import type { LeafletMouseEvent } from 'leaflet';
interface Props {
active: boolean;
onConfirmMove: (lat: number, lng: number) => void;
onCancel: () => void;
}
function MoveHandler({ onClickMap }: { onClickMap: (lat: number, lng: number) => void }) {
useMapEvents({
click(e: LeafletMouseEvent) {
onClickMap(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
export default function MoveLocationMode({ active, onConfirmMove, onCancel }: Props) {
const map = useMap();
const [target, setTarget] = useState<[number, number] | null>(null);
useEffect(() => {
if (active) {
map.getContainer().style.cursor = 'crosshair';
map.doubleClickZoom.disable();
setTarget(null);
} else {
map.getContainer().style.cursor = '';
map.doubleClickZoom.enable();
setTarget(null);
}
return () => {
map.getContainer().style.cursor = '';
map.doubleClickZoom.enable();
};
}, [active, map]);
if (!active) return null;
return (
<>
{!target && <MoveHandler onClickMap={(lat, lng) => setTarget([lat, lng])} />}
{target && (
<CircleMarker
center={target}
radius={10}
pathOptions={{ fillColor: '#9d4edd', fillOpacity: 0.9, color: '#fff', weight: 2 }}
>
<Popup autoClose={false} closeOnClick={false}>
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Confirm move?</div>
<div style={{ fontSize: 11, marginBottom: 8, color: 'rgba(255,255,255,0.65)' }}>
{target[0].toFixed(5)}, {target[1].toFixed(5)}
</div>
<Space>
<Button
type="primary"
size="small"
onClick={() => onConfirmMove(target[0], target[1])}
>
Confirm
</Button>
<Button size="small" onClick={onCancel}>
Cancel
</Button>
</Space>
</div>
</Popup>
</CircleMarker>
)}
</>
);
}

View File

@ -0,0 +1,34 @@
interface Props {
bearing?: number;
style?: React.CSSProperties;
}
export default function NorthCompass({ bearing = 0, style }: Props) {
return (
<div
style={{
position: 'absolute',
top: 190,
right: 14,
zIndex: 1000,
...style,
}}
>
<svg
width={36}
height={36}
viewBox="0 0 36 36"
style={{ transform: `rotate(${bearing}deg)`, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,0.5))' }}
>
{/* Background circle */}
<circle cx={18} cy={18} r={17} fill="rgba(0,0,0,0.7)" stroke="rgba(255,255,255,0.3)" strokeWidth={1} />
{/* North arrow (red) */}
<polygon points="18,4 14,18 18,15 22,18" fill="#e74c3c" />
{/* South arrow (white) */}
<polygon points="18,32 14,18 18,21 22,18" fill="rgba(255,255,255,0.7)" />
{/* N label */}
<text x={18} y={9} textAnchor="middle" fill="#fff" fontSize={7} fontWeight={700} fontFamily="Arial, sans-serif">N</text>
</svg>
</div>
);
}

View File

@ -0,0 +1,58 @@
import { GlobalOutlined, EyeOutlined } from '@ant-design/icons';
import { TILE_LAYERS } from './tileLayers';
interface Props {
activeKey: string;
onChange: (key: string) => void;
position?: 'bottom-left' | 'bottom-right';
}
const icons: Record<string, React.ReactNode> = {
normal: <GlobalOutlined />,
dark: <EyeOutlined />,
satellite: <GlobalOutlined style={{ transform: 'rotate(45deg)' }} />,
};
export default function TileLayerToggle({ activeKey, onChange, position = 'bottom-right' }: Props) {
const posStyle = position === 'bottom-left'
? { left: 10, bottom: 80 }
: { right: 10, bottom: 80 };
return (
<div
style={{
position: 'absolute',
...posStyle,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
{TILE_LAYERS.map((layer) => (
<button
key={layer.key}
onClick={() => onChange(layer.key)}
title={layer.label}
style={{
width: 36,
height: 36,
borderRadius: 8,
border: activeKey === layer.key ? '2px solid #1890ff' : '1px solid rgba(255,255,255,0.3)',
background: activeKey === layer.key ? 'rgba(24, 144, 255, 0.25)' : 'rgba(0,0,0,0.6)',
color: activeKey === layer.key ? '#1890ff' : 'rgba(255,255,255,0.8)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
backdropFilter: 'blur(4px)',
transition: 'all 0.2s',
}}
>
{icons[layer.key] ?? <GlobalOutlined />}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,57 @@
import type { Location, SupportLevel } from '@/types/api';
import { SUPPORT_LEVEL_COLORS } from '@/types/api';
export const NO_LEVEL_COLOR = '#3498db';
export function getMarkerColor(level: SupportLevel | null): string {
if (!level) return NO_LEVEL_COLOR;
return SUPPORT_LEVEL_COLORS[level] ?? NO_LEVEL_COLOR;
}
export interface LocationGroup {
latitude: number;
longitude: number;
locations: Location[];
isMultiUnit: boolean;
dominantLevel: SupportLevel | null;
}
export function groupLocations(locations: Location[]): LocationGroup[] {
const groups = new Map<string, Location[]>();
for (const loc of locations) {
if (loc.latitude == null || loc.longitude == null) continue;
const key = `${parseFloat(loc.latitude).toFixed(6)},${parseFloat(loc.longitude).toFixed(6)}`;
const existing = groups.get(key);
if (existing) {
existing.push(loc);
} else {
groups.set(key, [loc]);
}
}
return Array.from(groups.entries()).map(([key, locs]) => {
const [lat, lng] = key.split(',');
const levelCounts: Record<string, number> = {};
for (const loc of locs) {
const level = loc.supportLevel || 'NONE';
levelCounts[level] = (levelCounts[level] || 0) + 1;
}
let dominant: SupportLevel | null = null;
let maxCount = 0;
for (const [level, count] of Object.entries(levelCounts)) {
if (count > maxCount) {
maxCount = count;
dominant = level === 'NONE' ? null : (level as SupportLevel);
}
}
return {
latitude: parseFloat(lat!),
longitude: parseFloat(lng!),
locations: locs,
isMultiUnit: locs.length > 1,
dominantLevel: dominant,
};
});
}

View File

@ -0,0 +1,53 @@
export interface TileLayerConfig {
key: string;
label: string;
url: string;
attribution: string;
maxZoom?: number;
}
export const TILE_LAYERS: TileLayerConfig[] = [
{
key: 'normal',
label: 'Normal',
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19,
},
{
key: 'dark',
label: 'Dark',
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 20,
},
{
key: 'satellite',
label: 'Satellite',
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '&copy; Esri, Maxar, Earthstar Geographics',
maxZoom: 18,
},
];
const STORAGE_KEY = 'cmlite:tilePref';
export function getPersistedTileLayer(): string {
try {
return localStorage.getItem(STORAGE_KEY) || 'dark';
} catch {
return 'dark';
}
}
export function persistTileLayer(key: string): void {
try {
localStorage.setItem(STORAGE_KEY, key);
} catch {
// localStorage not available
}
}
export function getTileConfig(key: string): TileLayerConfig {
return TILE_LAYERS.find((t) => t.key === key) || TILE_LAYERS[1]!; // default dark
}

View File

@ -0,0 +1,46 @@
import { useState, useCallback } from 'react';
import { Modal, message } from 'antd';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { MkDocsBuildResult } from '@/types/api';
/**
* Hook for triggering MkDocs static site builds with a confirmation modal.
* Returns { building, confirmAndBuild } for use in any admin page.
*/
export function useMkDocsBuild() {
const [building, setBuilding] = useState(false);
const { user } = useAuthStore();
const isSuperAdmin = user?.role === 'SUPER_ADMIN';
const confirmAndBuild = useCallback(() => {
if (!isSuperAdmin) {
message.warning('Only super admins can trigger builds');
return;
}
Modal.confirm({
title: 'Build Static Site',
content: 'This will rebuild the MkDocs static site. The dev server is unaffected. Continue?',
okText: 'Build',
cancelText: 'Cancel',
onOk: async () => {
setBuilding(true);
try {
const res = await api.post<MkDocsBuildResult>('/docs/build');
if (res.data.success) {
message.success(`Build succeeded in ${(res.data.duration / 1000).toFixed(1)}s`);
} else {
message.error('Build failed — check Docs Settings > Build for details');
}
} catch {
message.error('Build request failed');
} finally {
setBuilding(false);
}
},
});
}, [isSuperAdmin]);
return { building, confirmAndBuild, isSuperAdmin };
}

78
admin/src/lib/api.ts Normal file
View File

@ -0,0 +1,78 @@
import axios from 'axios';
import type { AuthResponse } from '@/types/api';
export const api = axios.create({
baseURL: '/api',
});
// Token accessor — set by auth store on init to break circular dependency
let getTokens: () => { accessToken: string | null; refreshToken: string | null } =
() => ({ accessToken: null, refreshToken: null });
let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {};
let onAuthFailure: () => void = () => {};
export function registerAuthCallbacks(callbacks: {
getTokens: typeof getTokens;
onTokenRefresh: typeof onTokenRefresh;
onAuthFailure: typeof onAuthFailure;
}) {
getTokens = callbacks.getTokens;
onTokenRefresh = callbacks.onTokenRefresh;
onAuthFailure = callbacks.onAuthFailure;
}
// Request interceptor: attach access token
api.interceptors.request.use((config) => {
const { accessToken } = getTokens();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor: handle 401 with token refresh
let refreshPromise: Promise<AuthResponse> | null = null;
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
const errorCode = error.response?.data?.error?.code;
if (
error.response?.status === 401 &&
(errorCode === 'INVALID_TOKEN' || errorCode === 'AUTH_REQUIRED') &&
!originalRequest._retry
) {
originalRequest._retry = true;
const { refreshToken } = getTokens();
if (!refreshToken) {
onAuthFailure();
return Promise.reject(error);
}
try {
if (!refreshPromise) {
refreshPromise = api
.post<AuthResponse>('/auth/refresh', { refreshToken })
.then((res) => res.data)
.finally(() => {
refreshPromise = null;
});
}
const data = await refreshPromise;
onTokenRefresh(data.accessToken, data.refreshToken);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest);
} catch {
onAuthFailure();
return Promise.reject(error);
}
}
return Promise.reject(error);
}
);

View File

@ -0,0 +1,20 @@
/**
* Build an iframe-compatible URL for a platform service.
*
* All URLs route through nginx which strips X-Frame-Options headers:
* - Real domain (app.cmlite.org) //db.cmlite.org (subdomain via nginx port 80)
* - Localhost dev //localhost:EMBED_PORT (dedicated nginx proxy ports)
*/
export function buildServiceUrl(
subdomain: string,
domain: string,
embedPort: number,
): string {
const hostname = window.location.hostname;
// Real domain (contains a dot) — use subdomain via nginx
if (hostname.includes('.')) {
return `//${subdomain}.${domain}`;
}
// Localhost dev — use dedicated nginx embed proxy port
return `//${hostname}:${embedPort}`;
}

10
admin/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import '@ant-design/v5-patch-for-react-19';
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@ -0,0 +1,180 @@
import { useState, useEffect, useCallback } from 'react';
import { Drawer, Table, Tag, Select, Statistic, Row, Col, Card, message } from 'antd';
import type { TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type {
Campaign,
CampaignEmail,
CampaignEmailStatus,
CampaignEmailsListResponse,
CampaignEmailStats,
EmailMethod,
} from '@/types/api';
interface Props {
campaign: Campaign | null;
open: boolean;
onClose: () => void;
}
const statusColors: Record<CampaignEmailStatus, string> = {
QUEUED: 'processing',
SENT: 'success',
FAILED: 'error',
CLICKED: 'blue',
USER_INFO_CAPTURED: 'purple',
};
const statusOptions: { value: CampaignEmailStatus; label: string }[] = [
{ value: 'QUEUED', label: 'Queued' },
{ value: 'SENT', label: 'Sent' },
{ value: 'FAILED', label: 'Failed' },
{ value: 'CLICKED', label: 'Clicked (Mailto)' },
{ value: 'USER_INFO_CAPTURED', label: 'User Info Captured' },
];
export default function CampaignEmailsDrawer({ campaign, open, onClose }: Props) {
const [emails, setEmails] = useState<CampaignEmail[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [statusFilter, setStatusFilter] = useState<CampaignEmailStatus | undefined>();
const [stats, setStats] = useState<CampaignEmailStats | null>(null);
const fetchEmails = useCallback(async (page = 1) => {
if (!campaign) return;
setLoading(true);
try {
const { data } = await api.get<CampaignEmailsListResponse>(`/campaigns/${campaign.id}/emails`, {
params: { page, limit: pagination.limit, status: statusFilter },
});
setEmails(data.emails);
setPagination(data.pagination);
} catch {
message.error('Failed to load emails');
} finally {
setLoading(false);
}
}, [campaign, pagination.limit, statusFilter]);
const fetchStats = useCallback(async () => {
if (!campaign) return;
try {
const { data } = await api.get<CampaignEmailStats>(`/campaigns/${campaign.id}/email-stats`);
setStats(data);
} catch {
// Silently fail — stats are informational
}
}, [campaign]);
useEffect(() => {
if (open && campaign) {
fetchEmails(1);
fetchStats();
}
}, [open, campaign, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchEmails(pag.current ?? 1);
};
const columns = [
{
title: 'Recipient',
dataIndex: 'recipientEmail',
key: 'recipientEmail',
ellipsis: true,
render: (email: string, record: CampaignEmail) =>
record.recipientName ? `${record.recipientName} <${email}>` : email,
},
{
title: 'Sender',
dataIndex: 'userName',
key: 'userName',
ellipsis: true,
render: (name: string | null, record: CampaignEmail) => name || record.userEmail || '--',
},
{
title: 'Method',
dataIndex: 'emailMethod',
key: 'emailMethod',
render: (method: EmailMethod) => (
<Tag color={method === 'SMTP' ? 'geekblue' : 'orange'}>{method}</Tag>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: CampaignEmailStatus) => (
<Tag color={statusColors[status]}>{status}</Tag>
),
},
{
title: 'Sent',
dataIndex: 'sentAt',
key: 'sentAt',
render: (date: string) => dayjs(date).format('MM-DD HH:mm'),
},
];
return (
<Drawer
title={`Emails — ${campaign?.title || ''}`}
open={open}
onClose={onClose}
width={720}
destroyOnClose
>
{stats && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col span={6}>
<Card size="small">
<Statistic title="Sent" value={stats.sent} valueStyle={{ color: '#52c41a' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="Queued" value={stats.queued} valueStyle={{ color: '#1890ff' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="Failed" value={stats.failed} valueStyle={{ color: '#ff4d4f' }} />
</Card>
</Col>
<Col span={6}>
<Card size="small">
<Statistic title="Mailto" value={stats.clicked} valueStyle={{ color: '#fa8c16' }} />
</Card>
</Col>
</Row>
)}
<Select
placeholder="Filter by status"
options={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: 200, marginBottom: 12 }}
/>
<Table<CampaignEmail>
columns={columns}
dataSource={emails}
rowKey="id"
loading={loading}
size="small"
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showTotal: (total) => `${total} emails`,
size: 'small',
}}
onChange={handleTableChange}
/>
</Drawer>
);
}

View File

@ -0,0 +1,506 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Form,
Switch,
Popconfirm,
message,
Row,
Col,
Divider,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
StarFilled,
MailOutlined,
LinkOutlined,
EyeOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
Campaign,
CampaignStatus,
GovernmentLevel,
CampaignsListResponse,
CampaignsListParams,
CreateCampaignPayload,
UpdateCampaignPayload,
} from '@/types/api';
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
const { TextArea } = Input;
const statusColors: Record<CampaignStatus, string> = {
DRAFT: 'default',
ACTIVE: 'green',
PAUSED: 'orange',
ARCHIVED: 'gray',
};
const statusOptions: { value: CampaignStatus; label: string }[] = [
{ value: 'DRAFT', label: 'Draft' },
{ value: 'ACTIVE', label: 'Active' },
{ value: 'PAUSED', label: 'Paused' },
{ value: 'ARCHIVED', label: 'Archived' },
];
const govLevelOptions: { value: GovernmentLevel; label: string }[] = [
{ value: 'FEDERAL', label: 'Federal' },
{ value: 'PROVINCIAL', label: 'Provincial' },
{ value: 'MUNICIPAL', label: 'Municipal' },
{ value: 'SCHOOL_BOARD', label: 'School Board' },
];
const govLevelColors: Record<GovernmentLevel, string> = {
FEDERAL: 'blue',
PROVINCIAL: 'purple',
MUNICIPAL: 'cyan',
SCHOOL_BOARD: 'magenta',
};
export default function CampaignsPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [statusFilter, setStatusFilter] = useState<CampaignStatus | undefined>();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);
const [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);
const [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
const fetchCampaigns = useCallback(async (params?: CampaignsListParams) => {
setLoading(true);
try {
const { data } = await api.get<CampaignsListResponse>('/campaigns', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
status: params?.status ?? statusFilter,
},
});
setCampaigns(data.campaigns);
setPagination(data.pagination);
} catch {
message.error('Failed to load campaigns');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
useEffect(() => {
fetchCampaigns({ page: 1 });
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchCampaigns({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleCreate = async (values: CreateCampaignPayload) => {
try {
await api.post('/campaigns', values);
message.success('Campaign created');
setCreateModalOpen(false);
createForm.resetFields();
fetchCampaigns({ page: 1 });
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create campaign';
message.error(msg);
}
};
const handleEdit = async (values: UpdateCampaignPayload) => {
if (!editingCampaign) return;
try {
await api.put(`/campaigns/${editingCampaign.id}`, values);
message.success('Campaign updated');
setEditModalOpen(false);
setEditingCampaign(null);
editForm.resetFields();
fetchCampaigns();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to update campaign';
message.error(msg);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/campaigns/${id}`);
message.success('Campaign deleted');
fetchCampaigns();
} catch {
message.error('Failed to delete campaign');
}
};
const openEdit = (campaign: Campaign) => {
setEditingCampaign(campaign);
editForm.setFieldsValue({
title: campaign.title,
description: campaign.description,
emailSubject: campaign.emailSubject,
emailBody: campaign.emailBody,
callToAction: campaign.callToAction,
status: campaign.status,
targetGovernmentLevels: campaign.targetGovernmentLevels,
allowSmtpEmail: campaign.allowSmtpEmail,
allowMailtoLink: campaign.allowMailtoLink,
collectUserInfo: campaign.collectUserInfo,
showEmailCount: campaign.showEmailCount,
showCallCount: campaign.showCallCount,
allowEmailEditing: campaign.allowEmailEditing,
allowCustomRecipients: campaign.allowCustomRecipients,
showResponseWall: campaign.showResponseWall,
highlightCampaign: campaign.highlightCampaign,
coverPhoto: campaign.coverPhoto,
});
setEditModalOpen(true);
};
const columns: ColumnsType<Campaign> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record: Campaign) => (
<div>
<Space>
<span style={{ fontWeight: 500 }}>{title}</span>
{record.highlightCampaign && <StarFilled style={{ color: '#faad14' }} />}
</Space>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/campaign/{record.slug}</div>
</div>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: CampaignStatus) => (
<Tag color={statusColors[status]}>{status}</Tag>
),
},
{
title: 'Gov. Levels',
dataIndex: 'targetGovernmentLevels',
key: 'targetGovernmentLevels',
render: (levels: GovernmentLevel[]) =>
levels.length > 0
? levels.map((l) => (
<Tag key={l} color={govLevelColors[l]} style={{ fontSize: 11 }}>
{l.replace('_', ' ')}
</Tag>
))
: '--',
responsive: ['md'],
},
{
title: 'Emails',
key: 'emails',
render: (_: unknown, record: Campaign) => record._count.emails,
responsive: ['md'],
},
{
title: 'Responses',
key: 'responses',
render: (_: unknown, record: Campaign) => record._count.responses,
responsive: ['lg'],
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: Campaign) => (
<Space>
{record.status === 'ACTIVE' && (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(`/campaign/${record.slug}`, '_blank')}
title="View public page"
/>
)}
<Button
type="link"
size="small"
icon={<LinkOutlined />}
onClick={() => {
const url = `${window.location.origin}/campaign/${record.slug}`;
navigator.clipboard.writeText(url);
message.success('Campaign link copied');
}}
title="Copy public link"
/>
<Button
type="link"
size="small"
icon={<MailOutlined />}
onClick={() => {
setEmailsCampaign(record);
setEmailsDrawerOpen(true);
}}
title="View emails"
/>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
title="Edit campaign"
/>
<Popconfirm
title="Delete this campaign?"
description="All associated emails and responses will also be deleted."
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
</Popconfirm>
</Space>
),
},
];
const campaignFormFields = (
<>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={2} />
</Form.Item>
<Form.Item
name="emailSubject"
label="Email Subject"
rules={[{ required: true, message: 'Email subject is required' }]}
>
<Input />
</Form.Item>
<Form.Item
name="emailBody"
label="Email Body"
rules={[{ required: true, message: 'Email body is required' }]}
>
<TextArea rows={4} />
</Form.Item>
<Form.Item name="callToAction" label="Call to Action">
<TextArea rows={2} />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="status" label="Status" initialValue="DRAFT">
<Select options={statusOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="targetGovernmentLevels" label="Government Levels" initialValue={[]}>
<Select mode="multiple" options={govLevelOptions} />
</Form.Item>
</Col>
</Row>
<Form.Item name="coverPhoto" label="Cover Photo URL">
<Input placeholder="https://..." />
</Form.Item>
<Divider orientation="left" plain>
Feature Flags
</Divider>
<Row gutter={[16, 8]}>
<Col xs={24} sm={12}>
<Form.Item name="allowSmtpEmail" label="Allow SMTP Email" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="allowMailtoLink" label="Allow Mailto Link" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="collectUserInfo" label="Collect User Info" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="showEmailCount" label="Show Email Count" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="showCallCount" label="Show Call Count" valuePropName="checked" initialValue={true}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="allowEmailEditing" label="Allow Email Editing" valuePropName="checked" initialValue={false}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="allowCustomRecipients" label="Allow Custom Recipients" valuePropName="checked" initialValue={false}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="showResponseWall" label="Show Response Wall" valuePropName="checked" initialValue={false}>
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="highlightCampaign" label="Highlight Campaign" valuePropName="checked" initialValue={false}>
<Switch />
</Form.Item>
</Col>
</Row>
</>
);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create Campaign
</Button>
), []);
useEffect(() => {
setPageHeader({ title: 'Campaigns', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
return (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<Campaign>
columns={columns}
dataSource={campaigns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} campaigns`,
}}
onChange={handleTableChange}
/>
{/* Create Modal */}
<Modal
title="Create Campaign"
open={createModalOpen}
destroyOnHidden
width={640}
onCancel={() => {
setCreateModalOpen(false);
createForm.resetFields();
}}
onOk={() => createForm.submit()}
okText="Create"
>
<Form form={createForm} onFinish={handleCreate} layout="vertical">
{campaignFormFields}
</Form>
</Modal>
{/* Edit Modal */}
<Modal
title="Edit Campaign"
open={editModalOpen}
destroyOnHidden
width={640}
onCancel={() => {
setEditModalOpen(false);
setEditingCampaign(null);
editForm.resetFields();
}}
onOk={() => editForm.submit()}
okText="Save"
>
<Form form={editForm} onFinish={handleEdit} layout="vertical">
{campaignFormFields}
</Form>
</Modal>
{/* Emails Drawer */}
<CampaignEmailsDrawer
campaign={emailsCampaign}
open={emailsDrawerOpen}
onClose={() => {
setEmailsDrawerOpen(false);
setEmailsCampaign(null);
}}
/>
</>
);
}

View File

@ -0,0 +1,315 @@
import { useEffect, useState, useCallback } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, Typography, Spin, Progress, App, Button, Space, Grid } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
HomeOutlined,
TeamOutlined,
ThunderboltOutlined,
CheckCircleOutlined,
EnvironmentOutlined,
HistoryOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
CanvassAdminStats,
CanvassVisit,
VolunteerSummary,
VisitOutcome,
} from '@/types/canvass';
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS } from '@/types/canvass';
import type { PaginationMeta, Cut, MapSettings } from '@/types/api';
import type { SessionRoute } from '@/types/tracking';
import AdminLiveMap from '@/components/canvass/AdminLiveMap';
import HistoricalRoutesDrawer from '@/components/canvass/HistoricalRoutesDrawer';
export default function CanvassDashboardPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const isDesktop = !!screens.lg;
const [stats, setStats] = useState<CanvassAdminStats | null>(null);
const [activity, setActivity] = useState<CanvassVisit[]>([]);
const [activityPagination, setActivityPagination] = useState<PaginationMeta | null>(null);
const [volunteers, setVolunteers] = useState<VolunteerSummary[]>([]);
const [cuts, setCuts] = useState<Cut[]>([]);
const [mapSettings, setMapSettings] = useState<MapSettings | null>(null);
const [loading, setLoading] = useState(true);
const [mapVisible, setMapVisible] = useState(true);
const [historyOpen, setHistoryOpen] = useState(false);
const [_historyRoute, setHistoryRoute] = useState<SessionRoute | null>(null);
useEffect(() => {
setPageHeader({ title: 'Canvassing' });
return () => setPageHeader(null);
}, [setPageHeader]);
const loadData = useCallback(async () => {
try {
const [statsRes, activityRes, volunteersRes, cutsRes, settingsRes] = await Promise.all([
api.get('/map/canvass/stats'),
api.get('/map/canvass/activity', { params: { limit: 10 } }),
api.get('/map/canvass/volunteers'),
api.get('/map/cuts', { params: { limit: 100 } }),
api.get('/map/settings').catch(() => ({ data: null })),
]);
setStats(statsRes.data);
setActivity(activityRes.data.visits);
setActivityPagination(activityRes.data.pagination);
setVolunteers(volunteersRes.data);
setCuts(cutsRes.data.cuts ?? cutsRes.data);
if (settingsRes.data) setMapSettings(settingsRes.data);
} catch {
message.error('Failed to load canvass data');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
loadData();
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [loadData]);
const activityColumns: ColumnsType<CanvassVisit> = [
{
title: 'Volunteer',
key: 'volunteer',
ellipsis: true,
render: (_: unknown, r: CanvassVisit) => r.user?.name || r.user?.email || '—',
},
{
title: 'Address',
key: 'address',
ellipsis: true,
render: (_: unknown, r: CanvassVisit) =>
`${r.location?.address ?? '?'}${r.location?.unitNumber ? ` #${r.location.unitNumber}` : ''}`,
},
{
title: 'Outcome',
dataIndex: 'outcome',
key: 'outcome',
width: 100,
render: (val: VisitOutcome) => (
<Tag color={VISIT_OUTCOME_COLORS[val]}>{VISIT_OUTCOME_LABELS[val]}</Tag>
),
},
{
title: 'When',
dataIndex: 'visitedAt',
key: 'visitedAt',
width: 80,
render: (val: string) => dayjs(val).format('h:mm A'),
},
];
const volunteerColumns: ColumnsType<VolunteerSummary> = [
{
title: 'Name',
key: 'name',
ellipsis: true,
render: (_: unknown, r: VolunteerSummary) => r.name || r.email,
},
{ title: 'Visits', dataIndex: 'totalVisits', key: 'visits', width: 60, sorter: (a: VolunteerSummary, b: VolunteerSummary) => a.totalVisits - b.totalVisits },
{ title: 'Sessions', dataIndex: 'sessions', key: 'sessions', width: 70 },
{
title: 'Last Active',
dataIndex: 'lastActive',
key: 'lastActive',
width: 120,
render: (val: string | null) => val ? dayjs(val).format('MMM D, h:mm A') : '—',
},
];
const cutColumns: ColumnsType<Cut> = [
{ title: 'Cut', dataIndex: 'name', key: 'name', ellipsis: true },
{
title: 'Progress',
key: 'progress',
width: 120,
render: (_: unknown, r: Cut) => (
<Progress
percent={r.completionPercentage}
size="small"
style={{ maxWidth: 100 }}
/>
),
},
{
title: 'Last Canvassed',
dataIndex: 'lastCanvassed',
key: 'lastCanvassed',
width: 100,
render: (val: string | null) => val ? dayjs(val).format('MMM D') : 'Never',
},
];
if (loading) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
// Convert cuts to PublicCut shape for AdminLiveMap
const publicCuts = cuts.map((c) => ({
id: c.id,
name: c.name,
description: c.description,
color: c.color,
opacity: c.opacity,
category: c.category,
geojson: c.geojson,
bounds: c.bounds,
}));
const mapHeight = isMobile ? 350 : isDesktop ? 'calc(100vh - 180px)' : 500;
const mapCard = (
<Card
title={
<Space>
<EnvironmentOutlined />
<span>Live Volunteer Map</span>
</Space>
}
size="small"
styles={{ body: { padding: 0 } }}
extra={
<Space>
<Button
size="small"
icon={<HistoryOutlined />}
onClick={() => setHistoryOpen(true)}
>
History
</Button>
<Button
size="small"
icon={mapVisible ? <EyeInvisibleOutlined /> : <EyeOutlined />}
onClick={() => setMapVisible(!mapVisible)}
>
{mapVisible ? 'Hide' : 'Show'}
</Button>
</Space>
}
>
{mapVisible && (
<div style={{ height: mapHeight }}>
<AdminLiveMap cuts={publicCuts} mapSettings={mapSettings} />
</div>
)}
{!mapVisible && (
<Typography.Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '24px 0' }}>
Map hidden to save resources. Click "Show" to enable.
</Typography.Text>
)}
</Card>
);
const rightPanel = (
<div
style={
isDesktop
? { position: 'sticky', top: 0, maxHeight: 'calc(100vh - 140px)', overflowY: 'auto', paddingRight: 4 }
: undefined
}
>
{/* Stats 2x2 grid */}
<Row gutter={[8, 8]} style={{ marginBottom: 12 }}>
<Col span={12}>
<Card size="small">
<Statistic title="Total Visits" value={stats?.totalVisits ?? 0} prefix={<HomeOutlined />} valueStyle={{ fontSize: 20 }} />
</Card>
</Col>
<Col span={12}>
<Card size="small">
<Statistic title="Active Volunteers" value={stats?.activeVolunteers ?? 0} prefix={<TeamOutlined />} valueStyle={{ fontSize: 20 }} />
</Card>
</Col>
<Col span={12}>
<Card size="small">
<Statistic title="Active Sessions" value={stats?.activeSessions ?? 0} prefix={<ThunderboltOutlined />} valueStyle={{ fontSize: 20 }} />
</Card>
</Col>
<Col span={12}>
<Card size="small">
<Statistic title="Completion" value={stats?.overallCompletion ?? 0} suffix="%" prefix={<CheckCircleOutlined />} valueStyle={{ fontSize: 20 }} />
</Card>
</Col>
</Row>
<Card title="Recent Activity" size="small" style={{ marginBottom: 12 }}>
<Table
dataSource={activity}
columns={activityColumns}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: true }}
/>
{activityPagination && activityPagination.total > 10 && (
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
Showing 10 of {activityPagination.total} visits
</Typography.Text>
)}
</Card>
<Card title="Cut Progress" size="small" style={{ marginBottom: 12 }}>
<Table
dataSource={cuts}
columns={cutColumns}
rowKey="id"
size="small"
pagination={false}
scroll={{ x: true }}
/>
</Card>
<Card title="Volunteer Leaderboard" size="small">
<Table
dataSource={volunteers}
columns={volunteerColumns}
rowKey="userId"
size="small"
pagination={false}
scroll={{ x: true }}
/>
</Card>
</div>
);
return (
<div>
{isMobile ? (
// Mobile: stack vertically
<>
{mapCard}
<div style={{ marginTop: 16 }}>{rightPanel}</div>
</>
) : (
// Desktop/tablet: side-by-side
<Row gutter={16}>
<Col xs={24} md={13} lg={15}>
{mapCard}
</Col>
<Col xs={24} md={11} lg={9}>
{rightPanel}
</Col>
</Row>
)}
{/* Historical Routes Drawer */}
<HistoricalRoutesDrawer
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onSelectRoute={setHistoryRoute}
volunteers={volunteers.map((v) => ({ userId: v.userId, name: v.name, email: v.email }))}
/>
</div>
);
}

View File

@ -0,0 +1,122 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, CodeOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { DocsStatus, DocsConfig } from '@/types/api';
export default function CodeEditorPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [codeServerPort, setCodeServerPort] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<DocsStatus>('/docs/status'),
api.get<DocsConfig>('/docs/config'),
]);
setOnline(statusRes.data.codeServer.online);
setCodeServerPort(configRes.data.codeServerPort);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const codeServerUrl = codeServerPort
? `//${window.location.hostname}:${codeServerPort}`
: null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{codeServerUrl && (
<Button
icon={<LinkOutlined />}
href={codeServerUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, codeServerUrl]);
useEffect(() => {
setPageHeader({ title: 'Code Editor', actions: headerActions, fullBleed: true });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="The code editor requires a desktop browser with a larger screen."
icon={<CodeOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !codeServerUrl) {
return (
<Result
status="error"
title="Code Server Unavailable"
subTitle="Code Server is not running or could not be reached. Check that the code-server container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
return (
<iframe
src={codeServerUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="Code Server"
/>
);
}

View File

@ -0,0 +1,300 @@
import { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
import {
Button,
Typography,
Spin,
Space,
Table,
Tag,
Row,
Col,
Card,
Statistic,
message,
} from 'antd';
import { PrinterOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { Cut, Location, CutStatistics, SupportLevel } from '@/types/api';
import {
SUPPORT_LEVEL_LABELS,
SUPPORT_LEVEL_COLORS,
CUT_CATEGORY_LABELS,
CUT_CATEGORY_COLORS,
} from '@/types/api';
const { Title, Text } = Typography;
export default function CutExportPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [cut, setCut] = useState<Cut | null>(null);
const [locations, setLocations] = useState<Location[]>([]);
const [stats, setStats] = useState<CutStatistics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!id) return;
(async () => {
try {
const [cutRes, locsRes, statsRes] = await Promise.all([
api.get<Cut>(`/map/cuts/${id}`),
api.get<Location[]>(`/map/cuts/${id}/locations`),
api.get<CutStatistics>(`/map/cuts/${id}/statistics`),
]);
setCut(cutRes.data);
setLocations(locsRes.data);
setStats(statsRes.data);
} catch {
message.error('Failed to load cut data');
} finally {
setLoading(false);
}
})();
}, [id]);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/map/cuts')}
>
Back to Cuts
</Button>
<Button
type="primary"
icon={<PrinterOutlined />}
onClick={() => window.print()}
>
Print
</Button>
</Space>
), [navigate]);
useEffect(() => {
setPageHeader({ title: cut?.name || 'Cut Export', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions, cut?.name]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Spin size="large" />
</div>
);
}
if (!cut) {
return <Text type="danger">Cut not found</Text>;
}
const now = dayjs().format('YYYY-MM-DD HH:mm');
const withEmail = locations.filter((l) => l.email).length;
const withPhone = locations.filter((l) => l.phone).length;
const columns: ColumnsType<Location> = [
{
title: 'Name',
key: 'name',
render: (_: unknown, record: Location) =>
[record.firstName, record.lastName].filter(Boolean).join(' ') || '--',
},
{
title: 'Address',
key: 'address',
render: (_: unknown, record: Location) =>
[record.address, record.unitNumber && `#${record.unitNumber}`].filter(Boolean).join(' ') || '--',
},
{
title: 'Support',
dataIndex: 'supportLevel',
key: 'supportLevel',
width: 120,
render: (level: SupportLevel | null) =>
level ? (
<Tag color={SUPPORT_LEVEL_COLORS[level]}>{SUPPORT_LEVEL_LABELS[level]}</Tag>
) : (
<Tag>None</Tag>
),
},
{
title: 'Phone',
dataIndex: 'phone',
key: 'phone',
width: 120,
render: (val: string | null) => val || '--',
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
width: 180,
render: (val: string | null) => val || '--',
},
{
title: 'Sign',
key: 'sign',
width: 80,
render: (_: unknown, record: Location) =>
record.sign
? `Yes${record.signSize ? ` (${record.signSize})` : ''}`
: 'No',
},
{
title: 'Notes',
dataIndex: 'notes',
key: 'notes',
width: 150,
ellipsis: true,
render: (val: string | null) => val || '--',
},
];
return (
<>
<style>{`
@media print {
body * { visibility: hidden !important; }
.cut-export-print, .cut-export-print * { visibility: visible !important; }
.cut-export-print {
position: fixed !important;
left: 0 !important;
top: 0 !important;
width: 100% !important;
padding: 0.4in !important;
background: white !important;
color: black !important;
font-size: 10px !important;
}
.cut-export-print .ant-table { font-size: 9px !important; }
.cut-export-print .ant-table-thead > tr > th {
background: #f0f0f0 !important;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
padding: 4px 6px !important;
}
.cut-export-print .ant-table-tbody > tr > td { padding: 3px 6px !important; }
.cut-export-print .ant-tag {
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
.cut-export-print .ant-card { box-shadow: none !important; border: 1px solid #ddd !important; }
@page { size: letter landscape; margin: 0.25in; }
}
`}</style>
{/* Printable content */}
<div className="cut-export-print">
{/* Report header */}
<div style={{ marginBottom: 16 }}>
<Row justify="space-between" align="middle">
<Col>
<Title level={4} style={{ margin: 0 }}>{cut.name}</Title>
<Space style={{ marginTop: 4 }}>
{cut.category && (
<Tag color={CUT_CATEGORY_COLORS[cut.category]}>
{CUT_CATEGORY_LABELS[cut.category]}
</Tag>
)}
{cut.assignedTo && <Text type="secondary">Assigned to: {cut.assignedTo}</Text>}
</Space>
</Col>
<Col>
<Text type="secondary">Generated: {now}</Text>
</Col>
</Row>
</div>
{/* Stats grid */}
{stats && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic title="Total" value={stats.total} />
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic
title="Strong"
value={stats.byLevel.LEVEL_1 || 0}
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_1 }}
/>
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic
title="Likely"
value={stats.byLevel.LEVEL_2 || 0}
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_2 }}
/>
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic
title="Unsure"
value={stats.byLevel.LEVEL_3 || 0}
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_3 }}
/>
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic
title="Oppose"
value={stats.byLevel.LEVEL_4 || 0}
valueStyle={{ color: SUPPORT_LEVEL_COLORS.LEVEL_4 }}
/>
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic title="None" value={stats.byLevel.NONE || 0} />
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic title="Signs" value={stats.withSign} />
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic title="Email" value={withEmail} />
</Card>
</Col>
<Col xs={8} sm={4}>
<Card size="small">
<Statistic title="Phone" value={withPhone} />
</Card>
</Col>
</Row>
)}
{/* Location table */}
<Table<Location>
columns={columns}
dataSource={locations}
rowKey="id"
pagination={false}
size="small"
bordered
/>
{/* Footer */}
<div style={{ marginTop: 16, textAlign: 'center' }}>
<Text type="secondary" style={{ fontSize: 10 }}>
Generated by Changemaker Lite &mdash; {now}
</Text>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,560 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Form,
Switch,
Popconfirm,
message,
Row,
Col,
Slider,
Segmented,
Drawer,
Upload,
Typography,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
EnvironmentOutlined,
TableOutlined,
PrinterOutlined,
DownloadOutlined,
UploadOutlined,
ExportOutlined,
ImportOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate, useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
Cut,
CutsListResponse,
CutsListParams,
CutCategory,
} from '@/types/api';
import { CUT_CATEGORY_LABELS, CUT_CATEGORY_COLORS } from '@/types/api';
import CutEditorMap from '@/components/map/CutEditorMap';
const categoryOptions = Object.entries(CUT_CATEGORY_LABELS).map(([value, label]) => ({
value,
label,
}));
export default function CutsPage() {
const navigate = useNavigate();
const [cuts, setCuts] = useState<Cut[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [categoryFilter, setCategoryFilter] = useState<CutCategory | undefined>();
const [activeTab, setActiveTab] = useState('table');
// Create modal
const [createModalOpen, setCreateModalOpen] = useState(false);
const [pendingGeojson, setPendingGeojson] = useState<string | null>(null);
const [pendingBounds, setPendingBounds] = useState<string | null>(null);
const [createForm] = Form.useForm();
// Import modal
const [importModalOpen, setImportModalOpen] = useState(false);
const [importing, setImporting] = useState(false);
// Edit drawer
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [editingCut, setEditingCut] = useState<Cut | null>(null);
const [editForm] = Form.useForm();
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
const fetchCuts = useCallback(async (params?: CutsListParams) => {
setLoading(true);
try {
const { data } = await api.get<CutsListResponse>('/map/cuts', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
category: params?.category ?? categoryFilter,
},
});
setCuts(data.cuts);
setPagination(data.pagination);
} catch {
message.error('Failed to load cuts');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, categoryFilter]);
useEffect(() => {
fetchCuts({ page: 1 });
}, [debouncedSearch, categoryFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchCuts({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleCreate = async (values: Record<string, unknown>) => {
try {
await api.post('/map/cuts', {
...values,
geojson: pendingGeojson,
bounds: pendingBounds,
});
message.success('Cut created');
setCreateModalOpen(false);
createForm.resetFields();
setPendingGeojson(null);
setPendingBounds(null);
fetchCuts({ page: 1 });
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create cut';
message.error(msg);
}
};
const handleEdit = async (values: Record<string, unknown>) => {
if (!editingCut) return;
try {
await api.put(`/map/cuts/${editingCut.id}`, values);
message.success('Cut updated');
setEditDrawerOpen(false);
setEditingCut(null);
editForm.resetFields();
fetchCuts();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to update cut';
message.error(msg);
}
};
const handleExportAll = async () => {
try {
const { data } = await api.get('/map/cuts/export-geojson', { responseType: 'blob' });
const blob = new Blob([JSON.stringify(data)], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cuts.geojson';
a.click();
URL.revokeObjectURL(url);
message.success('GeoJSON exported');
} catch {
message.error('Export failed');
}
};
const handleExportSingle = async (id: string, name: string) => {
try {
const { data } = await api.get(`/map/cuts/${id}/export-geojson`);
const blob = new Blob([JSON.stringify(data)], { type: 'application/geo+json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${name.replace(/\s+/g, '-').toLowerCase()}.geojson`;
a.click();
URL.revokeObjectURL(url);
} catch {
message.error('Export failed');
}
};
const handleImport = async (file: File) => {
setImporting(true);
try {
const formData = new FormData();
formData.append('file', file);
const { data } = await api.post('/map/cuts/import-geojson', formData);
message.success(`Imported: ${data.success} of ${data.total} features`);
if (data.failed > 0) {
message.warning(`${data.failed} features failed to import`);
}
setImportModalOpen(false);
fetchCuts({ page: 1 });
} catch {
message.error('Import failed');
} finally {
setImporting(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/map/cuts/${id}`);
message.success('Cut deleted');
fetchCuts();
} catch {
message.error('Failed to delete cut');
}
};
const openEdit = (cut: Cut) => {
setEditingCut(cut);
editForm.setFieldsValue({
name: cut.name,
description: cut.description,
color: cut.color,
opacity: parseFloat(String(cut.opacity)),
category: cut.category,
isPublic: cut.isPublic,
isOfficial: cut.isOfficial,
showLocations: cut.showLocations,
exportEnabled: cut.exportEnabled,
assignedTo: cut.assignedTo,
});
setEditDrawerOpen(true);
};
const handleFinishDraw = (geojson: string, bounds: string) => {
setPendingGeojson(geojson);
setPendingBounds(bounds);
createForm.resetFields();
createForm.setFieldsValue({ color: '#3388ff', opacity: 0.3 });
setCreateModalOpen(true);
};
const columns: ColumnsType<Cut> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name: string) => <span style={{ fontWeight: 500 }}>{name}</span>,
},
{
title: 'Color',
dataIndex: 'color',
key: 'color',
width: 60,
render: (color: string, record: Cut) => (
<div
style={{
width: 24,
height: 24,
borderRadius: 4,
backgroundColor: color,
opacity: parseFloat(String(record.opacity)) || 0.3,
border: `2px solid ${color}`,
}}
/>
),
},
{
title: 'Category',
dataIndex: 'category',
key: 'category',
render: (cat: CutCategory | null) =>
cat ? (
<Tag color={CUT_CATEGORY_COLORS[cat]}>{CUT_CATEGORY_LABELS[cat]}</Tag>
) : (
<Tag>None</Tag>
),
},
{
title: 'Public',
dataIndex: 'isPublic',
key: 'isPublic',
width: 80,
render: (isPublic: boolean, record: Cut) => (
<Switch
checked={isPublic}
size="small"
onChange={async (checked) => {
try {
await api.put(`/map/cuts/${record.id}`, { isPublic: checked });
fetchCuts();
} catch {
message.error('Failed to update');
}
}}
/>
),
},
{
title: 'Assigned To',
dataIndex: 'assignedTo',
key: 'assignedTo',
render: (val: string | null) => val || '--',
responsive: ['lg'],
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['xl'],
},
{
title: 'Actions',
key: 'actions',
width: 150,
render: (_: unknown, record: Cut) => (
<Space>
<Button
type="link"
size="small"
icon={<DownloadOutlined />}
onClick={(e) => { e.stopPropagation(); handleExportSingle(record.id, record.name); }}
title="Download GeoJSON"
/>
<Button
type="link"
size="small"
icon={<PrinterOutlined />}
onClick={(e) => { e.stopPropagation(); navigate(`/app/map/cuts/${record.id}/export`); }}
title="Export"
/>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
title="Edit"
/>
<Popconfirm
title="Delete this cut?"
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
</Popconfirm>
</Space>
),
},
];
const cutFormFields = (
<>
<Form.Item
name="name"
label="Name"
rules={[{ required: true, message: 'Name is required' }]}
>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} />
</Form.Item>
<Row gutter={12}>
<Col span={8}>
<Form.Item name="color" label="Color">
<Input type="color" style={{ width: 60, padding: 2, cursor: 'pointer' }} />
</Form.Item>
</Col>
<Col span={16}>
<Form.Item name="opacity" label="Opacity">
<Slider min={0} max={1} step={0.05} />
</Form.Item>
</Col>
</Row>
<Form.Item name="category" label="Category">
<Select options={categoryOptions} allowClear placeholder="Select category" />
</Form.Item>
<Row gutter={12}>
<Col span={8}>
<Form.Item name="isPublic" label="Public" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="isOfficial" label="Official" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="showLocations" label="Show Locations" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item name="assignedTo" label="Assigned To">
<Input placeholder="e.g. Volunteer name" />
</Form.Item>
</>
);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
<Segmented
value={activeTab}
onChange={(val) => setActiveTab(val as string)}
options={[
{ value: 'table', icon: <TableOutlined />, label: 'Table' },
{ value: 'map', icon: <EnvironmentOutlined />, label: 'Map' },
]}
size="middle"
/>
<Button icon={<ExportOutlined />} onClick={handleExportAll}>
Export GeoJSON
</Button>
<Button icon={<ImportOutlined />} onClick={() => setImportModalOpen(true)}>
Import GeoJSON
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setActiveTab('map')}
>
Draw New Cut
</Button>
</Space>
), [activeTab]);
useEffect(() => {
setPageHeader({ title: 'Cuts', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
return (
<>
{activeTab === 'table' ? (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search name or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Select
placeholder="Category"
options={categoryOptions}
value={categoryFilter}
onChange={setCategoryFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<Cut>
columns={columns}
dataSource={cuts}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} cuts`,
}}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => openEdit(record),
style: { cursor: 'pointer' },
})}
/>
</>
) : (
<CutEditorMap
cuts={cuts}
onFinishDraw={handleFinishDraw}
/>
)}
{/* Create Modal */}
<Modal
title="Create Cut"
open={createModalOpen}
destroyOnHidden
width={500}
onCancel={() => {
setCreateModalOpen(false);
createForm.resetFields();
setPendingGeojson(null);
setPendingBounds(null);
}}
onOk={() => createForm.submit()}
okText="Create"
>
<Form
form={createForm}
onFinish={handleCreate}
layout="vertical"
initialValues={{ color: '#3388ff', opacity: 0.3 }}
>
{cutFormFields}
</Form>
</Modal>
{/* Import GeoJSON Modal */}
<Modal
title="Import GeoJSON"
open={importModalOpen}
destroyOnHidden
width={500}
onCancel={() => setImportModalOpen(false)}
footer={null}
>
<Typography.Paragraph type="secondary">
Upload a GeoJSON file containing Polygon or MultiPolygon features.
Supported formats: Feature, FeatureCollection, or bare Polygon/MultiPolygon.
Properties like name, color, fill-opacity, and category will be mapped automatically.
</Typography.Paragraph>
<Upload.Dragger
accept=".geojson,.json"
maxCount={1}
showUploadList={false}
customRequest={({ file }) => handleImport(file as File)}
disabled={importing}
>
<p style={{ fontSize: 32, color: '#1890ff' }}>
<UploadOutlined />
</p>
<p>{importing ? 'Importing...' : 'Click or drag a .geojson file here'}</p>
</Upload.Dragger>
</Modal>
{/* Edit Drawer */}
<Drawer
title="Edit Cut"
open={editDrawerOpen}
width={480}
onClose={() => {
setEditDrawerOpen(false);
setEditingCut(null);
editForm.resetFields();
}}
extra={
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
}
>
<Form form={editForm} onFinish={handleEdit} layout="vertical">
{cutFormFields}
</Form>
</Drawer>
</>
);
}

View File

@ -0,0 +1,66 @@
import { Row, Col, Card, Statistic, Alert, Typography } from 'antd';
import {
TeamOutlined,
SendOutlined,
EnvironmentOutlined,
MailOutlined,
} from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
const { Title } = Typography;
export default function DashboardPage() {
const { user } = useAuthStore();
return (
<>
<Title level={4}>Welcome{user?.name ? `, ${user.name}` : ''}</Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Total Users"
value="--"
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Active Campaigns"
value="--"
prefix={<SendOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Map Locations"
value="--"
prefix={<EnvironmentOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Emails Sent"
value="--"
prefix={<MailOutlined />}
/>
</Card>
</Col>
</Row>
<Alert
message="Dashboard analytics coming soon"
description="Statistics and charts will be populated as additional modules are implemented."
type="info"
showIcon
/>
</>
);
}

1169
admin/src/pages/DocsPage.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,140 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Card, Statistic, Row, Col, Button, Space, Tag, message } from 'antd';
import {
PauseCircleOutlined,
PlayCircleOutlined,
DeleteOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { QueueStats } from '@/types/api';
export default function EmailQueuePage() {
const [stats, setStats] = useState<QueueStats | null>(null);
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState(false);
const fetchStats = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<QueueStats>('/email-queue/stats');
setStats(data);
} catch {
message.error('Failed to load queue stats');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
const interval = setInterval(fetchStats, 10_000);
return () => clearInterval(interval);
}, [fetchStats]);
const handlePauseResume = useCallback(async () => {
if (!stats) return;
setActionLoading(true);
try {
const action = stats.paused ? 'resume' : 'pause';
await api.post(`/email-queue/${action}`);
message.success(`Queue ${action}d`);
fetchStats();
} catch {
message.error('Action failed');
} finally {
setActionLoading(false);
}
}, [stats, fetchStats]);
const handleClean = useCallback(async () => {
setActionLoading(true);
try {
const { data } = await api.post<{ cleaned: number }>('/email-queue/clean');
message.success(`Cleaned ${data.cleaned} completed jobs`);
fetchStats();
} catch {
message.error('Clean failed');
} finally {
setActionLoading(false);
}
}, [fetchStats]);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
{stats && (
<Tag color={stats.paused ? 'orange' : 'green'}>
{stats.paused ? 'PAUSED' : 'RUNNING'}
</Tag>
)}
<Button icon={<ReloadOutlined />} onClick={fetchStats} loading={loading}>
Refresh
</Button>
<Button
icon={stats?.paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
onClick={handlePauseResume}
loading={actionLoading}
>
{stats?.paused ? 'Resume' : 'Pause'}
</Button>
<Button
icon={<DeleteOutlined />}
onClick={handleClean}
loading={actionLoading}
>
Clean Old Jobs
</Button>
</Space>
), [stats, loading, actionLoading, fetchStats, handlePauseResume, handleClean]);
useEffect(() => {
setPageHeader({ title: 'Email Queue', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
return (
<>
<Row gutter={[16, 16]}>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Waiting"
value={stats?.waiting ?? 0}
valueStyle={{ color: '#1890ff' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Active"
value={stats?.active ?? 0}
valueStyle={{ color: '#52c41a' }}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Completed"
value={stats?.completed ?? 0}
/>
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic
title="Failed"
value={stats?.failed ?? 0}
valueStyle={{ color: '#ff4d4f' }}
/>
</Card>
</Col>
</Row>
</>
);
}

View File

@ -0,0 +1,123 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, BranchesOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
export default function GiteaPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.gitea.online);
setConfig(configRes.data);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const serviceUrl = config
? buildServiceUrl(config.giteaSubdomain, config.domain, config.giteaPort)
: null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{serviceUrl && (
<Button
icon={<LinkOutlined />}
href={serviceUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, serviceUrl]);
useEffect(() => {
setPageHeader({ title: 'Git Repository', actions: headerActions, fullBleed: true });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="The Git repository browser requires a desktop browser with a larger screen."
icon={<BranchesOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="Gitea Unavailable"
subTitle="Gitea is not running or could not be reached. Check that the Gitea container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
return (
<iframe
src={serviceUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="Gitea"
/>
);
}

View File

@ -0,0 +1,465 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Form,
Popconfirm,
message,
Typography,
Row,
Col,
Radio,
Checkbox,
Divider,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
EyeOutlined,
SettingOutlined,
SyncOutlined,
BuildOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
import type { LandingPage, EditorMode, LandingPagesListResponse, LandingPagesListParams } from '@/types/api';
const { Title } = Typography;
const { TextArea } = Input;
const publishedOptions = [
{ value: 'true', label: 'Published' },
{ value: 'false', label: 'Draft' },
];
export default function LandingPagesPage() {
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const [pages, setPages] = useState<LandingPage[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [syncing, setSyncing] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [publishedFilter, setPublishedFilter] = useState<'true' | 'false' | undefined>();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const [editingPage, setEditingPage] = useState<LandingPage | null>(null);
const [createForm] = Form.useForm();
const [settingsForm] = Form.useForm();
const navigate = useNavigate();
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
const fetchPages = useCallback(async (params?: LandingPagesListParams) => {
setLoading(true);
try {
const { data } = await api.get<LandingPagesListResponse>('/pages', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
published: params?.published ?? publishedFilter,
},
});
setPages(data.pages);
setPagination(data.pagination);
} catch {
message.error('Failed to load pages');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, publishedFilter]);
useEffect(() => {
fetchPages({ page: 1 });
}, [debouncedSearch, publishedFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchPages({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleCreate = async (values: { title: string; description?: string; editorMode?: EditorMode }) => {
try {
const { data } = await api.post<LandingPage>('/pages', values);
message.success('Page created');
setCreateModalOpen(false);
createForm.resetFields();
navigate(`/app/pages/${data.id}/edit`);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create page';
message.error(msg);
}
};
const handleSyncOverrides = async () => {
setSyncing(true);
try {
const { data } = await api.post<{ imported: number; updated: number; stubs: number }>('/pages/sync');
if (data.imported > 0 || data.updated > 0 || data.stubs > 0) {
message.success(`Synced: ${data.imported} imported, ${data.updated} updated, ${data.stubs} stubs created`);
fetchPages();
} else {
message.info('No new overrides to sync');
}
} catch {
message.error('Failed to sync overrides');
} finally {
setSyncing(false);
}
};
const handleSettingsSave = async (values: Record<string, unknown>) => {
if (!editingPage) return;
try {
await api.put(`/pages/${editingPage.id}`, values);
message.success('Page settings updated');
setSettingsModalOpen(false);
setEditingPage(null);
settingsForm.resetFields();
fetchPages();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to update page';
message.error(msg);
}
};
const handleTogglePublished = async (page: LandingPage) => {
try {
await api.put(`/pages/${page.id}`, { published: !page.published });
message.success(page.published ? 'Page unpublished' : 'Page published');
fetchPages();
} catch {
message.error('Failed to update page');
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/pages/${id}`);
message.success('Page deleted');
fetchPages();
} catch {
message.error('Failed to delete page');
}
};
const openSettings = (page: LandingPage) => {
setEditingPage(page);
settingsForm.setFieldsValue({
title: page.title,
description: page.description,
mkdocsPath: page.mkdocsPath,
mkdocsExportMode: page.mkdocsExportMode,
mkdocsHideNav: page.mkdocsHideNav,
mkdocsHideToc: page.mkdocsHideToc,
seoTitle: page.seoTitle,
seoDescription: page.seoDescription,
seoImage: page.seoImage,
});
setSettingsModalOpen(true);
};
const columns: ColumnsType<LandingPage> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record: LandingPage) => (
<div>
<span style={{ fontWeight: 500 }}>{title}</span>
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/p/{record.slug}</div>
</div>
),
},
{
title: 'Editor',
dataIndex: 'editorMode',
key: 'editorMode',
render: (mode: EditorMode) => (
<Tag color={mode === 'VISUAL' ? 'green' : 'blue'}>
{mode === 'VISUAL' ? 'Visual' : 'Code'}
</Tag>
),
responsive: ['sm'],
},
{
title: 'Status',
dataIndex: 'published',
key: 'published',
render: (published: boolean) => (
<Tag color={published ? 'green' : 'default'}>{published ? 'Published' : 'Draft'}</Tag>
),
},
{
title: 'MkDocs',
dataIndex: 'mkdocsPath',
key: 'mkdocsPath',
render: (_: string | null, record: LandingPage) => (
<div>
<div>{record.mkdocsPath || '--'}</div>
{record.mkdocsStubPath && (
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.45)' }}>{record.mkdocsStubPath}</div>
)}
</div>
),
responsive: ['lg'],
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Updated',
dataIndex: 'updatedAt',
key: 'updatedAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: LandingPage) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/app/pages/${record.id}/edit`)}
title={record.editorMode === 'CODE' ? 'Edit code' : 'Edit in builder'}
/>
<Button
type="link"
size="small"
icon={<SettingOutlined />}
onClick={() => openSettings(record)}
title="Page settings"
/>
{record.published && (
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(`/p/${record.slug}`, '_blank')}
title="View page"
/>
)}
<Button
type="link"
size="small"
onClick={() => handleTogglePublished(record)}
title={record.published ? 'Unpublish' : 'Publish'}
>
{record.published ? 'Unpublish' : 'Publish'}
</Button>
<Popconfirm
title="Delete this page?"
description="This action cannot be undone."
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
</Popconfirm>
</Space>
),
},
];
return (
<>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
Landing Pages
</Title>
</Col>
<Col>
<Space>
{isSuperAdmin && (
<Button
icon={<BuildOutlined />}
loading={building}
onClick={confirmAndBuild}
>
Build Site
</Button>
)}
<Button
icon={<SyncOutlined spin={syncing} />}
loading={syncing}
onClick={handleSyncOverrides}
>
Sync Overrides
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create Page
</Button>
</Space>
</Col>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search by title or description"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={publishedOptions}
value={publishedFilter}
onChange={setPublishedFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<LandingPage>
columns={columns}
dataSource={pages}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} pages`,
}}
onChange={handleTableChange}
/>
{/* Create Modal */}
<Modal
title="Create Landing Page"
open={createModalOpen}
destroyOnHidden
onCancel={() => {
setCreateModalOpen(false);
createForm.resetFields();
}}
onOk={() => createForm.submit()}
okText="Create & Edit"
>
<Form form={createForm} onFinish={handleCreate} layout="vertical" initialValues={{ editorMode: 'VISUAL' }}>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={3} />
</Form.Item>
<Form.Item name="editorMode" label="Editor Mode">
<Radio.Group>
<Radio.Button value="VISUAL">Visual Editor</Radio.Button>
<Radio.Button value="CODE">Code Editor</Radio.Button>
</Radio.Group>
</Form.Item>
</Form>
</Modal>
{/* Settings Modal */}
<Modal
title="Page Settings"
open={settingsModalOpen}
destroyOnHidden
width={560}
onCancel={() => {
setSettingsModalOpen(false);
setEditingPage(null);
settingsForm.resetFields();
}}
onOk={() => settingsForm.submit()}
okText="Save"
>
<Form form={settingsForm} onFinish={handleSettingsSave} layout="vertical">
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="seoTitle" label="SEO Title">
<Input />
</Form.Item>
<Form.Item name="seoDescription" label="SEO Description">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="seoImage" label="SEO Image URL">
<Input placeholder="https://..." />
</Form.Item>
<Divider>MkDocs Integration</Divider>
<Form.Item name="mkdocsPath" label="Override Path">
<Input placeholder="e.g. about.html" />
</Form.Item>
<Form.Item
name="mkdocsExportMode"
valuePropName="checked"
getValueFromEvent={(e: { target: { checked: boolean } }) => e.target.checked ? 'STANDALONE' : 'THEMED'}
getValueProps={(value: string) => ({ checked: value === 'STANDALONE' })}
help="Publish as a full HTML page with no MkDocs header, footer, or theme (like lander.html)"
>
<Checkbox>Full page MkDocs</Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.mkdocsExportMode !== cur.mkdocsExportMode}>
{({ getFieldValue }) =>
getFieldValue('mkdocsExportMode') !== 'STANDALONE' && (
<>
<Form.Item name="mkdocsHideNav" valuePropName="checked">
<Checkbox>Hide navigation sidebar</Checkbox>
</Form.Item>
<Form.Item name="mkdocsHideToc" valuePropName="checked">
<Checkbox>Hide table of contents</Checkbox>
</Form.Item>
</>
)
}
</Form.Item>
</Form>
</Modal>
</>
);
}

View File

@ -0,0 +1,395 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Card,
Button,
Space,
Badge,
Descriptions,
Table,
Collapse,
Popconfirm,
App,
Row,
Col,
Spin,
Radio,
Alert,
} from 'antd';
import {
SyncOutlined,
LinkOutlined,
ApiOutlined,
ReloadOutlined,
DesktopOutlined,
SettingOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
ListmonkStatus,
ListmonkStats,
ListmonkSyncResult,
ListmonkSyncAllResult,
} from '@/types/api';
dayjs.extend(relativeTime);
export default function ListmonkPage() {
const { message } = App.useApp();
const [status, setStatus] = useState<ListmonkStatus | null>(null);
const [stats, setStats] = useState<ListmonkStats | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState<Record<string, boolean>>({});
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
const [iframeLoading, setIframeLoading] = useState(false);
const [iframeError, setIframeError] = useState<string | null>(null);
const iframeInitialized = useRef(false);
const [activeTab, setActiveTab] = useState<'management' | 'admin'>('management');
const fetchStatus = useCallback(async () => {
try {
const res = await api.get<ListmonkStatus>('/listmonk');
setStatus(res.data);
} catch {
// Status fetch failed — leave null
}
}, []);
const fetchStats = useCallback(async () => {
try {
const res = await api.get<ListmonkStats>('/listmonk/stats');
setStats(res.data);
} catch {
// Stats fetch may fail if not initialized
}
}, []);
const fetchAll = useCallback(async () => {
setLoading(true);
await Promise.all([fetchStatus(), fetchStats()]);
setLoading(false);
}, [fetchStatus, fetchStats]);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const handleTestConnection = useCallback(async () => {
setSyncing(s => ({ ...s, test: true }));
try {
const res = await api.post<{ success: boolean; message: string }>('/listmonk/test-connection');
if (res.data.success) {
message.success(res.data.message);
} else {
message.warning(res.data.message);
}
await fetchStatus();
} catch {
message.error('Failed to test connection');
} finally {
setSyncing(s => ({ ...s, test: false }));
}
}, [fetchStatus]);
const handleSync = async (type: 'participants' | 'locations' | 'users') => {
setSyncing(s => ({ ...s, [type]: true }));
try {
const res = await api.post<ListmonkSyncResult>(`/listmonk/sync/${type}`);
if (res.data.success) {
message.success(res.data.message);
if (res.data.results && res.data.results.failed > 0) {
message.warning(`${res.data.results.failed} failed — check logs for details`);
}
} else {
message.error(res.data.message);
}
await Promise.all([fetchStatus(), fetchStats()]);
} catch {
message.error(`Failed to sync ${type}`);
} finally {
setSyncing(s => ({ ...s, [type]: false }));
}
};
const handleSyncAll = async () => {
setSyncing(s => ({ ...s, all: true }));
try {
const res = await api.post<ListmonkSyncAllResult>('/listmonk/sync/all');
if (res.data.success) {
message.success(res.data.message);
if (res.data.results) {
const { participants, locations, users } = res.data.results;
const totalFailed = participants.failed + locations.failed + users.failed;
if (totalFailed > 0) {
message.warning(`${totalFailed} total failures — check logs for details`);
}
}
} else {
message.error(res.data.message);
}
await Promise.all([fetchStatus(), fetchStats()]);
} catch {
message.error('Failed to sync all');
} finally {
setSyncing(s => ({ ...s, all: false }));
}
};
const handleReinitialize = async () => {
setSyncing(s => ({ ...s, reinit: true }));
try {
await api.post('/listmonk/reinitialize');
message.success('Lists reinitialized');
await Promise.all([fetchStatus(), fetchStats()]);
} catch {
message.error('Failed to reinitialize lists');
} finally {
setSyncing(s => ({ ...s, reinit: false }));
}
};
const { setPageHeader } = useOutletContext<AppOutletContext>();
const loadIframe = useCallback(async () => {
if (iframeInitialized.current && iframeSrc) return;
setIframeLoading(true);
setIframeError(null);
try {
const res = await api.get<{ port: number; token: string }>('/listmonk/proxy-url');
const { port, token } = res.data;
const url = `//${window.location.hostname}:${port}/auth?token=${encodeURIComponent(token)}`;
setIframeSrc(url);
iframeInitialized.current = true;
} catch {
setIframeError('Failed to load Listmonk admin — ensure the proxy is running');
} finally {
setIframeLoading(false);
}
}, [iframeSrc]);
const listmonkAdminUrl = `//${window.location.hostname}:9001`;
const headerActions = useMemo(() => (
<Space>
<Radio.Group
value={activeTab}
onChange={(e) => {
const tab = e.target.value as 'management' | 'admin';
setActiveTab(tab);
if (tab === 'admin') loadIframe();
}}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="management"><SettingOutlined /> Management</Radio.Button>
<Radio.Button value="admin"><DesktopOutlined /> Listmonk Admin</Radio.Button>
</Radio.Group>
<Button
icon={<ApiOutlined />}
loading={syncing.test}
onClick={handleTestConnection}
>
Test Connection
</Button>
<Button
icon={<LinkOutlined />}
href={listmonkAdminUrl}
target="_blank"
>
Open Listmonk
</Button>
</Space>
), [activeTab, syncing.test, handleTestConnection, listmonkAdminUrl, loadIframe]);
useEffect(() => {
setPageHeader({ title: 'Newsletter / Listmonk', actions: headerActions, fullBleed: activeTab === 'admin' });
return () => setPageHeader(null);
}, [setPageHeader, headerActions, activeTab]);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return (
<div>
{activeTab === 'management' && (
<>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="Status" size="small">
<Descriptions column={1} size="small">
<Descriptions.Item label="Sync Enabled">
<Badge
status={status?.enabled ? 'success' : 'error'}
text={status?.enabled ? 'Enabled' : 'Disabled'}
/>
</Descriptions.Item>
<Descriptions.Item label="Connection">
<Badge
status={status?.connected ? 'success' : status?.enabled ? 'warning' : 'default'}
text={status?.connected ? 'Connected' : status?.enabled ? 'Disconnected' : 'N/A'}
/>
</Descriptions.Item>
<Descriptions.Item label="Lists Initialized">
<Badge
status={status?.initialized ? 'success' : 'default'}
text={status?.initialized ? 'Yes' : 'No'}
/>
</Descriptions.Item>
<Descriptions.Item label="Last Sync">
{status?.lastSyncAt ? dayjs(status.lastSyncAt).fromNow() : 'Never'}
</Descriptions.Item>
<Descriptions.Item label="Last Error">
{status?.lastError || 'None'}
</Descriptions.Item>
</Descriptions>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="Sync Actions" size="small">
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
<Button
block
icon={<SyncOutlined />}
loading={syncing.participants}
onClick={() => handleSync('participants')}
disabled={!status?.enabled}
>
Sync Participants
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
icon={<SyncOutlined />}
loading={syncing.locations}
onClick={() => handleSync('locations')}
disabled={!status?.enabled}
>
Sync Locations
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
icon={<SyncOutlined />}
loading={syncing.users}
onClick={() => handleSync('users')}
disabled={!status?.enabled}
>
Sync Users
</Button>
</Col>
<Col xs={24} sm={12}>
<Button
block
type="primary"
icon={<SyncOutlined />}
loading={syncing.all}
onClick={handleSyncAll}
disabled={!status?.enabled}
>
Sync All
</Button>
</Col>
</Row>
</Space>
</Card>
</Col>
</Row>
<Card title="List Statistics" size="small" style={{ marginTop: 16 }}>
<Table
dataSource={stats?.lists || []}
rowKey="name"
size="small"
loading={loading}
pagination={false}
columns={[
{ title: 'List Name', dataIndex: 'name', key: 'name' },
{
title: 'Subscribers',
dataIndex: 'subscriberCount',
key: 'subscriberCount',
width: 120,
align: 'right' as const,
},
]}
locale={{ emptyText: status?.initialized ? 'No lists found' : 'Lists not initialized — run a sync or reinitialize' }}
/>
</Card>
<Collapse
style={{ marginTop: 16 }}
items={[
{
key: 'advanced',
label: 'Advanced',
children: (
<Space>
<Popconfirm
title="Reinitialize Lists"
description="This will re-create any missing lists in Listmonk. Existing lists are preserved."
onConfirm={handleReinitialize}
okText="Reinitialize"
>
<Button icon={<ReloadOutlined />} loading={syncing.reinit} disabled={!status?.enabled}>
Reinitialize Lists
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
)}
{activeTab === 'admin' && (
<div>
{iframeLoading && (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
)}
{iframeError && (
<Alert
type="error"
message={iframeError}
showIcon
action={
<Button size="small" onClick={loadIframe}>
Retry
</Button>
}
style={{ marginBottom: 16 }}
/>
)}
{iframeSrc && !iframeLoading && (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="Listmonk Admin"
/>
)}
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,99 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Form, Input, Button, Alert, Typography } from 'antd';
import { MailOutlined, LockOutlined } from '@ant-design/icons';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { ADMIN_ROLES, type UserRole } from '@/types/api';
const { Title, Text } = Typography;
function getPostLoginPath(role?: UserRole): string {
if (role && ADMIN_ROLES.includes(role)) return '/app';
return '/volunteer';
}
export default function LoginPage() {
const navigate = useNavigate();
const { login, isAuthenticated, isLoading, error, user } = useAuthStore();
const { settings } = useSettingsStore();
const [form] = Form.useForm();
useEffect(() => {
if (isAuthenticated && user) {
navigate(getPostLoginPath(user.role), { replace: true });
}
}, [isAuthenticated, user, navigate]);
const handleSubmit = async (values: { email: string; password: string }) => {
try {
await login(values.email, values.password);
const updatedUser = useAuthStore.getState().user;
navigate(getPostLoginPath(updatedUser?.role), { replace: true });
} catch {
// Error is set in store
}
};
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
padding: 16,
background: '#120b1a',
}}
>
<Card style={{ width: '100%', maxWidth: 400 }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Title level={3} style={{ marginBottom: 4 }}>
{settings?.organizationName ?? 'Changemaker Lite'}
</Title>
<Text type="secondary">{settings?.loginSubtitle ?? 'Admin'}</Text>
</div>
{error && (
<Alert
message={error}
type="error"
showIcon
closable
style={{ marginBottom: 16 }}
/>
)}
<Form form={form} onFinish={handleSubmit} layout="vertical" size="large">
<Form.Item
name="email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' },
]}
>
<Input prefix={<MailOutlined />} placeholder="Email" autoFocus />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: 'Please enter your password' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="Password" />
</Form.Item>
<Form.Item>
<Button
type="primary"
htmlType="submit"
loading={isLoading}
block
>
Sign In
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
}

View File

@ -0,0 +1,123 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, MailOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
export default function MailHogPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.mailhog.online);
setConfig(configRes.data);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const serviceUrl = config
? buildServiceUrl(config.mailhogSubdomain, config.domain, config.mailhogPort)
: null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{serviceUrl && (
<Button
icon={<LinkOutlined />}
href={serviceUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, serviceUrl]);
useEffect(() => {
setPageHeader({ title: 'MailHog', actions: headerActions, fullBleed: true });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="MailHog requires a desktop browser with a larger screen."
icon={<MailOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="MailHog Unavailable"
subTitle="MailHog is not running or could not be reached. Check that the MailHog container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
return (
<iframe
src={serviceUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="MailHog"
/>
);
}

View File

@ -0,0 +1,432 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Form,
Input,
InputNumber,
Button,
Slider,
Card,
Row,
Col,
Typography,
Divider,
message,
Spin,
Space,
AutoComplete,
} from 'antd';
import { SaveOutlined, PrinterOutlined, SearchOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { MapSettings, GeocodeSearchResult } from '@/types/api';
const { Text } = Typography;
export default function MapSettingsPage() {
const [form] = Form.useForm();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [citySearch, setCitySearch] = useState('');
const [cityOptions, setCityOptions] = useState<{ value: string; label: React.ReactNode; result: GeocodeSearchResult }[]>([]);
const [citySearching, setCitySearching] = useState(false);
const cityTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const printRef = useRef<HTMLDivElement>(null);
// Watch all form values so the walk sheet preview updates live
const watched = Form.useWatch(undefined, form) as Record<string, string | undefined> | undefined;
const fetchSettings = useCallback(async () => {
try {
const { data } = await api.get<MapSettings>('/map/settings');
form.setFieldsValue({
latitude: data.latitude ? parseFloat(data.latitude) : 45.4215,
longitude: data.longitude ? parseFloat(data.longitude) : -75.6972,
zoom: data.zoom ?? 12,
walkSheetTitle: data.walkSheetTitle,
walkSheetSubtitle: data.walkSheetSubtitle,
walkSheetFooter: data.walkSheetFooter,
qrCode1Url: data.qrCode1Url,
qrCode1Label: data.qrCode1Label,
qrCode2Url: data.qrCode2Url,
qrCode2Label: data.qrCode2Label,
qrCode3Url: data.qrCode3Url,
qrCode3Label: data.qrCode3Label,
});
} catch {
message.error('Failed to load map settings');
} finally {
setLoading(false);
}
}, [form]);
useEffect(() => {
fetchSettings();
}, [fetchSettings]);
const handleCitySearch = useCallback((value: string) => {
setCitySearch(value);
clearTimeout(cityTimerRef.current);
if (value.length < 2) {
setCityOptions([]);
return;
}
cityTimerRef.current = setTimeout(async () => {
setCitySearching(true);
try {
const { data } = await api.get<GeocodeSearchResult[]>('/map/geocoding/search', {
params: { q: value, limit: 5 },
});
setCityOptions(data.map((r) => ({
value: r.displayName,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 300 }}>
{r.displayName}
</span>
<span style={{ color: '#888', fontSize: 11, marginLeft: 8, flexShrink: 0 }}>{r.type}</span>
</div>
),
result: r,
})));
} catch {
setCityOptions([]);
} finally {
setCitySearching(false);
}
}, 400);
}, []);
const handleCitySelect = useCallback((_value: string, option: { result: GeocodeSearchResult }) => {
form.setFieldsValue({
latitude: option.result.latitude,
longitude: option.result.longitude,
zoom: 12,
});
setCitySearch('');
setCityOptions([]);
message.success('Coordinates auto-filled. Fine-tune below.');
}, [form]);
useEffect(() => {
return () => clearTimeout(cityTimerRef.current);
}, []);
const handleSave = async (values: Record<string, unknown>) => {
setSaving(true);
try {
await api.put('/map/settings', values);
message.success('Map settings saved');
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to save settings';
message.error(msg);
} finally {
setSaving(false);
}
};
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
<Button
icon={<PrinterOutlined />}
onClick={() => window.print()}
>
Print Walk Sheet
</Button>
<Button
type="primary"
icon={<SaveOutlined />}
loading={saving}
onClick={() => form.submit()}
>
Save Settings
</Button>
</Space>
), [saving, form]);
useEffect(() => {
setPageHeader({ title: 'Map Settings', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
<Spin size="large" />
</div>
);
}
// QR codes from live form values
const qrCodes = [
{ url: watched?.qrCode1Url, label: watched?.qrCode1Label },
{ url: watched?.qrCode2Url, label: watched?.qrCode2Label },
{ url: watched?.qrCode3Url, label: watched?.qrCode3Label },
].filter((qr) => qr.url);
return (
<>
<style>{`
@media print {
body * { visibility: hidden !important; }
.walk-sheet-print, .walk-sheet-print * { visibility: visible !important; }
.walk-sheet-print {
position: fixed !important;
left: 0 !important;
top: 0 !important;
width: 8.5in !important;
height: 11in !important;
padding: 0.4in 0.5in !important;
background: white !important;
color: black !important;
box-sizing: border-box !important;
}
@page { size: letter; margin: 0; }
.walk-sheet-print img { print-color-adjust: exact; -webkit-print-color-adjust: exact; }
}
`}</style>
<Row gutter={24}>
{/* Left column: Settings form */}
<Col xs={24} lg={10}>
<Form form={form} onFinish={handleSave} layout="vertical">
<Card title="Map Center & Zoom" style={{ marginBottom: 24 }}>
<div style={{ marginBottom: 16 }}>
<AutoComplete
value={citySearch}
options={cityOptions}
onSearch={handleCitySearch}
onSelect={handleCitySelect as any}
placeholder="Search for a city to auto-fill coordinates..."
style={{ width: '100%' }}
suffixIcon={citySearching ? <Spin size="small" /> : <SearchOutlined />}
/>
<Text type="secondary" style={{ fontSize: 12, marginTop: 4, display: 'block' }}>
Search for a city to auto-fill coordinates. Fine-tune below.
</Text>
</div>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item name="latitude" label="Latitude" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} step={0.0001} min={-90} max={90} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item name="longitude" label="Longitude" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} step={0.0001} min={-180} max={180} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item name="zoom" label="Zoom Level">
<Slider min={2} max={19} />
</Form.Item>
</Col>
</Row>
<Text type="secondary">
Set the default center point and zoom level for the public map view.
</Text>
</Card>
<Card title="Walk Sheet Configuration" style={{ marginBottom: 24 }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Configure headers and footers for printed walk sheets. Changes preview live on the right.
</Text>
<Form.Item name="walkSheetTitle" label="Title">
<Input placeholder="Walk Sheet Title" />
</Form.Item>
<Form.Item name="walkSheetSubtitle" label="Subtitle">
<Input placeholder="Walk Sheet Subtitle" />
</Form.Item>
<Form.Item name="walkSheetFooter" label="Footer">
<Input.TextArea rows={2} placeholder="Footer text for walk sheets" />
</Form.Item>
<Divider orientation="left" plain>QR Codes</Divider>
{[1, 2, 3].map((i) => (
<Row gutter={12} key={i}>
<Col xs={24} sm={16}>
<Form.Item name={`qrCode${i}Url`} label={`QR Code ${i} URL`}>
<Input placeholder="https://..." />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item name={`qrCode${i}Label`} label="Label">
<Input placeholder={`QR ${i} Label`} />
</Form.Item>
</Col>
</Row>
))}
</Card>
</Form>
</Col>
{/* Right column: Live walk sheet preview */}
<Col xs={24} lg={14}>
<Card
title="Walk Sheet Preview"
extra={
<Button
size="small"
icon={<PrinterOutlined />}
onClick={() => window.print()}
>
Print
</Button>
}
style={{ marginBottom: 24 }}
styles={{ body: { padding: 0, maxHeight: 'calc(100vh - 200px)', overflow: 'auto' } }}
>
<div
ref={printRef}
className="walk-sheet-print"
style={{
background: '#fff',
color: '#000',
padding: 32,
maxWidth: '8.5in',
margin: '0 auto',
fontFamily: 'Arial, Helvetica, sans-serif',
fontSize: 11,
}}
>
{/* Header */}
<div style={{ borderBottom: '2px solid #000', paddingBottom: 6, marginBottom: 10 }}>
<div style={{ fontSize: 18, fontWeight: 700, textAlign: 'center' }}>
{watched?.walkSheetTitle || 'Walk Sheet'}
</div>
{watched?.walkSheetSubtitle && (
<div style={{ fontSize: 12, textAlign: 'center', color: '#333', marginTop: 2 }}>
{watched.walkSheetSubtitle}
</div>
)}
</div>
{/* QR Codes */}
{qrCodes.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginBottom: 10 }}>
{qrCodes.map((qr, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<img
src={`/api/qr?text=${encodeURIComponent(qr.url!)}&size=200`}
alt={qr.label || `QR Code ${i + 1}`}
style={{ width: 80, height: 80 }}
/>
{qr.label && (
<div style={{ fontSize: 9, marginTop: 2 }}>{qr.label}</div>
)}
</div>
))}
</div>
)}
{/* Contact entry blocks */}
{[1, 2].map((blockNum) => (
<div key={blockNum} style={{ marginBottom: 6 }}>
{blockNum > 1 && (
<div style={{ borderTop: '1px dashed #999', marginBottom: 8 }} />
)}
<FormRow left="First Name" right="Last Name" />
<FormRow left="Email" right="Phone" />
<FormRow left="Address" right="Unit Number" />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 6 }}>
<div>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Support Level</div>
<div style={{ display: 'flex', gap: 8 }}>
{[1, 2, 3, 4].map((n) => (
<span key={n} style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Circle /><span style={{ fontSize: 10 }}>{n}</span>
</span>
))}
</div>
</div>
<div>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Sign Request</div>
<div style={{ display: 'flex', gap: 8 }}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Circle /><span style={{ fontSize: 10 }}>Y</span>
</span>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Circle /><span style={{ fontSize: 10 }}>N</span>
</span>
</div>
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 6 }}>
<div>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Sign Size</div>
<div style={{ display: 'flex', gap: 8 }}>
{['R', 'L', 'U'].map((s) => (
<span key={s} style={{ display: 'inline-flex', alignItems: 'center', gap: 2 }}>
<Circle /><span style={{ fontSize: 10 }}>{s}</span>
</span>
))}
</div>
</div>
<div>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Visited Date</div>
<div style={{ borderBottom: '1px solid #999', height: 18 }} />
</div>
</div>
</div>
))}
{/* Notes */}
<div style={{ marginTop: 8 }}>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>Notes &amp; Comments</div>
<div
style={{
border: '1px solid #999',
borderRadius: 3,
minHeight: 80,
}}
/>
</div>
{/* Footer */}
{watched?.walkSheetFooter && (
<div style={{ marginTop: 10, textAlign: 'center', fontSize: 9, color: '#666' }}>
{watched.walkSheetFooter}
</div>
)}
</div>
</Card>
</Col>
</Row>
</>
);
}
function FormRow({ left, right }: { left: string; right: string }) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 6 }}>
<div>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>{left}</div>
<div style={{ borderBottom: '1px solid #999', height: 18 }} />
</div>
<div>
<div style={{ fontSize: 9, color: '#666', marginBottom: 3 }}>{right}</div>
<div style={{ borderBottom: '1px solid #999', height: 18 }} />
</div>
</div>
);
}
function Circle() {
return (
<div
style={{
width: 13,
height: 13,
borderRadius: '50%',
border: '1.5px solid #333',
flexShrink: 0,
display: 'inline-block',
}}
/>
);
}

File diff suppressed because it is too large Load Diff

123
admin/src/pages/N8nPage.tsx Normal file
View File

@ -0,0 +1,123 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, ApiOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
export default function N8nPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.n8n.online);
setConfig(configRes.data);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const serviceUrl = config
? buildServiceUrl(config.n8nSubdomain, config.domain, config.n8nPort)
: null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{serviceUrl && (
<Button
icon={<LinkOutlined />}
href={serviceUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, serviceUrl]);
useEffect(() => {
setPageHeader({ title: 'Workflow Automation', actions: headerActions, fullBleed: true });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="The workflow editor requires a desktop browser with a larger screen."
icon={<ApiOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="n8n Unavailable"
subTitle="n8n is not running or could not be reached. Check that the n8n container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
return (
<iframe
src={serviceUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="n8n Workflows"
/>
);
}

View File

@ -0,0 +1,123 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, DatabaseOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
export default function NocoDBPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.nocodb.online);
setConfig(configRes.data);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const serviceUrl = config
? buildServiceUrl(config.nocodbSubdomain, config.domain, config.nocodbPort)
: null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{serviceUrl && (
<Button
icon={<LinkOutlined />}
href={serviceUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, serviceUrl]);
useEffect(() => {
setPageHeader({ title: 'Database Browser', actions: headerActions, fullBleed: true });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="The database browser requires a desktop browser with a larger screen."
icon={<DatabaseOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="NocoDB Unavailable"
subTitle="NocoDB is not running or could not be reached. Check that the NocoDB container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
return (
<iframe
src={serviceUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="NocoDB"
/>
);
}

View File

@ -0,0 +1,236 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button, Switch, Space, Typography, message, Spin, Tag, Grid, Result, theme } from 'antd';
import { ArrowLeftOutlined, SaveOutlined, EyeOutlined } from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import { api } from '@/lib/api';
import GrapesJSEditor from '@/components/GrapesJSEditor';
import type { GrapesJSEditorHandle } from '@/components/GrapesJSEditor';
import type { LandingPage, PageBlock } from '@/types/api';
const { Text } = Typography;
export default function PageEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [page, setPage] = useState<LandingPage | null>(null);
const [blocks, setBlocks] = useState<PageBlock[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [codeContent, setCodeContent] = useState('');
const editorRef = useRef<GrapesJSEditorHandle>(null);
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const isCodeMode = page?.editorMode === 'CODE';
useEffect(() => {
const fetchData = async () => {
try {
if (isCodeMode) {
// CODE mode only needs page data
const pageRes = await api.get<LandingPage>(`/pages/${id}`);
setPage(pageRes.data);
setCodeContent(pageRes.data.htmlOutput || '');
} else {
const [pageRes, blocksRes] = await Promise.all([
api.get<LandingPage>(`/pages/${id}`),
api.get<PageBlock[]>('/page-blocks'),
]);
setPage(pageRes.data);
setBlocks(blocksRes.data);
setCodeContent(pageRes.data.htmlOutput || '');
}
} catch {
message.error('Failed to load page');
navigate('/app/pages');
} finally {
setLoading(false);
}
};
fetchData();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
const handleSaveVisual = useCallback(async (data: { projectData: Record<string, unknown>; html: string; css: string }) => {
if (!page) return;
setSaving(true);
try {
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
blocks: data.projectData,
htmlOutput: data.html,
cssOutput: data.css,
});
setPage(updated);
message.success('Page saved');
} catch {
message.error('Failed to save page');
} finally {
setSaving(false);
}
}, [page]);
const handleSaveCode = useCallback(async () => {
if (!page) return;
setSaving(true);
try {
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
htmlOutput: codeContent,
});
setPage(updated);
message.success('Page saved');
} catch {
message.error('Failed to save page');
} finally {
setSaving(false);
}
}, [page, codeContent]);
const handleTogglePublished = async () => {
if (!page) return;
try {
const { data: updated } = await api.put<LandingPage>(`/pages/${page.id}`, {
published: !page.published,
});
setPage(updated);
message.success(updated.published ? 'Page published' : 'Page unpublished');
} catch {
message.error('Failed to update page');
}
};
// Ctrl+S handler for CODE mode
useEffect(() => {
if (!isCodeMode) return;
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
handleSaveCode();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [isCodeMode, handleSaveCode]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!page) return null;
if (isMobile) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100vh', padding: 24, background: token.colorBgBase }}>
<Result
status="info"
title="Desktop Required"
subTitle={`The ${isCodeMode ? 'code' : 'page'} editor requires a desktop browser. Please switch to a larger screen to edit this page.`}
extra={
<Button type="primary" onClick={() => navigate('/app/pages')}>
Back to Pages
</Button>
}
/>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', background: token.colorBgBase }}>
{/* Toolbar */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '8px 16px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
background: token.colorBgBase,
zIndex: 10,
flexShrink: 0,
}}
>
<Space>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/pages')}
aria-label="Back to pages list"
style={{ color: '#fff' }}
/>
<Text strong style={{ color: '#fff', fontSize: 16 }}>{page.title}</Text>
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12 }}>/p/{page.slug}</Text>
<Tag color={isCodeMode ? 'blue' : 'green'}>{isCodeMode ? 'Code' : 'Visual'}</Tag>
</Space>
<Space>
<Space size={4}>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 13 }}>Published</Text>
<Switch
checked={page.published}
onChange={handleTogglePublished}
size="small"
/>
</Space>
{page.published && (
<Tag color="green">Live</Tag>
)}
{page.published && (
<Button
size="small"
icon={<EyeOutlined />}
onClick={() => window.open(`/p/${page.slug}`, '_blank')}
>
Preview
</Button>
)}
<Button
type="primary"
size="small"
icon={<SaveOutlined />}
loading={saving}
onClick={() => {
if (isCodeMode) {
handleSaveCode();
} else {
editorRef.current?.triggerSave();
}
}}
>
Save
</Button>
</Space>
</div>
{/* Editor — conditional on mode */}
{isCodeMode ? (
<div style={{ flex: 1, overflow: 'hidden' }}>
<Editor
height="100%"
defaultLanguage="html"
theme="vs-dark"
value={codeContent}
onChange={(value) => setCodeContent(value ?? '')}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
scrollBeyondLastLine: false,
automaticLayout: true,
}}
/>
</div>
) : (
<GrapesJSEditor
ref={editorRef}
initialData={page.blocks as Record<string, unknown>}
onSave={handleSaveVisual}
customBlocks={blocks}
/>
)}
</div>
);
}

View File

@ -0,0 +1,250 @@
import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, Popconfirm, App,
} from 'antd';
import {
CloudServerOutlined, SyncOutlined, DeleteOutlined, CheckCircleOutlined, CloseCircleOutlined,
RocketOutlined, CopyOutlined,
} from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { PangolinStatus, PangolinConfig, PangolinResource } from '@/types/api';
const { Text, Paragraph } = Typography;
export default function PangolinPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const [status, setStatus] = useState<PangolinStatus | null>(null);
const [config, setConfig] = useState<PangolinConfig | null>(null);
const [resources, setResources] = useState<PangolinResource[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(false);
const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);
useEffect(() => {
setPageHeader({ title: 'Tunnel Management' });
return () => setPageHeader(null);
}, [setPageHeader]);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const [statusRes, configRes] = await Promise.all([
api.get<PangolinStatus>('/api/pangolin/status'),
api.get<PangolinConfig>('/api/pangolin/config'),
]);
setStatus(statusRes.data);
setConfig(configRes.data);
if (statusRes.data.configured) {
try {
const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/api/pangolin/resources');
setResources(resourcesRes.data.resources);
} catch {
// Resources may not load if site isn't set up
}
}
} catch {
message.error('Failed to load Pangolin status');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => { fetchData(); }, [fetchData]);
const handleSetup = async (values: { siteName?: string }) => {
setActionLoading(true);
try {
const res = await api.post('/api/pangolin/setup', { siteName: values.siteName });
setSetupResult(res.data);
message.success('Setup complete! See credentials below.');
fetchData();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Setup failed';
message.error(msg);
} finally {
setActionLoading(false);
}
};
const handleSync = async () => {
setActionLoading(true);
try {
const res = await api.post<{ created: number; skipped: number; errors: number }>('/api/pangolin/sync');
message.success(`Sync complete: ${res.data.created} created, ${res.data.skipped} skipped`);
fetchData();
} catch (err: unknown) {
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Sync failed';
message.error(msg);
} finally {
setActionLoading(false);
}
};
const handleDeleteResource = async (resourceId: string) => {
try {
await api.delete(`/api/pangolin/resource/${resourceId}`);
message.success('Resource deleted');
fetchData();
} catch {
message.error('Failed to delete resource');
}
};
if (loading) {
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
}
const isConfigured = status?.configured;
const isHealthy = status?.healthy;
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
{/* Status Card */}
<Card title={<><CloudServerOutlined /> Pangolin Tunnel Status</>}>
<Descriptions column={{ xs: 1, sm: 2 }} bordered size="small">
<Descriptions.Item label="Configured">
{isConfigured
? <Tag icon={<CheckCircleOutlined />} color="success">Yes</Tag>
: <Tag icon={<CloseCircleOutlined />} color="error">No</Tag>}
</Descriptions.Item>
<Descriptions.Item label="Server Health">
{isHealthy
? <Tag icon={<CheckCircleOutlined />} color="success">Healthy</Tag>
: <Tag icon={<CloseCircleOutlined />} color="error">Unreachable</Tag>}
</Descriptions.Item>
<Descriptions.Item label="API URL">
<Text copyable>{config?.pangolinApiUrl || 'Not set'}</Text>
</Descriptions.Item>
<Descriptions.Item label="Newt">
{status?.newtConfigured
? <Tag color="success">Configured</Tag>
: <Tag color="warning">Not configured</Tag>}
</Descriptions.Item>
<Descriptions.Item label="Organization ID">
<Text code>{config?.orgId || 'Not set'}</Text>
</Descriptions.Item>
<Descriptions.Item label="Site ID">
<Text code>{config?.siteId || 'Not set'}</Text>
</Descriptions.Item>
</Descriptions>
</Card>
{/* Setup Wizard — shown when not configured or no site */}
{(!isConfigured || !config?.siteId) && (
<Card title={<><RocketOutlined /> Initial Setup</>}>
{!isConfigured ? (
<Alert
type="info"
showIcon
message="Pangolin Not Configured"
description="Set PANGOLIN_API_URL, PANGOLIN_API_KEY, and PANGOLIN_ORG_ID in your .env file, then restart the API container."
style={{ marginBottom: 16 }}
/>
) : (
<Form layout="inline" onFinish={handleSetup}>
<Form.Item name="siteName" label="Site Name">
<Input placeholder={`changemaker-${config?.domain || 'cmlite.org'}`} style={{ width: 300 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={actionLoading} icon={<RocketOutlined />}>
Create Site + Resources
</Button>
</Form.Item>
</Form>
)}
{setupResult && (
<Alert
type="success"
showIcon
closable
onClose={() => setSetupResult(null)}
message="Setup Complete"
description={
<div>
<Paragraph>Add these to your <Text code>.env</Text> file:</Paragraph>
<pre style={{ background: 'rgba(0,0,0,0.2)', padding: 12, borderRadius: 6, fontSize: 12, overflow: 'auto' }}>
{((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1).join('\n')}
</pre>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => {
const text = ((setupResult as Record<string, unknown>).instructions as string[] || []).slice(1).join('\n');
navigator.clipboard.writeText(text);
message.success('Copied to clipboard');
}}
>
Copy
</Button>
</div>
}
style={{ marginTop: 16 }}
/>
)}
</Card>
)}
{/* Resource Management — shown when configured */}
{isConfigured && config?.siteId && (
<Card
title="Tunnel Resources"
extra={
<Button icon={<SyncOutlined />} loading={actionLoading} onClick={handleSync}>
Sync Resources
</Button>
}
>
<Table
dataSource={resources}
rowKey="resourceId"
size="small"
pagination={false}
columns={[
{
title: 'Name',
dataIndex: 'name',
key: 'name',
},
{
title: 'Domain',
key: 'domain',
render: (_, r: PangolinResource) => (
<Text copyable>{r.fullDomain || r.subdomain || '(root)'}</Text>
),
},
{
title: 'SSL',
dataIndex: 'ssl',
key: 'ssl',
render: (ssl: boolean) => ssl ? <Tag color="green">Yes</Tag> : <Tag>No</Tag>,
},
{
title: 'Active',
dataIndex: 'active',
key: 'active',
render: (active: boolean) => active !== false
? <Tag color="success">Active</Tag>
: <Tag color="error">Inactive</Tag>,
},
{
title: 'Actions',
key: 'actions',
render: (_, r: PangolinResource) => (
<Popconfirm title="Delete this resource?" onConfirm={() => handleDeleteResource(r.resourceId)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
]}
/>
</Card>
)}
</Space>
);
}

View File

@ -0,0 +1,386 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Table,
Button,
Input,
Tag,
Space,
Modal,
Popconfirm,
message,
Row,
Col,
Card,
Statistic,
Descriptions,
} from 'antd';
import {
SearchOutlined,
DeleteOutlined,
EyeOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
Representative,
RepresentativesListResponse,
RepresentativesListParams,
RepresentativeLookupResponse,
CacheStats,
} from '@/types/api';
export default function RepresentativesPage() {
const [representatives, setRepresentatives] = useState<Representative[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [postalCodeFilter, setPostalCodeFilter] = useState('');
const [debouncedPostalCode, setDebouncedPostalCode] = useState('');
const postalTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [lookupInput, setLookupInput] = useState('');
const [lookupLoading, setLookupLoading] = useState(false);
const [stats, setStats] = useState<CacheStats | null>(null);
const [detailModalOpen, setDetailModalOpen] = useState(false);
const [selectedRep, setSelectedRep] = useState<Representative | null>(null);
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
const handlePostalCodeFilterChange = (value: string) => {
setPostalCodeFilter(value);
clearTimeout(postalTimerRef.current);
postalTimerRef.current = setTimeout(() => setDebouncedPostalCode(value), 300);
};
useEffect(() => {
return () => {
clearTimeout(searchTimerRef.current);
clearTimeout(postalTimerRef.current);
};
}, []);
const fetchRepresentatives = useCallback(async (params?: RepresentativesListParams) => {
setLoading(true);
try {
const { data } = await api.get<RepresentativesListResponse>('/representatives', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
postalCode: params?.postalCode ?? (debouncedPostalCode || undefined),
},
});
setRepresentatives(data.representatives);
setPagination(data.pagination);
} catch {
message.error('Failed to load representatives');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, debouncedPostalCode]);
const fetchStats = useCallback(async () => {
try {
const { data } = await api.get<CacheStats>('/representatives/cache-stats');
setStats(data);
} catch {
// Stats are non-critical
}
}, []);
useEffect(() => {
fetchRepresentatives({ page: 1 });
}, [debouncedSearch, debouncedPostalCode]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
fetchStats();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchRepresentatives({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleLookup = useCallback(async () => {
const code = lookupInput.replace(/\s/g, '').toUpperCase();
if (!code) return;
setLookupLoading(true);
try {
const { data } = await api.get<RepresentativeLookupResponse>(
`/representatives/by-postal/${encodeURIComponent(code)}`,
{ params: { refresh: 'true' } }
);
message.success(
`Found ${data.representatives.length} representative(s) for ${code} (source: ${data.source})`
);
// Refresh table and stats
fetchRepresentatives({ page: 1 });
fetchStats();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Lookup failed';
message.error(msg);
} finally {
setLookupLoading(false);
}
}, [lookupInput, fetchRepresentatives, fetchStats]);
const handleDelete = async (id: string) => {
try {
await api.delete(`/representatives/${id}`);
message.success('Representative removed from cache');
fetchRepresentatives();
fetchStats();
} catch {
message.error('Failed to delete representative');
}
};
const handleClearPostalCode = async (postalCode: string) => {
try {
const { data } = await api.delete<{ deleted: number; postalCode: string }>(
`/representatives/by-postal/${encodeURIComponent(postalCode)}`
);
message.success(`Cleared ${data.deleted} cached rep(s) for ${postalCode}`);
fetchRepresentatives();
fetchStats();
} catch {
message.error('Failed to clear cache');
}
};
const openDetail = (rep: Representative) => {
setSelectedRep(rep);
setDetailModalOpen(true);
};
const columns: ColumnsType<Representative> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name: string | null) => name || '--',
},
{
title: 'Postal Code',
dataIndex: 'postalCode',
key: 'postalCode',
render: (code: string) => <Tag color="blue">{code}</Tag>,
},
{
title: 'Elected Office',
dataIndex: 'electedOffice',
key: 'electedOffice',
render: (office: string | null) => office || '--',
responsive: ['md'],
},
{
title: 'Party',
dataIndex: 'partyName',
key: 'partyName',
render: (party: string | null) => party || '--',
responsive: ['lg'],
},
{
title: 'District',
dataIndex: 'districtName',
key: 'districtName',
render: (district: string | null) => district || '--',
responsive: ['lg'],
},
{
title: 'Cached',
dataIndex: 'cachedAt',
key: 'cachedAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: Representative) => (
<Space>
<Button
type="link"
size="small"
icon={<EyeOutlined />}
onClick={() => openDetail(record)}
/>
{record.id && (
<Popconfirm
title="Remove from cache?"
onConfirm={() => handleDelete(record.id!)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
)}
</Space>
),
},
];
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Space>
<Input
placeholder="Postal code (e.g. T2P1N3)"
value={lookupInput}
onChange={(e) => setLookupInput(e.target.value)}
onPressEnter={handleLookup}
style={{ width: 180 }}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={lookupLoading}
onClick={handleLookup}
>
Lookup / Refresh
</Button>
</Space>
), [lookupInput, lookupLoading, handleLookup]);
useEffect(() => {
setPageHeader({ title: 'Representatives Cache', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
return (
<>
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic title="Cached Representatives" value={stats.totalRepresentatives} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic title="Postal Codes with Reps" value={stats.postalCodesWithRepresentatives} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic title="Postal Codes with Geo Data" value={stats.totalPostalCodes} />
</Card>
</Col>
</Row>
)}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search name, email, or district"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Input
placeholder="Filter by postal code"
value={postalCodeFilter}
onChange={(e) => handlePostalCodeFilterChange(e.target.value)}
allowClear
/>
</Col>
</Row>
<Table<Representative>
columns={columns}
dataSource={representatives}
rowKey={(r) => r.id || `${r.postalCode}-${r.name}-${r.electedOffice}`}
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} representatives`,
}}
onChange={handleTableChange}
/>
{/* Detail Modal */}
<Modal
title="Representative Details"
open={detailModalOpen}
onCancel={() => {
setDetailModalOpen(false);
setSelectedRep(null);
}}
footer={
selectedRep?.postalCode ? (
<Space>
<Popconfirm
title={`Clear all cached reps for ${selectedRep.postalCode}?`}
onConfirm={() => {
handleClearPostalCode(selectedRep.postalCode);
setDetailModalOpen(false);
setSelectedRep(null);
}}
>
<Button danger>Clear Postal Code Cache</Button>
</Popconfirm>
<Button onClick={() => { setDetailModalOpen(false); setSelectedRep(null); }}>
Close
</Button>
</Space>
) : null
}
width={640}
>
{selectedRep && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Name">{selectedRep.name || '--'}</Descriptions.Item>
<Descriptions.Item label="Email">{selectedRep.email || '--'}</Descriptions.Item>
<Descriptions.Item label="Postal Code">{selectedRep.postalCode}</Descriptions.Item>
<Descriptions.Item label="Elected Office">{selectedRep.electedOffice || '--'}</Descriptions.Item>
<Descriptions.Item label="Party">{selectedRep.partyName || '--'}</Descriptions.Item>
<Descriptions.Item label="District">{selectedRep.districtName || '--'}</Descriptions.Item>
<Descriptions.Item label="Representative Set">{selectedRep.representativeSetName || '--'}</Descriptions.Item>
<Descriptions.Item label="URL">
{selectedRep.url ? (
<a href={selectedRep.url} target="_blank" rel="noopener noreferrer">
{selectedRep.url}
</a>
) : '--'}
</Descriptions.Item>
<Descriptions.Item label="Photo URL">
{selectedRep.photoUrl ? (
<a href={selectedRep.photoUrl} target="_blank" rel="noopener noreferrer">
{selectedRep.photoUrl}
</a>
) : '--'}
</Descriptions.Item>
<Descriptions.Item label="Offices">
{selectedRep.offices && Array.isArray(selectedRep.offices) && selectedRep.offices.length > 0
? (
<pre style={{ margin: 0, fontSize: 12, whiteSpace: 'pre-wrap' }}>
{JSON.stringify(selectedRep.offices, null, 2)}
</pre>
)
: '--'}
</Descriptions.Item>
<Descriptions.Item label="Cached At">
{dayjs(selectedRep.cachedAt).format('YYYY-MM-DD HH:mm:ss')}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</>
);
}

View File

@ -0,0 +1,400 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import {
Table,
Tag,
Button,
Space,
Input,
Select,
Popconfirm,
message,
Drawer,
Descriptions,
Row,
Col,
} from 'antd';
import {
CheckCircleOutlined,
CloseCircleOutlined,
DeleteOutlined,
MailOutlined,
SafetyCertificateOutlined,
SearchOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
RepresentativeResponse,
ResponsesListResponse,
ResponseStatus,
CampaignsListResponse,
Campaign,
PaginationMeta,
} from '@/types/api';
import {
RESPONSE_TYPE_LABELS,
RESPONSE_STATUS_COLORS,
GOVERNMENT_LEVEL_COLORS,
GOVERNMENT_LEVEL_LABELS,
} from '@/types/api';
import dayjs from 'dayjs';
export default function ResponsesPage() {
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<ResponseStatus | undefined>();
const [campaignFilter, setCampaignFilter] = useState<string | undefined>();
const [search, setSearch] = useState('');
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [detailResponse, setDetailResponse] = useState<RepresentativeResponse | null>(null);
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const fetchResponses = useCallback(async (page = 1) => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: 20 };
if (statusFilter) params.status = statusFilter;
if (campaignFilter) params.campaignId = campaignFilter;
if (search) params.search = search;
const { data } = await api.get<ResponsesListResponse>('/responses', { params });
setResponses(data.responses);
setPagination(data.pagination);
} catch {
message.error('Failed to load responses');
} finally {
setLoading(false);
}
}, [statusFilter, campaignFilter, search]);
const fetchCampaigns = async () => {
try {
const { data } = await api.get<CampaignsListResponse>('/campaigns', { params: { limit: 100 } });
setCampaigns(data.campaigns);
} catch {
// Non-critical
}
};
useEffect(() => {
fetchCampaigns();
}, []);
useEffect(() => {
fetchResponses(1);
}, [fetchResponses]);
const handleSearch = (value: string) => {
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => {
setSearch(value);
}, 400);
};
const handleApprove = async (id: string) => {
setActionLoading(id);
try {
await api.patch(`/responses/${id}/status`, { status: 'APPROVED' });
message.success('Response approved');
fetchResponses(pagination.page);
} catch {
message.error('Failed to approve response');
} finally {
setActionLoading(null);
}
};
const handleReject = async (id: string) => {
setActionLoading(id);
try {
await api.patch(`/responses/${id}/status`, { status: 'REJECTED' });
message.success('Response rejected');
fetchResponses(pagination.page);
} catch {
message.error('Failed to reject response');
} finally {
setActionLoading(null);
}
};
const handleResendVerification = async (id: string) => {
setActionLoading(id);
try {
await api.post(`/responses/${id}/resend-verification`);
message.success('Verification email sent');
} catch {
message.error('Failed to send verification email');
} finally {
setActionLoading(null);
}
};
const handleDelete = async (id: string) => {
setActionLoading(id);
try {
await api.delete(`/responses/${id}`);
message.success('Response deleted');
fetchResponses(pagination.page);
} catch {
message.error('Failed to delete response');
} finally {
setActionLoading(null);
}
};
const columns: ColumnsType<RepresentativeResponse> = [
{
title: 'Representative',
dataIndex: 'representativeName',
key: 'representativeName',
ellipsis: true,
},
{
title: 'Level',
dataIndex: 'representativeLevel',
key: 'level',
width: 120,
render: (level) => (
<Tag color={GOVERNMENT_LEVEL_COLORS[level as keyof typeof GOVERNMENT_LEVEL_COLORS]}>
{GOVERNMENT_LEVEL_LABELS[level as keyof typeof GOVERNMENT_LEVEL_LABELS]}
</Tag>
),
},
{
title: 'Type',
dataIndex: 'responseType',
key: 'type',
width: 110,
render: (type) => <Tag>{RESPONSE_TYPE_LABELS[type as keyof typeof RESPONSE_TYPE_LABELS]}</Tag>,
},
{
title: 'Campaign',
key: 'campaign',
width: 160,
ellipsis: true,
render: (_, record) => record.campaign?.title || '—',
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status) => (
<Tag color={RESPONSE_STATUS_COLORS[status as keyof typeof RESPONSE_STATUS_COLORS]}>
{status}
</Tag>
),
},
{
title: 'Verified',
key: 'verified',
width: 80,
align: 'center',
responsive: ['md'] as any,
render: (_, record) =>
record.isVerified ? (
<SafetyCertificateOutlined style={{ color: '#16a34a', fontSize: 16 }} />
) : null,
},
{
title: 'Upvotes',
dataIndex: 'upvoteCount',
key: 'upvotes',
width: 80,
align: 'center',
responsive: ['md'] as any,
},
{
title: 'Submitted',
dataIndex: 'createdAt',
key: 'createdAt',
width: 120,
responsive: ['sm'] as any,
render: (date) => dayjs(date).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 200,
render: (_, record) => (
<Space size="small" wrap>
{record.status !== 'APPROVED' && (
<Button
size="small"
type="link"
icon={<CheckCircleOutlined />}
style={{ color: '#16a34a' }}
loading={actionLoading === record.id}
onClick={() => handleApprove(record.id)}
>
Approve
</Button>
)}
{record.status !== 'REJECTED' && (
<Button
size="small"
type="link"
icon={<CloseCircleOutlined />}
danger
loading={actionLoading === record.id}
onClick={() => handleReject(record.id)}
>
Reject
</Button>
)}
{record.representativeEmail && (
<Button
size="small"
type="link"
icon={<MailOutlined />}
loading={actionLoading === record.id}
onClick={() => handleResendVerification(record.id)}
>
Verify
</Button>
)}
<Popconfirm title="Delete this response?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" type="link" icon={<DeleteOutlined />} danger loading={actionLoading === record.id} />
</Popconfirm>
</Space>
),
},
];
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Button icon={<ReloadOutlined />} onClick={() => fetchResponses(pagination.page)}>
Refresh
</Button>
), [fetchResponses, pagination.page]);
useEffect(() => {
setPageHeader({ title: 'Responses', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
return (
<>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={8}>
<Input
placeholder="Search responses..."
prefix={<SearchOutlined />}
onChange={(e) => handleSearch(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={5}>
<Select
placeholder="Status"
allowClear
style={{ width: '100%' }}
value={statusFilter}
onChange={(v) => setStatusFilter(v)}
options={[
{ value: 'PENDING', label: 'Pending' },
{ value: 'APPROVED', label: 'Approved' },
{ value: 'REJECTED', label: 'Rejected' },
]}
/>
</Col>
<Col xs={12} sm={7}>
<Select
placeholder="Campaign"
allowClear
style={{ width: '100%' }}
value={campaignFilter}
onChange={(v) => setCampaignFilter(v)}
options={campaigns.map((c) => ({ value: c.id, label: c.title }))}
showSearch
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
/>
</Col>
</Row>
<Table
columns={columns}
dataSource={responses}
rowKey="id"
loading={loading}
scroll={{ x: 900 }}
onRow={(record) => ({
onClick: () => setDetailResponse(record),
style: { cursor: 'pointer' },
})}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: false,
onChange: (page) => fetchResponses(page),
}}
/>
<Drawer
title="Response Details"
open={!!detailResponse}
onClose={() => setDetailResponse(null)}
width={520}
destroyOnClose
>
{detailResponse && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="Representative">
{detailResponse.representativeName}
{detailResponse.representativeTitle && `${detailResponse.representativeTitle}`}
</Descriptions.Item>
<Descriptions.Item label="Level">
<Tag color={GOVERNMENT_LEVEL_COLORS[detailResponse.representativeLevel]}>
{GOVERNMENT_LEVEL_LABELS[detailResponse.representativeLevel]}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Type">
<Tag>{RESPONSE_TYPE_LABELS[detailResponse.responseType]}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Campaign">{detailResponse.campaign?.title || '—'}</Descriptions.Item>
<Descriptions.Item label="Status">
<Tag color={RESPONSE_STATUS_COLORS[detailResponse.status]}>{detailResponse.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Verified">
{detailResponse.isVerified ? (
<Space>
<SafetyCertificateOutlined style={{ color: '#16a34a' }} />
{detailResponse.verifiedBy && `by ${detailResponse.verifiedBy}`}
{detailResponse.verifiedAt && ` on ${dayjs(detailResponse.verifiedAt).format('MMM D, YYYY')}`}
</Space>
) : 'No'}
</Descriptions.Item>
<Descriptions.Item label="Upvotes">{detailResponse.upvoteCount}</Descriptions.Item>
<Descriptions.Item label="Response Text">
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 300, overflow: 'auto' }}>
{detailResponse.responseText}
</div>
</Descriptions.Item>
{detailResponse.userComment && (
<Descriptions.Item label="User Comment">
<div style={{ whiteSpace: 'pre-wrap' }}>{detailResponse.userComment}</div>
</Descriptions.Item>
)}
<Descriptions.Item label="Submitted By">
{detailResponse.isAnonymous
? 'Anonymous'
: `${detailResponse.submittedByName || '—'} (${detailResponse.submittedByEmail || '—'})`}
</Descriptions.Item>
<Descriptions.Item label="Submitted">
{dayjs(detailResponse.createdAt).format('MMM D, YYYY h:mm A')}
</Descriptions.Item>
</Descriptions>
)}
</Drawer>
</>
);
}

View File

@ -0,0 +1,419 @@
import { useEffect, useState } from 'react';
import { useOutletContext } from 'react-router-dom';
import {
Typography,
Tabs,
Form,
Input,
InputNumber,
Switch,
Button,
ColorPicker,
Alert,
Divider,
Space,
Segmented,
Tag,
message,
Spin,
} from 'antd';
import {
SettingOutlined,
SaveOutlined,
ThunderboltOutlined,
SendOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { SmtpTestResult, SmtpSendTestResult } from '@/types/api';
const { Text } = Typography;
export default function SettingsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { settings, loading, fetchAdminSettings, updateSettings } = useSettingsStore();
const [form] = Form.useForm();
const [testingConnection, setTestingConnection] = useState(false);
const [connectionResult, setConnectionResult] = useState<SmtpTestResult | null>(null);
const [sendingTest, setSendingTest] = useState(false);
const [sendResult, setSendResult] = useState<SmtpSendTestResult | null>(null);
useEffect(() => {
setPageHeader({ title: 'Settings' });
return () => setPageHeader(null);
}, [setPageHeader]);
useEffect(() => {
fetchAdminSettings();
}, [fetchAdminSettings]);
useEffect(() => {
if (settings) {
form.setFieldsValue(settings);
}
}, [settings, form]);
const handleSave = async () => {
try {
const values = form.getFieldsValue();
// Convert ColorPicker values to hex strings
const colorFields = [
'adminColorPrimary',
'adminColorBgBase',
'publicColorPrimary',
'publicColorBgBase',
'publicColorBgContainer',
] as const;
for (const field of colorFields) {
const val = values[field];
if (val && typeof val === 'object' && 'toHexString' in val) {
values[field] = val.toHexString();
}
}
await updateSettings(values);
setConnectionResult(null);
setSendResult(null);
message.success('Settings saved successfully');
} catch {
message.error('Failed to save settings');
}
};
const handleTestConnection = async () => {
setTestingConnection(true);
setConnectionResult(null);
try {
const { data } = await api.post<SmtpTestResult>('/settings/email/test-connection');
setConnectionResult(data);
} catch {
setConnectionResult({ success: false, message: 'Request failed' });
} finally {
setTestingConnection(false);
}
};
const handleSendTest = async () => {
setSendingTest(true);
setSendResult(null);
try {
const to = form.getFieldValue('testEmailRecipient');
const { data } = await api.post<SmtpSendTestResult>('/settings/email/test-send', { to });
setSendResult(data);
} catch {
setSendResult({ success: false, testMode: false, recipient: '' });
} finally {
setSendingTest(false);
}
};
const handleProviderToggle = async (value: string | number) => {
const provider = value as 'mailhog' | 'production';
try {
await updateSettings({ smtpActiveProvider: provider });
form.setFieldsValue({ smtpActiveProvider: provider });
message.success(`Switched to ${provider === 'mailhog' ? 'MailHog' : 'Production'} SMTP`);
setConnectionResult(null);
setSendResult(null);
} catch {
message.error('Failed to switch SMTP provider');
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
const items = [
{
key: 'organization',
label: 'Organization',
icon: <SettingOutlined />,
children: (
<div style={{ maxWidth: 600 }}>
<Form.Item label="Organization Name" name="organizationName">
<Input placeholder="Changemaker Lite" />
</Form.Item>
<Form.Item label="Short Name" name="organizationShortName" extra="Shown when sidebar is collapsed (max 10 chars)">
<Input placeholder="CML" maxLength={10} />
</Form.Item>
<Form.Item label="Logo URL" name="organizationLogoUrl">
<Input placeholder="https://example.com/logo.png" />
</Form.Item>
<Form.Item label="Favicon URL" name="organizationFaviconUrl">
<Input placeholder="https://example.com/favicon.ico" />
</Form.Item>
<Form.Item label="Footer Text" name="footerText">
<Input placeholder="Powered by Changemaker Lite" />
</Form.Item>
<Form.Item label="Login Subtitle" name="loginSubtitle" extra="Shown below the organization name on the login page">
<Input placeholder="Admin" />
</Form.Item>
</div>
),
},
{
key: 'theme',
label: 'Theme Colors',
children: (
<div style={{ maxWidth: 600 }}>
<Text strong style={{ fontSize: 15 }}>Admin Theme</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="Primary Color" name="adminColorPrimary">
<ColorPicker format="hex" showText />
</Form.Item>
<Form.Item label="Background Color" name="adminColorBgBase">
<ColorPicker format="hex" showText />
</Form.Item>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Public Theme</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="Primary Color" name="publicColorPrimary">
<ColorPicker format="hex" showText />
</Form.Item>
<Form.Item label="Background Color" name="publicColorBgBase">
<ColorPicker format="hex" showText />
</Form.Item>
<Form.Item label="Container Color" name="publicColorBgContainer">
<ColorPicker format="hex" showText />
</Form.Item>
<Form.Item label="Header Gradient" name="publicHeaderGradient" extra="CSS gradient string, e.g. linear-gradient(135deg, #005a9c 0%, #007acc 100%)">
<Input placeholder="linear-gradient(135deg, #005a9c 0%, #007acc 100%)" />
</Form.Item>
</div>
{settings && (
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Preview</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Swatch label="Admin Primary" color={settings.adminColorPrimary} />
<Swatch label="Admin BG" color={settings.adminColorBgBase} />
<Swatch label="Public Primary" color={settings.publicColorPrimary} />
<Swatch label="Public BG" color={settings.publicColorBgBase} />
<Swatch label="Public Container" color={settings.publicColorBgContainer} />
</div>
<div
style={{
marginTop: 12,
padding: '12px 24px',
background: settings.publicHeaderGradient,
borderRadius: 8,
color: '#fff',
fontWeight: 600,
}}
>
Header Gradient Preview
</div>
</div>
)}
</div>
),
},
{
key: 'email',
label: 'Email',
children: (
<div style={{ maxWidth: 600 }}>
{/* Sender */}
<Text strong style={{ fontSize: 15 }}>Sender</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="From Name" name="emailFromName" extra="The sender name used in outgoing emails">
<Input placeholder="Changemaker Lite" />
</Form.Item>
<Form.Item label="From Address" name="smtpFromAddress" extra="The sender email address (leave empty to use env default)">
<Input placeholder="noreply@cmlite.org" />
</Form.Item>
{/* Active SMTP Provider */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Active SMTP Provider</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ marginBottom: 16 }}>
<Segmented
value={settings?.smtpActiveProvider || 'mailhog'}
options={[
{ label: 'MailHog', value: 'mailhog' },
{ label: 'Production', value: 'production' },
]}
onChange={handleProviderToggle}
size="large"
/>
<Tag
color="green"
style={{ marginLeft: 12, verticalAlign: 'middle' }}
>
{(settings?.smtpActiveProvider || 'mailhog') === 'mailhog' ? 'MailHog Active' : 'Production Active'}
</Tag>
</div>
</div>
{/* MailHog Info */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>MailHog (Testing)</Text>
<Divider style={{ margin: '12px 0' }} />
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.04)', borderRadius: 8, border: '1px solid rgba(255,255,255,0.08)' }}>
<div><Text type="secondary">Host:</Text> <Text code>mailhog-changemaker</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Port:</Text> <Text code>1025</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Auth:</Text> <Text type="secondary">None required</Text></div>
<div style={{ marginTop: 4 }}><Text type="secondary">Web UI:</Text> <Text code>http://localhost:8025</Text></div>
</div>
</div>
{/* Production SMTP */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Production SMTP</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item label="SMTP Host" name="smtpHost" extra="Production mail server hostname">
<Input placeholder="smtp.protonmail.ch" />
</Form.Item>
<Form.Item label="SMTP Port" name="smtpPort" extra="Common ports: 587 (STARTTLS), 465 (SSL)">
<InputNumber min={0} max={65535} style={{ width: '100%' }} placeholder="587" />
</Form.Item>
<Form.Item label="SMTP User" name="smtpUser">
<Input placeholder="user@example.com" />
</Form.Item>
<Form.Item label="SMTP Password" name="smtpPass">
<Input.Password placeholder="SMTP password or app-specific password" />
</Form.Item>
</div>
{/* Test Mode */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Test Mode</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item
label="Enable Test Mode"
name="emailTestMode"
valuePropName="checked"
extra="When enabled, all emails are redirected to the test recipient"
>
<Switch />
</Form.Item>
<Form.Item label="Test Recipient" name="testEmailRecipient" extra="All emails will be sent to this address when test mode is on">
<Input placeholder="admin@cmlite.org" />
</Form.Item>
</div>
{/* Test Actions */}
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 15 }}>Test Actions</Text>
<Divider style={{ margin: '12px 0' }} />
<Alert
type="info"
message="Tests run against the active provider. Save production credentials before switching."
showIcon
style={{ marginBottom: 16 }}
/>
<Space>
<Button
icon={<ThunderboltOutlined />}
loading={testingConnection}
onClick={handleTestConnection}
>
Test Connection
</Button>
<Button
icon={<SendOutlined />}
loading={sendingTest}
onClick={handleSendTest}
>
Send Test Email
</Button>
</Space>
{connectionResult && (
<Alert
type={connectionResult.success ? 'success' : 'error'}
message={connectionResult.message}
icon={connectionResult.success ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
showIcon
style={{ marginTop: 12 }}
/>
)}
{sendResult && (
<Alert
type={sendResult.success ? 'success' : 'error'}
message={
sendResult.success
? `Test email sent to ${sendResult.recipient}${sendResult.testMode ? ' (test mode)' : ''}`
: 'Failed to send test email'
}
description={sendResult.messageId ? `Message ID: ${sendResult.messageId}` : undefined}
showIcon
style={{ marginTop: 12 }}
/>
)}
</div>
</div>
),
},
{
key: 'features',
label: 'Feature Toggles',
children: (
<div style={{ maxWidth: 600 }}>
<Alert
type="info"
message="Disabling a module hides it from navigation but does not delete data."
showIcon
style={{ marginBottom: 24 }}
/>
<Form.Item label="Enable Influence" name="enableInfluence" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Enable Map" name="enableMap" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Enable Newsletter" name="enableNewsletter" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label="Enable Landing Pages" name="enableLandingPages" valuePropName="checked">
<Switch />
</Form.Item>
</div>
),
},
];
return (
<Form form={form} layout="vertical">
<Tabs items={items} />
<div style={{ marginTop: 24 }}>
<Button type="primary" icon={<SaveOutlined />} size="large" onClick={handleSave}>
Save Settings
</Button>
</div>
</Form>
);
}
function Swatch({ label, color }: { label: string; color: string }) {
return (
<div style={{ textAlign: 'center' }}>
<div
style={{
width: 48,
height: 48,
borderRadius: 8,
background: color,
border: '2px solid rgba(255,255,255,0.2)',
marginBottom: 4,
}}
/>
<Text style={{ fontSize: 11 }}>{label}</Text>
</div>
);
}

View File

@ -0,0 +1,756 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Form,
Switch,
Popconfirm,
message,
Typography,
Row,
Col,
Progress,
DatePicker,
TimePicker,
InputNumber,
Drawer,
Card,
Statistic,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
CalendarOutlined,
MailOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
StopOutlined,
TeamOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type {
Shift,
ShiftSignup,
ShiftsListResponse,
ShiftsListParams,
ShiftStats,
ShiftStatus,
Cut,
} from '@/types/api';
import { SHIFT_STATUS_COLORS, SHIFT_STATUS_LABELS, SIGNUP_SOURCE_COLORS } from '@/types/api';
const { Text } = Typography;
const statusOptions = Object.entries(SHIFT_STATUS_LABELS).map(([value, label]) => ({
value,
label,
}));
export default function ShiftsPage() {
const [shifts, setShifts] = useState<Shift[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [statusFilter, setStatusFilter] = useState<ShiftStatus | undefined>();
const [stats, setStats] = useState<ShiftStats | null>(null);
// Create modal
const [createModalOpen, setCreateModalOpen] = useState(false);
const [createForm] = Form.useForm();
// Edit drawer
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [editingShift, setEditingShift] = useState<Shift | null>(null);
const [editForm] = Form.useForm();
// Signups drawer
const [signupsDrawerOpen, setSignupsDrawerOpen] = useState(false);
const [signupsShift, setSignupsShift] = useState<Shift | null>(null);
const [signups, setSignups] = useState<ShiftSignup[]>([]);
const [signupsLoading, setSignupsLoading] = useState(false);
const [addEmail, setAddEmail] = useState('');
const [addName, setAddName] = useState('');
// Cuts for area dropdown
const [cuts, setCuts] = useState<Cut[]>([]);
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
const fetchStats = useCallback(async () => {
try {
const { data } = await api.get<ShiftStats>('/map/shifts/stats');
setStats(data);
} catch {
// Non-critical
}
}, []);
const fetchCuts = useCallback(async () => {
try {
const { data } = await api.get('/map/cuts', { params: { limit: 100 } });
setCuts(data.cuts ?? data);
} catch {
// Non-critical
}
}, []);
const fetchShifts = useCallback(async (params?: ShiftsListParams) => {
setLoading(true);
try {
const { data } = await api.get<ShiftsListResponse>('/map/shifts', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
status: params?.status ?? statusFilter,
},
});
setShifts(data.shifts);
setPagination(data.pagination);
} catch {
message.error('Failed to load shifts');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);
useEffect(() => {
fetchShifts({ page: 1 });
fetchStats();
fetchCuts();
}, [debouncedSearch, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchShifts({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleCreate = async (values: Record<string, unknown>) => {
try {
const payload = {
title: values.title,
description: values.description || undefined,
date: dayjs(values.date as string).format('YYYY-MM-DD'),
startTime: dayjs(values.startTime as string).format('HH:mm'),
endTime: dayjs(values.endTime as string).format('HH:mm'),
location: values.location || undefined,
maxVolunteers: values.maxVolunteers,
isPublic: values.isPublic || false,
cutId: values.cutId || undefined,
};
await api.post('/map/shifts', payload);
message.success('Shift created');
setCreateModalOpen(false);
createForm.resetFields();
fetchShifts({ page: 1 });
fetchStats();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create shift';
message.error(msg);
}
};
const handleEdit = async (values: Record<string, unknown>) => {
if (!editingShift) return;
try {
const payload: Record<string, unknown> = {};
if (values.title !== undefined) payload.title = values.title;
if (values.description !== undefined) payload.description = values.description || null;
if (values.date !== undefined) payload.date = dayjs(values.date as string).format('YYYY-MM-DD');
if (values.startTime !== undefined) payload.startTime = dayjs(values.startTime as string).format('HH:mm');
if (values.endTime !== undefined) payload.endTime = dayjs(values.endTime as string).format('HH:mm');
if (values.location !== undefined) payload.location = values.location || null;
if (values.maxVolunteers !== undefined) payload.maxVolunteers = values.maxVolunteers;
if (values.isPublic !== undefined) payload.isPublic = values.isPublic;
if (values.status !== undefined) payload.status = values.status;
if (values.cutId !== undefined) payload.cutId = values.cutId || null;
await api.put(`/map/shifts/${editingShift.id}`, payload);
message.success('Shift updated');
setEditDrawerOpen(false);
setEditingShift(null);
editForm.resetFields();
fetchShifts();
fetchStats();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to update shift';
message.error(msg);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/map/shifts/${id}`);
message.success('Shift deleted');
fetchShifts();
fetchStats();
} catch {
message.error('Failed to delete shift');
}
};
const openEdit = (shift: Shift) => {
setEditingShift(shift);
editForm.setFieldsValue({
title: shift.title,
description: shift.description,
date: dayjs(shift.date),
startTime: dayjs(shift.startTime, 'HH:mm'),
endTime: dayjs(shift.endTime, 'HH:mm'),
location: shift.location,
maxVolunteers: shift.maxVolunteers,
isPublic: shift.isPublic,
status: shift.status,
cutId: shift.cutId,
});
setEditDrawerOpen(true);
};
const openSignups = async (shift: Shift) => {
setSignupsShift(shift);
setSignupsDrawerOpen(true);
await fetchSignups(shift.id);
};
const fetchSignups = async (shiftId: string) => {
setSignupsLoading(true);
try {
const { data } = await api.get<Shift>(`/map/shifts/${shiftId}`);
setSignups(data.signups || []);
setSignupsShift(data);
} catch {
message.error('Failed to load signups');
} finally {
setSignupsLoading(false);
}
};
const handleAddSignup = async () => {
if (!signupsShift || !addEmail.trim()) return;
try {
await api.post(`/map/shifts/${signupsShift.id}/signups`, {
userEmail: addEmail.trim(),
userName: addName.trim() || undefined,
});
message.success('Volunteer added');
setAddEmail('');
setAddName('');
fetchSignups(signupsShift.id);
fetchShifts();
fetchStats();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to add volunteer';
message.error(msg);
}
};
const handleRemoveSignup = async (signupId: string) => {
if (!signupsShift) return;
try {
await api.delete(`/map/shifts/${signupsShift.id}/signups/${signupId}`);
message.success('Volunteer removed');
fetchSignups(signupsShift.id);
fetchShifts();
fetchStats();
} catch {
message.error('Failed to remove volunteer');
}
};
const handleEmailAll = async () => {
if (!signupsShift) return;
try {
const { data } = await api.post<{ sent: number; failed: number }>(
`/map/shifts/${signupsShift.id}/email-details`
);
message.success(`Emailed ${data.sent} volunteer(s)${data.failed ? `, ${data.failed} failed` : ''}`);
} catch {
message.error('Failed to email volunteers');
}
};
const columns: ColumnsType<Shift> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string) => <span style={{ fontWeight: 500 }}>{title}</span>,
},
{
title: 'Date',
dataIndex: 'date',
key: 'date',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
},
{
title: 'Time',
key: 'time',
render: (_: unknown, record: Shift) => `${record.startTime}${record.endTime}`,
responsive: ['md'],
},
{
title: 'Location',
dataIndex: 'location',
key: 'location',
render: (loc: string | null) => loc || '--',
responsive: ['lg'],
},
{
title: 'Area',
key: 'cut',
render: (_: unknown, record: Shift) => record.cut?.name || '--',
responsive: ['md'],
},
{
title: 'Volunteers',
key: 'volunteers',
width: 140,
render: (_: unknown, record: Shift) => {
const confirmed = record._count?.signups ?? record.currentVolunteers;
const pct = record.maxVolunteers > 0 ? Math.round((confirmed / record.maxVolunteers) * 100) : 0;
return (
<div>
<Progress
percent={pct}
size="small"
status={pct >= 100 ? 'exception' : 'active'}
format={() => `${confirmed}/${record.maxVolunteers}`}
/>
</div>
);
},
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: ShiftStatus) => (
<Tag color={SHIFT_STATUS_COLORS[status]}>{SHIFT_STATUS_LABELS[status]}</Tag>
),
},
{
title: 'Public',
dataIndex: 'isPublic',
key: 'isPublic',
width: 70,
render: (isPublic: boolean) =>
isPublic ? <CheckCircleOutlined style={{ color: '#52c41a' }} /> : null,
},
{
title: 'Actions',
key: 'actions',
width: 120,
render: (_: unknown, record: Shift) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={(e) => { e.stopPropagation(); openEdit(record); }}
title="Edit"
/>
<Popconfirm
title="Delete this shift?"
onConfirm={(e) => { e?.stopPropagation(); handleDelete(record.id); }}
onCancel={(e) => e?.stopPropagation()}
>
<Button
type="link"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => e.stopPropagation()}
title="Delete"
/>
</Popconfirm>
</Space>
),
},
];
const signupColumns: ColumnsType<ShiftSignup> = [
{
title: 'Email',
dataIndex: 'userEmail',
key: 'userEmail',
},
{
title: 'Name',
dataIndex: 'userName',
key: 'userName',
render: (name: string | null) => name || '--',
},
{
title: 'Phone',
key: 'phone',
render: (_: unknown, record: ShiftSignup) =>
record.userPhone || record.user?.phone || '--',
responsive: ['md'],
},
{
title: 'Source',
dataIndex: 'signupSource',
key: 'signupSource',
width: 100,
render: (source: string) => (
<Tag color={SIGNUP_SOURCE_COLORS[source as keyof typeof SIGNUP_SOURCE_COLORS]}>
{source}
</Tag>
),
},
{
title: 'Date',
dataIndex: 'signupDate',
key: 'signupDate',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['lg'],
},
{
title: '',
key: 'remove',
width: 60,
render: (_: unknown, record: ShiftSignup) =>
record.status === 'CONFIRMED' ? (
<Popconfirm title="Remove this volunteer?" onConfirm={() => handleRemoveSignup(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />} title="Remove" />
</Popconfirm>
) : (
<Tag color="red">Cancelled</Tag>
),
},
];
const cutOptions = cuts.map((c) => ({ value: c.id, label: c.name }));
const shiftFormFields = (isEdit = false) => (
<>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="e.g. Door Knocking, Phone Banking" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Shift details and instructions" />
</Form.Item>
<Row gutter={12}>
<Col xs={24} sm={8}>
<Form.Item
name="date"
label="Date"
rules={[{ required: true, message: 'Date is required' }]}
>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item
name="startTime"
label="Start Time"
rules={[{ required: true, message: 'Required' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item
name="endTime"
label="End Time"
rules={[{ required: true, message: 'Required' }]}
>
<TimePicker format="HH:mm" style={{ width: '100%' }} minuteStep={5} />
</Form.Item>
</Col>
</Row>
<Form.Item name="location" label="Location">
<Input placeholder="e.g. Campaign HQ, 123 Main St" />
</Form.Item>
<Form.Item name="cutId" label="Area (Cut)">
<Select
options={cutOptions}
placeholder="Assign a canvass area..."
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<Row gutter={12}>
<Col xs={12} sm={8}>
<Form.Item
name="maxVolunteers"
label="Max Volunteers"
rules={[{ required: true, message: 'Required' }]}
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="isPublic" label="Public" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
{isEdit && (
<Col xs={12} sm={8}>
<Form.Item name="status" label="Status">
<Select options={statusOptions} />
</Form.Item>
</Col>
)}
</Row>
</>
);
const { setPageHeader } = useOutletContext<AppOutletContext>();
const headerActions = useMemo(() => (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
setCreateModalOpen(true);
}}
>
Create Shift
</Button>
), [createForm]);
useEffect(() => {
setPageHeader({ title: 'Shifts', actions: headerActions });
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
return (
<>
{/* Stats Row */}
{stats && (
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={4}>
<Card size="small">
<Statistic title="Total" value={stats.total} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={12} sm={4}>
<Card size="small">
<Statistic title="Open" value={stats.open} valueStyle={{ color: '#52c41a' }} prefix={<CheckCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={4}>
<Card size="small">
<Statistic title="Full" value={stats.full} valueStyle={{ color: '#faad14' }} prefix={<StopOutlined />} />
</Card>
</Col>
<Col xs={12} sm={4}>
<Card size="small">
<Statistic title="Cancelled" value={stats.cancelled} valueStyle={{ color: '#ff4d4f' }} prefix={<StopOutlined />} />
</Card>
</Col>
<Col xs={12} sm={4}>
<Card size="small">
<Statistic title="Upcoming" value={stats.upcoming} prefix={<ClockCircleOutlined />} />
</Card>
</Col>
<Col xs={12} sm={4}>
<Card size="small">
<Statistic title="Signups" value={stats.totalSignups} prefix={<TeamOutlined />} />
</Card>
</Col>
</Row>
)}
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search title or location"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Select
placeholder="Status"
options={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
{/* Table */}
<Table<Shift>
columns={columns}
dataSource={shifts}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} shifts`,
}}
onChange={handleTableChange}
onRow={(record) => ({
onClick: () => openSignups(record),
style: { cursor: 'pointer' },
})}
/>
{/* Create Modal */}
<Modal
title="Create Shift"
open={createModalOpen}
destroyOnHidden
width={560}
onCancel={() => {
setCreateModalOpen(false);
createForm.resetFields();
}}
onOk={() => createForm.submit()}
okText="Create"
>
<Form form={createForm} onFinish={handleCreate} layout="vertical">
{shiftFormFields(false)}
</Form>
</Modal>
{/* Edit Drawer */}
<Drawer
title="Edit Shift"
open={editDrawerOpen}
width={520}
onClose={() => {
setEditDrawerOpen(false);
setEditingShift(null);
editForm.resetFields();
}}
extra={
<Button type="primary" onClick={() => editForm.submit()}>
Save
</Button>
}
>
<Form form={editForm} onFinish={handleEdit} layout="vertical">
{shiftFormFields(true)}
</Form>
</Drawer>
{/* Signups Drawer */}
<Drawer
title={
<Space>
<TeamOutlined />
<span>Signups {signupsShift?.title}</span>
</Space>
}
open={signupsDrawerOpen}
width={640}
onClose={() => {
setSignupsDrawerOpen(false);
setSignupsShift(null);
setSignups([]);
setAddEmail('');
setAddName('');
}}
extra={
<Button
icon={<MailOutlined />}
onClick={handleEmailAll}
disabled={signups.filter((s) => s.status === 'CONFIRMED').length === 0}
>
Email All
</Button>
}
>
{signupsShift && (
<Card size="small" style={{ marginBottom: 16 }}>
<Row gutter={12}>
<Col span={8}>
<Text type="secondary">Date</Text>
<br />
<Text strong>{dayjs(signupsShift.date).format('YYYY-MM-DD')}</Text>
</Col>
<Col span={8}>
<Text type="secondary">Time</Text>
<br />
<Text strong>{signupsShift.startTime} {signupsShift.endTime}</Text>
</Col>
<Col span={8}>
<Text type="secondary">Volunteers</Text>
<br />
<Text strong>
{signupsShift._count?.signups ?? signupsShift.currentVolunteers} / {signupsShift.maxVolunteers}
</Text>
</Col>
</Row>
</Card>
)}
<Table<ShiftSignup>
columns={signupColumns}
dataSource={signups.filter((s) => s.status === 'CONFIRMED')}
rowKey="id"
loading={signupsLoading}
pagination={false}
size="small"
/>
<div style={{ marginTop: 16, display: 'flex', gap: 8 }}>
<Input
placeholder="Email"
value={addEmail}
onChange={(e) => setAddEmail(e.target.value)}
style={{ flex: 2 }}
/>
<Input
placeholder="Name (optional)"
value={addName}
onChange={(e) => setAddName(e.target.value)}
style={{ flex: 1 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddSignup}
disabled={!addEmail.trim()}
>
Add
</Button>
</div>
</Drawer>
</>
);
}

View File

@ -0,0 +1,463 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Modal,
Form,
InputNumber,
Popconfirm,
message,
Typography,
Row,
Col,
DatePicker,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type {
User,
UserRole,
UserStatus,
UsersListResponse,
UsersListParams,
CreateUserPayload,
UpdateUserPayload,
} from '@/types/api';
const { Title } = Typography;
const roleColors: Record<UserRole, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'volcano',
MAP_ADMIN: 'orange',
USER: 'blue',
TEMP: 'default',
};
const statusColors: Record<UserStatus, string> = {
ACTIVE: 'green',
INACTIVE: 'default',
SUSPENDED: 'red',
EXPIRED: 'orange',
};
const roleOptions: { value: UserRole; label: string }[] = [
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
{ value: 'INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'MAP_ADMIN', label: 'Map Admin' },
{ value: 'USER', label: 'User' },
{ value: 'TEMP', label: 'Temp' },
];
const statusOptions: { value: UserStatus; label: string }[] = [
{ value: 'ACTIVE', label: 'Active' },
{ value: 'INACTIVE', label: 'Inactive' },
{ value: 'SUSPENDED', label: 'Suspended' },
{ value: 'EXPIRED', label: 'Expired' },
];
export default function UsersPage() {
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [roleFilter, setRoleFilter] = useState<UserRole | undefined>();
const [statusFilter, setStatusFilter] = useState<UserStatus | undefined>();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const handleSearchChange = (value: string) => {
setSearch(value);
clearTimeout(searchTimerRef.current);
searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);
};
useEffect(() => {
return () => clearTimeout(searchTimerRef.current);
}, []);
const fetchUsers = useCallback(async (params?: UsersListParams) => {
setLoading(true);
try {
const { data } = await api.get<UsersListResponse>('/users', {
params: {
page: params?.page ?? pagination.page,
limit: params?.limit ?? pagination.limit,
search: params?.search ?? (debouncedSearch || undefined),
role: params?.role ?? roleFilter,
status: params?.status ?? statusFilter,
},
});
setUsers(data.users);
setPagination(data.pagination);
} catch {
message.error('Failed to load users');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, debouncedSearch, roleFilter, statusFilter]);
useEffect(() => {
fetchUsers({ page: 1 });
}, [debouncedSearch, roleFilter, statusFilter]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTableChange = (pag: TablePaginationConfig) => {
fetchUsers({ page: pag.current ?? 1, limit: pag.pageSize ?? 20 });
};
const handleCreate = async (values: CreateUserPayload & { expiresAtDate?: dayjs.Dayjs }) => {
try {
const payload: CreateUserPayload = { ...values };
if (values.expiresAtDate) {
payload.expiresAt = values.expiresAtDate.toISOString();
}
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
await api.post('/users', payload);
message.success('User created');
setCreateModalOpen(false);
createForm.resetFields();
fetchUsers({ page: 1 });
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to create user';
message.error(msg);
}
};
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null }) => {
if (!editingUser) return;
try {
const payload: UpdateUserPayload = { ...values };
if (values.expiresAtDate) {
payload.expiresAt = values.expiresAtDate.toISOString();
} else if (values.expiresAtDate === null) {
payload.expiresAt = null;
}
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
// Remove empty password
if (!payload.password) delete payload.password;
await api.put(`/users/${editingUser.id}`, payload);
message.success('User updated');
setEditModalOpen(false);
setEditingUser(null);
editForm.resetFields();
fetchUsers();
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Failed to update user';
message.error(msg);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/users/${id}`);
message.success('User deleted');
fetchUsers();
} catch {
message.error('Failed to delete user');
}
};
const openEdit = (user: User) => {
setEditingUser(user);
editForm.setFieldsValue({
email: user.email,
name: user.name,
phone: user.phone,
role: user.role,
status: user.status,
expireDays: user.expireDays,
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
});
setEditModalOpen(true);
};
const columns: ColumnsType<User> = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (name: string | null) => name || '--',
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
},
{
title: 'Role',
dataIndex: 'role',
key: 'role',
render: (role: UserRole) => (
<Tag color={roleColors[role]}>{role.replace('_', ' ')}</Tag>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
render: (status: UserStatus) => (
<Tag color={statusColors[status]}>{status}</Tag>
),
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
render: (date: string) => dayjs(date).format('YYYY-MM-DD'),
responsive: ['md'],
},
{
title: 'Last Login',
dataIndex: 'lastLoginAt',
key: 'lastLoginAt',
render: (date: string | null) =>
date ? dayjs(date).format('YYYY-MM-DD HH:mm') : '--',
responsive: ['lg'],
},
{
title: 'Actions',
key: 'actions',
render: (_: unknown, record: User) => (
<Space>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
/>
<Popconfirm
title="Delete this user?"
onConfirm={() => handleDelete(record.id)}
>
<Button type="link" size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const createRoleValue = Form.useWatch('role', createForm);
const editRoleValue = Form.useWatch('role', editForm);
return (
<>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
Users
</Title>
</Col>
<Col>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModalOpen(true)}
>
Create User
</Button>
</Col>
</Row>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={10} md={8}>
<Input
placeholder="Search by name or email"
prefix={<SearchOutlined />}
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Role"
options={roleOptions}
value={roleFilter}
onChange={setRoleFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
<Col xs={12} sm={7} md={4}>
<Select
placeholder="Status"
options={statusOptions}
value={statusFilter}
onChange={setStatusFilter}
allowClear
style={{ width: '100%' }}
/>
</Col>
</Row>
<Table<User>
columns={columns}
dataSource={users}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: true,
showTotal: (total) => `${total} users`,
}}
onChange={handleTableChange}
/>
{/* Create Modal */}
<Modal
title="Create User"
open={createModalOpen}
destroyOnHidden
onCancel={() => {
setCreateModalOpen(false);
createForm.resetFields();
}}
onOk={() => createForm.submit()}
okText="Create"
>
<Form form={createForm} onFinish={handleCreate} layout="vertical">
<Form.Item
name="email"
label="Email"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Invalid email' },
]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="Password"
rules={[
{ required: true, message: 'Password is required' },
{ min: 8, message: 'Minimum 8 characters' },
]}
>
<Input.Password />
</Form.Item>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="phone" label="Phone">
<Input />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="role" label="Role" initialValue="USER">
<Select options={roleOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="Status" initialValue="ACTIVE">
<Select options={statusOptions} />
</Form.Item>
</Col>
</Row>
{createRoleValue === 'TEMP' && (
<Row gutter={12}>
<Col span={12}>
<Form.Item name="expiresAtDate" label="Expires At">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="expireDays" label="Expire Days">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
)}
</Form>
</Modal>
{/* Edit Modal */}
<Modal
title="Edit User"
open={editModalOpen}
destroyOnHidden
onCancel={() => {
setEditModalOpen(false);
setEditingUser(null);
editForm.resetFields();
}}
onOk={() => editForm.submit()}
okText="Save"
>
<Form form={editForm} onFinish={handleEdit} layout="vertical">
<Form.Item
name="email"
label="Email"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Invalid email' },
]}
>
<Input />
</Form.Item>
<Form.Item
name="password"
label="Password"
help="Leave blank to keep current password"
>
<Input.Password />
</Form.Item>
<Form.Item name="name" label="Name">
<Input />
</Form.Item>
<Form.Item name="phone" label="Phone">
<Input />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="role" label="Role">
<Select options={roleOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="status" label="Status">
<Select options={statusOptions} />
</Form.Item>
</Col>
</Row>
{editRoleValue === 'TEMP' && (
<Row gutter={12}>
<Col span={12}>
<Form.Item name="expiresAtDate" label="Expires At">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="expireDays" label="Expire Days">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
)}
</Form>
</Modal>
</>
);
}

View File

@ -0,0 +1,187 @@
import { useEffect, useState } from 'react';
import { Button, Typography, Spin, App } from 'antd';
import { PrinterOutlined } from '@ant-design/icons';
import { useOutletContext } from 'react-router-dom';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { MapSettings } from '@/types/api';
const { Title, Text } = Typography;
const API_BASE = import.meta.env.VITE_API_URL || '';
export default function WalkSheetPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const [settings, setSettings] = useState<MapSettings | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setPageHeader({
title: 'Walk Sheet',
actions: (
<Button icon={<PrinterOutlined />} onClick={() => window.print()}>
Print
</Button>
),
});
return () => setPageHeader(null);
}, [setPageHeader]);
useEffect(() => {
api.get('/map/settings')
.then(({ data }) => setSettings(data))
.catch(() => message.error('Failed to load settings'))
.finally(() => setLoading(false));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (loading) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
const qrCodes = [
{ url: settings?.qrCode1Url, label: settings?.qrCode1Label },
{ url: settings?.qrCode2Url, label: settings?.qrCode2Label },
{ url: settings?.qrCode3Url, label: settings?.qrCode3Label },
].filter((q) => q.url);
const rows = Array.from({ length: 12 }, (_, i) => i);
return (
<>
<style>{`
@media print {
body * { visibility: hidden; }
.walk-sheet-print, .walk-sheet-print * { visibility: visible; }
.walk-sheet-print {
position: absolute;
left: 0;
top: 0;
width: 100%;
font-size: 11px;
}
.walk-sheet-print .no-print { display: none !important; }
}
.walk-sheet-print table {
width: 100%;
border-collapse: collapse;
}
.walk-sheet-print th,
.walk-sheet-print td {
border: 1px solid #555;
padding: 4px 6px;
text-align: left;
font-size: 11px;
}
.walk-sheet-print th {
background: rgba(255,255,255,0.05);
font-weight: 600;
}
.support-circle {
display: inline-block;
width: 16px;
height: 16px;
border: 1.5px solid rgba(255,255,255,0.4);
border-radius: 50%;
text-align: center;
line-height: 14px;
font-size: 9px;
margin-right: 2px;
}
`}</style>
<div className="walk-sheet-print">
{/* Header */}
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<Title level={3} style={{ marginBottom: 2 }}>
{settings?.walkSheetTitle || 'Walk Sheet'}
</Title>
{settings?.walkSheetSubtitle && (
<Text type="secondary" style={{ fontSize: 14 }}>{settings.walkSheetSubtitle}</Text>
)}
</div>
{/* QR Codes */}
{qrCodes.length > 0 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 24, marginBottom: 16 }}>
{qrCodes.map((qr, idx) => (
<div key={idx} style={{ textAlign: 'center' }}>
<img
src={`${API_BASE}/api/qr?text=${encodeURIComponent(qr.url!)}&size=100`}
alt={qr.label || 'QR'}
style={{ width: 80, height: 80 }}
/>
{qr.label && (
<div style={{ fontSize: 10, marginTop: 2 }}>
<Text type="secondary">{qr.label}</Text>
</div>
)}
</div>
))}
</div>
)}
{/* Volunteer info line */}
<div style={{ display: 'flex', gap: 16, marginBottom: 12 }}>
<div style={{ flex: 1 }}>
<Text strong>Volunteer: </Text>
<span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 200 }}>&nbsp;</span>
</div>
<div>
<Text strong>Date: </Text>
<span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>
</div>
<div>
<Text strong>Area/Cut: </Text>
<span style={{ borderBottom: '1px solid rgba(255,255,255,0.3)', display: 'inline-block', width: 120 }}>&nbsp;</span>
</div>
</div>
{/* Contact table */}
<table>
<thead>
<tr>
<th style={{ width: 20 }}>#</th>
<th>Name</th>
<th>Address / Unit</th>
<th>Email</th>
<th>Phone</th>
<th style={{ width: 70 }}>Support</th>
<th style={{ width: 50 }}>Sign</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{rows.map((i) => (
<tr key={i}>
<td style={{ textAlign: 'center' }}>{i + 1}</td>
<td style={{ height: 28 }}>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td>&nbsp;</td>
<td style={{ textAlign: 'center' }}>
<span className="support-circle">1</span>
<span className="support-circle">2</span>
<span className="support-circle">3</span>
<span className="support-circle">4</span>
</td>
<td style={{ textAlign: 'center' }}>
<span className="support-circle">R</span>
<span className="support-circle">L</span>
</td>
<td>&nbsp;</td>
</tr>
))}
</tbody>
</table>
{/* Footer */}
{settings?.walkSheetFooter && (
<div style={{ marginTop: 16, fontSize: 11, textAlign: 'center' }}>
<Text type="secondary">{settings.walkSheetFooter}</Text>
</div>
)}
</div>
</>
);
}

View File

@ -0,0 +1,613 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Typography,
Input,
Button,
Card,
Space,
Tag,
Avatar,
message,
Spin,
Result,
Grid,
theme,
} from 'antd';
import {
SearchOutlined,
SendOutlined,
MailOutlined,
UserOutlined,
MessageOutlined,
CopyOutlined,
CheckCircleOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import type {
Campaign,
Representative,
RepresentativeLookupResponse,
} from '@/types/api';
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
import { mapRepSetToLevel } from '@/utils/representatives';
const { Title, Text, Paragraph } = Typography;
const apiBase = '/api';
type Step = 'info' | 'reps' | 'send';
export default function CampaignPage() {
const { slug } = useParams<{ slug: string }>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Step tracking
const [currentStep, setCurrentStep] = useState<Step>('info');
// Step 1: User info
const [postalCode, setPostalCode] = useState('');
const [userName, setUserName] = useState('');
const [userEmail, setUserEmail] = useState('');
// Step 2: Representatives
const [representatives, setRepresentatives] = useState<Representative[]>([]);
const [lookupLoading, setLookupLoading] = useState(false);
// Step 3: Send email
const [sendingTo, setSendingTo] = useState<string | null>(null);
const [sentTo, setSentTo] = useState<Set<string>>(new Set());
const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('');
const [linkCopied, setLinkCopied] = useState(false);
useEffect(() => {
fetchCampaign();
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchCampaign = async () => {
setLoading(true);
try {
const { data } = await axios.get<Campaign>(`${apiBase}/campaigns/${slug}/details`);
setCampaign(data);
setEditSubject(data.emailSubject);
setEditBody(data.emailBody);
} catch {
setError('Campaign not found or is no longer active.');
} finally {
setLoading(false);
}
};
const handleLookup = async () => {
if (!postalCode.trim()) {
message.warning('Please enter your postal code');
return;
}
setLookupLoading(true);
try {
const code = postalCode.replace(/\s/g, '').toUpperCase();
const { data } = await axios.get<RepresentativeLookupResponse>(
`${apiBase}/representatives/by-postal/${code}`
);
let reps = data.representatives;
if (campaign?.targetGovernmentLevels && campaign.targetGovernmentLevels.length > 0) {
const targetLevels = new Set(campaign.targetGovernmentLevels);
reps = reps.filter((r) => {
const level = mapRepSetToLevel(r.representativeSetName);
return level && targetLevels.has(level);
});
}
setRepresentatives(reps);
setCurrentStep('reps');
} catch {
message.error('Could not look up representatives for this postal code');
} finally {
setLookupLoading(false);
}
};
const handleSendSmtp = async (rep: Representative) => {
if (!campaign) return;
if (campaign.collectUserInfo && (!userName.trim() || !userEmail.trim())) {
message.warning('Please enter your name and email in Step 1');
setCurrentStep('info');
return;
}
if (!rep.email) return;
setSendingTo(rep.email);
try {
await axios.post(`${apiBase}/campaigns/${slug}/send-email`, {
userEmail: userEmail || 'anonymous@cmlite.org',
userName: userName || 'Anonymous',
postalCode: postalCode.replace(/\s/g, '').toUpperCase(),
recipientEmail: rep.email,
recipientName: rep.name,
recipientTitle: rep.electedOffice,
recipientLevel: mapRepSetToLevel(rep.representativeSetName),
emailMethod: 'SMTP',
customEmailSubject: campaign.allowEmailEditing ? editSubject : undefined,
customEmailBody: campaign.allowEmailEditing ? editBody : undefined,
});
message.success(`Email sent to ${rep.name}`);
setSentTo((prev) => new Set(prev).add(rep.email!));
} catch {
message.error('Failed to send email');
} finally {
setSendingTo(null);
}
};
const handleMailto = async (rep: Representative) => {
if (!campaign || !rep.email) return;
const subject = encodeURIComponent(campaign.allowEmailEditing ? editSubject : campaign.emailSubject);
const body = encodeURIComponent(campaign.allowEmailEditing ? editBody : campaign.emailBody);
window.open(`mailto:${rep.email}?subject=${subject}&body=${body}`, '_blank');
try {
await axios.post(`${apiBase}/campaigns/${slug}/track-mailto`, {
recipientEmail: rep.email,
recipientName: rep.name,
recipientTitle: rep.electedOffice,
recipientLevel: mapRepSetToLevel(rep.representativeSetName),
userEmail: userEmail || undefined,
userName: userName || undefined,
postalCode: postalCode.replace(/\s/g, '').toUpperCase() || undefined,
});
} catch { /* tracking is non-critical */ }
setSentTo((prev) => new Set(prev).add(rep.email!));
};
const handleCopyLink = () => {
const url = `${window.location.origin}/campaign/${slug}`;
navigator.clipboard.writeText(url);
setLinkCopied(true);
setTimeout(() => setLinkCopied(false), 2000);
};
if (loading) {
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
}
if (error || !campaign) {
return <Result status="404" title="Campaign Not Found" subTitle={error} />;
}
const stepDefs: { key: Step; label: string; shortLabel: string }[] = [
{ key: 'info', label: 'Enter Your Info', shortLabel: 'Info' },
{ key: 'reps', label: 'Find Representatives', shortLabel: 'Reps' },
{ key: 'send', label: 'Send Messages', shortLabel: 'Send' },
];
const stepIndex = stepDefs.findIndex((s) => s.key === currentStep);
return (
<div>
{/* Hero Section */}
<div
style={{
background: campaign.coverPhoto
? `linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.55)), url(${campaign.coverPhoto}) center/cover`
: 'linear-gradient(135deg, #3498db, #2c3e50)',
borderRadius: 12,
padding: isMobile ? '32px 16px' : '48px 32px',
textAlign: 'center',
marginBottom: 24,
position: 'relative',
}}
>
<Title level={isMobile ? 3 : 2} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.3)' }}>
{campaign.title}
</Title>
{campaign.description && (
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 14 : 16, margin: '12px auto 0', maxWidth: 600 }}>
{campaign.description}
</Paragraph>
)}
{/* Stats Circles */}
<div style={{ display: 'flex', justifyContent: 'center', gap: isMobile ? 12 : 20, marginTop: 24, flexWrap: 'wrap' }}>
{campaign.showEmailCount && (
<div style={getStatCircleStyle(isMobile)}>
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{campaign._count.emails}</span>
<span style={{ fontSize: isMobile ? 9 : 10, color: 'rgba(255,255,255,0.7)', textTransform: 'uppercase', letterSpacing: 1 }}>Emails</span>
</div>
)}
{campaign.showResponseWall && (
<div style={getStatCircleStyle(isMobile)}>
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{campaign._count.responses}</span>
<span style={{ fontSize: isMobile ? 9 : 10, color: 'rgba(255,255,255,0.7)', textTransform: 'uppercase', letterSpacing: 1 }}>Responses</span>
</div>
)}
</div>
{/* Share Buttons */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginTop: 20 }}>
<Button
icon={linkCopied ? <CheckCircleOutlined /> : <CopyOutlined />}
onClick={handleCopyLink}
style={{
background: 'rgba(255,255,255,0.15)',
border: '1px solid rgba(255,255,255,0.25)',
color: '#fff',
backdropFilter: 'blur(8px)',
}}
>
{linkCopied ? 'Copied!' : 'Copy Link'}
</Button>
</div>
</div>
{/* Call to Action */}
{campaign.callToAction && (
<Card
style={{
marginBottom: 24,
background: '#1b2838',
border: `1px solid ${token.colorPrimary}40`,
textAlign: 'center',
}}
>
<Text style={{ fontSize: 16, color: 'rgba(255,255,255,0.85)' }}>{campaign.callToAction}</Text>
</Card>
)}
{/* Step Indicator */}
<div
style={{
display: 'flex',
background: '#1b2838',
borderRadius: 8,
padding: isMobile ? '8px 8px' : '12px 16px',
marginBottom: 24,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
{stepDefs.map((step, i) => {
const isActive = step.key === currentStep;
const isCompleted = i < stepIndex;
const isClickable = (step.key === 'info') ||
(step.key === 'reps' && representatives.length > 0) ||
(step.key === 'send' && representatives.length > 0);
return (
<div
key={step.key}
role="button"
tabIndex={isClickable ? 0 : -1}
onClick={() => isClickable && setCurrentStep(step.key)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && isClickable) {
e.preventDefault();
setCurrentStep(step.key);
}
}}
aria-label={`Step ${i + 1}: ${step.label}${isCompleted ? ' (completed)' : ''}${isActive ? ' (current)' : ''}`}
style={{
flex: 1,
textAlign: 'center',
padding: isMobile ? '6px 2px' : '8px 4px',
cursor: isClickable ? 'pointer' : 'default',
color: isActive ? token.colorPrimary : isCompleted ? token.colorSuccess : 'rgba(255,255,255,0.4)',
fontWeight: isActive ? 700 : 400,
fontSize: isMobile ? 12 : 14,
position: 'relative',
transition: 'color 0.2s',
outline: 'none',
}}
>
{isCompleted && <CheckCircleOutlined style={{ marginRight: isMobile ? 4 : 6 }} />}
{isMobile ? step.shortLabel : step.label}
{i < stepDefs.length - 1 && (
<ArrowRightOutlined
style={{
position: 'absolute',
right: -4,
top: '50%',
transform: 'translateY(-50%)',
fontSize: 11,
color: 'rgba(255,255,255,0.2)',
}}
/>
)}
</div>
);
})}
</div>
{/* Step 1: Your Information */}
{currentStep === 'info' && (
<Card
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
>
<Title level={4} style={{ color: '#fff', marginBottom: 4 }}>Your Information</Title>
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 20 }}>
We need some basic information to find your representatives and track campaign engagement.
</Text>
<div style={{ maxWidth: 500 }}>
<div style={{ marginBottom: 16 }}>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
Your Postal Code *
</Text>
<Input
placeholder="e.g. T5K 2M5"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
onPressEnter={handleLookup}
size="large"
style={{ textTransform: 'uppercase' }}
disabled={lookupLoading}
/>
</div>
{campaign.collectUserInfo && (
<>
<div style={{ marginBottom: 16 }}>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
Your Name
</Text>
<Input
placeholder="Your full name"
value={userName}
onChange={(e) => setUserName(e.target.value)}
size="large"
disabled={lookupLoading}
/>
</div>
<div style={{ marginBottom: 16 }}>
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
Your Email (Optional - If you would like a reply)
</Text>
<Input
placeholder="your@email.com"
value={userEmail}
onChange={(e) => setUserEmail(e.target.value)}
size="large"
type="email"
disabled={lookupLoading}
/>
</div>
</>
)}
<Button
type="primary"
size="large"
icon={<SearchOutlined />}
onClick={handleLookup}
loading={lookupLoading}
style={{ marginTop: 8, background: '#005a9c' }}
>
Find My Representatives
</Button>
</div>
</Card>
)}
{/* Step 2: Representatives */}
{currentStep === 'reps' && (
<Card
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
>
<Title level={4} style={{ color: '#fff', marginBottom: 16 }}>
Your Representatives ({representatives.length})
</Title>
{representatives.length === 0 ? (
<Result
status="info"
title="No Representatives Found"
subTitle="No representatives matched the target levels for this campaign."
/>
) : (
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{representatives.map((rep, idx) => {
const level = mapRepSetToLevel(rep.representativeSetName);
const isSent = rep.email ? sentTo.has(rep.email) : false;
return (
<div
key={rep.id || idx}
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 12 : 16,
padding: '16px',
background: '#0d1b2a',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.06)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Avatar src={rep.photoUrl} icon={!rep.photoUrl ? <UserOutlined /> : undefined} size={isMobile ? 48 : 56} />
<div style={{ flex: 1, minWidth: 150 }}>
<Text strong style={{ color: '#fff', fontSize: 15 }}>{rep.name || 'Unknown'}</Text>
<br />
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>{rep.electedOffice}</Text>
{rep.districtName && (
<>
<br />
<Text style={{ color: 'rgba(255,255,255,0.4)', fontSize: 12 }}>{rep.districtName}</Text>
</>
)}
<div style={{ marginTop: 4 }}>
{level && <Tag color={GOVERNMENT_LEVEL_COLORS[level]} style={{ fontSize: 11 }}>{GOVERNMENT_LEVEL_LABELS[level]}</Tag>}
{rep.partyName && <Tag style={{ fontSize: 11 }}>{rep.partyName}</Tag>}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', width: isMobile ? '100%' : 'auto' }}>
{rep.email && !isSent && (
<>
{campaign.allowSmtpEmail && (
<Button
type="primary"
size="small"
icon={<SendOutlined />}
loading={sendingTo === rep.email}
onClick={() => handleSendSmtp(rep)}
style={{ background: '#005a9c' }}
>
Send
</Button>
)}
{campaign.allowMailtoLink && (
<Button
size="small"
icon={<MailOutlined />}
onClick={() => handleMailto(rep)}
>
Email App
</Button>
)}
</>
)}
{isSent && (
<Tag color="success" icon={<CheckCircleOutlined />} style={{ margin: 0, padding: '4px 12px' }}>
Sent
</Tag>
)}
{!rep.email && (
<Text style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12 }}>No email available</Text>
)}
</div>
</div>
);
})}
</Space>
)}
{representatives.length > 0 && (
<div style={{ marginTop: 16 }}>
<Button type="link" onClick={() => setCurrentStep('send')} style={{ padding: 0, color: token.colorPrimary }}>
View / Edit Email Message <ArrowRightOutlined />
</Button>
</div>
)}
</Card>
)}
{/* Step 3: Email Preview */}
{currentStep === 'send' && campaign && (
<Card
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
>
<Title level={4} style={{ color: '#fff', marginBottom: 4 }}>
<MailOutlined style={{ marginRight: 8 }} />Email Preview
</Title>
{campaign.allowEmailEditing && (
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 16 }}>
You can edit this message before sending to your representatives:
</Text>
)}
<div style={{ marginBottom: 16 }}>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, display: 'block', marginBottom: 4 }}>Subject</Text>
{campaign.allowEmailEditing ? (
<Input
value={editSubject}
onChange={(e) => setEditSubject(e.target.value)}
size="large"
/>
) : (
<div style={{ padding: '10px 14px', background: '#0d1b2a', borderRadius: 6, border: '1px solid rgba(255,255,255,0.08)', color: '#fff', fontWeight: 600 }}>
{campaign.emailSubject}
</div>
)}
</div>
<div style={{ marginBottom: 16 }}>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, display: 'block', marginBottom: 4 }}>Message</Text>
{campaign.allowEmailEditing ? (
<Input.TextArea
value={editBody}
onChange={(e) => setEditBody(e.target.value)}
rows={10}
/>
) : (
<div style={{
padding: '14px',
background: '#0d1b2a',
borderRadius: 6,
border: '1px solid rgba(255,255,255,0.08)',
color: 'rgba(255,255,255,0.85)',
whiteSpace: 'pre-wrap',
maxHeight: 400,
overflow: 'auto',
lineHeight: 1.7,
}}>
{campaign.emailBody}
</div>
)}
</div>
<Button type="link" onClick={() => setCurrentStep('reps')} style={{ padding: 0, color: token.colorPrimary }}>
Back to Representatives
</Button>
</Card>
)}
{/* Response Wall CTA */}
{campaign.showResponseWall && (
<div
style={{
textAlign: 'center',
padding: isMobile ? '32px 16px' : '40px 24px',
background: '#1b2838',
borderRadius: 12,
border: '1px solid rgba(255,255,255,0.08)',
}}
>
<MessageOutlined style={{ fontSize: 28, color: '#667eea', marginBottom: 8 }} />
<Title level={4} style={{ color: '#fff' }}>See What People Are Saying</Title>
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 20 }}>
Check out responses to people who have taken action on this campaign
</Text>
<Link to={`/campaign/${slug}/responses`}>
<Button
type="primary"
size="large"
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
borderRadius: 50,
padding: '0 32px',
height: 48,
fontWeight: 600,
fontSize: 15,
}}
>
View Response Wall
</Button>
</Link>
</div>
)}
</div>
);
}
function getStatCircleStyle(isMobile: boolean): React.CSSProperties {
const size = isMobile ? 64 : 80;
return {
background: 'rgba(255,255,255,0.15)',
border: '2px solid rgba(255,255,255,0.25)',
borderRadius: '50%',
width: size,
height: size,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(10px)',
};
}

View File

@ -0,0 +1,565 @@
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
Typography,
Card,
Tag,
Row,
Col,
Spin,
Empty,
Statistic,
Result,
Button,
Input,
Avatar,
Space,
Divider,
Grid,
Tooltip,
message,
theme,
} from 'antd';
import {
MailOutlined,
MessageOutlined,
StarFilled,
SearchOutlined,
UserOutlined,
PhoneOutlined,
GlobalOutlined,
ArrowRightOutlined,
CopyOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
import type { Campaign, GovernmentLevel, Representative, RepresentativeLookupResponse } from '@/types/api';
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
import { groupRepsByLevel, GOVERNMENT_LEVEL_DISPLAY_COLORS } from '@/utils/representatives';
const { Title, Text, Paragraph } = Typography;
export default function CampaignsListPage() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const { token } = theme.useToken();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { settings: siteSettings } = useSettingsStore();
// Rep lookup state
const [postalCode, setPostalCode] = useState('');
const [lookupLoading, setLookupLoading] = useState(false);
const [representatives, setRepresentatives] = useState<Representative[] | null>(null);
const [lookupLocation, setLookupLocation] = useState<{ city: string | null; province: string | null } | null>(null);
useEffect(() => {
fetchCampaigns();
}, []);
const fetchCampaigns = async () => {
setLoading(true);
setError(false);
try {
const { data } = await axios.get<Campaign[]>('/api/campaigns/public');
setCampaigns(data);
} catch {
setError(true);
} finally {
setLoading(false);
}
};
const handleLookup = async () => {
if (!postalCode.trim()) {
message.warning('Please enter your postal code');
return;
}
setLookupLoading(true);
try {
const code = postalCode.replace(/\s/g, '').toUpperCase();
const { data } = await axios.get<RepresentativeLookupResponse>(
`/api/representatives/by-postal/${code}`
);
setRepresentatives(data.representatives);
setLookupLocation(data.location);
} catch {
message.error('Could not look up representatives for this postal code');
} finally {
setLookupLoading(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (error) {
return (
<Result
status="500"
title="Failed to Load Campaigns"
subTitle="There was an error loading campaigns. Please try again later."
extra={
<Button type="primary" onClick={fetchCampaigns}>
Retry
</Button>
}
/>
);
}
const featured = campaigns.filter(c => c.highlightCampaign);
const nonFeatured = campaigns.filter(c => !c.highlightCampaign);
// Group representatives by government level
const repsByLevel = representatives ? groupRepsByLevel(representatives) : null;
const locationText = lookupLocation
? [lookupLocation.city, lookupLocation.province].filter(Boolean).join(', ')
: '';
return (
<div>
{/* Hero Banner */}
<div
style={{
textAlign: 'center',
padding: isMobile ? '36px 16px 32px' : '48px 24px 40px',
marginBottom: 32,
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 50%, #0099ff 100%)',
borderRadius: 12,
position: 'relative',
overflow: 'hidden',
}}
>
<Title level={2} style={{ color: '#fff', margin: 0, fontSize: isMobile ? 26 : 32, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
{siteSettings?.organizationName ?? 'Changemaker Lite'}
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 14 : 16, margin: '12px 0 0' }}>
Connect with your elected representatives across all levels of government
</Paragraph>
</div>
{/* Find Your Representatives Section */}
<div style={{ marginBottom: 32 }}>
<Title level={3} style={{ color: '#fff', textAlign: 'center', marginBottom: 16 }}>
Find Your Representatives
</Title>
<Card
style={{
background: 'rgba(27, 40, 56, 0.8)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
maxWidth: 600,
margin: '0 auto',
}}
>
<Text style={{ color: 'rgba(255,255,255,0.7)', display: 'block', textAlign: 'center', marginBottom: 12 }}>
Enter your postal code:
</Text>
<div style={{ display: 'flex', gap: 12 }}>
<Input
placeholder="e.g. T5K 2M5"
value={postalCode}
onChange={(e) => setPostalCode(e.target.value)}
onPressEnter={handleLookup}
size="large"
style={{ textTransform: 'uppercase', flex: 1 }}
disabled={lookupLoading}
prefix={lookupLoading ? <Spin size="small" /> : undefined}
/>
<Button
type="primary"
size="large"
icon={<SearchOutlined />}
onClick={handleLookup}
loading={lookupLoading}
style={{ background: '#005a9c' }}
>
Search
</Button>
</div>
</Card>
{/* Representative Results */}
{repsByLevel && (
<div style={{ marginTop: 24 }}>
{locationText && (
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', textAlign: 'center', marginBottom: 16 }}>
Showing representatives for {locationText}
</Text>
)}
{representatives!.length === 0 ? (
<Result
status="info"
title="No Representatives Found"
subTitle="No representatives were found for this postal code."
/>
) : (
Object.entries(repsByLevel).map(([level, reps]) => (
<div key={level} style={{ marginBottom: 24 }}>
<Title level={4} style={{ color: GOVERNMENT_LEVEL_DISPLAY_COLORS[level] || '#94a3b8', marginBottom: 12 }}>
{GOVERNMENT_LEVEL_LABELS[level as GovernmentLevel] || level} Representatives
</Title>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{reps.map((rep, idx) => (
<Card
key={rep.id || idx}
style={{
background: '#1b2838',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
}}
>
<div style={{ display: 'flex', gap: 16, flexDirection: isMobile ? 'column' : 'row' }}>
<Avatar src={rep.photoUrl} icon={!rep.photoUrl ? <UserOutlined /> : undefined} size={isMobile ? 64 : 80} />
<div style={{ flex: 1 }}>
<Text strong style={{ color: token.colorPrimary, fontSize: 17 }}>
{rep.name || 'Unknown'}
</Text>
<div style={{ marginTop: 4 }}>
{rep.electedOffice && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Office:</Text> {rep.electedOffice}</Text></div>
)}
{rep.districtName && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>District:</Text> {rep.districtName}</Text></div>
)}
{rep.partyName && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Party:</Text> {rep.partyName}</Text></div>
)}
{rep.email && (
<div><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Email:</Text> {rep.email}</Text></div>
)}
{rep.offices?.map((office, oi) => (
office.tel && (
<div key={oi}><Text style={{ color: 'rgba(255,255,255,0.7)' }}><Text strong style={{ color: 'rgba(255,255,255,0.85)' }}>Phone:</Text> {office.tel}{office.type ? ` (${office.type})` : ''}</Text></div>
)
))}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 12 }}>
{rep.email && (
<Button
type="primary"
size="small"
icon={<MailOutlined />}
onClick={() => window.open(`mailto:${rep.email}`, '_blank')}
style={{ background: '#005a9c' }}
>
Send Email
</Button>
)}
{rep.offices?.some(o => o.tel) && (
<Button
size="small"
icon={<PhoneOutlined />}
style={{ background: '#16a34a', border: 'none', color: '#fff' }}
onClick={() => {
const phone = rep.offices?.find(o => o.tel)?.tel;
if (phone) window.open(`tel:${phone.replace(/\s/g, '')}`, '_blank');
}}
>
Call
</Button>
)}
{rep.url && (
<Button
size="small"
icon={<GlobalOutlined />}
onClick={() => window.open(rep.url!, '_blank')}
>
View Profile
</Button>
)}
</div>
{rep.offices && rep.offices.length > 0 && (
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
{rep.offices.map((office, oi) => (
office.postal && (
<Tag key={oi} color="processing" style={{ fontSize: 11 }}>
{office.type || 'Office'}: {office.postal}
</Tag>
)
))}
</div>
)}
</div>
</div>
</Card>
))}
</Space>
</div>
))
)}
</div>
)}
</div>
<Divider style={{ borderColor: 'rgba(255,255,255,0.08)' }} />
{/* Featured Campaign */}
{featured.map((campaign) => (
<div key={campaign.id} style={{ marginBottom: 24 }}>
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block' }}>
<Card
hoverable
style={{
background: 'linear-gradient(135deg, #1b2838 0%, #1a3a5c 100%)',
border: '1px solid rgba(255,215,0,0.3)',
borderRadius: 12,
overflow: 'hidden',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<StarFilled style={{ color: '#ffd700', fontSize: 16 }} />
<Tag color="gold" style={{ margin: 0, fontWeight: 600, fontSize: 11 }}>FEATURED</Tag>
</div>
<Title level={3} style={{ color: '#fff', margin: '4px 0 8px' }}>{campaign.title}</Title>
{campaign.description && (
<Paragraph style={{ color: 'rgba(255,255,255,0.7)', margin: '0 0 12px' }} ellipsis={{ rows: 2 }}>
{campaign.description}
</Paragraph>
)}
<div style={{ display: 'flex', gap: 24, flexWrap: 'wrap', alignItems: 'center' }}>
{campaign.showEmailCount && (
<Statistic
value={campaign._count.emails}
suffix="emails sent"
valueStyle={{ color: token.colorPrimary, fontSize: 18, fontWeight: 700 }}
/>
)}
{campaign.showResponseWall && campaign._count.responses > 0 && (
<Statistic
value={campaign._count.responses}
suffix="responses"
valueStyle={{ color: token.colorSuccess, fontSize: 18, fontWeight: 700 }}
/>
)}
{campaign.targetGovernmentLevels.map((l: GovernmentLevel) => (
<Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]}>{GOVERNMENT_LEVEL_LABELS[l]}</Tag>
))}
</div>
</Card>
</Link>
<ShareButtons campaign={campaign} />
</div>
))}
{/* Campaigns Grid */}
<Title level={3} style={{ color: 'rgba(255,255,255,0.85)', textAlign: 'center', marginBottom: 8 }}>
Active Campaigns
</Title>
<Paragraph style={{ color: 'rgba(255,255,255,0.55)', textAlign: 'center', marginBottom: 24 }}>
Join ongoing campaigns to make your voice heard on important issues
</Paragraph>
{campaigns.length === 0 ? (
<Empty
description={<Text style={{ color: 'rgba(255,255,255,0.45)' }}>No active campaigns</Text>}
style={{ padding: 60 }}
/>
) : nonFeatured.length === 0 ? (
<Empty
description={<Text style={{ color: 'rgba(255,255,255,0.45)' }}>Check out the featured campaign above</Text>}
style={{ padding: 60 }}
/>
) : (
<Row gutter={[20, 20]}>
{nonFeatured.map((campaign) => (
<Col xs={24} sm={12} lg={8} key={campaign.id}>
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Card
style={{
flex: 1,
background: '#1b2838',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
overflow: 'hidden',
}}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: '100%' } }}
>
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block', flex: 1, padding: 20 }}>
{campaign.coverPhoto ? (
<div
style={{
height: 140,
margin: '-20px -20px 16px -20px',
backgroundImage: `url(${campaign.coverPhoto})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Title level={4} style={{ color: '#fff', margin: 0, textShadow: '0 2px 8px rgba(0,0,0,0.6)', textAlign: 'center', padding: '0 16px' }} ellipsis={{ rows: 2 }}>
{campaign.title}
</Title>
</div>
) : (
<div
style={{
height: 140,
margin: '-20px -20px 16px -20px',
background: 'linear-gradient(135deg, #1a3a5c 0%, #1b2838 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Title level={4} style={{ color: '#fff', margin: 0, textAlign: 'center', padding: '0 16px' }} ellipsis={{ rows: 2 }}>
{campaign.title}
</Title>
</div>
)}
{campaign.description && (
<Paragraph
style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13, margin: '0 0 12px' }}
ellipsis={{ rows: 2 }}
>
{campaign.description}
</Paragraph>
)}
<div style={{ marginBottom: 8 }}>
{campaign.targetGovernmentLevels.map((l: GovernmentLevel) => (
<Tag key={l} color={GOVERNMENT_LEVEL_COLORS[l]} style={{ fontSize: 11 }}>
{GOVERNMENT_LEVEL_LABELS[l]}
</Tag>
))}
</div>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
{campaign.showEmailCount && (
<span style={{ color: token.colorPrimary, fontSize: 13 }}>
<MailOutlined style={{ marginRight: 4 }} />
{campaign._count.emails} emails sent
</span>
)}
{campaign.showResponseWall && (
<span style={{ color: token.colorSuccess, fontSize: 13 }}>
<MessageOutlined style={{ marginRight: 4 }} />
{campaign._count.responses} responses
</span>
)}
</div>
</Link>
<div style={{ borderTop: '1px solid rgba(255,255,255,0.06)', padding: '8px 20px' }}>
<ShareButtons campaign={campaign} compact />
<Link to={`/campaign/${campaign.slug}`} style={{ display: 'block', marginTop: 4 }}>
<Button type="link" style={{ padding: 0, color: token.colorPrimary, fontSize: 13 }}>
Learn More & Participate <ArrowRightOutlined />
</Button>
</Link>
</div>
</Card>
</div>
</Col>
))}
</Row>
)}
</div>
);
}
// --- Share Buttons Component ---
function ShareButtons({ campaign, compact }: { campaign: Campaign; compact?: boolean }) {
const url = `${window.location.origin}/campaign/${campaign.slug}`;
const text = `${campaign.title} - Take action now!`;
const shareLinks = [
{
label: 'Share on X',
shortLabel: 'X',
icon: <span style={{ fontWeight: 700, fontSize: compact ? 12 : 14 }}>&#x1D54F;</span>,
href: `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`,
},
{
label: 'Share on Facebook',
shortLabel: 'Facebook',
icon: <span style={{ fontWeight: 700, fontSize: compact ? 12 : 14 }}>f</span>,
href: `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`,
},
{
label: 'Share on LinkedIn',
shortLabel: 'LinkedIn',
icon: <span style={{ fontWeight: 700, fontSize: compact ? 11 : 13 }}>in</span>,
href: `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(url)}`,
},
{
label: 'Share on Reddit',
shortLabel: 'Reddit',
icon: <span style={{ fontWeight: 700, fontSize: compact ? 12 : 14 }}>r/</span>,
href: `https://reddit.com/submit?url=${encodeURIComponent(url)}&title=${encodeURIComponent(campaign.title)}`,
},
{
label: 'Share via Email',
shortLabel: 'Email',
icon: <MailOutlined style={{ fontSize: compact ? 12 : 14 }} />,
href: `mailto:?subject=${encodeURIComponent(campaign.title)}&body=${encodeURIComponent(`Check out this campaign: ${url}`)}`,
},
];
const handleCopy = () => {
navigator.clipboard.writeText(url);
message.success('Link copied!');
};
const size = compact ? 30 : 34;
const btnStyle: React.CSSProperties = {
width: size,
height: size,
minWidth: size,
padding: 0,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
border: '1px solid rgba(255,255,255,0.15)',
background: 'transparent',
color: 'rgba(255,255,255,0.6)',
cursor: 'pointer',
transition: 'all 0.2s',
};
return (
<div style={{ display: 'flex', alignItems: 'center', gap: compact ? 6 : 8, marginTop: compact ? 0 : 8, flexWrap: 'wrap' }}>
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: compact ? 12 : 13 }}>Share:</Text>
{shareLinks.map((link) => (
<Tooltip key={link.shortLabel} title={link.shortLabel}>
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
style={btnStyle}
onClick={(e) => e.stopPropagation()}
aria-label={link.label}
>
{link.icon}
</a>
</Tooltip>
))}
<Tooltip title="Copy Link">
<button
onClick={(e) => { e.stopPropagation(); e.preventDefault(); handleCopy(); }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
handleCopy();
}
}}
style={btnStyle}
aria-label="Copy link to campaign"
>
<CopyOutlined style={{ fontSize: compact ? 12 : 14 }} />
</button>
</Tooltip>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Spin, Result } from 'antd';
import axios from 'axios';
import type { LandingPage as LandingPageType } from '@/types/api';
export default function PublicLandingPage() {
const { slug } = useParams<{ slug: string }>();
const [page, setPage] = useState<LandingPageType | null>(null);
const [loading, setLoading] = useState(true);
const [notFound, setNotFound] = useState(false);
useEffect(() => {
const fetchPage = async () => {
try {
const { data } = await axios.get<LandingPageType>(`/api/pages/${slug}/view`);
setPage(data);
// Set SEO meta
document.title = data.seoTitle || data.title;
const metaDesc = document.querySelector('meta[name="description"]');
if (metaDesc && data.seoDescription) {
metaDesc.setAttribute('content', data.seoDescription);
}
const metaOgImage = document.querySelector('meta[property="og:image"]');
if (metaOgImage && data.seoImage) {
metaOgImage.setAttribute('content', data.seoImage);
}
} catch {
setNotFound(true);
} finally {
setLoading(false);
}
};
fetchPage();
}, [slug]);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#0d1b2a' }}>
<Spin size="large" />
</div>
);
}
if (notFound || !page) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: '#0d1b2a' }}>
<Result
status="404"
title="Page Not Found"
subTitle="The page you're looking for doesn't exist or hasn't been published yet."
style={{ color: '#fff' }}
/>
</div>
);
}
// HTML/CSS is admin-authored via GrapesJS editor (not user-submitted content).
// Only authenticated admins can create/edit pages, so XSS risk is accepted.
return (
<>
{page.cssOutput && <style dangerouslySetInnerHTML={{ __html: page.cssOutput }} />}
<div dangerouslySetInnerHTML={{ __html: page.htmlOutput || '' }} />
</>
);
}

View File

@ -0,0 +1,411 @@
import { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { ConfigProvider, Spin, Typography, theme, Button, Tooltip, message } from 'antd';
import { Link } from 'react-router-dom';
import { ArrowLeftOutlined, AimOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, useMapEvents } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import axios from 'axios';
import { useSettingsStore } from '@/stores/settings.store';
import type { MapSettings, Location, SupportLevel, PublicCut } from '@/types/api';
import { SUPPORT_LEVEL_LABELS } from '@/types/api';
import { groupLocations, getMarkerColor } from '@/components/map/mapUtils';
import MapLegend from '@/components/map/MapLegend';
import CutOverlays from '@/components/map/CutOverlays';
import CutOverlayControls from '@/components/map/CutOverlayControls';
import 'leaflet/dist/leaflet.css';
type BoundsQuery = {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
};
const HEADER_HEIGHT = 48;
function FlyToPosition({ position }: { position: [number, number] }) {
const map = useMap();
useEffect(() => {
map.flyTo(position, 17, { duration: 1.5 });
}, [map, position]);
return null;
}
function FullscreenInvalidator() {
const map = useMap();
useEffect(() => {
const handler = () => {
setTimeout(() => map.invalidateSize(), 100);
};
document.addEventListener('fullscreenchange', handler);
document.addEventListener('webkitfullscreenchange', handler);
return () => {
document.removeEventListener('fullscreenchange', handler);
document.removeEventListener('webkitfullscreenchange', handler);
};
}, [map]);
return null;
}
function MapEventsHandler({ onMove }: { onMove: (map: LeafletMap) => void }) {
const map = useMapEvents({
moveend: () => {
// Only trigger if not animating to prevent Leaflet state corruption
if (!map._animatingZoom && !map._moving) {
onMove(map);
}
},
zoomend: () => {
// Wait a tick for Leaflet to finish internal zoom state updates
setTimeout(() => {
if (!map._animatingZoom && !map._moving) {
onMove(map);
}
}, 100);
},
});
return null;
}
export default function MapPage() {
const [settings, setSettings] = useState<MapSettings | null>(null);
const [locations, setLocations] = useState<Location[]>([]);
const [loading, setLoading] = useState(true);
const [loadingLocations, setLoadingLocations] = useState(false);
const [cuts, setCuts] = useState<PublicCut[]>([]);
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
const [userPosition, setUserPosition] = useState<[number, number] | null>(null);
const [flyTo, setFlyTo] = useState<[number, number] | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [bounds, setBounds] = useState<BoundsQuery | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>();
const abortControllerRef = useRef<AbortController | null>(null);
const { settings: siteSettings } = useSettingsStore();
const fetchLocations = useCallback(async (b: BoundsQuery) => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
setLoadingLocations(true);
const controller = new AbortController();
abortControllerRef.current = controller;
try {
const params = new URLSearchParams({
minLat: b.minLat.toString(),
maxLat: b.maxLat.toString(),
minLng: b.minLng.toString(),
maxLng: b.maxLng.toString(),
});
const res = await axios.get<Location[]>(`/api/map/locations/public?${params}`, {
signal: controller.signal,
});
// Check if we hit the safety limit
const limitHit = res.headers['x-location-limit-hit'] === 'true';
if (limitHit) {
message.warning('Too many locations in view. Zoom in for more detail.', 3);
}
setLocations(res.data);
setBounds(b);
} catch (err) {
if (!axios.isCancel(err)) {
message.error('Failed to load locations');
}
} finally {
setLoadingLocations(false);
abortControllerRef.current = null;
}
}, []);
const handleMapMove = useCallback((map: LeafletMap) => {
const b = map.getBounds();
const newBounds = {
minLat: b.getSouth(),
maxLat: b.getNorth(),
minLng: b.getWest(),
maxLng: b.getEast(),
};
clearTimeout(fetchTimerRef.current);
fetchTimerRef.current = setTimeout(() => {
fetchLocations(newBounds);
}, 800); // Increased debounce to 800ms to allow zoom animations to complete
}, [fetchLocations]);
useEffect(() => {
const fetchData = async () => {
try {
const [settingsRes, cutsRes] = await Promise.all([
axios.get<MapSettings>('/api/map/settings'),
axios.get<PublicCut[]>('/api/map/cuts/public'),
]);
setSettings(settingsRes.data);
setCuts(cutsRes.data);
setVisibleCutIds(new Set(cutsRes.data.map((c) => c.id)));
} catch {
// Still render map with defaults
} finally {
setLoading(false);
}
};
fetchData();
}, []);
// Clear user position after 30s
useEffect(() => {
if (userPosition) {
const timer = setTimeout(() => setUserPosition(null), 30000);
return () => clearTimeout(timer);
}
}, [userPosition]);
// Track fullscreen state
useEffect(() => {
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
document.addEventListener('webkitfullscreenchange', handler);
return () => {
document.removeEventListener('fullscreenchange', handler);
document.removeEventListener('webkitfullscreenchange', handler);
};
}, []);
const groups = useMemo(() => groupLocations(locations), [locations]);
const center: [number, number] = settings?.latitude && settings?.longitude
? [parseFloat(settings.latitude), parseFloat(settings.longitude)]
: [45.4215, -75.6972];
const zoom = settings?.zoom ?? 12;
const handleGeolocate = useCallback(() => {
if (!navigator.geolocation) {
message.error('Geolocation not supported');
return;
}
navigator.geolocation.getCurrentPosition(
(pos) => {
const p: [number, number] = [pos.coords.latitude, pos.coords.longitude];
setUserPosition(p);
setFlyTo(p);
setTimeout(() => setFlyTo(null), 2000);
},
() => message.error('Could not get your location'),
{ enableHighAccuracy: true, timeout: 10000 }
);
}, []);
const handleFullscreen = useCallback(() => {
const el = containerRef.current;
if (!el) return;
if (!document.fullscreenElement) {
const requestFs = el.requestFullscreen || (el as any).webkitRequestFullscreen;
if (requestFs) requestFs.call(el);
} else {
const exitFs = document.exitFullscreen || (document as any).webkitExitFullscreen;
if (exitFs) exitFs.call(document);
}
}, []);
const toggleCut = useCallback((id: string) => {
setVisibleCutIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: siteSettings?.publicColorPrimary ?? '#3498db',
colorBgBase: siteSettings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: siteSettings?.publicColorBgContainer ?? '#1b2838',
colorBgElevated: siteSettings?.publicColorBgContainer ?? '#1b2838',
colorBorder: 'rgba(255,255,255,0.1)',
colorBorderSecondary: 'rgba(255,255,255,0.06)',
borderRadius: 8,
colorLink: siteSettings?.publicColorPrimary ?? '#3498db',
},
}}
>
<div ref={containerRef} style={{ height: '100vh', display: 'flex', flexDirection: 'column', background: siteSettings?.publicColorBgBase ?? '#0d1b2a' }}>
{/* Thin header */}
<div
style={{
height: HEADER_HEIGHT,
background: siteSettings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
flexShrink: 0,
}}
>
<Link to="/campaigns" style={{ textDecoration: 'none', display: 'flex', alignItems: 'center', gap: 8 }}>
<ArrowLeftOutlined style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }} />
<Typography.Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13 }}>
Back to Campaigns
</Typography.Text>
</Link>
<Typography.Text strong style={{ fontSize: 16, color: '#fff' }}>
{siteSettings?.organizationName ?? 'Changemaker Lite'}
</Typography.Text>
<div style={{ width: 140 }} /> {/* Spacer for centering */}
</div>
{/* Full-viewport map */}
{loading ? (
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<Spin size="large" />
</div>
) : (
<div style={{ flex: 1, position: 'relative' }}>
{/* Floating controls */}
<div
style={{
position: 'absolute',
top: 10,
right: 10,
zIndex: 1000,
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<Tooltip title="My Location" placement="left">
<Button
type="primary"
shape="circle"
icon={<AimOutlined />}
onClick={handleGeolocate}
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
/>
</Tooltip>
<Tooltip title={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} placement="left">
<Button
type="primary"
shape="circle"
icon={isFullscreen ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={handleFullscreen}
style={{ background: 'rgba(27, 40, 56, 0.9)', borderColor: 'rgba(255,255,255,0.2)' }}
/>
</Tooltip>
</div>
{loadingLocations && (
<div style={{ position: 'absolute', top: 16, right: 16, zIndex: 1000 }}>
<Spin size="small" />
</div>
)}
<MapContainer
center={center}
zoom={zoom}
style={{ width: '100%', height: '100%' }}
scrollWheelZoom={true}
>
<FullscreenInvalidator />
<MapEventsHandler onMove={handleMapMove} />
{flyTo && <FlyToPosition position={flyTo} />}
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Cut overlays (render beneath markers) */}
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
{/* User position */}
{userPosition && (
<CircleMarker
center={userPosition}
radius={8}
pathOptions={{
fillColor: '#2196F3',
fillOpacity: 0.9,
color: '#fff',
weight: 2,
opacity: 1,
}}
/>
)}
{groups.map((group, idx) => {
const color = getMarkerColor(group.dominantLevel);
const radius = group.isMultiUnit ? 10 : 7;
return (
<CircleMarker
key={idx}
center={[group.latitude, group.longitude]}
radius={radius}
pathOptions={{
fillColor: color,
fillOpacity: 0.8,
color: '#fff',
weight: group.isMultiUnit ? 2 : 1,
opacity: 0.9,
}}
>
<Popup>
<div style={{ minWidth: 140 }}>
{group.locations.map((loc, i) => (
<div key={loc.id} style={{ marginBottom: i < group.locations.length - 1 ? 8 : 0 }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>
{loc.address || 'Unknown address'}
</div>
{loc.unitNumber && (
<div style={{ fontSize: 12, color: '#666' }}>Unit {loc.unitNumber}</div>
)}
{loc.supportLevel && (
<div style={{ fontSize: 12 }}>
<span
style={{
display: 'inline-block',
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: getMarkerColor(loc.supportLevel as SupportLevel),
marginRight: 4,
}}
/>
{SUPPORT_LEVEL_LABELS[loc.supportLevel as SupportLevel]}
</div>
)}
{loc.sign && (
<div style={{ fontSize: 11, color: '#888' }}>
Sign{loc.signSize ? ` (${loc.signSize})` : ''}
</div>
)}
</div>
))}
</div>
</Popup>
</CircleMarker>
);
})}
</MapContainer>
<MapLegend variant="public" />
{/* Cut overlay controls */}
{cuts.length > 0 && (
<CutOverlayControls
cuts={cuts}
visibleCutIds={visibleCutIds}
onToggleCut={toggleCut}
variant="public"
/>
)}
</div>
)}
</div>
</ConfigProvider>
);
}

View File

@ -0,0 +1,491 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
Typography,
Card,
Button,
Space,
Tag,
Row,
Col,
Select,
Statistic,
Spin,
Result,
Modal,
Form,
Input,
Checkbox,
message,
Pagination,
Divider,
Grid,
Tooltip,
theme,
} from 'antd';
import {
LikeOutlined,
LikeFilled,
SafetyCertificateOutlined,
ArrowLeftOutlined,
PlusOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import type {
Campaign,
RepresentativeResponse,
ResponseStats,
GovernmentLevel,
} from '@/types/api';
import {
GOVERNMENT_LEVEL_COLORS,
GOVERNMENT_LEVEL_LABELS,
RESPONSE_TYPE_LABELS,
} from '@/types/api';
import dayjs from 'dayjs';
const { Title, Text, Paragraph } = Typography;
const apiBase = '/api';
export default function ResponseWallPage() {
const { slug } = useParams<{ slug: string }>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [responses, setResponses] = useState<RepresentativeResponse[]>([]);
const [stats, setStats] = useState<ResponseStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [sort, setSort] = useState<'recent' | 'upvotes' | 'verified'>('recent');
const [levelFilter, setLevelFilter] = useState<GovernmentLevel | undefined>();
const [submitModalOpen, setSubmitModalOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [upvotedIds, setUpvotedIds] = useState<Set<string>>(new Set());
const [form] = Form.useForm();
useEffect(() => {
fetchCampaign();
fetchStats();
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchCampaign = async () => {
try {
const { data } = await axios.get<Campaign>(`${apiBase}/campaigns/${slug}/details`);
setCampaign(data);
} catch {
setError('Campaign not found or is no longer active.');
}
};
const fetchStats = async () => {
try {
const { data } = await axios.get<ResponseStats>(`${apiBase}/campaigns/${slug}/response-stats`);
setStats(data);
} catch {
// Non-critical
}
};
const fetchResponses = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: 20, sort };
if (levelFilter) params.level = levelFilter;
const { data } = await axios.get(`${apiBase}/campaigns/${slug}/responses`, { params });
setResponses(data.responses);
setTotal(data.pagination.total);
} catch {
message.error('Failed to load responses');
} finally {
setLoading(false);
}
}, [slug, page, sort, levelFilter]);
useEffect(() => {
if (slug) fetchResponses();
}, [fetchResponses, slug]);
const handleUpvote = async (responseId: string) => {
if (upvotedIds.has(responseId)) {
// Remove upvote
try {
await axios.delete(`${apiBase}/responses/${responseId}/upvote`);
setUpvotedIds((prev) => {
const next = new Set(prev);
next.delete(responseId);
return next;
});
setResponses((prev) =>
prev.map((r) =>
r.id === responseId ? { ...r, upvoteCount: Math.max(0, r.upvoteCount - 1) } : r
)
);
} catch {
// Ignore
}
} else {
try {
const { data } = await axios.post(`${apiBase}/responses/${responseId}/upvote`);
if (data.alreadyUpvoted) {
message.info('You have already upvoted this response');
setUpvotedIds((prev) => new Set(prev).add(responseId));
} else {
setUpvotedIds((prev) => new Set(prev).add(responseId));
setResponses((prev) =>
prev.map((r) =>
r.id === responseId ? { ...r, upvoteCount: r.upvoteCount + 1 } : r
)
);
}
} catch {
// Ignore
}
}
};
const handleSubmit = async (values: any) => {
setSubmitting(true);
try {
await axios.post(`${apiBase}/campaigns/${slug}/responses`, {
representativeName: values.representativeName,
representativeLevel: values.representativeLevel,
responseType: values.responseType,
responseText: values.responseText,
representativeTitle: values.representativeTitle || undefined,
representativeEmail: values.representativeEmail || undefined,
userComment: values.userComment || undefined,
submittedByName: values.submittedByName || undefined,
submittedByEmail: values.submittedByEmail || undefined,
isAnonymous: values.isAnonymous || false,
sendVerification: values.sendVerification || false,
});
message.success('Response submitted! It will appear after moderation.');
setSubmitModalOpen(false);
form.resetFields();
fetchResponses();
fetchStats();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to submit response';
message.error(msg);
} finally {
setSubmitting(false);
}
};
if (error) {
return <Result status="404" title="Campaign Not Found" subTitle={error} />;
}
return (
<div>
{/* Back link + Header */}
<Link to={`/campaign/${slug}`}>
<Button type="link" icon={<ArrowLeftOutlined />} style={{ padding: 0, marginBottom: 16 }}>
Back to Campaign
</Button>
</Link>
{campaign && (
<Link to={`/campaign/${slug}`} style={{ textDecoration: 'none' }}>
<div
style={{
background: 'linear-gradient(135deg, #3498db, #2c3e50)',
borderRadius: 12,
padding: isMobile ? '24px 16px' : '32px 24px',
textAlign: 'center',
marginBottom: 24,
cursor: 'pointer',
}}
>
<Title level={3} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
{campaign.title}
</Title>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 15 }}>Community Response Wall</Text>
</div>
</Link>
)}
{/* Stats Banner */}
{stats && (
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic title="Responses" value={stats.total} />
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic
title="Verified"
value={stats.verified}
prefix={<SafetyCertificateOutlined style={{ color: token.colorSuccess }} />}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card size="small">
<Statistic title="Upvotes" value={stats.totalUpvotes} prefix={<LikeOutlined />} />
</Card>
</Col>
</Row>
)}
{/* Controls */}
<Row gutter={[12, 12]} align="middle" style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Select
value={sort}
onChange={(v) => { setSort(v); setPage(1); }}
style={{ width: '100%' }}
options={[
{ value: 'recent', label: 'Most Recent' },
{ value: 'upvotes', label: 'Most Upvotes' },
{ value: 'verified', label: 'Verified First' },
]}
/>
</Col>
<Col xs={12} sm={6}>
<Select
placeholder="Filter by level"
allowClear
value={levelFilter}
onChange={(v) => { setLevelFilter(v); setPage(1); }}
style={{ width: '100%' }}
options={Object.entries(GOVERNMENT_LEVEL_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Col>
<Col xs={24} sm={12} style={{ textAlign: 'right' }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setSubmitModalOpen(true)}
>
Share a Response
</Button>
</Col>
</Row>
{/* Response Cards */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : responses.length === 0 ? (
<Result
status="info"
title="No Responses Yet"
subTitle="Be the first to share a representative's response!"
extra={
<Button type="primary" icon={<PlusOutlined />} onClick={() => setSubmitModalOpen(true)}>
Share a Response
</Button>
}
/>
) : (
<>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{responses.map((response) => (
<Card key={response.id} size="small">
<Row justify="space-between" align="top">
<Col flex="1">
<Space wrap>
<Text strong style={{ fontSize: 15 }}>
{response.representativeName}
</Text>
{response.representativeTitle && (
<Text type="secondary">
{response.representativeTitle}
</Text>
)}
</Space>
<br />
<Space size={4} style={{ marginTop: 4 }}>
<Tag color={GOVERNMENT_LEVEL_COLORS[response.representativeLevel]}>
{GOVERNMENT_LEVEL_LABELS[response.representativeLevel]}
</Tag>
<Tag>{RESPONSE_TYPE_LABELS[response.responseType]}</Tag>
{response.isVerified && (
<Tooltip title="This response was verified by emailing the representative">
<Tag icon={<SafetyCertificateOutlined />} color="success">
Verified
</Tag>
</Tooltip>
)}
</Space>
</Col>
<Col>
<Button
type={upvotedIds.has(response.id) ? 'primary' : 'default'}
size="small"
icon={upvotedIds.has(response.id) ? <LikeFilled /> : <LikeOutlined />}
onClick={() => handleUpvote(response.id)}
aria-label={upvotedIds.has(response.id) ? `Remove upvote (${response.upvoteCount} total)` : `Upvote response (${response.upvoteCount} total)`}
>
{response.upvoteCount}
</Button>
</Col>
</Row>
<Divider style={{ margin: '12px 0' }} />
<Paragraph style={{ margin: 0, whiteSpace: 'pre-wrap' }}>
{response.responseText}
</Paragraph>
{response.userComment && (
<div style={{
marginTop: 12,
padding: '8px 12px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 6,
borderLeft: `3px solid ${token.colorPrimary}`,
}}>
<Text type="secondary" style={{ fontSize: 12 }}>Comment: </Text>
<Text style={{ fontSize: 13 }}>{response.userComment}</Text>
</div>
)}
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{response.isAnonymous ? 'Anonymous' : response.submittedByName || 'Anonymous'}
{' '}&middot;{' '}
{dayjs(response.createdAt).format('MMM D, YYYY')}
</Text>
</div>
</Card>
))}
</Space>
<div style={{ textAlign: 'center', marginTop: 24 }}>
<Pagination
current={page}
total={total}
pageSize={20}
onChange={(p) => setPage(p)}
showSizeChanger={false}
/>
</div>
</>
)}
{/* Submit Response Modal */}
<Modal
title="Share a Representative's Response"
open={submitModalOpen}
onCancel={() => setSubmitModalOpen(false)}
footer={null}
width={isMobile ? '100%' : 560}
style={{ maxWidth: '95vw' }}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
name="representativeName"
label="Representative Name"
rules={[{ required: true, message: 'Required' }]}
>
<Input placeholder="e.g. John Smith" autoFocus />
</Form.Item>
<Row gutter={12}>
<Col xs={24} sm={12}>
<Form.Item
name="representativeLevel"
label="Government Level"
rules={[{ required: true, message: 'Required' }]}
>
<Select
placeholder="Select level"
options={Object.entries(GOVERNMENT_LEVEL_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item
name="responseType"
label="Response Type"
rules={[{ required: true, message: 'Required' }]}
>
<Select
placeholder="How did they respond?"
options={Object.entries(RESPONSE_TYPE_LABELS).map(([value, label]) => ({
value,
label,
}))}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="representativeTitle" label="Representative Title">
<Input placeholder="e.g. MP, MPP, City Councillor" />
</Form.Item>
<Form.Item
name="responseText"
label="Response Text"
rules={[{ required: true, message: 'Required' }]}
>
<Input.TextArea
rows={5}
placeholder="What did the representative say or write?"
/>
</Form.Item>
<Form.Item name="userComment" label="Your Comment (optional)">
<Input.TextArea rows={2} placeholder="Any additional context?" />
</Form.Item>
<Divider style={{ margin: '12px 0' }} />
<Row gutter={12}>
<Col xs={24} sm={12}>
<Form.Item name="submittedByName" label="Your Name">
<Input placeholder="Optional" />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="submittedByEmail" label="Your Email">
<Input placeholder="Optional" type="email" />
</Form.Item>
</Col>
</Row>
<Form.Item name="representativeEmail" label="Representative's Email">
<Input placeholder="For verification (optional)" type="email" />
</Form.Item>
<Space>
<Form.Item name="isAnonymous" valuePropName="checked" noStyle>
<Checkbox>Submit anonymously</Checkbox>
</Form.Item>
<Form.Item name="sendVerification" valuePropName="checked" noStyle>
<Checkbox>Send verification to representative</Checkbox>
</Form.Item>
</Space>
<div style={{ marginTop: 24, textAlign: 'right' }}>
<Space>
<Button onClick={() => setSubmitModalOpen(false)}>Cancel</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Submit Response
</Button>
</Space>
</div>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,343 @@
import { useState, useEffect } from 'react';
import {
Typography,
Card,
Button,
Row,
Col,
Progress,
Modal,
Form,
Input,
message,
Spin,
Result,
Grid,
theme,
} from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
TeamOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
const { Title, Text, Paragraph } = Typography;
const apiBase = '/api';
interface PublicShift {
id: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: 'OPEN' | 'FULL' | 'CANCELLED';
}
export default function PublicShiftsPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [shifts, setShifts] = useState<PublicShift[]>([]);
const [loading, setLoading] = useState(true);
const [signupModalOpen, setSignupModalOpen] = useState(false);
const [selectedShift, setSelectedShift] = useState<PublicShift | null>(null);
const [submitting, setSubmitting] = useState(false);
const [successShift, setSuccessShift] = useState<PublicShift | null>(null);
const [form] = Form.useForm();
useEffect(() => {
fetchShifts();
}, []);
const fetchShifts = async () => {
setLoading(true);
try {
const { data } = await axios.get<PublicShift[]>(`${apiBase}/map/shifts/public`);
setShifts(data);
} catch {
message.error('Failed to load volunteer opportunities');
} finally {
setLoading(false);
}
};
const handleSignup = async (values: { name: string; email: string; phone?: string }) => {
if (!selectedShift) return;
setSubmitting(true);
try {
await axios.post(`${apiBase}/map/shifts/public/${selectedShift.id}/signup`, {
name: values.name,
email: values.email,
phone: values.phone || undefined,
});
setSuccessShift(selectedShift);
setSignupModalOpen(false);
form.resetFields();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to sign up';
message.error(msg);
} finally {
setSubmitting(false);
}
};
const openSignup = (shift: PublicShift) => {
setSelectedShift(shift);
form.resetFields();
setSignupModalOpen(true);
};
return (
<div>
{/* Hero */}
<div
style={{
background: 'linear-gradient(135deg, #3498db, #2c3e50)',
borderRadius: 12,
padding: isMobile ? '32px 16px' : '48px 24px',
textAlign: 'center',
marginBottom: 32,
}}
>
<Title level={2} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.2)' }}>
Volunteer Opportunities
</Title>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 16 }}>
Sign up for upcoming shifts and make a difference
</Text>
</div>
{/* Content */}
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}>
<Spin size="large" />
</div>
) : shifts.length === 0 ? (
<Result
status="info"
title="No Upcoming Shifts"
subTitle="Check back later for volunteer opportunities."
/>
) : (
<Row gutter={[16, 16]}>
{shifts.map((shift) => {
const spotsLeft = shift.maxVolunteers - shift.currentVolunteers;
const isFull = shift.status === 'FULL' || spotsLeft <= 0;
const pct = shift.maxVolunteers > 0
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100)
: 0;
return (
<Col key={shift.id} xs={24} sm={12} lg={8}>
<Card
hoverable={!isFull}
style={{
height: '100%',
opacity: isFull ? 0.7 : 1,
}}
styles={{
body: {
display: 'flex',
flexDirection: 'column',
height: '100%',
},
}}
>
<div style={{ flex: 1 }}>
<Title level={5} style={{ margin: '0 0 8px 0' }}>
{shift.title}
</Title>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>
<CalendarOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{dayjs(shift.date).format('dddd, MMMM D, YYYY')}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{shift.startTime} {shift.endTime}</Text>
</div>
{shift.location && (
<div style={{ marginBottom: 4 }}>
<EnvironmentOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{shift.location}</Text>
</div>
)}
</div>
{shift.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ marginBottom: 12 }}
>
{shift.description}
</Paragraph>
)}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary">
<TeamOutlined style={{ marginRight: 4 }} />
Volunteers
</Text>
<Text type="secondary">{shift.currentVolunteers}/{shift.maxVolunteers}</Text>
</div>
<Progress
percent={pct}
size="small"
status={isFull ? 'exception' : 'active'}
showInfo={false}
/>
<Text type="secondary" style={{ fontSize: 12 }}>
{isFull ? 'No spots available' : `${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} remaining`}
</Text>
</div>
</div>
<Button
type="primary"
block
size="large"
disabled={isFull}
onClick={() => openSignup(shift)}
>
{isFull ? 'Shift Full' : 'Sign Up'}
</Button>
</Card>
</Col>
);
})}
</Row>
)}
{/* Signup Modal */}
<Modal
title={
<span>
<CalendarOutlined style={{ marginRight: 8 }} />
Sign Up {selectedShift?.title}
</span>
}
open={signupModalOpen}
onCancel={() => setSignupModalOpen(false)}
footer={null}
width={isMobile ? '100%' : 440}
style={{ maxWidth: '95vw' }}
destroyOnClose
>
{selectedShift && (
<div style={{
padding: '12px 16px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
marginBottom: 16,
}}>
<div>
<CalendarOutlined style={{ marginRight: 8 }} />
<Text>{dayjs(selectedShift.date).format('dddd, MMMM D, YYYY')}</Text>
</div>
<div>
<ClockCircleOutlined style={{ marginRight: 8 }} />
<Text>{selectedShift.startTime} {selectedShift.endTime}</Text>
</div>
{selectedShift.location && (
<div>
<EnvironmentOutlined style={{ marginRight: 8 }} />
<Text>{selectedShift.location}</Text>
</div>
)}
</div>
)}
<Form form={form} layout="vertical" onFinish={handleSignup}>
<Form.Item
name="name"
label="Your Name"
rules={[{ required: true, message: 'Name is required' }]}
>
<Input placeholder="Jane Doe" autoFocus />
</Form.Item>
<Form.Item
name="email"
label="Email Address"
rules={[
{ required: true, message: 'Email is required' },
{ type: 'email', message: 'Enter a valid email' },
]}
>
<Input placeholder="jane@example.com" />
</Form.Item>
<Form.Item name="phone" label="Phone (optional)">
<Input placeholder="555-123-4567" />
</Form.Item>
<div style={{ textAlign: 'right' }}>
<Button onClick={() => setSignupModalOpen(false)} style={{ marginRight: 8 }}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={submitting}>
Sign Up
</Button>
</div>
</Form>
</Modal>
{/* Success Modal */}
<Modal
open={!!successShift}
onCancel={() => setSuccessShift(null)}
footer={
<Button type="primary" onClick={() => setSuccessShift(null)}>
Done
</Button>
}
width={isMobile ? '100%' : 440}
style={{ maxWidth: '95vw' }}
>
{successShift && (
<Result
icon={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
title="You're signed up!"
subTitle="A confirmation email has been sent with your shift details."
extra={
<div style={{
textAlign: 'left',
padding: '16px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
}}>
<div style={{ marginBottom: 4 }}>
<Text strong>{successShift.title}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
<Text>{dayjs(successShift.date).format('dddd, MMMM D, YYYY')}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<ClockCircleOutlined style={{ marginRight: 8 }} />
<Text>{successShift.startTime} {successShift.endTime}</Text>
</div>
{successShift.location && (
<div>
<EnvironmentOutlined style={{ marginRight: 8 }} />
<Text>{successShift.location}</Text>
</div>
)}
</div>
}
/>
)}
</Modal>
</div>
);
}

View File

@ -0,0 +1,136 @@
import { useEffect, useState } from 'react';
import { Card, Row, Col, Statistic, Table, Tag, Typography, Spin, Grid, App } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { MyCanvassStats, CanvassVisit } from '@/types/canvass';
import { VISIT_OUTCOME_LABELS, VISIT_OUTCOME_COLORS, type VisitOutcome } from '@/types/canvass';
import type { PaginationMeta } from '@/types/api';
export default function MyActivityPage() {
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [stats, setStats] = useState<MyCanvassStats | null>(null);
const [visits, setVisits] = useState<CanvassVisit[]>([]);
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const [statsRes, visitsRes] = await Promise.all([
api.get('/map/canvass/my/stats'),
api.get('/map/canvass/my/visits', { params: { page, limit: 20 } }),
]);
setStats(statsRes.data);
setVisits(visitsRes.data.visits);
setPagination(visitsRes.data.pagination);
} catch {
message.error('Failed to load activity');
} finally {
setLoading(false);
}
};
load();
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
const columns: ColumnsType<CanvassVisit> = [
{
title: 'Address',
key: 'address',
render: (_: unknown, record: CanvassVisit) => (
<span>
{record.location?.address ?? 'Unknown'}
{record.location?.unitNumber && ` #${record.location.unitNumber}`}
</span>
),
},
{
title: 'Outcome',
dataIndex: 'outcome',
key: 'outcome',
render: (outcome: VisitOutcome) => (
<Tag color={VISIT_OUTCOME_COLORS[outcome]}>
{VISIT_OUTCOME_LABELS[outcome]}
</Tag>
),
},
{
title: 'When',
dataIndex: 'visitedAt',
key: 'visitedAt',
render: (val: string) => dayjs(val).format('MMM D, h:mm A'),
},
];
if (loading && !stats) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
// Outcome breakdown
const outcomeEntries = stats?.byOutcome
? Object.entries(stats.byOutcome).sort((a, b) => b[1] - a[1])
: [];
return (
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>My Activity</Typography.Title>
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
<Col xs={12} md={8}>
<Card size="small">
<Statistic title="Today" value={stats?.todayVisits ?? 0} />
</Card>
</Col>
<Col xs={12} md={8}>
<Card size="small">
<Statistic title="Total Doors" value={stats?.totalVisits ?? 0} />
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Statistic title="Sessions" value={stats?.sessions ?? 0} />
</Card>
</Col>
</Row>
{outcomeEntries.length > 0 && (
<Card size="small" title="Outcome Breakdown" style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{outcomeEntries.map(([key, count]) => (
<Tag
key={key}
color={VISIT_OUTCOME_COLORS[key as VisitOutcome]}
style={{ fontSize: 13 }}
>
{VISIT_OUTCOME_LABELS[key as VisitOutcome]}: {count}
</Tag>
))}
</div>
</Card>
)}
<Card title="Visit History" size="small">
<Table
dataSource={visits}
columns={columns}
rowKey="id"
size="small"
scroll={isMobile ? { x: 500 } : undefined}
loading={loading}
pagination={{
current: page,
total: pagination?.total ?? 0,
pageSize: 20,
showSizeChanger: false,
onChange: setPage,
}}
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,274 @@
import { useEffect, useState, useRef } from 'react';
import { Card, Row, Col, Statistic, Table, Button, Typography, Spin, Grid, App, theme } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { EyeOutlined, EyeInvisibleOutlined } from '@ant-design/icons';
import { MapContainer, TileLayer, Polyline, CircleMarker, useMap } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import { api } from '@/lib/api';
import type { TrackingSessionSummary, SessionRoute, TrackPointEvent } from '@/types/tracking';
import type { PaginationMeta } from '@/types/api';
dayjs.extend(duration);
const EVENT_COLORS: Record<TrackPointEvent, string> = {
SESSION_STARTED: '#52c41a',
SESSION_ENDED: '#ff4d4f',
VISIT_RECORDED: '#1890ff',
LOCATION_ADDED: '#fa8c16',
};
const EVENT_LABELS: Record<TrackPointEvent, string> = {
SESSION_STARTED: 'Start',
SESSION_ENDED: 'End',
VISIT_RECORDED: 'Visit',
LOCATION_ADDED: 'Location Added',
};
function FitBounds({ coordinates }: { coordinates: [number, number][] }) {
const map = useMap();
useEffect(() => {
if (coordinates.length > 0) {
const bounds = coordinates.map((c) => [c[0], c[1]] as [number, number]);
map.fitBounds(bounds, { padding: [30, 30] });
}
}, [coordinates, map]);
return null;
}
function formatDuration(startedAt: string, endedAt: string | null): string {
if (!endedAt) return 'Active';
const dur = dayjs.duration(dayjs(endedAt).diff(dayjs(startedAt)));
const h = Math.floor(dur.asHours());
const m = dur.minutes();
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function formatDistance(meters: number): string {
if (meters >= 1000) return `${(meters / 1000).toFixed(1)} km`;
return `${Math.round(meters)} m`;
}
export default function MyRoutesPage() {
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [sessions, setSessions] = useState<TrackingSessionSummary[]>([]);
const [pagination, setPagination] = useState<PaginationMeta | null>(null);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [route, setRoute] = useState<SessionRoute | null>(null);
const [routeLoading, setRouteLoading] = useState(false);
const mapRef = useRef<LeafletMap | null>(null);
useEffect(() => {
const load = async () => {
setLoading(true);
try {
const { data } = await api.get('/map/tracking/my/sessions', {
params: { page, limit: 15 },
});
setSessions(data.sessions);
setPagination(data.pagination);
} catch {
message.error('Failed to load routes');
} finally {
setLoading(false);
}
};
load();
}, [page]); // eslint-disable-line react-hooks/exhaustive-deps
const handleView = async (sessionId: string) => {
if (selectedId === sessionId) {
setSelectedId(null);
setRoute(null);
return;
}
setSelectedId(sessionId);
setRouteLoading(true);
try {
const { data } = await api.get(`/map/tracking/my/sessions/${sessionId}/route`);
setRoute(data);
} catch {
message.error('Failed to load route');
setSelectedId(null);
} finally {
setRouteLoading(false);
}
};
// Aggregate stats
const totalSessions = pagination?.total ?? 0;
const totalDistance = sessions.reduce((sum, s) => sum + s.totalDistanceM, 0);
const totalTime = sessions.reduce((sum, s) => {
if (!s.endedAt) return sum;
return sum + dayjs(s.endedAt).diff(dayjs(s.startedAt), 'minute');
}, 0);
const columns: ColumnsType<TrackingSessionSummary> = [
{
title: 'Date',
dataIndex: 'startedAt',
key: 'startedAt',
render: (val: string) => dayjs(val).format('MMM D, h:mm A'),
},
{
title: 'Duration',
key: 'duration',
render: (_: unknown, r: TrackingSessionSummary) => formatDuration(r.startedAt, r.endedAt),
},
{
title: 'Distance',
dataIndex: 'totalDistanceM',
key: 'distance',
render: (val: number) => formatDistance(val),
},
{
title: 'Points',
dataIndex: 'totalPoints',
key: 'points',
responsive: ['md'] as any,
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, r: TrackingSessionSummary) => (
<Button
type={selectedId === r.id ? 'primary' : 'text'}
icon={selectedId === r.id ? <EyeInvisibleOutlined /> : <EyeOutlined />}
size="small"
loading={routeLoading && selectedId === r.id}
onClick={() => handleView(r.id)}
>
{isMobile ? '' : selectedId === r.id ? 'Hide' : 'View'}
</Button>
),
},
];
if (loading && sessions.length === 0) {
return <div style={{ textAlign: 'center', padding: 48 }}><Spin size="large" /></div>;
}
return (
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>My Routes</Typography.Title>
<Row gutter={[12, 12]} style={{ marginBottom: 24 }}>
<Col xs={8}>
<Card size="small"><Statistic title="Sessions" value={totalSessions} /></Card>
</Col>
<Col xs={8}>
<Card size="small"><Statistic title="Distance" value={formatDistance(totalDistance)} /></Card>
</Col>
<Col xs={8}>
<Card size="small"><Statistic title="Time" value={totalTime > 60 ? `${Math.floor(totalTime / 60)}h ${totalTime % 60}m` : `${totalTime}m`} /></Card>
</Col>
</Row>
{/* Map */}
<Card
size="small"
style={{ marginBottom: 24, overflow: 'hidden' }}
styles={{ body: { padding: 0 } }}
>
<div style={{ height: 300 }}>
<MapContainer
center={[45.42, -75.69]}
zoom={12}
style={{ height: '100%', width: '100%' }}
ref={mapRef}
>
<TileLayer
attribution='&copy; <a href="https://carto.com">CARTO</a>'
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
/>
{route && route.coordinates.length > 0 && (
<>
<Polyline
positions={route.coordinates}
pathOptions={{ color: token.colorPrimary, weight: 3, opacity: 0.8 }}
/>
{route.events.map((evt, i) => (
<CircleMarker
key={i}
center={[evt.latitude, evt.longitude]}
radius={6}
pathOptions={{
color: EVENT_COLORS[evt.eventType],
fillColor: EVENT_COLORS[evt.eventType],
fillOpacity: 0.9,
weight: 2,
}}
>
</CircleMarker>
))}
<FitBounds coordinates={route.coordinates} />
</>
)}
{!route && (
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
zIndex: 1000,
color: 'rgba(255,255,255,0.5)',
fontSize: 14,
textAlign: 'center',
pointerEvents: 'none',
}}
>
Select a session to view its route
</div>
)}
</MapContainer>
</div>
</Card>
{/* Legend */}
{route && (
<div style={{ display: 'flex', gap: 16, marginBottom: 16, flexWrap: 'wrap' }}>
{Object.entries(EVENT_COLORS).map(([key, color]) => (
<div key={key} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: color }} />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{EVENT_LABELS[key as TrackPointEvent]}
</Typography.Text>
</div>
))}
</div>
)}
<Card title="Sessions" size="small">
<Table
dataSource={sessions}
columns={columns}
rowKey="id"
size="small"
scroll={isMobile ? { x: 400 } : undefined}
loading={loading}
pagination={{
current: page,
total: pagination?.total ?? 0,
pageSize: 15,
showSizeChanger: false,
onChange: setPage,
}}
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,548 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Drawer, Spin, App, ConfigProvider, theme } from 'antd';
import { MapContainer, useMapEvents } from 'react-leaflet';
import type { Map as LeafletMap } from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { useCanvassStore } from '@/stores/canvass.store';
import { useTrackingStore } from '@/stores/tracking.store';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { MAP_ADMIN_ROLES } from '@/types/api';
import type { RecordVisitPayload } from '@/types/canvass';
import VolunteerSessionBar from '@/components/canvass/VolunteerMapHeader';
import VolunteerMapDrawer from '@/components/canvass/VolunteerMapDrawer';
import MapCrosshair from '@/components/canvass/MapCrosshair';
import CanvassMarker from '@/components/canvass/CanvassMarker';
import WalkingRouteLine from '@/components/canvass/WalkingRouteLine';
import GPSTracker from '@/components/canvass/GPSTracker';
import CanvassBottomToolbar from '@/components/canvass/CanvassBottomToolbar';
import CanvassLegend from '@/components/canvass/CanvassLegend';
import VisitRecordingForm from '@/components/canvass/VisitRecordingForm';
import LocationEditDrawer from '@/components/canvass/LocationEditDrawer';
import AddLocationDrawer from '@/components/canvass/AddLocationDrawer';
import CutOverlays from '@/components/map/CutOverlays';
import CutOverlayControls from '@/components/map/CutOverlayControls';
import DynamicTileLayer from '@/components/map/DynamicTileLayer';
import TileLayerToggle from '@/components/map/TileLayerToggle';
import NorthCompass from '@/components/map/NorthCompass';
import AddressSearchOverlay from '@/components/canvass/AddressSearchOverlay';
import VolunteerFooterNav from '@/components/VolunteerFooterNav';
import { getPersistedTileLayer, persistTileLayer, getTileConfig } from '@/components/map/tileLayers';
const DEFAULT_CENTER: [number, number] = [45.42, -75.69];
const DEFAULT_ZOOM = 13;
function MapEventsHandler({ onMove }: { onMove: (map: LeafletMap) => void }) {
const map = useMapEvents({
moveend: () => {
// Only trigger if not animating to prevent Leaflet state corruption
if (!map._animatingZoom && !map._moving) {
onMove(map);
}
},
zoomend: () => {
// Wait a tick for Leaflet to finish internal zoom state updates
setTimeout(() => {
if (!map._animatingZoom && !map._moving) {
onMove(map);
}
}, 100);
},
});
return null;
}
export default function VolunteerMapPage() {
const [searchParams] = useSearchParams();
const { message, modal } = App.useApp();
const { user } = useAuthStore();
const { settings } = useSettingsStore();
const trackingStore = useTrackingStore();
const {
mode, activeCutId, session,
locations,
cuts, mapSettings,
route, routeVisible, loadRoute, toggleRoute,
selectedLocationId, selectLocation,
gpsFollowing, setGpsFollowing,
loadAllLocations, loadCuts, loadMapSettings, loadSession,
enterSessionMode, exitSessionMode,
recordVisit, reset,
} = useCanvassStore();
const [loading, setLoading] = useState(true);
const [drawerOpen, setDrawerOpen] = useState(false);
const [bottomSheetOpen, setBottomSheetOpen] = useState(false);
const [editDrawerOpen, setEditDrawerOpen] = useState(false);
const [addDrawerOpen, setAddDrawerOpen] = useState(false);
const [addLatLng, setAddLatLng] = useState<{ lat: number; lng: number } | null>(null);
const [endingSession, setEndingSession] = useState(false);
const [recording, setRecording] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
const [tileKey, setTileKey] = useState(getPersistedTileLayer);
const mapRef = useRef<LeafletMap | null>(null);
const gpsPositionRef = useRef<{ lat: number; lng: number } | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const fetchTimerRef = useRef<ReturnType<typeof setTimeout>>();
const abortControllerRef = useRef<AbortController | null>(null);
const userRole = user?.role ?? 'TEMP';
const isAdmin = MAP_ADMIN_ROLES.includes(userRole);
const sessionActive = mode === 'session' && !!session;
// Footer nav height + session bar height for positioning
const FOOTER_HEIGHT = 56;
const SESSION_BAR_GAP = 4; // gap between session bar and footer
const SESSION_BAR_HEIGHT = sessionActive ? 40 + SESSION_BAR_GAP : 0;
const toolbarBottom = FOOTER_HEIGHT + SESSION_BAR_HEIGHT + 16;
// ─── Initialize ──────────────────────────────────────────────────
useEffect(() => {
const init = async () => {
setLoading(true);
try {
await Promise.all([loadAllLocations(), loadCuts(), loadMapSettings()]);
// Check for existing active session
await loadSession();
const state = useCanvassStore.getState();
// Handle URL query params
const cutIdParam = searchParams.get('cutId');
const shiftIdParam = searchParams.get('shiftId') ?? undefined;
if (cutIdParam && !state.session) {
await enterSessionMode(cutIdParam, shiftIdParam);
} else if (state.session) {
// Re-load cut-filtered locations for existing session
const store = useCanvassStore.getState();
if (store.session) {
await useCanvassStore.getState().loadLocations(store.session.cutId);
useCanvassStore.setState({ mode: 'session', activeCutId: store.session.cutId });
}
}
// Start GPS tracking (restore existing or create new)
await trackingStore.restoreSession();
if (!useTrackingStore.getState().isTracking) {
await trackingStore.startTracking();
}
} catch {
message.error('Failed to load map data');
} finally {
setLoading(false);
}
};
init();
return () => {
trackingStore.stopTracking();
reset();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Initialize visible cuts when cuts load
useEffect(() => {
if (cuts.length > 0) {
setVisibleCutIds(new Set(cuts.map((c) => c.id)));
}
}, [cuts]);
// Fit map to cut bounds in session mode
useEffect(() => {
if (!sessionActive || !activeCutId || !mapRef.current) return;
const cut = cuts.find((c) => c.id === activeCutId);
if (!cut?.bounds) return;
try {
const bounds = JSON.parse(cut.bounds);
mapRef.current.fitBounds(
[[bounds.minLat, bounds.minLng], [bounds.maxLat, bounds.maxLng]],
{ padding: [20, 20] },
);
} catch { /* ignore */ }
}, [sessionActive, activeCutId, cuts]);
// Center map on settings when in free mode on load
useEffect(() => {
if (loading || sessionActive || !mapSettings || !mapRef.current) return;
const lat = mapSettings.latitude ? parseFloat(mapSettings.latitude) : null;
const lng = mapSettings.longitude ? parseFloat(mapSettings.longitude) : null;
const zoom = mapSettings.zoom ?? DEFAULT_ZOOM;
if (lat && lng) {
mapRef.current.setView([lat, lng], zoom);
}
}, [loading, sessionActive, mapSettings]);
// ─── Map Move Handler (Viewport Loading) ──────────────────────────
const handleMapMove = useCallback((map: LeafletMap) => {
const b = map.getBounds();
const newBounds = {
minLat: b.getSouth(),
maxLat: b.getNorth(),
minLng: b.getWest(),
maxLng: b.getEast(),
};
clearTimeout(fetchTimerRef.current);
fetchTimerRef.current = setTimeout(() => {
// Cancel any pending request
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
abortControllerRef.current = new AbortController();
if (mode === 'session' && activeCutId) {
useCanvassStore.getState().loadLocations(activeCutId, newBounds);
} else {
useCanvassStore.getState().loadAllLocations(newBounds);
}
}, 800); // Increased debounce to 800ms to allow zoom animations to complete
}, [mode, activeCutId]);
// ─── Handlers ────────────────────────────────────────────────────
const handleMarkerClick = useCallback((locationId: string) => {
selectLocation(locationId);
setBottomSheetOpen(true);
}, [selectLocation]);
const handleRecordVisit = useCallback(async (payload: RecordVisitPayload) => {
setRecording(true);
try {
await recordVisit(payload);
// Track event point for visit
const pos = gpsPositionRef.current;
if (pos) trackingStore.addEventPoint(pos.lat, pos.lng, 'VISIT_RECORDED');
message.success('Visit recorded');
setBottomSheetOpen(false);
} catch {
message.error('Failed to record visit');
} finally {
setRecording(false);
}
}, [recordVisit, message, trackingStore]);
const handleNextDoor = useCallback(() => {
const unvisited = locations.filter((l) => !l.lastVisit);
if (unvisited.length === 0) {
message.info('All locations visited!');
return;
}
let target = unvisited[0]!;
if (gpsPositionRef.current) {
let nearest = Infinity;
for (const loc of unvisited) {
const dx = loc.latitude - gpsPositionRef.current.lat;
const dy = loc.longitude - gpsPositionRef.current.lng;
const dist = dx * dx + dy * dy;
if (dist < nearest) {
nearest = dist;
target = loc;
}
}
}
selectLocation(target.id);
setBottomSheetOpen(true);
mapRef.current?.setView([target.latitude, target.longitude], 18, { animate: true });
}, [locations, selectLocation, message]);
const handleGpsPositionChange = useCallback((lat: number, lng: number) => {
gpsPositionRef.current = { lat, lng };
}, []);
const handleEndSession = useCallback(async () => {
modal.confirm({
title: 'End Canvass Session?',
content: 'Your progress will be saved. You can start a new session later.',
okText: 'End Session',
okType: 'danger',
onOk: async () => {
setEndingSession(true);
try {
const pos = gpsPositionRef.current;
if (pos) trackingStore.addEventPoint(pos.lat, pos.lng, 'SESSION_ENDED');
await exitSessionMode();
message.success('Session ended');
if (mapSettings && mapRef.current) {
const lat = mapSettings.latitude ? parseFloat(mapSettings.latitude) : null;
const lng = mapSettings.longitude ? parseFloat(mapSettings.longitude) : null;
if (lat && lng) {
mapRef.current.setView([lat, lng], mapSettings.zoom ?? DEFAULT_ZOOM);
}
}
} catch {
message.error('Failed to end session');
} finally {
setEndingSession(false);
}
},
});
}, [exitSessionMode, message, modal, mapSettings]);
const handleStartSession = useCallback(async (cutId: string, shiftId?: string) => {
try {
const pos = gpsPositionRef.current;
await enterSessionMode(cutId, shiftId, pos?.lat, pos?.lng);
// Link canvass session to tracking + add event
const canvassSession = useCanvassStore.getState().session;
if (canvassSession) {
await trackingStore.linkCanvassSession(canvassSession.id);
if (pos) trackingStore.addEventPoint(pos.lat, pos.lng, 'SESSION_STARTED');
}
message.success('Session started');
} catch {
message.error('Failed to start session');
}
}, [enterSessionMode, message, trackingStore]);
const handleTileChange = useCallback((key: string) => {
setTileKey(key);
persistTileLayer(key);
}, []);
const handleSearchFlyTo = useCallback((lat: number, lng: number) => {
mapRef.current?.flyTo([lat, lng], 18);
setGpsFollowing(false);
}, [setGpsFollowing]);
const handleToggleCut = useCallback((id: string) => {
setVisibleCutIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
// "+" tapped — read map center from the crosshair position
const handleAddAtCenter = useCallback(() => {
if (!mapRef.current) return;
const center = mapRef.current.getCenter();
setAddLatLng({ lat: center.lat, lng: center.lng });
setAddDrawerOpen(true);
}, []);
const handleToggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
(containerRef.current.requestFullscreen?.() ??
(containerRef.current as unknown as { webkitRequestFullscreen?: () => Promise<void> }).webkitRequestFullscreen?.());
setFullscreen(true);
} else {
document.exitFullscreen();
setFullscreen(false);
}
}, []);
useEffect(() => {
const handler = () => { if (!document.fullscreenElement) setFullscreen(false); };
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
const selectedLocation = locations.find((l) => l.id === selectedLocationId) ?? null;
const visitedCount = locations.filter((l) => l.lastVisit).length;
const center: [number, number] = mapSettings?.latitude && mapSettings?.longitude
? [parseFloat(mapSettings.latitude), parseFloat(mapSettings.longitude)]
: DEFAULT_CENTER;
const zoom = mapSettings?.zoom ?? DEFAULT_ZOOM;
if (loading) {
return (
<div style={{ height: '100vh', display: 'flex', justifyContent: 'center', alignItems: 'center', background: '#0d1b2a' }}>
<Spin size="large" />
</div>
);
}
return (
<ConfigProvider
theme={{
algorithm: theme.darkAlgorithm,
token: {
colorPrimary: settings?.publicColorPrimary ?? '#3498db',
colorBgBase: settings?.publicColorBgBase ?? '#0d1b2a',
colorBgContainer: settings?.publicColorBgContainer ?? '#1b2838',
colorBgElevated: settings?.publicColorBgContainer ?? '#1b2838',
borderRadius: 8,
},
}}
>
<App>
<div ref={containerRef} style={{ position: 'relative', width: '100vw', height: '100vh' }}>
<MapContainer
center={center}
zoom={zoom}
style={{ width: '100%', height: '100%' }}
zoomControl={false}
ref={mapRef}
>
<MapEventsHandler onMove={handleMapMove} />
<DynamicTileLayer config={getTileConfig(tileKey)} />
{/* Cut polygon overlays */}
<CutOverlays cuts={cuts} visibleCutIds={visibleCutIds} />
{/* Location markers */}
{locations.map((loc) => (
<CanvassMarker
key={loc.id}
location={loc}
isSelected={loc.id === selectedLocationId}
onClick={() => handleMarkerClick(loc.id)}
/>
))}
{/* Walking route (session mode) */}
{sessionActive && routeVisible && route && <WalkingRouteLine route={route} />}
{/* GPS tracker */}
<GPSTracker
following={gpsFollowing}
onPositionChange={handleGpsPositionChange}
/>
</MapContainer>
{/* Persistent crosshair at map center — tap center dot to add location */}
<MapCrosshair onClick={handleAddAtCenter} />
{/* Legend — top-right, clear of bottom controls */}
<CanvassLegend />
{/* North compass — below legend */}
<NorthCompass />
{/* Tile layer toggle — bottom-left */}
<TileLayerToggle activeKey={tileKey} onChange={handleTileChange} position="bottom-left" />
{/* Address search overlay */}
<AddressSearchOverlay onFlyTo={handleSearchFlyTo} />
{/* Cut overlay controls — top-left to avoid footer collision */}
{cuts.length > 0 && (
<CutOverlayControls
cuts={cuts}
visibleCutIds={visibleCutIds}
onToggleCut={handleToggleCut}
variant="public"
style={{ top: 10, bottom: 'auto', left: 10 }}
/>
)}
{/* Floating toolbar — above footer + session bar */}
<CanvassBottomToolbar
visitedCount={visitedCount}
totalCount={locations.length}
routeVisible={routeVisible}
gpsFollowing={gpsFollowing}
onNextDoor={handleNextDoor}
onToggleRoute={() => {
if (!routeVisible && activeCutId) {
const pos = gpsPositionRef.current;
loadRoute(activeCutId, true, pos?.lat, pos?.lng);
}
toggleRoute();
}}
onToggleGps={() => setGpsFollowing(!gpsFollowing)}
sessionActive={sessionActive}
onAddAtCenter={handleAddAtCenter}
fullscreen={fullscreen}
onToggleFullscreen={handleToggleFullscreen}
onMenuOpen={() => setDrawerOpen(true)}
bottomOffset={toolbarBottom}
/>
{/* Session bar — above footer nav, only when session active */}
{sessionActive && (
<VolunteerSessionBar
sessionCutName={session?.cut?.name}
sessionStartedAt={session?.startedAt}
onEndSession={handleEndSession}
endingSession={endingSession}
headerGradient={settings?.publicHeaderGradient}
/>
)}
{/* Footer nav — bottom of screen */}
<VolunteerFooterNav
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
{/* Left drawer — menu, stats, assignments, session picker */}
<VolunteerMapDrawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
cuts={cuts}
onStartSession={handleStartSession}
/>
{/* Bottom sheet — visit recording */}
<Drawer
placement="bottom"
open={bottomSheetOpen && !!selectedLocation}
onClose={() => { setBottomSheetOpen(false); selectLocation(null); }}
height="auto"
styles={{
body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' },
header: { display: 'none' },
}}
maskClosable
>
{selectedLocation && (
<>
<VisitRecordingForm
location={selectedLocation}
sessionId={session?.id}
shiftId={session?.shiftId ?? undefined}
onRecord={handleRecordVisit}
recording={recording}
userRole={userRole}
/>
{isAdmin && (
<div style={{ marginTop: 8, textAlign: 'center' }}>
<a
onClick={() => { setBottomSheetOpen(false); setEditDrawerOpen(true); }}
style={{ fontSize: 12 }}
>
Edit Location Details
</a>
</div>
)}
</>
)}
</Drawer>
{/* Bottom sheet — location editing (admin only) */}
<LocationEditDrawer
open={editDrawerOpen}
onClose={() => setEditDrawerOpen(false)}
location={selectedLocation}
/>
{/* Bottom sheet — add new location + record visit */}
<AddLocationDrawer
open={addDrawerOpen}
onClose={() => { setAddDrawerOpen(false); setAddLatLng(null); }}
lat={addLatLng?.lat ?? null}
lng={addLatLng?.lng ?? null}
userRole={userRole}
sessionId={session?.id}
shiftId={session?.shiftId ?? undefined}
/>
</div>
</App>
</ConfigProvider>
);
}

View File

@ -0,0 +1,311 @@
import { useEffect, useState } from 'react';
import {
Typography,
Card,
Button,
Row,
Col,
Progress,
Segmented,
Spin,
Result,
Grid,
App,
theme,
} from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
TeamOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
const { Title, Text, Paragraph } = Typography;
interface VolunteerShift {
id: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: 'OPEN' | 'FULL' | 'CANCELLED';
isSignedUp: boolean;
}
interface MySignup {
id: string;
shiftId: string;
shiftTitle: string;
signupDate: string;
shift: {
id: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: string;
};
}
type Tab = 'upcoming' | 'my-signups';
export default function VolunteerShiftsPage() {
const { message, modal } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { token } = theme.useToken();
const [tab, setTab] = useState<Tab>('upcoming');
const [shifts, setShifts] = useState<VolunteerShift[]>([]);
const [signups, setSignups] = useState<MySignup[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
useEffect(() => {
if (tab === 'upcoming') fetchUpcoming();
else fetchSignups();
}, [tab]); // eslint-disable-line react-hooks/exhaustive-deps
const fetchUpcoming = async () => {
setLoading(true);
try {
const { data } = await api.get<VolunteerShift[]>('/map/shifts/volunteer/upcoming');
setShifts(data);
} catch {
message.error('Failed to load shifts');
} finally {
setLoading(false);
}
};
const fetchSignups = async () => {
setLoading(true);
try {
const { data } = await api.get<MySignup[]>('/map/shifts/volunteer/my-signups');
setSignups(data);
} catch {
message.error('Failed to load signups');
} finally {
setLoading(false);
}
};
const handleSignup = (shift: VolunteerShift) => {
modal.confirm({
title: 'Sign Up',
content: (
<div>
<Text>Sign up for <Text strong>{shift.title}</Text>?</Text>
<div style={{ marginTop: 8, padding: '8px 12px', background: 'rgba(255,255,255,0.04)', borderRadius: 6 }}>
<div><CalendarOutlined style={{ marginRight: 6 }} />{dayjs(shift.date).format('dddd, MMMM D, YYYY')}</div>
<div><ClockCircleOutlined style={{ marginRight: 6 }} />{shift.startTime} {shift.endTime}</div>
{shift.location && <div><EnvironmentOutlined style={{ marginRight: 6 }} />{shift.location}</div>}
</div>
</div>
),
okText: 'Sign Up',
onOk: async () => {
setActionLoading(shift.id);
try {
await api.post(`/map/shifts/volunteer/${shift.id}/signup`);
message.success('Signed up successfully!');
fetchUpcoming();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to sign up';
message.error(msg);
} finally {
setActionLoading(null);
}
},
});
};
const handleCancel = (shiftId: string, shiftTitle: string) => {
modal.confirm({
title: 'Cancel Signup',
content: `Cancel your signup for "${shiftTitle}"?`,
okText: 'Cancel Signup',
okButtonProps: { danger: true },
onOk: async () => {
setActionLoading(shiftId);
try {
await api.delete(`/map/shifts/volunteer/${shiftId}/signup`);
message.success('Signup cancelled');
if (tab === 'upcoming') fetchUpcoming();
else fetchSignups();
} catch (err: any) {
const msg = err.response?.data?.error?.message || 'Failed to cancel signup';
message.error(msg);
} finally {
setActionLoading(null);
}
},
});
};
const renderShiftCard = (shift: VolunteerShift) => {
const spotsLeft = shift.maxVolunteers - shift.currentVolunteers;
const isFull = shift.status === 'FULL' || spotsLeft <= 0;
const pct = shift.maxVolunteers > 0
? Math.round((shift.currentVolunteers / shift.maxVolunteers) * 100)
: 0;
return (
<Col key={shift.id} xs={24} sm={12}>
<Card
hoverable={!isFull && !shift.isSignedUp}
style={{ height: '100%', opacity: isFull && !shift.isSignedUp ? 0.7 : 1 }}
styles={{ body: { display: 'flex', flexDirection: 'column', height: '100%' } }}
>
<div style={{ flex: 1 }}>
<Title level={5} style={{ margin: '0 0 8px 0' }}>{shift.title}</Title>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>
<CalendarOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{dayjs(shift.date).format('ddd, MMM D, YYYY')}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{shift.startTime} {shift.endTime}</Text>
</div>
{shift.location && (
<div style={{ marginBottom: 4 }}>
<EnvironmentOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{shift.location}</Text>
</div>
)}
</div>
{shift.description && (
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 12 }}>
{shift.description}
</Paragraph>
)}
<div style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary"><TeamOutlined style={{ marginRight: 4 }} />Volunteers</Text>
<Text type="secondary">{shift.currentVolunteers}/{shift.maxVolunteers}</Text>
</div>
<Progress percent={pct} size="small" status={isFull ? 'exception' : 'active'} showInfo={false} />
<Text type="secondary" style={{ fontSize: 12 }}>
{isFull ? 'No spots available' : `${spotsLeft} spot${spotsLeft !== 1 ? 's' : ''} remaining`}
</Text>
</div>
</div>
{shift.isSignedUp ? (
<div>
<Button type="primary" block icon={<CheckCircleOutlined />} style={{ marginBottom: 8 }}>
Signed Up
</Button>
<Button
type="text"
danger
block
size="small"
loading={actionLoading === shift.id}
onClick={() => handleCancel(shift.id, shift.title)}
>
Cancel Signup
</Button>
</div>
) : (
<Button
type="primary"
block
disabled={isFull}
loading={actionLoading === shift.id}
onClick={() => handleSignup(shift)}
>
{isFull ? 'Shift Full' : 'Sign Up'}
</Button>
)}
</Card>
</Col>
);
};
const renderSignupCard = (signup: MySignup) => {
const { shift } = signup;
return (
<Col key={signup.id} xs={24} sm={12}>
<Card style={{ height: '100%' }} styles={{ body: { display: 'flex', flexDirection: 'column', height: '100%' } }}>
<div style={{ flex: 1 }}>
<Title level={5} style={{ margin: '0 0 8px 0' }}>{shift.title}</Title>
<div style={{ marginBottom: 12 }}>
<div style={{ marginBottom: 4 }}>
<CalendarOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{dayjs(shift.date).format('ddd, MMM D, YYYY')}</Text>
</div>
<div style={{ marginBottom: 4 }}>
<ClockCircleOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{shift.startTime} {shift.endTime}</Text>
</div>
{shift.location && (
<div style={{ marginBottom: 4 }}>
<EnvironmentOutlined style={{ marginRight: 8, color: token.colorPrimary }} />
<Text>{shift.location}</Text>
</div>
)}
</div>
{shift.description && (
<Paragraph type="secondary" ellipsis={{ rows: 2 }} style={{ marginBottom: 12 }}>
{shift.description}
</Paragraph>
)}
</div>
<Button
type="text"
danger
block
loading={actionLoading === shift.id}
onClick={() => handleCancel(shift.id, shift.title)}
>
Cancel Signup
</Button>
</Card>
</Col>
);
};
return (
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>Shifts</Typography.Title>
<Segmented
value={tab}
onChange={(val) => setTab(val as Tab)}
options={[
{ label: 'Upcoming', value: 'upcoming' },
{ label: 'My Signups', value: 'my-signups' },
]}
block={isMobile}
style={{ marginBottom: 24 }}
/>
{loading ? (
<div style={{ textAlign: 'center', padding: 60 }}><Spin size="large" /></div>
) : tab === 'upcoming' ? (
shifts.length === 0 ? (
<Result status="info" title="No Upcoming Shifts" subTitle="Check back later for volunteer opportunities." />
) : (
<Row gutter={[16, 16]}>{shifts.map(renderShiftCard)}</Row>
)
) : signups.length === 0 ? (
<Result status="info" title="No Signups" subTitle="You haven't signed up for any shifts yet." />
) : (
<Row gutter={[16, 16]}>{signups.map(renderSignupCard)}</Row>
)}
</div>
);
}

View File

@ -0,0 +1,154 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { api, registerAuthCallbacks } from '@/lib/api';
import type { User, AuthResponse } from '@/types/api';
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
interface AuthActions {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
fetchMe: () => Promise<void>;
hydrate: () => Promise<void>;
setTokens: (accessToken: string, refreshToken: string) => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: true,
error: null,
login: async (email: string, password: string) => {
set({ error: null, isLoading: true });
try {
const { data } = await api.post<AuthResponse>('/auth/login', {
email,
password,
});
set({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
isAuthenticated: true,
isLoading: false,
});
} catch (err: unknown) {
const message =
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message || 'Login failed';
set({ error: message, isLoading: false });
throw err;
}
},
logout: async () => {
const { refreshToken } = get();
try {
if (refreshToken) {
await api.post('/auth/logout', { refreshToken });
}
} catch {
// Ignore logout errors
}
get().clearAuth();
},
refresh: async () => {
const { refreshToken } = get();
if (!refreshToken) {
get().clearAuth();
return;
}
try {
const { data } = await api.post<AuthResponse>('/auth/refresh', {
refreshToken,
});
set({
user: data.user,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
isAuthenticated: true,
});
} catch {
get().clearAuth();
}
},
fetchMe: async () => {
try {
const { data } = await api.get<User>('/auth/me');
set({ user: data, isAuthenticated: true, isLoading: false });
} catch {
get().clearAuth();
}
},
hydrate: async () => {
const { accessToken } = get();
if (!accessToken) {
set({ isLoading: false });
return;
}
try {
await get().fetchMe();
} catch {
set({ isLoading: false });
}
},
setTokens: (accessToken: string, refreshToken: string) => {
set({ accessToken, refreshToken });
},
clearAuth: () => {
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isLoading: false,
error: null,
});
},
}),
{
name: 'cml-auth',
partialize: (state) => ({
accessToken: state.accessToken,
refreshToken: state.refreshToken,
}),
}
)
);
// Register callbacks for the API interceptor (breaks circular dependency)
registerAuthCallbacks({
getTokens: () => {
const state = useAuthStore.getState();
return {
accessToken: state.accessToken,
refreshToken: state.refreshToken,
};
},
onTokenRefresh: (accessToken, refreshToken) => {
useAuthStore.getState().setTokens(accessToken, refreshToken);
},
onAuthFailure: () => {
useAuthStore.getState().clearAuth();
window.location.href = '/login';
},
});

View File

@ -0,0 +1,346 @@
import { create } from 'zustand';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CanvassLocation,
WalkingRoute,
CanvassSession,
RecordVisitPayload,
CanvassVisit,
} from '@/types/canvass';
import type { PublicCut, MapSettings, ReverseGeocodeResult, GeocodeResult } from '@/types/api';
interface CanvassState {
// Mode
mode: 'free' | 'session';
activeCutId: string | null;
// Session
session: CanvassSession | null;
sessionLoading: boolean;
// Locations
locations: CanvassLocation[];
locationsLoading: boolean;
// Cuts & map settings
cuts: PublicCut[];
mapSettings: MapSettings | null;
// Walking route
route: WalkingRoute | null;
routeVisible: boolean;
// Selected location
selectedLocationId: string | null;
// GPS
gpsFollowing: boolean;
// Actions — data loading
loadAllLocations: () => Promise<void>;
loadCuts: () => Promise<void>;
loadMapSettings: () => Promise<void>;
loadSession: () => Promise<void>;
loadRoute: (cutId: string, excludeVisited?: boolean, lat?: number, lng?: number) => Promise<void>;
// Actions — session management
enterSessionMode: (cutId: string, shiftId?: string, lat?: number, lng?: number) => Promise<void>;
exitSessionMode: () => Promise<void>;
startSession: (cutId: string, shiftId?: string, lat?: number, lng?: number) => Promise<CanvassSession>;
endSession: () => Promise<void>;
// Actions — location management
loadLocations: (cutId: string) => Promise<void>;
recordVisit: (payload: RecordVisitPayload) => Promise<CanvassVisit>;
updateLocationFields: (id: string, data: Record<string, unknown>) => Promise<void>;
addLocation: (data: Record<string, unknown>) => Promise<CanvassLocation>;
reverseGeocode: (lat: number, lng: number) => Promise<ReverseGeocodeResult>;
geocodeSearch: (address: string) => Promise<GeocodeResult>;
// Actions — UI
selectLocation: (id: string | null) => void;
toggleRoute: () => void;
setGpsFollowing: (v: boolean) => void;
reset: () => void;
}
export const useCanvassStore = create<CanvassState>((set, get) => ({
mode: 'free',
activeCutId: null,
session: null,
sessionLoading: false,
locations: [],
locationsLoading: false,
cuts: [],
mapSettings: null,
route: null,
routeVisible: false,
selectedLocationId: null,
gpsFollowing: true,
// ─── Data Loading ──────────────────────────────────────────────────
loadAllLocations: async (bounds?: {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}) => {
set({ locationsLoading: true });
try {
let url = '/map/canvass/locations';
if (bounds) {
const params = new URLSearchParams({
minLat: bounds.minLat.toString(),
maxLat: bounds.maxLat.toString(),
minLng: bounds.minLng.toString(),
maxLng: bounds.maxLng.toString(),
});
url += `?${params}`;
}
const { data } = await api.get(url);
set({ locations: data });
} finally {
set({ locationsLoading: false });
}
},
loadCuts: async () => {
try {
const { data } = await axios.get('/api/map/cuts/public');
set({ cuts: data });
} catch {
// Non-critical — cuts overlay just won't show
}
},
loadMapSettings: async () => {
try {
const { data } = await axios.get('/api/map/settings');
set({ mapSettings: data });
} catch {
// Non-critical — use default center/zoom
}
},
loadSession: async () => {
set({ sessionLoading: true });
try {
const { data } = await api.get('/map/canvass/my/session');
if (data) {
set({ session: data, mode: 'session', activeCutId: data.cutId });
} else {
set({ session: null });
}
} catch {
set({ session: null });
} finally {
set({ sessionLoading: false });
}
},
loadLocations: async (
cutId,
bounds?: {
minLat: number;
maxLat: number;
minLng: number;
maxLng: number;
}
) => {
set({ locationsLoading: true });
try {
let url = `/map/canvass/cuts/${cutId}/locations`;
if (bounds) {
const params = new URLSearchParams({
minLat: bounds.minLat.toString(),
maxLat: bounds.maxLat.toString(),
minLng: bounds.minLng.toString(),
maxLng: bounds.maxLng.toString(),
});
url += `?${params}`;
}
const { data } = await api.get(url);
set({ locations: data });
} finally {
set({ locationsLoading: false });
}
},
loadRoute: async (cutId, excludeVisited = true, lat, lng) => {
const params = new URLSearchParams();
if (excludeVisited) params.set('excludeVisited', 'true');
if (lat !== undefined) params.set('startLatitude', lat.toString());
if (lng !== undefined) params.set('startLongitude', lng.toString());
const { data } = await api.get(`/map/canvass/cuts/${cutId}/route?${params}`);
set({ route: data });
},
// ─── Session Management ────────────────────────────────────────────
enterSessionMode: async (cutId, shiftId, lat, lng) => {
const { session } = get();
// End any existing session for a different cut
if (session && session.cutId !== cutId) {
await api.post(`/map/canvass/sessions/${session.id}/end`);
}
// Start new session if none or different cut
if (!session || session.cutId !== cutId) {
const { data } = await api.post('/map/canvass/sessions', {
cutId,
shiftId,
startLatitude: lat,
startLongitude: lng,
});
set({ session: data });
}
// Load cut-filtered locations
set({ locationsLoading: true, mode: 'session', activeCutId: cutId });
try {
const { data } = await api.get(`/map/canvass/cuts/${cutId}/locations`);
set({ locations: data });
} finally {
set({ locationsLoading: false });
}
},
exitSessionMode: async () => {
const { session } = get();
if (session) {
await api.post(`/map/canvass/sessions/${session.id}/end`);
}
set({
session: null,
mode: 'free',
activeCutId: null,
route: null,
routeVisible: false,
});
// Reload all locations
await get().loadAllLocations();
},
startSession: async (cutId, shiftId, lat, lng) => {
const { data } = await api.post('/map/canvass/sessions', {
cutId,
shiftId,
startLatitude: lat,
startLongitude: lng,
});
set({ session: data });
return data;
},
endSession: async () => {
const { session } = get();
if (!session) return;
await api.post(`/map/canvass/sessions/${session.id}/end`);
set({ session: null });
},
// ─── Location Management ───────────────────────────────────────────
recordVisit: async (payload) => {
const { data } = await api.post('/map/canvass/visits', payload);
// Optimistic update: mark the location as visited in local state
set((state) => ({
locations: state.locations.map((loc) =>
loc.id === payload.locationId
? {
...loc,
lastVisit: {
outcome: payload.outcome,
visitedAt: new Date().toISOString(),
visitorName: null,
isMyVisit: true,
},
}
: loc,
),
selectedLocationId: null,
}));
return data;
},
updateLocationFields: async (id, data) => {
await api.put(`/map/canvass/locations/${id}`, data);
// Optimistic update in locations array
set((state) => ({
locations: state.locations.map((loc) =>
loc.id === id
? {
...loc,
...data,
// Keep lastVisit, latitude, longitude from existing
}
: loc,
),
}));
},
addLocation: async (data) => {
const { data: newLoc } = await api.post('/map/canvass/locations', data);
// Build a CanvassLocation from the response
const canvassLoc: CanvassLocation = {
id: newLoc.id,
latitude: Number(newLoc.latitude),
longitude: Number(newLoc.longitude),
address: newLoc.address,
unitNumber: newLoc.unitNumber,
firstName: newLoc.firstName,
lastName: newLoc.lastName,
supportLevel: newLoc.supportLevel,
sign: newLoc.sign,
signSize: newLoc.signSize,
notes: newLoc.notes,
lastVisit: null,
};
set((state) => ({ locations: [canvassLoc, ...state.locations] }));
return canvassLoc;
},
reverseGeocode: async (lat, lng) => {
const { data } = await api.post('/map/canvass/reverse-geocode', {
latitude: lat,
longitude: lng,
});
return data;
},
geocodeSearch: async (address) => {
const { data } = await api.post('/map/canvass/geocode-search', { address });
return data;
},
// ─── UI ────────────────────────────────────────────────────────────
selectLocation: (id) => set({ selectedLocationId: id }),
toggleRoute: () => set((state) => ({ routeVisible: !state.routeVisible })),
setGpsFollowing: (v) => set({ gpsFollowing: v }),
reset: () =>
set({
mode: 'free',
activeCutId: null,
session: null,
sessionLoading: false,
locations: [],
locationsLoading: false,
cuts: [],
mapSettings: null,
route: null,
routeVisible: false,
selectedLocationId: null,
gpsFollowing: true,
}),
}));

View File

@ -0,0 +1,44 @@
import { create } from 'zustand';
import axios from 'axios';
import { api } from '@/lib/api';
import type { SiteSettings } from '@/types/api';
interface SettingsState {
settings: SiteSettings | null;
loading: boolean;
fetchSettings: () => Promise<void>;
fetchAdminSettings: () => Promise<void>;
updateSettings: (data: Partial<SiteSettings>) => Promise<SiteSettings>;
}
export const useSettingsStore = create<SettingsState>((set) => ({
settings: null,
loading: true,
fetchSettings: async () => {
try {
// Use axios directly (not { api }) — this is a public endpoint needed before auth
const { data } = await axios.get<SiteSettings>('/api/settings');
set({ settings: data, loading: false });
} catch {
set({ loading: false });
}
},
/** Fetch full settings including SMTP credentials (SUPER_ADMIN only) */
fetchAdminSettings: async () => {
try {
const { data } = await api.get<SiteSettings>('/settings/admin');
set({ settings: data, loading: false });
} catch {
set({ loading: false });
}
},
updateSettings: async (data) => {
// Use authenticated api client for writes
const { data: updated } = await api.put<SiteSettings>('/settings', data);
set({ settings: updated });
return updated;
},
}));

View File

@ -0,0 +1,131 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
import type { TrackPointPayload, TrackPointEvent } from '@/types/tracking';
interface TrackingState {
trackingSessionId: string | null;
isTracking: boolean;
pendingPoints: TrackPointPayload[];
startTracking(canvassSessionId?: string, lat?: number, lng?: number): Promise<void>;
stopTracking(): Promise<void>;
addPoint(point: TrackPointPayload): void;
flushPoints(): Promise<void>;
linkCanvassSession(canvassSessionId: string): Promise<void>;
addEventPoint(lat: number, lng: number, eventType: TrackPointEvent): void;
restoreSession(): Promise<void>;
reset(): void;
}
export const useTrackingStore = create<TrackingState>((set, get) => ({
trackingSessionId: null,
isTracking: false,
pendingPoints: [],
startTracking: async (canvassSessionId, lat, lng) => {
if (get().isTracking) return;
try {
const { data } = await api.post('/map/tracking/sessions', {
canvassSessionId,
latitude: lat,
longitude: lng,
});
set({ trackingSessionId: data.id, isTracking: true });
} catch {
// Non-critical — tracking just won't record
}
},
stopTracking: async () => {
const { trackingSessionId, pendingPoints } = get();
if (!trackingSessionId) {
set({ isTracking: false });
return;
}
// Flush remaining points before ending
if (pendingPoints.length > 0) {
try {
await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
points: pendingPoints,
});
} catch {
// Best effort
}
}
try {
await api.post(`/map/tracking/sessions/${trackingSessionId}/end`);
} catch {
// Best effort
}
set({ trackingSessionId: null, isTracking: false, pendingPoints: [] });
},
addPoint: (point) => {
set((state) => ({
pendingPoints: [...state.pendingPoints, point],
}));
},
flushPoints: async () => {
const { trackingSessionId, pendingPoints } = get();
if (!trackingSessionId || pendingPoints.length === 0) return;
// Take a snapshot and clear the buffer optimistically
const toSend = [...pendingPoints];
set({ pendingPoints: [] });
try {
await api.post(`/map/tracking/sessions/${trackingSessionId}/points`, {
points: toSend,
});
} catch {
// Re-add failed points to the buffer
set((state) => ({
pendingPoints: [...toSend, ...state.pendingPoints],
}));
}
},
linkCanvassSession: async (canvassSessionId) => {
const { trackingSessionId } = get();
if (!trackingSessionId) return;
try {
await api.post(`/map/tracking/sessions/${trackingSessionId}/link-canvass`, {
canvassSessionId,
});
} catch {
// Non-critical
}
},
addEventPoint: (lat, lng, eventType) => {
get().addPoint({
latitude: lat,
longitude: lng,
recordedAt: new Date().toISOString(),
eventType,
});
},
restoreSession: async () => {
try {
const { data } = await api.get('/map/tracking/my/session');
if (data) {
set({ trackingSessionId: data.id, isTracking: true });
}
} catch {
// No active session
}
},
reset: () => {
set({
trackingSessionId: null,
isTracking: false,
pendingPoints: [],
});
},
}));

987
admin/src/types/api.ts Normal file
View File

@ -0,0 +1,987 @@
// TypeScript interfaces mirroring API response shapes
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER' | 'TEMP';
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED';
export type CreatedVia = 'ADMIN' | 'PUBLIC_SHIFT_SIGNUP' | 'STANDARD';
export interface User {
id: string;
email: string;
name: string | null;
phone: string | null;
role: UserRole;
status: UserStatus;
permissions: Record<string, unknown> | null;
createdVia: CreatedVia;
expiresAt: string | null;
expireDays: number | null;
lastLoginAt: string | null;
emailVerified: boolean;
createdAt: string;
updatedAt: string;
}
export interface AuthResponse {
user: User;
accessToken: string;
refreshToken: string;
}
export interface PaginationMeta {
page: number;
limit: number;
total: number;
totalPages: number;
}
export interface UsersListResponse {
users: User[];
pagination: PaginationMeta;
}
export interface ApiError {
error: {
message: string;
code: string;
details?: Record<string, string[]>;
};
}
export interface CreateUserPayload {
email: string;
password: string;
name?: string;
phone?: string;
role?: UserRole;
status?: UserStatus;
expiresAt?: string;
expireDays?: number;
}
export interface UpdateUserPayload {
email?: string;
password?: string;
name?: string;
phone?: string;
role?: UserRole;
status?: UserStatus;
expiresAt?: string | null;
expireDays?: number;
}
export interface UsersListParams {
page?: number;
limit?: number;
search?: string;
role?: UserRole;
status?: UserStatus;
}
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
// --- Campaigns ---
export type CampaignStatus = 'DRAFT' | 'ACTIVE' | 'PAUSED' | 'ARCHIVED';
export type GovernmentLevel = 'FEDERAL' | 'PROVINCIAL' | 'MUNICIPAL' | 'SCHOOL_BOARD';
export interface Campaign {
id: string;
slug: string;
title: string;
description: string | null;
emailSubject: string;
emailBody: string;
callToAction: string | null;
coverPhoto: string | null;
status: CampaignStatus;
allowSmtpEmail: boolean;
allowMailtoLink: boolean;
collectUserInfo: boolean;
showEmailCount: boolean;
showCallCount: boolean;
allowEmailEditing: boolean;
allowCustomRecipients: boolean;
showResponseWall: boolean;
highlightCampaign: boolean;
targetGovernmentLevels: GovernmentLevel[];
createdByUserId: string | null;
createdByUserEmail: string | null;
createdByUserName: string | null;
createdAt: string;
updatedAt: string;
_count: {
emails: number;
responses: number;
};
}
export interface CampaignsListResponse {
campaigns: Campaign[];
pagination: PaginationMeta;
}
export interface CreateCampaignPayload {
title: string;
emailSubject: string;
emailBody: string;
description?: string;
callToAction?: string;
status?: CampaignStatus;
targetGovernmentLevels?: GovernmentLevel[];
allowSmtpEmail?: boolean;
allowMailtoLink?: boolean;
collectUserInfo?: boolean;
showEmailCount?: boolean;
showCallCount?: boolean;
allowEmailEditing?: boolean;
allowCustomRecipients?: boolean;
showResponseWall?: boolean;
highlightCampaign?: boolean;
coverPhoto?: string;
}
export interface UpdateCampaignPayload {
title?: string;
emailSubject?: string;
emailBody?: string;
description?: string | null;
callToAction?: string | null;
status?: CampaignStatus;
targetGovernmentLevels?: GovernmentLevel[];
allowSmtpEmail?: boolean;
allowMailtoLink?: boolean;
collectUserInfo?: boolean;
showEmailCount?: boolean;
showCallCount?: boolean;
allowEmailEditing?: boolean;
allowCustomRecipients?: boolean;
showResponseWall?: boolean;
highlightCampaign?: boolean;
coverPhoto?: string | null;
}
export interface CampaignsListParams {
page?: number;
limit?: number;
search?: string;
status?: CampaignStatus;
}
// --- Representatives ---
export interface RepresentativeOffice {
type?: string;
tel?: string;
fax?: string;
postal?: string;
}
export interface Representative {
id: string | null;
postalCode: string;
name: string | null;
email: string | null;
districtName: string | null;
electedOffice: string | null;
partyName: string | null;
representativeSetName: string | null;
url: string | null;
photoUrl: string | null;
offices: RepresentativeOffice[] | null;
cachedAt: string;
}
export interface RepresentativesListResponse {
representatives: Representative[];
pagination: PaginationMeta;
}
export interface RepresentativeLookupResponse {
source: 'cache' | 'api';
postalCode: string;
location: {
city: string | null;
province: string | null;
};
representatives: Representative[];
}
export interface CacheStats {
totalRepresentatives: number;
postalCodesWithRepresentatives: number;
totalPostalCodes: number;
}
export interface RepresentativesListParams {
page?: number;
limit?: number;
search?: string;
postalCode?: string;
}
// --- Campaign Emails ---
export type EmailMethod = 'SMTP' | 'MAILTO';
export type CampaignEmailStatus = 'QUEUED' | 'SENT' | 'FAILED' | 'CLICKED' | 'USER_INFO_CAPTURED';
export interface CampaignEmail {
id: string;
userEmail: string | null;
userName: string | null;
userPostalCode: string | null;
recipientEmail: string;
recipientName: string | null;
recipientLevel: GovernmentLevel | null;
emailMethod: EmailMethod;
subject: string;
status: CampaignEmailStatus;
sentAt: string;
}
export interface CampaignEmailsListResponse {
emails: CampaignEmail[];
pagination: PaginationMeta;
}
export interface CampaignEmailStats {
total: number;
queued: number;
sent: number;
failed: number;
clicked: number;
smtpCount: number;
mailtoCount: number;
}
export interface QueueStats {
waiting: number;
active: number;
completed: number;
failed: number;
paused: boolean;
}
// --- Representative Responses ---
export type ResponseType = 'EMAIL' | 'LETTER' | 'PHONE_CALL' | 'MEETING' | 'SOCIAL_MEDIA' | 'OTHER';
export type ResponseStatus = 'PENDING' | 'APPROVED' | 'REJECTED';
export interface RepresentativeResponse {
id: string;
representativeName: string;
representativeTitle: string | null;
representativeLevel: GovernmentLevel;
representativeEmail: string | null;
responseType: ResponseType;
responseText: string;
userComment: string | null;
submittedByName: string | null;
submittedByEmail: string | null;
isAnonymous: boolean;
status: ResponseStatus;
isVerified: boolean;
verifiedAt: string | null;
verifiedBy: string | null;
upvoteCount: number;
createdAt: string;
campaign?: {
id: string;
title: string;
slug: string;
};
}
export interface ResponsesListResponse {
responses: RepresentativeResponse[];
pagination: PaginationMeta;
}
export interface ResponseStats {
total: number;
verified: number;
totalUpvotes: number;
byLevel: Record<string, number>;
}
export interface SubmitResponsePayload {
representativeName: string;
representativeLevel: GovernmentLevel;
responseType: ResponseType;
responseText: string;
representativeTitle?: string;
representativeEmail?: string;
userComment?: string;
submittedByName?: string;
submittedByEmail?: string;
isAnonymous?: boolean;
sendVerification?: boolean;
}
export interface ResponsesListParams {
page?: number;
limit?: number;
status?: ResponseStatus;
campaignId?: string;
search?: string;
}
export interface PublicResponsesListParams {
page?: number;
limit?: number;
sort?: 'recent' | 'upvotes' | 'verified';
level?: GovernmentLevel;
}
export const RESPONSE_TYPE_LABELS: Record<ResponseType, string> = {
EMAIL: 'Email',
LETTER: 'Letter',
PHONE_CALL: 'Phone Call',
MEETING: 'Meeting',
SOCIAL_MEDIA: 'Social Media',
OTHER: 'Other',
};
export const RESPONSE_STATUS_COLORS: Record<ResponseStatus, string> = {
PENDING: 'orange',
APPROVED: 'green',
REJECTED: 'red',
};
export const GOVERNMENT_LEVEL_COLORS: Record<GovernmentLevel, string> = {
FEDERAL: 'blue',
PROVINCIAL: 'purple',
MUNICIPAL: 'green',
SCHOOL_BOARD: 'orange',
};
export const GOVERNMENT_LEVEL_LABELS: Record<GovernmentLevel, string> = {
FEDERAL: 'Federal',
PROVINCIAL: 'Provincial',
MUNICIPAL: 'Municipal',
SCHOOL_BOARD: 'School Board',
};
// --- Map / Locations ---
export type SupportLevel = 'LEVEL_1' | 'LEVEL_2' | 'LEVEL_3' | 'LEVEL_4';
export type GeocodeProvider = 'MAPBOX' | 'NOMINATIM' | 'PHOTON' | 'LOCATIONIQ' | 'ARCGIS' | 'UNKNOWN';
export interface Location {
id: string;
latitude: string | null; // Prisma Decimal comes as string
longitude: string | null;
firstName: string | null;
lastName: string | null;
email: string | null;
phone: string | null;
unitNumber: string | null;
supportLevel: SupportLevel | null;
address: string | null;
sign: boolean;
signSize: string | null;
notes: string | null;
geocodeConfidence: number | null;
geocodeProvider: GeocodeProvider | null;
createdByUserId: string | null;
updatedByUserId: string | null;
createdAt: string;
updatedAt: string;
}
export interface LocationsListResponse {
locations: Location[];
pagination: PaginationMeta;
}
export interface LocationStats {
total: number;
supportLevels: {
LEVEL_1: number;
LEVEL_2: number;
LEVEL_3: number;
LEVEL_4: number;
NONE: number;
};
signs: number;
geocoded: number;
ungeocoded: number;
}
export interface MapSettings {
id: string;
latitude: string | null;
longitude: string | null;
zoom: number | null;
walkSheetTitle: string | null;
walkSheetSubtitle: string | null;
walkSheetFooter: string | null;
qrCode1Url: string | null;
qrCode1Label: string | null;
qrCode2Url: string | null;
qrCode2Label: string | null;
qrCode3Url: string | null;
qrCode3Label: string | null;
createdAt: string;
updatedAt: string;
}
export interface GeocodeResult {
latitude: number;
longitude: number;
confidence: number;
provider: GeocodeProvider;
formattedAddress?: string;
}
export interface LocationsListParams {
page?: number;
limit?: number;
search?: string;
supportLevel?: SupportLevel;
hasSign?: boolean;
sortBy?: 'createdAt' | 'address' | 'supportLevel';
sortOrder?: 'asc' | 'desc';
}
export const SUPPORT_LEVEL_LABELS: Record<SupportLevel, string> = {
LEVEL_1: 'Strong Support',
LEVEL_2: 'Likely Support',
LEVEL_3: 'Unsure',
LEVEL_4: 'Opposition',
};
export const SUPPORT_LEVEL_COLORS: Record<SupportLevel, string> = {
LEVEL_1: '#27ae60',
LEVEL_2: '#f1c40f',
LEVEL_3: '#e67e22',
LEVEL_4: '#e74c3c',
};
export interface ReverseGeocodeResult {
address: string;
city?: string;
province?: string;
country?: string;
}
// --- Map / Cuts ---
export type CutCategory = 'CUSTOM' | 'WARD' | 'NEIGHBORHOOD' | 'DISTRICT';
export interface Cut {
id: string;
name: string;
description: string | null;
color: string;
opacity: string; // Prisma Decimal comes as string
category: CutCategory | null;
isPublic: boolean;
isOfficial: boolean;
geojson: string;
bounds: string | null;
showLocations: boolean;
exportEnabled: boolean;
assignedTo: string | null;
filterSettings: Record<string, unknown> | null;
lastCanvassed: string | null;
completionPercentage: number;
createdByUserId: string | null;
createdAt: string;
updatedAt: string;
}
export interface PublicCut {
id: string;
name: string;
description: string | null;
color: string;
opacity: string;
category: CutCategory | null;
geojson: string;
bounds: string | null;
}
export interface CutsListResponse {
cuts: Cut[];
pagination: PaginationMeta;
}
export interface CreateCutPayload {
name: string;
geojson: string;
description?: string;
color?: string;
opacity?: number;
category?: CutCategory;
isPublic?: boolean;
isOfficial?: boolean;
bounds?: string;
showLocations?: boolean;
exportEnabled?: boolean;
assignedTo?: string;
}
export interface UpdateCutPayload {
name?: string;
description?: string | null;
color?: string;
opacity?: number;
category?: CutCategory | null;
isPublic?: boolean;
isOfficial?: boolean;
geojson?: string;
bounds?: string | null;
showLocations?: boolean;
exportEnabled?: boolean;
assignedTo?: string | null;
lastCanvassed?: string | null;
completionPercentage?: number;
}
export interface CutsListParams {
page?: number;
limit?: number;
category?: CutCategory;
search?: string;
}
export interface CutStatistics {
total: number;
byLevel: Record<string, number>;
withSign: number;
}
export const CUT_CATEGORY_LABELS: Record<CutCategory, string> = {
CUSTOM: 'Custom',
WARD: 'Ward',
NEIGHBORHOOD: 'Neighborhood',
DISTRICT: 'District',
};
export const CUT_CATEGORY_COLORS: Record<CutCategory, string> = {
CUSTOM: 'default',
WARD: 'blue',
NEIGHBORHOOD: 'green',
DISTRICT: 'purple',
};
export const MAP_ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN'];
// --- Map / Shifts ---
export type ShiftStatus = 'OPEN' | 'FULL' | 'CANCELLED';
export type SignupStatus = 'CONFIRMED' | 'CANCELLED';
export type SignupSource = 'AUTHENTICATED' | 'PUBLIC' | 'ADMIN';
export interface Shift {
id: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
location: string | null;
maxVolunteers: number;
currentVolunteers: number;
status: ShiftStatus;
isPublic: boolean;
cutId: string | null;
cut?: { id: string; name: string } | null;
createdBy: string | null;
createdAt: string;
updatedAt: string;
_count?: {
signups: number;
};
signups?: ShiftSignup[];
}
export interface ShiftSignup {
id: string;
shiftId: string;
shiftTitle: string | null;
userId: string | null;
userEmail: string;
userName: string | null;
userPhone: string | null;
signupDate: string;
status: SignupStatus;
signupSource: SignupSource;
user?: {
id: string;
email: string;
name: string | null;
phone: string | null;
};
}
export interface ShiftsListResponse {
shifts: Shift[];
pagination: PaginationMeta;
}
export interface ShiftStats {
total: number;
open: number;
full: number;
cancelled: number;
upcoming: number;
totalSignups: number;
}
export interface ShiftsListParams {
page?: number;
limit?: number;
search?: string;
status?: ShiftStatus;
upcoming?: boolean;
sortBy?: 'date' | 'createdAt' | 'title';
sortOrder?: 'asc' | 'desc';
}
export interface CreateShiftPayload {
title: string;
description?: string;
date: string;
startTime: string;
endTime: string;
location?: string;
maxVolunteers: number;
isPublic?: boolean;
cutId?: string;
}
export interface UpdateShiftPayload {
title?: string;
description?: string | null;
date?: string;
startTime?: string;
endTime?: string;
location?: string | null;
maxVolunteers?: number;
isPublic?: boolean;
status?: ShiftStatus;
cutId?: string | null;
}
export const SHIFT_STATUS_COLORS: Record<ShiftStatus, string> = {
OPEN: 'green',
FULL: 'orange',
CANCELLED: 'red',
};
export const SHIFT_STATUS_LABELS: Record<ShiftStatus, string> = {
OPEN: 'Open',
FULL: 'Full',
CANCELLED: 'Cancelled',
};
export const SIGNUP_SOURCE_COLORS: Record<SignupSource, string> = {
AUTHENTICATED: 'blue',
PUBLIC: 'green',
ADMIN: 'purple',
};
// --- Landing Pages ---
export type EditorMode = 'VISUAL' | 'CODE';
export type MkdocsExportMode = 'THEMED' | 'STANDALONE';
export interface LandingPage {
id: string;
slug: string;
title: string;
description: string | null;
editorMode: EditorMode;
blocks: unknown; // GrapesJS project data
htmlOutput: string | null;
cssOutput: string | null;
mkdocsPath: string | null;
mkdocsStubPath: string | null;
mkdocsExportMode: MkdocsExportMode;
mkdocsHideNav: boolean;
mkdocsHideToc: boolean;
published: boolean;
seoTitle: string | null;
seoDescription: string | null;
seoImage: string | null;
createdAt: string;
updatedAt: string;
}
export interface PageBlock {
id: string;
type: string;
label: string;
schema: Record<string, unknown>;
defaults: Record<string, unknown>;
thumbnail: string | null;
category: string | null;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface LandingPagesListResponse {
pages: LandingPage[];
pagination: PaginationMeta;
}
export interface LandingPagesListParams {
page?: number;
limit?: number;
search?: string;
published?: 'true' | 'false';
}
// --- Docs / Code Server ---
export interface DocsStatus {
mkdocs: { online: boolean; url: string };
codeServer: { online: boolean; url: string };
siteServer: { online: boolean; url: string };
}
export interface DocsConfig {
codeServerPort: number;
mkdocsPort: number;
mkdocsSitePort: number;
}
// --- Docs / File Tree ---
export interface FileNode {
name: string;
path: string;
isDirectory: boolean;
children?: FileNode[];
}
// --- Platform Services ---
export interface ServicesStatus {
nocodb: { online: boolean; url: string };
n8n: { online: boolean; url: string };
gitea: { online: boolean; url: string };
mailhog: { online: boolean; url: string };
}
export interface ServicesConfig {
nocodbPort: number;
n8nPort: number;
giteaPort: number;
mailhogPort: number;
domain: string;
nocodbSubdomain: string;
n8nSubdomain: string;
giteaSubdomain: string;
mailhogSubdomain: string;
}
// --- Site Settings ---
export interface SiteSettings {
id: string;
organizationName: string;
organizationShortName: string;
organizationLogoUrl: string | null;
organizationFaviconUrl: string | null;
adminColorPrimary: string;
adminColorBgBase: string;
publicColorPrimary: string;
publicColorBgBase: string;
publicColorBgContainer: string;
publicHeaderGradient: string;
footerText: string;
loginSubtitle: string;
emailFromName: string;
// SMTP fields (only present from admin endpoint)
smtpHost?: string;
smtpPort?: number;
smtpUser?: string;
smtpPass?: string;
smtpFromAddress?: string;
smtpActiveProvider?: 'mailhog' | 'production';
emailTestMode?: boolean;
testEmailRecipient?: string;
enableInfluence: boolean;
enableMap: boolean;
enableNewsletter: boolean;
enableLandingPages: boolean;
createdAt: string;
updatedAt: string;
}
// --- MkDocs Config ---
export interface MkDocsConfigResponse {
content: string;
}
export interface MkDocsBuildResult {
success: boolean;
output: string;
duration: number;
}
// --- Pangolin ---
export interface PangolinStatus {
configured: boolean;
healthy: boolean;
pangolinUrl: string | null;
orgId: string | null;
siteId: string | null;
newtConfigured: boolean;
}
export interface PangolinConfig {
pangolinApiUrl: string | null;
pangolinEndpoint: string | null;
orgId: string | null;
siteId: string | null;
newtId: string | null;
newtConfigured: boolean;
domain: string;
}
export interface PangolinResource {
resourceId: string;
siteId: string;
orgId: string;
name: string;
subdomain?: string;
fullDomain?: string;
ssl?: boolean;
active?: boolean;
proxyPort?: number;
protocol?: string;
}
// --- Listmonk ---
export interface ListmonkStatus {
enabled: boolean;
connected: boolean;
initialized: boolean;
lastSyncAt: string | null;
lastError: string | null;
}
export interface ListmonkListStat {
name: string;
subscriberCount: number;
}
export interface ListmonkStats {
lists: ListmonkListStat[];
}
export interface ListmonkSyncResult {
success: boolean;
message: string;
results?: {
total: number;
success: number;
failed: number;
errors: string[];
};
}
// --- Geocoding ---
export interface GeocodeSearchResult {
latitude: number;
longitude: number;
displayName: string;
type: string;
provider: string;
}
// --- Bulk Import ---
export interface BulkImportResult {
total: number;
created: number;
skippedDuplicate: number;
skippedOutOfBounds: number;
skippedInvalid: number;
errors: string[];
}
// --- NAR Server Import ---
export interface NarDataset {
provinceCode: string;
provinceName: string;
provinceAbbr: string;
addressFiles: { filename: string; sizeFormatted: string; sizeBytes: number }[];
locationFiles: { filename: string; sizeFormatted: string; sizeBytes: number }[];
totalAddressSize: number;
totalLocationSize: number;
}
export interface NarDatasetsResponse {
narDir: string | null;
datasets: NarDataset[];
}
export interface NarServerImportResult {
provinceCode: string;
provinceName: string;
totalRows: number;
created: number;
skippedDuplicate: number;
skippedOutOfBounds: number;
skippedNonResidential: number;
skippedInvalid: number;
errors: string[];
durationMs: number;
}
export interface NarImportProgress {
status: 'loading-locations' | 'importing' | 'complete' | 'failed';
totalRows: number;
created: number;
skippedDuplicate: number;
skippedOutOfBounds: number;
skippedNonResidential: number;
skippedInvalid: number;
currentFile: string;
provinceName: string;
error?: string;
result?: NarServerImportResult;
}
// --- SMTP Test Results ---
export interface SmtpTestResult {
success: boolean;
message: string;
}
export interface SmtpSendTestResult {
success: boolean;
messageId?: string;
testMode: boolean;
recipient: string;
}
export interface ListmonkSyncAllResult {
success: boolean;
message: string;
results?: {
participants: { total: number; success: number; failed: number; errors: string[] };
locations: { total: number; success: number; failed: number; errors: string[] };
users: { total: number; success: number; failed: number; errors: string[] };
};
}

152
admin/src/types/canvass.ts Normal file
View File

@ -0,0 +1,152 @@
import type { SupportLevel } from './api';
// --- Enums ---
export type VisitOutcome =
| 'NOT_HOME'
| 'REFUSED'
| 'MOVED'
| 'ALREADY_VOTED'
| 'SPOKE_WITH'
| 'LEFT_LITERATURE'
| 'COME_BACK_LATER';
export type CanvassSessionStatus = 'ACTIVE' | 'COMPLETED' | 'ABANDONED';
export const VISIT_OUTCOME_LABELS: Record<VisitOutcome, string> = {
NOT_HOME: 'Not Home',
REFUSED: 'Refused',
MOVED: 'Moved',
ALREADY_VOTED: 'Already Voted',
SPOKE_WITH: 'Spoke With',
LEFT_LITERATURE: 'Left Literature',
COME_BACK_LATER: 'Come Back Later',
};
export const VISIT_OUTCOME_COLORS: Record<VisitOutcome, string> = {
NOT_HOME: '#f1c40f',
REFUSED: '#e74c3c',
MOVED: '#95a5a6',
ALREADY_VOTED: '#9b59b6',
SPOKE_WITH: '#27ae60',
LEFT_LITERATURE: '#3498db',
COME_BACK_LATER: '#e67e22',
};
// --- Interfaces ---
export interface CanvassLocation {
id: string;
latitude: number;
longitude: number;
address: string | null;
unitNumber: string | null;
firstName: string | null;
lastName: string | null;
supportLevel: SupportLevel | null;
sign: boolean;
signSize: string | null;
notes: string | null;
lastVisit: {
outcome: VisitOutcome;
visitedAt: string;
visitorName: string | null;
isMyVisit: boolean;
} | null;
}
export interface WalkingRoute {
orderedLocations: { id: string; latitude: number; longitude: number }[];
totalDistanceMeters: number;
estimatedMinutes: number;
}
export interface CanvassSession {
id: string;
userId: string;
cutId: string;
shiftId: string | null;
status: CanvassSessionStatus;
startedAt: string;
endedAt: string | null;
startLatitude: string | null;
startLongitude: string | null;
cut?: { id: string; name: string };
shift?: { id: string; title: string } | null;
}
export interface MyAssignment {
shiftId: string;
shiftTitle: string;
shiftDate: string;
startTime: string;
endTime: string;
location: string | null;
cutId: string;
cutName: string;
completionPercentage: number;
}
export interface MyCanvassStats {
totalVisits: number;
todayVisits: number;
byOutcome: Record<string, number>;
sessions: number;
}
export interface RecordVisitPayload {
locationId: string;
outcome: VisitOutcome;
supportLevel?: SupportLevel;
signRequested?: boolean;
signSize?: string;
notes?: string;
durationSeconds?: number;
sessionId?: string;
shiftId?: string;
updateLocation?: boolean;
}
export interface CanvassVisit {
id: string;
locationId: string;
userId: string;
outcome: VisitOutcome;
supportLevel: SupportLevel | null;
signRequested: boolean;
signSize: string | null;
notes: string | null;
durationSeconds: number | null;
visitedAt: string;
location?: { id: string; address: string | null; unitNumber: string | null };
user?: { id: string; name: string | null; email: string };
}
// --- Admin types ---
export interface CanvassAdminStats {
totalVisits: number;
todayVisits: number;
activeSessions: number;
activeVolunteers: number;
overallCompletion: number;
}
export interface CutCanvassStats {
cutId: string;
cutName: string;
totalLocations: number;
visitedLocations: number;
completionPercentage: number;
totalVisits: number;
byOutcome: Record<string, number>;
}
export interface VolunteerSummary {
userId: string;
name: string | null;
email: string;
totalVisits: number;
sessions: number;
lastActive: string | null;
}

View File

@ -0,0 +1,45 @@
export type TrackPointEvent = 'LOCATION_ADDED' | 'VISIT_RECORDED' | 'SESSION_STARTED' | 'SESSION_ENDED';
export interface TrackPointPayload {
latitude: number;
longitude: number;
accuracy?: number;
recordedAt: string;
eventType?: TrackPointEvent;
}
export interface LiveVolunteer {
userId: string;
name: string | null;
email: string;
latitude: number;
longitude: number;
lastRecordedAt: string;
recentTrail: [number, number][];
canvassSessionId: string | null;
trackingSessionId: string;
}
export interface TrackingSessionSummary {
id: string;
userId: string;
userName: string | null;
userEmail: string;
startedAt: string;
endedAt: string | null;
totalPoints: number;
totalDistanceM: number;
canvassSessionId: string | null;
isActive: boolean;
}
export interface SessionRoute {
coordinates: [number, number][];
events: {
latitude: number;
longitude: number;
eventType: TrackPointEvent;
recordedAt: string;
}[];
totalPoints: number;
}

View File

@ -0,0 +1,43 @@
import type { GovernmentLevel, Representative } from '@/types/api';
export function mapRepSetToLevel(repSetName: string | null): GovernmentLevel | null {
if (!repSetName) return null;
const lower = repSetName.toLowerCase();
if (lower.includes('house of commons') || lower.includes('senate')) return 'FEDERAL';
if (lower.includes('legislative') || lower.includes('national assembly') || lower.includes('provincial')) return 'PROVINCIAL';
if (lower.includes('council') || lower.includes('city') || lower.includes('municipal') || lower.includes('mayor')) return 'MUNICIPAL';
if (lower.includes('school') || lower.includes('board') || lower.includes('trustee')) return 'SCHOOL_BOARD';
if (lower.includes('mp') || lower.includes('federal')) return 'FEDERAL';
if (lower.includes('mpp') || lower.includes('mla') || lower.includes('mna')) return 'PROVINCIAL';
return null;
}
export function groupRepsByLevel(reps: Representative[]): Record<string, Representative[]> {
const groups: Record<string, Representative[]> = {};
const levelOrder: GovernmentLevel[] = ['FEDERAL', 'PROVINCIAL', 'MUNICIPAL', 'SCHOOL_BOARD'];
for (const rep of reps) {
const level = mapRepSetToLevel(rep.representativeSetName) || 'OTHER';
if (!groups[level]) groups[level] = [];
groups[level].push(rep);
}
// Sort by level order
const sorted: Record<string, Representative[]> = {};
for (const level of levelOrder) {
if (groups[level]) sorted[level] = groups[level];
}
if (groups['OTHER']) sorted['OTHER'] = groups['OTHER'];
return sorted;
}
/**
* Maps Ant Design tag color names to actual hex colors for use in inline styles.
*/
export const GOVERNMENT_LEVEL_DISPLAY_COLORS: Record<string, string> = {
FEDERAL: '#3b82f6',
PROVINCIAL: '#a855f7',
MUNICIPAL: '#22c55e',
SCHOOL_BOARD: '#f97316',
OTHER: '#94a3b8',
};

13
admin/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
/// <reference types="vite/client" />
declare module 'grapesjs-tabs' {
import { Plugin } from 'grapesjs';
const plugin: Plugin;
export default plugin;
}
declare module 'grapesjs-touch' {
import { Plugin } from 'grapesjs';
const plugin: Plugin;
export default plugin;
}

24
admin/tsconfig.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

Some files were not shown because too many files have changed in this diff Show More