Initial commit

Next.js + Express event management app for Hope Family Church.
This commit is contained in:
2026-07-23 15:26:47 +02:00
commit 3d381944d2
246 changed files with 57565 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+318
View File
@@ -0,0 +1,318 @@
# Hope Events — Frontend
Next.js 15 (App Router) frontend for the Hope Events platform used by Hope Family Church (`events.hopehenley.co.za`).
---
## Table of Contents
1. [Overview](#overview)
2. [Tech Stack](#tech-stack)
3. [Prerequisites](#prerequisites)
4. [Installation](#installation)
5. [Environment Variables](#environment-variables)
6. [Running the App](#running-the-app)
7. [Project Structure](#project-structure)
8. [Pages & Routes](#pages--routes)
9. [Authentication & Roles](#authentication--roles)
10. [Payment Flow](#payment-flow)
11. [Notifications](#notifications)
12. [Deployment](#deployment)
13. [Troubleshooting](#troubleshooting)
---
## Overview
The frontend handles:
- Public event listings and event detail pages
- User registration, login, and account management
- Attendee registration and Yoco checkout
- Ticket viewing and delivery
- Staff ticket scanning (QR scanner)
- Supervisor tools: event management, payments, bulk messaging (email + WhatsApp), reports, at-the-door check-in
- Admin tools: user management, WhatsApp instance management, form builder
---
## Tech Stack
| Layer | Technology |
|-------|-----------|
| Framework | Next.js 15 (App Router, React 19) |
| Language | TypeScript |
| Styling | Tailwind CSS 3 |
| UI Primitives | Radix UI |
| Forms | React Hook Form + Zod |
| Icons | Lucide React |
| QR Scanning | `@zxing/browser`, `react-webcam` |
| QR Generation | `qrcode` |
| Date handling | `date-fns`, `react-day-picker` |
---
## Prerequisites
- Node.js 18+
- npm
- A running backend API (see `../backend/README.md`)
---
## Installation
```bash
cd frontend
npm install
```
---
## Environment Variables
Create `frontend/.env.local`:
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_API_URL` | Yes | Base URL of the backend API, e.g. `https://api.yourdomain.com` or `http://localhost:5000` for local dev |
> **Organisation name, brand colour, logo, and contact email** are now stored in the database and managed via **Admin → Site Settings**. `NEXT_PUBLIC_APP_NAME`, `NEXT_PUBLIC_ORG_NAME`, `NEXT_PUBLIC_BRAND_COLOR`, and `NEXT_PUBLIC_CONTACT_EMAIL` are no longer needed and can be removed from your `.env.local`.
---
## Running the App
```bash
# Development (Turbopack)
npm run dev
# Production build + start
npm run build
npm start
# Lint
npm run lint
```
---
## Project Structure
```
frontend/src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Login, register, forgot/reset password
│ ├── [redirectUrl]/ # Event alias redirects
│ ├── activate-account/ # Account activation from email link
│ ├── dashboard/
│ │ ├── layout.tsx # Shared dashboard shell + nav
│ │ ├── admin/ # Admin-only pages
│ │ ├── supervisor/ # Supervisor + admin pages
│ │ ├── staff/ # Staff + above pages
│ │ └── user/ # All authenticated users
│ ├── events/ # Public event listing + detail
│ ├── forms/ # Public form submission
│ ├── legal/ # Privacy policy, terms
│ ├── payment/ # Payment success/failure/cancel
│ ├── register/[eventId]/ # Event registration flow
│ ├── registration/ # Post-registration success
│ ├── reset-password/ # Password reset (token flow)
│ ├── self-service/ # Guest self-service portal
│ ├── set-banner/ # Quick banner editor (supervisor)
│ └── layout.tsx # Root layout
├── components/ # Shared UI components
├── hooks/ # Custom React hooks (useAuth, etc.)
└── lib/ # Utilities (api.ts fetch wrapper, etc.)
```
---
## Pages & Routes
### Public
| Route | Description |
|-------|-------------|
| `/` | Landing page |
| `/events` | Public event listing — shows "Sold Out" button when all limited-stock options are exhausted; free events show "Register" without a price suffix |
| `/events/[id]` | Event detail page — sold-out/registration-closed/not-yet-open states; zero-price options display "Free"; tiered low-stock badges (≤ 50 tickets: 20 %; 51200: 15 %; 2011 000: 10 %; 1 000+: 5 %) |
| `/[redirectUrl]` | Event alias redirect |
| `/register/[eventId]` | Registration flow — sold-out banner + disabled button when event is fully booked; tiered stock badges; variant qty controls; early-bird notice |
| `/forms` | Public form submission |
| `/legal/privacy` | Privacy policy |
| `/legal/terms` | Terms of service |
| `/lockdown-rules` | House rules page |
| `/payment/success` | Yoco checkout success |
| `/payment/failure` | Yoco checkout failure |
| `/payment/cancel` | Yoco checkout cancelled |
| `/registration/success` | Post-registration confirmation |
| `/self-service` | Guest self-service (find tickets by email) |
| `/activate-account` | Account activation via email link |
| `/reset-password` | Password reset (token from email) |
### Auth
| Route | Description |
|-------|-------------|
| `/login` | Login form |
| `/register` | Sign-up form |
| `/forgot-password` | Request password reset |
### User Dashboard — `/dashboard/user/`
| Route | Description |
|-------|-------------|
| `/dashboard/user` | My registrations + tickets overview |
| `/dashboard/user/profile` | Edit profile, notification preferences |
| `/dashboard/user/pay` | Pay outstanding balance |
| `/dashboard/user/donate` | Make a donation |
| `/dashboard/user/forms` | Complete registration forms |
| `/dashboard/user/reset-password` | Change password (authenticated) |
### Staff Dashboard — `/dashboard/staff/`
| Route | Description |
|-------|-------------|
| `/dashboard/staff` | Staff overview |
| `/dashboard/staff/ticket-scanning` | QR code scanner for check-in |
| `/dashboard/staff/event-tickets` | All tickets for an event |
### Supervisor Dashboard — `/dashboard/supervisor/`
| Route | Description |
|-------|-------------|
| `/dashboard/supervisor` | Supervisor overview + quick links |
| `/dashboard/supervisor/events` | Create/edit/manage events — 6-step wizard (Basic Details, Items & Pricing, Sections, Form, Visibility, Attachments); form builder with up/down reorder, type badges, separate shortcut buttons for statement/heading fields; early-bird tiers with labelled deadline/price inputs; event image file upload with preview; attachments can be staged during creation (uploaded after save) |
| `/dashboard/supervisor/event-options` | Manage ticket types / options |
| `/dashboard/supervisor/payments` | View and manage payments |
| `/dashboard/supervisor/manual` | Manual payment entry |
| `/dashboard/supervisor/manual-registration` | Register an attendee manually |
| `/dashboard/supervisor/at-the-door` | At-the-door check-in management |
| `/dashboard/supervisor/email-attendees` | Bulk email + automations + broadcasts + scheduled |
| `/dashboard/supervisor/whatsapp-attendees` | Bulk WhatsApp + automations + broadcasts + scheduled |
| `/dashboard/supervisor/reports` | Generate/download/email reports |
| `/dashboard/supervisor/forms` | Event form builder and response viewer — three modes: view responses (printable, page-break per submission), fill/edit responses, edit form structure; improved form builder with reorder buttons and type badges |
| `/dashboard/supervisor/sections` | Manage event sections |
| `/set-banner` | Quick banner editor |
### Admin Dashboard — `/dashboard/admin/`
| Route | Description |
|-------|-------------|
| `/dashboard/admin` | Admin overview |
| `/dashboard/admin/users` | User management — list, inline edit, deactivate, role assignment, per-page selector (10/25/50/100), server-side search/filter by name/email/phone/role/status, "Delete data" button to anonymise a user's personal information |
| `/dashboard/admin/registrations` | All registrations — filter by event, status, and fuzzy text search; expandable rows showing ticket options and form responses |
| `/dashboard/admin/whatsapp` | WhatsApp instance management (WAWP) |
| `/dashboard/admin/forms` | View all form responses (inherits supervisor/forms) |
| `/dashboard/admin/settings` | Site Settings — organisation details, logo, brand colour, notification emails, SMTP delivery (with test connection; friendly errors + collapsible raw details on failure), legal page content |
### Setup — `/setup`
| Route | Description |
|-------|-------------|
| `/setup` | First-time setup wizard — shown automatically on a fresh deployment (empty database). Creates the first admin account and initial site settings in three steps: organisation details, admin account, branding. After completion, redirected to login. |
---
## Authentication & Roles
Auth state is managed via the `useAuth` hook (`src/hooks/useAuth.ts`). The JWT is stored in `localStorage` and sent as a `Bearer` token on every API call via `src/lib/api.ts` (`apiFetch`).
### Role access matrix
| Page group | `user` | `staff` | `supervisor` | `admin` |
|------------|--------|---------|--------------|---------|
| Public pages | Yes | Yes | Yes | Yes |
| `/dashboard/user/*` | Yes | Yes | Yes | Yes |
| `/dashboard/staff/*` | — | Yes | Yes | Yes |
| `/dashboard/supervisor/*` | — | — | Yes | Yes |
| `/dashboard/admin/*` | — | — | — | Yes |
Unauthenticated users are redirected to `/login`. Insufficient-role users see an in-page warning.
### Notification preferences
Users set their preference on the profile page:
- `email` — email notifications only
- `whatsapp` — WhatsApp notifications only
- `both` — both channels
The supervisor bulk-messaging pages show preference indicators in user/attendee dropdowns:
- **Indigo badge** — email preference matches (email/both) on the email page
- **Green badge** — WhatsApp preference matches (whatsapp/both) on the WA page
- **Amber badge** — preference mismatch (will still receive message)
A "Select Email/both" or "Select WhatsApp/both" quick-select button is available in each dropdown.
---
## Payment Flow
1. User registers for a paid event → registration created with `status: pending`
2. User clicks "Pay" → calls `POST /api/payments/yoco-checkout`
3. Backend returns a Yoco `redirectUrl` → frontend opens it in a new tab
4. User pays on Yoco's hosted page → Yoco sends webhook to backend
5. Backend reconciles payment, updates registration status, generates and emails tickets
6. User sees `/payment/success` and tickets appear in their dashboard
Outstanding balances can be paid at any time from `/dashboard/user/pay`.
---
## Notifications
### Bulk Email / WhatsApp (`email-attendees`, `whatsapp-attendees`)
Both pages share the same feature set:
- **Attendees tab** — send or schedule a message to attendees of a specific event; filter by payment status; templates: custom, payment reminder, event reminder, ticket delivery
- **Automations tab** — schedule pre-event (1 week), final reminder (24/48 h), thank-you, and promo messages with smart default timing based on the event's dates
- **Broadcasts tab** — send to a selected user list plus ad-hoc email addresses or phone numbers
- **Scheduled tab** — view, edit (reschedule / update content), or cancel queued jobs
Mismatch warnings appear when selected users prefer a different channel. Falling back to all attendees if no matching-preference attendees exist.
The attendee selection dropdown is constrained to a fixed width (`max-w-xs` / `w-64` panel) so it does not grow to fill the page on wide screens.
---
## Deployment
### Vercel (recommended)
1. Push the `frontend/` directory to GitHub
2. Create a Vercel project pointing to `frontend/`
3. Set env var: `NEXT_PUBLIC_API_URL=https://your-api.domain.com`
4. Deploy
### Self-hosted (Node.js)
```bash
npm run build
npm start # port 3000 by default
```
Use nginx/Caddy as a reverse proxy.
### PM2
```bash
pm2 start npm --name hope-events-frontend -- start
pm2 save && pm2 startup
```
### Checklist
- [ ] `NEXT_PUBLIC_API_URL` set to production backend URL
- [ ] Backend `FRONTEND_URL` includes this frontend's origin
- [ ] Yoco webhook URL reachable over HTTPS
- [ ] `npm run build` completes without errors
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| 4xx/5xx API calls | Check `NEXT_PUBLIC_API_URL` is correct and reachable from the browser |
| CORS errors | Confirm backend `FRONTEND_URL` includes your frontend origin |
| Port conflict in dev | Next.js auto-selects 3001+ if 3000 is taken — keep `NEXT_PUBLIC_API_URL` pointing at the backend port |
| Images not showing | Backend must serve `/uploads` and `public/uploads` must be writable |
| Login loop | Clear `localStorage` and re-login; check `JWT_SECRET` on backend hasn't changed |
| QR scanner not working | Browser requires camera permission; `@zxing/browser` requires HTTPS in production |
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;
+15
View File
@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
domains: ['images.unsplash.com'],
},
eslint: {
ignoreDuringBuilds: true,
},
compiler: {
removeConsole: process.env.NODE_ENV === 'production',
},
};
export default nextConfig;
+8232
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
{
"name": "hope-events-frontend",
"version": "1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-radio-group": "^1.3.7",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-tooltip": "^1.2.7",
"@zxing/browser": "^0.1.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.536.0",
"next": "15.4.5",
"qrcode": "^1.5.4",
"react": "19.1.0",
"react-day-picker": "^9.8.1",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
"react-webcam": "^7.2.0",
"tailwind-merge": "^3.3.1",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.0.15"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/qrcode": "^1.5.5",
"@types/react": "^19",
"@types/react-dom": "^19",
"autoprefixer": "^10.4.21",
"eslint": "^9",
"eslint-config-next": "15.4.5",
"postcss": "^8.5.6",
"tailwindcss": "3.4",
"typescript": "^5"
}
}
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) return;
try {
setLoading(true);
setStatus(null);
const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } });
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
} catch (err: any) {
try {
const parsed = JSON.parse(err?.message || "");
setStatus(parsed?.message || "Something went wrong");
} catch {
setStatus(err?.message || "Something went wrong");
}
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<h1 className="text-2xl font-semibold mb-4">Forgot your password?</h1>
<p className="text-gray-700 mb-6">Enter your account email and we'll send you a link to reset your password.</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
<button disabled={loading || !email} className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60">
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
{status && <p className="text-sm text-gray-700 mt-4">{status}</p>}
</div>
</main>
<Footer />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { LoginForm } from "@/components/auth/LoginForm";
export default function LoginPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold mb-4">Login</h1>
<Suspense fallback={<div />}>
<LoginForm />
</Suspense>
</div>
</main>
<Footer />
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
"use client";
import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { RegisterForm } from "@/components/auth/RegisterForm";
export default function RegisterPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold mb-4">Create Account</h1>
<Suspense fallback={<div />}>
<RegisterForm />
</Suspense>
</div>
</main>
<Footer />
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { redirect } from "next/navigation";
import { apiFetch } from "@/lib/api";
export const revalidate = 60;
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
const { redirectUrl } = await params;
let event: any = null;
try {
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
} catch (error) {
console.error("Failed to fetch event:", error);
}
if (!event || event.message?.toLowerCase().includes("not found")) {
return (
<main className="flex flex-col items-center justify-center min-h-screen text-center px-4">
<p className="text-6xl font-bold text-gray-200 mb-2">404</p>
<h1 className="text-2xl font-bold mb-2">Page Not Found</h1>
<p className="text-gray-500 mb-6">
The page you&apos;re looking for doesn&apos;t exist.
</p>
<a href="/" className="text-blue-600 hover:underline transition-all duration-200">
Back to Home &rarr;
</a>
</main>
);
}
if (event.isLive || event.isActive) {
redirect(`/events/${event.id}`);
}
return (
<main className="flex flex-col items-center justify-center min-h-screen text-center">
<h1 className="text-3xl font-bold mb-2">This event has ended</h1>
<p className="text-gray-600">Thanks for joining us! Stay tuned for the next one.</p>
<a href="/" className="mt-4 text-blue-600 hover:underline">
Back to Home &rarr;
</a>
</main>
);
}
@@ -0,0 +1,79 @@
"use client";
import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { appName } from "@/lib/siteConfig";
function ActivateAccountContent() {
const search = useSearchParams();
const router = useRouter();
const token = search.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token, [password, confirm, token]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
try {
setLoading(true);
setStatus(null);
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
// Auto-login: save token and redirect to dashboard
if (data?.token) {
localStorage.setItem("token", data.token);
}
setStatus("Your account is now active. Redirecting…");
setTimeout(() => router.push("/dashboard"), 1500);
} catch (err: any) {
setStatus(err?.message || "Activation failed. The link may have expired.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<h1 className="text-2xl font-semibold mb-2">Activate your account</h1>
<p className="text-gray-500 text-sm mb-5">Set a password to activate your {appName} account.</p>
{!token && <p className="text-red-600 mb-4">Missing or invalid activation link.</p>}
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
<p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
{password && confirm && password !== confirm && (
<p className="text-xs text-red-600">Passwords do not match.</p>
)}
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60 w-full py-2.5 font-medium">
{loading ? "Activating…" : "Activate my account"}
</button>
</form>
{status && <p className="text-sm text-gray-700 mt-4">{status}</p>}
</div>
</main>
<Footer />
</div>
);
}
export default function ActivateAccountPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<ActivateAccountContent />
</Suspense>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

@@ -0,0 +1,505 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
const ZAR_DENOMINATIONS = [200, 100, 50, 20, 10, 5, 2, 1, 0.5, 0.2, 0.1];
function money(n: number | null | undefined): string {
return `R${Number(n || 0).toFixed(2)}`;
}
function denomLabel(v: number): string {
return v >= 1 ? `R${v}` : `${Math.round(v * 100)}c`;
}
type LineInput = { actualAmount: string; notes: string };
export default function EventCashupPage() {
const params = useParams<{ id: string }>();
const eventId = params.id;
const router = useRouter();
const { token } = useAuth();
const [tab, setTab] = useState<"costs" | "reconciliation">("costs");
const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const isClosed = data?.event?.cashupStatus === "closed";
const load = async () => {
if (!token || !eventId) return;
setLoading(true);
setError(null);
try {
const [financials, event] = await Promise.all([
apiFetch<EventFinancials>(`/api/cashups/event/${eventId}`, { authToken: token }),
apiFetch<any>(`/api/events/${eventId}`, { authToken: token })
]);
setData(financials);
setEventOptions((event.eventOptions || []).map((o: any) => ({ id: o.id, name: o.name })));
} catch (e: any) {
setError(e?.message || "Failed to load cashup data");
} finally {
setLoading(false);
}
};
useEffect(() => { load(); }, [token, eventId]);
return (
<div className="max-w-4xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<button className="text-xs text-indigo-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold mt-1">{data?.event?.title || "Event"} Cashup</h1>
</div>
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
<div className="flex gap-2 border-b">
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
</div>
{loading && <div className="text-sm text-gray-400">Loading</div>}
{!loading && data && tab === "costs" && (
<CostsTab eventId={eventId} token={token || ""} costs={data.costs} eventOptions={eventOptions} isClosed={isClosed} onChanged={load} />
)}
{!loading && data && tab === "reconciliation" && (
<ReconciliationTab
eventId={eventId}
token={token || ""}
data={data}
busy={busy}
setBusy={setBusy}
setError={setError}
onChanged={load}
/>
)}
</div>
);
}
// ─── Costs tab ─────────────────────────────────────────────────────────────
function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }: {
eventId: string; token: string; costs: EventCost[]; eventOptions: { id: string; name: string }[]; isClosed: boolean; onChanged: () => void;
}) {
const [editingId, setEditingId] = useState<string | "new" | null>(null);
const [label, setLabel] = useState("");
const [costType, setCostType] = useState<EventCostType>("once_off");
const [amount, setAmount] = useState("");
const [eventOptionId, setEventOptionId] = useState("");
const [paidFromMethod, setPaidFromMethod] = useState<"" | CashupMethod>("");
const [notes, setNotes] = useState("");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const startNew = () => {
setEditingId("new");
setLabel(""); setCostType("once_off"); setAmount(""); setEventOptionId(""); setPaidFromMethod(""); setNotes("");
setErr(null);
};
const startEdit = (c: EventCost) => {
setEditingId(c.id);
setLabel(c.label); setCostType(c.costType); setAmount(String(c.amount)); setEventOptionId(c.eventOptionId || ""); setPaidFromMethod(c.paidFromMethod || ""); setNotes(c.notes || "");
setErr(null);
};
const cancel = () => setEditingId(null);
const save = async () => {
if (!label.trim()) { setErr("Label is required"); return; }
if (!amount || isNaN(parseFloat(amount))) { setErr("Amount is required"); return; }
if (costType === "per_item" && !eventOptionId) { setErr("Select a ticket type for per-item costs"); return; }
setSaving(true);
setErr(null);
try {
const body = { label: label.trim(), costType, amount: parseFloat(amount), eventOptionId: costType === "per_item" ? eventOptionId : null, paidFromMethod: paidFromMethod || null, notes: notes || null };
if (editingId === "new") {
await apiFetch(`/api/events/${eventId}/costs`, { method: "POST", authToken: token, body });
} else {
await apiFetch(`/api/costs/${editingId}`, { method: "PUT", authToken: token, body });
}
setEditingId(null);
onChanged();
} catch (e: any) {
setErr(e?.message || "Failed to save cost");
} finally {
setSaving(false);
}
};
const remove = async (id: string) => {
if (!confirm("Delete this cost?")) return;
try {
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
onChanged();
} catch (e: any) {
alert(e?.message || "Failed to delete cost");
}
};
const totalCosts = costs.reduce((sum, c) => sum + (c.total ?? c.amount), 0);
return (
<div className="bg-white border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Event costs</div>
{!isClosed && editingId === null && (
<button className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={startNew}>+ Add cost</button>
)}
</div>
{isClosed && <div className="text-xs text-gray-500">This event is closed costs can't be changed until it's reopened.</div>}
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
<th className="py-1">Type</th>
<th className="py-1">Ticket type</th>
<th className="py-1">Paid from</th>
<th className="py-1 text-right">Amount</th>
<th className="py-1 text-right">Total</th>
{!isClosed && <th className="py-1"></th>}
</tr>
</thead>
<tbody>
{costs.map(c => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5">{c.costType === "once_off" ? "Once-off" : "Per item"}</td>
<td className="py-1.5">{c.eventOption?.name || "—"}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
<td className="py-1.5 text-right">{money(c.amount)}</td>
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
{!isClosed && (
<td className="py-1.5 text-right whitespace-nowrap">
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
</td>
)}
</tr>
))}
{costs.length === 0 && (
<tr><td colSpan={7} className="py-3 text-gray-400 text-center">No costs added yet.</td></tr>
)}
</tbody>
{costs.length > 0 && (
<tfoot>
<tr>
<td colSpan={5} className="pt-2 text-right text-gray-500">Total costs</td>
<td className="pt-2 text-right font-semibold">{money(totalCosts)}</td>
{!isClosed && <td />}
</tr>
</tfoot>
)}
</table>
{editingId !== null && (
<div className="border rounded p-3 space-y-2 bg-gray-50">
{err && <div className="text-xs text-red-600">{err}</div>}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Label</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={label} onChange={e => setLabel(e.target.value)} placeholder="e.g. Venue hire" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Type</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={costType} onChange={e => setCostType(e.target.value as EventCostType)}>
<option value="once_off">Once-off / overall</option>
<option value="per_item">Per item (ticket type)</option>
</select>
</div>
{costType === "per_item" && (
<div>
<label className="block text-xs text-gray-600 mb-1">Ticket type</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={eventOptionId} onChange={e => setEventOptionId(e.target.value)}>
<option value="">Select</option>
{eventOptions.map(o => <option key={o.id} value={o.id}>{o.name}</option>)}
</select>
</div>
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Amount {costType === "per_item" ? "(per ticket)" : "(flat total)"}</label>
<input type="number" step="0.01" className="w-full border rounded px-2 py-1.5 text-sm" value={amount} onChange={e => setAmount(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Paid from (optional)</label>
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as any)}>
<option value="">Not from event takings</option>
{METHODS.map(m => <option key={m} value={m}>{METHOD_LABELS[m]}</option>)}
</select>
<p className="text-[10px] text-gray-400 mt-0.5">If this was paid out of the door takings (e.g. cash to a vendor), tag it so the Cashup report deducts it from that method's expected amount.</p>
</div>
<div className="col-span-2">
<label className="block text-xs text-gray-600 mb-1">Notes (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={notes} onChange={e => setNotes(e.target.value)} />
</div>
</div>
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={cancel} disabled={saving}>Cancel</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
)}
</div>
);
}
// ─── Reconciliation tab ─────────────────────────────────────────────────────
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: {
eventId: string; token: string; data: EventFinancials; busy: boolean;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void;
}) {
const isClosed = data.event.cashupStatus === "closed";
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
for (const l of draftLines || []) {
if (l.method in base) base[l.method as CashupMethod] = { actualAmount: l.actualAmount != null ? String(l.actualAmount) : "", notes: l.notes || "" };
}
return base;
}, [draftLines]);
const initialDenomCounts: Record<number, string> = useMemo(() => {
const cashLine = (draftLines || []).find(l => l.method === "cash");
const out: Record<number, string> = {};
for (const d of cashLine?.denominations || []) out[d.value] = String(d.count);
return out;
}, [draftLines]);
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
const [denomCounts, setDenomCounts] = useState<Record<number, string>>(initialDenomCounts);
const [closeNotes, setCloseNotes] = useState("");
const [reopenNotes, setReopenNotes] = useState("");
useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]);
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
};
const cashDenominationsPayload = () => ZAR_DENOMINATIONS
.map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 }))
.filter(d => d.count > 0);
const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0);
const buildLinesPayload = () => METHODS.map(m => m === "cash"
? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null }
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
const saveDraft = async () => {
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/draft`, { method: "PUT", authToken: token, body: { lines: buildLinesPayload() } });
onChanged();
} catch (e: any) {
setError(e?.message || "Failed to save draft");
} finally {
setBusy(false);
}
};
const closeWithCashup = async () => {
if (!confirm("Close this event with the entered reconciliation? This will count unallocated donations as profit and fully lock the event until it's reopened.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
setBusy(false);
}
};
const quickClose = async () => {
if (!confirm("Quick close this event without a per-method cashup? System totals will be accepted as-is, unallocated donations will be counted as profit, and the event will be fully locked until it's reopened.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
onChanged();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
setBusy(false);
}
};
const reopen = async () => {
if (!confirm("Reopen this event? Registrations, payments, refunds and donations will be allowed again.")) return;
setBusy(true); setError(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/reopen`, { method: "POST", authToken: token, body: { notes: reopenNotes || null } });
onChanged();
} catch (e: any) {
setError(e?.message || "Failed to reopen event");
} finally {
setBusy(false);
}
};
const reconciled = data.reconciled;
return (
<div className="space-y-4">
<div className="bg-white border rounded-lg p-4 space-y-3 overflow-auto">
<div className="text-sm font-medium">Cash-up reconciliation</div>
<table className="w-full text-sm min-w-[640px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Method</th>
<th className="py-1 text-right">Income</th>
<th className="py-1 text-right">Costs from method</th>
<th className="py-1 text-right">Expected cash</th>
<th className="py-1 text-right">Actual</th>
<th className="py-1 text-right">Variance</th>
<th className="py-1">Notes</th>
</tr>
</thead>
<tbody>
{METHODS.map(m => {
const r = reconciled?.byMethod?.[m];
return (
<tr key={m} className="border-b last:border-0 align-top">
<td className="py-1.5">{METHOD_LABELS[m]}</td>
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.costsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
<td className="py-1.5 text-right">
{isClosed ? (
money(r?.actual ?? null)
) : m === "cash" ? (
money(cashActualFromDenoms)
) : (
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
)}
</td>
<td className="py-1.5 text-right">
{isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (m === "cash"
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "")
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : ""))}
</td>
<td className="py-1.5">
{isClosed ? (r?.notes || "") : (
<input className="w-full border rounded px-2 py-1 text-sm" value={lines[m].notes} onChange={e => setLine(m, "notes", e.target.value)} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">Cash denomination count</div>
{isClosed ? (
reconciled?.byMethod?.cash?.denominations?.length ? (
<table className="text-sm">
<tbody>
{reconciled.byMethod.cash.denominations.map(d => (
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
</table>
) : <div className="text-xs text-gray-400">No denomination breakdown recorded for this cashup.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{ZAR_DENOMINATIONS.map(v => (
<div key={v} className="flex items-center gap-2">
<span className="text-sm w-14">{denomLabel(v)}</span>
<span className="text-xs text-gray-400">×</span>
<input
type="number"
min={0}
step={1}
className="w-16 border rounded px-2 py-1 text-sm"
value={denomCounts[v] || ""}
onChange={e => setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))}
/>
</div>
))}
</div>
)}
</div>
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<div className="text-gray-500 text-xs">Donations counted as profit</div>
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Total costs</div>
<div className="font-semibold">{money(data.totalCosts)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Net profit</div>
<div className="font-semibold">{money(data.netProfit)}</div>
</div>
</div>
{!isClosed && (
<div className="bg-white border rounded-lg p-4 space-y-2">
<label className="block text-xs text-gray-600 mb-1">Notes for closing (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={closeNotes} onChange={e => setCloseNotes(e.target.value)} />
<div className="flex flex-wrap gap-2 justify-end pt-1">
<button className="text-xs px-3 py-1.5 rounded border" disabled={busy} onClick={saveDraft}>Save draft</button>
<button className="text-xs px-3 py-1.5 rounded bg-amber-600 text-white hover:bg-amber-700" disabled={busy} onClick={quickClose}>Quick close (skip cashup)</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
</div>
</div>
)}
{isClosed && (
<div className="bg-white border rounded-lg p-4 space-y-2">
<label className="block text-xs text-gray-600 mb-1">Notes for reopening (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={reopenNotes} onChange={e => setReopenNotes(e.target.value)} />
<div className="flex justify-end">
<button className="text-xs px-3 py-1.5 rounded bg-rose-600 text-white hover:bg-rose-700" disabled={busy} onClick={reopen}>Reopen event</button>
</div>
</div>
)}
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">History</div>
{data.history.length === 0 && <div className="text-xs text-gray-400">No close/reopen actions yet.</div>}
<ul className="space-y-1.5">
{data.history.map(h => (
<li key={h.id} className="text-xs border-b last:border-0 pb-1.5 flex items-center justify-between gap-3">
<span>
<span className="font-medium">{h.action === "closed" ? "Closed (full cashup)" : h.action === "quick_closed" ? "Quick closed" : "Reopened"}</span>
{" "}{h.performedBy?.name || "Unknown"} — {new Date(h.createdAt).toLocaleString()}
</span>
{(h.action === "closed" || h.action === "quick_closed") && (
<span className="text-gray-500 shrink-0">Donations to profit {money(h.unallocatedDonationsTotal)} · Costs {money(h.totalCosts)}</span>
)}
</li>
))}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,112 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
export default function CashupLandingPage() {
const { token } = useAuth();
const router = useRouter();
const [events, setEvents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [showPast, setShowPast] = useState(true);
const [showInactive, setShowInactive] = useState(false);
const [showClosed, setShowClosed] = useState(false);
useEffect(() => {
if (!token) return;
(async () => {
setLoading(true);
setError(null);
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e: any) {
setError(e?.message || "Failed to load events");
} finally {
setLoading(false);
}
})();
}, [token]);
const filtered = useMemo(() => {
const now = new Date();
return events
.filter(ev => showPast || !ev.endDate || new Date(ev.endDate) >= now)
.filter(ev => showInactive || ev.isActive !== false)
.filter(ev => showClosed || ev.cashupStatus !== "closed")
.filter(ev => !search.trim() || ev.title?.toLowerCase().includes(search.trim().toLowerCase()))
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
}, [events, showPast, showInactive, showClosed, search]);
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">Post-event Cashup</h1>
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
</div>
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
onClick={() => router.push("/dashboard")}
>Back</button>
</div>
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
<div className="bg-white border rounded-lg p-3 flex flex-wrap items-center gap-3">
<input
className="border rounded px-3 py-1.5 text-sm flex-1 min-w-48"
placeholder="Search events…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<label className="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} /> Past events
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" checked={showInactive} onChange={e => setShowInactive(e.target.checked)} /> Inactive events
</label>
<label className="flex items-center gap-1.5 text-sm text-gray-600">
<input type="checkbox" checked={showClosed} onChange={e => setShowClosed(e.target.checked)} /> Closed events
</label>
</div>
{loading && <div className="text-sm text-gray-400">Loading</div>}
{!loading && (
<ul className="space-y-2">
{filtered.map(ev => {
const isClosed = ev.cashupStatus === "closed";
return (
<li
key={ev.id}
className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
onClick={() => router.push(`/dashboard/admin/cashup/${ev.id}`)}
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{ev.title}</span>
<span className={"text-[10px] px-1.5 py-0.5 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` ${new Date(ev.endDate).toLocaleDateString()}` : ""}
</div>
</div>
<span className="text-xs text-indigo-600 shrink-0">Manage </span>
</li>
);
})}
{filtered.length === 0 && <div className="text-sm text-gray-400">No events match the current filters.</div>}
</ul>
)}
</div>
);
}
@@ -0,0 +1,9 @@
"use client";
import React from "react";
import FormsBrowserPage from "@/app/dashboard/supervisor/forms/page";
export default function AdminFormsBrowserPage() {
// Reuse the same component; admin also has access
return <FormsBrowserPage />;
}
+240
View File
@@ -0,0 +1,240 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
export default function AdminDashboardPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = useMemo(() => (user?.role === "admin"), [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/admin) that
// computes it all server-side — no more separate calls plus a full payments/events pull
// just to reduce them down to a couple of numbers client-side.
// useStableState skips the re-render entirely when a poll returns identical data, and
// hasLoadedOnce below means "Refreshing…" only ever shows for the very first load —
// together these stop the stats panels from flickering on every 15s poll.
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const loadStats = async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/admin", { authToken: token });
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch (e) {
// ignore errors for dashboard summaries
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
// Poll every 15s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
useVisiblePolling(() => {
if (!token) return;
loadStats();
}, 15000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Admin Dashboard{user ? `${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>Manage users</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>Manage registrations</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>WhatsApp API</button>
</div>
</div>
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to use these tools.
</div>
)}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>
Manage users
<div className="text-xs text-white/90">Create, edit, change roles and passwords</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>
Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>
Manage registrations
<div className="text-xs text-white/90">Cancel, update status, and search registrations</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>
Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>
Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>
Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>
Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/forms")}>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees")}>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees")}>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>
Manage WhatsApp API
<div className="text-xs text-white/90">Manage the WhatsApp API config</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/cashup")}>
Post-event Cashup
<div className="text-xs text-white/90">Set costs, reconcile takings, and close out events</div>
</button>
</div>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Payments</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{paymentStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">R{paymentStats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Past Week</div>
<div className="text-lg font-semibold">R{paymentStats.totalWeek}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Past Month</div>
<div className="text-lg font-semibold">R{paymentStats.totalMonth}</div>
</div>
</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Admin stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
</div>
</div>
<div className="text-sm text-gray-600 mt-6">
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,350 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
const t = target.toLowerCase();
if (t.includes(q)) return true;
const tokens = q.split(/\s+/).filter(Boolean);
return tokens.every(tok => t.includes(tok));
}
export default function AdminRegistrationsPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = useMemo(() => user?.role === "admin", [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [registrations, setRegistrations] = useState<any[]>([]);
const [events, setEvents] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
// Filters
const [query, setQuery] = useState("");
const [eventFilter, setEventFilter] = useState("");
const [statusFilter, setStatusFilter] = useState("");
const [includePastEvents, setIncludePastEvents] = useState(false);
const [includeInactiveEvents, setIncludeInactiveEvents] = useState(false);
// Expanded rows + form responses cache
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
const loadRegistrations = async () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs.sort((a, b) => {
const eventCompare = (a.event?.startDate || a.eventId).localeCompare(b.event?.startDate || b.eventId);
if (eventCompare !== 0) return eventCompare;
return (a.user?.name || a.userId).localeCompare(b.user?.name || b.userId);
}) : [];
setRegistrations(list);
} catch (e: any) {
setError(e?.message || "Failed to load registrations");
} finally {
setLoadingRegs(false);
}
};
const loadEvents = async () => {
if (!token) return;
try {
const params = new URLSearchParams();
if (includePastEvents) params.set("includePast", "true");
if (includeInactiveEvents) params.set("includeInactive", "true");
const qs = params.toString() ? `?${params.toString()}` : "";
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
setEvents(Array.isArray(evs) ? evs.sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
} catch {}
};
useEffect(() => {
loadRegistrations();
}, [token]);
useEffect(() => {
loadEvents();
}, [token, includePastEvents, includeInactiveEvents]);
const toggleExpand = async (reg: any) => {
const id = reg.id;
const next = new Set(expanded);
if (next.has(id)) {
next.delete(id);
setExpanded(next);
return;
}
next.add(id);
setExpanded(next);
// Load form responses if not cached
if (!formResponses[id] && !loadingForms.has(id)) {
setLoadingForms(prev => new Set(prev).add(id));
try {
const res = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
const items = Array.isArray(res?.items) ? res.items : (Array.isArray(res) ? res : []);
setFormResponses(prev => ({ ...prev, [id]: items }));
} catch {
setFormResponses(prev => ({ ...prev, [id]: [] }));
} finally {
setLoadingForms(prev => { const s = new Set(prev); s.delete(id); return s; });
}
}
};
const cancelRegistration = async (reg: any) => {
if (!token) return;
setError(null); setInfo(null);
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
try {
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "DELETE", authToken: token });
setInfo("Registration cancelled");
await loadRegistrations();
} catch (e: any) {
setError(e?.message || "Failed to cancel registration");
}
};
const updateStatus = async (reg: any, status: string) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/registrations/${encodeURIComponent(reg.id)}`, { method: "PUT", authToken: token, body: { status } });
setInfo("Status updated");
await loadRegistrations();
} catch (e: any) {
setError(e?.message || "Failed to update status");
}
};
const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]);
const filtered = useMemo(() => {
return registrations.filter((r: any) => {
if (!eventIds.has(r.eventId)) return false;
if (eventFilter && r.eventId !== eventFilter) return false;
if (statusFilter && r.status !== statusFilter) return false;
if (!query.trim()) return true;
const haystack = [
r.id, r.user?.name, r.user?.email, r.user?.phoneNumber,
r.userId, r.event?.title, r.eventId, r.status,
].map((x: any) => String(x || "")).join(" ");
return fuzzyMatch(query.trim(), haystack);
});
}, [registrations, query, eventFilter, statusFilter]);
const statusColor = (s: string) => {
if (s === "paid") return "text-green-700 bg-green-50";
if (s === "confirmed") return "text-blue-700 bg-blue-50";
if (s === "partial_paid") return "text-amber-700 bg-amber-50";
if (s === "cancelled") return "text-red-700 bg-red-50";
return "text-gray-700 bg-gray-50";
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manage Registrations</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
{/* Filters */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<div className="flex flex-wrap gap-3 items-end">
<div className="flex-1 min-w-48">
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone, event, ID)</label>
<input
className="w-full border rounded px-3 py-1.5 text-sm"
placeholder="Type to search…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm max-w-48" value={eventFilter} onChange={e => setEventFilter(e.target.value)}>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
Past
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={includeInactiveEvents} onChange={e => setIncludeInactiveEvents(e.target.checked)} />
Inactive
</label>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Status</label>
<select className="border rounded px-2 py-1.5 text-sm" value={statusFilter} onChange={e => setStatusFilter(e.target.value)}>
<option value="">All statuses</option>
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={loadRegistrations} disabled={loadingRegs}>
{loadingRegs ? "Loading…" : "Refresh"}
</button>
</div>
<div className="mt-2 text-xs text-gray-500">{filtered.length} of {registrations.length} registrations</div>
</div>
<div className="border rounded-xl bg-white shadow-sm">
<ul className="divide-y text-sm">
{filtered.map((r: any) => {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const isExpanded = expanded.has(r.id);
const responses = formResponses[r.id];
const loadingResponse = loadingForms.has(r.id);
return (
<li key={r.id} className="hover:bg-gray-50">
<div className="p-3 cursor-pointer" onClick={() => toggleExpand(r)}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{r.user?.name || r.userId}</span>
<span className="text-gray-400"></span>
<span className="text-gray-700">{r.event?.title || r.eventId}</span>
<span className={`text-xs px-1.5 py-0.5 rounded font-medium ${statusColor(r.status)}`}>{r.status}</span>
</div>
<div className="text-xs text-gray-500 mt-0.5">
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
<span>R {totalDue.toFixed(2)}</span>
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="text-xs text-gray-400">{new Date(r.createdAt).toLocaleDateString()}</span>
<span className="text-gray-400 text-xs">{isExpanded ? "▲" : "▼"}</span>
</div>
</div>
</div>
{isExpanded && (
<div className="px-3 pb-3 bg-gray-50 border-t" onClick={e => e.stopPropagation()}>
{/* Actions */}
<div className="flex items-center gap-2 py-2 border-b border-gray-200 mb-3">
<select
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-60"
value={r.status}
onChange={e => updateStatus(r, e.target.value)}
disabled={r.status === 'cancelled'}
>
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<button
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 disabled:opacity-50"
onClick={() => cancelRegistration(r)}
disabled={r.status === 'cancelled'}
>
Cancel registration
</button>
</div>
{/* Ticket options */}
{(r.registrationOptions || []).length > 0 && (
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
<div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => (
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
<div className="text-gray-500">
{(() => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
})()}
</div>
{opt.appliedTierId && (
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
)}
</div>
))}
</div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
</div>
)}
{/* Form responses */}
<div>
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
{loadingResponse ? (
<div className="text-xs text-gray-400">Loading</div>
) : !responses || responses.length === 0 ? (
<div className="text-xs text-gray-400">No form responses submitted.</div>
) : (
<div className="space-y-2">
{responses.map((resp: any, idx: number) => (
<div key={resp.id || idx} className="bg-white border rounded p-2">
<div className="text-xs font-medium text-gray-600 mb-1">Response #{idx + 1}</div>
<div className="grid sm:grid-cols-2 gap-1.5">
{(resp.answers || []).map((a: any) => (
<div key={a.id} className="bg-gray-50 border rounded p-1.5 text-xs">
<div className="text-[10px] text-gray-500">{a.field?.label || a.fieldId}</div>
<div className="font-medium">{a.value}</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="mt-2 text-[10px] text-gray-400">
Registration ID: {r.id} · Created: {new Date(r.createdAt).toLocaleString()}
</div>
</div>
)}
</li>
);
})}
{filtered.length === 0 && (
<li className="p-4 text-gray-500 text-sm">{loadingRegs ? "Loading…" : "No registrations found."}</li>
)}
</ul>
</div>
</div>
);
}
@@ -0,0 +1,511 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
const TABS: { id: TabId; label: string }[] = [
{ id: "organisation", label: "Organisation" },
{ id: "branding", label: "Branding" },
{ id: "notifications", label: "Notifications" },
{ id: "email", label: "Email" },
{ id: "legal", label: "Legal" },
];
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
function Field({
label, hint, required, children,
}: {
label: string; hint?: string; required?: boolean; children: React.ReactNode;
}) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
{children}
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
</div>
);
}
function SaveBar({
saving, onSave, result, onDismiss,
}: {
saving: boolean;
onSave: () => void;
result: { ok: boolean; message: string } | null;
onDismiss: () => void;
}) {
return (
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
{result ? (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
<button type="button" onClick={onDismiss} className="ml-1 text-gray-400 hover:text-gray-600 text-xs">×</button>
</span>
) : (
<span />
)}
<button
type="button"
disabled={saving}
onClick={onSave}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
);
}
export default function SiteSettingsPage() {
const { token } = useAuth();
const router = useRouter();
const { reload: reloadSettings } = useSiteSettings();
const [activeTab, setActiveTab] = useState<TabId>("organisation");
const [loadingInitial, setLoadingInitial] = useState(true);
// Per-tab save state
const [saving, setSaving] = useState(false);
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null);
// ── Organisation ──
const [orgName, setOrgName] = useState("");
const [orgTagline, setOrgTagline] = useState("");
const [orgEmail, setOrgEmail] = useState("");
const [orgPhone, setOrgPhone] = useState("");
const [orgAddress, setOrgAddress] = useState("");
const [appBaseUrl, setAppBaseUrl] = useState("");
// ── Branding ──
const [accentColor, setAccentColor] = useState("#2563eb");
const [logoUrl, setLogoUrl] = useState("");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
// ── Notifications ──
const [notifEmails, setNotifEmails] = useState("");
// ── SMTP ──
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("587");
const [smtpSecure, setSmtpSecure] = useState(false);
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
const [smtpPassSet, setSmtpPassSet] = useState(false);
const [smtpTesting, setSmtpTesting] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string; raw?: string } | null>(null);
// ── Legal ──
const [legalOperatorName, setLegalOperatorName] = useState("");
const [legalIoName, setLegalIoName] = useState("");
const [legalIoEmail, setLegalIoEmail] = useState("");
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
apiFetch<Record<string, string>>("/api/settings/all", { authToken: token })
.then((s) => {
setOrgName(s.org_name || "");
setOrgTagline(s.org_tagline || "");
setOrgEmail(s.org_email || "");
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setAccentColor(s.accent_color || "#2563eb");
setLogoUrl(s.logo_url || "");
setNotifEmails(s.reg_notification_emails || "");
setSmtpHost(s.smtp_host || "");
setSmtpPort(s.smtp_port || "587");
setSmtpSecure((s.smtp_secure || "").toLowerCase() === "true");
setSmtpFrom(s.smtp_from || "");
setSmtpUser(s.smtp_user || "");
setSmtpPassSet(!!s.smtp_pass && s.smtp_pass !== "");
setSmtpPass("");
setLegalOperatorName(s.legal_operator_name || "");
setLegalIoName(s.legal_io_name || "");
setLegalIoEmail(s.legal_io_email || "");
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
}, [token]);
// Clear result when switching tabs
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
// ── Save helpers ──────────────────────────────────────────────────────────
const save = async (updates: Record<string, string>) => {
if (!token) return;
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const saveOrganisation = () => save({
org_name: orgName.trim(),
org_tagline: orgTagline.trim(),
org_email: orgEmail.trim(),
org_phone: orgPhone.trim(),
org_address: orgAddress.trim(),
app_base_url: appBaseUrl.trim(),
});
const saveBranding = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
let finalLogoUrl = logoUrl;
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error("Logo upload failed");
const data = await res.json();
finalLogoUrl = data.url || logoUrl;
}
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { accent_color: accentColor, logo_url: finalLogoUrl },
});
setLogoUrl(finalLogoUrl);
setLogoFile(null);
setLogoPreview(null);
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
const saveSmtp = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
const updates: Record<string, string> = {
smtp_host: smtpHost.trim(),
smtp_port: smtpPort.trim(),
smtp_secure: smtpSecure ? "true" : "false",
smtp_from: smtpFrom.trim(),
smtp_user: smtpUser.trim(),
};
if (smtpPass.trim() !== "") updates.smtp_pass = smtpPass.trim();
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: updates });
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to save" });
} finally {
setSaving(false);
}
};
const saveLegal = () => save({
legal_operator_name: legalOperatorName.trim(),
legal_io_name: legalIoName.trim(),
legal_io_email: legalIoEmail.trim(),
legal_website_url: legalWebsiteUrl.trim(),
legal_effective_date: legalEffectiveDate.trim(),
});
const handleTestSmtp = async () => {
if (!token) return;
setSmtpTesting(true);
setSmtpTestResult(null);
try {
await apiFetch("/api/settings/test-smtp", {
method: "POST",
authToken: token,
body: {
host: smtpHost.trim(),
port: smtpPort.trim(),
secure: smtpSecure,
from: smtpFrom.trim(),
user: smtpUser.trim(),
pass: smtpPass.trim() !== "" ? smtpPass.trim() : "••••••••",
},
});
setSmtpTestResult({ ok: true, message: "Connection successful — check your inbox for a test email." });
} catch (e: any) {
setSmtpTestResult({ ok: false, message: e?.message || "SMTP test failed", raw: e?.data?.raw });
} finally {
setSmtpTesting(false);
}
};
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings</div>;
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
return (
<div className="max-w-3xl mx-auto w-full p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Site Settings</h1>
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
</div>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
onClick={() => router.push("/dashboard/admin")}
>
Back
</button>
</div>
{/* Tab bar */}
<div className="flex items-center gap-2 flex-wrap mb-6">
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => switchTab(tab.id)}
className={`px-3 py-1.5 text-sm rounded border transition-colors ${
activeTab === tab.id
? "bg-indigo-600 text-white border-indigo-600"
: "bg-white text-gray-800 border-gray-200 hover:bg-gray-50"
}`}
>
{tab.label}
</button>
))}
</div>
{/* ── Organisation ─────────────────────────────────────────────────── */}
{activeTab === "organisation" && (
<div className="space-y-4">
<Field label="Organisation name" required>
<input className={inputCls} placeholder="Hope Family Church"
value={orgName} onChange={e => setOrgName(e.target.value)} />
</Field>
<Field label="Tagline">
<input className={inputCls} placeholder="Connecting community through events"
value={orgTagline} onChange={e => setOrgTagline(e.target.value)} />
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="Contact email">
<input type="email" className={inputCls} placeholder="admin@yourchurch.org"
value={orgEmail} onChange={e => setOrgEmail(e.target.value)} />
</Field>
<Field label="Phone">
<input className={inputCls} placeholder="+27 12 345 6789"
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
</Field>
</div>
<Field label="Address">
<input className={inputCls} placeholder="123 Church St, City"
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
</Field>
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveOrganisation} result={result} onDismiss={() => setResult(null)} />
</div>
)}
{/* ── Branding ─────────────────────────────────────────────────────── */}
{activeTab === "branding" && (
<div className="space-y-5">
<Field label="Accent / brand colour">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
</div>
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
</Field>
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
{currentLogoSrc && (
<div className="mb-3 flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={currentLogoSrc} alt="Current logo" className="h-16 object-contain border rounded p-1 bg-gray-50" />
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
}} className="text-sm" />
</Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} onDismiss={() => setResult(null)} />
</div>
)}
{/* ── Notifications ─────────────────────────────────────────────────── */}
{activeTab === "notifications" && (
<div className="space-y-4">
<Field
label="Registration notification emails"
hint="Who gets notified when someone registers for an event. Separate multiple addresses with commas."
>
<input className={inputCls} placeholder="registrations@yourchurch.org, admin@yourchurch.org"
value={notifEmails} onChange={e => setNotifEmails(e.target.value)} />
</Field>
<SaveBar saving={saving} onSave={saveNotifications} result={result} onDismiss={() => setResult(null)} />
</div>
)}
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
{activeTab === "email" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Outgoing email for tickets, payment confirmations, and account notifications.
Leave blank to use server environment variables. The password is stored encrypted.
</p>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="SMTP host">
<input className={inputCls} placeholder="smtp.yourprovider.com"
value={smtpHost} onChange={e => setSmtpHost(e.target.value)} />
</Field>
<Field label="Port">
<input type="number" className={inputCls} placeholder="587"
value={smtpPort} onChange={e => setSmtpPort(e.target.value)} />
</Field>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="smtpSecure" checked={smtpSecure}
onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
<label htmlFor="smtpSecure" className="text-sm text-gray-700">Use TLS/SSL (port 465)</label>
</div>
<Field label="From address" hint="The address emails appear to come from.">
<input type="email" className={inputCls} placeholder="no-reply@yourchurch.org"
value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} />
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="SMTP username">
<input type="email" className={inputCls} placeholder="mail@yourchurch.org"
value={smtpUser} onChange={e => setSmtpUser(e.target.value)} autoComplete="username" />
</Field>
<Field
label="SMTP password"
hint={smtpPassSet ? "Password is saved. Leave blank to keep it." : undefined}
>
<div className="relative">
<input type="password" className={inputCls}
placeholder={smtpPassSet ? "Leave blank to keep current" : "Enter password"}
value={smtpPass} onChange={e => setSmtpPass(e.target.value)} autoComplete="new-password" />
{smtpPassSet && smtpPass === "" && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-green-600 pointer-events-none"> saved</span>
)}
</div>
</Field>
</div>
{/* Test connection */}
<div className="pt-1 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<button
type="button"
disabled={smtpTesting || !smtpHost.trim()}
onClick={handleTestSmtp}
className="px-4 py-2 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50 border"
>
{smtpTesting ? "Testing…" : "Test connection"}
</button>
{smtpTestResult && (
<span className={`text-sm ${smtpTestResult.ok ? "text-green-600" : "text-red-600"}`}>
{smtpTestResult.ok ? "✓" : "✗"} {smtpTestResult.message}
</span>
)}
</div>
{smtpTestResult && !smtpTestResult.ok && smtpTestResult.raw && (
<details className="text-xs text-gray-500">
<summary className="cursor-pointer select-none hover:text-gray-700">Show technical details</summary>
<pre className="mt-1 p-2 bg-gray-100 rounded text-xs overflow-x-auto whitespace-pre-wrap break-all">{smtpTestResult.raw}</pre>
</details>
)}
</div>
<SaveBar saving={saving} onSave={saveSmtp} result={result} onDismiss={() => setResult(null)} />
</div>
)}
{/* ── Legal ─────────────────────────────────────────────────────────── */}
{activeTab === "legal" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
These values populate the Terms of Use and Privacy Policy pages automatically.
</p>
<Field label="Operator / responsible party"
hint='Shown in the "Owned and operated by" line of the Terms of Use.'>
<input className={inputCls} placeholder="Jane Smith on behalf of Example Church, City, South Africa"
value={legalOperatorName} onChange={e => setLegalOperatorName(e.target.value)} />
</Field>
<Field label="Website URL (without https://)">
<input className={inputCls} placeholder="events.yourchurch.org"
value={legalWebsiteUrl} onChange={e => setLegalWebsiteUrl(e.target.value)} />
</Field>
<Field label="Effective date">
<input className={inputCls} placeholder="April 2026"
value={legalEffectiveDate} onChange={e => setLegalEffectiveDate(e.target.value)} />
</Field>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Information Officer (POPIA)</p>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="Full name">
<input className={inputCls} placeholder="Jane Smith"
value={legalIoName} onChange={e => setLegalIoName(e.target.value)} />
</Field>
<Field label="Email">
<input type="email" className={inputCls} placeholder="io@yourchurch.org"
value={legalIoEmail} onChange={e => setLegalIoEmail(e.target.value)} />
</Field>
</div>
</div>
<SaveBar saving={saving} onSave={saveLegal} result={result} onDismiss={() => setResult(null)} />
</div>
)}
</div>
);
}
@@ -0,0 +1,435 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
interface UserItem {
id: string;
name: string;
email: string;
role: string;
phoneNumber?: string | null;
isActive: boolean;
createdAt: string;
updatedAt: string;
}
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
type Role = typeof roleOptions[number];
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
const t = target.toLowerCase();
if (t.includes(q)) return true;
// token-based: all tokens must appear somewhere
const tokens = q.split(/\s+/).filter(Boolean);
return tokens.every(tok => t.includes(tok));
}
export default function AdminUsersPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = useMemo(() => user?.role === "admin", [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Data state
const [users, setUsers] = useState<UserItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [pageSize, setPageSize] = useState(50);
// Search and filter state (server-side)
const [query, setQuery] = useState("");
const [debouncedQuery, setDebouncedQuery] = useState("");
const [roleFilter, setRoleFilter] = useState<Role | "">("");
const [activeFilter, setActiveFilter] = useState<"" | "true" | "false">("");
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedQuery(query), 350);
return () => clearTimeout(t);
}, [query]);
// Create form state
const [createOpen, setCreateOpen] = useState(false);
const [cName, setCName] = useState("");
const [cEmail, setCEmail] = useState("");
const [cPassword, setCPassword] = useState("");
const [cPhone, setCPhone] = useState("");
const [cRole, setCRole] = useState<Role>("user");
const [creating, setCreating] = useState(false);
// Inline edit state
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
const [saving, setSaving] = useState(false);
const buildQuery = useCallback((p: number, ps = pageSize) => {
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
if (debouncedQuery.trim()) qs.set("search", debouncedQuery.trim());
if (roleFilter) qs.set("role", roleFilter);
if (activeFilter !== "") qs.set("isActive", activeFilter);
return `/api/users?${qs.toString()}`;
}, [debouncedQuery, roleFilter, activeFilter, pageSize]);
const loadUsers = useCallback(async (p = 1, ps = pageSize) => {
if (!token) return;
setError(null);
setFetching(true);
try {
const url = buildQuery(p, ps);
const res = await apiFetch<any>(url, { authToken: token });
setUsers(Array.isArray(res?.data) ? res.data : []);
setTotal(res?.total ?? 0);
setTotalPages(res?.pages ?? 1);
setPage(p);
} catch (e: any) {
setError(e?.message || "Failed to load users");
} finally {
setFetching(false);
}
}, [token, buildQuery]);
// Initial load + reload on filter change
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
const goToPage = (p: number) => loadUsers(p);
const handlePageSizeChange = (newSize: number) => {
setPageSize(newSize);
loadUsers(1, newSize);
};
const resetCreateForm = () => {
setCName(""); setCEmail(""); setCPassword(""); setCPhone(""); setCRole("user");
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
try {
setCreating(true);
const created = await apiFetch<any>("/api/users", {
method: "POST",
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
});
if (cRole && cRole !== "user" && created?.id) {
await apiFetch(`/api/users/${encodeURIComponent(created.id)}`, {
method: "PUT", authToken: token!, body: { role: cRole },
});
}
resetCreateForm();
setCreateOpen(false);
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to create user");
} finally {
setCreating(false);
}
};
const startEdit = (u: UserItem) => { setEditingId(u.id); setEditData({ ...u, password: "" }); };
const cancelEdit = () => { setEditingId(null); setEditData({}); };
const saveEdit = async () => {
if (!editingId) return;
try {
setSaving(true);
const payload: any = {
name: editData.name,
email: editData.email,
role: editData.role,
phoneNumber: editData.phoneNumber || null,
isActive: editData.isActive,
};
if (editData.password && editData.password.trim().length > 0) {
payload.password = editData.password.trim();
}
await apiFetch(`/api/users/${encodeURIComponent(editingId)}`, {
method: "PUT", authToken: token!, body: payload,
});
setEditingId(null);
setEditData({});
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to save user");
} finally {
setSaving(false);
}
};
const revokeUserSessions = async (id: string, name: string) => {
if (!confirm(`Revoke all active sessions for ${name}? They will be signed out on all devices.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/revoke-sessions`, { method: "POST", authToken: token! });
} catch (e: any) {
setError(e?.message || "Failed to revoke sessions");
}
};
const deactivate = async (id: string) => {
if (!confirm("Deactivate this user? Their account will be disabled.")) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to deactivate user");
}
};
const deleteUserData = async (id: string, name: string) => {
if (!confirm(`Delete personal data for "${name}"?\n\nThis will set their name to "Deleted User", clear their email and phone number, and deactivate the account. This action cannot be undone.`)) return;
try {
await apiFetch(`/api/users/${encodeURIComponent(id)}/anonymize`, { method: "POST", authToken: token! });
await loadUsers(page);
} catch (e: any) {
setError(e?.message || "Failed to delete user data");
}
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">User Management</h1>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => setCreateOpen(v => !v)}>
{createOpen ? "Close" : "Create user"}
</button>
</div>
</div>
{!isAdmin && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need admin access to manage users.
</div>
)}
{error && <div className="mb-3 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>}
{createOpen && (
<form onSubmit={handleCreate} className="border rounded-xl p-4 bg-white shadow-sm mb-6 grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-sm text-gray-700 mb-1">Name</label>
<input className="w-full border rounded px-3 py-2" value={cName} onChange={e => setCName(e.target.value)} />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Email</label>
<input type="email" className="w-full border rounded px-3 py-2" value={cEmail} onChange={e => setCEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Password</label>
<input type="password" className="w-full border rounded px-3 py-2" value={cPassword} onChange={e => setCPassword(e.target.value)} required />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Phone</label>
<input className="w-full border rounded px-3 py-2" value={cPhone} onChange={e => setCPhone(e.target.value)} />
</div>
<div>
<label className="block text-sm text-gray-700 mb-1">Role</label>
<select className="w-full border rounded px-3 py-2" value={cRole} onChange={e => setCRole(e.target.value as Role)}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div className="flex items-end">
<button disabled={creating} className="px-3 py-2 rounded bg-indigo-600 text-white disabled:opacity-50" type="submit">
{creating ? "Creating…" : "Create"}
</button>
</div>
</form>
)}
<div className="border rounded-xl p-4 bg-white shadow-sm">
{/* Filters row */}
<div className="flex flex-wrap items-end gap-3 mb-4">
<div className="flex-1 min-w-48">
<label className="block text-xs text-gray-600 mb-1">Search (name, email, phone)</label>
<input
className="w-full border rounded px-3 py-1.5 text-sm"
placeholder="Type to search…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Role</label>
<select className="border rounded px-2 py-1.5 text-sm" value={roleFilter} onChange={e => setRoleFilter(e.target.value as Role | "")}>
<option value="">All roles</option>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Status</label>
<select className="border rounded px-2 py-1.5 text-sm" value={activeFilter} onChange={e => setActiveFilter(e.target.value as "" | "true" | "false")}>
<option value="">Active &amp; inactive</option>
<option value="true">Active only</option>
<option value="false">Inactive only</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Per page</label>
<select className="border rounded px-2 py-1.5 text-sm" value={pageSize} onChange={e => handlePageSizeChange(Number(e.target.value))}>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
</select>
</div>
<button className="text-sm px-2 py-1.5 rounded bg-gray-100 hover:bg-gray-200" onClick={() => loadUsers(page)} disabled={fetching}>
{fetching ? "Refreshing…" : "Refresh"}
</button>
</div>
<div className="text-xs text-gray-500 mb-2">
{total} user{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
</div>
<div className="overflow-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-gray-600 border-b">
<th className="p-2">Name</th>
<th className="p-2">Email</th>
<th className="p-2">Role</th>
<th className="p-2">Phone</th>
<th className="p-2">Active</th>
<th className="p-2">Password</th>
<th className="p-2">Actions</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} className="border-t hover:bg-gray-50">
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
) : (
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
) : (
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<span className="capitalize">{u.role}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
) : (
<span>{u.phoneNumber || ""}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
) : (
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<div className="flex gap-2">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
) : (
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
)}
</td>
</tr>
))}
{users.length === 0 && !fetching && (
<tr>
<td className="p-3 text-gray-500" colSpan={7}>No users found.</td>
</tr>
)}
{fetching && (
<tr>
<td className="p-3 text-gray-400" colSpan={7}>Loading</td>
</tr>
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 text-sm">
<span className="text-gray-500">Page {page} of {totalPages}</span>
<div className="flex gap-1">
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page <= 1 || fetching}
onClick={() => goToPage(page - 1)}
>
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1)
.reduce<(number | "…")[]>((acc, p, i, arr) => {
if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…");
acc.push(p);
return acc;
}, [])
.map((p, i) =>
p === "…" ? (
<span key={`ellipsis-${i}`} className="px-2 py-1 text-gray-400"></span>
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>
{p}
</button>
)
)}
<button
className="px-2 py-1 rounded bg-gray-100 hover:bg-gray-200 disabled:opacity-40"
disabled={page >= totalPages || fetching}
onClick={() => goToPage(page + 1)}
>
Next
</button>
</div>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,830 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
// ─── Types ────────────────────────────────────────────────────────────────────
type WAStatus =
| "WORKING"
| "CONNECTED"
| "SCAN_QR_CODE"
| "STARTING"
| "FAILED"
| "STOPPED"
| string;
interface ConfigResponse {
tokenMasked: string;
instanceId: string;
hasToken: boolean;
hasInstance: boolean;
configured: boolean;
}
interface StatusResponse {
status: WAStatus;
message?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
WORKING: "bg-green-100 text-green-800 border-green-300",
CONNECTED: "bg-green-100 text-green-800 border-green-300",
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
FAILED: "bg-red-100 text-red-800 border-red-300",
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
};
const STATUS_ICONS: Record<string, string> = {
WORKING: "🟢",
CONNECTED: "🟢",
SCAN_QR_CODE: "📷",
STARTING: "🔄",
FAILED: "🔴",
STOPPED: "⚫",
};
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
function Spinner() {
return (
<svg
className="animate-spin h-4 w-4 text-indigo-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
function Alert({
type,
children,
}: {
type: "ok" | "err" | "info";
children: React.ReactNode;
}) {
const cls =
type === "ok"
? "bg-green-50 text-green-800 border-green-200"
: type === "err"
? "bg-red-50 text-red-800 border-red-200"
: "bg-blue-50 text-blue-800 border-blue-200";
return (
<div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function WhatsAppAdminPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const isAdmin = useMemo(() => user?.role === "admin", [user]);
useEffect(() => {
if (loading) return;
if (!user || !isAdmin) router.replace("/dashboard");
}, [user, loading, isAdmin, router]);
// ── Config state (drives wizard steps) ──────────────────────────────────────
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
const [cfgLoading, setCfgLoading] = useState(true);
// Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
// ── Step 1 inputs ────────────────────────────────────────────────────────────
const [inputToken, setInputToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
// ── Step 2 inputs ────────────────────────────────────────────────────────────
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
const [inputInstanceId, setInputInstanceId] = useState("");
const [savingInstance, setSavingInstance] = useState(false);
// ── Step 3: session state ────────────────────────────────────────────────────
const [status, setStatus] = useState<WAStatus | null>(null);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [qrSrc, setQrSrc] = useState<string | null>(null);
const [pairingPhone, setPairingPhone] = useState("");
// ── Shared action feedback ───────────────────────────────────────────────────
const [actionMsg, setActionMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// ── Load config ──────────────────────────────────────────────────────────────
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
setCfg(res);
} catch {
// network error — leave cfg null, user sees loading state
} finally {
setCfgLoading(false);
}
}, [token]);
useEffect(() => { fetchConfig(); }, [fetchConfig]);
// ── Status fetch (step 3 only) ───────────────────────────────────────────────
const fetchStatus = useCallback(async () => {
if (!token || step !== 3) return;
try {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch (e: any) {
// Re-fetch config — if the session was not found, backend clears the
// instance ID and the step recomputes to 2 (Session Instance setup).
await fetchConfig();
setStatus("FAILED");
setStatusMsg(null);
}
}, [token, step, fetchConfig]);
useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]);
// Auto-poll status when not stable
useEffect(() => {
if (step !== 3 || status === null) return;
if (ACTIVE_STATUSES.has(status)) return;
const id = setInterval(fetchStatus, 5_000);
return () => clearInterval(id);
}, [step, status, fetchStatus]);
// ── QR fetch ─────────────────────────────────────────────────────────────────
const fetchQr = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
} catch {
setQrSrc(null);
}
}, [token]);
useEffect(() => {
if (status === "SCAN_QR_CODE") { fetchQr(); }
else { setQrSrc(null); }
}, [status, fetchQr]);
// Auto-refresh QR every 20s while waiting
useEffect(() => {
if (status !== "SCAN_QR_CODE") return;
const id = setInterval(fetchQr, 20_000);
return () => clearInterval(id);
}, [status, fetchQr]);
// ── Generic session action ───────────────────────────────────────────────────
const doAction = async (action: string, body?: object) => {
if (!token) return;
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<any>(`/api/whatsapp/${action}`, {
method: "POST",
authToken: token,
body,
});
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
} catch (e: any) {
let msg = e?.message || `${action} failed.`;
try { msg = JSON.parse(msg)?.message || msg; } catch {}
// SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so
// the wizard steps back to Step 2; no need to show an error message.
await fetchConfig();
if (!msg.includes("SESSION_NOT_FOUND")) {
setActionMsg({ type: "err", text: msg });
}
await fetchStatus();
} finally {
setBusy(null);
}
};
// ─── Step 1: Save token ──────────────────────────────────────────────────────
const saveToken = async () => {
if (!inputToken.trim()) {
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
return;
}
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: inputToken.trim(), instanceId: "" },
});
setInputToken("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
} finally {
setSavingToken(false);
}
};
// ─── Step 2: Enter existing instance ID ──────────────────────────────────────
const saveInstanceId = async () => {
if (!inputInstanceId.trim()) {
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
return;
}
setSavingInstance(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: "", instanceId: inputInstanceId.trim() },
// token left blank → backend keeps existing token
});
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
} finally {
setSavingInstance(false);
}
};
// ─── Step 2: Create new instance ─────────────────────────────────────────────
const createInstance = async () => {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<any>("/api/whatsapp/create-instance", {
method: "POST",
authToken: token!,
});
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
} finally {
setSavingInstance(false);
}
};
// ─── Pairing code ────────────────────────────────────────────────────────────
const requestPairingCode = async () => {
if (!pairingPhone.trim()) {
setActionMsg({ type: "err", text: "Enter your phone number first." });
return;
}
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
};
// ─── Reset credentials (go back to step 1) ───────────────────────────────────
const resetToken = async () => {
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: "_clear_", instanceId: "" },
});
} catch {}
// Force a re-read — even if the above fails, clear local state
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
// ────────────────────────────────────────────────────────────────────────────
// Render
// ────────────────────────────────────────────────────────────────────────────
if (loading || cfgLoading) {
return (
<div className="max-w-xl mx-auto w-full p-6 flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Loading
</div>
);
}
return (
<div className="max-w-xl mx-auto w-full p-6 space-y-6">
{/* Back button */}
<button
onClick={() => router.push("/dashboard")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
Back
</button>
{/* Header */}
<div className="flex items-center gap-3">
<span className="text-3xl">💬</span>
<div>
<h1 className="text-2xl font-semibold leading-tight">WhatsApp Integration</h1>
<p className="text-sm text-gray-500">Powered by WAWP</p>
</div>
</div>
{/* Step indicator */}
<StepIndicator step={step} />
{/* Global action message */}
{actionMsg && (
<Alert type={actionMsg.type}>{actionMsg.text}</Alert>
)}
{/* ── STEP 1: Enter access token ──────────────────────────────────────── */}
{step === 1 && (
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Step 1 Enter your WAWP Access Token</h2>
<p className="text-sm text-gray-600">
Your access token is found in your WAWP account dashboard at{" "}
<a
href="https://app.wawp.net"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
app.wawp.net
</a>
.
</p>
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700">Access Token</label>
<input
type="password"
value={inputToken}
onChange={e => setInputToken(e.target.value)}
onKeyDown={e => e.key === "Enter" && saveToken()}
placeholder="Paste your WAWP access token"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={saveToken}
disabled={savingToken}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Token & Continue"}
</button>
</section>
)}
{/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */}
{step === 2 && (
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Step 2 Set Up Session Instance</h2>
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">
Token: {cfg?.tokenMasked}
</span>
</div>
<p className="text-sm text-gray-600">
You need a WAWP session instance. Either create a brand-new one, or enter an
existing Instance ID.
</p>
{/* Tab toggle */}
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
<button
onClick={() => setInstanceMode("create")}
className={`flex-1 px-4 py-2.5 transition-colors ${
instanceMode === "create"
? "bg-indigo-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
Create new instance
</button>
<button
onClick={() => setInstanceMode("enter")}
className={`flex-1 px-4 py-2.5 border-l transition-colors ${
instanceMode === "enter"
? "bg-indigo-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
Enter existing ID
</button>
</div>
{instanceMode === "create" && (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Click below to create a new WAWP session. The Instance ID will be saved
automatically.
</p>
<button
onClick={createInstance}
disabled={savingInstance}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingInstance && <Spinner />}
{savingInstance ? "Creating…" : "Create Instance"}
</button>
</div>
)}
{instanceMode === "enter" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Instance ID
</label>
<input
type="text"
value={inputInstanceId}
onChange={e => setInputInstanceId(e.target.value)}
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
placeholder="e.g. BF14B761C364"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={saveInstanceId}
disabled={savingInstance}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingInstance && <Spinner />}
{savingInstance ? "Saving…" : "Save & Continue"}
</button>
</div>
)}
<button
onClick={resetToken}
className="text-xs text-gray-400 hover:text-red-500 hover:underline"
>
Change access token
</button>
</section>
)}
{/* ── STEP 3: Full management ─────────────────────────────────────────── */}
{step === 3 && (
<>
{/* Status card */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Session Status</h2>
<button
onClick={fetchStatus}
className="text-xs text-indigo-600 hover:underline"
>
Refresh
</button>
</div>
{status === null ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Fetching status
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
<span
className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${
STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"
}`}
>
{status}
</span>
</div>
)}
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
{status === "FAILED" && (
<Alert type="err">
The session has failed. The system will attempt to auto-restart. You can also
restart manually below.
</Alert>
)}
{status && POLLING_STATUSES.has(status) && (
<p className="text-xs text-gray-400 flex items-center gap-1">
<Spinner /> Auto-refreshing every 5 seconds
</p>
)}
{/* Config info strip */}
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
<span>
Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span>
</span>
<span>
Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span>
</span>
</div>
</section>
{/* QR Code */}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Scan QR Code</h2>
<button
onClick={fetchQr}
className="text-xs text-indigo-600 hover:underline"
>
Refresh QR
</button>
</div>
<p className="text-sm text-gray-600">
Open WhatsApp Linked Devices Link a Device, then scan the code below.
</p>
{qrSrc ? (
<img
src={qrSrc}
alt="WhatsApp QR Code"
className="w-56 h-56 border rounded-lg"
/>
) : (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Spinner /> Loading QR
</div>
)}
<p className="text-xs text-gray-400">
QR codes expire after ~20 seconds click Refresh QR if it stops working.
</p>
</section>
)}
{/* Pairing code */}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Link by Phone Number Instead</h2>
<p className="text-sm text-gray-600">
Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing
code on your phone.
</p>
<div className="flex gap-2">
<input
type="tel"
value={pairingPhone}
onChange={e => setPairingPhone(e.target.value)}
placeholder="082 123 4567"
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
onClick={requestPairingCode}
disabled={busy === "request-code"}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{busy === "request-code" && <Spinner />}
{busy === "request-code" ? "Sending…" : "Send code"}
</button>
</div>
</section>
)}
{/* Session controls */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Session Controls</h2>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Start"
busyLabel="Starting…"
isBusy={busy === "start"}
disabled={!!busy}
color="green"
onClick={() => doAction("start")}
/>
<ActionButton
label="Restart"
busyLabel="Restarting…"
isBusy={busy === "restart"}
disabled={!!busy}
color="amber"
onClick={() => doAction("restart")}
/>
<ActionButton
label="Logout"
busyLabel="Logging out…"
isBusy={busy === "logout"}
disabled={!!busy}
color="red-outline"
onClick={() => {
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
doAction("logout");
}}
/>
</div>
</section>
{/* Instance management */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Instance Management</h2>
<p className="text-sm text-gray-600">
Create a brand-new instance or permanently delete the current one. Deleting will
require you to set up a new instance.
</p>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Create New Instance"
busyLabel="Creating…"
isBusy={busy === "create-instance"}
disabled={!!busy}
color="blue"
onClick={() => doAction("create-instance")}
/>
<ActionButton
label="Delete Instance"
busyLabel="Deleting…"
isBusy={busy === "delete-instance"}
disabled={!!busy}
color="red-outline"
onClick={() => {
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
doAction("delete-instance");
}}
/>
</div>
</section>
{/* Update credentials */}
<details className="border rounded-xl bg-white shadow-sm">
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
<span>Update Credentials</span>
<span className="text-gray-400 text-xs">expand </span>
</summary>
<div className="px-5 pb-5 space-y-3 border-t pt-4">
<p className="text-sm text-gray-600">
Change your WAWP access token or Instance ID. Leave a field blank to keep the
current value.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
New Access Token
</label>
<input
type="password"
value={inputToken}
onChange={e => setInputToken(e.target.value)}
placeholder="Leave blank to keep current token"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
New Instance ID
</label>
<input
type="text"
value={inputInstanceId}
onChange={e => setInputInstanceId(e.target.value)}
placeholder="Leave blank to keep current instance"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={async () => {
if (!inputToken.trim() && !inputInstanceId.trim()) {
setActionMsg({ type: "err", text: "Enter at least one field to update." });
return;
}
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: {
token: inputToken.trim() || undefined,
instanceId: inputInstanceId.trim() || undefined,
},
});
setActionMsg({ type: "ok", text: "Credentials updated." });
setInputToken("");
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
} finally {
setSavingToken(false);
}
}}
disabled={savingToken}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Changes"}
</button>
</div>
</details>
</>
)}
<p className="text-xs text-gray-400 text-center">
WhatsApp notifications powered by{" "}
<a
href="https://wawp.net"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
WAWP
</a>
. Session auto-recovers on failure; admin alert sent if recovery fails.
</p>
</div>
);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StepIndicator({ step }: { step: number }) {
const steps = [
{ n: 1, label: "Access Token" },
{ n: 2, label: "Session Instance" },
{ n: 3, label: "Connected" },
];
return (
<div className="flex items-center gap-0">
{steps.map((s, i) => {
const done = step > s.n;
const current = step === s.n;
return (
<React.Fragment key={s.n}>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
done
? "bg-green-500 border-green-500 text-white"
: current
? "bg-indigo-600 border-indigo-600 text-white"
: "bg-white border-gray-300 text-gray-400"
}`}
>
{done ? "✓" : s.n}
</div>
<span
className={`text-xs mt-1 font-medium ${
done || current ? "text-gray-700" : "text-gray-400"
}`}
>
{s.label}
</span>
</div>
{i < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${
done ? "bg-green-400" : "bg-gray-200"
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
}
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
function ActionButton({
label,
busyLabel,
isBusy,
disabled,
color,
onClick,
}: {
label: string;
busyLabel: string;
isBusy: boolean;
disabled: boolean;
color: ButtonColor;
onClick: () => void;
}) {
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
const colors: Record<ButtonColor, string> = {
green: "bg-green-600 text-white hover:bg-green-700",
amber: "bg-amber-500 text-white hover:bg-amber-600",
blue: "bg-blue-600 text-white hover:bg-blue-700",
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
};
return (
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
{isBusy && <Spinner />}
{isBusy ? busyLabel : label}
</button>
);
}
+81
View File
@@ -0,0 +1,81 @@
"use client";
import React, { useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import { usePathname, useRouter } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
// Redirect unauthenticated users to login
useEffect(() => {
if (loading) return;
if (!user) {
router.replace("/login");
}
}, [user, loading, router]);
// Role-based route guard within /dashboard
useEffect(() => {
if (loading) return;
if (!user) return; // handled above
if (!pathname || !pathname.startsWith("/dashboard")) return;
const role = user.role || "user";
// Determine allowed prefixes and default destination per role
let allowed: string[] = ["/dashboard/user"]; // everyone can access user dashboard
let dest = "/dashboard/user";
if (role === "admin") {
allowed = ["/dashboard/admin", "/dashboard/user"];
dest = "/dashboard/admin";
} else if (role === "supervisor") {
allowed = ["/dashboard/supervisor", "/dashboard/user"];
dest = "/dashboard/supervisor";
} else if (role === "staff") {
allowed = ["/dashboard/staff", "/dashboard/user"];
dest = "/dashboard/staff";
}
// Allow admin to access supervisor and staff subpages (but not their root dashboards)
const isAllowed =
allowed.some(prefix => pathname === prefix || pathname.startsWith(prefix + "/")) ||
(role === "admin" && (pathname.startsWith("/dashboard/supervisor/") || pathname.startsWith("/dashboard/staff/"))) ||
(role === "supervisor" && pathname.startsWith("/dashboard/staff/"));
if (!isAllowed && pathname !== dest) {
router.replace(dest);
}
}, [pathname, user, loading, router]);
// Don't render protected content until auth state is known
if (loading || !user) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-sm text-gray-400">Loading</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col md:flex-row">
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
<main className="flex-1 p-6 bg-gray-50">{children}</main>
</div>
<Footer />
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
"use client";
import React, { useEffect } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
export default function DashboardIndex() {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (loading) return; // wait for auth to initialize
if (!user) {
router.replace("/login");
return;
}
// Role-aware routing
const role = user.role || "user";
if (role === "admin") router.replace("/dashboard/admin");
else if (role === "supervisor") router.replace("/dashboard/supervisor");
else if (role === "staff") router.replace("/dashboard/staff");
else router.replace("/dashboard/user");
}, [router, user, loading]);
// This route only ever exists to redirect elsewhere (login now goes straight to the role
// dashboard — see LoginForm — so this mainly serves bookmarks/direct links); show a deliberate
// loading state instead of a blank screen while that redirect is worked out.
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-sm text-gray-400">Loading</div>
</div>
);
}
@@ -0,0 +1,397 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
function EventTicketsContent() {
const { token, user } = useAuth();
const params = useSearchParams();
const router = useRouter();
const paramEventId = params.get("eventId") || "";
const paramUserId = params.get("userId") || "";
const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>(paramEventId);
const [tickets, setTickets] = useState<any[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<string>(paramUserId);
const [selectedVariantId, setSelectedVariantId] = useState<string>("");
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor" || role === "staff";
}, [user]);
// Load events for dropdown
useEffect(() => {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter((ev) => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t > now;
});
active.sort(
(a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
setEvents(active);
if (!paramEventId && active.length > 0) {
setEventId(active[0].id);
router.replace(
`?eventId=${encodeURIComponent(active[0].id)}${
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
}`
);
}
} catch (e: any) {
setError(e?.message || "Failed to load events");
}
})();
}, [token]);
// Sync params → state
useEffect(() => {
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
if (paramUserId && paramUserId !== selectedUserId)
setSelectedUserId(paramUserId);
}, [paramEventId, paramUserId]);
// Load tickets when event changes
useEffect(() => {
(async () => {
if (!token || !eventId) return;
setLoading(true);
setError(null);
try {
const list = await apiFetch<any[]>(
`/api/tickets/event/${encodeURIComponent(eventId)}`,
{ authToken: token }
);
setTickets(list);
} catch (e: any) {
setError(e?.message || "Failed to load tickets");
} finally {
setLoading(false);
}
})();
}, [token, eventId]);
// Build variants list for filter (only variants present in current event's tickets)
const variantsList = useMemo(() => {
const map = new Map<string, string>();
for (const t of tickets) {
const v = t.registrationOption?.variant;
if (v?.id) map.set(v.id, v.name);
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [tickets]);
// Build users list depending on event selection
const usersList = useMemo(() => {
let source = [...tickets];
if (eventId) {
source = source.filter((t) => String(t.eventId) === String(eventId));
}
const map = new Map<string, string>();
for (const t of source) {
const uid = t.user?.id || t.userId;
if (!uid) continue;
const name = t.user?.name || t.user?.email || String(uid);
if (!map.has(String(uid))) map.set(String(uid), name);
}
return Array.from(map.entries())
.map(([id, name]) => ({ id, name }))
.sort((a, b) => a.name.localeCompare(b.name));
}, [tickets, eventId]);
// Filter tickets by event + user + variant
const filteredTickets = useMemo(() => {
let list = [...tickets];
if (eventId) {
list = list.filter((t) => String(t.eventId) === String(eventId));
}
if (selectedUserId) {
list = list.filter(
(t) => String(t.user?.id || t.userId) === String(selectedUserId)
);
}
if (selectedVariantId) {
list = list.filter(
(t) => String(t.registrationOption?.variant?.id || "") === String(selectedVariantId)
);
}
return list;
}, [tickets, eventId, selectedUserId, selectedVariantId]);
// Printing helpers
const buildTicketHtmlCard = (t: any) => {
const eventTitle = t.event?.title || t.eventId || "Event";
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase}${optVariant}` : optBase;
const qty = t.quantity || 1;
const holder = t.user?.name || t.userId || "";
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=140x140&data=${encodeURIComponent(
t.qrCode || t.id
)}`;
const dateStr = eventDate ? formatDate(eventDate) : "";
return `
<div class="ticket">
<div class="evt">${eventTitle}</div>
${dateStr ? `<div class="date">${dateStr}</div>` : ""}
<div class="type">${type}</div>
<div class="qty">Qty: <strong>${qty}</strong></div>
<div class="qrwrap"><img src="${qrSrc}" alt="QR" /></div>
<div class="meta">ID: ${t.id}</div>
${holder ? `<div class="meta">${holder}</div>` : ""}
</div>
`;
};
const openPrintWindow = (pagesHtml: string) => {
const w = window.open("", "_blank");
if (!w) return;
w.document.write(`<!doctype html><html><head><title>Tickets</title>
<style>
@page { size: A4; margin: 10mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; padding: 0; }
.page {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(4, 1fr);
height: 277mm;
gap: 6mm;
page-break-after: always;
break-after: page;
}
.page:last-child { page-break-after: avoid; break-after: avoid; }
.ticket { border: 1px solid #000; padding: 5mm; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; }
.evt { font-weight: bold; font-size: 14px; }
.type { font-size: 12px; margin-top: 2mm; }
.qty { font-size: 11px; margin-top: 1mm; }
.date { font-size: 10px; color: #555; margin-top: 1mm; }
.qrwrap { display: flex; justify-content: center; margin: 2mm 0; }
.qrwrap img { width: 30mm; height: 30mm; }
.meta { font-size: 10px; text-align: center; }
</style>
</head><body>
${pagesHtml}
<script>
(function(){
function printWhenReady(){
var imgs = Array.prototype.slice.call(document.images);
if(imgs.length === 0){ window.print(); return; }
var remaining = imgs.length;
function done(){ remaining--; if(remaining <= 0){ setTimeout(function(){ window.print(); }, 100); } }
imgs.forEach(function(img){
if (img.complete) { done(); }
else {
img.addEventListener('load', done, { once: true });
img.addEventListener('error', done, { once: true });
}
});
}
if (document.readyState === 'complete') printWhenReady();
else window.addEventListener('load', printWhenReady);
})();
</script>
</body></html>`);
w.document.close();
w.focus();
};
const printTickets = (ticketList: any[]) => {
if (!ticketList || ticketList.length === 0) return;
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
const PAGE_SIZE = 8;
const pages: string[] = [];
for (let i = 0; i < ticketList.length; i += PAGE_SIZE) {
const chunk = ticketList.slice(i, i + PAGE_SIZE);
pages.push(`<div class="page">${chunk.map(buildTicketHtmlCard).join("")}</div>`);
}
openPrintWindow(pages.join(""));
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event Tickets</h1>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
onClick={() => router.push("/dashboard")}
>
Back
</button>
</div>
<p className="text-sm text-gray-600 mb-4">
View and print tickets for an event.
</p>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need staff, supervisor, or admin access to view this page.
</div>
)}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-4">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
value={eventId}
onChange={(e) => {
const v = e.target.value;
setEventId(v);
setSelectedUserId("");
router.replace(
`?eventId=${encodeURIComponent(v)}${
selectedUserId
? `&userId=${encodeURIComponent(selectedUserId)}`
: ""
}`
);
}}
>
<option value="">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>
{ev.title}
</option>
))}
</select>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">User</label>
<select
className="border rounded px-3 py-2 w-full sm:w-64 max-w-full"
value={selectedUserId}
onChange={(e) => {
const v = e.target.value;
setSelectedUserId(v);
router.replace(
`?eventId=${encodeURIComponent(eventId || "")}${
v ? `&userId=${encodeURIComponent(v)}` : ""
}`
);
}}
disabled={usersList.length === 0}
>
<option value="">All users</option>
{usersList.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
{variantsList.length > 0 && (
<>
<label className="text-sm text-gray-700 sm:ml-4 shrink-0">Variant</label>
<select
className="border rounded px-3 py-2 w-full sm:w-48 max-w-full"
value={selectedVariantId}
onChange={(e) => setSelectedVariantId(e.target.value)}
>
<option value="">All variants</option>
{variantsList.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</>
)}
{filteredTickets.length > 0 && (
<div className="flex gap-2">
<button
onClick={() => printTickets(filteredTickets)}
className="px-3 py-2 border rounded"
>
Print all
</button>
<button
onClick={() =>
printTickets(filteredTickets.filter((t) => !t.isUsed))
}
className="px-3 py-2 bg-blue-600 text-white rounded"
>
Print unused
</button>
</div>
)}
</div>
{loading && <div>Loading...</div>}
{error && <div className="text-red-600 text-sm">{error}</div>}
{filteredTickets.length > 0 && (
<div className="mb-3 text-sm text-gray-700">
<span className="mr-3">
Total tickets: {filteredTickets.length}
</span>
<span className="mr-3">
Used: {filteredTickets.filter((t) => t.isUsed).length}
</span>
<span>
Unused: {filteredTickets.filter((t) => !t.isUsed).length}
</span>
</div>
)}
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{filteredTickets.map((t) => {
const eventDate = t.event?.startDate
? new Date(t.event.startDate)
: null;
const optBase = t.registrationOption?.eventOption?.name || "Ticket";
const optVariant = t.registrationOption?.variant?.name;
const type = optVariant ? `${optBase}${optVariant}` : optBase;
return (
<div
key={t.id}
className="border rounded-xl p-3 space-y-2 bg-white shadow-sm"
>
<div className="flex items-start justify-between">
<div className="font-medium">
{t.event?.title || t.eventId}
</div>
{eventDate && (
<span className="text-xs text-gray-500">
{formatDate(eventDate)}
</span>
)}
</div>
<div className="text-sm text-gray-700">{type}</div>
<div className="flex justify-center">
<QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" />
</div>
<div className="text-center text-xs text-gray-500">
#{String(t.id).slice(0, 8)} {" "}
{t.isUsed ? "Used" : "Unused"}
</div>
</div>
);
})}
</div>
</div>
);
}
export default function EventTicketsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EventTicketsContent />
</Suspense>
);
}
+152
View File
@@ -0,0 +1,152 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
export default function StaffDashboardPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
// Access control (staff/supervisor/admin)
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor" || role === "staff";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Stats and recent scans for quick overview — both come from one endpoint
// (/api/stats/staff) instead of two separate calls. useStableState skips re-renders when
// a poll returns identical data, and hasLoadedOnce below means "Refreshing…" only shows
// on the very first load — together these stop the stats panel from flickering.
const [stats, setStats] = useStableState<any | null>(null);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const loadStats = async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
setStats(data.scanStats);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch (e) {
// ignore
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
// Poll every 10s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
useVisiblePolling(() => {
if (!token) return;
loadStats();
}, 10000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Staff Dashboard{user ? `${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets</button>
</div>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need staff, supervisor, or admin access to use staff tools.
</div>
)}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-2 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
</div>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Scanner stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats</div>}
{stats && (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{stats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{stats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{stats.lastHour}</div>
</div>
</div>
)}
{stats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,487 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useRouter } from "next/navigation";
export default function TicketScanningPage() {
const router = useRouter();
const scannerRef = useRef<QRScannerHandle>(null);
const [lastResult, setLastResult] = useState<string | null>(null);
const [scanInfo, setScanInfo] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
const [errorModal, setErrorModal] = useState<string | null>(null);
const { token, user } = useAuth();
const [recentScans, setRecentScans] = useState<any[]>([]);
const [stats, setStats] = useState<any | null>(null);
const [loadingStats, setLoadingStats] = useState<boolean>(false);
const [successModal, setSuccessModal] = useState<{
optionName: string;
ticketId: string;
eventTitle: string;
qtyRedeemed: number;
remaining: number;
total: number;
} | null>(null);
// Confirm step state
const [confirmModal, setConfirmModal] = useState<{
qrCode: string;
ticket: any;
remaining: number;
} | null>(null);
const [confirmQty, setConfirmQty] = useState(1);
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
// Event selection state
const [allEvents, setAllEvents] = useState<any[]>([]);
const [includePastEvents, setIncludePastEvents] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("all");
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
const [sections, setSections] = useState<any[]>([]);
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
const [loadingSections, setLoadingSections] = useState(false);
// All events from API (including past); frontend filters to 12h window or all past
useEffect(() => {
(async () => {
try {
if (!token) return;
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
const sorted = (evs || []).sort((a, b) => new Date(a.endDate).getTime() - new Date(b.endDate).getTime());
setAllEvents(sorted);
} catch (e: any) {
// non-fatal
} finally {
setLoadingEvents(false);
}
})();
}, [token]);
const events = useMemo(() => {
if (includePastEvents) return allEvents;
const cutoff = Date.now() - 12 * 60 * 60 * 1000;
return allEvents.filter(ev => {
const t = new Date(ev.endDate).getTime();
return !isNaN(t) && t >= cutoff;
});
}, [allEvents, includePastEvents]);
useEffect(() => {
(async () => {
if (!token || !selectedEventId || selectedEventId === "all") {
setSections([]);
setSelectedSectionId("all");
return;
}
try {
setLoadingSections(true);
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
setSections(Array.isArray(list) ? list : []);
setSelectedSectionId("all");
} catch {
setSections([]);
} finally {
setLoadingSections(false);
}
})();
}, [selectedEventId, token]);
// Bug fix: stop the scanner when the event or section filter changes so stale scan
// context is never used. The user must press "Scan Ticket" again to resume.
const isFirstFilterRender = useRef(true);
useEffect(() => {
if (isFirstFilterRender.current) { isFirstFilterRender.current = false; return; }
scannerRef.current?.stop();
}, [selectedEventId, selectedSectionId]);
async function handleResult(text: string) {
setLastResult(text);
setError(null);
setScanInfo(null);
try {
if (!token) {
setErrorModal("Please login as staff to scan tickets.");
return;
}
// Always preview first to validate the ticket before showing confirm step
// Uses the lightweight scan-preview endpoint (only fetches fields needed for the confirm modal)
let preview: any;
try {
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
} catch (pe: any) {
setErrorModal(pe?.message || 'Failed to validate ticket');
return;
}
// Validate event
if (selectedEventId && selectedEventId !== "all") {
const ticketEventId = preview?.event?.id || preview?.eventId;
if (ticketEventId && ticketEventId !== selectedEventId) {
const eventTitle = preview?.event?.title || 'Event';
setErrorModal(`This ticket is for a different event: ${eventTitle}`);
return;
}
// Validate section
if (selectedSectionId !== "all") {
const ticketOptionId = preview?.registrationOption?.eventOption?.id || preview?.eventOptionId;
if (!ticketOptionId) {
setErrorModal("Unable to determine ticket type");
return;
}
const section = sections.find(s => s.id === selectedSectionId);
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
if (!allowedOptionIds.includes(ticketOptionId)) {
setErrorModal("This ticket is not valid for this section");
return;
}
}
}
// Compute remaining qty
const ticketQty = preview?.quantity || 1;
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
const remaining = ticketQty - totalRedeemed;
if (remaining <= 0) {
setAlreadyUsedModal({ message: 'Ticket has already been fully used', ticket: preview });
return;
}
// Show confirm step
setConfirmQty(remaining);
setConfirmQtyRaw(String(remaining));
setConfirmModal({ qrCode: text, ticket: preview, remaining });
} catch (e: any) {
setErrorModal(e?.message || "Failed to scan ticket");
}
}
async function commitScan() {
if (!confirmModal || !token) return;
// Capture everything before clearing modal state
const { qrCode, ticket, remaining } = confirmModal;
const qty = confirmQty;
const baseName = ticket?.registrationOption?.eventOption?.name || "Ticket";
const variantName = ticket?.registrationOption?.variant?.name;
const optionName = variantName ? `${baseName}${variantName}` : baseName;
const eventTitle = ticket?.event?.title || 'Event';
const idPart = ticket?.id ? String(ticket.id).slice(0, 6) : '';
const newRemaining = remaining - qty;
// Show success immediately — we already have all the data from the preview step.
// The actual write happens in the background so the user sees an instant response.
setConfirmModal(null);
setScanInfo(`Ticket #${idPart}${optionName}${eventTitle}`);
setSuccessModal({
optionName,
ticketId: idPart,
eventTitle,
qtyRedeemed: qty,
remaining: newRemaining,
total: ticket?.quantity || 1,
});
// Background write — reverse the optimistic update on failure
try {
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
}`;
await apiFetch<any>(url, {
method: "POST",
authToken: token,
body: { qty },
});
refreshStatsAndRecent();
} catch (e: any) {
setSuccessModal(null);
setScanInfo(null);
const raw = e?.message || "Failed to scan ticket";
try {
const parsed = JSON.parse(raw);
const msg: string | undefined = parsed?.message;
if (msg && /already\s*(been)?\s*used/i.test(msg)) {
setAlreadyUsedModal({ message: msg, ticket: parsed?.ticket });
return;
}
setErrorModal(msg || raw);
} catch {
setErrorModal(raw);
}
}
}
const loadRecent = async () => {
if (!token) return;
try {
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
setRecentScans(list);
} catch {}
};
const loadStats = async () => {
if (!token) return;
try {
setLoadingStats(true);
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
setStats(s);
} catch {} finally {
setLoadingStats(false);
}
};
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
useEffect(() => {
refreshStatsAndRecent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token]);
return (
<div className="max-w-6xl mx-auto w-full p-4">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Ticket Scanning</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">
Use the button to start/stop scanning. The back camera will be used when available.
</p>
{/* Event filter */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Event</label>
<select
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
>
<option value="all">All events</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
<label className="flex items-center gap-1.5 text-sm text-gray-600 cursor-pointer">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} />
Include past events
</label>
{loadingEvents && <span className="text-xs text-gray-500">Loading events</span>}
</div>
{sections.length > 0 && (
<div className="flex flex-col sm:flex-row sm:items-center gap-2 mb-3">
<label className="text-sm text-gray-700 shrink-0">Section</label>
<select
className="border rounded px-3 py-2 w-full sm:w-56 max-w-full"
value={selectedSectionId}
onChange={(e) => setSelectedSectionId(e.target.value)}
>
<option value="all">All sections</option>
{sections.map(section => (
<option key={section.id} value={section.id}>{section.name}</option>
))}
</select>
{loadingSections && <span className="text-xs text-gray-500">Loading sections</span>}
</div>
)}
<QRScanner
ref={scannerRef}
onResult={handleResult}
paused={!!(confirmModal || alreadyUsedModal || errorModal || successModal)}
/>
{scanInfo && (
<div className="mt-2 p-3 border rounded bg-green-50 text-green-800 text-sm">{scanInfo}</div>
)}
{error && (
<div className="mt-2 p-3 border rounded bg-red-50 text-red-800 text-sm">{error}</div>
)}
</div>
<div>
<h2 className="text-xl font-semibold mb-3">Scanner Stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats</div>}
{stats && (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{stats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{stats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{stats.lastHour}</div>
</div>
</div>
)}
{stats?.byStaff?.length > 0 && (
<div className="mb-4">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
)}
<h3 className="text-lg font-semibold mb-2">Last 10 scans</h3>
<ul className="text-sm space-y-2 max-h-80 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2 bg-white">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">
{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{u.ticket?.registrationOption?.variant?.name ? `${u.ticket.registrationOption.variant.name}` : ''}
{u.quantityRedeemed && u.quantityRedeemed > 1 ? ` ×${u.quantityRedeemed}` : ''}
{' — '}#{String(u.ticket?.id || '').slice(0,8)}
</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
</div>
{/* ── Confirm scan modal ───────────────────────────────────────────── */}
{confirmModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-1 text-indigo-700">Confirm Scan</h3>
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
<div><span className="font-medium">Ticket type:</span> {confirmModal.ticket?.registrationOption?.eventOption?.name || 'Ticket'}{confirmModal.ticket?.registrationOption?.variant?.name ? `${confirmModal.ticket.registrationOption.variant.name}` : ''}</div>
<div><span className="font-medium">Event:</span> {confirmModal.ticket?.event?.title || '—'}</div>
<div><span className="font-medium">Holder:</span> {confirmModal.ticket?.user?.name || confirmModal.ticket?.registrationOption?.registration?.user?.name || '—'}</div>
<div><span className="font-medium">Qty on ticket:</span> {confirmModal.ticket?.quantity || 1}</div>
<div><span className="font-medium">Remaining:</span> <span className={confirmModal.remaining < (confirmModal.ticket?.quantity || 1) ? 'text-amber-600 font-semibold' : 'text-green-700 font-semibold'}>{confirmModal.remaining}</span></div>
</div>
{(confirmModal.ticket?.quantity || 1) > 1 && (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-1">Qty to redeem (max {confirmModal.remaining})</label>
<input
type="text"
inputMode="numeric"
min={1}
max={confirmModal.remaining}
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
value={confirmQtyRaw}
onChange={e => setConfirmQtyRaw(e.target.value)}
onBlur={() => {
const parsed = parseInt(confirmQtyRaw, 10);
const clamped = isNaN(parsed) ? 1 : Math.min(confirmModal.remaining, Math.max(1, parsed));
setConfirmQty(clamped);
setConfirmQtyRaw(String(clamped));
}}
/>
</div>
)}
<div className="flex gap-3">
<button
onClick={() => setConfirmModal(null)}
className="flex-1 px-4 py-2 rounded-lg border text-sm font-medium text-gray-700 hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={commitScan}
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
>
Confirm Scan
</button>
</div>
</div>
</div>
)}
{/* ── Already used modal ───────────────────────────────────────────── */}
{alreadyUsedModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
<h3 className="text-lg font-semibold mb-2 text-red-600">Ticket Already Scanned</h3>
<p className="text-sm text-gray-700 mb-3">{alreadyUsedModal.message}</p>
{alreadyUsedModal.ticket && (
<div className="text-xs text-gray-600 bg-gray-50 p-3 rounded border">
<div><span className="font-medium">Ticket ID:</span> {alreadyUsedModal.ticket.id}</div>
{alreadyUsedModal.ticket.event?.title && (
<div><span className="font-medium">Event:</span> {alreadyUsedModal.ticket.event.title}</div>
)}
{Array.isArray(alreadyUsedModal.ticket.usages) && alreadyUsedModal.ticket.usages.length > 0 && (
<div className="mt-2">
<div className="font-medium">Usages:</div>
<ul className="list-disc ml-5 mt-1 max-h-32 overflow-auto">
{alreadyUsedModal.ticket.usages.map((u: any) => (
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{u.quantityRedeemed > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
))}
</ul>
</div>
)}
</div>
)}
<div className="mt-4 flex justify-end">
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setAlreadyUsedModal(null)}>OK</button>
</div>
</div>
</div>
)}
{/* ── Error modal ──────────────────────────────────────────────────── */}
{errorModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded shadow-lg max-w-md w-full p-5">
<h3 className="text-lg font-semibold mb-2 text-red-600">Scan Error</h3>
<p className="text-sm text-gray-700 mb-3">{errorModal}</p>
<div className="mt-4 flex justify-end">
<button className="px-4 py-2 rounded bg-red-600 text-white" onClick={() => setErrorModal(null)}>OK</button>
</div>
</div>
</div>
)}
{/* ── Success modal ────────────────────────────────────────────────── */}
{successModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-4 text-green-600">Scan Successful</h3>
<div className="text-3xl font-bold text-center text-gray-900 mb-2">
{successModal.optionName}
</div>
<div className="text-sm text-center text-gray-600 mb-3">
#{successModal.ticketId} {successModal.eventTitle}
</div>
{successModal.total > 1 && (
<div className={`text-center text-sm font-medium mb-4 ${successModal.remaining > 0 ? 'text-amber-600' : 'text-green-700'}`}>
{successModal.qtyRedeemed} redeemed · {successModal.remaining} remaining of {successModal.total}
</div>
)}
<div className="flex justify-center">
<button className="px-6 py-2 rounded bg-green-600 text-white" onClick={() => setSuccessModal(null)}>OK</button>
</div>
</div>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,297 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
function toLocalDateTimeInputValue(input: string | number | Date | null | undefined): string {
if (input == null) return "";
const d = new Date(input);
if (isNaN(d.getTime())) return "";
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${y}-${m}-${day}T${hh}:${mm}`;
}
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
const tiers = Array.isArray(option?.earlyBirdTiers) ? option.earlyBirdTiers : [];
const normalized = tiers
.slice()
.sort((a: any, b: any) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
.map((t: any, i: number) => ({ id: t.id, deadline: toLocalDateTimeInputValue(t.deadline), price: String(t.price ?? ''), order: typeof t.order === 'number' ? t.order : i }));
setRows(normalized);
}, [option?.id, option?.earlyBirdTiers]);
const addRow = () => {
setRows((r) => [...r, { deadline: '', price: '', order: (r.length || 0) }]);
};
const removeRow = (idx: number) => {
const copy = rows.slice();
copy.splice(idx, 1);
setRows(copy);
};
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
const copy = rows.slice();
copy[idx] = { ...copy[idx], ...patch } as any;
setRows(copy);
};
const save = async () => {
setSaving(true);
try {
const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers);
} finally {
setSaving(false);
}
};
return (
<div className="mt-3 border-t pt-3">
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setOpen(!open)}>
{open ? 'Hide early-bird tiers' : 'Manage early-bird tiers'}
</button>
{open && (
<div className="mt-2 bg-gray-50 border rounded p-2">
{rows.length === 0 ? (
<div className="text-xs text-gray-600 mb-2">No tiers yet. Add deadlines and prices for early-bird discounts.</div>
) : (
<ul className="space-y-2 mb-2">
{rows.map((row, idx) => (
<li key={idx} className="bg-white border rounded p-2">
<div className="flex flex-wrap items-center gap-2">
<input className="border rounded px-2 py-1 text-xs" type="datetime-local" value={row.deadline} onChange={(e) => updateRow(idx, { deadline: e.target.value })} />
<input className="border rounded px-2 py-1 text-xs w-24" type="number" step="0.01" value={row.price} onChange={(e) => updateRow(idx, { price: e.target.value })} placeholder="Price" />
<input className="border rounded px-2 py-1 text-xs w-16" type="number" step="1" value={typeof row.order === 'number' ? String(row.order) : ''} onChange={(e) => updateRow(idx, { order: parseInt(e.target.value || '0', 10) })} placeholder="#" />
<button type="button" className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700" onClick={() => removeRow(idx)}>Remove</button>
</div>
</li>
))}
</ul>
)}
<div className="flex items-center gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
</div>
</div>
)}
</div>
);
}
function EventOptionsContent() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
const [loadingEv, setLoadingEv] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
try {
setLoadingEv(true);
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
setEvents(evs || []);
} catch (e) {
// ignore
} finally {
setLoadingEv(false);
}
};
useEffect(() => { loadEvents(); }, [token]);
// Preselect event from query (?eventId=)
useEffect(() => {
const q = search?.get("eventId");
if (!q) return;
// if events already loaded, ensure it exists then set; otherwise, set directly and let options hook handle
setSelectedEventId(q);
}, [search]);
useEffect(() => {
const ev = events.find(e => e.id === selectedEventId);
if (ev) {
setOptions(ev.options || ev.eventOptions || []);
} else {
setOptions([]);
}
}, [selectedEventId, events]);
const [newOpt, setNewOpt] = useState({ name: "", price: "", isMainTicket: false });
const createOption = async () => {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
if (!newOpt.name) { setError("Option name is required"); return; }
const priceNum = parseFloat(newOpt.price || "0");
try {
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}/options`, {
method: "POST",
authToken: token,
body: { name: newOpt.name, price: priceNum || 0, isMainTicket: !!newOpt.isMainTicket }
});
setNewOpt({ name: "", price: "", isMainTicket: false });
setInfo("Option created");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to create option");
}
};
const updateOption = async (opt: any, patch: any) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "PUT",
authToken: token,
body: patch
});
setInfo("Option updated");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to update option");
}
};
const deleteOption = async (opt: any) => {
if (!token) return;
setError(null); setInfo(null);
try {
await apiFetch(`/api/events/options/${encodeURIComponent(opt.id)}`, {
method: "DELETE",
authToken: token,
});
setInfo("Option deleted");
await loadEvents();
} catch (e: any) {
setError(e?.message || "Failed to delete option");
}
};
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event options</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-3">Select event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
const ev = events.find(e => e.id === selectedEventId);
if (!ev) return null;
return <>
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
</>;
})()}
</div>
)}
</div>
{selectedEventId && (
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Options</div>
{options.length === 0 ? (
<div className="text-sm text-gray-500">No options yet.</div>
) : (
<ul className="space-y-3">
{options.map(opt => (
<li key={opt.id} className="border rounded p-3">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500">Price: R {(opt.price || 0).toFixed(2)} {opt.isMainTicket ? "• Main ticket" : ""}</div>
</div>
<div className="flex items-center gap-2">
<input className="w-36 border rounded px-2 py-1 text-sm" defaultValue={opt.name} onBlur={e => { const v = e.target.value.trim(); if (v && v !== opt.name) updateOption(opt, { name: v }); }} />
<input className="w-28 border rounded px-2 py-1 text-sm" type="number" step="0.01" defaultValue={(opt.price || 0)} onBlur={e => { const v = parseFloat(e.target.value || '0'); if (!isNaN(v) && v !== opt.price) updateOption(opt, { price: v }); }} />
<label className="text-xs flex items-center gap-1"><input type="checkbox" defaultChecked={!!opt.isMainTicket} onChange={e => updateOption(opt, { isMainTicket: e.target.checked })} /> Main</label>
{user?.role === 'admin' && (
<button onClick={() => deleteOption(opt)} className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700">
Delete
</button>
)}
</div>
</div>
{/* Early Bird Tiers Editor */}
<EarlyBirdTiersEditor
option={opt}
onSave={(tiers) => updateOption(opt, { earlyBirdTiers: tiers })}
/>
</li>
))}
</ul>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create new option</div>
<div className="grid gap-2">
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm">Create option</button>
</div>
</div>
</div>
)}
</div>
);
}
export default function EventOptionsPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<EventOptionsContent />
</Suspense>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,734 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
const fields = value.fields || [];
const addField = (type: FormFieldType = 'text') => {
onChange({ ...value, fields: [...fields, { type, label: '', isRequired: false, helpText: '' }] });
};
const updateField = (idx: number, patch: Partial<EventFormDef['fields'][number]>) => {
const copy = fields.slice();
copy[idx] = { ...copy[idx], ...patch };
onChange({ ...value, fields: copy });
};
const removeField = (idx: number) => {
const copy = fields.slice();
copy.splice(idx, 1);
onChange({ ...value, fields: copy });
};
const moveField = (idx: number, dir: -1 | 1) => {
const copy = fields.slice();
const target = idx + dir;
if (target < 0 || target >= copy.length) return;
[copy[idx], copy[target]] = [copy[target], copy[idx]];
onChange({ ...value, fields: copy });
};
const typeLabel = (type: FormFieldType) => {
const labels: Record<FormFieldType, string> = { text: 'Text', numeric: 'Number', date: 'Date', yes_no: 'Yes/No', statement: 'Statement', paragraph: 'Heading' };
return labels[type] || type;
};
return (
<div className="border rounded-lg p-3 bg-gray-50">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-gray-800">Registration Form Fields</div>
<label className="text-xs flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" checked={!!value.isRequired} onChange={e => onChange({ ...value, isRequired: e.target.checked })} />
<span>Required before tickets generate</span>
</label>
</div>
{fields.length === 0 ? (
<div className="text-xs text-gray-500 mb-3 italic">No fields yet add questions below.</div>
) : (
<ul className="space-y-2 mb-3">
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium shrink-0">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400 shrink-0">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, -1)} disabled={idx === 0} title="Move up"></button>
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, 1)} disabled={idx === fields.length - 1} title="Move down"></button>
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 hover:bg-red-100 border border-red-200" onClick={() => removeField(idx)}>Remove</button>
</div>
<div className="flex flex-wrap items-center gap-2 mb-2">
<select
className="border rounded px-2 py-1 text-xs"
value={f.type}
onChange={e => updateField(idx, { type: e.target.value as FormFieldType })}
>
<option value="text">Text answer</option>
<option value="numeric">Number</option>
<option value="date">Date</option>
<option value="yes_no">Yes / No</option>
<option value="statement">Statement (display text)</option>
<option value="paragraph">Heading + paragraph</option>
</select>
{f.type !== 'statement' && f.type !== 'paragraph' && (
<label className="text-xs flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={!!f.isRequired} onChange={e => updateField(idx, { isRequired: e.target.checked })} />
Required
</label>
)}
</div>
<input
className="border rounded px-2 py-1 text-sm w-full mb-1.5"
placeholder={f.type === 'paragraph' ? 'Heading text' : f.type === 'statement' ? 'Statement text' : 'Question / field label'}
value={f.label}
onChange={e => updateField(idx, { label: e.target.value })}
/>
{f.type === 'paragraph' ? (
<textarea
className="border rounded px-2 py-1 text-xs w-full"
placeholder="Paragraph body text"
rows={2}
value={f.helpText || ''}
onChange={e => updateField(idx, { helpText: e.target.value })}
/>
) : f.type !== 'statement' ? (
<input
className="border rounded px-2 py-1 text-xs w-full text-gray-500"
placeholder="Help text shown below the field (optional)"
value={f.helpText || ''}
onChange={e => updateField(idx, { helpText: e.target.value })}
/>
) : null}
</li>
))}
</ul>
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => addField('text')}>+ Add question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('statement')}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('paragraph')}>+ Heading</button>
</div>
</div>
);
}
export default function FormsBrowserPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor" || role === "staff";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [mode, setMode] = useState<'view' | 'manage' | 'fill'>('view');
const [allEvents, setAllEvents] = useState<any[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const [evIncludeInactive, setEvIncludeInactive] = useState(false);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [userId, setUserId] = useState<string>("");
const [userSearch, setUserSearch] = useState<string>("");
const [allUsers, setAllUsers] = useState<any[]>([]);
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
const [registrationId, setRegistrationId] = useState<string>("");
const [items, setItems] = useState<any[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loadingList, setLoadingList] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const loadEvents = async () => {
if (!token) return;
try {
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
const sorted = (evs || []).sort((a: any, b: any) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
setAllEvents(Array.isArray(sorted) ? sorted : []);
} catch {}
};
useEffect(() => { loadEvents(); }, [token]);
useEffect(() => {
if (!token) return;
fetchAllUsers(token)
.then(list => {
setAllUsers(list.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")));
})
.catch(() => {});
}, [token]);
const filteredUsers = useMemo(() => {
const q = userSearch.trim().toLowerCase();
if (!q) return allUsers.slice(0, 50);
return allUsers.filter(u =>
(u.name || "").toLowerCase().includes(q) ||
(u.email || "").toLowerCase().includes(q) ||
(u.phoneNumber || "").toLowerCase().includes(q)
).slice(0, 50);
}, [allUsers, userSearch]);
const isAdmin = user?.role === "admin";
const events = useMemo(() => {
const now = Date.now();
return allEvents.filter(ev => {
if (!evIncludeInactive && ev.isActive === false) return false;
if (!evIncludePast) {
const t = new Date(ev.endDate).getTime();
if (!isNaN(t) && t < now) return false;
}
return true;
});
}, [allEvents, evIncludePast, evIncludeInactive]);
const search = async (append = false) => {
if (!token) return;
setError(null);
try {
setLoadingList(true);
const qs = new URLSearchParams();
if (selectedEventId) qs.set("eventId", selectedEventId);
if (userId.trim()) qs.set("userId", userId.trim());
if (registrationId.trim()) qs.set("registrationId", registrationId.trim());
if (append && nextCursor) qs.set("cursor", nextCursor);
const url = "/api/forms/responses" + (qs.toString() ? `?${qs.toString()}` : "");
const res = await apiFetch<{ items: any[]; nextCursor?: string }>(url, { authToken: token });
const newItems = Array.isArray(res?.items) ? res!.items : [];
setItems(prev => append ? [...prev, ...newItems] : newItems);
setNextCursor(res?.nextCursor || null);
} catch (e: any) {
setError(e?.message || "Failed to load form responses");
} finally {
setLoadingList(false);
}
};
useEffect(() => {
if (token) search(false);
}, [token]);
const onPrint = () => {
if (!items || items.length === 0) return;
const w = window.open("", "_blank");
if (!w) return;
const formPages = items.map((r: any) => {
const eventTitle = r.registration?.event?.title || "Event";
const attendeeName = r.registration?.user?.name || "Attendee";
const attendeeEmail = r.registration?.user?.email || "";
const regId = String(r.registrationId || r.id || "").slice(0, 8);
const createdAt = r.createdAt ? new Date(r.createdAt).toLocaleDateString("en-ZA") : "";
const answers = (r.answers || []).map((a: any) => `
<div class="answer">
<div class="label">${a.field?.label || a.fieldId || "Field"}</div>
<div class="value">${a.value ?? ""}</div>
</div>`).join("");
return `
<div class="page">
<div class="header">
<div>
<div class="event">${eventTitle}</div>
<div class="regid">Registration #${regId}${createdAt ? ` · ${createdAt}` : ""}</div>
</div>
<div class="attendee">
<div class="name">${attendeeName}</div>
<div class="email">${attendeeEmail}</div>
</div>
</div>
<div class="answers">${answers || '<div class="empty">No answers submitted.</div>'}</div>
</div>`;
}).join("");
w.document.write(`<!doctype html><html><head><title>Form Responses</title>
<style>
@page { size: A4; margin: 15mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; margin: 0; background: #fff; color: #111; }
.page { page-break-after: always; break-after: page; padding-bottom: 8mm; }
.page:last-child { page-break-after: avoid; break-after: avoid; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #111; padding-bottom: 6px; margin-bottom: 12px; }
.event { font-size: 16px; font-weight: bold; }
.regid { font-size: 11px; color: #555; margin-top: 2px; }
.attendee { text-align: right; }
.name { font-size: 14px; font-weight: 600; }
.email { font-size: 11px; color: #555; }
.answers { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.answer { border: 1px solid #d1d5db; border-radius: 4px; padding: 8px; }
.label { font-size: 10px; color: #6b7280; margin-bottom: 3px; text-transform: uppercase; letter-spacing: 0.03em; }
.value { font-size: 13px; font-weight: 500; word-break: break-word; }
.empty { font-size: 12px; color: #9ca3af; font-style: italic; grid-column: 1 / -1; }
</style>
</head><body>${formPages}
<script>
if(document.readyState==='complete') window.print();
else window.addEventListener('load', function(){ window.print(); });
</script>
</body></html>`);
w.document.close();
w.focus();
};
// Manage forms state
const [formLoading, setFormLoading] = useState(false);
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [formLoadedFor, setFormLoadedFor] = useState<string>("");
const loadFormForEvent = async (eventId: string) => {
if (!eventId) return;
try {
setFormLoading(true);
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFormDef({ isRequired: !!f.isRequired, fields: f.fields.map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '' })) });
} else {
setFormDef({ isRequired: false, fields: [] });
}
setFormLoadedFor(eventId);
} catch (e:any) {
setError(e?.message || 'Failed to load form');
} finally {
setFormLoading(false);
}
};
useEffect(() => {
if (mode === 'manage' && selectedEventId && selectedEventId !== formLoadedFor) {
loadFormForEvent(selectedEventId);
}
}, [mode, selectedEventId]);
const saveForm = async () => {
if (!token || !selectedEventId) return;
setError(null); setInfo(null);
try {
const fields = (formDef.fields || []).filter(f => (f.label || '').trim().length > 0).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph', order: i, helpText: f.helpText || null }));
await apiFetch(`/api/events/${encodeURIComponent(selectedEventId)}`, { method: 'PUT', authToken: token, body: { form: { isRequired: !!formDef.isRequired, fields } } });
setInfo('Form saved');
} catch (e:any) {
setError(e?.message || 'Failed to save form');
}
};
// Fill/edit responses state
const [fillEventId, setFillEventId] = useState<string>("");
const [fillRegistrations, setFillRegistrations] = useState<any[]>([]);
const [loadingRegs, setLoadingRegs] = useState(false);
const [fillRegistrationId, setFillRegistrationId] = useState<string>("");
const [fillRegistration, setFillRegistration] = useState<any | null>(null);
const [fillForm, setFillForm] = useState<EventFormDef>({ isRequired: false, fields: [] });
const [existingResponses, setExistingResponses] = useState<any[]>([]);
const [savingFill, setSavingFill] = useState(false);
const loadRegsForEvent = async (evId: string) => {
if (!token || !evId) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
const sorted = (Array.isArray(regs) ? regs : []).sort((a, b) => (a.user?.name || '').toLowerCase().localeCompare((b.user?.name || '').toLowerCase()));
setFillRegistrations(sorted);
} catch {
setFillRegistrations([]);
} finally {
setLoadingRegs(false);
}
};
useEffect(() => {
if (mode === 'fill') setFillEventId(prev => prev || selectedEventId || "");
}, [mode]);
useEffect(() => {
if (mode !== 'fill') return;
if (fillEventId) loadRegsForEvent(fillEventId);
setFillRegistrationId(""); setFillRegistration(null); setExistingResponses([]); setFillForm({ isRequired: false, fields: [] });
}, [fillEventId, mode]);
useEffect(() => {
(async () => {
if (mode !== 'fill' || !fillRegistrationId) return;
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
setFillRegistration(reg);
if (reg?.event?.id) {
const evFull = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.event.id)}`);
const f = evFull?.form;
if (f && Array.isArray(f.fields)) {
setFillForm({ isRequired: !!f.isRequired, fields: f.fields.map((x:any)=>({ id:x.id, type:x.type, label:x.label, isRequired:!!x.isRequired, order:x.order, helpText:x.helpText||'' })) });
} else {
setFillForm({ isRequired: false, fields: [] });
}
}
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
setExistingResponses(items);
} catch (e:any) {
setError(e?.message || 'Failed to load registration/form');
}
})();
}, [fillRegistrationId, mode]);
const mainTicketCount = useMemo(() => {
const ros = fillRegistration?.registrationOptions || [];
return ros.filter((ro:any)=> ro?.eventOption?.isMainTicket).reduce((s:number, ro:any)=> s + (ro.quantity||0), 0);
}, [fillRegistration]);
const [editableResponses, setEditableResponses] = useState<{ answers: Record<string,string> }[]>([]);
useEffect(() => {
if (mode !== 'fill') return;
const fields = (fillForm.fields || []).filter(f => f.type !== 'statement' && f.type !== 'paragraph');
const mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r:any)=>{
const ans: Record<string,string> = {};
(r.answers || []).forEach((a:any)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
for (const f of fields) if (!(f.id! in ans)) ans[f.id!] = '';
return { answers: ans };
});
const target = Math.max(0, mainTicketCount);
while (mapped.length < target) {
const ans: Record<string,string> = {};
for (const f of fields) ans[f.id!] = '';
mapped.push({ answers: ans });
}
if (mapped.length > target) mapped.length = target;
setEditableResponses(mapped);
}, [existingResponses, fillForm, mainTicketCount, mode]);
const setAnswer = (respIdx: number, fieldId: string, value: string) => {
setEditableResponses(prev => {
const copy = prev.slice();
if (!copy[respIdx]) copy[respIdx] = { answers: {} } as any;
copy[respIdx] = { answers: { ...copy[respIdx].answers, [fieldId]: value } };
return copy;
});
};
const saveResponses = async () => {
if (!token || !fillRegistrationId) return;
setError(null); setInfo(null);
try {
setSavingFill(true);
const requiredIds = (fillForm.fields||[]).filter(f=> f.type!== 'statement' && !!f.isRequired).map(f=> f.id);
for (let i=0; i<editableResponses.length; i++) {
const er = editableResponses[i];
for (const fid of requiredIds) {
const v = (er.answers || {})[fid!];
if (v == null || String(v).trim() === '') throw new Error(`Response #${i+1}: Missing answer for a required field`);
}
}
await apiFetch(`/api/registrations/${encodeURIComponent(fillRegistrationId)}/forms/responses`, {
method: 'PUT', authToken: token, body: { responses: editableResponses }
});
setInfo('Responses saved');
const resList = await apiFetch<any>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
const items = Array.isArray(resList?.items) ? resList.items : (Array.isArray(resList) ? resList : []);
setExistingResponses(items);
} catch (e:any) {
setError(e?.message || 'Failed to save responses');
} finally {
setSavingFill(false);
}
};
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 no-print">
<h1 className="text-2xl font-semibold">Forms</h1>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
{mode === 'view' && (
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={onPrint} disabled={items.length === 0}>
Print forms
</button>
)}
</div>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4 no-print">
You need staff, supervisor or admin access to use this page.
</div>
)}
{/* Mode tabs */}
<div className="mb-4 flex items-center gap-2 no-print">
{(['view', 'fill', 'manage'] as const).map(m => (
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
<input type="radio" name="mode" value={m} className="hidden" checked={mode===m} onChange={() => setMode(m)} />
{m === 'view' ? 'View responses' : m === 'fill' ? 'Fill / edit responses' : 'Edit form structure'}
</label>
))}
</div>
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm no-print">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm no-print">{info}</div>}
{/* ── VIEW MODE ─────────────────────────────── */}
{mode === 'view' && (
<>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4 no-print">
<div className="text-base font-semibold mb-3">Filters</div>
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-3 items-end">
<div className="lg:col-span-2">
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full"
value={selectedEventId}
onChange={e => setSelectedEventId(e.target.value)}
>
<option value="">All events</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>
{ev.title}{!ev.isActive ? ' (inactive)' : ''}{ev.endDate && new Date(ev.endDate) < new Date() ? ' (past)' : ''}
</option>
))}
</select>
<div className="flex items-center gap-3 mt-1 text-xs text-gray-500">
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={e => setEvIncludePast(e.target.checked)} /> Include past events
</label>
{isAdmin && (
<label className="flex items-center gap-1 cursor-pointer">
<input type="checkbox" checked={evIncludeInactive} onChange={e => setEvIncludeInactive(e.target.checked)} /> Inactive events
</label>
)}
</div>
</div>
<div className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="border rounded px-2 py-1.5 text-sm w-full max-w-xs"
placeholder="Search by name, email or phone…"
value={userSearch}
onChange={e => { setUserSearch(e.target.value); setUserDropdownOpen(true); if (!e.target.value) setUserId(""); }}
onFocus={() => setUserDropdownOpen(true)}
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
/>
{userId && (
<div className="text-xs text-indigo-600 mt-0.5 truncate max-w-xs">
{allUsers.find(u => u.id === userId)?.name || userId}
<button className="ml-1 text-gray-400 hover:text-gray-600" onMouseDown={e => { e.preventDefault(); setUserId(""); setUserSearch(""); }}></button>
</div>
)}
{userDropdownOpen && filteredUsers.length > 0 && (
<div className="absolute z-20 left-0 mt-1 w-full max-w-xs bg-white border rounded shadow-lg max-h-48 overflow-y-auto">
{filteredUsers.map(u => (
<button
key={u.id}
className="w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex flex-col"
onMouseDown={e => { e.preventDefault(); setUserId(u.id); setUserSearch(u.name || u.email || u.id); setUserDropdownOpen(false); }}
>
<span className="font-medium truncate">{u.name || "(no name)"}</span>
<span className="text-xs text-gray-400 truncate">{u.email}</span>
</button>
))}
</div>
)}
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration ID</label>
<input className="border rounded px-2 py-1.5 text-sm w-full" placeholder="Paste registration ID…" value={registrationId} onChange={e => setRegistrationId(e.target.value)} />
</div>
</div>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
onClick={() => search(false)}
disabled={loadingList}
>
{loadingList ? "Searching…" : "Search"}
</button>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
onClick={() => { setSelectedEventId(""); setUserId(""); setUserSearch(""); setRegistrationId(""); setItems([]); setNextCursor(null); }}
>
Clear
</button>
{items.length > 0 && (
<span className="self-center text-xs text-gray-500">{items.length} response{items.length !== 1 ? 's' : ''} loaded</span>
)}
</div>
</div>
<div className="border rounded-xl bg-white shadow-sm">
{items.length === 0 ? (
<div className="p-6 text-sm text-gray-500 text-center">
{loadingList ? "Loading…" : "No form responses found. Select an event and search."}
</div>
) : (
<ul className="divide-y print-form-wrapper">
{items.map((r: any) => (
<li key={r.id} className="print-page-break">
<div className="p-4 print-response">
{/* Header */}
<div className="flex flex-wrap justify-between gap-2 mb-3 pb-2 border-b border-gray-200">
<div>
<div className="font-semibold text-gray-900">{r.registration?.event?.title || 'Event'}</div>
<div className="text-xs text-gray-500 mt-0.5">Registration #{r.registrationId?.slice(0, 8)}</div>
</div>
<div className="text-right">
<div className="font-medium text-gray-800">{r.registration?.user?.name || 'Attendee'}</div>
<div className="text-xs text-gray-500">{r.registration?.user?.email}</div>
<div className="text-xs text-gray-400">{new Date(r.createdAt).toLocaleString()}</div>
</div>
</div>
{/* Answers */}
{(!r.answers || r.answers.length === 0) ? (
<div className="text-xs text-gray-400 italic">No answers submitted.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2 print-answers">
{r.answers.map((a: any) => (
<div key={a.id} className="bg-gray-50 border rounded p-2 print-answer">
<div className="text-[10px] text-gray-500 mb-0.5 print-answer-label">{a.field?.label || a.fieldId}</div>
<div className="font-medium text-sm break-words print-answer-value">{a.value}</div>
</div>
))}
</div>
)}
</div>
</li>
))}
</ul>
)}
{nextCursor && (
<div className="p-3 border-t no-print">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" disabled={loadingList} onClick={() => search(true)}>
Load more
</button>
</div>
)}
</div>
</>
)}
{/* ── MANAGE MODE ─────────────────────────────── */}
{mode === 'manage' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-3">Edit form structure</div>
<div className="mb-4">
<label className="block text-xs text-gray-600 mb-1">Select event</label>
<select
className="border rounded px-2 py-1.5 text-sm w-full sm:max-w-sm"
value={selectedEventId}
onChange={e => { setSelectedEventId(e.target.value); setFormLoadedFor(""); }}
>
<option value="">Choose an event</option>
{allEvents.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}{!ev.isActive ? ' (inactive)' : ''}</option>
))}
</select>
<div className="text-xs text-gray-400 mt-1">All events shown (including inactive/past) so you can edit historical forms.</div>
{formLoading && <span className="text-xs text-gray-400 mt-1 block">Loading form</span>}
</div>
{!selectedEventId ? (
<div className="text-sm text-gray-500">Select an event above to manage its attendee form.</div>
) : (
<>
<FormBuilder value={formDef} onChange={setFormDef} />
<div className="mt-3 flex gap-2">
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
onClick={saveForm}
disabled={formLoading}
>
Save form
</button>
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
onClick={() => loadFormForEvent(selectedEventId)}
disabled={formLoading}
>
Reload
</button>
</div>
</>
)}
</div>
)}
{/* ── FILL MODE ─────────────────────────────── */}
{mode === 'fill' && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-3">Fill / edit responses</div>
<div className="grid sm:grid-cols-2 gap-3 mb-4 items-end">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillEventId} onChange={e => setFillEventId(e.target.value)}>
<option value="">Select event</option>
{allEvents.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Registration</label>
<select className="border rounded px-2 py-1.5 text-sm w-full" value={fillRegistrationId} onChange={e => setFillRegistrationId(e.target.value)} disabled={!fillEventId || loadingRegs}>
<option value="">{loadingRegs ? 'Loading registrations…' : 'Select registration…'}</option>
{fillRegistrations.map((r:any)=>{
const label = `${r.user?.name || r.userId}${r.user?.email || ''} — #${String(r.id).slice(0,8)}`;
return <option key={r.id} value={r.id}>{label}</option>;
})}
</select>
</div>
</div>
{!fillRegistrationId ? (
<div className="text-sm text-gray-500">Choose an event and registration to fill responses.</div>
) : fillForm.fields.length === 0 ? (
<div className="text-sm text-gray-500">This event has no form configured. Go to &ldquo;Edit form structure&rdquo; to add fields.</div>
) : (
<div className="space-y-4">
<div className="text-sm text-gray-600">
Main tickets: <span className="font-medium">{mainTicketCount}</span>
{mainTicketCount === 0 && <span className="ml-2 text-amber-600 text-xs">(No main tickets found check registration options)</span>}
</div>
{editableResponses.map((resp, idx) => (
<div key={idx} className="border rounded-lg p-3 bg-white shadow-sm">
<div className="text-sm font-semibold mb-3 text-gray-700">Attendee #{idx+1}</div>
<div className="grid sm:grid-cols-2 gap-3">
{fillForm.fields.filter(f=>f.type!=='statement' && f.type !== 'paragraph').map((f:any)=> (
<div key={f.id}>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'text' && (
<input className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{f.type === 'numeric' && (
<input type="number" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{f.type === 'date' && (
<input type="date" className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)} />
)}
{f.type === 'yes_no' && (
<select className="border rounded px-2 py-1 text-sm w-full" value={resp.answers[f.id]||''} onChange={e=> setAnswer(idx, f.id!, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
)}
{f.helpText && <div className="text-[10px] text-gray-500 mt-0.5">{f.helpText}</div>}
</div>
))}
</div>
</div>
))}
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
onClick={saveResponses}
disabled={savingFill || mainTicketCount === 0}
>
{savingFill ? 'Saving…' : 'Save responses'}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,203 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
export default function ManualRegistrationPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [eventId, setEventId] = useState("");
const [optionId, setOptionId] = useState("");
const [quantity, setQuantity] = useState(1);
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [createdReg, setCreatedReg] = useState<any | null>(null);
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!token) return;
setBusy(true);
setError(null);
try {
const res = await apiFetch<any>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
eventId,
options: [{ eventOptionId: optionId, quantity }],
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
guestOnly: registerAsGuest,
},
});
setCreatedReg(res);
// Load form definition for this event (if any)
try {
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
if (ev?.form) setForm(ev.form);
} catch {}
alert("Manual registration created");
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} finally {
setBusy(false);
}
}
return (
<div className="max-w-xl">
<h1 className="text-xl font-semibold mb-4">Manual Registration</h1>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Event ID</label>
<input className="w-full border rounded px-3 py-2" value={eventId} onChange={(e) => setEventId(e.target.value)} required />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Option ID</label>
<input className="w-full border rounded px-3 py-2" value={optionId} onChange={(e) => setOptionId(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Quantity</label>
<input type="number" min={1} className="w-full border rounded px-3 py-2" value={quantity} onChange={(e) => setQuantity(parseInt(e.target.value || "1", 10))} required />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Name</label>
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
</div>
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium">Email</label>
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={registerAsGuest} onChange={e=>setRegisterAsGuest(e.target.checked)} /> Guest (no account)</label>
</div>
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
</div>
</div>
<div>
<label className="block text-sm font-medium">Cell Number</label>
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
</div>
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="bg-blue-600 text-white rounded px-4 py-2 disabled:opacity-60">
{busy ? "Submitting..." : "Create"}
</button>
</form>
{createdReg && form && Array.isArray(form.fields) && (
<AttendeeFormsSection registration={createdReg} form={form} formsData={formsData} setFormsData={setFormsData} />
)}
</div>
);
}
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
const { token } = useAuth();
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets);
const canSubmit = React.useMemo(() => {
if (!form || count <= 0) return false;
const reqFields = (form.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < count; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [form, formsData, count]);
const update = (idx: number, fieldId: string, value: string) => {
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
};
const submit = async () => {
if (!token) return;
try {
setSubmitting(true);
setError(null); setInfo(null);
const payload = [] as any[];
for (let i = 0; i < count; i++) payload.push({ answers: formsData[i] || {} });
await apiFetch(`/api/registrations/${encodeURIComponent(registration.id)}/forms/responses`, {
method: 'POST', authToken: token, body: { responses: payload }
});
setInfo('Attendee forms submitted successfully.');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
} finally {
setSubmitting(false);
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="mt-6 border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Attendee forms for this registration</div>
{error && <div className="text-xs text-red-600 mb-2">{error}</div>}
{info && <div className="text-xs text-emerald-700 mb-2">{info}</div>}
<div className="space-y-4">
{Array.from({ length: count }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {idx + 1}</div>
{form.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
</div>
);
}
@@ -0,0 +1,451 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
function effectiveOptionUnit(opt: any): number {
const base = opt.price || 0;
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return applicable.length > 0 ? applicable[0].price : base;
}
function effectiveVariantUnit(opt: any, variant: any): number {
const base = variant.price !== null && variant.price !== undefined ? variant.price : opt.price || 0;
const allTiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : [];
const variantTiers = allTiers.filter((t: any) => t.variantId === variant.id);
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => now < t.deadline)
.sort((a: any, b: any) => a.deadline - b.deadline || (a.order || 0) - (b.order || 0) || a.price - b.price);
return applicable.length > 0 ? applicable[0].price : base;
}
function fmtPrice(n: number) {
return n === 0 ? "Free" : `R ${n.toFixed(2)}`;
}
// ─── Component ───────────────────────────────────────────────────────────────
export default function ManualRegistrationPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [events, setEvents] = useState<any[]>([]);
const [selectedEventId, setSelectedEventId] = useState<string>("");
const [options, setOptions] = useState<any[]>([]);
// All system users for fuzzy lookup
const [allUsers, setAllUsers] = useState<any[]>([]);
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
const [registerAsGuest, setRegisterAsGuest] = useState(false);
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [quantities, setQuantities] = useState<Record<string, number>>({});
// User search state
const [userQuery, setUserQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const searchRef = useRef<HTMLDivElement>(null);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Load all users for client-side fuzzy matching
useEffect(() => {
if (!token) return;
fetchAllUsers(token)
.then(users => setAllUsers(users))
.catch(() => {});
}, [token]);
// Fuzzy match results (top 6, score threshold 0.45)
const matchedUsers = useMemo(() => {
if (userQuery.trim().length < 2) return [];
return allUsers
.map(u => ({ u, score: scoreUser(u, userQuery) }))
.filter(x => x.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 6)
.map(x => x.u);
}, [userQuery, allUsers]);
const selectUser = (u: any) => {
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
setUserQuery(u.name || "");
setDropdownOpen(false);
};
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setDropdownOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
useEffect(() => {
(async () => {
try {
if (!token) return;
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
const now = Date.now();
const active = (evs || []).filter(ev => {
const t = new Date(ev.endDate).getTime();
// Manual registration is rejected server-side for closed (cashed-up) events —
// don't offer them here even in the rare case one is closed before it ends.
return !isNaN(t) && t > now && ev.cashupStatus !== 'closed';
});
active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
setEvents(active);
} catch (e: any) {
// ignore
}
})();
}, [token]);
useEffect(() => {
const ev = events.find(e => e.id === selectedEventId);
if (ev) {
const opts = ev.options || ev.eventOptions || [];
setOptions(opts);
const map: Record<string, number> = {};
opts.forEach((o: any) => {
if ((o.variants || []).length > 0) {
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
} else {
map[o.id] = 0;
}
});
setQuantities(map);
} else {
setOptions([]);
setQuantities({});
}
}, [selectedEventId, events]);
const totalDue = useMemo(() => {
return options.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants as any[]).reduce((vs: number, v: any) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
}, 0);
}, [options, quantities]);
const submit = async () => {
if (!token) return;
setError(null);
setMessage(null);
if (!selectedEventId) { setError("Please select an event."); return; }
if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); return; }
const opts = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
try {
setSubmitting(true);
const hasEmail = !!guest.email.trim();
const hasPhone = !!guest.phoneNumber.trim();
const resolvedPref = hasEmail && hasPhone ? notifPref : hasPhone ? "whatsapp" : "email";
const res = await apiFetch<any>("/api/registrations/manual", {
method: "POST",
authToken: token,
body: {
eventId: selectedEventId,
options: opts,
user: guest,
guestOnly: registerAsGuest,
notificationPreference: resolvedPref,
}
});
setMessage("Manual registration created successfully.");
// Reset guest/ticket fields so the next registration starts from a clean slate
setGuest({ name: "", email: "", phoneNumber: "" });
setRegisterAsGuest(false);
setNotifPref("email");
setUserQuery("");
setDropdownOpen(false);
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
} catch (e: any) {
setError(e?.message || "Failed to create manual registration");
} finally {
setSubmitting(false);
}
};
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manual registration</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{message && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{message}</div>}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="grid md:grid-cols-2 gap-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">1) Choose event</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={selectedEventId} onChange={e => setSelectedEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map(ev => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
{selectedEventId && (
<div className="mt-3 text-xs text-gray-600">
{(() => {
const ev = events.find(e => e.id === selectedEventId);
if (!ev) return null;
return <>
<div>{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
</>;
})()}
</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">2) Guest details</div>
{/* ── User lookup ─────────────────────────────────────────── */}
<div ref={searchRef} className="relative mb-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Search existing user <span className="font-normal">(name, email or phone)</span>
</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Start typing to find a user…"
value={userQuery}
autoComplete="off"
onChange={e => { setUserQuery(e.target.value); setDropdownOpen(true); }}
onFocus={() => { if (userQuery.length >= 2) setDropdownOpen(true); }}
/>
{dropdownOpen && userQuery.trim().length >= 2 && (
<div className="absolute z-30 top-full left-0 right-0 mt-1 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden">
{matchedUsers.length > 0 ? (
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">
{matchedUsers.length} match{matchedUsers.length !== 1 ? "es" : ""} click to auto-fill
</div>
{matchedUsers.map(u => (
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
{u.phoneNumber && <span>· {u.phoneNumber}</span>}
</div>
</button>
))}
</>
) : (
<div className="px-3 py-3 text-sm text-gray-500 italic">
No matching users found fill in details below manually.
</div>
)}
</div>
)}
</div>
<div className="border-t pt-3 mb-3">
<div className="flex items-center gap-2">
<input id="registerAsGuest" type="checkbox" checked={registerAsGuest} onChange={e => setRegisterAsGuest(e.target.checked)} />
<label htmlFor="registerAsGuest" className="text-sm text-gray-700">Guest (do not link to an existing account)</label>
</div>
</div>
<div className="grid gap-2">
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Full name"
value={guest.name}
onChange={e => setGuest({ ...guest, name: e.target.value })}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
type="email"
value={guest.email}
onChange={e => setGuest({ ...guest, email: e.target.value })}
required={!registerAsGuest}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Phone (optional)"
value={guest.phoneNumber}
onChange={e => {
const v = e.target.value;
setGuest({ ...guest, phoneNumber: v });
if (v.trim() && !guest.email.trim()) setNotifPref("whatsapp");
else if (!v.trim() && guest.email.trim()) setNotifPref("email");
}}
/>
{/* Preference selector */}
{(() => {
const hasEmail = !!guest.email.trim();
const hasPhone = !!guest.phoneNumber.trim();
if (!hasEmail && !hasPhone) return null;
if (hasEmail && !hasPhone) return (
<p className="text-xs text-gray-500">Tickets will be sent via <strong>email</strong>.</p>
);
if (hasPhone && !hasEmail) return (
<p className="text-xs text-gray-500">Tickets will be sent via <strong>WhatsApp</strong>.</p>
);
return (
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Send tickets via</label>
<div className="flex rounded-lg border overflow-hidden text-xs font-medium">
{(["email", "whatsapp", "both"] as const).map((p) => (
<button
key={p}
type="button"
onClick={() => setNotifPref(p)}
className={`flex-1 py-2 transition-colors ${
notifPref === p
? p === "whatsapp" ? "bg-green-600 text-white border-green-600"
: p === "both" ? "bg-indigo-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
{p === "email" ? "Email" : p === "whatsapp" ? "WhatsApp" : "Both"}
</button>
))}
</div>
</div>
);
})()}
{guest.name && (
<button
type="button"
onClick={() => { setGuest({ name: "", email: "", phoneNumber: "" }); setUserQuery(""); setNotifPref("email"); }}
className="text-xs text-gray-400 hover:text-gray-600 text-left"
>
Clear
</button>
)}
</div>
</div>
</div>
<div className="mt-6 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">3) Select ticket options</div>
{options.length === 0 ? (
<div className="text-sm text-gray-500">Select an event to view options.</div>
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{options.map(opt => {
const hasVariants = (opt.variants || []).length > 0;
if (hasVariants) {
return (
<div key={opt.id} className="border rounded overflow-hidden col-span-full sm:col-span-1">
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-blue-600 font-normal"> Main</span> : null}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
{fmtPrice(unit)}
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird, was {fmtPrice(basePrice)})</span>}
</div>
</div>
<div className="flex items-center gap-2">
<label className="text-xs text-gray-500">Qty</label>
<input
type="number" min={0}
className="w-16 border rounded px-2 py-1 text-sm"
value={quantities[key] || 0}
onChange={e => setQuantities(q => ({ ...q, [key]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
</div>
);
})}
</div>
);
}
const unit = effectiveOptionUnit(opt);
return (
<div key={opt.id} className="border rounded p-3">
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500">
{fmtPrice(unit)}
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was {fmtPrice(opt.price)})</span>}
{opt.isMainTicket ? " • Main" : ""}
</div>
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-gray-600">Qty</label>
<input
type="number" min={0}
className="w-20 border rounded px-2 py-1 text-sm"
value={quantities[opt.id] || 0}
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
</div>
);
})}
</div>
)}
</div>
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</div>
);
}
@@ -0,0 +1,215 @@
"use client";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
export default function SupervisorDashboardPage() {
const { user, loading, token } = useAuth();
const router = useRouter();
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/supervisor)
// that computes it all server-side — no more separate calls plus a full payments/events
// pull just to reduce them down to a couple of numbers client-side.
// useStableState skips re-renders when a poll returns identical data, and hasLoadedOnce
// below means "Refreshing…" only shows on the very first load — together these stop the
// stats panels from flickering on every 15s poll.
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const loadStats = async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/supervisor", { authToken: token });
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
} catch (e) {
// ignore
} finally {
hasLoadedOnce.current = true;
if (isFirstLoad) setLoadingStats(false);
}
};
useEffect(() => {
if (!token) return;
loadStats();
}, [token]);
// Poll every 15s while the tab is visible; pause in the background and refetch
// immediately on return instead of leaving stale numbers up.
useVisiblePolling(() => {
if (!token) return;
loadStats();
}, 15000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Supervisor Dashboard{user ? `${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
</div>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use these tools.
</div>
)}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/forms") }>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees") }>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees") }>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
</div>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Scanner activity</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{scanStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{scanStats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{scanStats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{scanStats.lastHour}</div>
</div>
</div>
) : (
<div className="text-sm text-gray-500">No scanner data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Supervisor stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,19 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import ReportsV2 from "@/components/reports/ReportsV2";
export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reports</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">View, export, or email operational reports for events.</p>
<ReportsV2 />
</div>
);
}
@@ -0,0 +1,12 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
export default function SectionsRedirectPage() {
const router = useRouter();
useEffect(() => {
router.replace("/dashboard/supervisor/events");
}, [router]);
return null;
}
@@ -0,0 +1,941 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
type UserEntry = { id: string; name: string; phone: string; pref: string };
function toLocalInputValue(d: Date) {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export default function WhatsAppAttendeesPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<WhatsAppAttendeesPageInner />
</Suspense>
);
}
// ─── Attendees dropdown with preference indicators ────────────────────────────
function AttendeesCheckboxDropdown({
attendees,
loading,
selectedIds,
onChange,
channel = "whatsapp",
}: {
attendees: (Attendee | UserEntry)[];
loading: boolean;
selectedIds: string[];
onChange: (ids: string[]) => void;
channel?: "whatsapp" | "email";
}) {
const [open, setOpen] = useState(false);
const allIds = useMemo(() => attendees.map((a) => a.id), [attendees]);
const allSelected = selectedIds.length > 0 && selectedIds.length === allIds.length;
const toggleAll = (checked: boolean) => onChange(checked ? allIds : []);
const toggleId = (id: string) => {
if (selectedIds.includes(id)) onChange(selectedIds.filter((x) => x !== id));
else onChange([...selectedIds, id]);
};
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const prefLabel = (pref: string) => {
if (pref === "both") return "both";
if (pref === "whatsapp") return "WA";
if (pref === "email") return "email";
return pref || "email";
};
const prefColor = (pref: string, match: boolean) =>
match ? "text-green-700 bg-green-50" : "text-amber-700 bg-amber-50";
const summary = loading
? "Loading…"
: attendees.length === 0
? "No attendees"
: allSelected
? `All (${attendees.length})`
: selectedIds.length === 0
? "None selected"
: `${selectedIds.length} selected`;
const mismatched = selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
});
return (
<div className="relative block w-full max-w-xs">
<button
type="button"
className="w-full border rounded px-3 py-2 text-sm bg-white hover:bg-gray-50 text-left"
onClick={() => setOpen((o) => !o)}
>
{summary}
{mismatched.length > 0 && (
<span className="ml-2 text-xs text-amber-600">({mismatched.length} pref mismatch)</span>
)}
</button>
{open && (
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={allSelected} onChange={(e) => toggleAll(e.target.checked)} />
<span className="font-medium">Select all</span>
</label>
<div className="flex gap-1 flex-wrap">
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-green-300 text-green-700 hover:bg-green-50"
onClick={() => onChange(attendees.filter((a) => prefMatch(a.pref)).map((a) => a.id))}
>
Select {channel === "whatsapp" ? "WhatsApp/both" : "Email/both"}
</button>
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-gray-300 text-gray-600 hover:bg-gray-50"
onClick={() => onChange([])}
>
Clear
</button>
</div>
</div>
{loading ? (
<div className="px-3 py-2 text-sm text-gray-500">Loading</div>
) : attendees.length === 0 ? (
<div className="px-3 py-2 text-sm text-gray-500">No attendees with phone numbers</div>
) : (
<ul className="py-1">
{attendees.map((a) => {
const checked = selectedIds.includes(a.id);
const match = prefMatch(a.pref);
return (
<li key={a.id} className={`px-3 py-1 hover:bg-gray-50 ${!match ? "opacity-75" : ""}`}>
<label className="flex items-center gap-2 text-sm min-w-0">
<input type="checkbox" checked={checked} onChange={() => toggleId(a.id)} />
<span className="truncate flex-1">
{a.name ? `${a.name} (${a.phone})` : a.phone}
</span>
<span className={`text-[10px] px-1 rounded shrink-0 ${prefColor(a.pref, match)}`}>
{prefLabel(a.pref)}
</span>
</label>
</li>
);
})}
</ul>
)}
<div className="px-3 py-2 border-t bg-gray-50 text-right">
<button
type="button"
className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-100"
onClick={() => setOpen(false)}
>
Done
</button>
</div>
</div>
)}
</div>
);
}
// ─── Preference warning banner ────────────────────────────────────────────────
function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee|UserEntry)[]; selectedIds: string[]; channel: "whatsapp" | "email" }) {
const prefMatch = (pref: string) =>
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
const mismatched = useMemo(
() => selectedIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && !prefMatch(a.pref);
}),
[attendees, selectedIds, channel]
);
if (mismatched.length === 0) return null;
return (
<div className="p-3 border rounded bg-amber-50 text-amber-800 text-xs">
<strong>{mismatched.length} selected attendee(s)</strong> have a notification preference that doesn&apos;t include{" "}
{channel === "whatsapp" ? "WhatsApp" : "email"}. They will still receive the message, but it may not be their preferred channel.
{" "}<button
type="button"
className="underline ml-1"
onClick={() => {/* handled by dropdown */}}
>
Use the dropdown to filter by preference.
</button>
</div>
);
}
// ─── WhatsApp Automations Panel ───────────────────────────────────────────────
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
const [autoEventId, setAutoEventId] = React.useState<string>("");
const currentEvent = React.useMemo(() => (events || []).find((e: any) => e.id === autoEventId), [events, autoEventId]);
const preWeekDefault = `Hi {{name}}\n\nJust a friendly reminder that {{event.title}} is one week away!\n\nEvent details: {{event.link}}`;
const finalDefault = `Hi {{name}}\n\nFinal reminder for {{event.title}}.\nPlease have your QR code/ticket ready at entry.\n\nEvent details: {{event.link}}`;
const thanksDefault = `Hi {{name}}\n\nThank you for joining us at {{event.title}}!\nWe hope you had a great time. See you next time!`;
const promoDefault = `Hi {{name}}\n\nWe'd love to see you at our next event: {{promo.title}}.\nFind out more and register here: {{promo.link}}`;
const [enablePre, setEnablePre] = React.useState(true);
const [enableFinal, setEnableFinal] = React.useState(true);
const [enableThanks, setEnableThanks] = React.useState(true);
const [enablePromo, setEnablePromo] = React.useState(false);
const [preBody, setPreBody] = React.useState(preWeekDefault);
const [finalBody, setFinalBody] = React.useState(finalDefault);
const [thanksBody, setThanksBody] = React.useState(thanksDefault);
const [promoBody, setPromoBody] = React.useState(promoDefault);
const [finalHours, setFinalHours] = React.useState<"24" | "48">("24");
const [preWhen, setPreWhen] = React.useState<string>("");
const [finalWhen, setFinalWhen] = React.useState<string>("");
const [thanksWhen, setThanksWhen] = React.useState<string>("");
const [promoWhen, setPromoWhen] = React.useState<string>("");
const [promoEventId, setPromoEventId] = React.useState<string>("");
React.useEffect(() => {
if (!currentEvent) { setPreWhen(""); setFinalWhen(""); setThanksWhen(""); setPromoWhen(""); return; }
try {
const start = new Date(currentEvent.startDate);
const end = new Date(currentEvent.endDate || currentEvent.startDate);
const pre = new Date(start); pre.setDate(pre.getDate() - 7); pre.setHours(9, 0, 0, 0);
setPreWhen(toLocalInputValue(pre));
const fin = new Date(start); fin.setHours(fin.getHours() - (finalHours === "48" ? 48 : 24));
setFinalWhen(toLocalInputValue(fin));
const ty = new Date(end); ty.setDate(ty.getDate() + 1); ty.setHours(9, 0, 0, 0);
setThanksWhen(toLocalInputValue(ty));
const pr = new Date(end); pr.setDate(pr.getDate() + 3); pr.setHours(9, 0, 0, 0);
setPromoWhen(toLocalInputValue(pr));
} catch {}
}, [currentEvent, finalHours]);
const onSchedule = async () => {
try {
onError(null as any); onInfo(null as any);
if (!token) { onError("Not authenticated"); return; }
if (!autoEventId) { onError("Please select an event"); return; }
const jobs: any[] = [];
if (enablePre && preBody.trim() && preWhen) jobs.push({ message: preBody, scheduledAt: new Date(preWhen).toISOString() });
if (enableFinal && finalBody.trim() && finalWhen) jobs.push({ message: finalBody, scheduledAt: new Date(finalWhen).toISOString() });
if (enableThanks && thanksBody.trim() && thanksWhen) jobs.push({ message: thanksBody, scheduledAt: new Date(thanksWhen).toISOString() });
if (enablePromo && promoBody.trim() && promoWhen) jobs.push({ message: promoBody, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined });
if (jobs.length === 0) { onError("Please enable at least one automation and fill in details"); return; }
let scheduled = 0;
for (const job of jobs) {
await apiFetch(`/api/events/${encodeURIComponent(autoEventId)}/whatsapp-attendees/schedule`, {
method: "POST", authToken: token,
body: { message: job.message, scheduledAt: job.scheduledAt, filter: { status: "paid" } },
});
scheduled++;
}
onInfo(`Scheduled ${scheduled} WhatsApp automation(s).`);
} catch (e: any) {
onError(e?.message || "Failed to schedule automations");
}
};
return (
<div className="grid gap-4">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={autoEventId} onChange={(e) => setAutoEventId(e.target.value)}>
<option value="">Select an event</option>
{(events || []).map((ev: any) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
<div className="grid gap-4">
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Pre-Event Reminder (1 week before)</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enablePre} onChange={(e) => setEnablePre(e.target.checked)} /> Enable
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={preWhen} onChange={(e) => setPreWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={preBody} onChange={(e) => setPreBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Final Reminder (2448 hrs before)</legend>
<div className="flex items-center gap-4 mb-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enableFinal} onChange={(e) => setEnableFinal(e.target.checked)} /> Enable
</label>
<label className="text-xs text-gray-600">Hours before:</label>
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as any)}>
<option value="24">24</option>
<option value="48">48</option>
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={finalWhen} onChange={(e) => setFinalWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={finalBody} onChange={(e) => setFinalBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Thank You / Wrap Up</legend>
<label className="inline-flex items-center gap-2 text-sm mb-2">
<input type="checkbox" checked={enableThanks} onChange={(e) => setEnableThanks(e.target.checked)} /> Enable
</label>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={thanksWhen} onChange={(e) => setThanksWhen(e.target.value)} />
</div>
<label className="block text-xs text-gray-600 mb-1 mt-2">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={thanksBody} onChange={(e) => setThanksBody(e.target.value)} />
</fieldset>
<fieldset className="border rounded p-3">
<legend className="text-sm font-medium">Next Event Promo</legend>
<div className="flex items-center gap-4 mb-2">
<label className="inline-flex items-center gap-2 text-sm">
<input type="checkbox" checked={enablePromo} onChange={(e) => setEnablePromo(e.target.checked)} /> Enable
</label>
</div>
<div className="grid sm:grid-cols-2 gap-3 mb-2">
<div>
<label className="block text-xs text-gray-600 mb-1">Promo Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={promoEventId} onChange={(e) => setPromoEventId(e.target.value)}>
<option value="">Select event to promote</option>
{(events || []).map((ev: any) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={promoWhen} onChange={(e) => setPromoWhen(e.target.value)} />
</div>
</div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={promoBody} onChange={(e) => setPromoBody(e.target.value)} />
</fieldset>
</div>
<div className="flex items-center gap-2">
<button type="button" className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700" onClick={onSchedule}>
Schedule selected
</button>
<div className="text-[11px] text-gray-500">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{event.link}}"}, {"{{promo.title}}"}, {"{{promo.link}}"}
</div>
</div>
</div>
);
}
// ─── Main Page ────────────────────────────────────────────────────────────────
function WhatsAppAttendeesPageInner() {
const { user, loading, token } = useAuth();
const router = useRouter();
const search = useSearchParams();
const preselectEventId = search?.get("eventId") || "";
const canView = useMemo(() => {
const role = user?.role;
return role === "admin" || role === "supervisor";
}, [user]);
useEffect(() => {
if (loading) return;
if (!user) router.replace("/login");
}, [user, loading, router]);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
const [loadingEvents, setLoadingEvents] = useState(false);
const [allEvents, setAllEvents] = useState<any[]>([]);
const [evIncludePast, setEvIncludePast] = useState(false);
const events = useMemo(() => {
const now = Date.now();
if (evIncludePast) return allEvents;
return allEvents.filter((ev) => !isNaN(new Date(ev.endDate).getTime()) && new Date(ev.endDate).getTime() > now);
}, [allEvents, evIncludePast]);
useEffect(() => {
const loadEvents = async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setAllEvents((evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
} catch (e: any) {
setError(e?.message || "Failed to load events");
} finally {
setLoadingEvents(false);
}
};
loadEvents();
}, [user, token]);
// ── Attendees tab ──────────────────────────────────────────────────────────
const [eventId, setEventId] = useState<string>(preselectEventId);
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<"custom" | "payment_reminder" | "event_reminder" | "tickets">("custom");
const [message, setMessage] = useState("");
const [messageDirty, setMessageDirty] = useState(false);
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
const [attendees, setAttendees] = useState<Attendee[]>([]);
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
const [loadingAttendees, setLoadingAttendees] = useState(false);
const [previewCount, setPreviewCount] = useState<number | null>(null);
const [previewSample, setPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [sending, setSending] = useState(false);
const [scheduledAtLocal, setScheduledAtLocal] = useState<string>("");
const [showInfo, setShowInfo] = useState(false);
const currentEvent = useMemo(() => (events || []).find((e) => e.id === eventId), [events, eventId]);
useEffect(() => {
const title = currentEvent?.title || "the event";
if (templateKey === "payment_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nFriendly reminder: you have an outstanding balance for ${title}.\n\nPlease settle your balance to secure your tickets. Thank you!`);
} else if (templateKey === "event_reminder") {
if (!messageDirty) setMessage(`Hi {{name}}\n\nA quick reminder about ${title}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`);
} else if (templateKey === "tickets") {
setMessageDirty(false); setMessage("");
}
}, [templateKey, currentEvent]);
useEffect(() => {
const run = async () => {
try {
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
if (!eventId || !token) return;
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
const uniq = new Map<string, Attendee>();
(regs || []).forEach((r: any) => {
const u = r?.user;
if (u?.id && u?.phoneNumber && !u.email?.endsWith("@guest.local") && u.isActive !== false) {
uniq.set(u.id, { id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" });
}
});
const list = Array.from(uniq.values()).sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setAttendees(list);
// Default: select those with whatsapp/both preference
const matching = list.filter((a) => a.pref === "whatsapp" || a.pref === "both").map((a) => a.id);
setSelectedAttendeeIds(matching.length > 0 ? matching : list.map((a) => a.id));
} catch { } finally { setLoadingAttendees(false); }
};
run();
}, [eventId, token]);
const buildPayload = (dryRun?: boolean) => {
const payload: any = {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey,
dryRun: dryRun || undefined,
};
if (templateKey !== "tickets") payload.message = message;
return payload;
};
const onPreview = async () => {
try {
setError(null); setInfo(null); setPreviewCount(null); setPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s) with a phone number.`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
};
const resetAttendeesForm = () => {
setTemplateKey("custom"); setMessage(""); setMessageDirty(false);
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
setSelectedAttendeeIds(attendees.map((a) => a.id));
};
const onSend = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
};
const onScheduleSend = async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!eventId) { setError("Please select an event"); return; }
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: any = {
message: message || undefined,
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined },
template: templateKey, scheduledAt: whenIso,
};
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to schedule"); }
};
// ── Broadcasts tab ─────────────────────────────────────────────────────────
const [users, setUsers] = useState<UserEntry[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [broadcastEventId, setBroadcastEventId] = useState<string>("");
const [broadcastMessage, setBroadcastMessage] = useState("");
const [broadcastPhones, setBroadcastPhones] = useState("");
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number | null>(null);
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
useEffect(() => {
const run = async () => {
try {
if (!token) return;
setLoadingUsers(true);
const allUsers = await fetchAllUsers(token);
const mapped = allUsers
.filter((u: any) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
.map((u: any) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber, pref: u.notificationPreference || "email" }))
.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
setUsers(mapped);
} catch { } finally { setLoadingUsers(false); }
};
run();
}, [token]);
const resetBroadcastForm = () => {
setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastMessage("");
setBroadcastPhones(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
setBroadcastScheduledAtLocal("");
};
// ── Scheduled tab ──────────────────────────────────────────────────────────
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null);
const [editMessage, setEditMessage] = useState<string>("");
const [editWhen, setEditWhen] = useState<string>("");
const [savingEdit, setSavingEdit] = useState(false);
const loadScheduled = async () => {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token });
// Filter to only WhatsApp jobs
const all = Array.isArray(res?.jobs) ? res.jobs : [];
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
} catch { } finally { setLoadingScheduled(false); }
};
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, token]);
const saveEdit = async () => {
if (!editing) return;
try {
setSavingEdit(true);
if (!token) { setError("Not authenticated"); return; }
const body: any = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.text = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to update"); } finally { setSavingEdit(false); }
};
const removeJob = async (job: ScheduledJob) => {
try {
if (!token) { setError("Not authenticated"); return; }
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: "DELETE", authToken: token });
setInfo("Scheduled message removed.");
loadScheduled();
} catch (e: any) { setError(e?.message || "Failed to remove"); }
};
// ── Render ─────────────────────────────────────────────────────────────────
const mismatchedAttendees = useMemo(
() => selectedAttendeeIds.filter((id) => {
const a = attendees.find((x) => x.id === id);
return a && a.pref !== "whatsapp" && a.pref !== "both";
}),
[attendees, selectedAttendeeIds]
);
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">WhatsApp Attendees</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
Back
</button>
</div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to use this page.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
{/* Tabs */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => (
<label key={t} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
{t.charAt(0).toUpperCase() + t.slice(1)}
</label>
))}
</div>
{/* ── Attendees Tab ── */}
{tab === "attendees" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<div className="flex gap-2 items-center">
<select className="border rounded px-3 py-2 text-sm flex-1 min-w-0" value={eventId} onChange={(e) => setEventId(e.target.value)}>
<option value="">Select an event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}{ev.endDate && new Date(ev.endDate) < new Date() ? " (past)" : ""}</option>
))}
</select>
{loadingEvents && <span className="text-xs text-gray-500">Loading</span>}
</div>
<label className="flex items-center gap-1.5 text-xs text-gray-500 mt-1 cursor-pointer">
<input type="checkbox" checked={evIncludePast} onChange={(e) => setEvIncludePast(e.target.checked)} />
Include past events
</label>
</div>
<div className="grid sm:grid-cols-3 gap-3">
<div>
<div className="flex items-center justify-between">
<label className="block text-xs text-gray-600 mb-1">Template</label>
<button type="button" className="text-[11px] text-gray-600 hover:text-gray-900 inline-flex items-center gap-1" onClick={() => setShowInfo(true)}>
<span className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-gray-300 text-[10px]">i</span>
Info
</button>
</div>
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as any)}>
<option value="custom">Custom message</option>
<option value="payment_reminder">Payment reminder</option>
<option value="event_reminder">Event reminder</option>
<option value="tickets">Send ticket PDFs</option>
</select>
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}
</div>
</div>
</div>
{showInfo && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Dynamic parameters</h3>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setShowInfo(false)}>Close</button>
</div>
<div className="text-[12px] text-gray-700 break-words">
<p className="mb-2">Personalize your message per recipient using these placeholders.</p>
<ul className="list-disc pl-5 space-y-1 mb-2">
<li><code>{"{{name}}"}</code> attendee&apos;s name.</li>
<li><code>{"{{event.title}}"}</code> the event title.</li>
<li><code>{"{{event.start}}"}</code> the event start date/time.</li>
<li><code>{"{{balance}}"}</code> outstanding balance for the event.</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
<span className="text-green-700 bg-green-50 px-1 rounded">both</span> = WhatsApp + Email,{" "}
<span className="text-amber-700 bg-amber-50 px-1 rounded">email</span> = Email only (will still receive message).
</p>
</div>
</div>
</div>
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Payment status filter</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as any)}>
<option value="any">Any</option>
<option value="paid">Paid</option>
<option value="unpaid">Unpaid (pending or partial)</option>
<option value="partial_paid">Partial paid</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
{templateKey !== "tickets" ? (
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={message}
onChange={(e) => { setMessage(e.target.value); setMessageDirty(true); }}
placeholder="Write your WhatsApp message to attendees..." />
</div>
) : (
<div className="p-3 border rounded bg-gray-50 text-sm text-gray-700">
This will send each selected attendee their ticket PDF(s) for this event via WhatsApp.
</div>
)}
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Attendees</label>
<AttendeesCheckboxDropdown attendees={attendees} loading={loadingAttendees} selectedIds={selectedAttendeeIds} onChange={setSelectedAttendeeIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mt-1">Attendees with WhatsApp/both preference are pre-selected.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={scheduledAtLocal} onChange={(e) => setScheduledAtLocal(e.target.value)} />
<div className="text-[10px] text-gray-500 mt-1">Leave empty to send immediately.</div>
</div>
</div>
{mismatchedAttendees.length > 0 && (
<div className="p-3 border rounded bg-amber-50 text-amber-800 text-xs">
<strong>{mismatchedAttendees.length} selected attendee(s)</strong> prefer email only they will still receive this message but it&apos;s not their preferred channel.
</div>
)}
<div className="flex items-center flex-wrap gap-2">
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={onPreview}>
Preview recipients
</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={onSend}>
{sending ? "Sending…" : "Send now"}
</button>
<button type="button" disabled={sending || !scheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={onScheduleSend}>
Schedule send
</button>
{previewCount != null && <span className="text-xs text-gray-600">Preview: {previewCount} recipient(s)</span>}
</div>
{previewSample && previewSample.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-600 mb-1">First {previewSample.length} recipient(s):</div>
<ul className="text-xs text-gray-800 list-disc pl-5 space-y-0.5">
{previewSample.map((r, idx) => (
<li key={idx}>{r.name ? `${r.name} (${r.phone})` : r.phone}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* ── Automations Tab ── */}
{tab === "automations" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<WAAutomationsPanel events={events} token={token} onInfo={setInfo} onError={setError} />
</div>
)}
{/* ── Broadcasts Tab ── */}
{tab === "broadcasts" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="grid gap-3">
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Users (optional)</label>
<AttendeesCheckboxDropdown attendees={users} loading={loadingUsers} selectedIds={selectedUserIds} onChange={setSelectedUserIds} channel="whatsapp" />
<div className="text-[11px] text-gray-500 mb-1">Only users with phone numbers shown. Green = WhatsApp/both preference.</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event (optional for placeholders)</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={broadcastEventId} onChange={(e) => setBroadcastEventId(e.target.value)}>
<option value="">No event</option>
{events.map((ev) => (
<option key={ev.id} value={ev.id}>{ev.title}</option>
))}
</select>
</div>
</div>
{selectedUserIds.length > 0 && (
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="whatsapp" />
)}
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={broadcastMessage}
onChange={(e) => setBroadcastMessage(e.target.value)}
placeholder="Write your message... Use {{name}}, {{event.title}}, {{event.start}}, {{event.link}}" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Additional phone numbers (one per line)</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={4} value={broadcastPhones}
onChange={(e) => setBroadcastPhones(e.target.value)}
placeholder={`0821234567\nJane Doe <0721234567>\n27831234567`} />
<div className="text-[11px] text-gray-500 mt-1">Formats: 0821234567 or Name &lt;0821234567&gt;</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at (optional)</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={broadcastScheduledAtLocal} onChange={(e) => setBroadcastScheduledAtLocal(e.target.value)} />
<div className="text-[10px] text-gray-500 mt-1">Leave empty to send immediately.</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={async () => {
try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
} catch (e: any) { setError(e?.message || "Failed to preview"); }
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to send"); }
}}>Send now</button>
<button type="button" disabled={!broadcastScheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } });
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm();
} catch (e: any) { setError(e?.message || "Failed to schedule broadcast"); }
}}>Schedule send</button>
{broadcastPreviewCount != null && <span className="text-xs text-gray-600">Preview: {broadcastPreviewCount} recipient(s)</span>}
</div>
{broadcastPreviewSample && broadcastPreviewSample.length > 0 && (
<div className="mt-2">
<div className="text-xs text-gray-600 mb-1">First {broadcastPreviewSample.length} recipient(s):</div>
<ul className="text-xs text-gray-800 list-disc pl-5 space-y-0.5">
{broadcastPreviewSample.map((r, idx) => (
<li key={idx}>{r.name ? `${r.name} (${r.phone})` : r.phone}</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{/* ── Scheduled Tab ── */}
{tab === "scheduled" && (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-medium">Scheduled WhatsApp Messages</h2>
<button type="button" className="text-sm px-2 py-1 rounded border bg-white hover:bg-gray-50" onClick={loadScheduled}>Refresh</button>
</div>
{loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div>
) : (
<ul className="divide-y border rounded">
{scheduled.map((job) => (
<li key={job.id} className="p-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span>
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
{job.sentAt && <span>Sent: {(() => { try { return new Date(job.sentAt!).toLocaleString(); } catch { return job.sentAt; } })()}</span>}
{job.lastError && <span className="ml-2 text-red-600">Error: {job.lastError}</span>}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
Edit
</button>
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
Remove
</button>
</div>
</li>
))}
</ul>
)}
{editing && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setEditing(null)} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold">Edit scheduled WhatsApp message</h3>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => setEditing(null)}>Close</button>
</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={6} value={editMessage} onChange={(e) => setEditMessage(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Send at</label>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={editWhen} onChange={(e) => setEditWhen(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-green-600 text-white hover:bg-green-700 disabled:opacity-50" onClick={saveEdit}>
{savingEdit ? "Saving…" : "Save changes"}
</button>
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,87 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
export default function DonatePage() {
const { token } = useAuth();
const [events, setEvents] = useState<any[]>([]);
const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
const evs = await apiFetch<any[]>("/api/events");
setEvents(evs);
if (evs.length > 0) setEventId(evs[0].id);
} catch (e: any) {
setError(e?.message || "Failed to load events");
}
})();
}, []);
const submit = async () => {
if (!token) { setError("Please login"); return; }
const amt = parseFloat(amount);
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
if (amt < 15) { setError("Minimum donation is R15"); return; }
if (!eventId) { setError("Select an event"); return; }
try {
setError(null);
setInfo(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
eventId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl;
} catch (e: any) {
setError(e?.message || "Failed to start donation checkout");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
<label className="block text-sm font-medium mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded px-3 py-2 mb-1"
/>
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
<button
disabled={loading}
onClick={submit}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Starting checkout...":"Donate"}</button>
</div>
);
}
@@ -0,0 +1,235 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function FormsContent() {
const search = useSearchParams();
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
const [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
// Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
useEffect(() => {
(async () => {
if (!token || !registrationId) return;
setLoading(true);
setError(null);
setInfo(null);
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
if (ev?.form) setEventForm(ev.form);
// Load any saved draft
try {
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token });
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
} catch {}
} catch (e: any) {
setError(e?.message || 'Failed to load registration');
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
const submittedCount = useMemo(() => {
return (registration?.formResponses || []).length;
}, [registration]);
const requiredCount = useMemo(() => {
if (!registration) return 0;
try {
return (registration.registrationOptions || [])
.filter((ro: any) => ro.eventOption?.isMainTicket)
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
} catch {
return 0;
}
}, [registration]);
const remaining = Math.max(0, requiredCount - submittedCount);
// Determine if all required fields are filled for each attendee form to be submitted
const canSubmit = useMemo(() => {
if (!eventForm || remaining <= 0) return false;
const reqFields = (eventForm.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < remaining; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [eventForm, formsData, remaining]);
const submit = async () => {
if (!token || !registrationId) return;
try {
setError(null); setInfo(null);
const payload: any[] = [];
for (let i = 0; i < remaining; i++) {
payload.push({ answers: formsData[i] || {} });
}
if (payload.length === 0) {
setInfo('No remaining attendee forms to submit.');
return;
}
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
method: 'POST',
authToken: token,
body: { responses: payload }
});
setInfo('Attendee forms submitted successfully.');
// reload registration to reflect new responses
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
setFormsData({});
// On success, redirect to the user dashbaord
router.push('/dashboard/user');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
}
};
const saveDraft = async () => {
if (!token || !registrationId) return;
try {
setError(null); setInfo(null);
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT',
authToken: token,
body: { data: formsData }
});
setInfo('Draft saved. You can return later to finish.');
} catch (e: any) {
setError(e?.message || 'Failed to save draft');
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Attendee forms</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
</div>
{loading && <div className="text-sm text-gray-600 mb-2">Loading</div>}
{error && <div className="text-sm text-red-600 mb-2">{error}</div>}
{info && <div className="text-sm text-emerald-700 mb-2">{info}</div>}
{registration && (
<div className="border rounded p-3 mb-4 bg-white">
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
<div className="text-xs text-gray-600">Registration #{registration.id.slice(0,8)}</div>
<div className="text-sm mt-1">Forms submitted: {submittedCount} / {requiredCount}</div>
</div>
)}
{/* Existing responses (read-only list) */}
{registration && (registration.formResponses || []).length > 0 && (
<div className="border rounded p-3 mb-4 bg-gray-50">
<div className="text-sm font-medium mb-2">Submitted responses</div>
<ul className="space-y-2">
{(registration.formResponses || []).map((resp: any, idx: number) => (
<li key={resp.id} className="bg-white border rounded p-2">
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
{(resp.answers || []).length === 0 ? (
<div className="text-xs text-gray-600">No answers recorded.</div>
) : (
<ul className="text-xs list-disc pl-5 space-y-0.5">
{resp.answers.map((a: any) => (
<li key={a.id}>
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
{/* Remaining forms to fill */}
{eventForm && remaining > 0 && (
<div className="border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
<div className="space-y-4">
{Array.from({ length: remaining }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</div>
{eventForm.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
)}
{eventForm && remaining === 0 && (
<div className="text-sm text-emerald-700">All required attendee forms are completed for this registration.</div>
)}
</div>
);
}
export default function FormsPage() {
return (
<Suspense fallback={<div className="p-6">Loading</div>}>
<FormsContent />
</Suspense>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,207 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
function MakePaymentContent() {
const searchParams = useSearchParams();
const registrationId = searchParams.get("registrationId");
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
const [registration, setRegistration] = useState<any | null>(null);
const [payments, setPayments] = useState<any[]>([]);
const [amount, setAmount] = useState<string>("");
const [priceUpdated, setPriceUpdated] = useState<{ newTotal: number; message: string } | null>(null);
// Compute effective unit price for a registration option.
// Uses priceSnapshot when available (authoritative backend price, variant-aware).
// Falls back to deadline-only early-bird calculation for legacy rows without a snapshot.
const optionUnitPrice = (opt: any, referenceTime: any, atTime: Date): number => {
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
return Number(opt.priceSnapshot);
}
const eo = opt.eventOption;
const variantId: string | null = opt.variantId || null;
const base = (opt.variant?.price !== null && opt.variant?.price !== undefined)
? Number(opt.variant.price)
: Number(eo?.price || 0);
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers.slice() : [];
const tiers = variantId
? allTiers.filter((t: any) => t.variantId === variantId)
: allTiers.filter((t: any) => !t.variantId);
if (tiers.length === 0) return base;
const t = atTime ? new Date(atTime) : new Date();
const ref = referenceTime ? new Date(referenceTime) : t;
const applicable = tiers
.map((x: any) => ({ ...x, deadline: new Date(x.deadline) }))
.filter((x: any) => (ref < x.deadline) && (t < x.deadline))
.sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime() || (a.order||0) - (b.order||0) || a.price - b.price);
if (applicable.length === 0) return base;
const price = Number(applicable[0].price);
return (price >= 0) ? price : base;
};
const totals = useMemo(() => {
if (!registration) return { totalDue: 0, totalPaid: 0, outstanding: 0 };
const now = new Date();
// totalDue uses priceSnapshot — not time-dependent, consistent with what the backend charges
const totalDue = (registration.registrationOptions || []).reduce((s: number, opt: any) => s + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = payments.reduce((s: number, p: any) => s + (p.amount || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
return { totalDue, totalPaid, outstanding };
}, [registration, payments]);
const minAllowed = useMemo(() => {
if (totals.outstanding <= 0) return 0;
return totals.outstanding >= 15 ? 15 : totals.outstanding;
}, [totals.outstanding]);
useEffect(() => {
(async () => {
if (!token || !registrationId) return;
try {
setError(null);
setLoading(true);
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch (e: any) {
setError(e?.message || "Failed to load registration");
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
useEffect(() => {
if (totals.outstanding <= 0) {
setAmount("");
}
}, [totals.outstanding]);
const submit = async () => {
if (!token || !registrationId) return;
const amt = parseFloat(amount);
if (!(amt > 0)) { setError("Enter a valid amount"); return; }
if (amt > totals.outstanding) { setError(`Amount cannot exceed outstanding (R ${totals.outstanding.toFixed(2)})`); return; }
if (amt < minAllowed) {
if (totals.outstanding >= 15) {
setError("Minimum payment is R15");
} else {
setError(`Please pay the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`);
}
return;
}
try {
setError(null);
setInfo(null);
setPriceUpdated(null);
setLoading(true);
const res = await apiFetch<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
registrationId,
amount: amt,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
if (res.priceUpdated) {
// Early-bird price changed — show warning and reload registration data
setPriceUpdated({ newTotal: res.newTotal ?? 0, message: res.message ?? 'Prices have changed.' });
// Reload registration so totals reflect updated priceSnapshot
try {
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
setRegistration(reg);
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
setPayments(pays);
} catch {}
setAmount("");
return;
}
setInfo("Redirecting to payment...");
window.location.href = res.redirectUrl!;
} catch (e: any) {
setError(e?.message || "Failed to create checkout");
} finally {
setLoading(false);
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a payment</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{priceUpdated && (
<div className="mb-4 p-3 border border-amber-300 bg-amber-50 rounded text-sm text-amber-800">
<strong>Pricing has changed.</strong> {priceUpdated.message}
<div className="mt-1">New outstanding: <strong>R {priceUpdated.newTotal.toFixed(2)}</strong></div>
<button
className="mt-2 text-xs underline text-amber-700 hover:text-amber-900"
onClick={() => setPriceUpdated(null)}
>OK, I understand continue with new price</button>
</div>
)}
{registration ? (
<div className="border rounded p-3 mb-4">
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
<div className="text-sm text-gray-600">Registration #{registration.id.slice(0,8)}</div>
<div className="mt-2 text-sm">
<div>Total: R {totals.totalDue.toFixed(2)}</div>
<div>Paid: R {totals.totalPaid.toFixed(2)}</div>
<div>Outstanding: <span className={totals.outstanding>0?"text-red-600":"text-green-700"}>R {totals.outstanding.toFixed(2)}</span></div>
</div>
{(() => {
const tiers = (registration.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
const upcoming = tiers.map((t: any) => ({ ...t, deadline: new Date(t.deadline) })).filter((t: any) => new Date() < t.deadline).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
if (upcoming.length === 0) return null;
const d = upcoming[0].deadline as Date;
const dateStr = d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
return <div className="mt-2 text-xs text-amber-700">Early bird pricing applies if paid before {dateStr}.</div>;
})()}
</div>
) : (
<p className="text-sm text-gray-600 mb-4">Loading registration...</p>
)}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min={minAllowed.toFixed(2)}
max={totals.outstanding > 0 ? totals.outstanding.toFixed(2) : "0"}
step="1"
placeholder={totals.outstanding >= 15 ? "Enter amount (min R15)" : `Enter amount (max R ${totals.outstanding.toFixed(2)})`}
value={amount}
onChange={(e) => setAmount(e.target.value)}
disabled={totals.outstanding <= 0}
className="w-full border rounded px-3 py-2 mb-1 disabled:opacity-60"
/>
{totals.outstanding > 0 && (
<p className="text-xs text-gray-600 mb-3">Minimum payment {totals.outstanding >= 15 ? "is R15" : `is the remaining outstanding amount (R ${totals.outstanding.toFixed(2)})`}.</p>
)}
<button
disabled={loading || totals.outstanding <= 0}
onClick={submit}
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Creating checkout...": totals.outstanding <= 0 ? "No outstanding amount" : "Pay now"}</button>
</div>
);
}
export default function MakePaymentPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<MakePaymentContent />
</Suspense>
);
}
@@ -0,0 +1,365 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
const router = useRouter();
useEffect(() => {
if (!user && !token) router.replace("/login");
}, [user, token, router]);
// ── Profile form ─────────────────────────────────────────────────────────
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [phone, setPhone] = useState("");
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
const [profileMsg, setProfileMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingProfile, setSavingProfile] = useState(false);
const hasValidPhone = isValidZAPhone(phone);
useEffect(() => {
if (user) {
setName(user.name || "");
setEmail(user.email || "");
setPhone(user.phoneNumber || "");
setNotifPref(user.notificationPreference || "email");
}
}, [user]);
const saveProfile = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setSavingProfile(true);
setProfileMsg(null);
try {
const res = await apiFetch<any>("/api/users/profile", {
method: "PUT",
authToken: token,
body: {
name,
email,
phoneNumber: phone || null,
notificationPreference: hasValidPhone ? notifPref : "email",
},
});
// Backend returns a refreshed token — save it so the session stays valid
if (res?.token) updateToken(res.token);
setProfileMsg({ type: "ok", text: "Profile updated." });
} catch (e: any) {
setProfileMsg({ type: "err", text: e?.message || "Failed to update profile." });
} finally {
setSavingProfile(false);
}
};
// ── Password change ───────────────────────────────────────────────────────
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [pwMsg, setPwMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [savingPw, setSavingPw] = useState(false);
const changePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPassword !== confirmPassword) {
setPwMsg({ type: "err", text: "Passwords do not match." });
return;
}
if (newPassword.length < 8) {
setPwMsg({ type: "err", text: "Password must be at least 8 characters." });
return;
}
if (!token) return;
setSavingPw(true);
setPwMsg(null);
try {
const res = await apiFetch<any>("/api/users/profile", {
method: "PUT",
authToken: token,
body: { currentPassword, password: newPassword },
});
if (res?.token) updateToken(res.token);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setPwMsg({ type: "ok", text: "Password changed." });
} catch (e: any) {
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
} finally {
setSavingPw(false);
}
};
// ── Revoke sessions ───────────────────────────────────────────────────────
const [revokeMsg, setRevokeMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [revoking, setRevoking] = useState(false);
const revokeSessions = async () => {
if (!confirm("This will sign you out of all other devices. You will need to log in again everywhere except here. Continue?")) return;
if (!token) return;
setRevoking(true);
setRevokeMsg(null);
try {
const res = await apiFetch<any>("/api/users/revoke-sessions", {
method: "POST",
authToken: token,
});
// Revoking sessions invalidates the token this device was using too —
// save the freshly issued one so this device stays signed in.
if (res?.token) updateToken(res.token);
setRevokeMsg({ type: "ok", text: res?.message || "All other sessions signed out." });
} catch (e: any) {
setRevokeMsg({ type: "err", text: e?.message || "Failed to revoke sessions." });
} finally {
setRevoking(false);
}
};
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
const [closePassword, setClosePassword] = useState("");
const [closeMsg, setCloseMsg] = useState<{ type: "ok" | "err"; text: string } | null>(null);
const [closing, setClosing] = useState(false);
const submitAccountClosure = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setClosing(true);
setCloseMsg(null);
try {
const res = await apiFetch<any>("/api/users/close-account", {
method: "POST",
authToken: token,
body: { password: closePassword, deleteData },
});
setCloseMsg({ type: "ok", text: res?.message || "Account closed." });
// Slight delay so the user can read the message, then log out
setTimeout(() => logout(), 2500);
} catch (e: any) {
setCloseMsg({ type: "err", text: e?.message || "Failed to close account." });
} finally {
setClosing(false);
}
};
// ── Shared helpers ────────────────────────────────────────────────────────
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{msg.text}
</div>
);
return (
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
<h1 className="text-2xl font-semibold">Profile &amp; Security</h1>
{/* ── Profile info ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="e.g. 082 123 4567"
/>
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={notifPref}
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
<button
type="submit"
disabled={savingProfile}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingProfile ? "Saving…" : "Save changes"}
</button>
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Password ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Change password</h2>
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={savingPw}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingPw ? "Changing…" : "Change password"}
</button>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-1">Security</h2>
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
</div>
);
}
@@ -0,0 +1,110 @@
"use client";
import React, { useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useRouter } from "next/navigation";
export default function ResetPasswordPage() {
const { token } = useAuth();
const router = useRouter();
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => current.length > 0 && password.length >= 8 && password === confirm, [current, password, confirm]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setStatus(null);
setError(null);
if (!token) {
setError("You must be logged in to change your password.");
return;
}
if (!canSubmit) return;
try {
setLoading(true);
await apiFetch("/api/users/profile", { method: "PUT", authToken: token, body: { password, currentPassword: current } });
setStatus("Your password has been updated successfully.");
setCurrent("");
setPassword("");
setConfirm("");
setTimeout(() => router.push("/dashboard/user"), 1200);
} catch (err: any) {
setError(err?.message || "Failed to update password");
} finally {
setLoading(false);
}
};
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reset password</h1>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{status && <p className="text-green-700 text-sm mb-3">{status}</p>}
<div className="border rounded-xl p-5 bg-white shadow-sm">
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Current password</label>
<input
type="password"
value={current}
onChange={e => setCurrent(e.target.value)}
required
className="w-full border rounded px-3 py-2"
placeholder="Enter your current password"
/>
<p className="text-xs text-gray-500 mt-1">For your security, please confirm your current password.</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">New password</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
required
className="w-full border rounded px-3 py-2"
placeholder="At least 8 characters"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm new password</label>
<input
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
required
className="w-full border rounded px-3 py-2"
/>
{password && confirm && password !== confirm && (
<p className="text-xs text-red-600 mt-1">Passwords do not match.</p>
)}
</div>
<div className="flex items-center gap-2">
<button
type="submit"
disabled={!canSubmit || loading}
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
>{loading ? "Saving…" : "Update password"}</button>
<button
type="button"
className="px-3 py-2 rounded bg-gray-100 hover:bg-gray-200 text-gray-800"
onClick={() => router.push("/dashboard/user")}
>Cancel</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,48 @@
"use client";
import { Share2, QrCode } from "lucide-react";
import QRCode from "qrcode";
export default function ClientActions({ event }: { event: any }) {
return (
<div className="flex gap-2">
<button
onClick={async () => {
try {
await navigator.share({
title: event.title,
text: event.description,
url: window.location.href,
});
} catch (err) {
console.error("Error sharing:", err);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
>
<Share2 className="w-4 h-4" />
Share
</button>
<button
onClick={async () => {
try {
const url = window.location.href;
const qrDataUrl = await QRCode.toDataURL(url);
const link = document.createElement("a");
link.href = qrDataUrl;
link.download = `${event.title}-qr.png`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (err) {
console.error("Error generating QR code:", err);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
>
<QrCode className="w-4 h-4" />
Save QR
</button>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions";
export const revalidate = 60;
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number };
type EventOption = {
id: string;
name: string;
price: number;
stockLimit?: number;
availableCount?: number;
isMainTicket?: boolean;
earlyBirdTiers?: { id: string; deadline: string; price: number; stockLimit?: number }[];
variants?: OptionVariant[];
};
type EventAttachment = { id: string; originalName: string; url: string; size: number; mimeType: string };
type Event = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
goLiveAt?: string;
price: number;
picture?: string;
eventOptions?: EventOption[];
attachments?: EventAttachment[];
requiresAuth?: boolean;
};
import { apiFetch, ApiError } from "@/lib/api";
import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
// Tiered low-stock threshold: larger events use a smaller percentage.
// Math.round avoids Math.ceil inflating the threshold (e.g. ceil(1.5)=2 made
// a 10-ticket event warn at 20% when the stated threshold was 15%).
function lowStockThreshold(stockLimit: number): number {
let pct: number;
if (stockLimit <= 50) pct = 0.20;
else if (stockLimit <= 200) pct = 0.15;
else if (stockLimit <= 1000) pct = 0.10;
else pct = 0.05;
return Math.round(stockLimit * pct);
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let event: Event;
try {
event = await apiFetch<Event>(`/api/events/${id}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
// The event endpoint 404s for missing, inactive, or not-yet-live events —
// render the standard not-found page instead of crashing.
if (e instanceof ApiError && e.status === 404) notFound();
throw e;
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-3xl mx-auto w-full">
<div className="space-y-4">
<h1 className="text-3xl font-bold">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateTimeRange(event.startDate, event.endDate)}</p>
<ClientActions event={event} />
{event.picture && (
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded" />
)}
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.attachments && event.attachments.length > 0 && (
<div>
<h2 className="text-xl font-semibold mt-6">Downloads</h2>
<ul className="list-disc pl-5 text-gray-700">
{event.attachments.map((att) => (
<li key={att.id}>
<a className="text-blue-600 hover:underline" href={att.url} target="_blank" rel="noopener noreferrer">
{att.originalName}
</a>
</li>
))}
</ul>
</div>
)}
<h2 className="text-xl font-semibold mt-6">Tickets</h2>
<div className="space-y-2">
{(event.eventOptions || []).map((opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
if (hasVariants) {
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
return (
<div key={opt.id} className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span className="font-medium text-sm">{opt.name}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{priceLabel}</span>
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
</div>
<ul className="divide-y">
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
return (
<li key={v.id} className="flex items-center justify-between px-3 py-2 text-sm text-gray-700">
<span>{v.name}</span>
<div className="flex items-center gap-2">
<span>{vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`}</span>
{vSoldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{vNearlyOut && !vSoldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{v.availableCount} remaining</span>}
</div>
</li>
);
})}
</ul>
</div>
);
}
// Early bird pricing display
const now = new Date();
const activeTiers = (opt.earlyBirdTiers || [])
.filter((t) => now < new Date(t.deadline))
.sort((a, b) => a.price - b.price);
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price;
return (
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
<span className="font-medium">{opt.name}</span>
<div className="flex items-center gap-2">
{activeTiers.length > 0 ? (
<>
<span className="text-indigo-700 font-medium">R{displayPrice.toFixed(2)}</span>
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
<span className="text-xs text-indigo-600">Early bird</span>
</>
) : (
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
)}
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
</div>
);
})}
</div>
{(() => {
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
if (notYetOpen) {
return (
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration not yet open">
Opens {goLive?.toLocaleString()}
</button>
);
}
if (closed) {
return (
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration closed">
Registration closed
</button>
);
}
if (eventSoldOut) {
return (
<button disabled className="inline-block bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
Sold Out
</button>
);
}
return (
<a href={`/register/${event.id}`} className="inline-block bg-blue-600 text-white px-4 py-2 rounded">
Register
</a>
);
})()}
</div>
</main>
<Footer />
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { apiFetch } from "@/lib/api";
export const revalidate = 60;
type Event = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
goLiveAt?: string;
price: number;
picture?: string;
isSoldOut?: boolean;
cashupStatus?: string;
};
export default async function EventsPage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
if (e.cashupStatus === "closed") return false;
const t = new Date(e.startDate).getTime();
return !isNaN(t) && t > now;
});
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
<h1 className="text-3xl font-bold mb-6">All Events</h1>
{sorted.length === 0 ? (
<p className="text-gray-600">No events available.</p>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{sorted.map((event) => (
<EventCard key={event.id} event={event} />)
)}
</div>
)}
</main>
<Footer />
</div>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+253
View File
@@ -0,0 +1,253 @@
"use client";
import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function FormsContent() {
const search = useSearchParams();
const router = useRouter();
const { token } = useAuth();
const registrationId = search.get("registrationId");
const [registration, setRegistration] = useState<any | null>(null);
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
// Local entry state for new responses
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
useEffect(() => {
(async () => {
if (!registrationId) return;
setLoading(true);
setError(null);
setInfo(null);
try {
// authToken optional — guests access via registrationId alone
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
setRegistration(reg);
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
if (ev?.form) setEventForm(ev.form);
// Load any saved draft to prefill fields
try {
const draft = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
if (draft && draft.data && typeof draft.data === 'object') setFormsData(draft.data);
} catch {}
} catch (e: any) {
setError(e?.message || 'Failed to load registration');
} finally {
setLoading(false);
}
})();
}, [token, registrationId]);
const submittedCount = useMemo(() => {
return (registration?.formResponses || []).length;
}, [registration]);
const requiredCount = useMemo(() => {
if (!registration) return 0;
try {
return (registration.registrationOptions || [])
.filter((ro: any) => ro.eventOption?.isMainTicket)
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
} catch {
return 0;
}
}, [registration]);
const remaining = Math.max(0, requiredCount - submittedCount);
// Determine if all required fields are filled for each attendee form to be submitted
const canSubmit = useMemo(() => {
if (!eventForm || remaining <= 0) return false;
const reqFields = (eventForm.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < remaining; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [eventForm, formsData, remaining]);
const submit = async () => {
if (!registrationId) return;
try {
setError(null); setInfo(null);
const payload: any[] = [];
for (let i = 0; i < remaining; i++) {
payload.push({ answers: formsData[i] || {} });
}
if (payload.length === 0) {
// Nothing left to submit still go to success page to continue the flow
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
return;
}
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
method: 'POST',
authToken: token || undefined,
body: { responses: payload }
});
// On success, redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
}
};
const saveDraft = async () => {
if (!registrationId) return;
try {
setError(null); setInfo(null);
await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT',
authToken: token || undefined,
body: { data: formsData }
});
setInfo('Draft saved. You can come back and finish later.');
if (!token || !registrationId) return;
try {
// Redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
} catch (e: any) {
setError(e?.message || 'Failded to go to next step');
}
} catch (e: any) {
setError(e?.message || 'Failed to save draft');
}
};
const skip = async () => {
if (!registrationId) return;
try {
// Redirect to the success page
const totalDue = (registration?.registrationOptions || []).reduce((s: number, ro: any) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
} catch (e: any) {
setError(e?.message || 'Failded to skip');
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Attendee forms</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={skip}>Skip for now</button>
</div>
{loading && <div className="text-sm text-gray-600 mb-2">Loading</div>}
{error && <div className="text-sm text-red-600 mb-2">{error}</div>}
{info && <div className="text-sm text-emerald-700 mb-2">{info}</div>}
{registration && (
<div className="border rounded p-3 mb-4 bg-white">
<div className="font-medium">{registration.event?.title || registration.eventId}</div>
<div className="text-xs text-gray-600">Registration #{registration.id.slice(0,8)}</div>
<div className="text-sm mt-1">Forms submitted: {submittedCount} / {requiredCount}</div>
</div>
)}
{/* Existing responses (read-only list) */}
{registration && (registration.formResponses || []).length > 0 && (
<div className="border rounded p-3 mb-4 bg-gray-50">
<div className="text-sm font-medium mb-2">Submitted responses</div>
<ul className="space-y-2">
{(registration.formResponses || []).map((resp: any, idx: number) => (
<li key={resp.id} className="bg-white border rounded p-2">
<div className="font-medium text-sm mb-1">Attendee {idx + 1}</div>
{(resp.answers || []).length === 0 ? (
<div className="text-xs text-gray-600">No answers recorded.</div>
) : (
<ul className="text-xs list-disc pl-5 space-y-0.5">
{resp.answers.map((a: any) => (
<li key={a.id}>
<span className="text-gray-600">{eventForm?.fields.find(f => f.id === a.fieldId)?.label || (`Field ${a.fieldId}`)}</span>: {a.value}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
)}
{/* Remaining forms to fill */}
{eventForm && remaining > 0 && (
<div className="border rounded p-3 bg-gray-50">
<div className="text-sm font-medium mb-2">Fill remaining attendee details ({remaining})</div>
<div className="space-y-4">
{Array.from({ length: remaining }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {submittedCount + idx + 1}</div>
{eventForm.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => setFormsData(prev => ({ ...prev, [idx]: { ...(prev[idx]||{}), [f.id]: e.target.value } }))} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
)}
{eventForm && remaining === 0 && (
<div className="text-sm text-emerald-700">All required attendee forms are completed for this registration.</div>
)}
</div>
);
}
export default function FormsPage() {
return (
<Suspense fallback={<div className="p-6">Loading</div>}>
<FormsContent />
</Suspense>
);
}
+6
View File
@@ -0,0 +1,6 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
scroll-behavior: smooth;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events';
export const metadata: Metadata = {
title: appName,
description: `Manage and register for events with ${appName}`,
icons: {
icon: "/favicon.ico",
},
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<SiteSettingsProvider>
<AuthProvider>
<ToastProvider>
<SetupGuard />
<BannerBar />
{children}
</ToastProvider>
</AuthProvider>
</SiteSettingsProvider>
</body>
</html>
);
}
+248
View File
@@ -0,0 +1,248 @@
"use client";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" },
{ id: "information", title: "2. Information We Collect" },
{ id: "use", title: "3. How We Use Your Information" },
{ id: "whatsapp", title: "4. WhatsApp Communications" },
{ id: "storage", title: "5. Where We Store Your Data" },
{ id: "retention", title: "6. Data Retention" },
{ id: "children", title: "7. Children and Minors" },
{ id: "sharing", title: "8. Sharing of Information" },
{ id: "rights", title: "9. Your Rights (under POPIA)" },
{ id: "cookies", title: "10. Cookies and Local Storage" },
{ id: "security", title: "11. Security" },
{ id: "changes", title: "12. Changes to This Policy" },
{ id: "officer", title: "13. Information Officer" },
{ id: "contact", title: "14. Contact" },
];
export default function PrivacyPolicyPage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church";
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
const ioName = settings.legal_io_name || "Joshua Oosthuizen";
const ioEmail = settings.legal_io_email || "joshua@crosscode.co.za";
const effectiveDate = settings.legal_effective_date || "April 2026";
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-6xl mx-auto px-6 py-12 grid grid-cols-1 lg:grid-cols-[250px_1fr] gap-10">
{/* Sidebar */}
<aside className="hidden lg:block text-sm text-gray-600 space-y-2 self-start sticky top-24 h-fit">
<h3 className="font-semibold text-gray-800 mb-3">On this page</h3>
<ul className="space-y-2 border-l border-gray-300 pl-3">
{sections.map((section) => (
<li key={section.id}>
<a
href={`#${section.id}`}
className={`block hover:text-blue-600 transition-colors ${
activeId === section.id ? "text-blue-600 font-semibold" : ""
}`}
>
{section.title}
</a>
</li>
))}
</ul>
</aside>
{/* Main Content */}
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-blue-700 mb-2">Privacy Policy</h1>
<p className="text-sm text-gray-500 mb-6">Effective Date: {effectiveDate}</p>
<div className="border-l-4 border-blue-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-blue-600 hover:underline">
{websiteUrl}
</a>
</p>
<p><strong>Responsible Party:</strong> {orgName}</p>
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${orgEmail}`} className="text-blue-600 hover:underline">
{orgEmail}
</a>
</p>
</div>
<section id="summary" className="mb-10">
<h2 className="text-2xl font-semibold text-blue-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to register you for events and process payments.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell or share your data with anyone.</li>
<li>You can ask us what info we have about you or request deletion at any time.</li>
<li>Under 18s must register with a parent&apos;s permission.</li>
<li>Payments go through Yoco; we never see your card details.</li>
<li>We may send you security alerts (e.g. new login notifications) via email and/or WhatsApp to keep your account safe.</li>
<li>If you opt in to WhatsApp notifications, we use your mobile number to send you event updates, tickets, and account alerts via WhatsApp (powered by WAWP).</li>
<li>We keep your data for up to 5 years after your last event registration.</li>
</ul>
</section>
{sections.slice(1).map((section) => (
<Section key={section.id} id={section.id} title={section.title}
orgName={orgName} orgEmail={orgEmail} ioName={ioName} ioEmail={ioEmail} />
))}
</article>
</main>
<Footer />
</div>
);
}
function Section({
id, title, orgName, orgEmail, ioName, ioEmail,
}: {
id: string;
title: string;
orgName: string;
orgEmail: string;
ioName: string;
ioEmail: string;
}) {
const content: Record<string, string[]> = {
introduction: [
`${orgName} ('we', 'us', 'our') values your privacy and is committed to protecting your personal information in accordance with the Protection of Personal Information Act (POPIA).`,
"This policy explains what data we collect, how we use it, and your rights.",
],
information: [
"We collect the following information when you interact with our platform:",
"• Account Information: name, email address, phone number, and password (encrypted).",
"• Event Information: events you register for, ticket selections, and related preferences.",
"• Payment Information: processed by Yoco; we receive only payment status and reference numbers.",
"• Communication Data: emails or messages exchanged with our team.",
"We do not collect or store credit card numbers or bank details.",
"Administrator-created accounts (e.g. walk-in event registrations) may be created with a mobile number only. These accounts use a placeholder email address and can be activated by the account holder at any time.",
],
use: [
"We use your information to:",
"• Process event registrations and payments.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Manage user accounts and event attendance records.",
"• Improve our website and event experience.",
"• Comply with legal obligations.",
],
whatsapp: [
"If you choose 'WhatsApp' or 'Both' as your notification preference, we will use your South African mobile number to send you messages via WhatsApp. These messages may include:",
"• Event registration confirmations and updates.",
"• Payment receipts.",
"• Your event tickets (as a PDF document).",
"• Security alerts such as new login notifications, password changes, and account closure confirmations.",
"WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages. WAWP does not retain your data beyond what is necessary for message delivery.",
"Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.",
"You may change your notification preference at any time from the Profile & Security page in your account. Selecting 'Email only' will stop WhatsApp messages from being sent to you.",
"For accounts registered without a valid email address (e.g. walk-in or at-the-door registrations), WhatsApp will be used as the primary delivery channel for payment confirmations and ticket delivery, regardless of notification preference, since email is unavailable.",
"If your account requires activation and no email address is on file, the activation link will be sent to your WhatsApp number instead.",
"WhatsApp is a product of Meta Platforms, Inc. By receiving WhatsApp messages from us, you are subject to WhatsApp's own Terms of Service and Privacy Policy.",
],
storage: [
"All data is securely stored on cloud servers hosted in South Africa.",
"We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.",
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 13).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
],
sharing: [
"We do not sell, rent, or share your personal information with any third parties.",
"The only exceptions are:",
"• Processing payments securely through Yoco (a South African payment processor).",
"• Delivering WhatsApp messages through WAWP (wawp.net), a WhatsApp Business API provider — only your mobile number and message content are shared, solely for delivery purposes.",
"• Where required by South African law.",
],
rights: [
"Under POPIA, you have the right to:",
"• Access your personal information.",
"• Request correction or deletion of your data.",
"• Withdraw consent for processing.",
"• Lodge a complaint with the Information Regulator of South Africa.",
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 13 below.",
],
cookies: [
"This website uses browser localStorage to store your login session token and user interface preferences (such as dismissed notifications). No third-party cookies, tracking pixels, or analytics tools are used.",
"You can clear your browser's local storage at any time through your browser settings, which will log you out of the site.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
"We may update this Privacy Policy periodically.",
"Any changes will be posted on this page with an updated effective date.",
],
officer: [
`The Information Officer responsible for overseeing compliance with POPIA on behalf of ${orgName} is:`,
`• Name: ${ioName}`,
`• Email: ${ioEmail}`,
"You may contact the Information Officer for any POPIA-related requests, including access to your personal data, requests for correction or deletion, and formal complaints.",
],
contact: [
"For general privacy-related questions or requests, you can also contact us at:",
`📧 ${orgEmail}`,
],
};
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-blue-600 mb-3">{title}</h3>
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
if (emailMatch) {
const email = emailMatch[0];
const parts = line.split(email);
return (
<p key={i} className="mb-2">
{parts[0]}
<a href={`mailto:${email}`} className="text-blue-600 hover:underline">
{email}
</a>
{parts[1] || ""}
</p>
);
}
if (line.includes("www.inforeg.org.za")) {
return (
<p key={i} className="mb-2">
{line.replace("www.inforeg.org.za", "")}
<a href="https://www.inforeg.org.za" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
www.inforeg.org.za
</a>
</p>
);
}
return (
<p key={i} className="mb-2">
{line}
</p>
);
})}
</section>
);
}
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
const sections = [
{ id: "summary", title: "Plain English Summary" },
{ id: "acceptance", title: "1. Acceptance of Terms" },
{ id: "purpose", title: "2. Purpose of the Website" },
{ id: "accounts", title: "3. User Accounts" },
{ id: "payments", title: "4. Payments & Refunds" },
{ id: "registrations", title: "5. Registrations & Tickets" },
{ id: "acceptable", title: "6. Acceptable Use" },
{ id: "communication", title: "7. Communication" },
{ id: "intellectual", title: "8. Intellectual Property" },
{ id: "liability", title: "9. Limitation of Liability" },
{ id: "forcemajeure", title: "10. Force Majeure" },
{ id: "termination", title: "11. Termination of Access" },
{ id: "law", title: "12. Governing Law" },
{ id: "contact", title: "13. Contact" },
];
export default function TermsOfUsePage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church";
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
const operatorName = settings.legal_operator_name || "Joshua Oosthuizen on behalf of Hope Family Church, Henley-on-Klip, South Africa";
const effectiveDate = settings.legal_effective_date || "April 2026";
return (
<div className="min-h-screen flex flex-col bg-gray-50">
<Navbar />
<main className="flex-1 max-w-6xl mx-auto px-6 py-12 grid grid-cols-1 lg:grid-cols-[250px_1fr] gap-10">
{/* Sidebar */}
<aside className="hidden lg:block text-sm text-gray-600 space-y-2 self-start sticky top-24 h-fit">
<h3 className="font-semibold text-gray-800 mb-3">On this page</h3>
<ul className="space-y-2 border-l border-gray-300 pl-3">
{sections.map((section) => (
<li key={section.id}>
<a
href={`#${section.id}`}
className={`block hover:text-blue-600 transition-colors ${
activeId === section.id ? "text-blue-600 font-semibold" : ""
}`}
>
{section.title}
</a>
</li>
))}
</ul>
</aside>
{/* Main Content */}
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-blue-700 mb-2">Terms of Use</h1>
<p className="text-sm text-gray-500 mb-6">Effective Date: {effectiveDate}</p>
<div className="border-l-4 border-blue-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-blue-600 hover:underline">
{websiteUrl}
</a>
</p>
<p><strong>Owned and operated by:</strong> {operatorName}</p>
</div>
<section id="summary" className="mb-10">
<h2 className="text-2xl font-semibold text-blue-600 mb-4">🟢 Plain English Summary</h2>
<p>Welcome to {orgName}! This site helps you book and pay for events hosted or supported by {orgName}.</p>
<p>By using it, you agree to:</p>
<ul className="list-disc ml-6 space-y-1">
<li>Use the platform responsibly and only for legitimate event participation.</li>
<li>Provide accurate information during registration and payments.</li>
<li>Keep your login details safe.</li>
<li>Respect that under 18s need parental permission to register.</li>
<li>Understand that all payments go through Yoco, and we don&apos;t store your card details.</li>
<li>Accept that we may contact you via email and/or WhatsApp (based on your preference) about events you register for, your tickets, and account security alerts.</li>
<li>Understand that registrations are personal to you and non-transferable.</li>
<li>If we cancel an event, you&apos;ll get a full refund on request or your registration moved to the new date.</li>
</ul>
<p className="mt-2">If you disagree with these terms, please don&apos;t use the site.</p>
</section>
{sections.slice(1).map((section) => (
<Section key={section.id} id={section.id} title={section.title}
orgName={orgName} orgEmail={orgEmail} />
))}
</article>
</main>
<Footer />
</div>
);
}
function Section({
id, title, orgName, orgEmail,
}: {
id: string;
title: string;
orgName: string;
orgEmail: string;
}) {
const content: Record<string, string[]> = {
acceptance: [
`By accessing or using the platform, you agree to be bound by these Terms of Use and any policies referenced herein.`,
`${orgName} ('we', 'our', or 'us') reserves the right to modify these terms at any time. Updated versions will be posted on this page with a new effective date.`,
],
purpose: [
`The platform is designed to:`,
`• Allow users to view, register, and pay for events hosted or co-hosted by ${orgName}.`,
`• Manage event registration details, communication, and participation records.`,
`The site may also provide updates or communication related to events.`,
],
accounts: [
"You must create an account to register for certain events or access restricted features.",
"You are responsible for keeping your password secure.",
"Passwords are encrypted and not accessible to any staff member.",
"Users under the age of 18 must only register or create an account with the consent and supervision of a parent or guardian.",
"You agree to provide accurate, current, and complete information when creating your account.",
`Accounts may also be created on your behalf by an administrator (e.g. for walk-in event registration). In this case, you will receive a WhatsApp message with an activation link if your mobile number is provided.`,
],
payments: [
"Payments are processed securely through Yoco, a third-party payment processor.",
`${orgName} does not store or process credit card information.`,
"Payment confirmations and receipts are issued electronically.",
"All prices are listed in South African Rand (ZAR) unless stated otherwise.",
"Where an event permits it, partial payments may be made. A minimum payment of R15 applies per transaction. The outstanding balance must be settled before your registration can be considered fully paid.",
"Early-bird pricing: Certain events offer reduced 'early-bird' prices for a limited time or while a limited number of places are available. Early-bird prices are locked in at the time of registration, provided that payment is completed before the stated early-bird deadline. If payment is not completed before the deadline, the early-bird price is forfeited and the standard price applies. Early-bird availability is subject to both time limits and stock limits; once the allocated places are filled, the early-bird tier closes regardless of the deadline. By registering at an early-bird price, you acknowledge these conditions.",
`Refund Policy: Refunds are handled on a per-event basis. As a baseline, if you are unable to attend an event you have paid for, please contact us at ${orgEmail} at least 48 hours before the event. Refund requests at this stage are at the discretion of the event organiser. No refunds will be issued for no-shows. Any event-specific refund terms will be communicated at the time of registration and take precedence over this baseline.`,
],
registrations: [
`Registrations and tickets are personal to the registered individual and are non-transferable unless explicitly authorised by ${orgName} in writing.`,
"You may not sell, gift, or pass on your registration to another person without written authorisation.",
"You may cancel your own registration at any time before any payment has been made. Once a payment has been recorded against your registration, cancellation must be requested through the organisation. Please contact us to arrange this.",
`If ${orgName} cancels or significantly postpones an event, affected registrants will be notified by email. In this case, a full refund will be issued upon request, or your registration may be transferred to the rescheduled or replacement event — whichever you prefer. Refund requests for organiser-initiated cancellations must be submitted to ${orgEmail} within 30 days of the cancellation notice.`,
],
acceptable: [
"You agree not to:",
"• Use the website for any unlawful or fraudulent purpose.",
"• Impersonate another person or misrepresent your identity.",
"• Interfere with or disrupt the operation of the site.",
"• Attempt to gain unauthorised access to our systems.",
"• Resell, tout, or transfer your registration or tickets without written authorisation.",
"• Use automated tools, bots, or scrapers to access or extract data from the site.",
],
communication: [
"By registering, you consent to receive communications related to your registrations, payments, tickets, and account security.",
"Depending on your notification preference, these may be delivered via email and/or WhatsApp. You can update your preference at any time from the Profile & Security page in your account.",
"Certain communications are required for the operation of the platform and cannot be opted out of entirely — these include payment confirmations, ticket delivery, and account security alerts (e.g. new login notifications, password resets, and account closure confirmations). Security alerts are always sent via email; WhatsApp delivery is additional when you have opted in.",
"For accounts without a valid email address (e.g. walk-in or at-the-door registrations), these required communications will be delivered via WhatsApp if a mobile number is on file.",
"WhatsApp messages are delivered via WAWP, a third-party WhatsApp Business API provider. By enabling WhatsApp notifications, you consent to your mobile number being used to send you messages via this service.",
],
intellectual: [
`All content on the platform, including text, graphics, and logos, is owned by ${orgName} unless otherwise stated.`,
"You may not copy, modify, or distribute any content without written permission.",
`By submitting any content through the platform (such as attendee details or form responses), you grant ${orgName} a limited licence to use that content solely for the purpose of managing your event registration.`,
],
liability: [
`${orgName} provides this platform on an 'as is' basis. We make no guarantees regarding uninterrupted access or error-free performance.`,
"We are not liable for any loss or damages arising from:",
"• Technical failures; or",
"• Misuse of the platform.",
"Liability for event cancellations or schedule changes is addressed under section 5 (Registrations & Tickets) and section 10 (Force Majeure).",
],
forcemajeure: [
`${orgName} shall not be liable for any failure or delay in performing its obligations where such failure or delay results from causes beyond its reasonable control, including but not limited to: acts of God, natural disasters, government restrictions or regulations, public health emergencies, venue closures, or any other event outside our reasonable control ('force majeure event').`,
`In the event of a force majeure event affecting a scheduled event, ${orgName} will endeavour to reschedule or provide reasonable alternatives where possible. Refunds in force majeure circumstances will be handled at our discretion and communicated to affected registrants as soon as reasonably practicable.`,
],
termination: [
"We may suspend or terminate access to your account if we believe you have violated these Terms of Use or engaged in behaviour harmful to others or the organisation.",
`You may delete your own account at any time from the Profile & Security page in your account, or by contacting ${orgEmail}. Upon deletion, your personal data will be handled in accordance with our Privacy Policy.`,
"Certain data may be retained for legal or financial record-keeping purposes even after account deletion.",
],
law: [
"These Terms of Use are governed by the laws of the Republic of South Africa.",
],
contact: [
"For questions or concerns about these Terms, contact:",
`📧 ${orgEmail}`,
],
};
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-blue-600 mb-3">{title}</h3>
{content[id]?.map((line, i) => {
if (line.includes("@") && !line.startsWith("•") && (line.includes("📧") || line.endsWith(orgEmail))) {
const email = orgEmail;
const parts = line.split(email);
return (
<p key={i} className="mb-2">
{parts[0]}
<a href={`mailto:${email}`} className="text-blue-600 hover:underline">
{email}
</a>
{parts[1] || ""}
</p>
);
}
return (
<p key={i} className="mb-2">
{line}
</p>
);
})}
</section>
);
}
+134
View File
@@ -0,0 +1,134 @@
import React from "react";
export default function LockdownRulesPage() {
const rules = [
{
icon: "💡",
title: "1. Respect People",
items: [
"Treat all leaders, volunteers, and other youth with kindness and respect.",
"Listen when leaders are speaking and follow all instructions the first time.",
"No bullying, name-calling, gossip, or exclusion of others.",
],
},
{
icon: "🏠",
title: "2. Stay in the Venue",
items: [
"Once checked in, no one may leave the premises until pick-up time (8 AM), unless approved by a leader.",
"For everyones safety, doors will remain locked from the inside during the event.",
"If you need to leave early, a parent or guardian must collect you in person and notify a leader.",
"Certain areas may be off-limits; please respect the signs and leader directions.",
],
},
{
icon: "🔇",
title: "3. Respect the Space",
items: [
"Keep all areas tidy and take care of church property and equipment.",
"Keep your area clean; bins are there for a reason.",
"No running or roughhousing inside unless part of a supervised activity.",
],
},
{
icon: "📱",
title: "4. Use Phones Wisely",
items: [
"Phones are allowed, but this is a time to connect with people in person!",
"No inappropriate photos, videos, or social media posts during the event.",
"If your phone becomes a distraction, a leader may ask to hold it safely until morning.",
],
},
{
icon: "🚫",
title: "5. No Forbidden Items",
items: [
"Do not bring or use any of the following:",
"• Alcohol, drugs, cigarettes, vapes, or any dangerous items",
"• Weapons, lighters, or fireworks",
"• Inappropriate material of any kind",
],
note:
"Any of these items will be confiscated and parents will be contacted immediately.",
},
{
icon: "⚡",
title: "6. Participate in Everything",
items: [
"The night is packed with awesome games, challenges, and sessions; get involved!",
"No sitting out unless youre unwell or a leader has excused you.",
"Be a team player; encourage others and bring positive vibes.",
],
},
{
icon: "🕒",
title: "7. Stay Awake, Stay Safe",
items: [
"This is an all-nighter! No sleeping bags or nap zones; the goal is to stay awake and enjoy it together.",
"If you start feeling too tired, talk to a leader; theyll help you grab some water or take a quiet moment.",
"Respect quiet moments during worship or reflection time; its not a nap zone, but it is sacred space.",
],
},
{
icon: "🙏",
title: "8. Represent Christ Well",
items: [
"Everything you say and do represents Wave Youth and Jesus.",
"Show love, joy, and respect in your words and actions.",
"Lets make this night unforgettable for all the right reasons!",
],
},
];
return (
<div className="min-h-screen bg-slate-950 text-gray-100 flex flex-col items-center py-10 px-4">
<div className="max-w-5xl w-full bg-slate-900/70 backdrop-blur-md rounded-2xl shadow-2xl border border-slate-800 p-8">
<header className="mb-8 text-center">
<h1 className="text-3xl md:text-4xl font-extrabold text-blue-400">
Hope Youth Lockdown Rules & Expectations
</h1>
<p className="text-slate-400 mt-3 max-w-2xl mx-auto">
The Hope Youth Lockdown is designed to be a night of fun,
friendship, and faith. To make sure everyone has a great experience,
all participants agree to the following:
</p>
</header>
<div className="bg-slate-800/60 text-cyan-300 rounded-full text-center py-2 px-4 font-medium text-sm mb-6">
Be kind 🔐 Stay safe 🙏 Honor God 🎉 Have fun
</div>
<section className="grid gap-5 md:grid-cols-2">
{rules.map((rule) => (
<div
key={rule.title}
className="bg-slate-800/60 border border-slate-700 rounded-xl p-5 shadow-md hover:shadow-blue-500/20 transition-shadow"
>
<h2 className="text-lg font-semibold mb-2 flex items-center gap-2 text-blue-300">
<span className="text-xl">{rule.icon}</span>
{rule.title}
</h2>
<ul className="list-disc pl-6 text-gray-300 text-sm space-y-1">
{rule.items.map((item, idx) => (
<li key={idx}>{item}</li>
))}
</ul>
{rule.note && (
<p className="text-sm text-slate-400 italic mt-3">{rule.note}</p>
)}
</div>
))}
</section>
<footer className="mt-10 text-center border-t border-slate-800 pt-6 text-sm text-slate-400">
<p>Questions or concerns? Chat to a leader anytime during the night.
Alternatively, you can contact Joshua on 072 160 3010 or Eben on 084 862 7655</p>
<p className="mt-1">
By attending, you agree to follow these rules so everyone can have a
safe, legendary night. 💙
</p>
</footer>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
{
"name": "hope-events",
"version": "1.2",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"private": true
}
+167
View File
@@ -0,0 +1,167 @@
.page {
--gray-rgb: 0, 0, 0;
--gray-alpha-200: rgba(var(--gray-rgb), 0.08);
--gray-alpha-100: rgba(var(--gray-rgb), 0.05);
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
display: grid;
grid-template-rows: 20px 1fr 20px;
align-items: center;
justify-items: center;
min-height: 100svh;
padding: 80px;
gap: 64px;
font-family: var(--font-geist-sans);
}
@media (prefers-color-scheme: dark) {
.page {
--gray-rgb: 255, 255, 255;
--gray-alpha-200: rgba(var(--gray-rgb), 0.145);
--gray-alpha-100: rgba(var(--gray-rgb), 0.06);
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
}
}
.main {
display: flex;
flex-direction: column;
gap: 32px;
grid-row-start: 2;
}
.main ol {
font-family: var(--font-geist-mono);
padding-left: 0;
margin: 0;
font-size: 14px;
line-height: 24px;
letter-spacing: -0.01em;
list-style-position: inside;
}
.main li:not(:last-of-type) {
margin-bottom: 8px;
}
.main code {
font-family: inherit;
background: var(--gray-alpha-100);
padding: 2px 4px;
border-radius: 4px;
font-weight: 600;
}
.ctas {
display: flex;
gap: 16px;
}
.ctas a {
appearance: none;
border-radius: 128px;
height: 48px;
padding: 0 20px;
border: 1px solid transparent;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 20px;
font-weight: 500;
}
a.primary {
background: var(--foreground);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--gray-alpha-200);
min-width: 158px;
}
.footer {
grid-row-start: 3;
display: flex;
gap: 24px;
}
.footer a {
display: flex;
align-items: center;
gap: 8px;
}
.footer img {
flex-shrink: 0;
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
.footer a:hover {
text-decoration: underline;
text-underline-offset: 4px;
}
}
@media (max-width: 600px) {
.page {
padding: 32px;
padding-bottom: 80px;
}
.main {
align-items: center;
}
.main ol {
text-align: center;
}
.ctas {
flex-direction: column;
}
.ctas a {
font-size: 14px;
height: 40px;
padding: 0 16px;
}
a.secondary {
min-width: auto;
}
.footer {
flex-wrap: wrap;
align-items: center;
justify-content: center;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
}
+70
View File
@@ -0,0 +1,70 @@
import { EventCard } from "@/components/events/EventCard";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { ContactSection } from "@/components/shared/ContactSection";
import { appName } from "@/lib/siteConfig";
type Event = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
goLiveAt?: string;
price: number;
picture?: string;
};
import { apiFetch } from "@/lib/api";
export default async function HomePage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
const upcoming = (events || []).filter(e => {
const t = new Date(e.startDate).getTime();
return !isNaN(t) && t > now;
});
const sorted = [...upcoming].sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1">
<section className="bg-gradient-to-br from-blue-100 to-white py-16 text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to {appName}</h1>
<p className="text-gray-600 text-lg mb-6">Experience unforgettable moments. Powered by purpose.</p>
<div className="space-x-4">
<a href="/events" className="px-6 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700">
View Events
</a>
<a href="/register" className="px-6 py-2 border border-blue-600 text-blue-600 rounded-xl hover:bg-blue-50">
Join Us
</a>
</div>
</section>
<section className="py-12 px-4 max-w-7xl mx-auto">
<SectionHeader title="Upcoming Events" />
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{sorted.slice(0, 3).map(event => (
<EventCard key={event.id} event={event} />
))}
</div>
<div className="text-center mt-8">
<a href="/events" className="text-blue-600 hover:underline text-sm">
View all events
</a>
</div>
</section>
<ContactSection />
</main>
<Footer />
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import React from "react";
export default function PaymentCancelPage() {
const router = useRouter();
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<h1 className="text-2xl font-semibold mb-2">Payment cancelled</h1>
<p className="text-gray-700">Your checkout was cancelled. You can resume payment from your dashboard later.</p>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm" onClick={() => router.push('/dashboard/user')}>Go to Dashboard</button>
</main>
<Footer />
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
"use client";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import React from "react";
export default function PaymentFailurePage() {
const router = useRouter();
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<h1 className="text-2xl font-semibold mb-2">Payment failed</h1>
<p className="text-gray-700">Unfortunately, your payment failed. Please try again or use a different method.</p>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm" onClick={() => router.push('/dashboard/user')}>Go to Dashboard</button>
</main>
<Footer />
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
"use client";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import React from "react";
export default function PaymentSuccessPage() {
const router = useRouter();
const { settings } = useSiteSettings();
const contactEmail = settings.org_email || "";
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<h1 className="text-2xl font-semibold mb-2">Payment successful</h1>
<p className="text-gray-700">
Thank you! Your payment was received. Your tickets will be sent to you shortly.
{contactEmail && (
<>
{" "}If you have any questions, contact us at{" "}
<a href={`mailto:${contactEmail}`} className="text-blue-600 hover:underline">
{contactEmail}
</a>.
</>
)}
</p>
<button
className="mt-4 px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm"
onClick={() => router.push('/dashboard/user')}
>
Go to Dashboard
</button>
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,508 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
function lowStockThreshold(stockLimit: number): number {
let pct: number;
if (stockLimit <= 50) pct = 0.20;
else if (stockLimit <= 200) pct = 0.15;
else if (stockLimit <= 1000) pct = 0.10;
else pct = 0.05;
return Math.round(stockLimit * pct);
}
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; order?: number };
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
type EventOption = {
id: string;
name: string;
price: number;
stockLimit?: number;
availableCount?: number;
isMainTicket?: boolean;
earlyBirdTiers?: EarlyBirdTier[];
variants?: OptionVariant[];
};
type Event = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
price: number;
picture?: string;
eventOptions?: EventOption[];
form?: { isRequired: boolean; fields?: any[] } | null;
requiresAuth?: boolean;
};
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
type QtyMap = Record<string, number>;
function effectiveUnitForOption(opt: EventOption): number {
const base = opt.price || 0;
const tiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers.slice() : [];
if (tiers.length === 0) return base;
const now = new Date();
const applicable = tiers
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || (t as any).availableCount === undefined || (t as any).availableCount > 0))
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
return applicable.length > 0 ? applicable[0].price : base;
}
function effectiveUnitForVariant(opt: EventOption, variant: OptionVariant): number {
if (variant.price !== null && variant.price !== undefined) return variant.price;
return effectiveUnitForOption(opt);
}
function nextTierForOption(opt: EventOption): EarlyBirdTier | null {
const tiers = Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers.slice() : [];
if (tiers.length === 0) return null;
const now = new Date();
const applicable = tiers
.map((t) => ({ ...t, _d: new Date(t.deadline) }))
.filter((t) => now < t._d)
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
if (applicable.length === 0) return null;
return applicable[0];
}
function StockBadge({ stockLimit, availableCount }: { stockLimit?: number; availableCount?: number }) {
if (!stockLimit || stockLimit === 0) return null;
if (availableCount === undefined) return null;
if (availableCount <= 0) {
return <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>;
}
const threshold = lowStockThreshold(stockLimit);
if (availableCount <= threshold) {
return <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{availableCount} remaining</span>;
}
return null;
}
function QtyControl({
value,
onChange,
disabled,
max,
}: {
value: number;
onChange: (v: number) => void;
disabled?: boolean;
max?: number;
}) {
return (
<div className="flex items-center gap-1.5">
<button
type="button"
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
onClick={() => onChange(Math.max(0, value - 1))}
disabled={disabled || value <= 0}
>
</button>
<span className="w-6 text-center text-sm font-medium">{value}</span>
<button
type="button"
className="w-7 h-7 flex items-center justify-center border rounded text-gray-600 hover:bg-gray-50 disabled:opacity-40"
onClick={() => onChange(value + 1)}
disabled={disabled || (max !== undefined && max > 0 && value >= max)}
>
+
</button>
</div>
);
}
function OptionCard({
opt,
quantities,
onQtyChange,
}: {
opt: EventOption;
quantities: QtyMap;
onQtyChange: (key: string, val: number) => void;
}) {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
if (hasVariants) {
const variants = opt.variants!.slice().sort((a, b) => (a.order || 0) - (b.order || 0));
// Compute "From R..." display
const variantPrices = variants.map((v) => effectiveUnitForVariant(opt, v));
const minPrice = Math.min(...variantPrices);
const maxPrice = Math.max(...variantPrices);
const priceLabel = minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
return (
<div className="border rounded-lg overflow-hidden bg-white shadow-sm">
{/* Option header */}
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<div>
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500 mt-0.5">{priceLabel}</div>
</div>
<div className="flex items-center gap-2">
{isSoldOut ? (
<span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>
) : (
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
)}
</div>
</div>
{/* Variant rows */}
<div className="divide-y">
{variants.map((variant) => {
const key = `${opt.id}:${variant.id}`;
const qty = quantities[key] || 0;
const unitPrice = effectiveUnitForVariant(opt, variant);
const variantSoldOut = (variant.stockLimit ?? 0) > 0 && variant.availableCount !== undefined && variant.availableCount <= 0;
const maxQty = ((variant.stockLimit ?? 0) > 0 && variant.availableCount !== undefined)
? variant.availableCount
: undefined;
return (
<div key={variant.id} className="flex items-center justify-between px-4 py-2.5">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm">{variant.name}</span>
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
</div>
<QtyControl
value={qty}
onChange={(v) => onQtyChange(key, v)}
disabled={!!(isSoldOut || variantSoldOut)}
max={maxQty}
/>
</div>
);
})}
</div>
</div>
);
}
// No variants — simple row
const key = opt.id;
const qty = quantities[key] || 0;
const tier = nextTierForOption(opt);
const price = effectiveUnitForOption(opt);
const maxQty = ((opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined)
? opt.availableCount
: undefined;
return (
<div className="flex items-center justify-between border rounded-lg p-3 bg-white shadow-sm">
<div className="min-w-0 flex-1 mr-3">
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-2 flex-wrap">
{tier ? (
<>
<span>R{price.toFixed(2)}</span>
<span className="text-indigo-600">
Early bird ends {new Date(tier.deadline).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" })}
</span>
</>
) : (
<span>R{price.toFixed(2)}</span>
)}
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
</div>
</div>
<QtyControl
value={qty}
onChange={(v) => onQtyChange(key, v)}
disabled={!!(isSoldOut)}
max={maxQty}
/>
</div>
);
}
export default function RegisterForm({ event }: { event: Event }) {
const { token, user } = useAuth();
const eventId = event.id;
const [quantities, setQuantities] = useState<QtyMap>(() => {
const initial: QtyMap = {};
(event.eventOptions || []).forEach((o) => {
if (Array.isArray(o.variants) && o.variants.length > 0) {
o.variants.forEach((v) => { initial[`${o.id}:${v.id}`] = 0; });
} else {
initial[o.id] = 0;
}
});
return initial;
});
const [error, setError] = useState<string | null>(null);
const [registrationId, setRegistrationId] = useState<string | null>(null);
const [creatingCheckout, setCreatingCheckout] = useState(false);
const [creatingRegistration, setCreatingRegistration] = useState(false);
// Guest details (for unauthenticated free-event registration)
const [guestName, setGuestName] = useState("");
const [guestEmail, setGuestEmail] = useState("");
const [guestPhone, setGuestPhone] = useState("");
// Event data is server-rendered, but "now" has to be evaluated in the browser (and this page
// can be served from a up-to-60s-old cache — see revalidate in page.tsx), so still guard against
// someone loading a link after registration has actually closed.
useEffect(() => {
try {
const now = new Date();
const end = event.endDate ? new Date(event.endDate) : null;
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
if ((deadline && now >= deadline) || (end && now >= end)) {
window.location.replace('/events');
}
} catch {}
}, [event.endDate, event.registrationDeadline]);
const handleQtyChange = (key: string, val: number) => {
setQuantities((q) => ({ ...q, [key]: val }));
};
const total = useMemo(() => {
return (event.eventOptions || []).reduce((sum, opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
if (hasVariants) {
return sum + opt.variants!.reduce((vsum, v) => {
const qty = quantities[`${opt.id}:${v.id}`] || 0;
return vsum + qty * effectiveUnitForVariant(opt, v);
}, 0);
}
return sum + (quantities[opt.id] || 0) * effectiveUnitForOption(opt);
}, 0);
}, [event, quantities]);
const isGuestEligible = !!(event.requiresAuth === false);
const isLoggedIn = !!user || !!token;
const showGuestForm = isGuestEligible && !isLoggedIn;
const eventSoldOut = useMemo(() => {
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const buildItems = () => {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
if (key.includes(":")) {
const [eventOptionId, variantId] = key.split(":");
items.push({ eventOptionId, quantity: qty, variantId });
} else {
items.push({ eventOptionId: key, quantity: qty });
}
});
return items;
};
const submitRegistration = async () => {
const items = buildItems();
if (items.length === 0) {
setError("Select at least one ticket.");
return;
}
if (isLoggedIn) {
if (!token) {
setError("Session expired — please log in again.");
window.location.href = `/login?redirect=/register/${eventId}`;
return;
}
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items },
authToken: token,
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
} else {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((total || 0).toFixed(2))}`;
}
}
} catch (e: any) {
setError(e?.message || "Registration failed");
} finally {
setCreatingRegistration(false);
}
return;
}
if (!showGuestForm) {
setError("Please log in to register for this event.");
window.location.href = `/login?redirect=/register/${eventId}`;
return;
}
if (!guestName.trim()) { setError("Please enter your name."); return; }
if (!guestEmail.trim() || !guestEmail.includes("@")) { setError("Please enter a valid email address."); return; }
try {
setError(null);
setCreatingRegistration(true);
const res = await apiFetch<{ id: string }>("/api/registrations", {
method: "POST",
body: { eventId, options: items, guestName: guestName.trim(), guestEmail: guestEmail.trim(), guestPhone: guestPhone.trim() || undefined },
});
if (res?.id) {
const hasForm = !!(event?.form && Array.isArray(event.form.fields) && event.form.fields.length > 0);
if (hasForm) {
window.location.href = `/forms?registrationId=${encodeURIComponent(res.id)}`;
} else {
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=0.00`;
}
}
} catch (e: any) {
setError(e?.message || "Registration failed");
} finally {
setCreatingRegistration(false);
}
};
const createYocoCheckout = async () => {
if (!token || !registrationId) return;
try {
setCreatingCheckout(true);
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
method: "POST",
body: {
registrationId,
successUrl: window.location.origin + "/payment/success",
cancelUrl: window.location.origin + "/payment/cancel",
failureUrl: window.location.origin + "/payment/failure",
},
authToken: token,
});
window.open(res.redirectUrl, "_blank");
} catch (e: any) {
setError(e?.message || "Failed to create checkout");
} finally {
setCreatingCheckout(false);
}
};
return (
<>
<h1 className="text-2xl font-semibold mb-4">Register for {event.title}</h1>
{eventSoldOut && (
<div className="mb-4 p-4 border rounded-lg bg-red-50 border-red-200 text-sm text-red-800 font-medium">
This event is sold out. Registration is no longer available.
</div>
)}
{!isLoggedIn && !isGuestEligible && (
<div className="mb-4 p-4 border rounded-lg bg-amber-50 border-amber-200 text-sm text-amber-800">
You need to <a href={`/login?redirect=/register/${eventId}`} className="font-semibold underline">log in</a> or <a href={`/register?redirect=/register/${eventId}`} className="font-semibold underline">create an account</a> to register for this event.
</div>
)}
{showGuestForm && (
<div className="mb-6 border rounded-xl p-4 bg-white shadow-sm">
<div className="text-base font-semibold mb-1">Your details</div>
<p className="text-sm text-gray-500 mb-3">
No account needed for this event.{" "}
<a href={`/login?redirect=/register/${eventId}`} className="text-indigo-600 hover:underline">Log in</a> if you already have one.
</p>
<div className="space-y-2">
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Full name *"
value={guestName}
onChange={e => setGuestName(e.target.value)}
required
/>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Email address *"
value={guestEmail}
onChange={e => setGuestEmail(e.target.value)}
required
/>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="Phone number (optional)"
value={guestPhone}
onChange={e => setGuestPhone(e.target.value)}
/>
</div>
</div>
)}
<div className="space-y-3">
{(event.eventOptions || []).map((opt) => (
<OptionCard
key={opt.id}
opt={opt}
quantities={quantities}
onQtyChange={handleQtyChange}
/>
))}
</div>
{(() => {
// Collect all upcoming early-bird tiers across all options
const now = new Date();
const upcomingTiers = (event.eventOptions || []).flatMap(opt =>
(opt.earlyBirdTiers || [])
.map((t: any) => ({ ...t, deadline: new Date(t.deadline) }))
.filter((t: any) => t.deadline > now)
).sort((a: any, b: any) => a.deadline.getTime() - b.deadline.getTime());
if (upcomingTiers.length === 0) return null;
const soonest = upcomingTiers[0].deadline as Date;
const dateStr = soonest.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
return (
<div className="mt-4 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-800">
<strong>Early bird pricing notice:</strong> Reduced early-bird prices are available until {dateStr}. Prices are subject to deadline and availability your price is locked at registration but payment must be completed before the deadline to guarantee the early-bird rate.
</div>
);
})()}
<div className="mt-6 flex items-center justify-between">
<div className="text-lg font-semibold">Total: R{total.toFixed(2)}</div>
{!registrationId ? (
eventSoldOut ? (
<button disabled className="bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
Sold Out
</button>
) : (
<button
onClick={submitRegistration}
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
>
{creatingRegistration ? "Registering..." : "Register"}
</button>
)
) : (
<button
onClick={createYocoCheckout}
disabled={creatingCheckout}
className="bg-green-600 text-white px-4 py-2 rounded disabled:opacity-60"
>
{creatingCheckout ? "Creating checkout..." : "Pay with Yoco"}
</button>
)}
</div>
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
</>
);
}
@@ -0,0 +1,30 @@
import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import RegisterForm from "./RegisterForm";
export const revalidate = 60;
export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) {
const { eventId } = await params;
let event: any;
try {
// Fetched server-side (same pattern as /events/[id]) so the form's content is part of
// the initial HTML instead of showing a loading skeleton after a client-side fetch.
event = await apiFetch<any>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
} catch (e) {
if (e instanceof ApiError && e.status === 404) notFound();
throw e;
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<RegisterForm event={event} />
</main>
<Footer />
</div>
);
}
@@ -0,0 +1,249 @@
"use client";
import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
function RegistrationSuccessContent() {
const search = useSearchParams();
const router = useRouter();
const registrationId = search.get("registrationId") || search.get("id");
const fallbackTotalParam = search.get("totalDue");
const fallbackTotalDue = React.useMemo(() => {
const n = fallbackTotalParam ? Number(fallbackTotalParam) : 0;
return isNaN(n) ? 0 : n;
}, [fallbackTotalParam]);
const [reg, setReg] = React.useState<any | null>(null);
const [form, setForm] = React.useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
const [formsData, setFormsData] = React.useState<Record<number, Record<string, string>>>({});
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [info, setInfo] = React.useState<string | null>(null);
React.useEffect(() => {
(async () => {
setError(null);
setInfo(null);
if (!registrationId) return;
try {
setLoading(true);
// Try to fetch registration and event form
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
if (r) {
setReg(r);
const ev = await (await import('@/lib/api')).apiFetch(`/api/events/${encodeURIComponent(r.eventId)}`);
if (ev?.form) setForm(ev.form);
// Load any saved draft
try {
const d = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, { authToken: token || undefined });
if (d && d.data && typeof d.data === 'object') setFormsData(d.data);
} catch {}
}
} catch (e: any) {
setError(e?.message || 'Failed to load details');
} finally {
setLoading(false);
}
})();
}, [registrationId]);
const totalDue = React.useMemo(() => {
if (!reg) return 0;
try {
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
? Number(ro.priceSnapshot)
: (ro.variant?.price ?? ro.eventOption?.price ?? 0);
return s + unit * (ro.quantity || 0);
}, 0);
} catch { return 0; }
}, [reg]);
React.useEffect(() => {
// If free registration, do not show Yoco and redirect to dashboard after a short delay
if (reg && totalDue === 0) {
// attempt ticket generation if status is paid (may fail if required forms aren't completed)
(async () => {
try {
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (token && reg?.id) {
await (await import('@/lib/api')).apiFetch('/api/tickets/generate', { method: 'POST', authToken: token, body: { registrationId: reg.id } });
}
} catch {}
setTimeout(() => router.replace('/dashboard'), 600);
})();
}
}, [reg, totalDue, router]);
const goPay = () => {
if (!registrationId) return;
router.push(`/dashboard/user/pay?registrationId=${encodeURIComponent(registrationId)}`);
};
const goDashboard = () => {
router.push("/dashboard/user");
};
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-2xl font-semibold mb-2">Registration successful</div>
<p className="text-gray-700 mb-4">Thank you! Your registration has been created{registrationId ? ` (#${registrationId.slice(0,8)})` : ""}.</p>
{form?.isRequired && reg ? (
<div className="mb-6 p-3 border rounded bg-yellow-50 text-yellow-800">
<div className="font-medium">This event requires attendee details.</div>
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
</div>
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
<p className="text-gray-700 mb-6">Would you like to pay with Yoco now?</p>
) : null}
<div className="flex flex-col sm:flex-row gap-3 mt-4">
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
<button
onClick={goPay}
disabled={!registrationId}
className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60"
>Pay with Yoco</button>
)}
<button
onClick={goDashboard}
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
>{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
</div>
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
{!registrationId && (
<p className="text-sm text-red-600 mt-4">Missing registration reference. You can still go to your Dashboard to view registrations.</p>
)}
</div>
</main>
<Footer />
</div>
);
}
function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }: { reg: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; setError: any; setInfo: any; }) {
const registrationId = reg?.id;
const mainTickets = (reg?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
const count = Math.max(0, mainTickets);
const canSubmit = React.useMemo(() => {
if (!form || count <= 0) return false;
const reqFields = (form.fields || [])
.filter(f => !!f.isRequired && f.type !== 'statement' && f.type !== 'paragraph')
.map(f => f.id);
for (let i = 0; i < count; i++) {
const data = formsData[i] || {};
for (const fid of reqFields) {
const v = data[fid];
if (v === undefined || v === null || String(v).trim() === '') {
return false;
}
}
}
return true;
}, [form, formsData, count]);
const update = (idx: number, fieldId: string, value: string) => {
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
};
const submit = async () => {
try {
setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to submit forms.'); return; }
const payload = [] as any[];
for (let i = 0; i < count; i++) {
payload.push({ answers: formsData[i] || {} });
}
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/responses`, {
method: 'POST',
authToken: token,
body: { responses: payload }
});
setInfo('Attendee forms submitted. You will receive tickets once payment is confirmed.');
} catch (e: any) {
setError(e?.message || 'Failed to submit forms');
}
};
const saveDraft = async () => {
try {
setError(null); setInfo(null);
const token = (typeof window !== 'undefined') ? localStorage.getItem('token') : null;
if (!token) { setError('Please login to save drafts.'); return; }
await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/forms/draft`, {
method: 'PUT',
authToken: token,
body: { data: formsData }
});
setInfo('Draft saved. You can finish later from your dashboard.');
} catch (e: any) {
setError(e?.message || 'Failed to save draft');
}
};
if (!form || !Array.isArray(form.fields) || count === 0) return null;
return (
<div className="border rounded p-3 bg-gray-50 mb-4">
<div className="text-sm font-medium mb-2">Attendee details</div>
<div className="space-y-4">
{Array.from({ length: count }, (_, idx) => (
<div key={idx} className="bg-white border rounded p-3">
<div className="font-medium mb-2">Attendee {idx + 1}</div>
{form.fields.map((f) => (
<div key={f.id} className="mb-2">
{f.type === 'statement' ? (
<div className="text-sm text-gray-700 whitespace-pre-line">{f.label}</div>
) : f.type === 'paragraph' ? (
<div className="text-sm text-gray-700">
{f.label && <div className="font-medium mb-1 whitespace-pre-line">{f.label}</div>}
{f.helpText && <div className="whitespace-pre-line">{f.helpText}</div>}
</div>
) : (
<>
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
{f.type === 'yes_no' ? (
<select className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)}>
<option value="">Select</option>
<option value="yes">Yes</option>
<option value="no">No</option>
</select>
) : f.type === 'date' ? (
<input type="date" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : f.type === 'numeric' ? (
<input type="number" className="border rounded px-2 py-1 text-sm" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
) : (
<input type="text" className="border rounded px-2 py-1 text-sm w-full" value={formsData[idx]?.[f.id] || ''} onChange={e => update(idx, f.id, e.target.value)} />
)}
{f.helpText && <div className="text-xs text-gray-500 mt-1">{f.helpText}</div>}
</>
)}
</div>
))}
</div>
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
);
}
export default function RegistrationSuccessPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<RegistrationSuccessContent />
</Suspense>
);
}
+70
View File
@@ -0,0 +1,70 @@
"use client";
import React, { Suspense, useMemo, useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
function ResetPasswordContent() {
const search = useSearchParams();
const router = useRouter();
const token = search.get("token") || "";
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const canSubmit = useMemo(() => password.length >= 8 && password === confirm && !!token, [password, confirm, token]);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
try {
setLoading(true);
setStatus(null);
await apiFetch("/api/users/reset", { method: "POST", body: { token, password } });
setStatus("Your password has been reset. You can now log in.");
setTimeout(() => router.push("/login"), 1500);
} catch (err: any) {
setStatus(err?.message || "Reset failed");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<h1 className="text-2xl font-semibold mb-4">Reset your password</h1>
{!token && <p className="text-red-600 mb-4">Missing or invalid token.</p>}
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">New Password</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} required className="w-full border rounded px-3 py-2" />
<p className="text-xs text-gray-500 mt-1">At least 8 characters.</p>
</div>
<div>
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60">
{loading ? "Saving..." : "Set new password"}
</button>
</form>
{status && <p className="text-sm text-gray-700 mt-4">{status}</p>}
</div>
</main>
<Footer />
</div>
);
}
export default function ResetPasswordPage() {
return (
<Suspense fallback={<div className="p-6">Loading...</div>}>
<ResetPasswordContent />
</Suspense>
);
}
File diff suppressed because it is too large Load Diff
+161
View File
@@ -0,0 +1,161 @@
"use client";
import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
type BannerType = "info" | "warning" | "success" | "danger";
const TYPE_LABELS: Record<BannerType, string> = {
info: "Info (blue)",
warning: "Warning (amber)",
success: "Success (green)",
danger: "Danger (red)",
};
export default function SetBannerPage() {
const { user, token, loading } = useAuth();
const router = useRouter();
const [message, setMessage] = useState("");
const [type, setType] = useState<BannerType>("info");
const [liveFrom, setLiveFrom] = useState("");
const [liveTill, setLiveTill] = useState("");
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loading) return;
if (!user) { router.replace("/login"); return; }
if (user.role !== "admin" && user.role !== "supervisor") {
router.replace("/dashboard");
}
}, [user, loading, router]);
// Load current banner
useEffect(() => {
apiFetch<any>("/api/banner")
.then(b => {
if (!b) return;
setMessage(b.message || "");
setType(b.type || "info");
// Convert ISO strings back to datetime-local format
setLiveFrom(b.liveFrom ? toLocalInput(b.liveFrom) : "");
setLiveTill(b.liveTill ? toLocalInput(b.liveTill) : "");
})
.catch(() => {});
}, []);
function toLocalInput(iso: string) {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
// datetime-local wants "YYYY-MM-DDTHH:MM"
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setError(null);
setStatus(null);
setBusy(true);
try {
await apiFetch("/api/banner", {
method: "POST",
authToken: token,
body: {
message,
type,
liveFrom: liveFrom ? new Date(liveFrom).toISOString() : null,
liveTill: liveTill ? new Date(liveTill).toISOString() : null,
},
});
setStatus("Banner saved.");
} catch (e: any) {
setError(e?.message || "Failed to save banner.");
} finally {
setBusy(false);
}
};
const clear = async () => {
if (!token) return;
setError(null);
setStatus(null);
setBusy(true);
try {
await apiFetch("/api/banner", {
method: "POST",
authToken: token,
body: { message: "", type: "info", liveFrom: null, liveTill: null },
});
setMessage("");
setType("info");
setLiveFrom("");
setLiveTill("");
setStatus("Banner cleared.");
} catch (e: any) {
setError(e?.message || "Failed to clear banner.");
} finally {
setBusy(false);
}
};
return (
<div className="min-h-screen bg-gray-50 flex items-start justify-center px-4 py-12">
<div className="w-full max-w-lg border rounded-xl bg-white shadow-sm p-6">
<h1 className="text-xl font-semibold mb-1">Site Banner</h1>
<p className="text-sm text-gray-500 mb-6">Set a message that appears at the top of every page for visitors within the scheduled window.</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Message</label>
<input
className="w-full border rounded px-3 py-2 text-sm"
placeholder="e.g. Registration for Camp 2026 is now open!"
value={message}
onChange={e => setMessage(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Type</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={type} onChange={e => setType(e.target.value as BannerType)}>
{(Object.keys(TYPE_LABELS) as BannerType[]).map(t => (
<option key={t} value={t}>{TYPE_LABELS[t]}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Live from</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2 text-sm" value={liveFrom} onChange={e => setLiveFrom(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Live until</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2 text-sm" value={liveTill} onChange={e => setLiveTill(e.target.value)} />
</div>
</div>
<p className="text-xs text-gray-500">Leave dates empty to show the banner immediately with no end date. Times use your local timezone.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
{status && <p className="text-sm text-emerald-700">{status}</p>}
<div className="flex gap-3 pt-1">
<button type="submit" disabled={busy} className="px-4 py-2 rounded bg-indigo-600 text-white text-sm disabled:opacity-60 hover:bg-indigo-700">
{busy ? "Saving…" : "Save banner"}
</button>
<button type="button" onClick={clear} disabled={busy} className="px-4 py-2 rounded bg-gray-100 text-gray-700 text-sm disabled:opacity-60 hover:bg-gray-200">
Clear banner
</button>
</div>
</form>
</div>
</div>
);
}
+531
View File
@@ -0,0 +1,531 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiFetch, API_BASE } from "@/lib/api";
type Step = 1 | 2 | 3 | 4 | 5 | 6;
const TOTAL_STEPS = 5; // steps 1-5; step 6 is the done screen
function ProgressBar({ step }: { step: Step }) {
return (
<div className="flex items-center gap-1 mb-8">
{Array.from({ length: TOTAL_STEPS }, (_, i) => i + 1).map((s) => (
<React.Fragment key={s}>
<div
className={`h-2 flex-1 rounded-full transition-colors ${
s < step ? "bg-indigo-600" : s === step ? "bg-indigo-400" : "bg-gray-200"
}`}
/>
</React.Fragment>
))}
</div>
);
}
function Field({
label, required, hint, children,
}: {
label: string; required?: boolean; hint?: string; children: React.ReactNode;
}) {
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
{children}
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
</div>
);
}
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
export default function SetupPage() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [setupToken, setSetupToken] = useState<string | null>(null);
// SMTP test state
const [smtpTesting, setSmtpTesting] = useState(false);
const [smtpTestResult, setSmtpTestResult] = useState<{ ok: boolean; message: string } | null>(null);
// Step 1 — Organisation
const [orgName, setOrgName] = useState("");
const [orgTagline, setOrgTagline] = useState("");
const [orgEmail, setOrgEmail] = useState("");
const [orgPhone, setOrgPhone] = useState("");
const [orgAddress, setOrgAddress] = useState("");
const [appBaseUrl, setAppBaseUrl] = useState("");
// Step 2 — Admin account
const [adminName, setAdminName] = useState("");
const [adminEmail, setAdminEmail] = useState("");
const [adminPassword, setAdminPassword] = useState("");
const [adminConfirm, setAdminConfirm] = useState("");
// Step 3 — Branding
const [accentColor, setAccentColor] = useState("#2563eb");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [notifEmail, setNotifEmail] = useState("");
// Step 4 — Legal
const [legalOperatorName, setLegalOperatorName] = useState("");
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
const [legalIoName, setLegalIoName] = useState("");
const [legalIoEmail, setLegalIoEmail] = useState("");
// Step 5 — Email / SMTP (skippable)
const [smtpHost, setSmtpHost] = useState("");
const [smtpPort, setSmtpPort] = useState("587");
const [smtpSecure, setSmtpSecure] = useState(false);
const [smtpFrom, setSmtpFrom] = useState("");
const [smtpUser, setSmtpUser] = useState("");
const [smtpPass, setSmtpPass] = useState("");
// Check setup isn't already done
useEffect(() => {
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
.catch(() => {});
}, []);
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
};
// ── Step validators ───────────────────────────────────────────────────────
const handleStep1 = (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!orgName.trim()) { setError("Organisation name is required."); return; }
next();
};
const handleStep2 = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!adminName.trim()) { setError("Your name is required."); return; }
if (!adminEmail.trim()) { setError("Email is required."); return; }
if (adminPassword.length < 8) { setError("Password must be at least 8 characters."); return; }
if (adminPassword !== adminConfirm) { setError("Passwords do not match."); return; }
setLoading(true);
try {
const res = await apiFetch<{ token: string }>("/api/setup/register", {
method: "POST",
body: { adminName, adminEmail, adminPassword },
});
setSetupToken(res.token);
next();
} catch (e: any) {
setError(e?.message || "Failed to create admin account. Please try again.");
} finally {
setLoading(false);
}
};
const handleStep3 = (e: React.FormEvent) => { e.preventDefault(); setError(null); next(); };
const handleStep4 = (e: React.FormEvent) => { e.preventDefault(); setError(null); next(); };
// ── SMTP connection test ──────────────────────────────────────────────────
const handleTestSmtp = async () => {
setSmtpTesting(true);
setSmtpTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; message?: string }>("/api/settings/test-smtp", {
method: "POST",
authToken: setupToken,
body: {
host: smtpHost.trim(),
port: smtpPort.trim(),
secure: smtpSecure,
from: smtpFrom.trim() || undefined,
user: smtpUser.trim() || undefined,
pass: smtpPass || undefined,
},
});
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
} catch (e: any) {
setSmtpTestResult({ ok: false, message: e?.message || "Connection failed." });
} finally {
setSmtpTesting(false);
}
};
// ── Finish (called from step 5 or skip) ──────────────────────────────────
const handleFinish = async (skipSmtp = false) => {
setError(null);
setLoading(true);
try {
let logoUrl = "";
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, { method: "POST", body: fd });
if (uploadRes.ok) {
const up = await uploadRes.json();
logoUrl = up.url || "";
}
}
const settings: Record<string, string> = {
org_name: orgName.trim(),
accent_color: accentColor,
};
if (orgTagline.trim()) settings.org_tagline = orgTagline.trim();
if (orgEmail.trim()) settings.org_email = orgEmail.trim();
if (orgPhone.trim()) settings.org_phone = orgPhone.trim();
if (orgAddress.trim()) settings.org_address = orgAddress.trim();
if (appBaseUrl.trim()) settings.app_base_url = appBaseUrl.trim();
if (notifEmail.trim()) settings.reg_notification_emails = notifEmail.trim();
if (logoUrl) settings.logo_url = logoUrl;
// Legal
if (legalOperatorName.trim()) settings.legal_operator_name = legalOperatorName.trim();
if (legalWebsiteUrl.trim()) settings.legal_website_url = legalWebsiteUrl.trim();
if (legalEffectiveDate.trim()) settings.legal_effective_date = legalEffectiveDate.trim();
if (legalIoName.trim()) settings.legal_io_name = legalIoName.trim();
if (legalIoEmail.trim()) settings.legal_io_email = legalIoEmail.trim();
// SMTP (only if not skipped and host provided)
if (!skipSmtp && smtpHost.trim()) {
settings.smtp_host = smtpHost.trim();
settings.smtp_port = smtpPort.trim();
settings.smtp_secure = smtpSecure ? "true" : "false";
if (smtpFrom.trim()) settings.smtp_from = smtpFrom.trim();
if (smtpUser.trim()) settings.smtp_user = smtpUser.trim();
if (smtpPass.trim()) settings.smtp_pass = smtpPass.trim();
}
await apiFetch("/api/setup", {
method: "POST",
authToken: setupToken,
body: { settings },
});
next(); // → step 6 (done)
} catch (e: any) {
setError(e?.message || "Setup failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8">
{/* Heading */}
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-indigo-100 mb-3">
<svg className="w-7 h-7 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900">Welcome</h1>
<p className="text-sm text-gray-500 mt-1">Let&apos;s set up your events platform</p>
</div>
{step < 6 && <ProgressBar step={step} />}
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-50 border border-red-200 text-sm text-red-700">
{error}
</div>
)}
{/* ── Step 1: Organisation ──────────────────────────── */}
{step === 1 && (
<form onSubmit={handleStep1} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Organisation details</h2>
<p className="text-sm text-gray-500">These appear throughout the site and in emails.</p>
</div>
<Field label="Organisation name" required>
<input className={inputCls} placeholder="e.g. Hope Family Church"
value={orgName} onChange={e => setOrgName(e.target.value)} autoFocus />
</Field>
<Field label="Tagline">
<input className={inputCls} placeholder="e.g. Connecting community through events"
value={orgTagline} onChange={e => setOrgTagline(e.target.value)} />
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="Contact email">
<input type="email" className={inputCls} placeholder="admin@yourchurch.org"
value={orgEmail} onChange={e => setOrgEmail(e.target.value)} />
</Field>
<Field label="Phone number">
<input className={inputCls} placeholder="+27 12 345 6789"
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
</Field>
</div>
<Field label="Address">
<input className={inputCls} placeholder="123 Church St, City"
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
</Field>
<Field label="Site URL" hint="The public URL of this site — used in email links. e.g. https://events.yourchurch.org">
<input className={inputCls} placeholder="https://events.yourchurch.org"
value={appBaseUrl} onChange={e => setAppBaseUrl(e.target.value)} />
</Field>
<div className="pt-2 flex justify-end">
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">
Next
</button>
</div>
</form>
)}
{/* ── Step 2: Admin account ─────────────────────────── */}
{step === 2 && (
<form onSubmit={handleStep2} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Create admin account</h2>
<p className="text-sm text-gray-500">Your primary administrator login.</p>
</div>
<Field label="Full name" required>
<input className={inputCls} placeholder="Your name"
value={adminName} onChange={e => setAdminName(e.target.value)} autoFocus />
</Field>
<Field label="Email address" required>
<input type="email" className={inputCls} placeholder="admin@yourchurch.org"
value={adminEmail} onChange={e => setAdminEmail(e.target.value)} />
</Field>
<Field label="Password" required>
<input type="password" className={inputCls} placeholder="Minimum 8 characters"
value={adminPassword} onChange={e => setAdminPassword(e.target.value)} />
</Field>
<Field label="Confirm password" required>
<input type="password" className={inputCls} placeholder="Repeat password"
value={adminConfirm} onChange={e => setAdminConfirm(e.target.value)} />
</Field>
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<button type="submit" disabled={loading} className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{loading ? "Creating account…" : "Next →"}
</button>
</div>
</form>
)}
{/* ── Step 3: Branding ─────────────────────────────── */}
{step === 3 && (
<form onSubmit={handleStep3} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Branding &amp; notifications</h2>
<p className="text-sm text-gray-500">Customise the look of your site. You can change this later.</p>
</div>
<Field label="Accent / brand colour">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} value={accentColor}
onChange={e => setAccentColor(e.target.value)} placeholder="#2563eb" />
</div>
<div className="mt-2 h-8 rounded-lg" style={{ background: accentColor }} />
</Field>
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
{logoPreview && (
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
)}
</Field>
<Field label="Registration notification email"
hint="Who to notify when someone registers for an event. Separate multiple addresses with commas.">
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org"
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} />
</Field>
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">Next </button>
</div>
</form>
)}
{/* ── Step 4: Legal ─────────────────────────────────── */}
{step === 4 && (
<form onSubmit={handleStep4} className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Legal pages</h2>
<p className="text-sm text-gray-500">
These values populate your Terms of Use and Privacy Policy. All fields are optional
you can fill them in later under Admin Site Settings.
</p>
</div>
<Field label="Operator / responsible party"
hint='Shown in "Owned and operated by" on the Terms of Use page.'>
<input className={inputCls}
placeholder="Jane Smith on behalf of Example Church, City, South Africa"
value={legalOperatorName} onChange={e => setLegalOperatorName(e.target.value)} />
</Field>
<Field label="Website URL (without https://)"
hint="e.g. events.yourchurch.org">
<input className={inputCls} placeholder="events.yourchurch.org"
value={legalWebsiteUrl} onChange={e => setLegalWebsiteUrl(e.target.value)} />
</Field>
<Field label="Effective date" hint='e.g. "April 2026" or "1 April 2026"'>
<input className={inputCls} placeholder="April 2026"
value={legalEffectiveDate} onChange={e => setLegalEffectiveDate(e.target.value)} />
</Field>
<div className="border-t pt-4">
<p className="text-sm font-medium text-gray-700 mb-3">Information Officer (POPIA)</p>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="Full name">
<input className={inputCls} placeholder="Jane Smith"
value={legalIoName} onChange={e => setLegalIoName(e.target.value)} />
</Field>
<Field label="Email">
<input type="email" className={inputCls} placeholder="io@yourchurch.org"
value={legalIoEmail} onChange={e => setLegalIoEmail(e.target.value)} />
</Field>
</div>
</div>
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">Next </button>
</div>
</form>
)}
{/* ── Step 5: Email / SMTP (skippable) ─────────────── */}
{step === 5 && (
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Email delivery</h2>
<p className="text-sm text-gray-500">
Configure outgoing email so tickets, payment confirmations, and account
notifications get delivered. <span className="font-medium text-gray-700">You can skip this and configure it later</span> under
Admin Site Settings (where you can also test the connection).
</p>
</div>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="SMTP host">
<input className={inputCls} placeholder="smtp.yourprovider.com"
value={smtpHost} onChange={e => setSmtpHost(e.target.value)} autoFocus />
</Field>
<Field label="Port">
<input type="number" className={inputCls} placeholder="587"
value={smtpPort} onChange={e => setSmtpPort(e.target.value)} />
</Field>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="smtpSecure" checked={smtpSecure}
onChange={e => setSmtpSecure(e.target.checked)} className="rounded" />
<label htmlFor="smtpSecure" className="text-sm text-gray-700">Use TLS/SSL (port 465)</label>
</div>
<Field label="From address" hint="The address emails appear to come from.">
<input type="email" className={inputCls} placeholder="no-reply@yourchurch.org"
value={smtpFrom} onChange={e => setSmtpFrom(e.target.value)} />
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="SMTP username">
<input type="email" className={inputCls} placeholder="mail@yourchurch.org"
value={smtpUser} onChange={e => setSmtpUser(e.target.value)} autoComplete="username" />
</Field>
<Field label="SMTP password" hint="Stored encrypted.">
<input type="password" className={inputCls} placeholder="Password"
value={smtpPass} onChange={e => setSmtpPass(e.target.value)} autoComplete="new-password" />
</Field>
</div>
{smtpHost.trim() && (
<div className="flex items-center gap-3">
<button
type="button"
disabled={smtpTesting}
onClick={handleTestSmtp}
className="px-4 py-2 rounded-lg text-sm border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
{smtpTesting ? "Testing…" : "Test connection"}
</button>
{smtpTestResult && (
<span className={`text-sm ${smtpTestResult.ok ? "text-green-600" : "text-red-600"}`}>
{smtpTestResult.ok ? "✓ " : "✗ "}{smtpTestResult.message}
</span>
)}
</div>
)}
<div className="pt-2 flex justify-between items-center flex-wrap gap-2">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<div className="flex items-center gap-3">
<button
type="button"
disabled={loading}
onClick={() => handleFinish(true)}
className="px-4 py-2 rounded-lg text-sm text-gray-500 hover:bg-gray-100 disabled:opacity-50"
>
Skip for now
</button>
<button
type="button"
disabled={loading}
onClick={() => handleFinish(false)}
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{loading ? "Setting up…" : "Finish setup"}
</button>
</div>
</div>
</div>
)}
{/* ── Step 6: Done ─────────────────────────────────── */}
{step === 6 && (
<div className="text-center space-y-4">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-green-100 mb-2">
<svg className="w-8 h-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h2 className="text-xl font-bold text-gray-900">You&apos;re all set!</h2>
<p className="text-sm text-gray-500">
{orgName || "Your organisation"}&apos;s events platform is ready. Log in with the admin account you just created.
</p>
{!smtpHost && (
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
Email delivery is not configured yet. Set it up under Admin Site Settings after logging in.
</p>
)}
<button
onClick={() => router.push("/login")}
className="mt-4 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium"
>
Go to login
</button>
</div>
)}
{step < 6 && (
<p className="text-center text-xs text-gray-400 mt-6">Step {step} of {TOTAL_STEPS}</p>
)}
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
export function LoginForm() {
const { login } = useAuth();
const searchParams = useSearchParams();
const raw = searchParams.get("redirect") || "";
const hasExplicitRedirect = raw.startsWith("/") && !raw.startsWith("//");
const redirectTo = hasExplicitRedirect ? raw : "/dashboard";
const registerHref = redirectTo !== "/dashboard" ? `/register?redirect=${encodeURIComponent(redirectTo)}` : "/register";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
// If it's already a normal Error
if (err.message && typeof err.message === "string") {
// Handle case where message is accidentally JSON string
try {
const parsed = JSON.parse(err.message);
return parsed.message || err.message;
} catch {
return err.message;
}
}
// If API response object was thrown directly
if (err.message) return err.message;
return "Login failed";
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const loggedInUser = await login(email, password);
// Skip the generic /dashboard hop (which just redirects again once the role is known)
// and go straight to the role-specific dashboard, unless the caller asked for a specific
// page back (e.g. returning to a registration flow after login).
const target = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
window.location.href = target;
} catch (err: any) {
setError(getErrorMessage(err) || "Login failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
<div>
<label className="block text-sm font-medium mb-1">Email or phone</label>
<input
type="text"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded px-3 py-2 pr-20"
required
/>
<button
type="button"
onClick={() => setShowPassword((s) => !s)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-sm text-blue-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? "Hide" : "Show"}
</button>
</div>
<div className="mt-1">
<a
href="/forgot-password"
className="text-xs text-blue-600 hover:underline"
>
Forgot your password?
</a>
</div>
<div className="mt-1">
<a
href={registerHref}
className="text-xs text-blue-600 hover:underline"
>
Don't have an account? Sign up here.
</a>
</div>
</div>
{error && (
<div className="text-sm text-red-600">
<p>{error}</p>
{error.toLowerCase().includes('not yet active') && (
<p className="mt-1 text-gray-600">Check your inbox for an activation email. If you didn&apos;t receive it, try logging in again to resend it.</p>
)}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white rounded py-2 disabled:opacity-60"
>
{loading ? "Logging in..." : "Login"}
</button>
<p className="text-xs text-gray-500 text-center mt-3">
By signing in, you acknowledge that youve read and agree to our{" "}
<Link
href="/legal/terms"
target="_blank"
className="text-blue-600 hover:underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="/legal/privacy"
target="_blank"
className="text-blue-600 hover:underline"
>
Privacy Policy
</Link>.
</p>
</form>
);
}
@@ -0,0 +1,184 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { isValidZAPhone } from "@/lib/phone";
export function RegisterForm() {
const { register } = useAuth();
const searchParams = useSearchParams();
const raw = searchParams.get("redirect") || "";
const redirectTo = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard";
const loginHref = redirectTo !== "/dashboard" ? `/login?redirect=${encodeURIComponent(redirectTo)}` : "/login";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [phoneNumber, setPhoneNumber] = useState("");
const [notificationPreference, setNotificationPreference] = useState<"email" | "whatsapp" | "both">("email");
const [accepted, setAccepted] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const hasValidPhone = isValidZAPhone(phoneNumber);
const getErrorMessage = (err: any) => {
if (!err) return "Login failed";
// If it's already a normal Error
if (err.message && typeof err.message === "string") {
// Handle case where message is accidentally JSON string
try {
const parsed = JSON.parse(err.message);
return parsed.message || err.message;
} catch {
return err.message;
}
}
// If API response object was thrown directly
if (err.message) return err.message;
return "Registration failed. Please try again.";
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!accepted) {
setError("Please accept the Terms of Use and Privacy Policy before continuing.");
return;
}
setError(null);
setLoading(true);
try {
await register({
name,
email,
password,
phoneNumber: phoneNumber || undefined,
notificationPreference: hasValidPhone ? notificationPreference : "email",
});
window.location.href = redirectTo;
} catch (err: any) {
setError(getErrorMessage(err) || "Registration failed. Please try again.");
} finally {
setLoading(false);
}
};
return (
<form onSubmit={onSubmit} className="space-y-4 max-w-sm w-full">
<div>
<label className="block text-sm font-medium mb-1">Full Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full border rounded px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Phone Number (optional)</label>
<input
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="e.g. 082 123 4567"
className="w-full border rounded px-3 py-2"
/>
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium mb-1">Notification Preference</label>
<select
value={notificationPreference}
onChange={(e) => setNotificationPreference(e.target.value as "email" | "whatsapp" | "both")}
className="w-full border rounded px-3 py-2"
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
</div>
)}
<div className="flex items-start gap-2">
<input
id="terms"
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
className="mt-1 h-4 w-4 border-gray-300 rounded accent-blue-600"
required
/>
<label htmlFor="terms" className="text-sm text-gray-700 leading-snug">
I confirm that Ive read and agree to the{" "}
<Link
href="/legal/terms"
target="_blank"
className="text-blue-600 hover:underline"
>
Terms of Use
</Link>{" "}
and{" "}
<Link
href="/legal/privacy"
target="_blank"
className="text-blue-600 hover:underline"
>
Privacy Policy
</Link>.
</label>
</div>
<div className="mt-1">
<a
href={loginHref}
className="text-xs text-blue-600 hover:underline"
>
Already have an account? Sign in here.
</a>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<button
type="submit"
disabled={loading || !accepted}
className={`w-full rounded py-2 text-white transition ${
accepted
? "bg-blue-600 hover:bg-blue-700"
: "bg-gray-400 cursor-not-allowed"
}`}
>
{loading ? "Creating account..." : "Create account"}
</button>
</form>
);
}
@@ -0,0 +1,80 @@
import { ApiImage } from "@/components/shared/ApiImage";
type Event = {
id: string;
title: string;
description?: string;
startDate: string;
endDate: string;
registrationDeadline?: string | null;
goLiveAt?: string;
price: number;
picture?: string;
isSoldOut?: boolean;
};
import { formatDateTimeRange } from "@/lib/date";
export const EventCard = ({ event }: { event: Event }) => {
const dateRange = formatDateTimeRange(event.startDate, event.endDate);
return (
<div className="border rounded-xl overflow-hidden shadow-sm hover:shadow-md transition">
<a href={`/events/${event.id}`} className="block">
<ApiImage src={event.picture} alt={event.title} className="h-48 w-full object-cover" />
<div className="p-4">
<h3 className="text-lg font-semibold hover:underline">{event.title}</h3>
<p className="text-sm text-gray-500">{dateRange}</p>
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
</div>
</a>
<div className="px-4 pb-4">
<div className="flex gap-2">
<a
href={`/events/${event.id}`}
className="flex-1 text-center text-sm text-gray-700 border py-2 rounded hover:bg-gray-50"
>
View details
</a>
{(() => {
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
if (notYetOpen) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration not yet open">
Opens {goLive?.toLocaleString()}
</button>
);
}
if (closed) {
return (
<button disabled className="flex-1 text-center text-sm text-gray-500 bg-gray-200 py-2 rounded cursor-not-allowed" title="Registration closed">
Registration closed
</button>
);
}
if (event.isSoldOut) {
return (
<button disabled className="flex-1 text-center text-sm text-red-700 bg-red-50 border border-red-200 py-2 rounded cursor-not-allowed">
Sold Out
</button>
);
}
return (
<a
href={`/register/${event.id}`}
className="flex-1 text-center text-sm text-white bg-blue-600 py-2 rounded hover:bg-blue-700"
>
Register{event.price > 0 ? ` - R${event.price.toFixed(2)}` : ""}
</a>
);
})()}
</div>
</div>
</div>
);
};
@@ -0,0 +1,150 @@
"use client";
import React, { useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { Button } from "@/components/shared/Button";
export function EventForm({ onCreated }: { onCreated?: () => void }) {
const { token } = useAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [price, setPrice] = useState<number>(0);
const [picture, setPicture] = useState("");
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [uploadingImage, setUploadingImage] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const [requiresAuth, setRequiresAuth] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleImageFile(file: File) {
setUploadingImage(true);
setError(null);
try {
const fd = new FormData();
fd.append("image", file);
const data = await apiFetch<{ url: string }>("/api/uploads/event-image", {
method: "POST",
authToken: token,
body: fd,
});
setPicture(data.url);
setImagePreview(URL.createObjectURL(file));
} catch (e: any) {
setError(e?.message || "Image upload failed");
} finally {
setUploadingImage(false);
}
}
function clearImage() {
setImagePreview(null);
setPicture("");
}
async function submit(e: React.FormEvent) {
e.preventDefault();
if (!token) { setError("Please login"); return; }
setLoading(true);
setError(null);
try {
await apiFetch("/api/events", {
method: "POST",
authToken: token,
body: { title, description, startDate, endDate, price, picture, isHidden, requiresAuth },
});
onCreated?.();
setTitle(""); setDescription(""); setStartDate(""); setEndDate(""); setPrice(0); setPicture(""); setImagePreview(null); setIsHidden(false); setRequiresAuth(true);
} catch (e: any) {
setError(e?.message || "Failed to create event");
} finally {
setLoading(false);
}
}
const previewSrc = imagePreview || (picture ? resolveToApiOrigin(picture) : null);
return (
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Title</label>
<input className="w-full border rounded px-3 py-2" value={title} onChange={(e) => setTitle(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">Description</label>
<textarea className="w-full border rounded px-3 py-2" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium">Start</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={startDate} onChange={(e) => setStartDate(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium">End</label>
<input type="datetime-local" className="w-full border rounded px-3 py-2" value={endDate} onChange={(e) => setEndDate(e.target.value)} required />
</div>
</div>
<div>
<label className="block text-sm font-medium">Price (R)</label>
<input type="number" step="0.01" className="w-full border rounded px-3 py-2" value={price} onChange={(e) => setPrice(parseFloat(e.target.value))} />
</div>
<div>
<label className="block text-sm font-medium mb-1">Event image</label>
{previewSrc && (
<div className="relative inline-block mb-2">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={previewSrc} alt="Preview" className="h-32 object-cover rounded border" />
<button
type="button"
onClick={clearImage}
className="absolute top-1 right-1 bg-white text-red-500 hover:text-red-700 rounded px-1 text-xs shadow"
></button>
</div>
)}
<div className="flex gap-2 items-center">
<label className={`cursor-pointer px-3 py-2 text-sm rounded border bg-gray-50 hover:bg-gray-100 ${uploadingImage ? "opacity-50 pointer-events-none" : ""}`}>
<input
type="file"
accept="image/*"
className="hidden"
disabled={uploadingImage}
onChange={e => {
const file = e.target.files?.[0];
if (file) handleImageFile(file);
e.target.value = "";
}}
/>
{uploadingImage ? "Uploading…" : "Upload image"}
</label>
<span className="text-xs text-gray-400">or</span>
<input
className="flex-1 border rounded px-3 py-2 text-sm"
placeholder="https://…"
value={picture}
onChange={e => { setPicture(e.target.value); setImagePreview(null); }}
/>
</div>
</div>
<div className="border rounded-lg p-3 bg-gray-50 space-y-2">
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Visibility &amp; Access</div>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={isHidden} onChange={e => setIsHidden(e.target.checked)} />
<span className="text-sm text-gray-700">
<span className="font-medium">Hidden event</span> not shown in the public listing; accessible by direct link only
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={!requiresAuth} onChange={e => setRequiresAuth(!e.target.checked)} />
<span className="text-sm text-gray-700">
<span className="font-medium">Allow unauthenticated registration</span> guests can register without creating an account
</span>
</label>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" loading={loading}>Create Event</Button>
</form>
);
}
@@ -0,0 +1,49 @@
"use client";
import React, { useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
const { token } = useAuth();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
async function upload(file: File) {
if (!token) { setError("Please login"); return; }
setLoading(true);
setError(null);
try {
const fd = new FormData();
fd.append("image", file);
const data = await apiFetch<{ url: string }>(`/api/uploads/event-image`, {
method: "POST",
body: fd,
authToken: token,
});
onUploaded(data.url);
} catch (e: any) {
setError(e?.message || "Upload failed");
} finally {
setLoading(false);
}
}
return (
<div>
<input
ref={inputRef}
type="file"
accept="image/*"
className="block w-full text-sm"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
}}
/>
{loading && <p className="text-xs text-gray-600 mt-1">Uploading...</p>}
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
);
}
@@ -0,0 +1,10 @@
import React from "react";
export function FormWrapper({ title, children }: { title?: string; children: React.ReactNode }) {
return (
<div className="rounded border bg-white p-4 shadow-sm">
{title && <h2 className="mb-3 text-lg font-semibold">{title}</h2>}
<div className="space-y-3">{children}</div>
</div>
);
}
@@ -0,0 +1,8 @@
"use client";
import React from "react";
import { QRScanner } from "@/components/qr/QRScanner";
export function TicketScanner({ onScanned }: { onScanned: (content: string) => void }) {
return <QRScanner onResult={onScanned} />;
}
@@ -0,0 +1,89 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { brandColor } from "@/lib/siteConfig";
export const BottomNav = () => {
const { user, loading: authLoading, logout } = useAuth();
const pathname = usePathname();
const { settings } = useSiteSettings();
const accentColor = settings.accent_color || brandColor;
const handleLogout = () => {
logout();
window.location.href = "/";
};
const isActive = (href: string) => {
if (href.includes("#")) return false;
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
type NavItem = { href: string; label: string; icon: React.ElementType; onClick?: () => void };
// Auth items are only known once the token has been validated — omitting
// them entirely while loading avoids a Login/Register -> Dashboard/Logout
// flash on every load (see Navbar for the same pattern).
const items: NavItem[] = [
{ href: "/", label: "Home", icon: Home },
{ href: "/events", label: "Events", icon: Calendar },
{ href: "/#contact", label: "Contact", icon: Phone },
...(authLoading
? []
: user
? [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "#", label: "Logout", icon: LogOut, onClick: handleLogout },
]
: [
{ href: "/login", label: "Login", icon: LogIn },
{ href: "/register", label: "Register", icon: UserPlus },
]),
];
return (
<nav
className="md:hidden fixed bottom-0 inset-x-0 z-50 bg-white border-t border-gray-200"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex items-stretch h-16">
{items.map((item) => {
const Icon = item.icon;
const active = isActive(item.href);
const color = active ? accentColor : "#9ca3af";
if (item.onClick) {
return (
<button
key={item.label}
onClick={item.onClick}
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium"
style={{ color }}
>
<Icon size={22} strokeWidth={1.8} />
<span>{item.label}</span>
</button>
);
}
return (
<Link
key={item.href}
href={item.href}
className="flex-1 flex flex-col items-center justify-center gap-0.5 text-[10px] font-medium"
style={{ color }}
>
<Icon size={22} strokeWidth={1.8} />
<span>{item.label}</span>
</Link>
);
})}
</div>
</nav>
);
};
+28
View File
@@ -0,0 +1,28 @@
"use client";
import Link from "next/link";
import { appName } from "@/lib/siteConfig";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
export const Footer = () => {
const { settings } = useSiteSettings();
const displayName = settings.org_name || appName;
return (
<>
<footer className="bg-gray-900 text-white py-6 text-center text-sm">
<p>© {new Date().getFullYear()} {displayName}. All rights reserved.</p>
<div className="mt-2 space-x-4">
<Link href="/legal/terms" className="hover:underline text-gray-300">
Terms of Use
</Link>
<Link href="/legal/privacy" className="hover:underline text-gray-300">
Privacy Policy
</Link>
</div>
</footer>
{/* Spacer so content isn't hidden behind the fixed mobile bottom nav */}
<div className="h-16 md:hidden" aria-hidden="true" />
</>
);
};
+115
View File
@@ -0,0 +1,115 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useAuth } from "@/hooks/useAuth";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { BottomNav } from "./BottomNav";
import churchLogo from "@/app/church_logo.jpg";
import { appName, brandColor } from "@/lib/siteConfig";
import { resolveToApiOrigin } from "@/lib/api";
type NavLink = {
href: string;
label: string;
onClick?: () => void;
};
export const Navbar = () => {
const { user, loading: authLoading, logout } = useAuth();
const { settings, loading: settingsLoading } = useSiteSettings();
const displayName = settings.org_name || appName;
const displayColor = settings.accent_color || brandColor;
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const handleLogout = () => {
logout();
window.location.href = "/";
};
const navLinks: NavLink[] = [
{ href: "/", label: "Home" },
{ href: "/events", label: "Events" },
{ href: "/#contact", label: "Contact" },
];
const authLinks: NavLink[] = user
? [
{ href: "/dashboard", label: "Dashboard" },
{
href: "#",
label: "Logout",
onClick: handleLogout,
},
]
: [
{ href: "/login", label: "Login" },
{ href: "/register", label: "Register" },
];
return (
<>
<header className="bg-white shadow-md sticky top-0 z-50">
<nav className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<Link href="/" className="flex items-center gap-2">
{settingsLoading ? (
<div className="h-10 w-10 rounded-sm bg-gray-100 animate-pulse" />
) : logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="Logo" className="h-10 w-auto object-contain rounded-sm" />
) : (
<Image
src={churchLogo}
alt="Church logo"
width={50}
height={50}
className="rounded-sm object-contain"
priority
/>
)}
{!settingsLoading && (
<span className="text-xl font-bold" style={{ color: displayColor }}>{displayName}</span>
)}
</Link>
{/* Desktop links */}
<div className="hidden md:flex items-center space-x-6">
{navLinks.map(link => (
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-700 hover:text-blue-600"
>
{link.label}
</Link>
))}
{/* Auth links are only known once the token has been validated
rendering nothing here (instead of guessing "logged out") avoids
a Login/Register -> Dashboard/Logout flash on every load. */}
{!authLoading && authLinks.map(link =>
link.onClick ? (
<button
key={link.label}
onClick={link.onClick}
className="text-sm text-gray-700 hover:text-blue-600"
>
{link.label}
</button>
) : (
<Link
key={link.href}
href={link.href}
className="text-sm text-gray-700 hover:text-blue-600"
>
{link.label}
</Link>
)
)}
</div>
</nav>
</header>
<BottomNav />
</>
);
};
+106
View File
@@ -0,0 +1,106 @@
"use client";
import React from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { RoleBadge } from "@/components/shared/RoleBadge";
import { useRouter, usePathname } from "next/navigation";
const navLinks: { href: string; label: string; roles?: string[] }[] = [
{ href: "/dashboard/user", label: "My Events", roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/profile", label: "Profile & Security", roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/staff", label: "Staff", roles: ["staff"] },
{ href: "/dashboard/supervisor", label: "Supervisor", roles: ["supervisor"] },
{ href: "/dashboard/admin", label: "Admin", roles: ["admin"] },
{ href: "/dashboard/admin/settings", label: "Site Settings", roles: ["admin"] }
];
export function Sidebar() {
const { user } = useAuth();
const role = user?.role || "user";
return (
<aside className="w-60 shrink-0 border-r bg-white p-4">
<div className="mb-4">
<div className="font-semibold">Dashboard</div>
{user && (
<div className="mt-1 text-xs text-gray-600 flex items-center space-x-2">
<span>{user.name}</span>
<RoleBadge role={role as any} />
</div>
)}
</div>
<nav className="space-y-1">
{navLinks
.filter((l) => !l.roles || l.roles.includes(role))
.map((l) => (
<Link key={l.href} className="block rounded px-3 py-2 text-sm hover:bg-gray-50" href={l.href}>
{l.label}
</Link>
))}
</nav>
</aside>
);
}
export function MobileSidebar() {
const { user } = useAuth();
const router = useRouter();
const role = user?.role || "user";
const pathname = usePathname();
const options = navLinks.filter((l) => !l.roles || l.roles.includes(role));
// Determine selected value based on current path or default by role
// Use longest matching href to handle nested paths (e.g. /dashboard/user/profile over /dashboard/user)
const selectedFromPath = options
.filter((o) => pathname?.startsWith(o.href))
.sort((a, b) => b.href.length - a.href.length)[0]?.href;
const roleDefault: Record<string, string> = {
admin: "/dashboard/admin",
supervisor: "/dashboard/supervisor",
staff: "/dashboard/staff",
user: "/dashboard/user",
};
const selectedValue = selectedFromPath || roleDefault[role] || options[0]?.href || "";
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
if (value && value !== selectedValue) router.push(value);
};
return (
<div className="md:hidden border-b bg-white px-4 py-3 shadow-sm">
<div className="flex items-center justify-between">
<div>
<div className="font-semibold">Dashboard</div>
{user && (
<div className="mt-0.5 text-xs text-gray-600 flex items-center space-x-2">
<span>{user.name}</span>
<RoleBadge role={role as any} />
</div>
)}
</div>
</div>
<label className="sr-only" htmlFor="mobile-dashboard-nav">Navigate dashboard</label>
<select
id="mobile-dashboard-nav"
className="mt-3 w-full rounded-md border-gray-300 text-sm py-2 px-3 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
value={selectedValue}
onChange={handleChange}
>
{/* Keep placeholder hidden if a value is selected */}
{!selectedValue && (
<option value="" disabled>
Select section...
</option>
)}
{options.map((l) => (
<option key={l.href} value={l.href}>
{l.label}
</option>
))}
</select>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
"use client";
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import Webcam from "react-webcam";
import { useQRScanner } from "@/hooks/useQRScanner";
export type QRScannerProps = {
onResult?: (text: string) => void;
onError?: (error: unknown) => void;
/** When true the camera stops; when it goes back to false the camera auto-resumes if it was on. */
paused?: boolean;
};
export type QRScannerHandle = {
/** Hard-stop the scanner (user must press "Scan Ticket" to restart). */
stop: () => void;
};
// Mobile-friendly QR scanner with start/stop control using the back camera via react-webcam
export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
function QRScanner({ onResult, onError, paused = false }, ref) {
const webcamRef = useRef<Webcam | null>(null);
const [localError, setLocalError] = useState<string | null>(null);
const [showCam, setShowCam] = useState<boolean>(false);
// Track the underlying HTMLVideoElement from react-webcam reliably
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
useEffect(() => {
let id: any;
if (showCam) {
id = setInterval(() => {
const anyCam = webcamRef.current as any;
const v: HTMLVideoElement | null = anyCam?.video ?? null;
if (v) { setVideoEl(v); clearInterval(id); }
}, 100);
} else {
setVideoEl(null);
}
return () => { if (id) clearInterval(id); };
}, [showCam]);
// Beep + cooldown state
const lastScanRef = useRef<number>(0);
const audioCtxRef = useRef<any>(null);
const playBeep = () => {
try {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
if (!AC) return;
if (!audioCtxRef.current) audioCtxRef.current = new AC();
const ctx = audioCtxRef.current as AudioContext;
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.type = "sine";
oscillator.frequency.value = 880;
gainNode.gain.value = 0.05;
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
const now = ctx.currentTime;
oscillator.start(now);
oscillator.stop(now + 0.12);
} catch {}
};
const wrappedOnResult = (text: string) => {
const nowTs = Date.now();
if (nowTs - lastScanRef.current < 2000) return;
lastScanRef.current = nowTs;
playBeep();
onResult?.(text);
};
const { active, toggle, stop, permissionError } = useQRScanner(videoEl, {
onResult: wrappedOnResult,
onError,
});
const constraints = useMemo(() => ({
facingMode: { ideal: "environment" },
width: { ideal: 1280 },
height: { ideal: 720 },
}), []);
const isSecure = typeof window !== "undefined" && window.isSecureContext;
const isOn = showCam;
// Stable stop function so effects and the ref handle can depend on it safely
const stopScan = useCallback(() => {
try { stop(); } catch {}
setShowCam(false);
}, [stop]);
// Expose hard-stop to parent via ref (used when filters change)
useImperativeHandle(ref, () => ({ stop: stopScan }), [stopScan]);
// Pause / auto-resume when the paused prop changes (e.g. a modal opens/closes)
const wasActiveRef = useRef(false);
useEffect(() => {
if (paused) {
if (showCam) {
wasActiveRef.current = true;
stopScan();
}
} else {
if (wasActiveRef.current) {
wasActiveRef.current = false;
// Re-show the camera — active is still true so the decode loop restarts automatically
// once the video element is acquired again.
setShowCam(true);
}
}
// showCam intentionally included: re-evaluate after stopScan sets it to false
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paused, stopScan]);
async function startScan() {
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
if (!(navigator as any)?.mediaDevices?.getUserMedia) {
setLocalError("Camera API is not available in this browser.");
return;
}
setLocalError(null);
try {
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false } as any);
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
setShowCam(true);
if (!active) toggle();
} catch (e: any) {
const name = e?.name || e?.message;
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
setLocalError("No suitable camera found. Try a device with a back camera.");
} else if (name === "NotReadableError") {
setLocalError("Camera is in use by another app. Close other apps and try again.");
} else if (!isSecure && !isLocal) {
setLocalError("Camera access requires HTTPS or running on http://localhost.");
} else {
setLocalError("Unable to access camera.");
}
if (active) stop();
setShowCam(false);
}
}
useEffect(() => {
return () => { stopScan(); };
}, [stopScan]);
return (
<div className="w-full max-w-md mx-auto">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">QR Scanner</h2>
<button
onClick={() => (isOn ? stopScan() : startScan())}
className={`px-4 py-2 rounded text-white ${isOn ? "bg-red-600" : "bg-blue-600"}`}
>
{isOn ? "Stop" : "Scan Ticket"}
</button>
</div>
<div className="relative rounded-lg overflow-hidden bg-black aspect-[3/4] sm:aspect-video">
{isOn ? (
<Webcam
ref={webcamRef}
audio={false}
videoConstraints={constraints}
mirrored={false}
forceScreenshotSourceSize
className="absolute inset-0 w-full h-full object-cover"
onUserMedia={() => setLocalError(null)}
onUserMediaError={(e) => {
const name = (e as any)?.name || (e as any)?.message;
const protocol = typeof window !== "undefined" ? window.location.protocol : "";
const host = typeof window !== "undefined" ? window.location.hostname : "";
const isLocal = host === "localhost" || host === "127.0.0.1";
if (protocol !== "https:" && !isLocal) {
setLocalError("Camera access requires HTTPS or running on http://localhost.");
return;
}
if (name === "NotAllowedError") {
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
} else if (name === "NotFoundError") {
setLocalError("No suitable camera found. Try a device with a back camera.");
} else {
setLocalError("Unable to access camera.");
}
}}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center text-white/80 text-sm p-4 text-center">
Camera is off. Tap "Scan Ticket" to start using the back camera.
</div>
)}
</div>
{(permissionError || localError) && (
<p className="text-red-600 text-sm mt-2">{permissionError || localError}</p>
)}
</div>
);
}
);
+533
View File
@@ -0,0 +1,533 @@
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { downloadCsv, mailtoReport, openPrintWindow } from "@/lib/export";
// Common small UI controls
function Section({ title, children, actions }: { title: string; children: React.ReactNode; actions?: React.ReactNode }) {
return (
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<div className="text-lg font-semibold">{title}</div>
<div className="flex gap-2">{actions}</div>
</div>
{children}
</div>
);
}
function MultiSelect({ options, value, onChange, className }: { options: { value: string; label: string }[]; value: string[]; onChange: (v: string[]) => void; className?: string }) {
const toggle = (v: string) => {
const set = new Set(value);
if (set.has(v)) set.delete(v); else set.add(v);
onChange(Array.from(set));
};
return (
<div className={"flex flex-wrap gap-2 " + (className || '')}>
{options.map(opt => (
<label key={opt.value} className={"px-2 py-1 text-xs rounded border cursor-pointer " + (value.includes(opt.value) ? "bg-indigo-600 text-white border-indigo-600" : "bg-white text-gray-800 border-gray-200") }>
<input type="checkbox" className="hidden" checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} />
{opt.label}
</label>
))}
</div>
);
}
export default function Reports() {
const { token, user } = useAuth();
const role = user?.role || 'user';
const canView = role === 'admin' || role === 'supervisor';
const [events, setEvents] = useState<any[]>([]);
const [loadingEvents, setLoadingEvents] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
(async () => {
try {
setLoadingEvents(true);
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
setEvents(Array.isArray(evs) ? evs : []);
} catch (e: any) {
setError(e?.message || 'Failed to load events');
} finally {
setLoadingEvents(false);
}
})();
}, [token, role]);
// Filters state
const [dateFrom, setDateFrom] = useState<string>("");
const [dateTo, setDateTo] = useState<string>("");
const [selectedEvents, setSelectedEvents] = useState<string[]>([]);
const [attendeesEventId, setAttendeesEventId] = useState<string>("");
const [includeCancelled, setIncludeCancelled] = useState<boolean>(false);
const [includePastEvents, setIncludePastEvents] = useState<boolean>(false);
// DATA
const [paymentsByEvent, setPaymentsByEvent] = useState<Record<string, any[]>>({});
const [registrationsByEvent, setRegistrationsByEvent] = useState<Record<string, any[]>>({});
const [loading, setLoading] = useState(false);
// Load initial selected events
useEffect(() => {
if (events.length > 0 && selectedEvents.length === 0) {
const nowIso = new Date().toISOString();
const filtered = events.filter(ev => includePastEvents || !ev.endDate || new Date(ev.endDate).toISOString() >= nowIso);
const ids = filtered.map(ev => ev.id);
setSelectedEvents(ids.slice(0, Math.min(ids.length, 3))); // pick first few by default
if (!attendeesEventId && ids.length > 0) setAttendeesEventId(ids[0]);
}
}, [events, includePastEvents, attendeesEventId]);
// Fetch payments per selected event (works for staff via /event/:id, and for supervisor/admin we could also use this)
const loadPayments = async (eventIds: string[]) => {
if (!token) return;
setLoading(true);
try {
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/payments/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
setPaymentsByEvent(byEv);
} finally {
setLoading(false);
}
};
// Fetch registrations for selected/attendees events
const loadRegistrations = async (eventIds: string[]) => {
if (!token) return;
setLoading(true);
try {
const byEv: Record<string, any[]> = {};
for (const id of eventIds) {
try {
const list = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(id)}`, { authToken: token });
byEv[id] = Array.isArray(list) ? list : [];
} catch (e) {
byEv[id] = [];
}
}
setRegistrationsByEvent(byEv);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (selectedEvents.length > 0) {
loadPayments(selectedEvents);
loadRegistrations(selectedEvents);
}
}, [token, selectedEvents]);
// Helpers
const paymentsRows = useMemo(() => {
// Flatten to rows applying date filter, grouped by event then user in presentation
const df = dateFrom ? new Date(dateFrom).getTime() : null;
const dt = dateTo ? new Date(dateTo).getTime() : null;
const rows: { eventId: string; eventTitle: string; userName: string; userEmail?: string; amount: number; method: string; isDonation?: boolean; createdAt: string; registrationId?: string }[] = [];
for (const evId of Object.keys(paymentsByEvent)) {
const ev = events.find(e => e.id === evId);
const evTitle = ev?.title || evId;
for (const p of paymentsByEvent[evId] || []) {
const t = new Date(p.createdAt).getTime();
if ((df && t < df) || (dt && t > dt)) continue;
// For reporting: if it's a donation, show the payer; otherwise show the user assigned to the registration
const user = p.isDonation ? (p.user || {}) : ((p.registration?.user || p.user) || {});
rows.push({
eventId: evId,
eventTitle: evTitle,
userName: user?.name || user?.email || p.userId || 'User',
userEmail: user?.email,
amount: p.amount,
method: p.method,
isDonation: p.isDonation,
createdAt: p.createdAt,
registrationId: p.registrationId || undefined,
});
}
}
// Sort by event, then user, then date
rows.sort((a,b) => (a.eventTitle.localeCompare(b.eventTitle) || (a.userName || '').localeCompare(b.userName || '') || new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()));
return rows;
}, [paymentsByEvent, dateFrom, dateTo, events]);
const attendeesRows = useMemo(() => {
const evId = attendeesEventId;
const regs = registrationsByEvent[evId] || [];
const filtered = regs.filter((r: any) => includeCancelled ? true : (r.status !== 'cancelled'));
const rows = filtered.map((r: any) => ({
name: r.user?.name || r.userId,
email: r.user?.email,
status: r.status,
options: (r.registrationOptions || []).map((ro: any) => `${ro.eventOption?.name || 'Option'} x${ro.quantity}`).join('; '),
registeredAt: r.createdAt,
}));
rows.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || ''));
return rows;
}, [registrationsByEvent, attendeesEventId, includeCancelled]);
const regTypeCounts = useMemo(() => {
// For selectedEvents gather counts of eventOption.name quantities
const counts: Record<string, Record<string, number>> = {}; // eventId -> optionName -> qty
for (const evId of selectedEvents) {
const regs = registrationsByEvent[evId] || [];
counts[evId] = counts[evId] || {};
for (const r of regs) {
if (r.status === 'cancelled') continue; // default exclude
for (const ro of (r.registrationOptions || [])) {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 0;
counts[evId][name] = (counts[evId][name] || 0) + qty;
}
}
}
return counts;
}, [registrationsByEvent, selectedEvents]);
// Actions for each report
const exportPaymentsCsv = () => {
downloadCsv(`payments_${new Date().toISOString().slice(0,10)}`, paymentsRows.map(r => ({
Event: r.eventTitle,
User: r.userName,
Email: r.userEmail || '',
Amount: r.amount,
Method: r.method,
Donation: r.isDonation ? 'Yes' : 'No',
Date: new Date(r.createdAt).toLocaleString()
})));
};
const printPayments = () => {
const html = tableHtml(['Event','User','Email','Amount','Method','Donation','Date'], paymentsRows.map(r => [
r.eventTitle,
r.userName,
r.userEmail || '',
String(r.amount),
r.method,
r.isDonation ? 'Yes' : 'No',
new Date(r.createdAt).toLocaleString()
]));
openPrintWindow('Payments Report', html);
};
const emailPayments = () => {
const summary = `Payments report generated on ${new Date().toLocaleString()}\nFilters: from ${dateFrom || '-'} to ${dateTo || '-'}; Events: ${selectedEvents.length}`;
mailtoReport('Payments Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
const exportAttendeesCsv = () => {
downloadCsv(`attendees_${attendeesEventId}_${new Date().toISOString().slice(0,10)}`, attendeesRows.map(r => ({
Name: r.name,
Email: r.email || '',
Status: r.status,
Options: r.options,
RegisteredAt: new Date(r.registeredAt).toLocaleString()
})));
};
const printAttendees = () => {
const html = tableHtml(['Name','Email','Status','Options','Registered At'], attendeesRows.map(r => [r.name, r.email || '', r.status, r.options, new Date(r.registeredAt).toLocaleString()]));
openPrintWindow('Attendees Report', html);
};
const emailAttendees = () => {
const ev = events.find(e => e.id === attendeesEventId);
const summary = `Attendees for ${ev?.title || attendeesEventId} generated on ${new Date().toLocaleString()}\nInclude cancelled: ${includeCancelled ? 'Yes' : 'No'}`;
mailtoReport('Attendees Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
const exportRegTypesCsv = () => {
const rows: any[] = [];
for (const evId of Object.keys(regTypeCounts)) {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId];
for (const type of Object.keys(byType)) rows.push({ Event: ev?.title || evId, Type: type, Quantity: byType[type] });
}
downloadCsv(`registration_types_${new Date().toISOString().slice(0,10)}`, rows);
};
const printRegTypes = () => {
const rows: string[][] = [];
for (const evId of Object.keys(regTypeCounts)) {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId];
for (const type of Object.keys(byType)) rows.push([ev?.title || evId, type, String(byType[type])]);
}
const html = tableHtml(['Event','Type','Quantity'], rows);
openPrintWindow('Registration Types Report', html);
};
const emailRegTypes = () => {
const summary = `Registration types report generated on ${new Date().toLocaleString()} for ${Object.keys(regTypeCounts).length} event(s).`;
mailtoReport('Registration Types Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
// Extra useful report: Ticket usage summary per event (Used vs Unused, requires ticket scans info already available via tickets API on staff pages)
const [ticketUsage, setTicketUsage] = useState<Record<string, { used: number; unused: number }>>({});
const loadTicketUsage = async (eventIds: string[]) => {
if (!token) return;
const result: Record<string, { used: number; unused: number }> = {};
for (const id of eventIds) {
try {
const tickets = await apiFetch<any[]>(`/api/tickets/event/${encodeURIComponent(id)}`, { authToken: token });
const used = tickets.filter(t => t.isUsed).length;
const unused = tickets.filter(t => !t.isUsed).length;
result[id] = { used, unused };
} catch (e) {
result[id] = { used: 0, unused: 0 };
}
}
setTicketUsage(result);
};
useEffect(() => { if (selectedEvents.length) loadTicketUsage(selectedEvents); }, [token, selectedEvents]);
const exportUsageCsv = () => {
const rows: any[] = [];
for (const evId of Object.keys(ticketUsage)) {
const ev = events.find(e => e.id === evId);
rows.push({ Event: ev?.title || evId, Used: ticketUsage[evId].used, Unused: ticketUsage[evId].unused });
}
downloadCsv(`ticket_usage_${new Date().toISOString().slice(0,10)}`, rows);
};
const printUsage = () => {
const rows: string[][] = [];
for (const evId of Object.keys(ticketUsage)) {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
rows.push([ev?.title || evId, String(tu.used), String(tu.unused)]);
}
const html = tableHtml(['Event','Used','Unused'], rows);
openPrintWindow('Ticket Usage Report', html);
};
const emailUsage = () => {
const summary = `Ticket usage report generated on ${new Date().toLocaleString()} for ${Object.keys(ticketUsage).length} event(s).`;
mailtoReport('Ticket Usage Report', summary + '\n\nPlease find the CSV/PDF attached.');
};
// UI
return (
<div>
{!canView && (
<div className="p-3 border rounded bg-yellow-50 text-yellow-800 text-sm mb-4">
You need supervisor or admin access to view reports.
</div>
)}
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-sm font-medium mb-2">Global filters</div>
<div className="flex flex-wrap items-end gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">From</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateFrom} onChange={e => setDateFrom(e.target.value)} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">To</label>
<input type="date" className="border rounded px-3 py-2 text-sm" value={dateTo} onChange={e => setDateTo(e.target.value)} />
</div>
<div className="flex-1 min-w-64">
<label className="block text-xs text-gray-600 mb-1">Select events</label>
<MultiSelect
options={events.map(ev => ({ value: ev.id, label: ev.title }))}
value={selectedEvents}
onChange={setSelectedEvents}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includePastEvents} onChange={e => setIncludePastEvents(e.target.checked)} /> Include past events in list
</label>
<button onClick={() => { loadPayments(selectedEvents); loadRegistrations(selectedEvents); }} className="px-3 py-2 text-sm rounded bg-gray-100 hover:bg-gray-200">Refresh data</button>
</div>
</div>
<Section
title="Payments between dates (grouped by event then user)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportPaymentsCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printPayments}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailPayments}>Email</button>
</>
}
>
{loading && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Event</th>
<th>User</th>
<th>Email</th>
<th>Amount</th>
<th>Method</th>
<th>Donation</th>
<th>Date</th>
</tr>
</thead>
<tbody>
{paymentsRows.length === 0 ? (
<tr><td colSpan={7} className="text-sm text-gray-500">No payments match the selected filters.</td></tr>
) : paymentsRows.map((r, idx) => (
<tr key={idx}>
<td>{r.eventTitle}</td>
<td>{r.userName}</td>
<td>{r.userEmail || ''}</td>
<td>R {Number(r.amount).toFixed(2)}</td>
<td>{r.method}</td>
<td>{r.isDonation ? 'Yes' : 'No'}</td>
<td>{new Date(r.createdAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Attendees per event and registration status"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportAttendeesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printAttendees}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailAttendees}>Email</button>
</>
}
>
<div className="flex flex-wrap items-center gap-3 mb-3">
<label className="text-sm">
<span className="text-gray-600 mr-2">Event</span>
<select className="border rounded px-3 py-2 text-sm" value={attendeesEventId} onChange={e => setAttendeesEventId(e.target.value)}>
{events.map(ev => (<option key={ev.id} value={ev.id}>{ev.title}</option>))}
</select>
</label>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={includeCancelled} onChange={e => setIncludeCancelled(e.target.checked)} /> Include cancelled registrations
</label>
<button className="px-3 py-1.5 text-sm rounded border" onClick={() => loadRegistrations([attendeesEventId])}>Refresh</button>
</div>
<div className="overflow-auto">
<table className="min-w-[640px]">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Options</th>
<th>Registered at</th>
</tr>
</thead>
<tbody>
{attendeesRows.length === 0 ? (
<tr><td colSpan={5} className="text-sm text-gray-500">No attendees for selected filters.</td></tr>
) : attendeesRows.map((r, idx) => (
<tr key={idx}>
<td>{r.name}</td>
<td>{r.email || ''}</td>
<td className="capitalize">{r.status}</td>
<td>{r.options}</td>
<td>{new Date(r.registeredAt).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
<Section
title="Registration type counts per event"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportRegTypesCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printRegTypes}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailRegTypes}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[480px]">
<thead>
<tr>
<th>Event</th>
<th>Registration Type</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
{Object.keys(regTypeCounts).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : (
Object.keys(regTypeCounts).flatMap(evId => {
const ev = events.find(e => e.id === evId);
const byType = regTypeCounts[evId] || {};
const rows = Object.keys(byType);
if (rows.length === 0) return [<tr key={evId}><td>{ev?.title || evId}</td><td colSpan={2} className="text-sm text-gray-500">No registrations</td></tr>];
return rows.map(type => (
<tr key={evId + type}>
<td>{ev?.title || evId}</td>
<td>{type}</td>
<td>{byType[type]}</td>
</tr>
));
})
)}
</tbody>
</table>
</div>
</Section>
<Section
title="Ticket usage summary (extra)"
actions={
<>
<button className="px-3 py-1.5 text-sm rounded border" onClick={exportUsageCsv}>Export CSV</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={printUsage}>Save as PDF</button>
<button className="px-3 py-1.5 text-sm rounded border" onClick={emailUsage}>Email</button>
</>
}
>
<div className="overflow-auto">
<table className="min-w-[420px]">
<thead>
<tr>
<th>Event</th>
<th>Used</th>
<th>Unused</th>
</tr>
</thead>
<tbody>
{Object.keys(ticketUsage).length === 0 ? (
<tr><td colSpan={3} className="text-sm text-gray-500">No data available. Select events and refresh.</td></tr>
) : Object.keys(ticketUsage).map(evId => {
const ev = events.find(e => e.id === evId);
const tu = ticketUsage[evId];
return (
<tr key={evId}>
<td>{ev?.title || evId}</td>
<td>{tu.used}</td>
<td>{tu.unused}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Section>
</div>
);
}
function tableHtml(headers: string[], rows: (string | number)[][]) {
const thead = `<tr>${headers.map(h => `<th>${escapeHtml(h)}</th>`).join('')}</tr>`;
const tbody = rows.map(r => `<tr>${r.map(c => `<td>${escapeHtml(String(c ?? ''))}</td>`).join('')}</tr>`).join('');
return `<table><thead>${thead}</thead><tbody>${tbody}</tbody></table>`;
}
function escapeHtml(s: string) {
return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { resolveToApiOrigin } from "@/lib/api";
type Props = {
src?: string | null;
alt?: string;
className?: string;
style?: React.CSSProperties;
};
export function ApiImage({ src, alt = "", className, style }: Props) {
const hasSrc = !!(src || "").trim();
const [resolved, setResolved] = React.useState<string | null>(null);
React.useEffect(() => {
const raw = (src || "").trim();
if (!raw) { setResolved(null); return; }
// Compute in the browser so that localhost origins are swapped to the actual device hostname.
// This has to stay in an effect (not computed during render) because the server-rendered pass
// has no `window`, so resolving synchronously would make the client's first render disagree
// with the server-rendered HTML (a hydration mismatch) — the round trip here is a browser-vs-
// server naming difference, not a network fetch.
const url = resolveToApiOrigin(raw);
setResolved(url);
}, [src]);
// No image was ever provided for this item — render nothing, same as before.
if (!hasSrc) return null;
// A src was provided but the browser-resolved URL isn't ready yet — reserve the same box the
// real <img> will occupy so the surrounding layout (e.g. a card) doesn't collapse to zero height
// and then jump once resolved — same class of flash as an unauthenticated nav link showing too early.
if (!resolved) return <div className={`${className || ""} bg-gray-100 animate-pulse`} style={style} aria-hidden="true" />;
return <img src={resolved} alt={alt} className={className} style={style} />;
}
@@ -0,0 +1,71 @@
"use client";
import React, { useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
type BannerType = "info" | "warning" | "success" | "danger";
interface Banner {
message: string;
type: BannerType;
liveFrom: string | null;
liveTill: string | null;
}
const STYLES: Record<BannerType, string> = {
info: "bg-blue-600 text-white",
warning: "bg-amber-400 text-amber-950",
success: "bg-emerald-600 text-white",
danger: "bg-red-600 text-white",
};
function dismissKey(banner: Banner) {
return `banner_dismissed_${banner.message}_${banner.liveFrom}_${banner.liveTill}`;
}
export function BannerBar() {
const [banner, setBanner] = useState<Banner | null>(null);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
apiFetch<Banner>("/api/banner")
.then(b => {
if (!b?.message) return;
setBanner(b);
const key = dismissKey(b);
if (typeof window !== "undefined" && localStorage.getItem(key) === "1") {
setDismissed(true);
}
})
.catch(() => {});
}, []);
if (!banner || !banner.message || dismissed) return null;
const now = Date.now();
const from = banner.liveFrom ? new Date(banner.liveFrom).getTime() : null;
const till = banner.liveTill ? new Date(banner.liveTill).getTime() : null;
if (from !== null && now < from) return null;
if (till !== null && now > till) return null;
const dismiss = () => {
if (typeof window !== "undefined") {
localStorage.setItem(dismissKey(banner), "1");
}
setDismissed(true);
};
return (
<div className={`w-full px-4 py-2.5 flex items-center justify-center gap-3 text-sm font-medium ${STYLES[banner.type ?? "info"]}`}>
<span className="flex-1 text-center">{banner.message}</span>
<button
onClick={dismiss}
aria-label="Dismiss banner"
className="shrink-0 opacity-70 hover:opacity-100 transition-opacity text-lg leading-none"
>
×
</button>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import React from "react";
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "outline" | "danger";
loading?: boolean;
};
export function Button({
variant = "primary",
loading = false,
className = "",
children,
disabled,
...rest
}: ButtonProps) {
const base = "inline-flex items-center justify-center rounded px-4 py-2 text-sm transition-colors";
const variants: Record<string, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-800 text-white hover:bg-black/90",
outline: "border border-gray-300 text-gray-800 hover:bg-gray-50",
danger: "bg-red-600 text-white hover:bg-red-700",
};
const cls = [base, variants[variant], disabled || loading ? "opacity-60 cursor-not-allowed" : "", className]
.filter(Boolean)
.join(" ");
return (
<button className={cls} disabled={disabled || loading} {...rest}>
{loading && (
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
)}
{children}
</button>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { SectionHeader } from "@/components/shared/SectionHeader";
export function ContactSection() {
const { settings } = useSiteSettings();
const email = settings.org_email || "";
const phone = settings.org_phone || "";
const address = settings.org_address || "";
if (!email && !phone && !address) return null;
return (
<section id="contact" className="bg-gray-100 py-12 px-4 text-center">
<SectionHeader title="Contact Us" />
{email && (
<p className="text-gray-700 mb-2">
📧{" "}
<a href={`mailto:${email}`} className="hover:underline">
{email}
</a>
</p>
)}
{phone && (
<p className="text-gray-700 mb-2">
📞{" "}
<a href={`tel:${phone}`} className="hover:underline">
{phone}
</a>
</p>
)}
{address && (
<p className="text-gray-700">
📍 {address}
</p>
)}
</section>
);
}
+13
View File
@@ -0,0 +1,13 @@
import React from "react";
export function Loader({ label = "Loading...", className = "" }: { label?: string; className?: string }) {
return (
<div className={["flex items-center space-x-2 text-gray-600", className].filter(Boolean).join(" ")}>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
<span className="text-sm">{label}</span>
</div>
);
}
@@ -0,0 +1,31 @@
"use client";
import React from "react";
import QRCode from "qrcode";
type Props = {
value: string;
size?: number;
alt?: string;
className?: string;
};
/**
* On-screen QR preview generated locally (via the `qrcode` package) instead of hitting
* api.qrserver.com removes a third-party network dependency from ticket views that are
* viewed frequently (dashboard, event-tickets, at-the-door).
*/
export function QrImage({ value, size = 120, alt = "QR", className }: Props) {
const [dataUrl, setDataUrl] = React.useState<string | null>(null);
React.useEffect(() => {
let cancelled = false;
QRCode.toDataURL(value, { width: size, margin: 1 })
.then((url) => { if (!cancelled) setDataUrl(url); })
.catch(() => { if (!cancelled) setDataUrl(null); });
return () => { cancelled = true; };
}, [value, size]);
if (!dataUrl) return <div className={`${className || ""} bg-gray-100 animate-pulse`} aria-hidden="true" />;
return <img src={dataUrl} alt={alt} className={className} />;
}
@@ -0,0 +1,20 @@
import React from "react";
export type Role = "user" | "staff" | "supervisor" | "admin";
export function RoleBadge({ role, className = "" }: { role: Role; className?: string }) {
const colors: Record<Role, string> = {
user: "bg-gray-100 text-gray-800",
staff: "bg-indigo-100 text-indigo-800",
supervisor: "bg-emerald-100 text-emerald-800",
admin: "bg-red-100 text-red-800",
};
return (
<span className={["inline-flex items-center rounded px-2 py-0.5 text-xs font-medium", colors[role], className]
.filter(Boolean)
.join(" ")}
>
{role}
</span>
);
}
@@ -0,0 +1,3 @@
export const SectionHeader = ({ title }: { title: string }) => (
<h2 className="text-2xl font-semibold mb-6 text-center">{title}</h2>
);
@@ -0,0 +1,29 @@
"use client";
import { useEffect, useRef } from "react";
import { useRouter, usePathname } from "next/navigation";
import { apiFetch } from "@/lib/api";
// Checks once per browser session whether first-time setup is needed.
// If the backend says yes and we're not already on /setup, redirect there.
export function SetupGuard() {
const router = useRouter();
const pathname = usePathname();
const checked = useRef(false);
useEffect(() => {
if (checked.current) return;
if (pathname?.startsWith("/setup")) return;
checked.current = true;
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
.then((data) => {
if (data?.needsSetup) {
router.replace("/setup");
}
})
.catch(() => {/* API down — don't block */});
}, [pathname]);
return null;
}

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