Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cf5f79d01 | ||
|
|
00464c086d | ||
|
|
4e07034bbd | ||
|
|
2d6c840614 | ||
|
|
235da5bf61 | ||
|
|
f6581eb441 | ||
|
|
0a8788afff | ||
|
|
b560d0d340 | ||
|
|
b2439a781e | ||
|
|
545f3d913e | ||
|
|
619b5b9a93 | ||
|
|
958a5a5950 | ||
|
|
f2be3a8b45 | ||
|
|
ce22f44f7c | ||
|
|
30b567f091 | ||
|
|
0b22eca0fb |
@@ -7,6 +7,41 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.26.0] - 2026-10-21
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded Prisma from 5.22 to 7.10. Prisma 7 connects through the `@prisma/adapter-pg` driver adapter (node-postgres) instead of the Rust query engine. The database URL moved from `schema.prisma` to the new `backend/prisma.config.js`, which loads `backend/.env` itself. All Prisma clients are now created in `src/config/db.js`, and the server shares one client instead of opening two connection pools. No schema or migration changes.
|
||||
- Prisma 7 checks TLS certificates on database connections. A `DATABASE_URL` that uses SSL with a self-signed or otherwise invalid certificate now fails to connect where Prisma 5 accepted it.
|
||||
- `npm run dev` in the backend now uses Node's built-in `node --watch` instead of `nodemon`.
|
||||
- The frontend now passes ESLint with no errors or warnings, down from 1,137, and `next build` runs ESLint again. It was skipped before (`eslint.ignoreDuringBuilds`), which is how the issues built up.
|
||||
- API data is now typed. `src/types` describes the records the backend returns, modelled on the Prisma schema, and `apiFetch` returns `unknown` unless the caller names a type, so untyped responses no longer pass silently.
|
||||
- Effects list everything they depend on. Data loaders are wrapped in `useCallback`, so a page reloads when its inputs change instead of showing stale data.
|
||||
- The pricing helpers that were copied into several pages now live in `src/lib/pricing.ts`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Internal links on the home page, the 404 page and "Forgot your password?" now navigate client-side instead of reloading the whole page.
|
||||
- Changing the page size on Admin → Users fetched the list twice; it now fetches once.
|
||||
- At the door, the refund history now shows a refund's reason. The page was reading a field that doesn't exist, so the reason never appeared.
|
||||
- In the Master Orders report, a ticket option named like one of the fixed columns (e.g. "name") could overwrite that column's value.
|
||||
- Removed code that could never run: an unreachable "Pay with Yoco" button on the registration form, an unused attendee-forms block on the registration success page, unused export helpers in Reports, and fonts that were downloaded but never applied.
|
||||
|
||||
### Security
|
||||
|
||||
- Backend `npm audit` now reports 0 vulnerabilities:
|
||||
- Patched `follow-redirects` (pulled in by `axios`) to 1.16.1, which fixes leaking credential headers on cross-origin redirects.
|
||||
- Removed `nodemon`, the only backend package that pulled in `braces`.
|
||||
- Jest's coverage loader now uses `js-yaml` 4, which drops the `sprintf-js` advisory.
|
||||
- Frontend: overrode `postcss-selector-parser` to 7.1.7, used by the Tailwind 3 build. The generated CSS is byte-identical. The remaining frontend findings (7 high) are all the same `braces` advisory (GHSA-vfj7-8cjw-p6xm). It has no patched release, comes in only through build and lint tools (Tailwind 3 and `eslint-config-next`), and only ever handles glob patterns from our own config, never user input.
|
||||
|
||||
## [1.25.1] - 2026-10-20
|
||||
|
||||
### Fixed
|
||||
|
||||
- On mobile, the cookie consent banner now sits above the bottom navigation bar instead of being hidden behind it.
|
||||
- The floating help button moves up above the cookie consent banner while it is showing, so the banner no longer covers it.
|
||||
|
||||
## [1.25.0] - 2026-10-20
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+1666
-372
File diff suppressed because it is too large
Load Diff
+12
-9
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"test": "jest",
|
||||
"postinstall": "prisma generate",
|
||||
"prisma:generate": "prisma generate",
|
||||
@@ -16,7 +16,8 @@
|
||||
"migrate:env-to-db:dry": "node scripts/migrate-env-to-db.js --dry-run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.4.2",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"@sentry/node": "^10.71.0",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -37,16 +38,18 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"jest": "^30.4.2",
|
||||
"nodemon": "^3.0.1",
|
||||
"prisma": "^5.4.2"
|
||||
"prisma": "^7.10.1"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
"uuid": "^11.1.1",
|
||||
"@istanbuljs/load-nyc-config": {
|
||||
"js-yaml": "^4.1.1"
|
||||
},
|
||||
"follow-redirects": "^1.16.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"prisma@5.22.0": true,
|
||||
"@prisma/client@5.22.0": true,
|
||||
"@prisma/engines@5.22.0": true,
|
||||
"prisma@7.10.1": true,
|
||||
"@prisma/engines@7.10.1": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"@parcel/watcher": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Prisma CLI config (migrate, generate, studio). Prisma 7 no longer reads the
|
||||
// datasource URL from schema.prisma or auto-loads .env, so both happen here.
|
||||
require('dotenv/config');
|
||||
const { defineConfig } = require('prisma/config');
|
||||
|
||||
module.exports = defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
migrations: {
|
||||
path: 'prisma/migrations',
|
||||
},
|
||||
datasource: {
|
||||
// process.env rather than env() so `prisma generate` still works on a
|
||||
// machine with no DATABASE_URL (generate never connects).
|
||||
url: process.env.DATABASE_URL,
|
||||
},
|
||||
});
|
||||
@@ -7,7 +7,6 @@ generator client {
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum UserRole {
|
||||
|
||||
@@ -24,7 +24,7 @@ const dotenv = require('dotenv');
|
||||
// Load .env from the backend root
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env') });
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const DRY_RUN = process.argv.includes('--dry-run');
|
||||
@@ -90,7 +90,7 @@ const MAPPINGS = [
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
console.log(`\n${'─'.repeat(60)}`);
|
||||
console.log(' Settings migration: .env → DB');
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
* Run: node scripts/seed-users.js
|
||||
*/
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
require('dotenv').config({ path: require('path').resolve(__dirname, '../.env') });
|
||||
const { createPrismaClient } = require('../src/config/db');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
const firstNames = [
|
||||
'Liam', 'Olivia', 'Noah', 'Emma', 'James', 'Ava', 'Elijah', 'Sophia',
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { PrismaPg } = require('@prisma/adapter-pg');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
// Prisma 7 talks to Postgres through the node-postgres driver adapter instead of
|
||||
// the old Rust engine. pg has no connect timeout by default; 5s matches Prisma 5.
|
||||
function createPrismaClient() {
|
||||
const adapter = new PrismaPg({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
return new PrismaClient({ adapter });
|
||||
}
|
||||
|
||||
module.exports = prisma;
|
||||
const prisma = createPrismaClient();
|
||||
|
||||
module.exports = prisma;
|
||||
module.exports.createPrismaClient = createPrismaClient;
|
||||
|
||||
@@ -24,13 +24,10 @@ const express = require('express');
|
||||
const { version: API_VERSION } = require('../package.json');
|
||||
const cors = require('cors');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = require('./config/db');
|
||||
const { notFound, errorHandler } = require('./middleware/errorMiddleware');
|
||||
const getRawBody = require('raw-body');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Initialize Express app
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
@@ -5,9 +5,6 @@ const nextConfig: NextConfig = {
|
||||
images: {
|
||||
domains: ['images.unsplash.com'],
|
||||
},
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === 'production',
|
||||
},
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
@@ -8478,9 +8478,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "6.1.4",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
|
||||
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
|
||||
"version": "7.1.7",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz",
|
||||
"integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -62,7 +62,8 @@
|
||||
"overrides": {
|
||||
"next": {
|
||||
"postcss": "^8.5.23"
|
||||
}
|
||||
},
|
||||
"postcss-selector-parser": "^7.1.7"
|
||||
},
|
||||
"allowScripts": {
|
||||
"@sentry/cli@2.58.6": true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -16,14 +17,14 @@ export default function ForgotPasswordPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const res: any = await apiFetch("/api/users/forgot", { method: "POST", body: { email } });
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/forgot", { method: "POST", body: { email } });
|
||||
setStatus(res?.message || "If that email exists, a password reset link has been sent.");
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
try {
|
||||
const parsed = JSON.parse(err?.message || "");
|
||||
const parsed = JSON.parse(errorMessage(err) || "");
|
||||
setStatus(parsed?.message || "Something went wrong");
|
||||
} catch {
|
||||
setStatus(err?.message || "Something went wrong");
|
||||
setStatus(errorMessage(err) || "Something went wrong");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -41,7 +42,7 @@ export default function ForgotPasswordPage() {
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Forgot your password?</h1>
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
|
||||
<p className="text-gray-600 text-sm 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>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
@@ -14,11 +16,11 @@ const VALID_ALIAS = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||
export default async function EventRedirectPage({ params }: { params: Promise<{ redirectUrl: string }> }) {
|
||||
const { redirectUrl } = await params;
|
||||
|
||||
let event: any = null;
|
||||
let event: (Event & { message?: string }) | null = null;
|
||||
|
||||
if (VALID_ALIAS.test(redirectUrl)) {
|
||||
try {
|
||||
event = await apiFetch<any>(`/api/events/by-alias/${redirectUrl}`);
|
||||
event = await apiFetch<Event & { message?: string }>(`/api/events/by-alias/${redirectUrl}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch event:", error);
|
||||
}
|
||||
@@ -32,9 +34,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<p className="text-gray-500 mb-6">
|
||||
The page you're looking for doesn't exist.
|
||||
</p>
|
||||
<a href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
<Link href="/" className="text-brand-600 hover:underline transition-all duration-200">
|
||||
Back to Home →
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -47,9 +49,9 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
|
||||
<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-brand-600 hover:underline">
|
||||
<Link href="/" className="mt-4 text-brand-600 hover:underline">
|
||||
Back to Home →
|
||||
</a>
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { saveToken } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { appName } from "@/lib/siteConfig";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
function ActivateAccountContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -24,14 +25,14 @@ function ActivateAccountContent() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setStatus(null);
|
||||
const data = await apiFetch<any>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
const data = await apiFetch<{ token?: string }>("/api/users/activate", { method: "POST", body: { token, password } });
|
||||
// Auto-login: save the session token (under the key AuthContext reads) and load the
|
||||
// dashboard fresh so the new session is picked up.
|
||||
if (data?.token) saveToken(data.token);
|
||||
setStatus("Your account is now active. Redirecting…");
|
||||
setTimeout(() => { window.location.href = "/dashboard"; }, 1500);
|
||||
} catch (err: any) {
|
||||
setStatus(err?.message || "Activation failed. The link may have expired.");
|
||||
} catch (err) {
|
||||
setStatus(errorMessage(err) || "Activation failed. The link may have expired.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { History } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
const ACTIONS = [
|
||||
"refund_created",
|
||||
@@ -174,14 +175,15 @@ export default function AdminAuditLogPage() {
|
||||
setEntries(res?.rows || []);
|
||||
setTotal(res?.total ?? 0);
|
||||
setPage(p);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load audit log");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load audit log");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, setError]);
|
||||
|
||||
useEffect(() => { if (token) load(1); }, [token, actionFilter, fromFilter, toFilter]);
|
||||
// load changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
|
||||
useEffect(() => { if (token) load(1); }, [token, load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-6">
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
|
||||
import type { EventCost, EventCostType, EventFinancials, CashupMethod, Event } from "@/types";
|
||||
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
|
||||
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
|
||||
@@ -36,25 +37,25 @@ export default function EventCashupPage() {
|
||||
|
||||
const isClosed = data?.event?.cashupStatus === "closed";
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(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 })
|
||||
apiFetch<Event>(`/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");
|
||||
setEventOptions((event.eventOptions || []).map((o) => ({ id: o.id, name: o.name })));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load cashup data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
@@ -151,8 +152,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
}
|
||||
setEditingId(null);
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save cost");
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save cost");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -163,8 +164,8 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
try {
|
||||
await apiFetch(`/api/costs/${id}`, { method: "DELETE", authToken: token });
|
||||
onChanged();
|
||||
} catch (e: any) {
|
||||
alert(e?.message || "Failed to delete cost");
|
||||
} catch (e) {
|
||||
alert(errorMessage(e) || "Failed to delete cost");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -179,7 +180,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
{isClosed && <div className="text-xs text-gray-500">This event is closed — costs can't be changed until it's reopened.</div>}
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full text-sm min-w-[600px]">
|
||||
@@ -257,11 +258,11 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
|
||||
</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)}>
|
||||
<select className="w-full border rounded px-2 py-1.5 text-sm" value={paidFromMethod} onChange={e => setPaidFromMethod(e.target.value as "" | CashupMethod)}>
|
||||
<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>
|
||||
<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>
|
||||
@@ -306,16 +307,17 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
|
||||
const load = () => {
|
||||
const load = useCallback(() => {
|
||||
if (!token || !eventId) return;
|
||||
setLoading(true);
|
||||
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
|
||||
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
|
||||
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
}, [eventId, token, onCashSummaryChange]);
|
||||
|
||||
useEffect(load, [eventId, token]);
|
||||
// onCashSummaryChange is a state setter in the parent, so this only reruns when the event/token change.
|
||||
useEffect(load, [load]);
|
||||
|
||||
const rows = data?.rows || [];
|
||||
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
|
||||
@@ -461,8 +463,8 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
|
||||
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
|
||||
});
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || "Failed to save count");
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || "Failed to save count");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -568,7 +570,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
<div className="text-sm font-medium">Cash denomination count</div>
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{reconciled.byMethod.cash.denominations.map((d: any) => (
|
||||
{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>
|
||||
@@ -590,7 +592,7 @@ function ReportTab({ eventId, token, data, isClosed }: {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.costs.map((c: any) => (
|
||||
{data.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 capitalize">{c.paidFromMethod || "—"}</td>
|
||||
@@ -625,7 +627,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => 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 draftLines = data.event.cashupDraft?.lines;
|
||||
|
||||
const initialLines: Record<CashupMethod, LineInput> = useMemo(() => {
|
||||
const base: Record<CashupMethod, LineInput> = { cash: { actualAmount: "", notes: "" }, card: { actualAmount: "", notes: "" }, eft: { actualAmount: "", notes: "" }, other: { actualAmount: "", notes: "" } };
|
||||
@@ -659,8 +661,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save draft");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -673,8 +675,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -687,8 +689,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
|
||||
onChanged();
|
||||
onClosed();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to close event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to close event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -700,8 +702,8 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reopen event");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -761,7 +763,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
</tbody>
|
||||
</table>
|
||||
{!isClosed && (
|
||||
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.</div>
|
||||
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below — there's no separate event-wide entry.</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -774,7 +776,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
|
||||
return denoms.length > 0 ? (
|
||||
<table className="text-sm">
|
||||
<tbody>
|
||||
{denoms.map((d: any) => (
|
||||
{denoms.map((d: PersonDenom & { id?: string }) => (
|
||||
<tr key={d.id || d.value}><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>
|
||||
|
||||
@@ -6,12 +6,14 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Wallet } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export default function CashupLandingPage() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -25,15 +27,15 @@ export default function CashupLandingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const now = new Date();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats } from "@/types";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -67,35 +68,35 @@ export default function AdminDashboardPage() {
|
||||
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
|
||||
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
|
||||
// computed server-side — no full payments/events list ever ships to the client.
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [dash, overviewData] = await Promise.all([
|
||||
apiFetch<any>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/admin", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
]);
|
||||
setPaymentStats(dash.paymentStats);
|
||||
setOverview(overviewData);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore errors for dashboard summaries
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setPaymentStats, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -105,7 +106,7 @@ export default function AdminDashboardPage() {
|
||||
}, 15000, !!token);
|
||||
|
||||
const paymentsTabValue = paymentStats
|
||||
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
|
||||
? (paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth) ?? 0
|
||||
: 0;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ClipboardList } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, FormResponse, Registration } from "@/types";
|
||||
|
||||
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
|
||||
|
||||
@@ -17,6 +19,16 @@ function fuzzyMatch(query: string, target: string): boolean {
|
||||
return tokens.every(tok => t.includes(tok));
|
||||
}
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: Registration) => r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
|
||||
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price || 0);
|
||||
return sum + unit * (opt.quantity || 0);
|
||||
}, 0);
|
||||
const totalPaidFor = (r: Registration) => (r.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
||||
|
||||
export default function AdminRegistrationsPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -28,8 +40,8 @@ export default function AdminRegistrationsPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
@@ -43,48 +55,48 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
// Expanded rows + form responses cache
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const [formResponses, setFormResponses] = useState<Record<string, any[]>>({});
|
||||
const [formResponses, setFormResponses] = useState<Record<string, FormResponse[]>>({});
|
||||
const [loadingForms, setLoadingForms] = useState<Set<string>>(new Set());
|
||||
|
||||
const loadRegistrations = async () => {
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load registrations");
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(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()) : []);
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
} catch {}
|
||||
};
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRegistrations();
|
||||
}, [token]);
|
||||
}, [loadRegistrations]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, [token, includePastEvents, includeInactiveEvents]);
|
||||
}, [loadEvents]);
|
||||
|
||||
const toggleExpand = async (reg: any) => {
|
||||
const toggleExpand = async (reg: Registration) => {
|
||||
const id = reg.id;
|
||||
const next = new Set(expanded);
|
||||
if (next.has(id)) {
|
||||
@@ -98,8 +110,8 @@ export default function AdminRegistrationsPage() {
|
||||
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 : []);
|
||||
const res = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(id)}`, { authToken: token! });
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
setFormResponses(prev => ({ ...prev, [id]: items }));
|
||||
} catch {
|
||||
setFormResponses(prev => ({ ...prev, [id]: [] }));
|
||||
@@ -109,7 +121,7 @@ export default function AdminRegistrationsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRegistration = async (reg: any) => {
|
||||
const cancelRegistration = async (reg: Registration) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
if (!confirm(`Cancel registration #${String(reg.id).slice(0, 8)} for ${reg.user?.name || reg.userId}?`)) return;
|
||||
@@ -117,27 +129,27 @@ export default function AdminRegistrationsPage() {
|
||||
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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to cancel registration");
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (reg: any, status: string) => {
|
||||
const updateStatus = async (reg: Registration, 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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update status");
|
||||
}
|
||||
};
|
||||
|
||||
const eventIds = useMemo(() => new Set(events.map((ev: any) => ev.id)), [events]);
|
||||
const eventIds = useMemo(() => new Set(events.map((ev) => ev.id)), [events]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return registrations.filter((r: any) => {
|
||||
return registrations.filter((r) => {
|
||||
if (!eventIds.has(r.eventId)) return false;
|
||||
if (eventFilter && r.eventId !== eventFilter) return false;
|
||||
if (statusFilter && r.status !== statusFilter) return false;
|
||||
@@ -145,10 +157,10 @@ export default function AdminRegistrationsPage() {
|
||||
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(" ");
|
||||
].map((x) => String(x || "")).join(" ");
|
||||
return fuzzyMatch(query.trim(), haystack);
|
||||
});
|
||||
}, [registrations, query, eventFilter, statusFilter]);
|
||||
}, [registrations, query, eventFilter, statusFilter, eventIds]);
|
||||
|
||||
const statusColor = (s: string) => {
|
||||
if (s === "paid") return "text-green-700 bg-green-50";
|
||||
@@ -158,15 +170,6 @@ export default function AdminRegistrationsPage() {
|
||||
return "text-gray-700 bg-gray-50";
|
||||
};
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans multiple
|
||||
// early-bird prices) — fall back to the old client-side estimate only for stale payloads.
|
||||
const totalDueFor = (r: any) => r.totalDueComputed ?? (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 totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
|
||||
|
||||
// Aggregate stats across the currently filtered registrations — counts by status, plus
|
||||
// revenue/outstanding totals (cancelled registrations are excluded from the money totals
|
||||
@@ -175,7 +178,7 @@ export default function AdminRegistrationsPage() {
|
||||
const counts: Record<string, number> = {};
|
||||
let totalRevenue = 0;
|
||||
let totalOutstanding = 0;
|
||||
filtered.forEach((r: any) => {
|
||||
filtered.forEach((r) => {
|
||||
counts[r.status] = (counts[r.status] || 0) + 1;
|
||||
if (r.status === "cancelled") return;
|
||||
const due = totalDueFor(r);
|
||||
@@ -275,7 +278,7 @@ export default function AdminRegistrationsPage() {
|
||||
|
||||
<div className="border rounded-xl bg-white shadow-sm">
|
||||
<ul className="divide-y text-sm">
|
||||
{filtered.map((r: any) => {
|
||||
{filtered.map((r) => {
|
||||
const totalDue = totalDueFor(r);
|
||||
const totalPaid = totalPaidFor(r);
|
||||
const outstanding = Math.max(totalDue - totalPaid, 0);
|
||||
@@ -336,7 +339,7 @@ export default function AdminRegistrationsPage() {
|
||||
<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) => {
|
||||
{(r.registrationOptions || []).map((opt) => {
|
||||
// A line can span multiple price tranches (e.g. tickets bought
|
||||
// before and after an early-bird tier expired) — show one row per
|
||||
// tranche so its own price/tier status is accurate, not blended.
|
||||
@@ -356,7 +359,7 @@ export default function AdminRegistrationsPage() {
|
||||
{opt.eventOption?.name || opt.eventOptionId}
|
||||
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
|
||||
</div>
|
||||
{tranches.map((t: any, idx: number) => {
|
||||
{tranches.map((t, idx) => {
|
||||
const unit = Number(t.priceSnapshot || 0);
|
||||
return (
|
||||
<div key={t.id || idx} className="text-gray-500">
|
||||
@@ -382,7 +385,7 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="text-xs text-gray-400">No payments recorded.</div>
|
||||
) : (
|
||||
<div className="grid sm:grid-cols-2 gap-2">
|
||||
{r.payments.map((p: any) => (
|
||||
{(r.payments || []).map((p) => (
|
||||
<div key={p.id} className="bg-white border rounded p-2 text-xs">
|
||||
<div className="font-medium">
|
||||
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
|
||||
@@ -409,11 +412,11 @@ export default function AdminRegistrationsPage() {
|
||||
<div className="text-xs text-gray-400">No form responses submitted.</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{responses.map((resp: any, idx: number) => (
|
||||
{responses.map((resp, idx) => (
|
||||
<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) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, CreditCard, DatabaseBackup, type LucideIcon } from "lucide-react";
|
||||
@@ -13,6 +13,7 @@ import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups";
|
||||
|
||||
@@ -178,9 +179,9 @@ function SiteSettingsPageInner() {
|
||||
setConsentRetentionDays(s.consent_medical_retention_days || "30");
|
||||
setNotifyOnImpersonation(s.notify_user_on_impersonation === "true");
|
||||
})
|
||||
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
|
||||
.catch((e: unknown) => setResult({ ok: false, message: errorMessage(e) || "Failed to load settings" }))
|
||||
.finally(() => setLoadingInitial(false));
|
||||
}, [token]);
|
||||
}, [token, setResult]);
|
||||
|
||||
// Clear result when switching tabs
|
||||
const switchTab = (id: TabId) => { setActiveTab(id); setResult(null); };
|
||||
@@ -194,8 +195,8 @@ function SiteSettingsPageInner() {
|
||||
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" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -253,8 +254,8 @@ function SiteSettingsPageInner() {
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -278,8 +279,8 @@ function SiteSettingsPageInner() {
|
||||
setSuggestedColors([]);
|
||||
setResult({ ok: true, message: "Reset to defaults." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to reset" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to reset" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -312,8 +313,8 @@ function SiteSettingsPageInner() {
|
||||
if (smtpPass.trim() !== "") { setSmtpPassSet(true); setSmtpPass(""); }
|
||||
setResult({ ok: true, message: "Saved." });
|
||||
reloadSettings();
|
||||
} catch (e: any) {
|
||||
setResult({ ok: false, message: e?.message || "Failed to save" });
|
||||
} catch (e) {
|
||||
setResult({ ok: false, message: errorMessage(e) || "Failed to save" });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -347,8 +348,9 @@ function SiteSettingsPageInner() {
|
||||
},
|
||||
});
|
||||
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 });
|
||||
} catch (e) {
|
||||
const raw = e instanceof ApiError && typeof e.data?.raw === "string" ? e.data.raw : undefined;
|
||||
setSmtpTestResult({ ok: false, message: errorMessage(e) || "SMTP test failed", raw });
|
||||
} finally {
|
||||
setSmtpTesting(false);
|
||||
}
|
||||
@@ -875,7 +877,7 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
|
||||
setStatus(res.status ?? null);
|
||||
setStatusMsg(res.message ?? null);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
await fetchConfig();
|
||||
setStatus("FAILED");
|
||||
setStatusMsg(null);
|
||||
@@ -919,12 +921,12 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setBusy(action);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/whatsapp/${action}`, { method: "POST", authToken: token, body });
|
||||
const res = await apiFetch<{ message?: string }>(`/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.`;
|
||||
} catch (e) {
|
||||
let msg = errorMessage(e) || `${action} failed.`;
|
||||
try { msg = JSON.parse(msg)?.message || msg; } catch {}
|
||||
await fetchConfig();
|
||||
if (!msg.includes("SESSION_NOT_FOUND")) {
|
||||
@@ -947,8 +949,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
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." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save token." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
@@ -965,8 +967,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
await apiFetch("/api/whatsapp/config", { method: "POST", authToken: token!, body: { token: "", instanceId: inputInstanceId.trim() } });
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to save Instance ID." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
@@ -976,11 +978,11 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setSavingInstance(true);
|
||||
setActionMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/whatsapp/create-instance", { method: "POST", authToken: token! });
|
||||
const res = await apiFetch<{ message?: string }>("/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." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to create instance." });
|
||||
} finally {
|
||||
setSavingInstance(false);
|
||||
}
|
||||
@@ -1224,8 +1226,8 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
setInputToken("");
|
||||
setInputInstanceId("");
|
||||
await fetchConfig();
|
||||
} catch (e: any) {
|
||||
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
|
||||
} catch (e) {
|
||||
setActionMsg({ type: "err", text: errorMessage(e) || "Failed to update." });
|
||||
} finally {
|
||||
setSavingToken(false);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { beginImpersonation } from "@/lib/impersonation";
|
||||
import { beginImpersonation, type ImpersonationStart } from "@/lib/impersonation";
|
||||
import { Users as UsersIcon } from "lucide-react";
|
||||
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
|
||||
interface UserItem {
|
||||
id: string;
|
||||
@@ -27,15 +29,6 @@ type Role = typeof roleOptions[number];
|
||||
|
||||
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
|
||||
|
||||
// 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();
|
||||
@@ -98,26 +91,26 @@ export default function AdminUsersPage() {
|
||||
setFetching(true);
|
||||
try {
|
||||
const url = buildQuery(p, ps);
|
||||
const res = await apiFetch<any>(url, { authToken: token });
|
||||
const res = await apiFetch<Paginated<UserItem>>(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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load users");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, pageSize, setError]);
|
||||
|
||||
// Initial load + reload on filter change
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, debouncedQuery, roleFilter, activeFilter]);
|
||||
// Initial load, and back to page 1 whenever a filter or the page size changes
|
||||
// (loadUsers is recreated when any of them do).
|
||||
useEffect(() => { if (token) loadUsers(1); }, [token, loadUsers]);
|
||||
|
||||
const goToPage = (p: number) => loadUsers(p);
|
||||
|
||||
const handlePageSizeChange = (newSize: number) => {
|
||||
setPageSize(newSize);
|
||||
loadUsers(1, newSize);
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
@@ -129,7 +122,7 @@ export default function AdminUsersPage() {
|
||||
if (!cEmail || !cPassword) { setError("Email and password are required"); return; }
|
||||
try {
|
||||
setCreating(true);
|
||||
const created = await apiFetch<any>("/api/users", {
|
||||
const created = await apiFetch<{ id?: string }>("/api/users", {
|
||||
method: "POST",
|
||||
body: { name: cName || cEmail.split("@")[0], email: cEmail, password: cPassword, phoneNumber: cPhone || undefined },
|
||||
});
|
||||
@@ -141,8 +134,8 @@ export default function AdminUsersPage() {
|
||||
resetCreateForm();
|
||||
setCreateOpen(false);
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create user");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
@@ -155,7 +148,7 @@ export default function AdminUsersPage() {
|
||||
if (!editingId) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
const payload: any = {
|
||||
const payload: Partial<UserItem> & { password?: string } = {
|
||||
name: editData.name,
|
||||
email: editData.email,
|
||||
role: editData.role,
|
||||
@@ -172,8 +165,8 @@ export default function AdminUsersPage() {
|
||||
setEditingId(null);
|
||||
setEditData({});
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save user");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -183,8 +176,8 @@ export default function AdminUsersPage() {
|
||||
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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to revoke sessions");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -197,10 +190,10 @@ export default function AdminUsersPage() {
|
||||
setImpBusy(true);
|
||||
setImpError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
const res = await apiFetch<ImpersonationStart>(`/api/admin/impersonate/${encodeURIComponent(impTarget.id)}`, { method: "POST", authToken: token, body: { reason: impReason } });
|
||||
beginImpersonation(res);
|
||||
} catch (e: any) {
|
||||
setImpError(e?.message || "Couldn't start impersonation");
|
||||
} catch (e) {
|
||||
setImpError(errorMessage(e) || "Couldn't start impersonation");
|
||||
setImpBusy(false);
|
||||
}
|
||||
};
|
||||
@@ -215,8 +208,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "PUT", authToken: token!, body: { twoFactorEnabled: false } });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to reset two-factor authentication");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to reset two-factor authentication");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,8 +218,8 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
await apiFetch(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token! });
|
||||
await loadUsers(page);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to deactivate user");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to deactivate user");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -235,8 +228,8 @@ export default function AdminUsersPage() {
|
||||
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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete user data");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { QrImage } from "@/components/shared/QrImage";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
function EventTicketsContent() {
|
||||
const { token, user } = useAuth();
|
||||
@@ -17,9 +19,9 @@ function EventTicketsContent() {
|
||||
const paramEventId = params.get("eventId") || "";
|
||||
const paramUserId = params.get("userId") || "";
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(paramEventId);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -36,7 +38,7 @@ function EventTicketsContent() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter((ev) => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -47,25 +49,27 @@ function EventTicketsContent() {
|
||||
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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
}, [token, setError]);
|
||||
|
||||
// Sync params → state
|
||||
// No event in the URL: default to the first upcoming one once the list arrives.
|
||||
useEffect(() => {
|
||||
if (paramEventId && paramEventId !== eventId) setEventId(paramEventId);
|
||||
if (paramUserId && paramUserId !== selectedUserId)
|
||||
setSelectedUserId(paramUserId);
|
||||
if (paramEventId || events.length === 0) return;
|
||||
setEventId(events[0].id);
|
||||
router.replace(
|
||||
`?eventId=${encodeURIComponent(events[0].id)}${
|
||||
selectedUserId ? `&userId=${encodeURIComponent(selectedUserId)}` : ""
|
||||
}`
|
||||
);
|
||||
}, [events, paramEventId, router, selectedUserId]);
|
||||
|
||||
// Sync params → state (setting the value it already has is a no-op)
|
||||
useEffect(() => {
|
||||
if (paramEventId) setEventId(paramEventId);
|
||||
if (paramUserId) setSelectedUserId(paramUserId);
|
||||
}, [paramEventId, paramUserId]);
|
||||
|
||||
// Load tickets when event changes
|
||||
@@ -75,18 +79,18 @@ function EventTicketsContent() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const list = await apiFetch<any[]>(
|
||||
const list = await apiFetch<TicketRecord[]>(
|
||||
`/api/tickets/event/${encodeURIComponent(eventId)}`,
|
||||
{ authToken: token }
|
||||
);
|
||||
setTickets(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load tickets");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load tickets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, eventId]);
|
||||
}, [token, eventId, setError]);
|
||||
|
||||
// Build variants list for filter (only variants present in current event's tickets)
|
||||
const variantsList = useMemo(() => {
|
||||
@@ -138,7 +142,7 @@ function EventTicketsContent() {
|
||||
}, [tickets, eventId, selectedUserId, selectedVariantId]);
|
||||
|
||||
// Printing helpers
|
||||
const buildTicketHtmlCard = (t: any) => {
|
||||
const buildTicketHtmlCard = (t: TicketRecord) => {
|
||||
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";
|
||||
@@ -216,7 +220,7 @@ function EventTicketsContent() {
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
const printTickets = (ticketList: TicketRecord[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
// Chunk into pages of 8 (2 columns × 4 rows per A4 sheet)
|
||||
const PAGE_SIZE = 8;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -9,6 +9,7 @@ import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import { QrCode, Ticket, Activity, UserCheck, Clock, Users } from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import type { DashboardStats, RecentScan, ScanStats } from "@/types";
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
|
||||
@@ -35,31 +36,31 @@ export default function StaffDashboardPage() {
|
||||
// (/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 [stats, setStats] = useStableState<ScanStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const data = await apiFetch<any>("/api/stats/staff", { authToken: token });
|
||||
const data = await apiFetch<DashboardStats>("/api/stats/staff", { authToken: token });
|
||||
setStats(data.scanStats);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setStats, setRecentScans]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 10s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -104,7 +105,7 @@ export default function StaffDashboardPage() {
|
||||
{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) => (
|
||||
{recentScans.map((u) => (
|
||||
<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>
|
||||
@@ -122,9 +123,9 @@ export default function StaffDashboardPage() {
|
||||
<div>
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
|
||||
{stats?.byStaff?.length > 0 ? (
|
||||
{stats && stats.byStaff?.length > 0 ? (
|
||||
<ul className="text-sm text-gray-700 space-y-1">
|
||||
{stats.byStaff.map((s: any) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { QRScanner, QRScannerHandle } from "@/components/qr/QRScanner";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -10,19 +10,39 @@ import { QrCode } from "lucide-react";
|
||||
import { useOfflineScanning } from "@/components/scanner/useOfflineScanning";
|
||||
import { OfflineScanPanel } from "@/components/scanner/OfflineScanPanel";
|
||||
import type { SnapshotTicket } from "@/lib/offlineScan";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, RecentScan, ScanStats, Section } from "@/types";
|
||||
|
||||
// GET /api/tickets/scan-preview/:qr — just the fields the confirm step needs. Offline
|
||||
// scans build the same shape from the downloaded ticket list.
|
||||
type ScanTicket = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
eventOptionId?: string;
|
||||
quantity?: number;
|
||||
consentStatus?: string;
|
||||
event?: { id?: string; title?: string } | null;
|
||||
registrationOption?: {
|
||||
eventOption?: { id?: string; name?: string } | null;
|
||||
variant?: { name?: string } | null;
|
||||
registration?: { user?: { name?: string } | null } | null;
|
||||
} | null;
|
||||
attendee?: { name: string } | null;
|
||||
user?: { name?: string; email?: string } | null;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
};
|
||||
|
||||
export default function TicketScanningPage() {
|
||||
const router = useRouter();
|
||||
const scannerRef = useRef<QRScannerHandle>(null);
|
||||
const [lastResult, setLastResult] = useState<string | null>(null);
|
||||
const [scanInfo, setScanInfo] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: any } | null>(null);
|
||||
const [alreadyUsedModal, setAlreadyUsedModal] = useState<{ message: string; ticket?: ScanTicket } | null>(null);
|
||||
const [errorModal, setErrorModal] = useState<string | null>(null);
|
||||
const { token, user } = useAuth();
|
||||
const offline = useOfflineScanning(token);
|
||||
const [recentScans, setRecentScans] = useState<any[]>([]);
|
||||
const [stats, setStats] = useState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useState<RecentScan[]>([]);
|
||||
const [stats, setStats] = useState<ScanStats | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState<boolean>(false);
|
||||
const [successModal, setSuccessModal] = useState<{
|
||||
optionName: string;
|
||||
@@ -36,7 +56,7 @@ export default function TicketScanningPage() {
|
||||
// Confirm step state
|
||||
const [confirmModal, setConfirmModal] = useState<{
|
||||
qrCode: string;
|
||||
ticket: any;
|
||||
ticket: ScanTicket;
|
||||
remaining: number;
|
||||
offlineTicket?: SnapshotTicket; // scanned against the downloaded list (offline mode)
|
||||
} | null>(null);
|
||||
@@ -47,11 +67,11 @@ export default function TicketScanningPage() {
|
||||
const [confirmQtyRaw, setConfirmQtyRaw] = useState("1");
|
||||
|
||||
// Event selection state
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [includePastEvents, setIncludePastEvents] = useState(false);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("all");
|
||||
const [loadingEvents, setLoadingEvents] = useState<boolean>(false);
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [selectedSectionId, setSelectedSectionId] = useState<string>("all");
|
||||
const [loadingSections, setLoadingSections] = useState(false);
|
||||
|
||||
@@ -61,10 +81,10 @@ export default function TicketScanningPage() {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/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) {
|
||||
} catch {
|
||||
// non-fatal
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
@@ -90,7 +110,7 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
try {
|
||||
setLoadingSections(true);
|
||||
const list = await apiFetch<any[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
const list = await apiFetch<Section[]>(`/api/sections?eventId=${selectedEventId}`, { authToken: token });
|
||||
setSections(Array.isArray(list) ? list : []);
|
||||
setSelectedSectionId("all");
|
||||
} catch {
|
||||
@@ -120,7 +140,6 @@ export default function TicketScanningPage() {
|
||||
}, [selectedEventId, selectedSectionId]);
|
||||
|
||||
async function handleResult(text: string) {
|
||||
setLastResult(text);
|
||||
setError(null);
|
||||
setScanInfo(null);
|
||||
try {
|
||||
@@ -157,11 +176,11 @@ export default function TicketScanningPage() {
|
||||
|
||||
// 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;
|
||||
let preview: ScanTicket;
|
||||
try {
|
||||
preview = await apiFetch<any>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe: any) {
|
||||
setErrorModal(pe?.message || 'Failed to validate ticket');
|
||||
preview = await apiFetch<ScanTicket>(`/api/tickets/scan-preview/${encodeURIComponent(text)}`, { authToken: token });
|
||||
} catch (pe) {
|
||||
setErrorModal(errorMessage(pe) || 'Failed to validate ticket');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -182,7 +201,7 @@ export default function TicketScanningPage() {
|
||||
return;
|
||||
}
|
||||
const section = sections.find(s => s.id === selectedSectionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o: any) => o.eventOptionId);
|
||||
const allowedOptionIds = (section?.allowedOptions || []).map((o) => o.eventOptionId);
|
||||
if (!allowedOptionIds.includes(ticketOptionId)) {
|
||||
setErrorModal("This ticket is not valid for this section");
|
||||
return;
|
||||
@@ -192,7 +211,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
// Compute remaining qty
|
||||
const ticketQty = preview?.quantity || 1;
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (preview?.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = ticketQty - totalRedeemed;
|
||||
|
||||
if (remaining <= 0) {
|
||||
@@ -204,8 +223,8 @@ export default function TicketScanningPage() {
|
||||
setConfirmQty(remaining);
|
||||
setConfirmQtyRaw(String(remaining));
|
||||
setConfirmModal({ qrCode: text, ticket: preview, remaining });
|
||||
} catch (e: any) {
|
||||
setErrorModal(e?.message || "Failed to scan ticket");
|
||||
} catch (e) {
|
||||
setErrorModal(errorMessage(e) || "Failed to scan ticket");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,9 +258,9 @@ export default function TicketScanningPage() {
|
||||
if (offlineTicket) {
|
||||
try {
|
||||
await offline.scanLocally(offlineTicket, qty);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setErrorModal(e?.message || "Couldn't save the scan on this device");
|
||||
setErrorModal(errorMessage(e) || "Couldn't save the scan on this device");
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -251,7 +270,7 @@ export default function TicketScanningPage() {
|
||||
const url = `/api/tickets/scan/${encodeURIComponent(qrCode)}${
|
||||
selectedEventId && selectedEventId !== "all" ? `?eventId=${encodeURIComponent(selectedEventId)}` : ''
|
||||
}`;
|
||||
await apiFetch<any>(url, {
|
||||
await apiFetch(url, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -262,10 +281,10 @@ export default function TicketScanningPage() {
|
||||
},
|
||||
});
|
||||
refreshStatsAndRecent();
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSuccessModal(null);
|
||||
setScanInfo(null);
|
||||
const raw = e?.message || "Failed to scan ticket";
|
||||
const raw = errorMessage(e) || "Failed to scan ticket";
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
const msg: string | undefined = parsed?.message;
|
||||
@@ -280,28 +299,27 @@ export default function TicketScanningPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecent = async () => {
|
||||
const loadRecent = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await apiFetch<any[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
const list = await apiFetch<RecentScan[]>(`/api/tickets/scans/recent?limit=10`, { authToken: token });
|
||||
setRecentScans(list);
|
||||
} catch {}
|
||||
};
|
||||
const loadStats = async () => {
|
||||
}, [token]);
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingStats(true);
|
||||
const s = await apiFetch<any>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
const s = await apiFetch<ScanStats>(`/api/tickets/scans/stats`, { authToken: token });
|
||||
setStats(s);
|
||||
} catch {} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
const refreshStatsAndRecent = () => { loadRecent(); loadStats(); };
|
||||
}, [token]);
|
||||
const refreshStatsAndRecent = useCallback(() => { loadRecent(); loadStats(); }, [loadRecent, loadStats]);
|
||||
useEffect(() => {
|
||||
refreshStatsAndRecent();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
}, [refreshStatsAndRecent]);
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto w-full p-4">
|
||||
@@ -403,11 +421,11 @@ export default function TicketScanningPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats?.byStaff?.length > 0 && (
|
||||
{stats && 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) => (
|
||||
{stats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
@@ -419,7 +437,7 @@ export default function TicketScanningPage() {
|
||||
|
||||
<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) => (
|
||||
{recentScans.map((u) => (
|
||||
<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>
|
||||
@@ -533,8 +551,8 @@ export default function TicketScanningPage() {
|
||||
<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>
|
||||
{alreadyUsedModal.ticket.usages.map((u) => (
|
||||
<li key={u.id}>{new Date(u.scannedAt).toLocaleString()}{(u.quantityRedeemed ?? 1) > 1 ? ` (×${u.quantityRedeemed})` : ''}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -8,10 +8,23 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { BedDouble, Plus, Wand2, Printer, Trash2, Pencil, Eraser } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
type Group = { id: string; name: string; capacity: number | null; gender: "male" | "female" | null; leaderUserId: string | null; leader?: { id: string; name: string } | null };
|
||||
type AllocSet = { id: string; name: string; showOnTicket: boolean; groups: Group[]; assignments: { attendeeId: string; groupId: string }[] };
|
||||
type Person = { id: string; name: string; gender: string | null; dateOfBirth: string | null; registrationId: string; bookedBy: string };
|
||||
// GET /api/allocations/sets/:id/lists — medical/emergency only when ?medical=true and consent was given.
|
||||
type ListMember = {
|
||||
groupId: string; id: string; name: string; gender: string | null; dateOfBirth: string | null;
|
||||
phone: string | null; parentName: string | null; parentPhone: string | null; bookedBy: string; bookedByPhone: string | null;
|
||||
medical?: Record<string, unknown> | null; emergency?: Record<string, unknown> | null;
|
||||
};
|
||||
type SetLists = {
|
||||
set: { id: string; name: string };
|
||||
event: { id: string; title: string; startDate: string };
|
||||
groups: { id: string; name: string; capacity: number | null; gender: string | null; leader: string | null; members: ListMember[] }[];
|
||||
};
|
||||
type Board = { event: { id: string; title: string; startDate: string; collectGender: boolean; requiresParentalConsent: boolean }; sets: AllocSet[]; attendees: Person[]; leaders: { id: string; name: string }[]; unnamedPlaces: number };
|
||||
|
||||
const UNASSIGNED = "unassigned";
|
||||
@@ -67,7 +80,7 @@ export default function AllocationsPage() {
|
||||
const canView = useMemo(() => user?.role === "admin" || user?.role === "supervisor", [user]);
|
||||
useEffect(() => { if (!loading && !user) router.replace("/login"); }, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [board, setBoard] = useState<Board | null>(null);
|
||||
const [setId, setSetId] = useState("");
|
||||
@@ -80,7 +93,7 @@ export default function AllocationsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const upcoming = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 7 * 864e5)
|
||||
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
@@ -96,8 +109,8 @@ export default function AllocationsPage() {
|
||||
const b = await apiFetch<Board>(`/api/allocations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
setBoard(b);
|
||||
setSetId(id => (b.sets.some(s => s.id === id) ? id : b.sets[0]?.id || ""));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load allocations");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load allocations");
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -126,8 +139,8 @@ export default function AllocationsPage() {
|
||||
}));
|
||||
try {
|
||||
await call(`/api/allocations/sets/${current.id}/assignments`, "PUT", { attendeeId, groupId });
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't move them");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't move them");
|
||||
load();
|
||||
}
|
||||
};
|
||||
@@ -138,8 +151,8 @@ export default function AllocationsPage() {
|
||||
const msg = await fn();
|
||||
if (msg) setInfo(msg);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -160,19 +173,19 @@ export default function AllocationsPage() {
|
||||
const printLists = async (master: boolean, medical: boolean) => {
|
||||
if (!current || !board) return;
|
||||
try {
|
||||
const data = await apiFetch<any>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const data = await apiFetch<SetLists>(`/api/allocations/sets/${current.id}/lists${medical ? "?medical=true" : ""}`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const row = (m: any) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
const row = (m: ListMember) => `<tr><td>${esc(m.name)}</td><td>${esc(genderLabel(m.gender))}</td><td>${esc(ageOn(m.dateOfBirth, board.event.startDate) ?? "")}</td>`
|
||||
+ `<td>${esc(m.phone || m.parentPhone || m.bookedByPhone || "")}</td><td>${esc(m.parentName || m.bookedBy || "")}</td>`
|
||||
+ (medical ? `<td>${esc(m.medical ? Object.values(m.medical).filter(Boolean).join("; ") : "")}</td><td>${esc(m.emergency ? Object.values(m.emergency).filter(Boolean).join(", ") : "")}</td>` : "") + `</tr>`;
|
||||
const head = `<tr><th>Name</th><th>Gender</th><th>Age</th><th>Phone</th><th>Parent / booked by</th>${medical ? "<th>Medical</th><th>Emergency contact</th>" : ""}</tr>`;
|
||||
let body = "";
|
||||
if (master) {
|
||||
const all = data.groups.flatMap((g: any) => g.members.map((m: any) => ({ ...m, group: g.name }))).sort((a: any, b: any) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m: any) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
const all = data.groups.flatMap((g) => g.members.map((m) => ({ ...m, group: g.name }))).sort((a, b) => a.name.localeCompare(b.name));
|
||||
body = `<h1>${esc(data.event.title)} — ${esc(data.set.name)} (master list)</h1><table><tr><th>${esc(data.set.name)}</th>${head.slice(4)}${all.map((m) => row(m).replace("<tr>", `<tr><td>${esc(m.group)}</td>`)).join("")}</table>`;
|
||||
} else {
|
||||
body = data.groups.map((g: any) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
body = data.groups.map((g) => `<section><h1>${esc(data.event.title)} — ${esc(g.name)}</h1>`
|
||||
+ `<p>${esc(data.set.name)}${g.leader ? ` · Leader: ${esc(g.leader)}` : ""} · ${g.members.length}${g.capacity ? ` / ${g.capacity}` : ""} people</p>`
|
||||
+ `<table>${head}${g.members.map(row).join("")}</table></section>`).join("");
|
||||
}
|
||||
@@ -182,8 +195,8 @@ export default function AllocationsPage() {
|
||||
section{page-break-after:always} section:last-child{page-break-after:auto}
|
||||
</style></head><body>${body}<script>window.onload=function(){window.print()}</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't load the lists");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't load the lists");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -10,6 +10,16 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
|
||||
|
||||
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
|
||||
|
||||
// GET /api/tickets/event/:id — a bare list (older builds wrapped it in `tickets` or `data`).
|
||||
type EventTicketsResponse = TicketRecord[] | { tickets?: TicketRecord[]; data?: TicketRecord[] };
|
||||
const ticketsFrom = (res: EventTicketsResponse | null | undefined): TicketRecord[] =>
|
||||
Array.isArray(res) ? res : (res?.tickets || res?.data || []);
|
||||
|
||||
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
|
||||
|
||||
@@ -31,7 +41,7 @@ const MODE_ICONS: Record<Mode, LucideIcon> = {
|
||||
|
||||
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||
function fuzzyFilterRegs(allRegs: Registration[], search: string): Registration[] {
|
||||
if (!search) return allRegs;
|
||||
if (search.length === 1) {
|
||||
const q = search.toLowerCase();
|
||||
@@ -50,37 +60,24 @@ function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
|
||||
|
||||
// ─── 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) return base;
|
||||
const now = new Date();
|
||||
const hit = 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 hit.length ? hit[0].price : base;
|
||||
// Total due on a registration: the backend's tranche-aware total when present, else
|
||||
// the sum of its lines at their snapshot (or current list) prices.
|
||||
function registrationTotal(r: Registration): number {
|
||||
return r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? 0);
|
||||
return sum + price * (opt.quantity || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
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 vtiers = allTiers.filter((t: any) => t.variantId === variant.id);
|
||||
const tiers = vtiers.length ? vtiers : allTiers.filter((t: any) => !t.variantId);
|
||||
if (!tiers.length) return base;
|
||||
const now = new Date();
|
||||
const hit = 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 hit.length ? hit[0].price : base;
|
||||
}
|
||||
const totalPaidOn = (r: Registration) => (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
|
||||
function initQuantities(eventOptions: any[]): Record<string, number> {
|
||||
function initQuantities(eventOptions: EventOption[]): Record<string, number> {
|
||||
const map: Record<string, number> = {};
|
||||
(eventOptions || []).forEach((o: any) => {
|
||||
(eventOptions || []).forEach((o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
(o.variants as any[]).forEach((v: any) => { map[`${o.id}::${v.id}`] = 0; });
|
||||
(o.variants || []).forEach((v) => { map[`${o.id}::${v.id}`] = 0; });
|
||||
} else {
|
||||
map[o.id] = 0;
|
||||
}
|
||||
@@ -88,7 +85,7 @@ function initQuantities(eventOptions: any[]): Record<string, number> {
|
||||
return map;
|
||||
}
|
||||
|
||||
function ticketLabel(t: any): string {
|
||||
function ticketLabel(t: TicketRecord): string {
|
||||
const opt = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variant = t.registrationOption?.variant?.name;
|
||||
return variant ? `${opt} — ${variant}` : opt;
|
||||
@@ -97,12 +94,12 @@ function ticketLabel(t: any): string {
|
||||
export default function AtTheDoorPage() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const router = useRouter();
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
|
||||
const [eventOptions, setEventOptions] = useState<any[]>([]);
|
||||
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
|
||||
const [showOptionsModal, setShowOptionsModal] = useState(false);
|
||||
const [pendingUser, setPendingUser] = useState<any | null>(null);
|
||||
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
|
||||
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [doorAllowsNames, setDoorAllowsNames] = useState(false);
|
||||
const [doorYouthStart, setDoorYouthStart] = useState<string | null>(null);
|
||||
@@ -110,7 +107,7 @@ export default function AtTheDoorPage() {
|
||||
const [doorNaming, setDoorNaming] = useState(false);
|
||||
const [doorEntries, setDoorEntries] = useState<AttendeeEntries>({});
|
||||
const [doorDelivery, setDoorDelivery] = useState<TicketDelivery>("payer");
|
||||
const [pendingEditReg, setPendingEditReg] = useState<any | null>(null);
|
||||
const [pendingEditReg, setPendingEditReg] = useState<Registration | null>(null);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
// Already-issued ticket quantity per option/variant, keyed like `quantities` — a
|
||||
@@ -119,7 +116,7 @@ export default function AtTheDoorPage() {
|
||||
const [minQuantities, setMinQuantities] = useState<Record<string, number>>({});
|
||||
|
||||
const [showDonationModal, setShowDonationModal] = useState(false);
|
||||
const [donationUser, setDonationUser] = useState<any | null>(null);
|
||||
const [donationUser, setDonationUser] = useState<UserSummary | null>(null);
|
||||
|
||||
const [showNewAttendeeModal, setShowNewAttendeeModal] = useState(false);
|
||||
const [newAttendeeSeed, setNewAttendeeSeed] = useState("");
|
||||
@@ -128,7 +125,7 @@ export default function AtTheDoorPage() {
|
||||
if (!token) return;
|
||||
(async () => {
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
|
||||
const active = (evs || []).filter(ev => {
|
||||
@@ -158,7 +155,7 @@ export default function AtTheDoorPage() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const ev = await apiFetch(`/api/events/${eventId}`, {
|
||||
const ev = await apiFetch<Event>(`/api/events/${eventId}`, {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
@@ -180,19 +177,19 @@ export default function AtTheDoorPage() {
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading]);
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [mode, setMode] = useState<Mode>("registration");
|
||||
|
||||
const [activeRegistration, setActiveRegistration] = useState<any | null>(null);
|
||||
const [activeRegistration, setActiveRegistration] = useState<Registration | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
|
||||
const handleRegistrationCreated = (registration: any) => {
|
||||
const handleRegistrationCreated = (registration: Registration) => {
|
||||
setActiveRegistration(registration); // 🚀 Jump automatically (see effect below)
|
||||
};
|
||||
|
||||
const handleRegistrationSelected = (registration: any) => {
|
||||
const handleRegistrationSelected = (registration: Registration) => {
|
||||
setActiveRegistration(registration);
|
||||
};
|
||||
|
||||
@@ -227,8 +224,8 @@ export default function AtTheDoorPage() {
|
||||
for (const [key, minQty] of Object.entries(minQuantities)) {
|
||||
if (minQty > 0 && (quantities[key] || 0) < minQty) {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
const opt = eventOptions.find((o: any) => o.id === eventOptionId);
|
||||
const variant = variantId ? (opt?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const opt = eventOptions.find((o) => o.id === eventOptionId);
|
||||
const variant = variantId ? (opt?.variants || []).find((v) => v.id === variantId) : null;
|
||||
const label = variant ? `${opt?.name || "item"} (${variant.name})` : (opt?.name || "item");
|
||||
setError(`Cannot reduce "${label}" below the ${minQty} already issued`);
|
||||
return;
|
||||
@@ -244,7 +241,7 @@ export default function AtTheDoorPage() {
|
||||
try {
|
||||
if (pendingEditReg) {
|
||||
// Edit existing registration — replace options via PUT
|
||||
let res = await apiFetch(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
let res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/options`, {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { options: opts }
|
||||
@@ -253,14 +250,14 @@ export default function AtTheDoorPage() {
|
||||
if (doorCoupon) {
|
||||
// Applied after the items are saved, so it's worked out on the final items.
|
||||
try {
|
||||
res = await apiFetch(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
res = await apiFetch<Registration>(`/api/registrations/${pendingEditReg.id}/coupon`, {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { code: doorCoupon.code },
|
||||
});
|
||||
message = `Registration updated — code ${doorCoupon.code} applied`;
|
||||
} catch (ce: any) {
|
||||
message = `Registration updated, but the code wasn't applied: ${ce?.message || "unknown error"}`;
|
||||
} catch (ce) {
|
||||
message = `Registration updated, but the code wasn't applied: ${errorMessage(ce) || "unknown error"}`;
|
||||
}
|
||||
}
|
||||
setShowOptionsModal(false);
|
||||
@@ -269,7 +266,7 @@ export default function AtTheDoorPage() {
|
||||
handleRegistrationSelected(res);
|
||||
} else if (pendingUser) {
|
||||
// New attendee
|
||||
const res = await apiFetch("/api/registrations/manual", {
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -291,8 +288,8 @@ export default function AtTheDoorPage() {
|
||||
if (res?.couponError) setError(`Registered, but the code wasn't applied: ${res.couponError}`);
|
||||
handleRegistrationCreated(res);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed");
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
@@ -311,7 +308,7 @@ export default function AtTheDoorPage() {
|
||||
eventOptions.forEach(o => {
|
||||
if (!o.isMainTicket) return;
|
||||
if ((o.variants || []).length > 0) {
|
||||
qtyMap[`${o.id}::${(o.variants as any[])[0].id}`] = 1;
|
||||
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
|
||||
} else {
|
||||
qtyMap[o.id] = 1;
|
||||
}
|
||||
@@ -332,27 +329,27 @@ export default function AtTheDoorPage() {
|
||||
// Re-fetches the registration fresh rather than trusting the (possibly up to 5-minutes-stale,
|
||||
// see DoorRegistrationPanel's polling interval) cached search-result snapshot, so the
|
||||
// already-issued-ticket floor below is always computed from current data.
|
||||
const handleEditRegistration = async (reg: any) => {
|
||||
const handleEditRegistration = async (reg: Registration) => {
|
||||
let freshReg = reg;
|
||||
try {
|
||||
freshReg = await apiFetch<any>(`/api/registrations/${reg.id}`, { authToken: token });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load latest registration data");
|
||||
freshReg = await apiFetch<Registration>(`/api/registrations/${reg.id}`, { authToken: token });
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load latest registration data");
|
||||
return;
|
||||
}
|
||||
|
||||
const qtyMap = initQuantities(eventOptions);
|
||||
const regOptions = freshReg.registrationOptions || freshReg.options || [];
|
||||
regOptions.forEach((ro: any) => {
|
||||
const regOptions = freshReg.registrationOptions || [];
|
||||
regOptions.forEach((ro) => {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
|
||||
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
|
||||
});
|
||||
|
||||
// Already-issued ticket quantity per option/variant — floor for the edit below
|
||||
const minQtyMap: Record<string, number> = {};
|
||||
regOptions.forEach((ro: any) => {
|
||||
regOptions.forEach((ro) => {
|
||||
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
|
||||
const issuedQty = (ro.tickets || []).reduce((s: number, t: any) => s + (t.quantity || 0), 0);
|
||||
const issuedQty = (ro.tickets || []).reduce((s, t) => s + (t.quantity || 0), 0);
|
||||
if (issuedQty > 0) minQtyMap[key] = (minQtyMap[key] || 0) + issuedQty;
|
||||
});
|
||||
|
||||
@@ -364,7 +361,7 @@ export default function AtTheDoorPage() {
|
||||
setShowOptionsModal(true);
|
||||
};
|
||||
|
||||
const handlePaymentCaptured = async (registration?: any, partial?: boolean) => {
|
||||
const handlePaymentCaptured = async (registration?: Registration, partial?: boolean) => {
|
||||
|
||||
if (partial && registration) {
|
||||
setActiveRegistration(registration);
|
||||
@@ -384,7 +381,7 @@ export default function AtTheDoorPage() {
|
||||
setMode("checkin");
|
||||
};
|
||||
|
||||
const handleDonation = (user?: any) => {
|
||||
const handleDonation = (user?: UserSummary) => {
|
||||
setDonationUser(user || null); // null = anonymous
|
||||
setShowDonationModal(true);
|
||||
};
|
||||
@@ -465,12 +462,10 @@ export default function AtTheDoorPage() {
|
||||
<DoorRegistrationPanel
|
||||
token={token}
|
||||
eventId={eventId}
|
||||
onCreated={handleRegistrationCreated}
|
||||
onSelected={handleRegistrationSelected}
|
||||
onEditRegistration={handleEditRegistration}
|
||||
onNewAttendee={handleShowNewAttendee}
|
||||
onDonation={handleDonation}
|
||||
setError={setError}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -492,7 +487,7 @@ export default function AtTheDoorPage() {
|
||||
)}
|
||||
|
||||
{mode === "refund" && (
|
||||
<DoorRefundPanel token={token} eventId={eventId} setError={setError} setInfo={setInfo} />
|
||||
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
|
||||
)}
|
||||
<OptionsModal
|
||||
open={showOptionsModal}
|
||||
@@ -504,7 +499,7 @@ export default function AtTheDoorPage() {
|
||||
onConfirm={confirmRegistration}
|
||||
confirming={confirming}
|
||||
isEdit={!!pendingEditReg}
|
||||
totalPaid={(pendingEditReg?.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)}
|
||||
totalPaid={pendingEditReg ? totalPaidOn(pendingEditReg) : 0}
|
||||
eventId={eventId}
|
||||
token={token}
|
||||
coupon={doorCoupon}
|
||||
@@ -537,9 +532,16 @@ export default function AtTheDoorPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRegistration, onNewAttendee, onDonation, setError }: any) {
|
||||
function DoorRegistrationPanel({ token, eventId, onSelected, onEditRegistration, onNewAttendee, onDonation }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
onSelected: (r: Registration) => void;
|
||||
onEditRegistration: (r: Registration) => void;
|
||||
onNewAttendee: (seed: string) => void;
|
||||
onDonation: (user?: UserSummary) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [allRegs, setAllRegs] = useState<any[]>([]);
|
||||
const [allRegs, setAllRegs] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Load registrations for this event — once on mount/eventId change, then every 5 min
|
||||
@@ -550,7 +552,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
if (!cancelled) setAllRegs(regs || []);
|
||||
} catch {
|
||||
if (!cancelled) setAllRegs([]);
|
||||
@@ -576,7 +578,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
setAllRegs(regs || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
}}
|
||||
@@ -644,7 +646,12 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
|
||||
);
|
||||
}
|
||||
|
||||
function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
function DoorPaymentPanel({ token, registration, onSuccess, setError }: {
|
||||
token: string | null;
|
||||
registration: Registration | null;
|
||||
onSuccess: (registration?: Registration, partial?: boolean) => void;
|
||||
setError: (e: string | null) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("card");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -659,23 +666,10 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
);
|
||||
}
|
||||
|
||||
const options = registration.options || registration.registrationOptions || [];
|
||||
const payments = registration.payments || [];
|
||||
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
const qty = opt.quantity || 0;
|
||||
return sum + price * qty;
|
||||
}, 0);
|
||||
|
||||
const paidValue = payments.reduce(
|
||||
(sum: number, p: any) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
const totalValue = registrationTotal(registration);
|
||||
const paidValue = totalPaidOn(registration);
|
||||
|
||||
const balance = Math.max(0, totalValue - paidValue);
|
||||
|
||||
@@ -698,26 +692,11 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await apiFetch(`/api/registrations/${registration.id}`, {
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, {
|
||||
authToken: token
|
||||
});
|
||||
|
||||
const updatedOptions = updated.options || updated.registrationOptions || [];
|
||||
const updatedPayments = updated.payments || [];
|
||||
|
||||
const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
|
||||
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
|
||||
? Number(opt.priceSnapshot)
|
||||
: (opt.eventOption?.price ?? opt.price ?? 0);
|
||||
return sum + price * (opt.quantity || 0);
|
||||
}, 0);
|
||||
|
||||
const paidValue = updatedPayments.reduce(
|
||||
(sum: number, p: any) => sum + (p.amount || 0),
|
||||
0
|
||||
);
|
||||
|
||||
const updatedBalance = Math.max(0, totalValue - paidValue);
|
||||
const updatedBalance = Math.max(0, registrationTotal(updated) - totalPaidOn(updated));
|
||||
|
||||
if (updatedBalance === 0) {
|
||||
onSuccess(updated); // 🎯 ONLY NOW generate tickets
|
||||
@@ -725,8 +704,8 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
onSuccess(updated, true); // 🎯 partial payment flow
|
||||
}
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Payment failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Payment failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -849,11 +828,10 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
token={token}
|
||||
registration={registration}
|
||||
maxRefund={paidValue}
|
||||
onRefunded={(updated: any) => {
|
||||
onRefunded={(updated) => {
|
||||
setShowRefund(false);
|
||||
onSuccess(updated, true);
|
||||
}}
|
||||
setError={setError}
|
||||
/>
|
||||
|
||||
<SendTicketsModal
|
||||
@@ -861,20 +839,23 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
|
||||
onClose={() => setShowSendTickets(false)}
|
||||
token={token}
|
||||
registration={registration}
|
||||
setError={setError}
|
||||
setInfo={() => {}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoorTicketsPanel({ token, eventId }: any) {
|
||||
function DoorTicketsPanel({ token, eventId }: { token: string | null; eventId: string }) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [allTickets, setAllTickets] = useState<any[]>([]);
|
||||
const [allTickets, setAllTickets] = useState<TicketRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendTarget, setSendTarget] = useState<any | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
const [sendTarget, setSendTarget] = useState<TicketRecord | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
// Memoised so the send modal's reset effect doesn't rerun on every render of this panel.
|
||||
const sendRegistration = useMemo(() => sendTarget ? {
|
||||
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId || "",
|
||||
user: sendTarget.user,
|
||||
} : null, [sendTarget]);
|
||||
|
||||
// Load all tickets for the event once
|
||||
useEffect(() => {
|
||||
@@ -882,8 +863,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(ticketsFrom(res));
|
||||
} catch {
|
||||
setAllTickets([]);
|
||||
} finally {
|
||||
@@ -916,8 +897,8 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(res?.tickets || res?.data || (Array.isArray(res) ? res : []));
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
setAllTickets(ticketsFrom(res));
|
||||
} catch {
|
||||
setAllTickets([]);
|
||||
} finally {
|
||||
@@ -925,7 +906,7 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
}
|
||||
};
|
||||
|
||||
const print = (ticket: any) => {
|
||||
const print = (ticket: TicketRecord) => {
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) return;
|
||||
const eventTitle = ticket.event?.title || ticket.eventId || "Event";
|
||||
@@ -1061,49 +1042,50 @@ function DoorTicketsPanel({ token, eventId }: any) {
|
||||
<TransferTicketModal
|
||||
ticket={transferTarget}
|
||||
event={transferTarget.event}
|
||||
token={token}
|
||||
token={token ?? ""}
|
||||
onClose={() => setTransferTarget(null)}
|
||||
onDone={(msg) => { setTransferTarget(null); setNotice(msg); load(); }}
|
||||
/>
|
||||
)}
|
||||
{sendTarget && (
|
||||
{sendRegistration && (
|
||||
<SendTicketsModal
|
||||
open={true}
|
||||
onClose={() => setSendTarget(null)}
|
||||
token={token}
|
||||
registration={{
|
||||
id: sendTarget.registrationOption?.registration?.id || sendTarget.registrationOption?.registrationId,
|
||||
user: sendTarget.user,
|
||||
}}
|
||||
setError={() => {}}
|
||||
setInfo={() => {}}
|
||||
registration={sendRegistration}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: any) {
|
||||
const [ticketsForEvent, setTicketsForEvent] = useState<any[]>([]);
|
||||
function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
registration: Registration | null;
|
||||
setError: (e: string | null) => void;
|
||||
setInfo: (s: string | null) => void;
|
||||
}) {
|
||||
const [ticketsForEvent, setTicketsForEvent] = useState<TicketRecord[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [qtyByTicket, setQtyByTicket] = useState<Record<string, number>>({});
|
||||
const [submittingId, setSubmittingId] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await apiFetch<any>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const tickets = res?.tickets || res?.data || (Array.isArray(res) ? res : []);
|
||||
setTicketsForEvent(tickets.filter((t: any) => t.registrationOption?.eventOption?.isMainTicket));
|
||||
const res = await apiFetch<EventTicketsResponse>(`/api/tickets/event/${eventId}`, { authToken: token });
|
||||
const tickets = ticketsFrom(res);
|
||||
setTicketsForEvent(tickets.filter((t) => t.registrationOption?.eventOption?.isMainTicket));
|
||||
} catch {
|
||||
setTicketsForEvent([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId]);
|
||||
|
||||
useEffect(() => { load(); }, [token, eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (!registration) {
|
||||
return (
|
||||
@@ -1116,29 +1098,30 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
const myTickets = ticketsForEvent
|
||||
.filter(t => t.registrationOption?.registration?.id === registration.id)
|
||||
.map(t => {
|
||||
const totalRedeemed = (t.usages || []).reduce((s: number, u: any) => s + (u.quantityRedeemed || 1), 0);
|
||||
const totalRedeemed = (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
||||
const remaining = (t.quantity || 1) - totalRedeemed;
|
||||
return { ...t, totalRedeemed, remaining };
|
||||
});
|
||||
|
||||
const qtyFor = (t: any) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
|
||||
const setQtyFor = (t: any, n: number) =>
|
||||
type CheckInTicket = (typeof myTickets)[number];
|
||||
const qtyFor = (t: CheckInTicket) => qtyByTicket[t.id] ?? (t.remaining > 0 ? t.remaining : 1);
|
||||
const setQtyFor = (t: CheckInTicket, n: number) =>
|
||||
setQtyByTicket(q => ({ ...q, [t.id]: Math.max(1, Math.min(t.remaining || 1, n)) }));
|
||||
|
||||
const commit = async (ticket: any) => {
|
||||
const commit = async (ticket: CheckInTicket) => {
|
||||
const qty = qtyFor(ticket);
|
||||
try {
|
||||
setSubmittingId(ticket.id);
|
||||
const res = await apiFetch<any>(
|
||||
const res = await apiFetch<{ qtyRedeemed?: number; remaining?: number }>(
|
||||
`/api/tickets/scan/${encodeURIComponent(ticket.qrCode)}?eventId=${encodeURIComponent(eventId)}`,
|
||||
{ method: "POST", authToken: token, body: { qty } }
|
||||
);
|
||||
setInfo(`${res?.qtyRedeemed ?? qty} checked in for ${registration.user?.name || "guest"}${
|
||||
res?.remaining > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||
(res?.remaining ?? 0) > 0 ? ` — ${res.remaining} remaining` : " — fully checked in"
|
||||
}. Confirmation sent.`);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Check-in failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Check-in failed");
|
||||
} finally {
|
||||
setSubmittingId(null);
|
||||
}
|
||||
@@ -1225,33 +1208,54 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
|
||||
);
|
||||
}
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: any) {
|
||||
type OptionsModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
options: EventOption[];
|
||||
quantities: Record<string, number>;
|
||||
setQuantities: React.Dispatch<React.SetStateAction<Record<string, number>>>;
|
||||
minQuantities?: Record<string, number>;
|
||||
onConfirm: () => void;
|
||||
confirming: boolean;
|
||||
isEdit: boolean;
|
||||
totalPaid?: number;
|
||||
eventId: string;
|
||||
token: string | null;
|
||||
coupon: AppliedCoupon | null;
|
||||
setCoupon: (c: AppliedCoupon | null) => void;
|
||||
couponForUserId: string | null;
|
||||
existingCode: string | null;
|
||||
// Named-attendee editor settings, or null when the event doesn't take names.
|
||||
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
|
||||
};
|
||||
|
||||
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, names }: OptionsModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const basket = Object.entries(quantities as Record<string, number>)
|
||||
const basket = Object.entries(quantities)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
.map(([key, quantity]) => {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, variantId: variantId || null };
|
||||
});
|
||||
|
||||
const safeOptions: any[] = options || [];
|
||||
const safeOptions: EventOption[] = options || [];
|
||||
|
||||
const total = safeOptions.reduce((sum: number, o: any) => {
|
||||
const total = safeOptions.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 + (o.variants || []).reduce((vs, v) =>
|
||||
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
}, 0);
|
||||
|
||||
const belowPaid = isEdit && total < totalPaid;
|
||||
const belowIssued = isEdit && Object.entries(minQuantities).some(
|
||||
([key, minQty]: [string, any]) => minQty > 0 && (quantities[key] || 0) < minQty
|
||||
([key, minQty]) => minQty > 0 && (quantities[key] || 0) < minQty
|
||||
);
|
||||
|
||||
const stepper = (key: string, delta: number) =>
|
||||
setQuantities((q: Record<string, number>) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
|
||||
setQuantities((q) => ({ ...q, [key]: Math.max(minQuantities[key] || 0, (q[key] || 0) + delta) }));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-4">
|
||||
@@ -1271,7 +1275,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain p-5 space-y-3">
|
||||
{safeOptions.map((opt: any) => {
|
||||
{safeOptions.map((opt) => {
|
||||
const hasVariants = (opt.variants || []).length > 0;
|
||||
if (hasVariants) {
|
||||
return (
|
||||
@@ -1280,8 +1284,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
{opt.name}
|
||||
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal">• Main</span>}
|
||||
</div>
|
||||
{(opt.variants as any[]).map((v: any) => {
|
||||
const unit = effectiveVariantUnit(opt, v);
|
||||
{(opt.variants || []).map((v) => {
|
||||
const unit = variantUnitNow(opt, v);
|
||||
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
const minQty = minQuantities[key] || 0;
|
||||
@@ -1306,7 +1310,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const unit = effectiveOptionUnit(opt);
|
||||
const unit = optionUnitNow(opt);
|
||||
const minQty = minQuantities[opt.id] || 0;
|
||||
return (
|
||||
<div key={opt.id} className="border rounded-xl p-3">
|
||||
@@ -1386,7 +1390,15 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
|
||||
);
|
||||
}
|
||||
|
||||
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: any) {
|
||||
function DonationModal({ open, onClose, user, token, eventId, onSuccess, setError }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
user: UserSummary | null;
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
onSuccess?: () => void;
|
||||
setError: (e: string | null) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -1421,8 +1433,8 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
|
||||
setAmount("");
|
||||
onClose();
|
||||
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Donation failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Donation failed");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1480,7 +1492,12 @@ function DonationModal({ open, onClose, user, token, eventId, onSuccess, setErro
|
||||
);
|
||||
}
|
||||
|
||||
function SendTicketsModal({ open, onClose, token, registration, setError, setInfo }: any) {
|
||||
function SendTicketsModal({ open, onClose, token, registration }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
token: string | null;
|
||||
registration: Pick<Registration, "id" | "user"> | null;
|
||||
}) {
|
||||
const [channel, setChannel] = useState<"email" | "whatsapp" | "both">("email");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -1495,7 +1512,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
setLocalError("");
|
||||
setLocalInfo("");
|
||||
}
|
||||
}, [open, registration]);
|
||||
}, [open, registration, setLocalError]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
@@ -1515,7 +1532,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
registrationId: registration.id,
|
||||
registrationId: registration?.id,
|
||||
channel,
|
||||
overrideEmail: email.trim() || undefined,
|
||||
overridePhone: phone.trim() || undefined,
|
||||
@@ -1523,8 +1540,8 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
});
|
||||
setLocalInfo("Tickets sent successfully.");
|
||||
setTimeout(() => { setLocalInfo(""); onClose(); }, 7000);
|
||||
} catch (e: any) {
|
||||
setLocalError(e?.message || "Failed to send tickets");
|
||||
} catch (e) {
|
||||
setLocalError(errorMessage(e) || "Failed to send tickets");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -1609,7 +1626,14 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
|
||||
);
|
||||
}
|
||||
|
||||
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded, setError }: any) {
|
||||
function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded }: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
token: string | null;
|
||||
registration: Registration | null;
|
||||
maxRefund: number;
|
||||
onRefunded: (updated: Registration) => void;
|
||||
}) {
|
||||
const [amount, setAmount] = useState("");
|
||||
const [method, setMethod] = useState("cash");
|
||||
const [reason, setReason] = useState("");
|
||||
@@ -1618,9 +1642,9 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
|
||||
useEffect(() => {
|
||||
if (open) { setAmount(""); setReason(""); setLocalError(""); }
|
||||
}, [open]);
|
||||
}, [open, setLocalError]);
|
||||
|
||||
if (!open) return null;
|
||||
if (!open || !registration) return null;
|
||||
|
||||
const save = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -1643,10 +1667,10 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
}
|
||||
});
|
||||
// Re-fetch registration to get updated status & payments
|
||||
const updated = await apiFetch<any>(`/api/registrations/${registration.id}`, { authToken: token });
|
||||
const updated = await apiFetch<Registration>(`/api/registrations/${registration.id}`, { authToken: token });
|
||||
onRefunded(updated);
|
||||
} catch (e: any) {
|
||||
setLocalError(e?.message || "Refund failed.");
|
||||
} catch (e) {
|
||||
setLocalError(errorMessage(e) || "Refund failed.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1729,48 +1753,48 @@ function RefundModal({ open, onClose, token, registration, maxRefund, onRefunded
|
||||
);
|
||||
}
|
||||
|
||||
function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
function DoorRefundPanel({ token, eventId, setInfo }: {
|
||||
token: string | null;
|
||||
eventId: string;
|
||||
setInfo: (s: string | null) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [allRegs, setAllRegs] = useState<any[]>([]);
|
||||
const [allRegs, setAllRegs] = useState<Registration[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedReg, setSelectedReg] = useState<any | null>(null);
|
||||
const [selectedReg, setSelectedReg] = useState<Registration | null>(null);
|
||||
const [showRefund, setShowRefund] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
if (!token || !eventId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token });
|
||||
setAllRegs(regs || []);
|
||||
} catch {
|
||||
setAllRegs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [token, eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [token, eventId]);
|
||||
}, [load]);
|
||||
|
||||
// Only show registrations that have at least one payment
|
||||
const paidRegs = allRegs.filter(r =>
|
||||
(r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0) > 0
|
||||
);
|
||||
const paidRegs = allRegs.filter(r => totalPaidOn(r) > 0);
|
||||
const results = fuzzyFilterRegs(paidRegs, search);
|
||||
|
||||
const handleRefunded = async (updated: any) => {
|
||||
const handleRefunded = async (updated: Registration) => {
|
||||
setShowRefund(false);
|
||||
setSelectedReg(updated);
|
||||
setInfo("Refund recorded");
|
||||
// Refresh list
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${eventId}`, { authToken: token }).catch(() => null);
|
||||
if (regs) setAllRegs(regs);
|
||||
};
|
||||
|
||||
const maxRefund = selectedReg
|
||||
? (selectedReg.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0)
|
||||
: 0;
|
||||
const maxRefund = selectedReg ? totalPaidOn(selectedReg) : 0;
|
||||
|
||||
return (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
@@ -1796,7 +1820,7 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
|
||||
<div className="space-y-2 max-h-[32rem] overflow-auto">
|
||||
{results.map(r => {
|
||||
const paidValue = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const paidValue = totalPaidOn(r);
|
||||
return (
|
||||
<div key={r.id} className="border rounded p-3 flex justify-between items-center">
|
||||
<div>
|
||||
@@ -1845,12 +1869,7 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
<div className="border rounded p-3 text-center">
|
||||
<div className="text-xs text-gray-500">TOTAL</div>
|
||||
<div className="font-semibold">
|
||||
R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
|
||||
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
|
||||
? Number(o.priceSnapshot)
|
||||
: (o.eventOption?.price || o.price || 0);
|
||||
return s + price * (o.quantity || 0);
|
||||
}, 0)).toFixed(2)}
|
||||
R {registrationTotal(selectedReg).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="border rounded p-3 text-center bg-red-50">
|
||||
@@ -1863,11 +1882,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
<div className="mt-3">
|
||||
<div className="text-xs font-medium text-gray-500 mb-1">Payment history</div>
|
||||
<div className="space-y-1">
|
||||
{selectedReg.payments.map((p: any, i: number) => (
|
||||
{(selectedReg.payments || []).map((p, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<span className={p.amount < 0 ? "text-red-600" : ""}>
|
||||
{p.amount < 0 ? "Refund" : "Payment"} — {p.method || "—"}
|
||||
{p.reason ? ` (${p.reason})` : ""}
|
||||
{/* A refund's reason is stored in its status field. */}
|
||||
{p.amount < 0 && p.status ? ` (${p.status})` : ""}
|
||||
</span>
|
||||
<span className={p.amount < 0 ? "text-red-600 font-medium" : ""}>
|
||||
R {Number(p.amount).toFixed(2)}
|
||||
@@ -1901,7 +1921,6 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
|
||||
registration={selectedReg}
|
||||
maxRefund={maxRefund}
|
||||
onRefunded={handleRefunded}
|
||||
setError={setError}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,26 +8,9 @@ import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ShieldCheck, Send, RefreshCw, HeartPulse, Printer } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, ConsentRow, MedicalRow } from "@/types";
|
||||
|
||||
type ConsentRow = {
|
||||
attendeeId: string;
|
||||
name: string;
|
||||
dateOfBirth: string | null;
|
||||
minor: boolean;
|
||||
parentName: string | null;
|
||||
parentEmail: string | null;
|
||||
parentPhone: string | null;
|
||||
registrationStatus: string;
|
||||
bookedBy: { name: string; email: string };
|
||||
status: "not_required" | "missing" | "pending" | "given" | "declined";
|
||||
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
|
||||
};
|
||||
type MedicalRow = {
|
||||
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
|
||||
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
|
||||
emergency: { name: string; phone: string; relationship: string } | null;
|
||||
photoConsent: boolean | null; purged: boolean;
|
||||
};
|
||||
|
||||
const STATUS_STYLE: Record<ConsentRow["status"], { label: string; cls: string }> = {
|
||||
not_required: { label: "Not needed (18+)", cls: "bg-gray-100 text-gray-600" },
|
||||
@@ -51,9 +34,9 @@ export default function ConsentTrackerPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>(search.get("eventId") || "");
|
||||
const [data, setData] = useState<{ event: any; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [data, setData] = useState<{ event: Pick<Event, "id" | "title">; counts: Record<string, number>; rows: ConsentRow[] } | null>(null);
|
||||
const [filter, setFilter] = useState<"minors" | "all" | "waiting">("minors");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -61,7 +44,7 @@ export default function ConsentTrackerPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const youth = (evs || []).filter(e => e.requiresParentalConsent);
|
||||
setEvents(youth);
|
||||
@@ -76,8 +59,8 @@ export default function ConsentTrackerPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setData(await apiFetch(`/api/consent/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load consent status");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load consent status");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -100,8 +83,8 @@ export default function ConsentTrackerPage() {
|
||||
const res = await fn();
|
||||
setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -114,7 +97,7 @@ export default function ConsentTrackerPage() {
|
||||
// Printable list for camp leaders. Opening it is recorded in the audit log.
|
||||
const printMedical = async () => {
|
||||
try {
|
||||
const res = await apiFetch<{ event: any; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const res = await apiFetch<{ event: Pick<Event, "id" | "title">; rows: MedicalRow[] }>(`/api/consent/events/${eventId}/medical`, { authToken: token! });
|
||||
const w = window.open("", "_blank");
|
||||
if (!w) { setError("Allow pop-ups to print the medical list."); return; }
|
||||
const rowsHtml = res.rows.map(r => `
|
||||
@@ -139,8 +122,8 @@ export default function ConsentTrackerPage() {
|
||||
<tbody>${rowsHtml || '<tr><td colspan="5">No consent given yet.</td></tr>'}</tbody></table>
|
||||
<script>window.onload=()=>window.print()</script></body></html>`);
|
||||
w.document.close();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the medical list");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the medical list");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TicketPercent, Plus, X, Search } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type CouponType = "percent" | "fixed" | "comp";
|
||||
type Role = "user" | "staff" | "supervisor" | "admin";
|
||||
@@ -164,8 +165,8 @@ export default function CouponsPage() {
|
||||
]);
|
||||
setCoupons(c || []);
|
||||
setEvents(ev || []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load coupons");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load coupons");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
@@ -183,8 +184,8 @@ export default function CouponsPage() {
|
||||
await apiFetch(`/api/coupons/${c.id}`, { method: "PUT", authToken: token!, body: { active: !c.active } });
|
||||
setInfo(`${c.code} turned ${c.active ? "off" : "on"}.`);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update coupon");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update coupon");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,8 +193,8 @@ export default function CouponsPage() {
|
||||
try {
|
||||
const full = await apiFetch<Coupon & { redemptions: Redemption[] }>(`/api/coupons/${c.id}`, { authToken: token! });
|
||||
setViewing({ coupon: c, redemptions: full.redemptions || [] });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load uses");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load uses");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -406,8 +407,8 @@ function CouponModal({ token, events, couponId, initial, onClose, onSaved }: {
|
||||
await apiFetch(`/api/coupons`, { method: "POST", authToken: token, body });
|
||||
onSaved(`${body.code.toUpperCase()} created.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to save");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Failed to save");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Mail, Users, Zap, Clock } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
|
||||
type Attendee = { id: string; name: string; email: string; pref: string };
|
||||
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
type StatusFilter = 'any'|'paid'|'unpaid'|'partial_paid'|'cancelled';
|
||||
type MessageFilter = { status?: Exclude<StatusFilter, 'any'>; attendeeIds?: string[]; allocationGroupId?: string };
|
||||
// A message body goes as `html` when it looks like markup, otherwise as plain `text`.
|
||||
type EmailContent = { html?: string; text?: string };
|
||||
type EmailSendResult = MessageSendResult<{ email: string; name?: string }>;
|
||||
|
||||
export default function EmailAttendeesPage() {
|
||||
return (
|
||||
@@ -135,16 +143,14 @@ function AttendeesCheckboxDropdown({
|
||||
}
|
||||
|
||||
function PrefWarning({ attendees, selectedIds, channel }: { attendees: { id: string; pref: string }[]; selectedIds: string[]; channel: "email" | "whatsapp" }) {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both";
|
||||
|
||||
const mismatched = useMemo(
|
||||
() => selectedIds.filter(id => {
|
||||
const mismatched = useMemo(() => {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "email" ? pref === "email" || pref === "both" : pref === "whatsapp" || pref === "both";
|
||||
return selectedIds.filter(id => {
|
||||
const a = attendees.find(x => x.id === id);
|
||||
return a && !prefMatch(a.pref);
|
||||
}),
|
||||
[attendees, selectedIds, channel]
|
||||
);
|
||||
});
|
||||
}, [attendees, selectedIds, channel]);
|
||||
|
||||
if (mismatched.length === 0) return null;
|
||||
return (
|
||||
@@ -180,9 +186,7 @@ function EmailAttendeesPageInner() {
|
||||
// Tabs: attendees (current), automations (coming soon), broadcasts
|
||||
const [tab, setTab] = useState<'attendees'|'automations'|'broadcasts'|'scheduled'>('attendees');
|
||||
|
||||
const isAdmin = user?.role === "admin";
|
||||
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
@@ -194,20 +198,21 @@ function EmailAttendeesPageInner() {
|
||||
});
|
||||
}, [allEvents, evIncludePast]);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
try {
|
||||
setLoadingEvents(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
const sorted = (evs || []).sort((a: any, b: any) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
setAllEvents(sorted);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [user, token]);
|
||||
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
|
||||
useEffect(() => { loadEvents(); }, [user, loadEvents]);
|
||||
|
||||
// Load users for broadcasts
|
||||
useEffect(() => {
|
||||
@@ -217,11 +222,11 @@ function EmailAttendeesPageInner() {
|
||||
setLoadingUsers(true);
|
||||
const allUsers = await fetchAllUsers(token);
|
||||
const mapped = allUsers
|
||||
.filter((u: any) => u.isActive !== false && !u.email?.endsWith('@guest.local'))
|
||||
.map((u: any) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }))
|
||||
.sort((a: any, b: any) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
||||
.filter((u) => u.isActive !== false && !u.email?.endsWith('@guest.local'))
|
||||
.map((u) => ({ id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' }))
|
||||
.sort((a, b) => (a.name || '').localeCompare(b.name || '', undefined, { sensitivity: 'base' }));
|
||||
setUsers(mapped);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore for now
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
@@ -236,7 +241,7 @@ function EmailAttendeesPageInner() {
|
||||
if (preselectEventId) setEventId(preselectEventId);
|
||||
}, [preselectEventId]);
|
||||
|
||||
const [templateKey, setTemplateKey] = useState<'custom'|'payment_reminder'|'event_reminder'|'tickets'>('custom');
|
||||
const [templateKey, setTemplateKey] = useState<TemplateKey>('custom');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
// Broadcasts state
|
||||
@@ -271,20 +276,20 @@ function EmailAttendeesPageInner() {
|
||||
const [editWhen, setEditWhen] = useState<string>('');
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
const loadScheduled = async () => {
|
||||
const loadScheduled = useCallback(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore here; surfaces via UI when tab open
|
||||
} finally {
|
||||
setLoadingScheduled(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, token]);
|
||||
useEffect(() => { if (tab === 'scheduled') loadScheduled(); }, [tab, loadScheduled]);
|
||||
|
||||
const openEdit = (job: ScheduledJob) => {
|
||||
setEditing(job);
|
||||
@@ -298,7 +303,7 @@ function EmailAttendeesPageInner() {
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
const body: any = {};
|
||||
const body: { scheduledAt?: string; subject?: string } & EmailContent = {};
|
||||
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
||||
if (editSubject.trim().length || editBody.trim().length) {
|
||||
body.subject = editSubject;
|
||||
@@ -314,8 +319,8 @@ function EmailAttendeesPageInner() {
|
||||
setInfo('Scheduled email updated.');
|
||||
setEditing(null);
|
||||
loadScheduled();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to update scheduled email');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to update scheduled email');
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
@@ -327,8 +332,8 @@ function EmailAttendeesPageInner() {
|
||||
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(job.id)}`, { method: 'DELETE', authToken: token });
|
||||
setInfo('Scheduled email removed.');
|
||||
loadScheduled();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to remove scheduled email');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to remove scheduled email');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -336,7 +341,7 @@ function EmailAttendeesPageInner() {
|
||||
const [body, setBody] = useState("");
|
||||
const [subjectDirty, setSubjectDirty] = useState(false);
|
||||
const [bodyDirty, setBodyDirty] = useState(false);
|
||||
const [status, setStatus] = useState<'any'|'paid'|'unpaid'|'partial_paid'|'cancelled'>("any");
|
||||
const [status, setStatus] = useState<StatusFilter>("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
// Attendees list and selection
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
@@ -360,11 +365,8 @@ function EmailAttendeesPageInner() {
|
||||
setBodyDirty(false);
|
||||
setSubject('');
|
||||
setBody('');
|
||||
} else {
|
||||
// custom: do not overwrite user content unless not dirty and we have event change
|
||||
if (!subjectDirty && subject) setSubject(subject); // keep as-is
|
||||
if (!bodyDirty && body) setBody(body);
|
||||
}
|
||||
// custom: leave whatever the supervisor typed alone.
|
||||
// When event changes, refresh defaults if not dirty
|
||||
}, [templateKey, currentEvent, subjectDirty, bodyDirty]);
|
||||
|
||||
@@ -389,10 +391,10 @@ function EmailAttendeesPageInner() {
|
||||
setAttendees([]);
|
||||
setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r: any) => {
|
||||
const u = r?.user;
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = r?.user;
|
||||
if (u?.id && u?.email && !u.email.endsWith('@guest.local') && u.isActive !== false) {
|
||||
uniq.set(u.id, { id: u.id, name: u.name || '', email: u.email || '', pref: u.notificationPreference || 'email' });
|
||||
}
|
||||
@@ -402,7 +404,7 @@ function EmailAttendeesPageInner() {
|
||||
// Default: select those with email/both preference; fall back to all
|
||||
const matching = list.filter(a => a.pref === 'email' || a.pref === 'both').map(a => a.id);
|
||||
setSelectedAttendeeIds(matching.length > 0 ? matching : list.map(a => a.id));
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingAttendees(false);
|
||||
@@ -420,7 +422,7 @@ function EmailAttendeesPageInner() {
|
||||
if (!subject.trim()) { setError("Subject is required"); return; }
|
||||
if (!body.trim()) { setError("Message is required"); return; }
|
||||
}
|
||||
const payload: any = {
|
||||
const payload: { subject: string; filter: MessageFilter; dryRun: boolean; template: TemplateKey } & EmailContent = {
|
||||
subject: subject || "(no subject)",
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
dryRun: true,
|
||||
@@ -429,12 +431,12 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
|
||||
}
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
setPreviewCount(res?.matched ?? 0);
|
||||
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
|
||||
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to preview recipients');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to preview recipients');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -460,7 +462,7 @@ function EmailAttendeesPageInner() {
|
||||
if (!body.trim()) { setError("Message is required"); return; }
|
||||
}
|
||||
setSending(true);
|
||||
const payload: any = {
|
||||
const payload: { subject: string; filter: MessageFilter; template: TemplateKey } & EmailContent = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
@@ -468,13 +470,13 @@ function EmailAttendeesPageInner() {
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
}
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
|
||||
const queued = res?.queued ?? res?.matched ?? 0;
|
||||
setInfo(`Queued ${queued} recipient(s) for sending.`);
|
||||
// Reset form to default state
|
||||
resetAttendeesForm();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to send emails');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
@@ -556,7 +558,7 @@ function EmailAttendeesPageInner() {
|
||||
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)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={e => setTemplateKey(e.target.value as TemplateKey)}>
|
||||
<option value="custom">Custom message</option>
|
||||
<option value="payment_reminder">Payment reminder (with outstanding)</option>
|
||||
<option value="event_reminder">Event reminder (date & time)</option>
|
||||
@@ -584,7 +586,7 @@ function EmailAttendeesPageInner() {
|
||||
<li><code>{'{{event.title}}'}</code> — the event title.</li>
|
||||
<li><code>{'{{event.start}}'}</code> — the event start date/time (local).</li>
|
||||
<li><code>{'{{balance}}'}</code> — outstanding amount across the attendee’s registrations for the selected event.</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
<li><code>{'{{payment.link}}'}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
</ul>
|
||||
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
|
||||
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
|
||||
@@ -601,7 +603,7 @@ function EmailAttendeesPageInner() {
|
||||
</div>
|
||||
<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)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={e => setStatus(e.target.value as StatusFilter)}>
|
||||
<option value="any">Any</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="unpaid">Unpaid (pending or partial)</option>
|
||||
@@ -660,16 +662,16 @@ function EmailAttendeesPageInner() {
|
||||
if (!body.trim()) { setError('Message is required'); return; }
|
||||
}
|
||||
const whenIso = new Date(scheduledAtLocal).toISOString();
|
||||
const payload = {
|
||||
const payload: { subject: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } & EmailContent = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
scheduledAt: whenIso,
|
||||
} as any;
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
}
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
if (res?.job?.id) {
|
||||
setInfo('Email scheduled. It will be sent around the specified time.');
|
||||
} else {
|
||||
@@ -677,8 +679,8 @@ function EmailAttendeesPageInner() {
|
||||
}
|
||||
// Reset form to default state
|
||||
resetAttendeesForm();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to schedule email');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to schedule email');
|
||||
}
|
||||
}}>Schedule send</button>
|
||||
{previewCount != null && (
|
||||
@@ -779,13 +781,13 @@ Jane Doe <jane@example.com>
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const body: any = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
|
||||
const res = await apiFetch(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
|
||||
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined };
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
|
||||
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 recipients');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to preview recipients');
|
||||
}
|
||||
}}>Preview recipients</button>
|
||||
|
||||
@@ -795,14 +797,14 @@ Jane Doe <jane@example.com>
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const payload: any = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
|
||||
const res = await apiFetch(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
|
||||
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
|
||||
// Reset broadcast form
|
||||
resetBroadcastForm();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to send broadcast');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to send broadcast');
|
||||
}
|
||||
}}>Send now</button>
|
||||
|
||||
@@ -813,14 +815,14 @@ Jane Doe <jane@example.com>
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
|
||||
const payload: any = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails };
|
||||
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
|
||||
const res = await apiFetch(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
|
||||
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
|
||||
// Reset broadcast form
|
||||
resetBroadcastForm();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to schedule broadcast');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to schedule broadcast');
|
||||
}
|
||||
}}>Schedule send</button>
|
||||
|
||||
@@ -963,9 +965,9 @@ function toLocalInputValue(d: Date) {
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function AutomationsPanel({ events, token, onInfo, onError }:{ events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
|
||||
function AutomationsPanel({ events, token, onInfo, onError }:{ events: Event[]; token?: string | null; onInfo: (s: string | null) => void; onError: (s: string | null) => void }) {
|
||||
const [autoEventId, setAutoEventId] = React.useState<string>('');
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e:any) => e.id === autoEventId), [events, autoEventId]);
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e) => e.id === autoEventId), [events, autoEventId]);
|
||||
|
||||
// Defaults builders
|
||||
const preWeekDefault = React.useMemo(() => ({
|
||||
@@ -1055,37 +1057,38 @@ With gratitude`);
|
||||
|
||||
const onSchedule = async () => {
|
||||
try {
|
||||
onError(null as any); onInfo(null as any);
|
||||
onError(null); onInfo(null);
|
||||
if (!token) { onError('Not authenticated'); return; }
|
||||
if (!autoEventId) { onError('Please select an event'); return; }
|
||||
const jobs: any[] = [];
|
||||
type AutomationJob = { subject: string; scheduledAt: string; audience?: 'sponsors'; promoEventId?: string } & EmailContent;
|
||||
const jobs: AutomationJob[] = [];
|
||||
if (enablePre && preSubject.trim() && preBody.trim() && preWhen) {
|
||||
const payload: any = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() };
|
||||
const payload: AutomationJob = { subject: preSubject, scheduledAt: new Date(preWhen).toISOString() };
|
||||
if (preBody.trim().startsWith('<')) payload.html = preBody; else payload.text = preBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enableFinal && finalSubject.trim() && finalBody.trim() && finalWhen) {
|
||||
const payload: any = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() };
|
||||
const payload: AutomationJob = { subject: finalSubject, scheduledAt: new Date(finalWhen).toISOString() };
|
||||
if (finalBody.trim().startsWith('<')) payload.html = finalBody; else payload.text = finalBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enableThanks && thanksSubject.trim() && thanksBody.trim() && thanksWhen) {
|
||||
const payload: any = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() };
|
||||
const payload: AutomationJob = { subject: thanksSubject, scheduledAt: new Date(thanksWhen).toISOString() };
|
||||
if (thanksBody.trim().startsWith('<')) payload.html = thanksBody; else payload.text = thanksBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (currentEvent?.allowSponsorships && enableSponsors && sponsorSubject.trim() && sponsorBody.trim() && thanksWhen) {
|
||||
const payload: any = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
|
||||
const payload: AutomationJob = { subject: sponsorSubject, scheduledAt: new Date(thanksWhen).toISOString(), audience: 'sponsors' };
|
||||
if (sponsorBody.trim().startsWith('<')) payload.html = sponsorBody; else payload.text = sponsorBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (enablePromo && promoSubject.trim() && promoBody.trim() && promoWhen) {
|
||||
const payload: any = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
|
||||
const payload: AutomationJob = { subject: promoSubject, scheduledAt: new Date(promoWhen).toISOString(), promoEventId: promoEventId || undefined };
|
||||
if (promoBody.trim().startsWith('<')) payload.html = promoBody; else payload.text = promoBody;
|
||||
jobs.push(payload);
|
||||
}
|
||||
if (jobs.length === 0) { onError('Please enable at least one automation and fill in details'); return; }
|
||||
const res = await apiFetch(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
|
||||
const res = await apiFetch<{ message?: string }>(`/api/automations/schedule`, { method: 'POST', authToken: token, body: { eventId: autoEventId, jobs } });
|
||||
onInfo(res?.message || `Scheduled ${jobs.length} automation(s).`);
|
||||
// Reset to defaults (keep event selection)
|
||||
setEnablePre(true); setEnableFinal(true); setEnableThanks(true); setEnablePromo(false);
|
||||
@@ -1093,8 +1096,8 @@ With gratitude`);
|
||||
setFinalSubject(finalDefault.subject); setFinalBody(finalDefault.body); setFinalHours('24');
|
||||
setThanksSubject(thanksDefault.subject); setThanksBody(thanksDefault.body);
|
||||
setPromoSubject(promoDefault.subject); setPromoBody(promoDefault.body); setPromoEventId('');
|
||||
} catch (e:any) {
|
||||
onError(e?.message || 'Failed to schedule automations');
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || 'Failed to schedule automations');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1104,7 +1107,7 @@ With gratitude`);
|
||||
<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) => (
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1137,7 +1140,7 @@ With gratitude`);
|
||||
<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)}>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={e => setFinalHours(e.target.value as '24'|'48')}>
|
||||
<option value="24">24</option>
|
||||
<option value="48">48</option>
|
||||
</select>
|
||||
@@ -1201,7 +1204,7 @@ With gratitude`);
|
||||
<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) => (
|
||||
{(events||[]).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Ticket } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventOption } from "@/types";
|
||||
|
||||
// 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
|
||||
@@ -21,7 +23,10 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
|
||||
return `${y}-${m}-${day}T${hh}:${mm}`;
|
||||
}
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
|
||||
type TierInput = { id?: string; deadline: string; price: number; order?: number };
|
||||
type OptionPatch = Partial<Pick<EventOption, "name" | "price" | "isMainTicket">> & { earlyBirdTiers?: TierInput[] };
|
||||
|
||||
function EarlyBirdTiersEditor({ option, onSave }: { option: EventOption; onSave: (tiers: TierInput[]) => 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);
|
||||
@@ -30,8 +35,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
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 }));
|
||||
.sort((a, b) => new Date(a.deadline).getTime() - new Date(b.deadline).getTime() || (a.order || 0) - (b.order || 0))
|
||||
.map((t, i) => ({ 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]);
|
||||
|
||||
@@ -45,7 +50,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
|
||||
};
|
||||
const updateRow = (idx: number, patch: Partial<{ deadline: string; price: string; order?: number }>) => {
|
||||
const copy = rows.slice();
|
||||
copy[idx] = { ...copy[idx], ...patch } as any;
|
||||
copy[idx] = { ...copy[idx], ...patch };
|
||||
setRows(copy);
|
||||
};
|
||||
|
||||
@@ -110,27 +115,23 @@ function EventOptionsContent() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
const [loadingEv, setLoadingEv] = useState(false);
|
||||
const [options, setOptions] = useState<EventOption[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingEv(true);
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingEv(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [token]);
|
||||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||||
|
||||
// Preselect event from query (?eventId=)
|
||||
useEffect(() => {
|
||||
@@ -165,12 +166,12 @@ function EventOptionsContent() {
|
||||
setNewOpt({ name: "", price: "", isMainTicket: false });
|
||||
setInfo("Option created");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create option");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create option");
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = async (opt: any, patch: any) => {
|
||||
const updateOption = async (opt: EventOption, patch: OptionPatch) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
@@ -181,12 +182,12 @@ function EventOptionsContent() {
|
||||
});
|
||||
setInfo("Option updated");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to update option");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to update option");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteOption = async (opt: any) => {
|
||||
const deleteOption = async (opt: EventOption) => {
|
||||
if (!token) return;
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
@@ -196,8 +197,8 @@ function EventOptionsContent() {
|
||||
});
|
||||
setInfo("Option deleted");
|
||||
await loadEvents();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to delete option");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to delete option");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { ApiImage } from "@/components/shared/ApiImage";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
import { Calendar } from "lucide-react";
|
||||
import { ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventAttachment, EventFormField, EventOption, Paginated, Section } from "@/types";
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,7 +47,26 @@ function DTInput({ label, value, onChange, hint, required }: { label: string; va
|
||||
// ─── types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type FormFieldType = "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: any }[] };
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string | null; options?: unknown }[] };
|
||||
type FieldDef = EventFormDef["fields"][number];
|
||||
|
||||
// What PUT /api/events/options/:id takes as earlyBirdTiers (variantId null = option-level).
|
||||
type TierPayload = { id?: string; deadline: string; price: number; stockLimit: number; order: number; variantId: string | null };
|
||||
|
||||
// GET /api/feedback/event/:id — the event's post-event feedback form, if it has one.
|
||||
type FeedbackFormResponse = {
|
||||
fields?: EventFormField[];
|
||||
sendAt?: string | null;
|
||||
audience?: string;
|
||||
anonymous?: boolean;
|
||||
intro?: string | null;
|
||||
sentAt: string | null;
|
||||
invited: number;
|
||||
responses: number;
|
||||
};
|
||||
|
||||
const toFieldDefs = (fields: EventFormField[] | undefined): FieldDef[] =>
|
||||
(fields || []).map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined }));
|
||||
|
||||
type Tier = { id?: string; deadline: string; price: string; stockLimit: string; order?: number };
|
||||
|
||||
@@ -88,8 +110,9 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("image", file);
|
||||
const res: any = await apiFetch("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
|
||||
if (res?.url || res?.path) onUploaded(res.url || res.path);
|
||||
const res = await apiFetch<{ url?: string; path?: string }>("/api/uploads/event-image", { method: "POST", body: fd, authToken: token });
|
||||
const uploaded = res?.url || res?.path;
|
||||
if (uploaded) onUploaded(uploaded);
|
||||
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
|
||||
}} />
|
||||
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
|
||||
@@ -109,7 +132,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
|
||||
}) {
|
||||
const fields = value.fields || [];
|
||||
const add = (type: FormFieldType) => onChange({ ...value, fields: [...fields, { type, label: "", isRequired: false, helpText: "" }] });
|
||||
const upd = (idx: number, patch: any) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
|
||||
const upd = (idx: number, patch: Partial<FieldDef>) => { const c = fields.slice(); c[idx] = { ...c[idx], ...patch }; onChange({ ...value, fields: c }); };
|
||||
const rem = (idx: number) => { const c = fields.slice(); c.splice(idx, 1); onChange({ ...value, fields: c }); };
|
||||
const mov = (idx: number, dir: -1 | 1) => {
|
||||
const c = fields.slice(); const t = idx + dir;
|
||||
@@ -143,7 +166,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
|
||||
<button type="button" className="text-xs px-2 py-0.5 rounded bg-red-50 text-red-700 border border-red-200" onClick={() => rem(idx)}>Remove</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-1.5">
|
||||
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value })}>
|
||||
<select className="border rounded px-2 py-1 text-xs" value={f.type} onChange={e => upd(idx, { type: e.target.value as FormFieldType })}>
|
||||
<option value="text">Text</option><option value="numeric">Number</option><option value="date">Date</option>
|
||||
<option value="yes_no">Yes/No</option><option value="rating">Rating (1–5 stars)</option><option value="multiple_choice">Multiple choice</option>
|
||||
<option value="statement">Statement</option><option value="paragraph">Heading+para</option>
|
||||
@@ -452,24 +475,24 @@ function SectionsDraftEditor({ sections, options, onChange }: {
|
||||
/** Edit-mode: live sections manager (DB-backed) */
|
||||
function SectionsManager({ eventId }: { eventId: string }) {
|
||||
const { token } = useAuth();
|
||||
const [sections, setSections] = useState<any[]>([]);
|
||||
const [eventOptions, setEventOptions] = useState<any[]>([]);
|
||||
const [sections, setSections] = useState<Section[]>([]);
|
||||
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newOptionIds, setNewOptionIds] = useState<string[]>([]);
|
||||
|
||||
const load = async () => {
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [secList, evFull] = await Promise.all([
|
||||
apiFetch<any[]>("/api/sections", { authToken: token || undefined }),
|
||||
apiFetch<any>(`/api/events/${eventId}`)
|
||||
apiFetch<Section[]>("/api/sections", { authToken: token || undefined }),
|
||||
apiFetch<Event>(`/api/events/${eventId}`)
|
||||
]);
|
||||
setSections((secList || []).filter(s => s.eventId === eventId));
|
||||
setEventOptions(evFull?.eventOptions || []);
|
||||
} catch {} finally { setLoading(false); }
|
||||
};
|
||||
useEffect(() => { if (eventId) load(); }, [eventId]);
|
||||
}, [eventId, token]);
|
||||
useEffect(() => { if (eventId) load(); }, [eventId, load]);
|
||||
|
||||
const create = async () => {
|
||||
if (!newName.trim() || !token) return;
|
||||
@@ -514,14 +537,19 @@ function SectionsManager({ eventId }: { eventId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function SectionRow({ sec, options, onDelete, onUpdate }: any) {
|
||||
function SectionRow({ sec, options, onDelete, onUpdate }: {
|
||||
sec: Section;
|
||||
options: EventOption[];
|
||||
onDelete: () => void;
|
||||
onUpdate: (id: string, name: string, optionIds: string[]) => void;
|
||||
}) {
|
||||
const [name, setName] = useState(sec.name || "");
|
||||
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o: any) => o.eventOptionId));
|
||||
const [optIds, setOptIds] = useState<string[]>((sec.allowedOptions || []).map((o) => o.eventOptionId));
|
||||
return (
|
||||
<div className="border rounded p-3 bg-white">
|
||||
<input className="border rounded px-2 py-1 text-sm w-full mb-2" value={name} onChange={e => setName(e.target.value)} />
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{options.map((opt: any) => (
|
||||
{options.map((opt) => (
|
||||
<label key={opt.id} className="text-xs flex items-center gap-1 cursor-pointer">
|
||||
<input type="checkbox" checked={optIds.includes(opt.id)}
|
||||
onChange={e => setOptIds(e.target.checked ? [...optIds, opt.id] : optIds.filter(id => id !== opt.id))} />
|
||||
@@ -541,15 +569,15 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
|
||||
|
||||
function AttachmentsManager({ eventId }: { eventId: string }) {
|
||||
const { token } = useAuth();
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [items, setItems] = useState<EventAttachment[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
const res = await apiFetch<any[]>(`/api/events/${eventId}/attachments`);
|
||||
const load = useCallback(async () => {
|
||||
const res = await apiFetch<EventAttachment[]>(`/api/events/${eventId}/attachments`);
|
||||
setItems(Array.isArray(res) ? res : []);
|
||||
};
|
||||
useEffect(() => { load(); }, [eventId]);
|
||||
}, [eventId]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const upload: React.ChangeEventHandler<HTMLInputElement> = async e => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -645,7 +673,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
|
||||
setSearching(true);
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
|
||||
const res = await apiFetch<Paginated<NotifyUser>>(`/api/users?search=${encodeURIComponent(search.trim())}&limit=10`, { authToken: token || undefined });
|
||||
if (!cancelled) setResults(Array.isArray(res?.data) ? res.data : []);
|
||||
} catch {
|
||||
if (!cancelled) setResults([]);
|
||||
@@ -728,10 +756,10 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch<any>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
|
||||
const res = await apiFetch<NotifyUser[]>(`/api/events/${eventId}/notify-recipients`, { authToken: token || undefined });
|
||||
if (!cancelled) setSelected(Array.isArray(res) ? res : []);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || "Failed to load notification recipients");
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(errorMessage(e) || "Failed to load notification recipients");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -750,9 +778,9 @@ function NotifyRecipientsManager({ eventId, creator, initialRecipients }: { even
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { userIds: next.map(u => u.id) },
|
||||
});
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setSelected(prev);
|
||||
setError(e?.message || "Failed to save notification recipients");
|
||||
setError(errorMessage(e) || "Failed to save notification recipients");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -813,14 +841,13 @@ const blankOptions = (): OptionDraft[] => [
|
||||
|
||||
interface EventModalProps {
|
||||
mode: "create" | "edit";
|
||||
event?: any;
|
||||
event?: Event | null;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const { token, user } = useAuth();
|
||||
const isAdmin = user?.role === "admin";
|
||||
const { token } = useAuth();
|
||||
const { settings } = useSiteSettings();
|
||||
const [step, setStep] = useState<StepIdx>(0);
|
||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||
@@ -851,8 +878,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
// ── options (with per-variant tiers) ──
|
||||
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
||||
if (!ev?.eventOptions?.length) return blankOptions();
|
||||
return ev.eventOptions.map((o: any) => {
|
||||
const allTiers: any[] = o.earlyBirdTiers || [];
|
||||
return ev.eventOptions.map((o) => {
|
||||
const allTiers = o.earlyBirdTiers || [];
|
||||
return {
|
||||
id: o.id,
|
||||
name: o.name,
|
||||
@@ -862,17 +889,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
|
||||
// Option-level tiers only (no variantId)
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t: any) => !t.variantId)
|
||||
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
variants: (o.variants || []).map((v: any) => ({
|
||||
.filter((t) => !t.variantId)
|
||||
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
variants: (o.variants || []).map((v) => ({
|
||||
id: v.id, name: v.name,
|
||||
price: v.price !== null && v.price !== undefined ? String(v.price) : "",
|
||||
stockLimit: String(v.stockLimit ?? "0"),
|
||||
staffPrice: v.staffPrice !== null && v.staffPrice !== undefined ? String(v.staffPrice) : "",
|
||||
// Variant-level tiers — filter by variantId
|
||||
earlyBirdTiers: allTiers
|
||||
.filter((t: any) => t.variantId === v.id)
|
||||
.map((t: any) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
.filter((t) => t.variantId === v.id)
|
||||
.map((t) => ({ id: t.id, deadline: toLocalDT(t.deadline), price: String(t.price ?? ""), stockLimit: String(t.stockLimit ?? "0") })),
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -892,9 +919,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const formLoadedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || formLoadedRef.current) return;
|
||||
apiFetch<any>(`/api/events/${ev.id}`).then(full => {
|
||||
apiFetch<Event>(`/api/events/${ev.id}`).then(full => {
|
||||
const f = full?.form;
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
|
||||
if (f) setFormDef({ isRequired: !!f.isRequired, fields: toFieldDefs(f.fields) });
|
||||
}).catch(() => {}).finally(() => { formLoadedRef.current = true; });
|
||||
}, [mode, ev?.id]);
|
||||
|
||||
@@ -905,10 +932,10 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
const [fbMeta, setFbMeta] = useState<{ sentAt: string | null; invited: number; responses: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "edit" || !ev?.id || !token) return;
|
||||
apiFetch<any>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
|
||||
apiFetch<FeedbackFormResponse | null>(`/api/feedback/event/${ev.id}`, { authToken: token }).then(f => {
|
||||
if (!f) return;
|
||||
setFbEnabled(true);
|
||||
setFbDef({ isRequired: false, fields: (f.fields || []).map((x: any) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, helpText: x.helpText || "", options: x.options || undefined })) });
|
||||
setFbDef({ isRequired: false, fields: toFieldDefs(f.fields) });
|
||||
setFbSettings({ sendAt: toLocalDT(f.sendAt), audience: f.audience || "checked_in", anonymous: !!f.anonymous, intro: f.intro || "" });
|
||||
setFbMeta({ sentAt: f.sentAt, invited: f.invited, responses: f.responses });
|
||||
}).catch(() => {});
|
||||
@@ -991,7 +1018,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (mode === "edit") {
|
||||
for (const opt of options) {
|
||||
// Build flat tier array: option-level + all variant tiers
|
||||
const allTiers: any[] = [
|
||||
const allTiers: TierPayload[] = [
|
||||
...opt.earlyBirdTiers
|
||||
.filter(t => t.deadline && t.price !== "")
|
||||
.map((t, i) => ({ id: t.id, deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: i, variantId: null })),
|
||||
@@ -1006,16 +1033,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
if (opt.id) {
|
||||
// Update option + variants first to get/confirm variant IDs
|
||||
const updated: any = await apiFetch(`/api/events/options/${opt.id}`, {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
|
||||
});
|
||||
|
||||
// Build variant tiers with confirmed IDs from response
|
||||
const serverVariants: any[] = updated?.variants || [];
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
const serverVariant = serverVariants[vi] || serverVariants.find((sv: any) => sv.name === v.name);
|
||||
const serverVariant = serverVariants[vi] || serverVariants.find((sv) => sv.name === v.name);
|
||||
if (!serverVariant) continue;
|
||||
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
|
||||
const t = v.earlyBirdTiers[ti];
|
||||
@@ -1038,19 +1065,19 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
});
|
||||
} else {
|
||||
// New option added during edit
|
||||
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
if (created?.id) {
|
||||
const updated: any = await apiFetch(`/api/events/options/${created.id}`, {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
|
||||
method: "PUT", authToken: token || undefined,
|
||||
body: { variants: variantsPayload }
|
||||
});
|
||||
const serverVariants: any[] = updated?.variants || [];
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
|
||||
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
|
||||
if (!sv) continue;
|
||||
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
|
||||
const t = v.earlyBirdTiers[ti];
|
||||
@@ -1068,9 +1095,9 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
} else {
|
||||
// Create mode — backend auto-creates a "Main Ticket" option; replace/create from draft
|
||||
let createdEvent: any = null;
|
||||
try { createdEvent = await apiFetch(`/api/events/${eventId}`); } catch {}
|
||||
const existingMain = (createdEvent?.eventOptions || []).find((o: any) => !!o.isMainTicket);
|
||||
let createdEvent: Event | null = null;
|
||||
try { createdEvent = await apiFetch<Event>(`/api/events/${eventId}`); } catch {}
|
||||
const existingMain = (createdEvent?.eventOptions || []).find((o) => !!o.isMainTicket);
|
||||
const uiMainIdx = options.findIndex(o => o.isMainTicket);
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
@@ -1091,7 +1118,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
});
|
||||
optId = existingMain.id;
|
||||
} else {
|
||||
const created: any = await apiFetch(`/api/events/${eventId}/options`, {
|
||||
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
|
||||
method: "POST", authToken: token || undefined,
|
||||
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
|
||||
});
|
||||
@@ -1101,18 +1128,18 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (!optId) continue;
|
||||
|
||||
// Save variants, then build tiers with resolved IDs
|
||||
const allTiers: any[] = opt.earlyBirdTiers
|
||||
const allTiers: TierPayload[] = opt.earlyBirdTiers
|
||||
.filter(t => t.deadline && t.price !== "")
|
||||
.map((t, ti) => ({ deadline: new Date(t.deadline).toISOString(), price: parseFloat(t.price), stockLimit: parseInt(t.stockLimit || "0"), order: ti, variantId: null }));
|
||||
|
||||
if (variantsPayload.length > 0 || opt.variants.some(v => v.earlyBirdTiers.length > 0)) {
|
||||
const updated: any = await apiFetch(`/api/events/options/${optId}`, {
|
||||
const updated = await apiFetch<EventOption>(`/api/events/options/${optId}`, {
|
||||
method: "PUT", authToken: token || undefined, body: { variants: variantsPayload }
|
||||
});
|
||||
const serverVariants: any[] = updated?.variants || [];
|
||||
const serverVariants = updated?.variants || [];
|
||||
for (let vi = 0; vi < opt.variants.length; vi++) {
|
||||
const v = opt.variants[vi];
|
||||
const sv = serverVariants[vi] || serverVariants.find((x: any) => x.name === v.name);
|
||||
const sv = serverVariants[vi] || serverVariants.find((x) => x.name === v.name);
|
||||
if (!sv) continue;
|
||||
for (let ti = 0; ti < v.earlyBirdTiers.length; ti++) {
|
||||
const t = v.earlyBirdTiers[ti];
|
||||
@@ -1131,7 +1158,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const saveStagedSections = async (eventId: string, createdOptions: any[]) => {
|
||||
const saveStagedSections = async (eventId: string, createdOptions: EventOption[]) => {
|
||||
for (const sec of sectionDrafts) {
|
||||
if (!sec.name.trim()) continue;
|
||||
const optionIds = sec.optionIndices
|
||||
@@ -1155,7 +1182,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
setSaving(true); setError(null);
|
||||
try {
|
||||
const body: any = {
|
||||
const body: Record<string, unknown> = {
|
||||
title: draft.title.trim(), description: draft.description || undefined,
|
||||
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
|
||||
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
|
||||
@@ -1186,6 +1213,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
if (mode === "edit") {
|
||||
body.isActive = draft.isActive;
|
||||
if (!ev) throw new Error("No event loaded to save");
|
||||
await apiFetch(`/api/events/${ev.id}`, { method: "PUT", authToken: token || undefined, body });
|
||||
await saveOptions(ev.id);
|
||||
// Form is saved inside saveOptions for edit mode; avoid double call
|
||||
@@ -1194,12 +1222,12 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
if (formDef.isRequired || formDef.fields.length > 0) {
|
||||
body.form = { isRequired: !!formDef.isRequired, fields: formDef.fields.filter(f => f.label?.trim()).map((f, i) => ({ type: f.type, label: f.label, isRequired: !!f.isRequired, order: i, helpText: f.helpText || null, options: f.type === "multiple_choice" ? cleanChoices(f.options) : undefined })) };
|
||||
}
|
||||
const created: any = await apiFetch("/api/events", { method: "POST", authToken: token || undefined, body });
|
||||
const created = await apiFetch<Event>("/api/events", { method: "POST", authToken: token || undefined, body });
|
||||
if (created?.id) {
|
||||
await saveOptions(created.id);
|
||||
await saveFeedback(created.id);
|
||||
// Fetch event to get final option IDs for sections
|
||||
const fullEvent: any = await apiFetch(`/api/events/${created.id}`).catch(() => null);
|
||||
const fullEvent = await apiFetch<Event>(`/api/events/${created.id}`).catch(() => null);
|
||||
const createdOptions = fullEvent?.eventOptions || [];
|
||||
if (sectionDrafts.length > 0) await saveStagedSections(created.id, createdOptions);
|
||||
// Upload staged attachments
|
||||
@@ -1219,8 +1247,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
}
|
||||
|
||||
onSuccess(); onClose();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save event");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save event");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -1306,7 +1334,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
<div className="p-5 space-y-4 max-h-[65vh] overflow-y-auto">
|
||||
{isClosed && (
|
||||
<div className="p-3 border rounded bg-rose-50 text-rose-800 text-sm">
|
||||
This event is closed — it can't be edited until it's reopened from the cashup page (admin only).
|
||||
This event is closed — it can't be edited until it's reopened from the cashup page (admin only).
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="p-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
|
||||
@@ -1342,7 +1370,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
onChange={e => upd({ requiresRegistration: !e.target.checked, price: e.target.checked ? "0" : draft.price })}
|
||||
/>
|
||||
<label htmlFor="contact-only-toggle" className="text-xs text-gray-700">
|
||||
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
|
||||
<span className="font-medium">This is a contact-only event</span> (e.g. baptism) — no online registration or payment. Shows a "Contact us" button on the public site instead of "Register".
|
||||
</label>
|
||||
</div>
|
||||
{draft.requiresRegistration ? (
|
||||
@@ -1369,7 +1397,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Picture</label>
|
||||
{draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
|
||||
<ApiImage src={draft.picture} className="h-20 w-20 object-cover rounded border mb-2" />
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="flex-1 border rounded px-3 py-2 text-sm" placeholder="/uploads/... or https://" value={draft.picture} onChange={e => upd({ picture: e.target.value })} />
|
||||
<UploadImageButton onUploaded={url => upd({ picture: url })} label="Upload" />
|
||||
@@ -1405,7 +1433,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
{/* ── Step 2: Sections ── */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").</p>
|
||||
<p className="text-xs text-gray-500 mb-3">Group ticket options into named sections shown on the registration page (e.g. "Tickets", "Add-ons", "Accommodation").</p>
|
||||
{mode === "edit" && ev?.id
|
||||
? <SectionsManager eventId={ev.id} />
|
||||
: <SectionsDraftEditor sections={sectionDrafts} options={options} onChange={setSectionDrafts} />
|
||||
@@ -1670,7 +1698,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||
|
||||
// ─── event card ───────────────────────────────────────────────────────────────
|
||||
|
||||
function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
|
||||
function EventCard({ ev, onEdit }: { ev: Event; onEdit: () => void }) {
|
||||
const isPast = ev.endDate && new Date(ev.endDate) < new Date();
|
||||
const isInactive = ev.isActive === false;
|
||||
const isClosed = ev.cashupStatus === "closed";
|
||||
@@ -1710,27 +1738,28 @@ export default function ManageEventsPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingEvents(true);
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
setEvents(Array.isArray(evs) ? evs.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime()) : []);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { loadEvents(); }, [user, token]);
|
||||
// Reload when the signed-in user changes too (e.g. after an impersonation switch).
|
||||
useEffect(() => { loadEvents(); }, [user, loadEvents]);
|
||||
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<any | null>(null);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [includePast, setIncludePast] = useState(false);
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showClosed, setShowClosed] = useState(false);
|
||||
@@ -1747,9 +1776,9 @@ export default function ManageEventsPage() {
|
||||
}), [events, includePast, showInactive, showClosed, search, now]);
|
||||
|
||||
const openCreate = () => { setEditingEvent(null); setModalMode("create"); };
|
||||
const openEdit = async (ev: any) => {
|
||||
const openEdit = async (ev: Event) => {
|
||||
try {
|
||||
const full = await apiFetch<any>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
const full = await apiFetch<Event>(`/api/events/${ev.id}`, { authToken: token || undefined });
|
||||
setEditingEvent(full);
|
||||
} catch { setEditingEvent(ev); }
|
||||
setModalMode("edit");
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType, ChoicesEditor } from "@/components/shared/FormFieldExtras";
|
||||
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, EventForm, FormResponse, Registration, User } from "@/types";
|
||||
|
||||
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph' | 'rating' | 'multiple_choice';
|
||||
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: any }[] };
|
||||
type EventFormDef = { isRequired: boolean; fields: { id?: string; type: FormFieldType; label: string; isRequired?: boolean; order?: number; helpText?: string|null; options?: unknown }[] };
|
||||
|
||||
// The form as GET /api/events/:id returns it, mapped into the editor's shape.
|
||||
const toFormDef = (f: EventForm | null | undefined): EventFormDef =>
|
||||
f && Array.isArray(f.fields)
|
||||
? { isRequired: !!f.isRequired, fields: f.fields.map((x) => ({ id: x.id, type: x.type, label: x.label, isRequired: !!x.isRequired, order: x.order, helpText: x.helpText || '', options: x.options || undefined })) }
|
||||
: { isRequired: false, fields: [] };
|
||||
|
||||
// /api/forms/responses returns { items } (older builds returned a bare array).
|
||||
const responseItems = (res: { items?: FormResponse[] } | FormResponse[] | null | undefined): FormResponse[] =>
|
||||
Array.isArray(res) ? res : (Array.isArray(res?.items) ? res.items : []);
|
||||
|
||||
function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: EventFormDef) => void }) {
|
||||
const fields = value.fields || [];
|
||||
@@ -138,36 +150,36 @@ export default function FormsBrowserPage() {
|
||||
|
||||
const [mode, setMode] = useState<'view' | 'manage' | 'fill'>('view');
|
||||
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
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 [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||
const [registrationId, setRegistrationId] = useState<string>("");
|
||||
const [items, setItems] = useState<any[]>([]);
|
||||
const [items, setItems] = useState<FormResponse[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
|
||||
const loadEvents = async () => {
|
||||
const loadEvents = useCallback(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());
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true&includeInactive=true", { authToken: token });
|
||||
const sorted = (evs || []).sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
|
||||
setAllEvents(Array.isArray(sorted) ? sorted : []);
|
||||
} catch {}
|
||||
};
|
||||
useEffect(() => { loadEvents(); }, [token]);
|
||||
}, [token]);
|
||||
useEffect(() => { loadEvents(); }, [loadEvents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
fetchAllUsers(token)
|
||||
.then(list => {
|
||||
setAllUsers(list.sort((a: any, b: any) => (a.name || "").localeCompare(b.name || "")));
|
||||
setAllUsers(list.sort((a, b) => (a.name || "").localeCompare(b.name || "")));
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
@@ -207,32 +219,36 @@ export default function FormsBrowserPage() {
|
||||
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 res = await apiFetch<{ items: FormResponse[]; 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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load form responses");
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load once the token is available; after that, searching is on demand (the
|
||||
// filters don't refetch as they're typed). The ref keeps the latest `search`.
|
||||
const searchRef = useRef(search);
|
||||
useEffect(() => { searchRef.current = search; });
|
||||
useEffect(() => {
|
||||
if (token) search(false);
|
||||
if (token) searchRef.current(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 formPages = items.map((r) => {
|
||||
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) => `
|
||||
const answers = (r.answers || []).map((a) => `
|
||||
<div class="answer">
|
||||
<div class="label">${a.field?.label || a.fieldId || "Field"}</div>
|
||||
<div class="value">${a.value ?? ""}</div>
|
||||
@@ -285,58 +301,53 @@ export default function FormsBrowserPage() {
|
||||
const [formLoading, setFormLoading] = useState(false);
|
||||
const [formDef, setFormDef] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [formLoadedFor, setFormLoadedFor] = useState<string>("");
|
||||
const loadFormForEvent = async (eventId: string) => {
|
||||
const loadFormForEvent = useCallback(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 || '', options: x.options || undefined })) });
|
||||
} else {
|
||||
setFormDef({ isRequired: false, fields: [] });
|
||||
}
|
||||
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
setFormDef(toFormDef(evFull?.form));
|
||||
setFormLoadedFor(eventId);
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to load form');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load form');
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
};
|
||||
}, [setError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'manage' && selectedEventId && selectedEventId !== formLoadedFor) {
|
||||
loadFormForEvent(selectedEventId);
|
||||
}
|
||||
}, [mode, selectedEventId]);
|
||||
}, [mode, selectedEventId, formLoadedFor, loadFormForEvent]);
|
||||
|
||||
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, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o: any) => String(o).trim()).filter(Boolean) : undefined }));
|
||||
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, options: f.type === 'multiple_choice' && Array.isArray(f.options) ? f.options.map((o) => String(o).trim()).filter(Boolean) : undefined }));
|
||||
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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save form');
|
||||
}
|
||||
};
|
||||
|
||||
// Fill/edit responses state
|
||||
const [fillEventId, setFillEventId] = useState<string>("");
|
||||
const [fillRegistrations, setFillRegistrations] = useState<any[]>([]);
|
||||
const [fillRegistrations, setFillRegistrations] = useState<Registration[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [fillRegistrationId, setFillRegistrationId] = useState<string>("");
|
||||
const [fillRegistration, setFillRegistration] = useState<any | null>(null);
|
||||
const [fillRegistration, setFillRegistration] = useState<Registration | null>(null);
|
||||
const [fillForm, setFillForm] = useState<EventFormDef>({ isRequired: false, fields: [] });
|
||||
const [existingResponses, setExistingResponses] = useState<any[]>([]);
|
||||
const [existingResponses, setExistingResponses] = useState<FormResponse[]>([]);
|
||||
const [savingFill, setSavingFill] = useState(false);
|
||||
|
||||
const loadRegsForEvent = async (evId: string) => {
|
||||
const loadRegsForEvent = useCallback(async (evId: string) => {
|
||||
if (!token || !evId) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(evId)}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/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 {
|
||||
@@ -344,54 +355,48 @@ export default function FormsBrowserPage() {
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'fill') setFillEventId(prev => prev || selectedEventId || "");
|
||||
}, [mode]);
|
||||
}, [mode, selectedEventId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'fill') return;
|
||||
if (fillEventId) loadRegsForEvent(fillEventId);
|
||||
setFillRegistrationId(""); setFillRegistration(null); setExistingResponses([]); setFillForm({ isRequired: false, fields: [] });
|
||||
}, [fillEventId, mode]);
|
||||
}, [fillEventId, mode, loadRegsForEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (mode !== 'fill' || !fillRegistrationId) return;
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
const reg = await apiFetch<Registration>(`/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||'', options: x.options || undefined })) });
|
||||
} else {
|
||||
setFillForm({ isRequired: false, fields: [] });
|
||||
}
|
||||
const evFull = await apiFetch<Event>(`/api/events/${encodeURIComponent(reg.event.id)}`);
|
||||
setFillForm(toFormDef(evFull?.form));
|
||||
}
|
||||
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');
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration/form');
|
||||
}
|
||||
})();
|
||||
}, [fillRegistrationId, mode]);
|
||||
}, [fillRegistrationId, mode, token, setError]);
|
||||
|
||||
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);
|
||||
return ros.filter((ro)=> ro?.eventOption?.isMainTicket).reduce((s, ro)=> 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 mapped: { answers: Record<string,string> }[] = (existingResponses || []).map((r)=>{
|
||||
const ans: Record<string,string> = {};
|
||||
(r.answers || []).forEach((a:any)=>{ if (a.fieldId) ans[a.fieldId] = String(a.value ?? ''); });
|
||||
(r.answers || []).forEach((a)=>{ 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 };
|
||||
});
|
||||
@@ -408,7 +413,7 @@ export default function FormsBrowserPage() {
|
||||
const setAnswer = (respIdx: number, fieldId: string, value: string) => {
|
||||
setEditableResponses(prev => {
|
||||
const copy = prev.slice();
|
||||
if (!copy[respIdx]) copy[respIdx] = { answers: {} } as any;
|
||||
if (!copy[respIdx]) copy[respIdx] = { answers: {} };
|
||||
copy[respIdx] = { answers: { ...copy[respIdx].answers, [fieldId]: value } };
|
||||
return copy;
|
||||
});
|
||||
@@ -431,11 +436,10 @@ export default function FormsBrowserPage() {
|
||||
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');
|
||||
const resList = await apiFetch<{ items?: FormResponse[] } | FormResponse[]>(`/api/forms/responses?registrationId=${encodeURIComponent(fillRegistrationId)}`, { authToken: token! });
|
||||
setExistingResponses(responseItems(resList));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save responses');
|
||||
} finally {
|
||||
setSavingFill(false);
|
||||
}
|
||||
@@ -573,7 +577,7 @@ export default function FormsBrowserPage() {
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y print-form-wrapper">
|
||||
{items.map((r: any) => (
|
||||
{items.map((r) => (
|
||||
<li key={r.id} className="print-page-break">
|
||||
<div className="p-4 print-response">
|
||||
{/* Header */}
|
||||
@@ -593,7 +597,7 @@ export default function FormsBrowserPage() {
|
||||
<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) => (
|
||||
{r.answers.map((a) => (
|
||||
<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>
|
||||
@@ -680,7 +684,7 @@ export default function FormsBrowserPage() {
|
||||
<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)=>{
|
||||
{fillRegistrations.map((r)=>{
|
||||
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>;
|
||||
})}
|
||||
@@ -702,7 +706,7 @@ export default function FormsBrowserPage() {
|
||||
<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)=> (
|
||||
{fillForm.fields.filter((f): f is typeof f & { id: string } => !!f.id && f.type!=='statement' && f.type !== 'paragraph').map((f)=> (
|
||||
<div key={f.id}>
|
||||
<label className="block text-xs text-gray-600 mb-1">{f.label}{f.isRequired ? ' *' : ''}</label>
|
||||
{f.type === 'text' && (
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -21,7 +23,7 @@ export default function ManualRegistrationPage() {
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
||||
const [createdReg, setCreatedReg] = useState<Registration | null>(null);
|
||||
const [form, setForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [formsData, setFormsData] = useState<Record<number, Record<string, string>>>({});
|
||||
|
||||
@@ -36,7 +38,7 @@ export default function ManualRegistrationPage() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/registrations/manual", {
|
||||
const res = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -48,12 +50,12 @@ export default function ManualRegistrationPage() {
|
||||
setCreatedReg(res);
|
||||
// Load form definition for this event (if any)
|
||||
try {
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/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");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create manual registration");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -110,12 +112,14 @@ export default function ManualRegistrationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: any; form: { isRequired: boolean; fields: FormField[] }; formsData: Record<number, Record<string,string>>; setFormsData: any; }) {
|
||||
type FormsData = Record<number, Record<string, string>>;
|
||||
|
||||
function AttendeeFormsSection({ registration, form, formsData, setFormsData }: { registration: Registration; form: { isRequired: boolean; fields: FormField[] }; formsData: FormsData; setFormsData: React.Dispatch<React.SetStateAction<FormsData>>; }) {
|
||||
const { token } = useAuth();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const mainTickets = (registration?.registrationOptions || []).filter((o: any) => o?.eventOption?.isMainTicket).reduce((s: number, o: any) => s + (o.quantity || 0), 0);
|
||||
const mainTickets = (registration?.registrationOptions || []).filter((o) => o?.eventOption?.isMainTicket).reduce((s, o) => s + (o.quantity || 0), 0);
|
||||
const count = Math.max(0, mainTickets);
|
||||
|
||||
const canSubmit = React.useMemo(() => {
|
||||
@@ -136,7 +140,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
}, [form, formsData, count]);
|
||||
|
||||
const update = (idx: number, fieldId: string, value: string) => {
|
||||
setFormsData((prev: any) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
setFormsData((prev) => ({ ...prev, [idx]: { ...(prev[idx]||{}), [fieldId]: value } }));
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
@@ -144,14 +148,14 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
setError(null); setInfo(null);
|
||||
const payload = [] as any[];
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -9,35 +9,12 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { UserPlus, CreditCard } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Registration, RegistrationOption, User } from "@/types";
|
||||
|
||||
// ─── 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)}`;
|
||||
}
|
||||
@@ -45,13 +22,14 @@ function fmtPrice(n: number) {
|
||||
// Effective unit price for an existing registration option (as returned by /api/registrations).
|
||||
// Prefers priceSnapshot (authoritative price captured at registration time) and only falls
|
||||
// back to a live early-bird calculation for legacy rows without a snapshot.
|
||||
function optionUnitPrice(opt: any): number {
|
||||
function optionUnitPrice(opt: RegistrationOption): number {
|
||||
if (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) {
|
||||
return Number(opt.priceSnapshot);
|
||||
}
|
||||
const eo = opt.eventOption;
|
||||
if (opt.variant) return effectiveVariantUnit(eo, opt.variant);
|
||||
return effectiveOptionUnit(eo);
|
||||
if (!eo) return 0;
|
||||
if (opt.variant) return variantUnitNow(eo, opt.variant);
|
||||
return optionUnitNow(eo);
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
@@ -70,12 +48,12 @@ export default function ManualRegistrationPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [selectedEventId, setSelectedEventId] = useState<string>("");
|
||||
const [options, setOptions] = useState<any[]>([]);
|
||||
const [options, setOptions] = useState<EventOption[]>([]);
|
||||
|
||||
// All system users for fuzzy lookup
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
|
||||
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||
@@ -94,7 +72,7 @@ export default function ManualRegistrationPage() {
|
||||
const [tab, setTab] = useState<"register" | "payment">("register");
|
||||
|
||||
// ── Record Payment tab state ─────────────────────────────────────────────
|
||||
const [allRegistrations, setAllRegistrations] = useState<any[]>([]);
|
||||
const [allRegistrations, setAllRegistrations] = useState<Registration[]>([]);
|
||||
const [payUserId, setPayUserId] = useState<string>("");
|
||||
const [payUserQuery, setPayUserQuery] = useState("");
|
||||
const [payDropdownOpen, setPayDropdownOpen] = useState(false);
|
||||
@@ -119,7 +97,7 @@ export default function ManualRegistrationPage() {
|
||||
// balances can be computed without a per-registration fetch loop).
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<any[]>("/api/registrations", { authToken: token })
|
||||
apiFetch<Registration[]>("/api/registrations", { authToken: token })
|
||||
.then(regs => setAllRegistrations(Array.isArray(regs) ? regs : []))
|
||||
.catch(() => {});
|
||||
}, [token]);
|
||||
@@ -127,8 +105,8 @@ export default function ManualRegistrationPage() {
|
||||
const regOutstanding = useMemo(() => {
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
for (const r of allRegistrations) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
return map;
|
||||
@@ -144,7 +122,7 @@ export default function ManualRegistrationPage() {
|
||||
.map(x => x.u);
|
||||
}, [payUserQuery, allUsers]);
|
||||
|
||||
const selectPayUser = (u: any) => {
|
||||
const selectPayUser = (u: User) => {
|
||||
setPayUserId(u.id);
|
||||
setPayUserQuery(u.name || "");
|
||||
setPayDropdownOpen(false);
|
||||
@@ -165,9 +143,9 @@ export default function ManualRegistrationPage() {
|
||||
const regsForPayUser = useMemo(() => {
|
||||
if (!payUserId) return [];
|
||||
return allRegistrations
|
||||
.filter((r: any) => String(r.userId || r.user?.id) === String(payUserId))
|
||||
.filter((r: any) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
.filter((r) => String(r.userId || r.user?.id) === String(payUserId))
|
||||
.filter((r) => (regOutstanding[r.id]?.outstanding ?? 0) > 0.000001)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [allRegistrations, payUserId, regOutstanding]);
|
||||
|
||||
const createManualPayment = async () => {
|
||||
@@ -179,7 +157,7 @@ export default function ManualRegistrationPage() {
|
||||
if (!payRegistrationId) { setPayError("Please select a registration"); return; }
|
||||
try {
|
||||
setPaySubmitting(true);
|
||||
await apiFetch<any>("/api/payments", {
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -196,11 +174,11 @@ export default function ManualRegistrationPage() {
|
||||
setPayPaidAtLocal("");
|
||||
// Refresh registrations so the displayed outstanding balance updates
|
||||
try {
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
setAllRegistrations(Array.isArray(regs) ? regs : []);
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
setPayError(e?.message || "Failed to record payment");
|
||||
} catch (e) {
|
||||
setPayError(errorMessage(e) || "Failed to record payment");
|
||||
} finally {
|
||||
setPaySubmitting(false);
|
||||
}
|
||||
@@ -229,7 +207,7 @@ export default function ManualRegistrationPage() {
|
||||
const selectedEventAllowsNames = !!selectedEv?.allowNamedAttendees || selectedIsYouth;
|
||||
const [pickedUserId, setPickedUserId] = useState<string | null>(null);
|
||||
|
||||
const selectUser = (u: any) => {
|
||||
const selectUser = (u: User) => {
|
||||
setPickedUserId(u.id || null);
|
||||
setGuest({ name: u.name || "", email: u.email || "", phoneNumber: u.phoneNumber || "" });
|
||||
setUserQuery(u.name || "");
|
||||
@@ -251,7 +229,7 @@ export default function ManualRegistrationPage() {
|
||||
(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
const evs = await apiFetch<any[]>("/api/events/all", { authToken: token });
|
||||
const evs = await apiFetch<Event[]>("/api/events/all", { authToken: token });
|
||||
const now = Date.now();
|
||||
const active = (evs || []).filter(ev => {
|
||||
const t = new Date(ev.endDate).getTime();
|
||||
@@ -261,7 +239,7 @@ export default function ManualRegistrationPage() {
|
||||
});
|
||||
active.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
setEvents(active);
|
||||
} catch (e: any) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
})();
|
||||
@@ -273,9 +251,9 @@ export default function ManualRegistrationPage() {
|
||||
const opts = ev.options || ev.eventOptions || [];
|
||||
setOptions(opts);
|
||||
const map: Record<string, number> = {};
|
||||
opts.forEach((o: any) => {
|
||||
opts.forEach((o) => {
|
||||
if ((o.variants || []).length > 0) {
|
||||
(o.variants as any[]).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
|
||||
(o.variants || []).forEach(v => { map[`${o.id}::${v.id}`] = 0; });
|
||||
} else {
|
||||
map[o.id] = 0;
|
||||
}
|
||||
@@ -290,9 +268,9 @@ export default function ManualRegistrationPage() {
|
||||
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 + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
|
||||
}
|
||||
return sum + (quantities[o.id] || 0) * effectiveOptionUnit(o);
|
||||
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
|
||||
}, 0);
|
||||
}, [options, quantities]);
|
||||
|
||||
@@ -319,7 +297,7 @@ export default function ManualRegistrationPage() {
|
||||
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", {
|
||||
const res = await apiFetch<Registration & { couponError?: string }>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -339,7 +317,7 @@ export default function ManualRegistrationPage() {
|
||||
|
||||
// Pre-load the Record Payment tab with this registration so it's ready to go the
|
||||
// moment a supervisor clicks over — the tab itself is never switched to automatically.
|
||||
setAllRegistrations(prev => [res, ...prev.filter((r: any) => r.id !== res.id)]);
|
||||
setAllRegistrations(prev => [res, ...prev.filter((r) => r.id !== res.id)]);
|
||||
setPayUserId(res.userId || res.user?.id || "");
|
||||
setPayUserQuery(res.user?.name || guest.name || "");
|
||||
setPayRegistrationId(res.id);
|
||||
@@ -356,8 +334,8 @@ export default function ManualRegistrationPage() {
|
||||
setAttendeeEntries({});
|
||||
setDelivery("payer");
|
||||
setQuantities(prev => Object.fromEntries(Object.keys(prev).map(k => [k, 0])));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create manual registration");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create manual registration");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -558,8 +536,8 @@ export default function ManualRegistrationPage() {
|
||||
<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-brand-600 font-normal">• Main</span> : null}
|
||||
</div>
|
||||
{(opt.variants as any[]).map((v: any) => {
|
||||
const unit = effectiveVariantUnit(opt, v);
|
||||
{(opt.variants || []).map((v) => {
|
||||
const unit = variantUnitNow(opt, v);
|
||||
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
|
||||
const key = `${opt.id}::${v.id}`;
|
||||
return (
|
||||
@@ -586,7 +564,7 @@ export default function ManualRegistrationPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const unit = effectiveOptionUnit(opt);
|
||||
const unit = optionUnitNow(opt);
|
||||
return (
|
||||
<div key={opt.id} className="border rounded p-3">
|
||||
<div className="font-medium text-sm">{opt.name}</div>
|
||||
@@ -718,7 +696,7 @@ export default function ManualRegistrationPage() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={payRegistrationId} onChange={e => setPayRegistrationId(e.target.value)} disabled={!payUserId}>
|
||||
<option value="">Select registration…</option>
|
||||
{regsForPayUser.map((r: any) => {
|
||||
{regsForPayUser.map((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id} title={label}>{label}</option>;
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { WifiOff, Unlock, CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
type Lock = { id: string; sectionName: string | null; deviceName: string | null; user: { name: string }; lockedAt: string; snapshotAt: string | null; lastSyncAt: string | null };
|
||||
type Report = {
|
||||
@@ -28,7 +30,7 @@ export default function OfflineScansPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState("");
|
||||
const [locks, setLocks] = useState<Lock[]>([]);
|
||||
const [reports, setReports] = useState<Report[]>([]);
|
||||
@@ -38,7 +40,7 @@ export default function OfflineScansPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token })
|
||||
.then(evs => {
|
||||
const recent = (evs || []).filter(e => new Date(e.endDate).getTime() > Date.now() - 14 * 864e5);
|
||||
setEvents(recent);
|
||||
@@ -57,8 +59,8 @@ export default function OfflineScansPage() {
|
||||
]);
|
||||
setLocks(l);
|
||||
setReports(r.filter(x => !onlyOpen || x.conflicts.length > 0 || x.afterForceRelease));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load offline scanning");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load offline scanning");
|
||||
}
|
||||
}, [token, eventId, onlyOpen, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
@@ -68,13 +70,13 @@ export default function OfflineScansPage() {
|
||||
const res = await apiFetch<{ message: string }>(`/api/scan-locks/${l.id}/force-release`, { method: "POST", authToken: token! });
|
||||
setInfo(res.message);
|
||||
load();
|
||||
} catch (e: any) { setError(e?.message || "Failed"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed"); }
|
||||
};
|
||||
const review = async (r: Report) => {
|
||||
try {
|
||||
await apiFetch(`/api/scan-locks/reports/${r.id}/review`, { method: "POST", authToken: token! });
|
||||
load();
|
||||
} catch (e: any) { setError(e?.message || "Failed"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed"); }
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -14,6 +14,7 @@ import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import type { DashboardStats, PaymentStats, RecentScan, ScanStats } from "@/types";
|
||||
|
||||
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
|
||||
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
|
||||
@@ -66,38 +67,38 @@ export default function SupervisorDashboardPage() {
|
||||
// /api/stats/supervisor covers scan activity + today's payments (unchanged from before);
|
||||
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
|
||||
// with the admin dashboard since supervisors already have full revenue/donation visibility.
|
||||
const [scanStats, setScanStats] = useStableState<any | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<any[]>([]);
|
||||
const [scanStats, setScanStats] = useStableState<ScanStats | null>(null);
|
||||
const [paymentStats, setPaymentStats] = useStableState<PaymentStats | null>(null);
|
||||
const [recentScans, setRecentScans] = useStableState<RecentScan[]>([]);
|
||||
const [overview, setOverview] = useStableState<Overview | null>(null);
|
||||
const [loadingStats, setLoadingStats] = useState(false);
|
||||
const hasLoadedOnce = useRef(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
const loadStats = useCallback(async () => {
|
||||
if (!token) return;
|
||||
const isFirstLoad = !hasLoadedOnce.current;
|
||||
try {
|
||||
if (isFirstLoad) setLoadingStats(true);
|
||||
const [data, overviewData] = await Promise.all([
|
||||
apiFetch<any>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<DashboardStats>("/api/stats/supervisor", { authToken: token }),
|
||||
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
|
||||
]);
|
||||
setScanStats(data.scanStats);
|
||||
setPaymentStats(data.paymentStats);
|
||||
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
|
||||
setOverview(overviewData);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
hasLoadedOnce.current = true;
|
||||
if (isFirstLoad) setLoadingStats(false);
|
||||
}
|
||||
};
|
||||
}, [token, setScanStats, setPaymentStats, setRecentScans, setOverview]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
loadStats();
|
||||
}, [token]);
|
||||
}, [token, loadStats]);
|
||||
|
||||
// Poll every 15s while the tab is visible; pause in the background and refetch
|
||||
// immediately on return instead of leaving stale numbers up.
|
||||
@@ -184,7 +185,7 @@ export default function SupervisorDashboardPage() {
|
||||
{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) => (
|
||||
{recentScans.map((u) => (
|
||||
<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>
|
||||
@@ -236,11 +237,11 @@ export default function SupervisorDashboardPage() {
|
||||
<div className="text-sm text-gray-500">No payment data yet.</div>
|
||||
)}
|
||||
|
||||
{scanStats?.byStaff?.length > 0 && (
|
||||
{scanStats && scanStats.byStaff?.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<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) => (
|
||||
{scanStats.byStaff.map((s) => (
|
||||
<li key={s.scannedById} className="flex justify-between">
|
||||
<span>{s.name || 'Staff'}</span>
|
||||
<span className="font-medium">{s.count}</span>
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { scoreUser } from "@/lib/fuzzyMatch";
|
||||
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import type { Event, Payment, Registration, User } from "@/types";
|
||||
|
||||
// A donation is never mutated once created — assigning it to a registration creates a separate
|
||||
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
|
||||
// That leg is not new money: it just re-labels part of an already-counted donation as applied
|
||||
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
|
||||
// excluded — the money was already counted via the original donation row.
|
||||
function isDonationLeg(p: any): boolean {
|
||||
function isDonationLeg(p: Payment): boolean {
|
||||
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
|
||||
}
|
||||
|
||||
@@ -29,13 +32,27 @@ function refundMethodForOriginal(method: string | null | undefined): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
|
||||
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
|
||||
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
|
||||
type YocoTx = {
|
||||
id: string;
|
||||
amount: number;
|
||||
checkoutId?: string | null;
|
||||
externalId?: string | null;
|
||||
methodType?: string | null;
|
||||
createdDate?: string | null;
|
||||
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
|
||||
};
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
regs: any[];
|
||||
regs: Registration[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{regs.map((r: any) => {
|
||||
{regs.map((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — ${r.user?.name || r.userId || 'User'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return (
|
||||
@@ -49,7 +66,7 @@ function RegistrationOptions({ regs, regOutstanding }: {
|
||||
}
|
||||
|
||||
function UserSearchField({ allUsers, value, onChange, placeholder = "Search by name, email or phone…", disabled = false }: {
|
||||
allUsers: any[];
|
||||
allUsers: User[];
|
||||
value: string;
|
||||
onChange: (userId: string) => void;
|
||||
placeholder?: string;
|
||||
@@ -136,87 +153,59 @@ function PaymentsContent() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [payments, setPayments] = useState<any[]>([]);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [loadingRegs, setLoadingRegs] = useState(false);
|
||||
const [regOutstanding, setRegOutstanding] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number }>>({});
|
||||
const [regOutstanding, setRegOutstanding] = useState<Outstanding>({});
|
||||
|
||||
const loadPayments = async () => {
|
||||
const loadPayments = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingList(true);
|
||||
const list = await fetchAllPayments(token);
|
||||
setPayments(list.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load payments");
|
||||
setPayments(list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load payments");
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
};
|
||||
}, [token, setError]);
|
||||
|
||||
useEffect(() => { loadPayments(); }, [token]);
|
||||
|
||||
// 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;
|
||||
};
|
||||
useEffect(() => { loadPayments(); }, [loadPayments]);
|
||||
|
||||
// Load registrations for dropdowns.
|
||||
// GET /api/registrations already embeds each registration's `payments`, so outstanding
|
||||
// balances are computed from that in one pass — no per-registration follow-up requests.
|
||||
const loadRegistrations = async () => {
|
||||
const loadRegistrations = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setLoadingRegs(true);
|
||||
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>("/api/registrations", { authToken: token });
|
||||
const list = Array.isArray(regs) ? regs : [];
|
||||
const now = new Date();
|
||||
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
|
||||
const map: Outstanding = {};
|
||||
for (const r of list) {
|
||||
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalPaid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
||||
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
|
||||
}
|
||||
setRegOutstanding(map);
|
||||
// Sort by createdAt desc
|
||||
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
setRegistrations(sorted);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoadingRegs(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadRegistrations(); }, [token]);
|
||||
useEffect(() => { loadRegistrations(); }, [loadRegistrations]);
|
||||
|
||||
// Load all users for Refund section
|
||||
useEffect(() => {
|
||||
@@ -226,7 +215,7 @@ function PaymentsContent() {
|
||||
setLoadingUsers(true);
|
||||
const users = await fetchAllUsers(token);
|
||||
setAllUsers(users);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore; fallback to derived users from registrations
|
||||
} finally {
|
||||
setLoadingUsers(false);
|
||||
@@ -240,12 +229,12 @@ function PaymentsContent() {
|
||||
const [registrationId, setRegistrationId] = useState<string>("");
|
||||
const [isDonation, setIsDonation] = useState<boolean>(false);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// Backdate field
|
||||
const [paidAtLocal, setPaidAtLocal] = useState<string>("");
|
||||
// Users for Refund section (load all users with supervisor rights)
|
||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<User[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||
|
||||
// New: user selection states for split dropdowns
|
||||
@@ -253,33 +242,12 @@ function PaymentsContent() {
|
||||
|
||||
// Derived lists for users and outstanding registrations
|
||||
const outstandingRegs = useMemo(() => {
|
||||
return registrations.filter((r: any) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
|
||||
return registrations.filter((r) => (r.status !== 'paid') && (r.status !== 'cancelled') && ((regOutstanding[r.id]?.outstanding ?? 0) > 0));
|
||||
}, [registrations, regOutstanding]);
|
||||
|
||||
// Users list for UI dropdowns (use all users if loaded; fallback to outstanding registrations)
|
||||
const usersList = useMemo(() => {
|
||||
if (allUsers && allUsers.length > 0) {
|
||||
return [...allUsers]
|
||||
//.filter(u => u?.isActive !== false)
|
||||
.map(u => ({ id: String(u.id), name: String(u.name || u.email || u.id) }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
|
||||
}
|
||||
const map = new Map<string, string>();
|
||||
for (const r of outstandingRegs) {
|
||||
const uid = r.user?.id || r.userId;
|
||||
if (!uid) continue;
|
||||
const name = r.user?.name || r.user?.email || String(r.userId || uid);
|
||||
if (!map.has(String(uid))) {
|
||||
map.set(String(uid), String(name));
|
||||
}
|
||||
}
|
||||
return Array.from(map.entries())
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined as any, { sensitivity: 'base' } as any));
|
||||
}, [allUsers, outstandingRegs]);
|
||||
|
||||
const regsForSelectedUser = useMemo(() => {
|
||||
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(selectedUserId)));
|
||||
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(selectedUserId)));
|
||||
}, [outstandingRegs, selectedUserId]);
|
||||
|
||||
// Payment link tab state
|
||||
@@ -292,7 +260,7 @@ function PaymentsContent() {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
|
||||
const regsForLinkUser = useMemo(() => {
|
||||
return outstandingRegs.filter((r: any) => (String(r.user?.id || r.userId) === String(linkUserId)));
|
||||
return outstandingRegs.filter((r) => (String(r.user?.id || r.userId) === String(linkUserId)));
|
||||
}, [outstandingRegs, linkUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -327,8 +295,8 @@ function PaymentsContent() {
|
||||
if (!res.redirectUrl) { setError("Failed to generate link"); return; }
|
||||
setLinkResult({ redirectUrl: res.redirectUrl, amount: res.amount ?? amt, registrationId: linkRegistrationId });
|
||||
setInfo("Payment link generated");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to generate payment link");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to generate payment link");
|
||||
} finally {
|
||||
setLinkGenerating(false);
|
||||
}
|
||||
@@ -356,8 +324,8 @@ function PaymentsContent() {
|
||||
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
|
||||
});
|
||||
setInfo(`Payment link sent via ${channel === "email" ? "email" : "WhatsApp"}`);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || `Failed to send link via ${channel}`);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || `Failed to send link via ${channel}`);
|
||||
} finally {
|
||||
setLinkSending(null);
|
||||
}
|
||||
@@ -371,7 +339,7 @@ function PaymentsContent() {
|
||||
// If a registration is preselected via query, infer and set its user
|
||||
useEffect(() => {
|
||||
if (!registrationId) return;
|
||||
const reg = registrations.find((r: any) => String(r.id) === String(registrationId));
|
||||
const reg = registrations.find((r) => String(r.id) === String(registrationId));
|
||||
const uid = reg?.user?.id || reg?.userId;
|
||||
if (uid) setSelectedUserId(String(uid));
|
||||
}, [registrationId, registrations]);
|
||||
@@ -387,7 +355,7 @@ function PaymentsContent() {
|
||||
if (eventsIncludePast) params.set("includePast", "true");
|
||||
if (eventsIncludeInactive) params.set("includeInactive", "true");
|
||||
const qs = params.toString() ? `?${params.toString()}` : "";
|
||||
const evs = await apiFetch<any[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
const evs = await apiFetch<Event[]>(`/api/events/all${qs}`, { authToken: token });
|
||||
setEvents(evs || []);
|
||||
} catch {}
|
||||
})();
|
||||
@@ -417,7 +385,7 @@ function PaymentsContent() {
|
||||
}
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const res = await apiFetch<any>("/api/payments", {
|
||||
await apiFetch("/api/payments", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -433,8 +401,8 @@ function PaymentsContent() {
|
||||
setInfo(`Payment created (R ${amt.toFixed(2)})`);
|
||||
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
|
||||
await Promise.all([loadPayments(), loadRegistrations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create payment");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create payment");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -454,38 +422,38 @@ function PaymentsContent() {
|
||||
|
||||
// Yoco unreconciled list and actions
|
||||
const [yocoLoading, setYocoLoading] = useState(false);
|
||||
const [yocoTxs, setYocoTxs] = useState<any[]>([]);
|
||||
const loadYocoUnreconciled = async () => {
|
||||
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
|
||||
const loadYocoUnreconciled = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setYocoLoading(true);
|
||||
const res = await apiFetch<any>("/api/yoco-transactions/unreconciled", { authToken: token });
|
||||
const items = Array.isArray(res?.data) ? res.data : (Array.isArray(res) ? res : []);
|
||||
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
|
||||
setYocoTxs(items);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// ignore silently, shown on demand via error state if needed
|
||||
} finally {
|
||||
setYocoLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => { loadYocoUnreconciled(); }, [token]);
|
||||
}, [token]);
|
||||
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
|
||||
|
||||
// Inline reconcile form state per-row
|
||||
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
|
||||
const openReconcileToRegistration = (tx: any) => {
|
||||
const openReconcileToRegistration = (tx: YocoTx) => {
|
||||
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
|
||||
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
|
||||
};
|
||||
|
||||
const openReconcileAsDonation = (tx: any) => {
|
||||
const openReconcileAsDonation = (tx: YocoTx) => {
|
||||
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
|
||||
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
|
||||
};
|
||||
|
||||
const regsForUser = (uid: string) => registrations
|
||||
.filter((r: any) => String(r.user?.id || r.userId) === String(uid))
|
||||
.filter((r: any) => {
|
||||
.filter((r) => String(r.user?.id || r.userId) === String(uid))
|
||||
.filter((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
return out > 0.000001; // show only registrations with an outstanding balance
|
||||
});
|
||||
@@ -515,8 +483,8 @@ function PaymentsContent() {
|
||||
}
|
||||
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to reconcile');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to reconcile');
|
||||
} finally {
|
||||
setReconcileForm(prev => ({ ...prev, submitting: false }));
|
||||
}
|
||||
@@ -618,8 +586,8 @@ function PaymentsContent() {
|
||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
|
||||
setInfo('Transaction ignored');
|
||||
await loadYocoUnreconciled();
|
||||
} catch (e:any) {
|
||||
setError(e?.message || 'Failed to ignore');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to ignore');
|
||||
}
|
||||
}}>Ignore</button>
|
||||
</td>
|
||||
@@ -637,7 +605,7 @@ function PaymentsContent() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Registration</label>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.registrationId} onChange={e => setReconcileForm(prev => ({ ...prev, registrationId: e.target.value }))} disabled={!reconcileForm.userId}>
|
||||
<option value="">Select registration…</option>
|
||||
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r: any) => {
|
||||
{reconcileForm.userId && regsForUser(reconcileForm.userId).map((r) => {
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id} title={label}>{label}</option>;
|
||||
@@ -753,8 +721,7 @@ function PaymentsContent() {
|
||||
<RefundSection
|
||||
payments={payments}
|
||||
allUsers={allUsers}
|
||||
usersList={usersList}
|
||||
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
|
||||
regsForUser={(uid:string)=> registrations.filter((r)=> String(r.user?.id||r.userId)===String(uid))}
|
||||
regOutstanding={regOutstanding}
|
||||
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
|
||||
/>
|
||||
@@ -875,15 +842,14 @@ function PaymentsContent() {
|
||||
}
|
||||
|
||||
type RefundSectionProps = {
|
||||
payments: any[];
|
||||
allUsers: any[];
|
||||
usersList: { id: string; name: string }[];
|
||||
regsForUser: (userId: string) => any[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
payments: Payment[];
|
||||
allUsers: User[];
|
||||
regsForUser: (userId: string) => Registration[];
|
||||
regOutstanding: Outstanding;
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
|
||||
function RefundSection({ payments, allUsers, regsForUser, regOutstanding, onDone }: RefundSectionProps) {
|
||||
const { token } = useAuth();
|
||||
const [userId, setUserId] = useState<string>("");
|
||||
const [target, setTarget] = useState<'payment'|'registration'>('payment');
|
||||
@@ -896,7 +862,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const paymentsForUser = useMemo(() => {
|
||||
if (!userId) return [] as any[];
|
||||
if (!userId) return [];
|
||||
return payments.filter(p => String(p.userId) === String(userId) && (p.amount || 0) > 0);
|
||||
}, [payments, userId]);
|
||||
|
||||
@@ -946,8 +912,8 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
});
|
||||
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to create refund');
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || 'Failed to create refund');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -970,7 +936,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<label className="text-xs text-gray-600 mt-2">Payment</label>
|
||||
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={paymentId} onChange={e => setPaymentId(e.target.value)} disabled={!userId}>
|
||||
<option value="">Select payment…</option>
|
||||
{paymentsForUser.map((p:any)=>{
|
||||
{paymentsForUser.map((p)=>{
|
||||
const label = `R ${(p.amount||0).toFixed(2)} — ${p.registrationId ? `Registration ${String(p.registrationId).slice(0,8)}` : (p.event?.title || p.eventId || 'Event')} — #${String(p.id).slice(0,8)}`;
|
||||
return <option key={p.id} value={p.id} title={label}>{label}</option>;
|
||||
})}
|
||||
@@ -981,7 +947,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<label className="text-xs text-gray-600 mt-2">Registration</label>
|
||||
<select className="w-full max-w-full border rounded px-3 py-2 text-sm truncate" value={registrationId} onChange={e => setRegistrationId(e.target.value)} disabled={!userId}>
|
||||
<option value="">Select registration…</option>
|
||||
{regs.map((r:any)=>{
|
||||
{regs.map((r)=>{
|
||||
const out = regOutstanding[r.id]?.outstanding ?? 0;
|
||||
const label = `${r.event?.title || r.eventId || 'Event'} — Outstanding: R ${out.toFixed(2)} — #${String(r.id).slice(0,8)}`;
|
||||
return <option key={r.id} value={r.id} title={label}>{label}</option>;
|
||||
@@ -1003,7 +969,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
<option value="eft-refund">EFT refund</option>
|
||||
<option value="voucher-refund">Voucher refund</option>
|
||||
</select>
|
||||
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method's total in reports.</div>
|
||||
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded — this is what nets against that method's total in reports.</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
|
||||
@@ -1019,7 +985,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
|
||||
// stats both deliberately filter them out (they're not new money, see isDonationLeg's comment) —
|
||||
// so this is the only place staff can see and reverse an assignment.
|
||||
type AssignedDonationsListProps = {
|
||||
payments: any[];
|
||||
payments: Payment[];
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1033,12 +999,12 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
const allLegs = useMemo(() => {
|
||||
return payments
|
||||
.filter(isDonationLeg)
|
||||
.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}, [payments]);
|
||||
|
||||
const donationById = useMemo(() => {
|
||||
const m = new Map<string, any>();
|
||||
payments.forEach((p: any) => { if (p.isDonation) m.set(p.id, p); });
|
||||
const m = new Map<string, Payment>();
|
||||
payments.forEach((p) => { if (p.isDonation) m.set(p.id, p); });
|
||||
return m;
|
||||
}, [payments]);
|
||||
|
||||
@@ -1046,7 +1012,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
// never shows an event with nothing to filter down to.
|
||||
const eventOptions = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
allLegs.forEach((leg: any) => {
|
||||
allLegs.forEach((leg) => {
|
||||
const ev = leg.registration?.event;
|
||||
if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id));
|
||||
});
|
||||
@@ -1055,10 +1021,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
|
||||
const legs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return allLegs.filter((leg: any) => {
|
||||
return allLegs.filter((leg) => {
|
||||
if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false;
|
||||
if (!q) return true;
|
||||
const donation = donationById.get(leg.originalPaymentId);
|
||||
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
|
||||
const haystack = [
|
||||
leg.registration?.user?.name,
|
||||
leg.registration?.user?.email,
|
||||
@@ -1072,7 +1038,7 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
});
|
||||
}, [allLegs, donationById, query, eventFilter]);
|
||||
|
||||
const unassign = async (leg: any) => {
|
||||
const unassign = async (leg: Payment) => {
|
||||
if (!token) return;
|
||||
setErr(null);
|
||||
const ok = window.confirm("Unassign this donation? The registration's balance will increase, and any tickets issued only because this payment completed it may be revoked.");
|
||||
@@ -1085,8 +1051,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
body: { legId: leg.id }
|
||||
});
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to unassign donation');
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || 'Failed to unassign donation');
|
||||
} finally {
|
||||
setUnassigningId(null);
|
||||
}
|
||||
@@ -1120,8 +1086,8 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
<div className="text-sm text-gray-500">No assigned donations match your search.</div>
|
||||
) : (
|
||||
<ul className="text-sm space-y-2 max-h-[420px] overflow-auto pr-2">
|
||||
{legs.map((leg: any) => {
|
||||
const donation = donationById.get(leg.originalPaymentId);
|
||||
{legs.map((leg) => {
|
||||
const donation = leg.originalPaymentId ? donationById.get(leg.originalPaymentId) : undefined;
|
||||
return (
|
||||
<li key={leg.id} className="border rounded p-2">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
@@ -1153,10 +1119,10 @@ function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps)
|
||||
}
|
||||
|
||||
type DonationAssignSectionProps = {
|
||||
payments: any[];
|
||||
allUsers: any[];
|
||||
registrations: any[];
|
||||
regOutstanding: Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
payments: Payment[];
|
||||
allUsers: User[];
|
||||
registrations: Registration[];
|
||||
regOutstanding: Outstanding;
|
||||
onDone: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
@@ -1180,8 +1146,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// Registrations for the selected user that can actually receive a donation: an outstanding
|
||||
// balance to apply it to, and an event that isn't closed.
|
||||
const regsForUser = useMemo(() => {
|
||||
if (!userId) return [] as any[];
|
||||
return registrations.filter((r: any) => {
|
||||
if (!userId) return [];
|
||||
return registrations.filter((r) => {
|
||||
if (String(r.user?.id || r.userId) !== String(userId)) return false;
|
||||
if (r.status === 'cancelled') return false;
|
||||
if (r.event?.cashupStatus === 'closed') return false;
|
||||
@@ -1191,7 +1157,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
}, [registrations, regOutstanding, userId]);
|
||||
|
||||
const selectedRegistration = useMemo(
|
||||
() => registrations.find((r: any) => String(r.id) === String(registrationId)),
|
||||
() => registrations.find((r) => String(r.id) === String(registrationId)),
|
||||
[registrations, registrationId]
|
||||
);
|
||||
|
||||
@@ -1204,7 +1170,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// adding the refund back on top of what's left to allocate).
|
||||
const legsById = useMemo(() => {
|
||||
const m = new Map<string, number>();
|
||||
payments.forEach((p: any) => {
|
||||
payments.forEach((p) => {
|
||||
if (p.originalPaymentId && !p.isDonation) {
|
||||
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
|
||||
}
|
||||
@@ -1215,9 +1181,9 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
// Donations "relevant to that event" — donations logged against the same event as the
|
||||
// chosen registration that still have a remaining, unused balance.
|
||||
const donationsForEvent = useMemo(() => {
|
||||
if (!selectedRegistration) return [] as any[];
|
||||
if (!selectedRegistration) return [];
|
||||
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
|
||||
return payments.filter((p: any) => {
|
||||
return payments.filter((p) => {
|
||||
if (!p.isDonation || p.registrationId) return false;
|
||||
if (String(p.eventId) !== String(eventId)) return false;
|
||||
if (sponsorshipsOnly && !p.isSponsorship) return false;
|
||||
@@ -1227,7 +1193,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
}, [payments, selectedRegistration, legsById, sponsorshipsOnly]);
|
||||
|
||||
const selectedDonation = useMemo(
|
||||
() => payments.find((p: any) => String(p.id) === String(paymentId)),
|
||||
() => payments.find((p) => String(p.id) === String(paymentId)),
|
||||
[payments, paymentId]
|
||||
);
|
||||
|
||||
@@ -1269,8 +1235,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
});
|
||||
setUserId(""); setRegistrationId(""); setPaymentId(""); setAmountStr("");
|
||||
await onDone();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message || 'Failed to assign donation');
|
||||
} catch (e) {
|
||||
setErr(errorMessage(e) || 'Failed to assign donation');
|
||||
} finally {
|
||||
setAssigning(false);
|
||||
}
|
||||
@@ -1306,7 +1272,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
|
||||
disabled={!registrationId}
|
||||
>
|
||||
<option value="">Select donation…</option>
|
||||
{donationsForEvent.map((p: any) => {
|
||||
{donationsForEvent.map((p) => {
|
||||
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
|
||||
const donor = p.isSponsorship ? `Sponsorship: ${p.sponsorAnonymous ? 'anonymous' : (p.sponsorName || p.user?.name || 'sponsor')}` : (p.user?.name || p.userId || 'Donor');
|
||||
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${donor} — #${String(p.id).slice(0,8)}`;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { formatDate } from "@/lib/date";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -43,7 +45,7 @@ export default function WaitlistPage() {
|
||||
if (!user) router.replace("/login");
|
||||
}, [user, loading, router]);
|
||||
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState(search.get("eventId") || "");
|
||||
const [entries, setEntries] = useState<Entry[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -52,7 +54,7 @@ export default function WaitlistPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canView) return;
|
||||
apiFetch<any[]>("/api/events/all", { authToken: token })
|
||||
apiFetch<Event[]>("/api/events/all", { authToken: token })
|
||||
.then(evs => {
|
||||
const withList = (evs || []).filter(e => e.waitlistEnabled);
|
||||
setEvents(withList);
|
||||
@@ -67,22 +69,22 @@ export default function WaitlistPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
setEntries(await apiFetch<Entry[]>(`/api/waitlist/events/${encodeURIComponent(eventId)}`, { authToken: token }));
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load the waitlist");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load the waitlist");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, [token, eventId, setError]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const act = async (path: string, body?: any) => {
|
||||
const act = async (path: string, body?: { direction: "up" | "down" }) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message?: string }>(path, { method: "POST", authToken: token!, body });
|
||||
if (res?.message) setInfo(res.message);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import React, { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import React, { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { apiFetch, fetchAllUsers } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
|
||||
import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
type UserEntry = { id: string; name: string; phone: string; pref: string };
|
||||
type TemplateKey = "custom" | "payment_reminder" | "event_reminder" | "tickets";
|
||||
type StatusFilter = "any" | "paid" | "unpaid" | "partial_paid" | "cancelled";
|
||||
type MessageFilter = { status?: Exclude<StatusFilter, "any">; attendeeIds?: string[]; allocationGroupId?: string };
|
||||
|
||||
const WA_TAB_ICONS: Record<"attendees" | "automations" | "broadcasts" | "scheduled", LucideIcon> = {
|
||||
attendees: Users,
|
||||
@@ -160,16 +165,14 @@ function AttendeesCheckboxDropdown({
|
||||
|
||||
// ─── 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 mismatched = useMemo(() => {
|
||||
const prefMatch = (pref: string) =>
|
||||
channel === "whatsapp" ? pref === "whatsapp" || pref === "both" : pref === "email" || pref === "both";
|
||||
return selectedIds.filter((id) => {
|
||||
const a = attendees.find((x) => x.id === id);
|
||||
return a && !prefMatch(a.pref);
|
||||
}),
|
||||
[attendees, selectedIds, channel]
|
||||
);
|
||||
});
|
||||
}, [attendees, selectedIds, channel]);
|
||||
|
||||
if (mismatched.length === 0) return null;
|
||||
return (
|
||||
@@ -188,9 +191,9 @@ function PrefWarning({ attendees, selectedIds, channel }: { attendees: (Attendee
|
||||
}
|
||||
|
||||
// ─── WhatsApp Automations Panel ───────────────────────────────────────────────
|
||||
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[]; token?: string | null; onInfo: (s: string) => void; onError: (s: string) => void }) {
|
||||
function WAAutomationsPanel({ events, token, onInfo, onError }: { events: Event[]; token?: string | null; onInfo: (s: string | null) => void; onError: (s: string | null) => void }) {
|
||||
const [autoEventId, setAutoEventId] = React.useState<string>("");
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e: any) => e.id === autoEventId), [events, autoEventId]);
|
||||
const currentEvent = React.useMemo(() => (events || []).find((e) => 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}}`;
|
||||
@@ -230,10 +233,10 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
|
||||
const onSchedule = async () => {
|
||||
try {
|
||||
onError(null as any); onInfo(null as any);
|
||||
onError(null); onInfo(null);
|
||||
if (!token) { onError("Not authenticated"); return; }
|
||||
if (!autoEventId) { onError("Please select an event"); return; }
|
||||
const jobs: any[] = [];
|
||||
const jobs: { message: string; scheduledAt: string; promoEventId?: string }[] = [];
|
||||
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() });
|
||||
@@ -248,8 +251,8 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
scheduled++;
|
||||
}
|
||||
onInfo(`Scheduled ${scheduled} WhatsApp automation(s).`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Failed to schedule automations");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Failed to schedule automations");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -259,7 +262,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
<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) => (
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -286,7 +289,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
<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)}>
|
||||
<select className="border rounded px-2 py-1 text-sm" value={finalHours} onChange={(e) => setFinalHours(e.target.value as "24" | "48")}>
|
||||
<option value="24">24</option>
|
||||
<option value="48">48</option>
|
||||
</select>
|
||||
@@ -324,7 +327,7 @@ function WAAutomationsPanel({ events, token, onInfo, onError }: { events: any[];
|
||||
<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) => (
|
||||
{(events || []).map((ev) => (
|
||||
<option key={ev.id} value={ev.id}>{ev.title}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -372,7 +375,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [tab, setTab] = useState<"attendees" | "automations" | "broadcasts" | "scheduled">("attendees");
|
||||
const [loadingEvents, setLoadingEvents] = useState(false);
|
||||
const [allEvents, setAllEvents] = useState<any[]>([]);
|
||||
const [allEvents, setAllEvents] = useState<Event[]>([]);
|
||||
const [evIncludePast, setEvIncludePast] = useState(false);
|
||||
|
||||
const events = useMemo(() => {
|
||||
@@ -385,24 +388,26 @@ function WhatsAppAttendeesPageInner() {
|
||||
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");
|
||||
const evs = await apiFetch<Event[]>("/api/events/all?includePast=true", { authToken: token || undefined });
|
||||
setAllEvents((evs || []).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
} finally {
|
||||
setLoadingEvents(false);
|
||||
}
|
||||
};
|
||||
loadEvents();
|
||||
}, [user, token]);
|
||||
}, [user, token, setError]);
|
||||
|
||||
// ── 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 [templateKey, setTemplateKey] = useState<TemplateKey>("custom");
|
||||
const [message, setMessage] = useState("");
|
||||
const [messageDirty, setMessageDirty] = useState(false);
|
||||
const [status, setStatus] = useState<"any" | "paid" | "unpaid" | "partial_paid" | "cancelled">("any");
|
||||
// Set once the supervisor edits the message, so picking a template doesn't overwrite it.
|
||||
// A ref, not state: it only gates the template effect below and is never rendered.
|
||||
const messageDirtyRef = useRef(false);
|
||||
const [status, setStatus] = useState<StatusFilter>("any");
|
||||
const [groupId, setGroupId] = useState("");
|
||||
const [attendees, setAttendees] = useState<Attendee[]>([]);
|
||||
const [selectedAttendeeIds, setSelectedAttendeeIds] = useState<string[]>([]);
|
||||
@@ -418,11 +423,11 @@ function WhatsAppAttendeesPageInner() {
|
||||
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!`);
|
||||
if (!messageDirtyRef.current) 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!`);
|
||||
if (!messageDirtyRef.current) 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("");
|
||||
messageDirtyRef.current = false; setMessage("");
|
||||
}
|
||||
}, [templateKey, currentEvent]);
|
||||
|
||||
@@ -431,10 +436,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
setLoadingAttendees(true); setAttendees([]); setSelectedAttendeeIds([]);
|
||||
if (!eventId || !token) return;
|
||||
const regs = await apiFetch<any[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const regs = await apiFetch<Registration[]>(`/api/registrations/event/${encodeURIComponent(eventId)}`, { authToken: token });
|
||||
const uniq = new Map<string, Attendee>();
|
||||
(regs || []).forEach((r: any) => {
|
||||
const u = r?.user;
|
||||
(regs || []).forEach((r) => {
|
||||
const u: User | undefined = 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" });
|
||||
}
|
||||
@@ -450,7 +455,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
}, [eventId, token]);
|
||||
|
||||
const buildPayload = (dryRun?: boolean) => {
|
||||
const payload: any = {
|
||||
const payload: { filter: MessageFilter; template: TemplateKey; dryRun?: boolean; message?: string } = {
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
dryRun: dryRun || undefined,
|
||||
@@ -465,15 +470,15 @@ function WhatsAppAttendeesPageInner() {
|
||||
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) });
|
||||
const res = await apiFetch<MessageSendResult>(`/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"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to preview"); }
|
||||
};
|
||||
|
||||
const resetAttendeesForm = () => {
|
||||
setTemplateKey("custom"); setMessage(""); setMessageDirty(false);
|
||||
setTemplateKey("custom"); setMessage(""); messageDirtyRef.current = false;
|
||||
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
|
||||
setSelectedAttendeeIds(attendees.map((a) => a.id));
|
||||
};
|
||||
@@ -485,10 +490,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
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() });
|
||||
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
|
||||
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
|
||||
resetAttendeesForm();
|
||||
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to send"); } finally { setSending(false); }
|
||||
};
|
||||
|
||||
const onScheduleSend = async () => {
|
||||
@@ -499,15 +504,15 @@ function WhatsAppAttendeesPageInner() {
|
||||
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 = {
|
||||
const payload: { message?: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } = {
|
||||
message: message || undefined,
|
||||
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey, scheduledAt: whenIso,
|
||||
};
|
||||
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
|
||||
const res = await apiFetch<MessageSendResult>(`/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"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to schedule"); }
|
||||
};
|
||||
|
||||
// ── Broadcasts tab ─────────────────────────────────────────────────────────
|
||||
@@ -528,9 +533,9 @@ function WhatsAppAttendeesPageInner() {
|
||||
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" }));
|
||||
.filter((u) => u.isActive !== false && !u.email?.endsWith("@guest.local") && u.phoneNumber)
|
||||
.map((u) => ({ id: u.id, name: u.name || "", phone: u.phoneNumber || "", pref: u.notificationPreference || "email" }))
|
||||
.sort((a, b) => (a.name || "").localeCompare(b.name || "", undefined, { sensitivity: "base" }));
|
||||
setUsers(mapped);
|
||||
} catch { } finally { setLoadingUsers(false); }
|
||||
};
|
||||
@@ -552,30 +557,30 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [editWhen, setEditWhen] = useState<string>("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
const loadScheduled = async () => {
|
||||
const loadScheduled = useCallback(async () => {
|
||||
try {
|
||||
if (!token) return;
|
||||
setLoadingScheduled(true);
|
||||
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
|
||||
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
|
||||
} catch { } finally { setLoadingScheduled(false); }
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, token]);
|
||||
useEffect(() => { if (tab === "scheduled") loadScheduled(); }, [tab, loadScheduled]);
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editing) return;
|
||||
try {
|
||||
setSavingEdit(true);
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
const body: any = {};
|
||||
const body: { scheduledAt?: string; message?: string } = {};
|
||||
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
|
||||
if (editMessage.trim()) body.message = 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); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to update"); } finally { setSavingEdit(false); }
|
||||
};
|
||||
|
||||
const removeJob = async (job: ScheduledJob) => {
|
||||
@@ -584,7 +589,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
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"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to remove"); }
|
||||
};
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
@@ -663,7 +668,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
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)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={templateKey} onChange={(e) => setTemplateKey(e.target.value as TemplateKey)}>
|
||||
<option value="custom">Custom message</option>
|
||||
<option value="payment_reminder">Payment reminder</option>
|
||||
<option value="event_reminder">Event reminder</option>
|
||||
@@ -693,7 +698,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<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>
|
||||
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
|
||||
<li><code>{"{{payment.link}}"}</code> — a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</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,{" "}
|
||||
@@ -709,7 +714,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<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)}>
|
||||
<select className="w-full border rounded px-3 py-2 text-sm" value={status} onChange={(e) => setStatus(e.target.value as StatusFilter)}>
|
||||
<option value="any">Any</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="unpaid">Unpaid (pending or partial)</option>
|
||||
@@ -724,7 +729,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
<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); }}
|
||||
onChange={(e) => { setMessage(e.target.value); messageDirtyRef.current = true; }}
|
||||
placeholder="Write your WhatsApp message to attendees..." />
|
||||
</div>
|
||||
) : (
|
||||
@@ -837,11 +842,11 @@ function WhatsAppAttendeesPageInner() {
|
||||
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 } });
|
||||
const res = await apiFetch<MessageSendResult>(`/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"); }
|
||||
} catch (e) { setError(errorMessage(e) || "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 () => {
|
||||
@@ -849,10 +854,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
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 } });
|
||||
const res = await apiFetch<MessageSendResult>(`/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"); }
|
||||
} catch (e) { setError(errorMessage(e) || "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 () => {
|
||||
@@ -861,10 +866,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
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 } });
|
||||
const res = await apiFetch<MessageSendResult>(`/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"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
|
||||
}}>Schedule send</button>
|
||||
|
||||
{broadcastPreviewCount != null && <span className="text-xs text-gray-600">Preview: {broadcastPreviewCount} recipient(s)</span>}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { HandHeart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
|
||||
export default function DonatePage() {
|
||||
const { token } = useAuth();
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -17,14 +19,14 @@ export default function DonatePage() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const evs = await apiFetch<any[]>("/api/events");
|
||||
const evs = await apiFetch<Event[]>("/api/events");
|
||||
setEvents(evs);
|
||||
if (evs.length > 0) setEventId(evs[0].id);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load events");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load events");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
}, [setError]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!token) { setError("Please login"); return; }
|
||||
@@ -49,8 +51,8 @@ export default function DonatePage() {
|
||||
});
|
||||
setInfo("Redirecting to payment...");
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to start donation checkout");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to start donation checkout");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -16,7 +18,7 @@ function FormsContent() {
|
||||
const { token } = useAuth();
|
||||
const registrationId = search.get("registrationId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [registration, setRegistration] = useState<Registration | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
@@ -32,22 +34,22 @@ function FormsContent() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
try {
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/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 });
|
||||
const draft = await apiFetch<{ data?: Record<number, Record<string, string>> } | null>(`/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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, registrationId]);
|
||||
}, [token, registrationId, setError, setInfo]);
|
||||
|
||||
const submittedCount = useMemo(() => {
|
||||
return (registration?.formResponses || []).length;
|
||||
@@ -57,8 +59,8 @@ function FormsContent() {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -88,7 +90,7 @@ function FormsContent() {
|
||||
if (!token || !registrationId) return;
|
||||
try {
|
||||
setError(null); setInfo(null);
|
||||
const payload: any[] = [];
|
||||
const payload: { answers: Record<string, string> }[] = [];
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
payload.push({ answers: formsData[i] || {} });
|
||||
}
|
||||
@@ -103,13 +105,13 @@ function FormsContent() {
|
||||
});
|
||||
setInfo('Attendee forms submitted successfully.');
|
||||
// reload registration to reflect new responses
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const reg = await apiFetch<Registration>(`/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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -123,8 +125,8 @@ function FormsContent() {
|
||||
body: { data: formsData }
|
||||
});
|
||||
setInfo('Draft saved. You can return later to finish.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save draft');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -159,14 +161,14 @@ function FormsContent() {
|
||||
<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) => (
|
||||
{(registration.formResponses || []).map((resp, idx) => (
|
||||
<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) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<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>
|
||||
|
||||
@@ -12,16 +12,22 @@ import { AttendeesPanel } from "@/components/shared/AttendeesPanel";
|
||||
import { MyWaitlists } from "@/components/shared/MyWaitlists";
|
||||
import { TransferTicketModal, canOfferTransfer } from "@/components/shared/TransferTicketModal";
|
||||
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
|
||||
|
||||
// Helper formatters
|
||||
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
|
||||
const isFuture = (d: string | Date) => new Date(d).getTime() > Date.now();
|
||||
const isEventOver = (ev: any) =>
|
||||
type EventStatusFields = Pick<Event, "endDate" | "cashupStatus" | "isActive">;
|
||||
type Billing = { totalDue: number; totalPaid: number; outstanding: number; payments: Payment[] };
|
||||
|
||||
const isEventOver = (ev: EventStatusFields | null | undefined) =>
|
||||
!!ev && ((ev.endDate && new Date(ev.endDate).getTime() < Date.now()) || ev.cashupStatus === 'closed');
|
||||
|
||||
// Status badge for an event, shown wherever an event title is displayed on this page.
|
||||
// Precedence when more than one applies: Closed > Past > Inactive.
|
||||
function EventStatusBadge({ event }: { event: any }) {
|
||||
function EventStatusBadge({ event }: { event: EventStatusFields | null | undefined }) {
|
||||
if (!event) return null;
|
||||
let label: string | null = null;
|
||||
let className = '';
|
||||
@@ -53,26 +59,26 @@ function RegistrationStatusBadge({ status }: { status: string }) {
|
||||
// Effective unit price for an event option (or one of its variants), early-bird aware.
|
||||
// Used by the registration editor, which works off raw /api/events/:id data rather than
|
||||
// a registration's priceSnapshot — mirrors the pricing logic in register/[eventId]/RegisterForm.tsx.
|
||||
function effectiveEventOptionUnitPrice(eo: any, variantId: string | null): number {
|
||||
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null): number {
|
||||
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
|
||||
const base = (variant && variant.price !== null && variant.price !== undefined)
|
||||
? Number(variant.price)
|
||||
: Number(eo?.price || 0);
|
||||
const allTiers = Array.isArray(eo?.earlyBirdTiers) ? eo.earlyBirdTiers : [];
|
||||
const tiers = variantId ? allTiers.filter((t: any) => t.variantId === variantId) : allTiers.filter((t: any) => !t.variantId);
|
||||
const tiers = variantId ? allTiers.filter((t) => t.variantId === variantId) : allTiers.filter((t) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
const now = new Date();
|
||||
const applicable = tiers
|
||||
.map((t: any) => ({ ...t, _d: new Date(t.deadline) }))
|
||||
.filter((t: any) => now < t._d)
|
||||
.sort((a: any, b: any) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||||
.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());
|
||||
return applicable.length > 0 ? applicable[0].price : base;
|
||||
}
|
||||
|
||||
export default function UserDashboardPage() {
|
||||
const { token, user } = useAuth();
|
||||
const [registrations, setRegistrations] = useState<any[]>([]);
|
||||
const [tickets, setTickets] = useState<any[]>([]);
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
const [error, setError] = useDismissingState<string | null>(null);
|
||||
const [info, setInfo] = useDismissingState<string | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
@@ -81,7 +87,7 @@ export default function UserDashboardPage() {
|
||||
const [showPast, setShowPast] = useState<boolean>(false);
|
||||
|
||||
// Billing map: regId -> { totalDue, totalPaid, outstanding, payments }
|
||||
const [billing, setBilling] = useState<Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }>>({});
|
||||
const [billing, setBilling] = useState<Record<string, Billing>>({});
|
||||
|
||||
// Forms status cache for registrations
|
||||
// regId -> { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }
|
||||
@@ -99,35 +105,6 @@ export default function UserDashboardPage() {
|
||||
// Track whether the active registration's event has attendee forms
|
||||
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | 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;
|
||||
// Use variant price as base if available, otherwise option base
|
||||
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() : [];
|
||||
// Filter: variant options use only their variant-specific tiers; plain options use option-level tiers
|
||||
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;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -139,29 +116,29 @@ export default function UserDashboardPage() {
|
||||
// waterfalling — this was previously fetched only after registrations + all
|
||||
// their payments had already resolved.
|
||||
const [myRegs, myTicks] = await Promise.all([
|
||||
apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
||||
apiFetch<any[]>("/api/tickets/mytickets", { authToken: token }),
|
||||
apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token }),
|
||||
apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token }),
|
||||
]);
|
||||
setRegistrations(myRegs);
|
||||
setTickets(myTicks);
|
||||
|
||||
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
|
||||
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
const totals: Record<string, Billing> = {};
|
||||
for (const r of myRegs) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
|
||||
// Fetch payments per registration to compute outstanding
|
||||
await Promise.all(
|
||||
myRegs.map(async (r: any) => {
|
||||
myRegs.map(async (r) => {
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
// totalDueComputed is not time-dependent, no need to recompute per payment time
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const outstanding = Math.max(0, totalDue - totalPaid);
|
||||
setBilling(prev => ({
|
||||
...prev,
|
||||
@@ -178,13 +155,13 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to load data");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [token, showPast]);
|
||||
}, [token, showPast, setError]);
|
||||
|
||||
// After registrations load, compute forms status for each registration.
|
||||
// `myregistrations` now embeds event.form (with fields) directly, so this no longer
|
||||
@@ -195,7 +172,7 @@ export default function UserDashboardPage() {
|
||||
if (!token || registrations.length === 0) return;
|
||||
const next: Record<string, { hasForm: boolean; isRequired?: boolean; submittedCount?: number; requiredCount?: number; complete?: boolean }> = {};
|
||||
await Promise.all(
|
||||
registrations.map(async (r: any) => {
|
||||
registrations.map(async (r) => {
|
||||
try {
|
||||
const ev = r.event;
|
||||
const hasForm = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
|
||||
@@ -204,12 +181,12 @@ export default function UserDashboardPage() {
|
||||
return;
|
||||
}
|
||||
// Load registration to get number of submitted responses
|
||||
const regFull = await apiFetch<any>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const regFull = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(r.id)}`, { authToken: token });
|
||||
const submittedCount = (regFull?.formResponses || []).length;
|
||||
// Required count equals the number of main tickets purchased
|
||||
const requiredCount = (r.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
const complete = submittedCount >= requiredCount && requiredCount > 0 ? true : (submittedCount >= requiredCount);
|
||||
next[r.id] = {
|
||||
hasForm: true,
|
||||
@@ -218,7 +195,7 @@ export default function UserDashboardPage() {
|
||||
requiredCount,
|
||||
complete,
|
||||
};
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// On any error, do not show forms status to avoid noisy UI
|
||||
next[r.id] = { hasForm: false };
|
||||
}
|
||||
@@ -245,16 +222,16 @@ export default function UserDashboardPage() {
|
||||
// Open loading dialog immediately
|
||||
setDialog({ open: true, message: "Sending tickets…", loading: true });
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
|
||||
method: "POST",
|
||||
body: { ticketIds },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) emailed successfully.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to email tickets");
|
||||
setError(errorMessage(e) || "Failed to email tickets");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -265,16 +242,16 @@ export default function UserDashboardPage() {
|
||||
setInfo(null);
|
||||
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true });
|
||||
try {
|
||||
const res: any = await apiFetch("/api/tickets/email", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/tickets/email", {
|
||||
method: "POST",
|
||||
body: { ticketIds, channel: "whatsapp" },
|
||||
authToken: token,
|
||||
});
|
||||
const msg = (res && res.message) ? res.message : `${ticketIds.length} ticket(s) sent to WhatsApp.`;
|
||||
setDialog({ open: true, message: msg, loading: false });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send tickets to WhatsApp");
|
||||
setError(errorMessage(e) || "Failed to send tickets to WhatsApp");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -288,15 +265,15 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your invoice.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send invoice");
|
||||
setError(errorMessage(e) || "Failed to send invoice");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -310,15 +287,15 @@ export default function UserDashboardPage() {
|
||||
loadingSubtitle: "Please wait while we prepare your receipt.",
|
||||
});
|
||||
try {
|
||||
const res: any = await apiFetch(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setDialog({ open: true, loading: false, title: "Receipt sent", message: (res && res.message) || "Receipt sent." });
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
setError(errorMessage(e) || "Failed to send receipt");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,10 +324,10 @@ export default function UserDashboardPage() {
|
||||
// Refresh this registration's billing so the displayed outstanding reflects the new price
|
||||
try {
|
||||
const now = new Date();
|
||||
const r = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
const totalDue = (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
|
||||
setBilling(prev => ({ ...prev, [registrationId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
} catch {}
|
||||
return;
|
||||
@@ -361,14 +338,14 @@ export default function UserDashboardPage() {
|
||||
return;
|
||||
}
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setDialog({ open: false, message: "", loading: false });
|
||||
setError(e?.message || "Failed to create checkout");
|
||||
setError(errorMessage(e) || "Failed to create checkout");
|
||||
}
|
||||
};
|
||||
|
||||
// Print helpers
|
||||
const buildTicketHtmlCard = (t: any, buyerName?: string) => {
|
||||
const buildTicketHtmlCard = (t: TicketRecord, buyerName?: string) => {
|
||||
const eventTitle = t.event?.title || t.eventId || "Event";
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
@@ -439,7 +416,7 @@ export default function UserDashboardPage() {
|
||||
w.focus();
|
||||
};
|
||||
|
||||
const printTickets = (ticketList: any[]) => {
|
||||
const printTickets = (ticketList: TicketRecord[]) => {
|
||||
if (!ticketList || ticketList.length === 0) return;
|
||||
const cards = ticketList.map(t => buildTicketHtmlCard(t, user?.name)).join("");
|
||||
openPrintWindow(`Tickets (${ticketList.length})`, cards);
|
||||
@@ -447,7 +424,7 @@ export default function UserDashboardPage() {
|
||||
|
||||
// Upcoming events derived from registrations
|
||||
const upcomingEvents = useMemo(() => {
|
||||
const map = new Map<string, any>();
|
||||
const map = new Map<string, Event>();
|
||||
for (const r of registrations) {
|
||||
if (r.event && isFuture(r.event.startDate)) {
|
||||
if (!map.has(r.event.id)) map.set(r.event.id, r.event);
|
||||
@@ -456,16 +433,6 @@ export default function UserDashboardPage() {
|
||||
return Array.from(map.values()).sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
|
||||
}, [registrations]);
|
||||
|
||||
// Tickets by grouping
|
||||
const ticketsByEvent = useMemo(() => {
|
||||
const m: Record<string, any[]> = {};
|
||||
for (const t of tickets) {
|
||||
const eid = t.eventId;
|
||||
if (!m[eid]) m[eid] = [];
|
||||
m[eid].push(t);
|
||||
}
|
||||
return m;
|
||||
}, [tickets]);
|
||||
|
||||
// Registration click -> open modal
|
||||
const activeReg = useMemo(() => registrations.find(r => r.id === activeRegId) || null, [registrations, activeRegId]);
|
||||
@@ -484,7 +451,7 @@ export default function UserDashboardPage() {
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(ar.eventId)}`);
|
||||
const ev = await apiFetch<Event>(`/api/events/${encodeURIComponent(ar.eventId)}`);
|
||||
const has = !!(ev?.form && Array.isArray(ev.form.fields) && ev.form.fields.length > 0);
|
||||
setActiveEventHasForm(has);
|
||||
} catch {
|
||||
@@ -515,35 +482,35 @@ export default function UserDashboardPage() {
|
||||
authToken: token,
|
||||
body: { code: codeInput.trim() },
|
||||
});
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
const updated = myRegs.find(r => r.id === activeRegId);
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeRegId)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = updated?.totalDueComputed ?? 0;
|
||||
setBilling(prev => ({ ...prev, [activeRegId]: { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid), payments: pays } }));
|
||||
try {
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
setCodeInput("");
|
||||
setInfo(res?.message || 'Code applied.');
|
||||
} catch (e: any) {
|
||||
setCodeError(e?.message || "Couldn't apply that code");
|
||||
} catch (e) {
|
||||
setCodeError(errorMessage(e) || "Couldn't apply that code");
|
||||
} finally {
|
||||
setCodeBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const [transferTarget, setTransferTarget] = useState<any | null>(null);
|
||||
const [transferTarget, setTransferTarget] = useState<TicketRecord | null>(null);
|
||||
|
||||
// Re-fetch registrations and tickets after something on one changes (attendee names).
|
||||
const reloadAfterAttendees = async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
};
|
||||
@@ -560,13 +527,13 @@ export default function UserDashboardPage() {
|
||||
method: 'DELETE',
|
||||
authToken: token,
|
||||
});
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
setActiveRegId(null);
|
||||
setShowCancelConfirm(false);
|
||||
setInfo('Registration cancelled.');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to cancel registration');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to cancel registration');
|
||||
setShowCancelConfirm(false);
|
||||
} finally {
|
||||
setCancelLoading(false);
|
||||
@@ -577,7 +544,7 @@ export default function UserDashboardPage() {
|
||||
// "eventOptionId" for plain options, "eventOptionId:variantId" for variant options.
|
||||
const [editMode, setEditMode] = useState<boolean>(false);
|
||||
const [editLoading, setEditLoading] = useState<boolean>(false);
|
||||
const [editEventOptions, setEditEventOptions] = useState<any[]>([]);
|
||||
const [editEventOptions, setEditEventOptions] = useState<EventOption[]>([]);
|
||||
const [editQuantities, setEditQuantities] = useState<Record<string, number>>({});
|
||||
const [editBaseQty, setEditBaseQty] = useState<Record<string, number>>({});
|
||||
const [editMinQty, setEditMinQty] = useState<Record<string, number>>({});
|
||||
@@ -591,9 +558,9 @@ export default function UserDashboardPage() {
|
||||
}, [activeReg]);
|
||||
|
||||
const editTotal = useMemo(() => {
|
||||
return editEventOptions.reduce((sum: number, eo: any) => {
|
||||
return editEventOptions.reduce((sum, eo) => {
|
||||
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
|
||||
return sum + eo.variants.reduce((vsum: number, v: any) => {
|
||||
return sum + eo.variants.reduce((vsum, v) => {
|
||||
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
|
||||
return vsum + qty * effectiveEventOptionUnitPrice(eo, v.id);
|
||||
}, 0);
|
||||
@@ -608,10 +575,10 @@ export default function UserDashboardPage() {
|
||||
setEditLoading(true);
|
||||
try {
|
||||
// Load event options (with variants) for this event
|
||||
const eventData = await apiFetch<any>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventOptions = (eventData?.eventOptions || []) as any[];
|
||||
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
|
||||
const eventOptions = eventData?.eventOptions || [];
|
||||
|
||||
const regOptById = new Map<string, any>((activeReg.registrationOptions || []).map((o: any) => [o.id, o]));
|
||||
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
|
||||
|
||||
// Prefill quantities from the current registration
|
||||
const qtyMap: Record<string, number> = {};
|
||||
@@ -624,16 +591,16 @@ export default function UserDashboardPage() {
|
||||
// (tickets are never deleted or shrunk, only ever grown, once paid).
|
||||
const minQtyMap: Record<string, number> = {};
|
||||
for (const t of tickets) {
|
||||
const ro = regOptById.get(t.registrationOptionId);
|
||||
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
|
||||
if (!ro) continue;
|
||||
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
|
||||
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
|
||||
}
|
||||
|
||||
// Ensure every option/variant has a (possibly zero) entry so they all render
|
||||
eventOptions.forEach((eo: any) => {
|
||||
eventOptions.forEach((eo) => {
|
||||
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
|
||||
eo.variants.forEach((v: any) => {
|
||||
eo.variants.forEach((v) => {
|
||||
const key = `${eo.id}:${v.id}`;
|
||||
if (!(key in qtyMap)) qtyMap[key] = 0;
|
||||
});
|
||||
@@ -648,7 +615,7 @@ export default function UserDashboardPage() {
|
||||
setEditMinQty(minQtyMap);
|
||||
setEditMode(true);
|
||||
} catch (e) {
|
||||
setEditError((e as any)?.message || 'Failed to load event options');
|
||||
setEditError(errorMessage(e) || 'Failed to load event options');
|
||||
} finally {
|
||||
setEditLoading(false);
|
||||
}
|
||||
@@ -662,8 +629,8 @@ export default function UserDashboardPage() {
|
||||
if (minQty <= 0) continue;
|
||||
if ((editQuantities[key] || 0) < minQty) {
|
||||
const [eventOptionId, variantId] = key.includes(':') ? key.split(':') : [key, undefined];
|
||||
const eo = editEventOptions.find((o: any) => o.id === eventOptionId);
|
||||
const variant = variantId ? (eo?.variants || []).find((v: any) => v.id === variantId) : null;
|
||||
const eo = editEventOptions.find((o) => o.id === eventOptionId);
|
||||
const variant = variantId ? (eo?.variants || []).find((v) => v.id === variantId) : null;
|
||||
const label = variant ? `${eo?.name || 'item'} (${variant.name})` : (eo?.name || 'item');
|
||||
setEditError(`Cannot reduce "${label}" below the ${minQty} already issued`);
|
||||
return;
|
||||
@@ -698,22 +665,22 @@ export default function UserDashboardPage() {
|
||||
body: { options: payloadOptions },
|
||||
});
|
||||
// Reload registrations to reflect changes
|
||||
const myRegs = await apiFetch<any[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
const myRegs = await apiFetch<Registration[]>(`/api/registrations/myregistrations?showPast=${showPast ? '1' : '0'}`, { authToken: token });
|
||||
setRegistrations(myRegs);
|
||||
try {
|
||||
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
|
||||
const myTicks = await apiFetch<TicketRecord[]>("/api/tickets/mytickets", { authToken: token });
|
||||
setTickets(myTicks);
|
||||
} catch {}
|
||||
// Recompute billing totals using the backend's tranche-aware totalDueComputed
|
||||
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
|
||||
const totals: Record<string, Billing> = {};
|
||||
for (const r of myRegs) {
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum, opt) => sum + registrationOptionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
|
||||
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
|
||||
}
|
||||
setBilling(totals);
|
||||
// Payments will be refreshed lazily or on next open; fetch for this one immediately
|
||||
try {
|
||||
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
|
||||
const pays = await apiFetch<Payment[]>(`/api/payments/registration/${encodeURIComponent(activeReg.id)}`, { authToken: token });
|
||||
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
|
||||
const totalDue = totals[activeReg.id]?.totalDue ?? 0;
|
||||
setBilling(prev => ({
|
||||
@@ -728,8 +695,8 @@ export default function UserDashboardPage() {
|
||||
} catch {}
|
||||
setInfo('Registration updated successfully');
|
||||
setEditMode(false);
|
||||
} catch (e: any) {
|
||||
setEditError(e?.message || 'Failed to update registration');
|
||||
} catch (e) {
|
||||
setEditError(errorMessage(e) || 'Failed to update registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -787,9 +754,9 @@ export default function UserDashboardPage() {
|
||||
<ul className="space-y-2">
|
||||
{registrations
|
||||
// Hide cancelled always; hide past unless showPast
|
||||
.filter((r: any) => r.status !== 'cancelled')
|
||||
.filter((r: any) => showPast || !isEventOver(r.event))
|
||||
.map((r: any) => {
|
||||
.filter((r) => r.status !== 'cancelled')
|
||||
.filter((r) => showPast || !isEventOver(r.event))
|
||||
.map((r) => {
|
||||
const bill = billing[r.id];
|
||||
const isCancelled = r.status === 'cancelled';
|
||||
const outstanding = isCancelled ? 0 : (bill?.outstanding ?? 0);
|
||||
@@ -829,18 +796,18 @@ export default function UserDashboardPage() {
|
||||
{(() => {
|
||||
if (r.status == 'paid') return null;
|
||||
// Early-bird notice: only for events with tiers that haven't passed
|
||||
const allTiers = (r.registrationOptions || []).flatMap((ro: any) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
|
||||
const allTiers = (r.registrationOptions || []).flatMap((ro) => Array.isArray(ro.eventOption?.earlyBirdTiers) ? ro.eventOption.earlyBirdTiers : []);
|
||||
const upcoming = allTiers
|
||||
.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());
|
||||
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t) => new Date() < t.deadline)
|
||||
.sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
|
||||
if (upcoming.length === 0) return null;
|
||||
const nextDeadline = upcoming[0].deadline;
|
||||
const pays = (billing[r.id]?.payments || []);
|
||||
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p: any) => new Date(p.createdAt).getTime()))) : null;
|
||||
const lastPaymentAt = pays.length > 0 ? new Date(Math.max(...pays.map((p) => new Date(p.createdAt).getTime()))) : null;
|
||||
const now = new Date();
|
||||
const priceNow = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
|
||||
const afterPrice = (r.registrationOptions || []).reduce((s: number, ro: any) => s + optionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
|
||||
const priceNow = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, now) * (ro.quantity || 0), 0);
|
||||
const afterPrice = (r.registrationOptions || []).reduce((s, ro) => s + registrationOptionUnitPrice(ro, lastPaymentAt, new Date(nextDeadline.getTime() + 1000)) * (ro.quantity || 0), 0);
|
||||
const dateStr = nextDeadline.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: '2-digit' });
|
||||
if (afterPrice > priceNow) {
|
||||
return (
|
||||
@@ -971,8 +938,8 @@ export default function UserDashboardPage() {
|
||||
) : (
|
||||
<ul className="grid sm:grid-cols-2 gap-3">
|
||||
{tickets
|
||||
.filter((t: any) => showPast || !isEventOver(t.event))
|
||||
.map((t: any) => {
|
||||
.filter((t) => showPast || !isEventOver(t.event))
|
||||
.map((t) => {
|
||||
const eventDate = t.event?.startDate ? new Date(t.event.startDate) : null;
|
||||
const optionName = t.registrationOption?.eventOption?.name || "Ticket";
|
||||
const variantName = t.registrationOption?.variant?.name;
|
||||
@@ -997,7 +964,7 @@ export default function UserDashboardPage() {
|
||||
</div>
|
||||
{(t.attendee?.allocations || []).length > 0 && (
|
||||
<div className="text-xs text-violet-800 bg-violet-50 rounded px-2 py-0.5 inline-block ml-1">
|
||||
{[...t.attendee.allocations].sort((a: any, b: any) => (a.set?.order || 0) - (b.set?.order || 0)).map((a: any) => `${a.set.name}: ${a.group.name}`).join(" · ")}
|
||||
{[...(t.attendee?.allocations || [])].sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0)).map((a) => `${a.set.name}: ${a.group.name}`).join(" · ")}
|
||||
</div>
|
||||
)}
|
||||
{t.userId && user?.id && t.userId !== user.id && t.user?.name && (
|
||||
@@ -1013,13 +980,13 @@ export default function UserDashboardPage() {
|
||||
<button onClick={() => printTickets([t])} className="px-3 py-1.5 text-sm 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">Print</button>
|
||||
{(t.transferRequests || [])[0] && (
|
||||
<span className="w-full text-xs text-violet-800 bg-violet-50 rounded px-2 py-1 flex items-center justify-between gap-2">
|
||||
<span>Waiting for {t.transferRequests[0].toName} to accept</span>
|
||||
<span>Waiting for {t.transferRequests?.[0]?.toName} to accept</span>
|
||||
<button className="underline" onClick={async () => {
|
||||
try {
|
||||
const r: any = await apiFetch(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
|
||||
const r = await apiFetch<{ message?: string }>(`/api/tickets/${t.id}/transfer`, { method: "DELETE", authToken: token! });
|
||||
setInfo(r?.message || "Transfer cancelled");
|
||||
reloadAfterAttendees();
|
||||
} catch (e: any) { setError(e?.message || "Couldn't cancel"); }
|
||||
} catch (e) { setError(errorMessage(e) || "Couldn't cancel"); }
|
||||
}}>Cancel</button>
|
||||
</span>
|
||||
)}
|
||||
@@ -1080,7 +1047,7 @@ export default function UserDashboardPage() {
|
||||
// early-bird rows separately from standard-price rows so repeat purchases
|
||||
// of the same item don't show as a wall of near-duplicate lines.
|
||||
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
|
||||
(activeReg.registrationOptions || []).forEach((opt: any) => {
|
||||
(activeReg.registrationOptions || []).forEach((opt) => {
|
||||
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
|
||||
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
|
||||
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
|
||||
@@ -1092,7 +1059,7 @@ export default function UserDashboardPage() {
|
||||
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
|
||||
appliedTierId: opt.appliedTierId,
|
||||
}];
|
||||
tranches.forEach((t: any) => {
|
||||
tranches.forEach((t) => {
|
||||
const unitPrice = Number(t.priceSnapshot || 0);
|
||||
const isEarlyBird = !!t.appliedTierId;
|
||||
const key = `${label}__${isEarlyBird}__${unitPrice}`;
|
||||
@@ -1163,7 +1130,7 @@ export default function UserDashboardPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{editEventOptions.map((eo: any) => {
|
||||
{editEventOptions.map((eo) => {
|
||||
const hasVariants = Array.isArray(eo.variants) && eo.variants.length > 0;
|
||||
|
||||
const renderQtyInput = (key: string, unitPrice: number, stockLimit?: number, availableCount?: number) => {
|
||||
@@ -1188,12 +1155,12 @@ export default function UserDashboardPage() {
|
||||
};
|
||||
|
||||
if (hasVariants) {
|
||||
const variants = eo.variants.slice().sort((a: any, b: any) => (a.order || 0) - (b.order || 0));
|
||||
const variants = (eo.variants || []).slice().sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
return (
|
||||
<div key={eo.id} className="border rounded bg-white overflow-hidden">
|
||||
<div className="px-3 py-2 bg-gray-100 text-sm font-medium">{eo.name}</div>
|
||||
<ul className="divide-y">
|
||||
{variants.map((v: any) => {
|
||||
{variants.map((v) => {
|
||||
const key = `${eo.id}:${v.id}`;
|
||||
const minQty = editMinQty[key] || 0;
|
||||
const unitPrice = effectiveEventOptionUnitPrice(eo, v.id);
|
||||
@@ -1271,7 +1238,7 @@ export default function UserDashboardPage() {
|
||||
<div>
|
||||
<div className="font-medium mb-1">Payments</div>
|
||||
<ul className="text-sm space-y-1">
|
||||
{activeBill.payments.map((p: any) => (
|
||||
{activeBill.payments.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-2 py-0.5">
|
||||
<span>{new Date(p.createdAt).toLocaleString()} — {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</span>
|
||||
<button className="text-xs text-brand-700 hover:underline shrink-0" onClick={() => setChannelPicker({ kind: 'receipt', id: p.id })}>Receipt</button>
|
||||
@@ -1298,8 +1265,8 @@ export default function UserDashboardPage() {
|
||||
>Make payment</button>
|
||||
)}
|
||||
<button className="px-3 py-1.5 text-sm 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={() => {
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o: any) => o.id));
|
||||
const list = tickets.filter(t => regOptIds.has(t.registrationOptionId));
|
||||
const regOptIds = new Set((activeReg.registrationOptions || []).map((o) => o.id));
|
||||
const list = tickets.filter(t => !!t.registrationOptionId && regOptIds.has(t.registrationOptionId));
|
||||
printTickets(list);
|
||||
}}>Print all tickets</button>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { formatDateTime } from "@/lib/date";
|
||||
import { formatPaymentMethod } from "@/lib/paymentMethod";
|
||||
import { Receipt } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Paginated } from "@/types";
|
||||
|
||||
interface PaymentItem {
|
||||
id: string;
|
||||
@@ -49,14 +51,14 @@ export default function UserPaymentsPage() {
|
||||
setInfo(null);
|
||||
setSendingReceipt(true);
|
||||
try {
|
||||
const res: any = await apiFetch<any>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
const res = await apiFetch<{ message?: string }>(`/api/payments/${encodeURIComponent(paymentId)}/send-receipt`, {
|
||||
method: "POST",
|
||||
body: { channel },
|
||||
authToken: token,
|
||||
});
|
||||
setInfo((res && res.message) || "Receipt sent.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to send receipt");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to send receipt");
|
||||
} finally {
|
||||
setSendingReceipt(false);
|
||||
}
|
||||
@@ -82,19 +84,20 @@ export default function UserPaymentsPage() {
|
||||
setError(null);
|
||||
setFetching(true);
|
||||
try {
|
||||
const res = await apiFetch<any>(buildQuery(p), { authToken: token });
|
||||
const res = await apiFetch<Paginated<PaymentItem>>(buildQuery(p), { authToken: token });
|
||||
setPayments(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 payments");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to load payments");
|
||||
} finally {
|
||||
setFetching(false);
|
||||
}
|
||||
}, [token, buildQuery]);
|
||||
}, [token, buildQuery, setError]);
|
||||
|
||||
useEffect(() => { if (token) loadPayments(1); }, [token, startDate, endDate, method, kind]);
|
||||
// loadPayments changes whenever a filter does (via buildQuery), so this refetches page 1 on filter changes.
|
||||
useEffect(() => { if (token) loadPayments(1); }, [token, loadPayments]);
|
||||
|
||||
const goToPage = (p: number) => loadPayments(p);
|
||||
|
||||
@@ -150,7 +153,7 @@ export default function UserPaymentsPage() {
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-600 mb-1">Type</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as any)}>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={kind} onChange={e => setKind(e.target.value as "" | "payment" | "refund")}>
|
||||
<option value="">Payments & refunds</option>
|
||||
<option value="payment">Payments only</option>
|
||||
<option value="refund">Refunds only</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
@@ -8,6 +8,7 @@ import { isValidZAPhone } from "@/lib/phone";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { TwoFactorChallenge, TwoFactorMethod } from "@/lib/auth";
|
||||
import { User, Lock, ShieldAlert, ShieldCheck, ShieldOff, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset" | "two_factor_enabled" | "two_factor_disabled"; device: string | null; createdAt: string };
|
||||
|
||||
@@ -54,7 +55,7 @@ export default function UserProfilePage() {
|
||||
setSavingProfile(true);
|
||||
setProfileMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -67,8 +68,8 @@ export default function UserProfilePage() {
|
||||
// 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." });
|
||||
} catch (e) {
|
||||
setProfileMsg({ type: "err", text: errorMessage(e) || "Failed to update profile." });
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
}
|
||||
@@ -95,7 +96,7 @@ export default function UserProfilePage() {
|
||||
setSavingPw(true);
|
||||
setPwMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/profile", {
|
||||
const res = await apiFetch<{ token?: string }>("/api/users/profile", {
|
||||
method: "PUT",
|
||||
authToken: token,
|
||||
body: { currentPassword, password: newPassword },
|
||||
@@ -106,8 +107,8 @@ export default function UserProfilePage() {
|
||||
setConfirmPassword("");
|
||||
setPwMsg({ type: "ok", text: "Password changed." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
|
||||
} catch (e) {
|
||||
setPwMsg({ type: "err", text: errorMessage(e) || "Failed to change password." });
|
||||
} finally {
|
||||
setSavingPw(false);
|
||||
}
|
||||
@@ -123,7 +124,7 @@ export default function UserProfilePage() {
|
||||
setRevoking(true);
|
||||
setRevokeMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/revoke-sessions", {
|
||||
const res = await apiFetch<{ token?: string; message?: string }>("/api/users/revoke-sessions", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
});
|
||||
@@ -131,8 +132,8 @@ export default function UserProfilePage() {
|
||||
// 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." });
|
||||
} catch (e) {
|
||||
setRevokeMsg({ type: "err", text: errorMessage(e) || "Failed to revoke sessions." });
|
||||
} finally {
|
||||
setRevoking(false);
|
||||
}
|
||||
@@ -142,20 +143,20 @@ export default function UserProfilePage() {
|
||||
const [activity, setActivity] = useState<ActivityEvent[]>([]);
|
||||
const [loadingActivity, setLoadingActivity] = useState(false);
|
||||
|
||||
const loadActivity = async () => {
|
||||
const loadActivity = useCallback(async () => {
|
||||
if (!token) return;
|
||||
setLoadingActivity(true);
|
||||
try {
|
||||
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
|
||||
setActivity(Array.isArray(res) ? res : []);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Non-critical — just leave the list empty rather than showing an error banner.
|
||||
} finally {
|
||||
setLoadingActivity(false);
|
||||
}
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { loadActivity(); }, [token]);
|
||||
useEffect(() => { loadActivity(); }, [loadActivity]);
|
||||
|
||||
// ── Two-factor authentication ─────────────────────────────────────────────
|
||||
// Mirrors the saved account state locally: the auth context's `user` is only
|
||||
@@ -203,8 +204,8 @@ export default function UserProfilePage() {
|
||||
setTfCode("");
|
||||
setTfPassword("");
|
||||
setTfStep("code");
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to send a verification code." });
|
||||
} catch (e) {
|
||||
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to send a verification code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
@@ -227,8 +228,8 @@ export default function UserProfilePage() {
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: wasEnabled ? `Codes will now be sent by ${TWO_FACTOR_METHOD_LABEL[res.twoFactorMethod]}.` : "Two-factor authentication is on." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to verify the code." });
|
||||
} catch (e) {
|
||||
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to verify the code." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
@@ -249,8 +250,8 @@ export default function UserProfilePage() {
|
||||
resetTwoFactorForm();
|
||||
setTfMsg({ type: "ok", text: "Two-factor authentication is off." });
|
||||
loadActivity();
|
||||
} catch (e: any) {
|
||||
setTfMsg({ type: "err", text: e?.message || "Failed to turn off two-factor authentication." });
|
||||
} catch (e) {
|
||||
setTfMsg({ type: "err", text: errorMessage(e) || "Failed to turn off two-factor authentication." });
|
||||
} finally {
|
||||
setTfBusy(false);
|
||||
}
|
||||
@@ -269,7 +270,7 @@ export default function UserProfilePage() {
|
||||
setClosing(true);
|
||||
setCloseMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<any>("/api/users/close-account", {
|
||||
const res = await apiFetch<{ message?: string }>("/api/users/close-account", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { password: closePassword, deleteData },
|
||||
@@ -277,8 +278,8 @@ export default function UserProfilePage() {
|
||||
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." });
|
||||
} catch (e) {
|
||||
setCloseMsg({ type: "err", text: errorMessage(e) || "Failed to close account." });
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const { token } = useAuth();
|
||||
@@ -35,8 +36,8 @@ export default function ResetPasswordPage() {
|
||||
setPassword("");
|
||||
setConfirm("");
|
||||
setTimeout(() => router.push("/dashboard/user"), 1200);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Failed to update password");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Failed to update password");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Share2, QrCode, CalendarPlus } from "lucide-react";
|
||||
import QRCode from "qrcode";
|
||||
import { API_BASE } from "@/lib/api";
|
||||
|
||||
export default function ClientActions({ event }: { event: any }) {
|
||||
type Props = { event: { id: string; title: string; description?: string | null } };
|
||||
|
||||
export default function ClientActions({ event }: Props) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -12,7 +14,7 @@ export default function ClientActions({ event }: { event: any }) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: event.title,
|
||||
text: event.description,
|
||||
text: event.description ?? undefined,
|
||||
url: window.location.href,
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number };
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type EventOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -283,7 +283,7 @@ function TicketList({ event }: { event: Event }) {
|
||||
</div>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
|
||||
{opt.variants!.slice().sort((a, b) => (a.order ?? 0) - (b.order ?? 0)).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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { MessageSquareHeart, CheckCircle2 } from "lucide-react";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Field = { id: string; type: string; label: string; isRequired: boolean; helpText: string | null; options: unknown };
|
||||
type FeedbackInfo = {
|
||||
@@ -46,8 +47,8 @@ export default function FeedbackPage() {
|
||||
try {
|
||||
await apiFetch(`/api/feedback/public/${encodeURIComponent(token!)}`, { method: "POST", body: { answers } });
|
||||
setDone(true);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't send your feedback");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't send your feedback");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { apiFetch } from "@/lib/api";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { FileText } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// Types for form fields
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
@@ -19,7 +21,7 @@ function FormsContent() {
|
||||
// Set when a named attendee opens the link sent to them — show only their form.
|
||||
const onlyAttendeeId = search.get("attendeeId");
|
||||
|
||||
const [registration, setRegistration] = useState<any | null>(null);
|
||||
const [registration, setRegistration] = useState<Registration | null>(null);
|
||||
const [eventForm, setEventForm] = useState<{ isRequired: boolean; fields: FormField[] } | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -36,17 +38,17 @@ function FormsContent() {
|
||||
setInfo(null);
|
||||
try {
|
||||
// authToken optional — guests access via registrationId alone
|
||||
const reg = await apiFetch<any>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
const reg = await apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token || undefined });
|
||||
setRegistration(reg);
|
||||
const ev = await apiFetch<any>(`/api/events/${encodeURIComponent(reg.eventId)}`);
|
||||
const ev = await apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/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 });
|
||||
const draft = await apiFetch<{ data?: Record<string, Record<string, string>> } | null>(`/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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load registration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -61,8 +63,8 @@ function FormsContent() {
|
||||
if (!registration) return 0;
|
||||
try {
|
||||
return (registration.registrationOptions || [])
|
||||
.filter((ro: any) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s: number, ro: any) => s + (ro.quantity || 0), 0);
|
||||
.filter((ro) => ro.eventOption?.isMainTicket)
|
||||
.reduce((s, ro) => s + (ro.quantity || 0), 0);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
@@ -75,13 +77,13 @@ function FormsContent() {
|
||||
const blocks = useMemo((): FormBlock[] => {
|
||||
if (!registration) return [];
|
||||
const responses = registration.formResponses || [];
|
||||
const answered = new Set(responses.map((r: any) => r.attendeeId).filter(Boolean));
|
||||
const attendees = (registration.attendees || []) as any[];
|
||||
const answered = new Set(responses.map((r) => r.attendeeId).filter(Boolean));
|
||||
const attendees = registration.attendees || [];
|
||||
const named = attendees
|
||||
.filter(a => !answered.has(a.id))
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id as string }));
|
||||
.map(a => ({ key: `a:${a.id}`, label: a.name, attendeeId: a.id }));
|
||||
const unnamedPlaces = Math.max(0, requiredCount - attendees.length);
|
||||
const unnamedAnswered = responses.filter((r: any) => !r.attendeeId).length;
|
||||
const unnamedAnswered = responses.filter((r) => !r.attendeeId).length;
|
||||
const unnamed = Array.from({ length: Math.max(0, unnamedPlaces - unnamedAnswered) }, (_, i) => ({
|
||||
key: String(i), label: `Attendee ${unnamedAnswered + i + 1}`,
|
||||
}));
|
||||
@@ -89,7 +91,7 @@ function FormsContent() {
|
||||
return onlyAttendeeId ? all.filter(b => b.attendeeId === onlyAttendeeId) : all;
|
||||
}, [registration, requiredCount, onlyAttendeeId]);
|
||||
const remaining = blocks.length;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a: any) => a.id === id)?.name;
|
||||
const attendeeName = (id: string | null | undefined) => (registration?.attendees || []).find((a) => a.id === id)?.name;
|
||||
|
||||
// Determine if all required fields are filled for each attendee form to be submitted
|
||||
const canSubmit = useMemo(() => {
|
||||
@@ -116,7 +118,7 @@ function FormsContent() {
|
||||
const payload = blocks.map(b => ({ ...(b.attendeeId ? { attendeeId: b.attendeeId } : {}), answers: formsData[b.key] || {} }));
|
||||
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);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => s + ((ro.eventOption?.price || 0) * (ro.quantity || 0)), 0);
|
||||
router.push(`/registration/success?registrationId=${encodeURIComponent(registrationId)}${totalDue>0?`&totalDue=${encodeURIComponent(totalDue.toFixed(2))}`:''}`);
|
||||
return;
|
||||
}
|
||||
@@ -126,10 +128,10 @@ function FormsContent() {
|
||||
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);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => 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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to submit forms');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,13 +148,13 @@ function FormsContent() {
|
||||
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);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => 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) {
|
||||
setError(errorMessage(e) || 'Failded to go to next step');
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save draft');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,10 +162,10 @@ function FormsContent() {
|
||||
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);
|
||||
const totalDue = (registration?.registrationOptions || []).reduce((s, ro) => 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');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failded to skip');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -210,14 +212,14 @@ function FormsContent() {
|
||||
<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) => (
|
||||
{(registration.formResponses || []).map((resp, idx) => (
|
||||
<li key={resp.id} className="bg-white border rounded p-2">
|
||||
<div className="font-medium text-sm mb-1">{attendeeName(resp.attendeeId) || `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) => (
|
||||
{(resp.answers || []).map((a) => (
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
@@ -14,16 +13,6 @@ import { API_BASE, resolveToApiOrigin } from "@/lib/api";
|
||||
import { buildThemeCssVars } from "@/lib/colorScale";
|
||||
import { appUrl } from "@/lib/siteConfig";
|
||||
|
||||
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 || 'Cross Code';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Link from "next/link";
|
||||
import { EventCard } from "@/components/events/EventCard";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
@@ -71,10 +72,10 @@ export default async function HomePage() {
|
||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {displayName}</h1>
|
||||
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-3">
|
||||
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
<Link href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
|
||||
<Calendar className="w-4 h-4" />
|
||||
View Events
|
||||
</a>
|
||||
</Link>
|
||||
<JoinUsButton />
|
||||
</div>
|
||||
</div>
|
||||
@@ -83,10 +84,10 @@ export default async function HomePage() {
|
||||
<section className="py-12 px-4 max-w-7xl mx-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-900">Upcoming Events</h2>
|
||||
<a href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
|
||||
<Link href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{sorted.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-xl bg-gray-50">
|
||||
@@ -101,10 +102,10 @@ export default async function HomePage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="text-center mt-8 sm:hidden">
|
||||
<a href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
|
||||
<Link href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
|
||||
View all events
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
|
||||
import { analyticsSessionId } from "@/lib/analytics";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
// Tiered low-stock threshold — mirrors events/[id]/page.tsx
|
||||
function lowStockThreshold(stockLimit: number): number {
|
||||
@@ -18,7 +19,7 @@ function lowStockThreshold(stockLimit: number): number {
|
||||
return Math.round(stockLimit * pct);
|
||||
}
|
||||
|
||||
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; order?: number };
|
||||
type EarlyBirdTier = { id: string; deadline: string; price: number; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type OptionVariant = { id: string; name: string; price: number | null; stockLimit?: number; availableCount?: number; order?: number };
|
||||
type EventOption = {
|
||||
id: string;
|
||||
@@ -31,7 +32,7 @@ type EventOption = {
|
||||
variants?: OptionVariant[];
|
||||
};
|
||||
|
||||
type Event = {
|
||||
export type RegisterEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
@@ -41,7 +42,8 @@ type Event = {
|
||||
price: number;
|
||||
picture?: string;
|
||||
eventOptions?: EventOption[];
|
||||
form?: { isRequired: boolean; fields?: any[] } | null;
|
||||
form?: { isRequired: boolean; fields?: unknown[] } | null;
|
||||
requiresRegistration?: boolean;
|
||||
requiresAuth?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
@@ -61,7 +63,7 @@ function effectiveUnitForOption(opt: EventOption): number {
|
||||
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))
|
||||
.filter((t) => now < t._d && (t.stockLimit === undefined || t.stockLimit === 0 || t.availableCount === undefined || t.availableCount > 0))
|
||||
.sort((a, b) => a.price - b.price || a._d.getTime() - b._d.getTime());
|
||||
return applicable.length > 0 ? applicable[0].price : base;
|
||||
}
|
||||
@@ -252,7 +254,7 @@ function OptionCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
||||
const { token, user } = useAuth();
|
||||
const eventId = baseEvent.id;
|
||||
|
||||
@@ -271,9 +273,9 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
setOffer(res);
|
||||
setQuantities(q => ({ ...q, [res.variantId ? `${res.eventOptionId}:${res.variantId}` : res.eventOptionId]: res.quantity }));
|
||||
})
|
||||
.catch((e: any) => setOfferError(e?.message || "This waitlist link is not valid."));
|
||||
.catch((e: unknown) => setOfferError(errorMessage(e) || "This waitlist link is not valid."));
|
||||
}, []);
|
||||
const event = useMemo<Event>(() => {
|
||||
const event = useMemo<RegisterEvent>(() => {
|
||||
if (!offer) return baseEvent;
|
||||
return {
|
||||
...baseEvent,
|
||||
@@ -300,8 +302,6 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
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);
|
||||
const [coupon, setCoupon] = useState<AppliedCoupon | null>(null);
|
||||
const [namingOthers, setNamingOthers] = useState(false);
|
||||
@@ -397,8 +397,8 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
body: { eventId, options: items, waitlistToken, analyticsSessionId: analyticsSessionId(), ...(namedAttendees().length > 0 ? { attendees: namedAttendees(), ticketDelivery: delivery } : {}) },
|
||||
});
|
||||
if (res?.id) window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=${encodeURIComponent((res.totalDueComputed ?? payable).toFixed(2))}`;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
@@ -441,8 +441,8 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
}
|
||||
window.location.href = next;
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Registration failed");
|
||||
} finally {
|
||||
setCreatingRegistration(false);
|
||||
}
|
||||
@@ -477,34 +477,13 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
window.location.href = `/registration/success?registrationId=${encodeURIComponent(res.id)}&totalDue=0.00`;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Registration failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "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 (
|
||||
<>
|
||||
@@ -582,11 +561,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
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());
|
||||
.map((t) => ({ ...t, deadline: new Date(t.deadline) }))
|
||||
.filter((t) => t.deadline > now)
|
||||
).sort((a, b) => a.deadline.getTime() - b.deadline.getTime());
|
||||
if (upcomingTiers.length === 0) return null;
|
||||
const soonest = upcomingTiers[0].deadline as Date;
|
||||
const soonest = upcomingTiers[0].deadline;
|
||||
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">
|
||||
@@ -628,12 +607,11 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
)}
|
||||
<div className="text-lg font-semibold">Total: R{payable.toFixed(2)}</div>
|
||||
</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>
|
||||
) : (
|
||||
{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 && !waitlistToken)}
|
||||
@@ -641,15 +619,6 @@ export default function RegisterForm({ event: baseEvent }: { event: Event }) {
|
||||
>
|
||||
{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>
|
||||
|
||||
|
||||
@@ -2,18 +2,18 @@ import { notFound, redirect } 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";
|
||||
import RegisterForm, { type RegisterEvent } from "./RegisterForm";
|
||||
import { FunnelTracker } from "@/components/events/FunnelTracker";
|
||||
|
||||
export const revalidate = 60;
|
||||
|
||||
export default async function RegisterPage({ params }: { params: Promise<{ eventId: string }> }) {
|
||||
const { eventId } = await params;
|
||||
let event: any;
|
||||
let event: RegisterEvent;
|
||||
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 } } });
|
||||
event = await apiFetch<RegisterEvent>(`/api/events/${eventId}`, { nextOptions: { next: { revalidate } } });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) notFound();
|
||||
throw e;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormFieldExtras";
|
||||
import React, { Suspense } from "react";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'|'rating'|'multiple_choice'; label: string; isRequired?: boolean; helpText?: string|null; options?: unknown };
|
||||
|
||||
@@ -20,37 +21,25 @@ function RegistrationSuccessContent() {
|
||||
return isNaN(n) ? 0 : n;
|
||||
}, [fallbackTotalParam]);
|
||||
|
||||
const [reg, setReg] = React.useState<any | null>(null);
|
||||
const [reg, setReg] = React.useState<Registration | 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);
|
||||
const [payLoading, setPayLoading] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
if (!registrationId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Try to fetch registration and event form
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
const r = token ? await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token }) : null;
|
||||
if (r) {
|
||||
setReg(r);
|
||||
const ev = await (await import('@/lib/api')).apiFetch(`/api/events/${encodeURIComponent(r.eventId)}`);
|
||||
const ev = await (await import('@/lib/api')).apiFetch<{ form?: { isRequired: boolean; fields: FormField[] } | null }>(`/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);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to load details');
|
||||
}
|
||||
})();
|
||||
}, [registrationId, token]);
|
||||
@@ -61,7 +50,7 @@ function RegistrationSuccessContent() {
|
||||
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
|
||||
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
|
||||
try {
|
||||
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
|
||||
return (reg.registrationOptions || []).reduce((s, ro) => {
|
||||
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
|
||||
? Number(ro.priceSnapshot)
|
||||
: (ro.variant?.price ?? ro.eventOption?.price ?? 0);
|
||||
@@ -98,15 +87,15 @@ function RegistrationSuccessContent() {
|
||||
// reload the registration so the displayed totalDue reflects it, instead of redirecting.
|
||||
setError(`${res.message || 'Pricing has changed.'} New total: R ${(res.newTotal ?? 0).toFixed(2)}. Please try again.`);
|
||||
try {
|
||||
const r = await (await import('@/lib/api')).apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
const r = await (await import('@/lib/api')).apiFetch<Registration>(`/api/registrations/${encodeURIComponent(registrationId)}`, { authToken: token });
|
||||
if (r) setReg(r);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
if (!res.redirectUrl) { setError('Failed to create checkout'); return; }
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to create checkout');
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to create checkout');
|
||||
} finally {
|
||||
setPayLoading(false);
|
||||
}
|
||||
@@ -161,119 +150,6 @@ function RegistrationSuccessContent() {
|
||||
);
|
||||
}
|
||||
|
||||
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 { token } = useAuth();
|
||||
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);
|
||||
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);
|
||||
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>
|
||||
{isExtraFieldType(f.type) ? (
|
||||
<ExtraFieldInput field={f} idPrefix={`a${idx}`} value={formsData[idx]?.[f.id] || ''} onChange={v => update(idx, f.id, v)} />
|
||||
) : 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-brand-600 text-white hover:bg-brand-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>}>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { KeyRound } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const search = useSearchParams();
|
||||
@@ -26,8 +27,8 @@ function ResetPasswordContent() {
|
||||
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");
|
||||
} catch (err) {
|
||||
setStatus(errorMessage(err) || "Reset failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ import { ExtraFieldInput, isExtraFieldType } from "@/components/shared/FormField
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { AuthResponse, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Registration } from "@/types";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
|
||||
@@ -25,7 +27,7 @@ type FormField = {
|
||||
isRequired: boolean;
|
||||
order: number;
|
||||
helpText?: string | null;
|
||||
options?: any;
|
||||
options?: unknown;
|
||||
};
|
||||
type EventForm = {
|
||||
id: string;
|
||||
@@ -187,7 +189,6 @@ export default function SelfServicePage() {
|
||||
|
||||
// ── Event form (attendee form) state ────────────────────────────
|
||||
const [currentRegistrationId, setCurrentRegistrationId] = useState<string | null>(null);
|
||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||
const [currentTicketIndex, setCurrentTicketIndex] = useState(0);
|
||||
const [allFormAnswers, setAllFormAnswers] = useState<Record<string, string>[]>([]);
|
||||
const [currentTicketAnswers, setCurrentTicketAnswers] = useState<Record<string, string>>({});
|
||||
@@ -294,11 +295,11 @@ export default function SelfServicePage() {
|
||||
// Second pass for accounts with two-factor on: exchange the code that was
|
||||
// sent after the password step. The kiosk never asks to be remembered.
|
||||
const data = setupChallenge
|
||||
? await apiFetch("/api/users/login/2fa", {
|
||||
? await apiFetch<AuthResponse>("/api/users/login/2fa", {
|
||||
method: "POST",
|
||||
body: { challengeId: setupChallenge.challengeId, code: setupCode, rememberDevice: false },
|
||||
})
|
||||
: await apiFetch("/api/users/login", {
|
||||
: await apiFetch<AuthResponse | TwoFactorChallenge>("/api/users/login", {
|
||||
method: "POST",
|
||||
body: { email: setupEmail, password: setupPassword },
|
||||
});
|
||||
@@ -319,8 +320,8 @@ export default function SelfServicePage() {
|
||||
setSetupCode("");
|
||||
setSupervisorToken(data.token);
|
||||
await loadEvents(data.token);
|
||||
} catch (err: any) {
|
||||
setSetupError(err?.message || "Login failed. Please check your credentials.");
|
||||
} catch (err) {
|
||||
setSetupError(errorMessage(err) || "Login failed. Please check your credentials.");
|
||||
} finally {
|
||||
setSetupLoading(false);
|
||||
}
|
||||
@@ -362,8 +363,8 @@ export default function SelfServicePage() {
|
||||
setScreen("setup");
|
||||
setSelectedEventId("");
|
||||
resetForm();
|
||||
} catch (err: any) {
|
||||
setChangeError(err?.message || "Incorrect password.");
|
||||
} catch (err) {
|
||||
setChangeError(errorMessage(err) || "Incorrect password.");
|
||||
} finally {
|
||||
setChangeLoading(false);
|
||||
}
|
||||
@@ -405,7 +406,15 @@ export default function SelfServicePage() {
|
||||
async function handleRegister() {
|
||||
setFormLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
const user: { name: string; email?: string; phoneNumber?: string; password?: string } = {
|
||||
name: visitorName.trim(),
|
||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
||||
};
|
||||
if (createAccount && visitorPassword) {
|
||||
user.password = visitorPassword;
|
||||
}
|
||||
const payload: Record<string, unknown> = {
|
||||
eventId: selectedEventId,
|
||||
options: Object.entries(quantities)
|
||||
.filter(([, qty]) => qty > 0)
|
||||
@@ -413,11 +422,7 @@ export default function SelfServicePage() {
|
||||
const [eventOptionId, variantId] = key.split("::");
|
||||
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
|
||||
}),
|
||||
user: {
|
||||
name: visitorName.trim(),
|
||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
||||
},
|
||||
user,
|
||||
// If the visitor declined to create an account, don't push an unsolicited
|
||||
// activation email/WhatsApp at them afterward.
|
||||
skipActivationNotice: !createAccount,
|
||||
@@ -431,18 +436,13 @@ export default function SelfServicePage() {
|
||||
payload.attendees = kioskNames;
|
||||
payload.ticketDelivery = delivery;
|
||||
}
|
||||
if (createAccount && visitorPassword) {
|
||||
payload.user.password = visitorPassword;
|
||||
}
|
||||
|
||||
const result: any = await apiFetch("/api/registrations/manual", {
|
||||
const result = await apiFetch<Registration>("/api/registrations/manual", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
authToken: supervisorToken,
|
||||
});
|
||||
|
||||
setCurrentRegistrationId(result?.id || null);
|
||||
setCurrentUserId(result?.userId || null);
|
||||
setRegistrationIsFree(result?.status === "paid" || (result?.totalDueComputed ?? total) === 0);
|
||||
setConfirmationName(visitorName.trim());
|
||||
|
||||
@@ -457,8 +457,8 @@ export default function SelfServicePage() {
|
||||
setScreen("confirmation");
|
||||
startCountdown();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFormError(err?.message || "Registration failed. Please try again.");
|
||||
} catch (err) {
|
||||
setFormError(errorMessage(err) || "Registration failed. Please try again.");
|
||||
} finally {
|
||||
setFormLoading(false);
|
||||
}
|
||||
@@ -505,8 +505,8 @@ export default function SelfServicePage() {
|
||||
|
||||
setScreen("confirmation");
|
||||
startCountdown();
|
||||
} catch (err: any) {
|
||||
setFormSubmitError(err?.message || "Form submission failed. Please try again.");
|
||||
} catch (err) {
|
||||
setFormSubmitError(errorMessage(err) || "Form submission failed. Please try again.");
|
||||
} finally {
|
||||
setFormSubmitLoading(false);
|
||||
}
|
||||
@@ -562,7 +562,6 @@ export default function SelfServicePage() {
|
||||
setLookupMessage(null);
|
||||
setFormError(null);
|
||||
setCurrentRegistrationId(null);
|
||||
setCurrentUserId(null);
|
||||
setCurrentTicketIndex(0);
|
||||
setAllFormAnswers([]);
|
||||
setCurrentTicketAnswers({});
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { Megaphone } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type BannerType = "info" | "warning" | "success" | "danger";
|
||||
|
||||
@@ -37,7 +38,7 @@ export default function SetBannerPage() {
|
||||
|
||||
// Load current banner
|
||||
useEffect(() => {
|
||||
apiFetch<any>("/api/banner")
|
||||
apiFetch<{ message?: string; type?: BannerType; liveFrom?: string | null; liveTill?: string | null } | null>("/api/banner")
|
||||
.then(b => {
|
||||
if (!b) return;
|
||||
setMessage(b.message || "");
|
||||
@@ -75,8 +76,8 @@ export default function SetBannerPage() {
|
||||
},
|
||||
});
|
||||
setStatus("Banner saved.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to save banner.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to save banner.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -98,8 +99,8 @@ export default function SetBannerPage() {
|
||||
setLiveFrom("");
|
||||
setLiveTill("");
|
||||
setStatus("Banner cleared.");
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to clear banner.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to clear banner.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Step = 1 | 2 | 3 | 4 | 5 | 6;
|
||||
|
||||
@@ -100,7 +101,7 @@ export default function SetupPage() {
|
||||
apiFetch<{ needsSetup: boolean }>("/api/settings/needs-setup")
|
||||
.then((d) => { if (!d?.needsSetup) router.replace("/login"); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
}, [router]);
|
||||
|
||||
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
|
||||
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
|
||||
@@ -144,8 +145,8 @@ export default function SetupPage() {
|
||||
});
|
||||
setSetupToken(res.token);
|
||||
next();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Failed to create admin account. Please try again.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Failed to create admin account. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -173,8 +174,8 @@ export default function SetupPage() {
|
||||
},
|
||||
});
|
||||
setSmtpTestResult({ ok: true, message: res.message || "Connection successful." });
|
||||
} catch (e: any) {
|
||||
setSmtpTestResult({ ok: false, message: e?.message || "Connection failed." });
|
||||
} catch (e) {
|
||||
setSmtpTestResult({ ok: false, message: errorMessage(e) || "Connection failed." });
|
||||
} finally {
|
||||
setSmtpTesting(false);
|
||||
}
|
||||
@@ -260,8 +261,8 @@ export default function SetupPage() {
|
||||
});
|
||||
|
||||
next(); // → step 6 (done)
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Setup failed. Please try again.");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Setup failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -386,6 +387,8 @@ export default function SetupPage() {
|
||||
<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 && (
|
||||
// Local file preview (data: URL) — nothing for next/image to optimize.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
|
||||
)}
|
||||
{suggestedColors.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { Ticket, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Offer = {
|
||||
status: "pending" | "accepted" | "declined" | "cancelled" | "expired";
|
||||
@@ -46,8 +47,8 @@ export default function TransferOfferPage() {
|
||||
try {
|
||||
const r = await apiFetch<{ message: string }>(`/api/tickets/transfer-offer/${encodeURIComponent(token!)}/${action}`, { method: "POST" });
|
||||
setDone({ ok: action === "accept", message: r.message });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Something went wrong");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Something went wrong");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, API_BASE } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { DatabaseBackup, Download } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
interface BackupEntry {
|
||||
filename: string;
|
||||
@@ -38,12 +39,12 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
]);
|
||||
setBackups(list || []);
|
||||
setRetainCount(allSettings?.backup_retain_count || "14");
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to load backups" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load backups" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
}, [token, setMessage]);
|
||||
|
||||
const saveRetainCount = async () => {
|
||||
if (!token) return;
|
||||
@@ -51,8 +52,8 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
try {
|
||||
await apiFetch("/api/settings", { method: "PUT", authToken: token, body: { backup_retain_count: retainCount } });
|
||||
setMessage({ type: "ok", text: "Retention setting saved." });
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to save retention setting" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to save retention setting" });
|
||||
} finally {
|
||||
setSavingRetain(false);
|
||||
}
|
||||
@@ -68,8 +69,8 @@ export function BackupsTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<{ filename: string }>("/api/backups/run", { method: "POST", authToken: token });
|
||||
setMessage({ type: "ok", text: `Backup created: ${res.filename}` });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Backup failed" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Backup failed" });
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { Copy, CreditCard, KeyRound, Trash2, Webhook } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Source = "db" | "env" | "none";
|
||||
|
||||
@@ -99,9 +100,9 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
const res = await apiFetch<{ webhooks: YocoWebhook[] }>("/api/yoco/webhooks", { authToken: token });
|
||||
setHooks(res.webhooks || []);
|
||||
setHooksError(null);
|
||||
} catch (e: any) {
|
||||
} catch (e) {
|
||||
setHooks([]);
|
||||
setHooksError(e?.message || "Could not load webhooks from Yoco");
|
||||
setHooksError(errorMessage(e) || "Could not load webhooks from Yoco");
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
@@ -114,8 +115,8 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
setHookUrl((prev) => prev || c.webhookUrl);
|
||||
if (c.secretKeySource !== "none") await loadHooks();
|
||||
else { setHooks([]); setHooksError(null); }
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Failed to load Yoco settings" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load Yoco settings" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -130,8 +131,8 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
const text = await fn();
|
||||
setMessage({ type: "ok", text });
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setMessage({ type: "err", text: e?.message || "Something went wrong" });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { AuthUser, TwoFactorChallenge, isTwoFactorChallenge } from "@/lib/auth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
// Matches the backend's minimum gap between code sends.
|
||||
const RESEND_COOLDOWN_SECONDS = 60;
|
||||
@@ -42,24 +43,16 @@ export function LoginForm() {
|
||||
window.location.href = hasExplicitRedirect ? redirectTo : `/dashboard/${loggedInUser.role || "user"}`;
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
const msg = errorMessage(err);
|
||||
if (!msg) return "Login failed";
|
||||
// Handle case where message is accidentally a JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
return parsed?.message || msg;
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// If API response object was thrown directly
|
||||
if (err.message) return err.message;
|
||||
|
||||
return "Login failed";
|
||||
};
|
||||
|
||||
const onSubmit = async (e: React.FormEvent) => {
|
||||
@@ -77,7 +70,7 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
goToDashboard(result);
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Login failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -92,7 +85,7 @@ export function LoginForm() {
|
||||
|
||||
try {
|
||||
goToDashboard(await verifyTwoFactor(challenge.challengeId, code, rememberDevice));
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Verification failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -108,7 +101,7 @@ export function LoginForm() {
|
||||
setChallenge({ ...next, enrolling: challenge.enrolling });
|
||||
setCode("");
|
||||
setResendIn(RESEND_COOLDOWN_SECONDS);
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Couldn't send a new code. Please try again.");
|
||||
}
|
||||
};
|
||||
@@ -225,19 +218,19 @@ export function LoginForm() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<a
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Forgot your password?
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<a
|
||||
href={registerHref}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Don't have an account? Sign up here.
|
||||
Don't have an account? Sign up here.
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export function RegisterForm() {
|
||||
const { register } = useAuth();
|
||||
@@ -23,24 +24,16 @@ export function RegisterForm() {
|
||||
|
||||
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;
|
||||
}
|
||||
const getErrorMessage = (err: unknown) => {
|
||||
const msg = errorMessage(err);
|
||||
if (!msg) return "Registration failed. Please try again.";
|
||||
// Handle case where message is accidentally a JSON string
|
||||
try {
|
||||
const parsed = JSON.parse(msg);
|
||||
return parsed?.message || msg;
|
||||
} catch {
|
||||
return msg;
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
@@ -62,7 +55,7 @@ export function RegisterForm() {
|
||||
notificationPreference: hasValidPhone ? notificationPreference : "email",
|
||||
});
|
||||
window.location.href = redirectTo;
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || "Registration failed. Please try again.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
|
||||
|
||||
@@ -63,8 +64,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
},
|
||||
});
|
||||
window.location.href = res.redirectUrl;
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't start the payment");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't start the payment");
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useRef, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void }) {
|
||||
const { token } = useAuth();
|
||||
@@ -23,8 +24,8 @@ export function FileUploader({ onUploaded }: { onUploaded: (url: string) => void
|
||||
authToken: token,
|
||||
});
|
||||
onUploaded(data.url);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Upload failed");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Upload failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
|
||||
import Webcam from "react-webcam";
|
||||
import { useQRScanner } from "@/hooks/useQRScanner";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
export type QRScannerProps = {
|
||||
onResult?: (text: string) => void;
|
||||
@@ -27,11 +28,10 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
// Track the underlying HTMLVideoElement from react-webcam reliably
|
||||
const [videoEl, setVideoEl] = useState<HTMLVideoElement | null>(null);
|
||||
useEffect(() => {
|
||||
let id: any;
|
||||
let id: ReturnType<typeof setInterval> | undefined;
|
||||
if (showCam) {
|
||||
id = setInterval(() => {
|
||||
const anyCam = webcamRef.current as any;
|
||||
const v: HTMLVideoElement | null = anyCam?.video ?? null;
|
||||
const v = webcamRef.current?.video ?? null;
|
||||
if (v) { setVideoEl(v); clearInterval(id); }
|
||||
}, 100);
|
||||
} else {
|
||||
@@ -42,13 +42,14 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
|
||||
// Beep + cooldown state
|
||||
const lastScanRef = useRef<number>(0);
|
||||
const audioCtxRef = useRef<any>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const playBeep = () => {
|
||||
try {
|
||||
const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
|
||||
// Older Safari only has the prefixed constructor.
|
||||
const AC = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AC) return;
|
||||
if (!audioCtxRef.current) audioCtxRef.current = new AC();
|
||||
const ctx = audioCtxRef.current as AudioContext;
|
||||
const ctx = audioCtxRef.current;
|
||||
const oscillator = ctx.createOscillator();
|
||||
const gainNode = ctx.createGain();
|
||||
oscillator.type = "sine";
|
||||
@@ -116,18 +117,20 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
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) {
|
||||
// mediaDevices is missing on insecure origins and some older browsers.
|
||||
if (typeof navigator === "undefined" || !navigator.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);
|
||||
const warmup = await navigator.mediaDevices.getUserMedia({ video: constraints, audio: false });
|
||||
try { warmup.getTracks().forEach(t => t.stop()); } catch {}
|
||||
setShowCam(true);
|
||||
if (!active) toggle();
|
||||
} catch (e: any) {
|
||||
const name = e?.name || e?.message;
|
||||
} catch (e) {
|
||||
// getUserMedia rejects with a DOMException whose `name` says why (NotAllowedError, ...).
|
||||
const name = (e as { name?: string } | null)?.name || errorMessage(e);
|
||||
if (name === "NotAllowedError") {
|
||||
setLocalError("Camera permission denied. Please enable camera access in your browser settings.");
|
||||
} else if (name === "NotFoundError" || name === "OverconstrainedError") {
|
||||
@@ -171,7 +174,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
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 name = typeof e === "string" ? e : e.name || e.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";
|
||||
@@ -190,7 +193,7 @@ export const QRScanner = React.forwardRef<QRScannerHandle, QRScannerProps>(
|
||||
/>
|
||||
) : (
|
||||
<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.
|
||||
Camera is off. Tap "Scan Ticket" to start using the back camera.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
|
||||
import { Wifi, WifiOff, CloudUpload, Lock } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import type { OtherLock } from "./useOfflineScanning";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Offline = ReturnType<typeof import("./useOfflineScanning").useOfflineScanning>;
|
||||
|
||||
@@ -38,8 +39,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
|
||||
try {
|
||||
const n = await offline.enable(eventId, eventTitle, sectionId, sectionName);
|
||||
onInfo(`Offline mode on — ${n} ticket(s) downloaded. You can keep scanning without a connection.`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Couldn't turn on offline mode");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Couldn't turn on offline mode");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -49,8 +50,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
|
||||
try {
|
||||
await offline.release();
|
||||
onInfo("Offline mode ended and the section is unlocked.");
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Couldn't end offline mode");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Couldn't end offline mode");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -59,8 +60,8 @@ export function OfflineScanPanel({ offline, token, eventId, eventTitle, sectionI
|
||||
try {
|
||||
const r = await offline.sync();
|
||||
if (r) onInfo(`Synced ${r.accepted} scan(s)${r.conflicts ? ` — ${r.conflicts} need a supervisor to review` : ""}.`);
|
||||
} catch (e: any) {
|
||||
onError(e?.message || "Sync failed — scans are kept on this device and will retry.");
|
||||
} catch (e) {
|
||||
onError(errorMessage(e) || "Sync failed — scans are kept on this device and will retry.");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ export function useOfflineScanning(token: string | null) {
|
||||
queue.forEach(q => byLock.set(q.lockId, [...(byLock.get(q.lockId) || []), q]));
|
||||
let accepted = 0, conflicts = 0;
|
||||
for (const [lockId, scans] of byLock) {
|
||||
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: any[] }>(`/api/scan-locks/${lockId}/sync`, {
|
||||
const res = await apiFetch<{ accepted: number; duplicates: number; conflicts: unknown[] }>(`/api/scan-locks/${lockId}/sync`, {
|
||||
method: "POST", authToken: token, body: { deviceId: deviceId(), scans },
|
||||
});
|
||||
accepted += res.accepted;
|
||||
|
||||
@@ -34,5 +34,8 @@ export function ApiImage({ src, alt = "", className, style }: Props) {
|
||||
// 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" />;
|
||||
|
||||
// A plain <img>: the API host is only known at runtime and the images have no fixed size,
|
||||
// so next/image's optimizer (which needs both up front) doesn't fit here.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={resolved} alt={alt} className={className} style={style} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import React, { useMemo, useState } from "react";
|
||||
import { Users, Send } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Attendee } from "@/types";
|
||||
|
||||
const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
pending: { text: "consent pending", cls: "text-amber-700" },
|
||||
@@ -11,31 +13,47 @@ const CONSENT_LABEL: Record<string, { text: string; cls: string }> = {
|
||||
declined: { text: "consent declined", cls: "text-red-700" },
|
||||
};
|
||||
|
||||
type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
|
||||
type Row = {
|
||||
id?: string; eventOptionId: string; variantId: string | null; label: string; name: string; email: string; phone: string; locked: boolean;
|
||||
dateOfBirth: string; gender: string; parentName: string; parentEmail: string; parentPhone: string;
|
||||
};
|
||||
|
||||
// The parts of a registration (with its attendees, options and event) this panel reads.
|
||||
export type AttendeesRegistration = {
|
||||
id: string;
|
||||
ticketDelivery?: "payer" | "each";
|
||||
attendees?: Attendee[];
|
||||
event?: {
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
startDate?: string;
|
||||
} | null;
|
||||
registrationOptions?: {
|
||||
eventOptionId: string;
|
||||
variantId?: string | null;
|
||||
quantity: number;
|
||||
eventOption?: { name?: string; isMainTicket?: boolean } | null;
|
||||
variant?: { name?: string } | null;
|
||||
}[];
|
||||
formResponses?: { attendeeId?: string | null }[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Named attendees on a registration (group tickets): shows who each main-ticket place
|
||||
* is for, and lets the payer (or staff) name places, change details, choose where the
|
||||
* tickets go, and send an attendee the link to their own form.
|
||||
*/
|
||||
export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved, onMessage }: {
|
||||
registration: any;
|
||||
registration: AttendeesRegistration;
|
||||
token: string;
|
||||
canEdit: boolean;
|
||||
hasForm: boolean;
|
||||
onSaved: () => void;
|
||||
onMessage: (msg: string) => void;
|
||||
}) {
|
||||
const attendees: Attendee[] = registration.attendees || [];
|
||||
const attendees = useMemo<Attendee[]>(() => registration.attendees || [], [registration.attendees]);
|
||||
const youth = !!registration.event?.requiresParentalConsent;
|
||||
const collectGender = !!registration.event?.collectGender;
|
||||
const eventStart: string | undefined = registration.event?.startDate;
|
||||
@@ -100,8 +118,8 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
setEditing(false);
|
||||
onMessage("Attendees saved.");
|
||||
onSaved();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't save attendees");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't save attendees");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -111,19 +129,19 @@ export function AttendeesPanel({ registration, token, canEdit, hasForm, onSaved,
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/registrations/${encodeURIComponent(registration.id)}/attendees/${encodeURIComponent(a.id)}/send-form-link`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't send the form link");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't send the form link");
|
||||
}
|
||||
};
|
||||
|
||||
const answered = new Set((registration.formResponses || []).map((r: any) => r.attendeeId).filter(Boolean));
|
||||
const answered = new Set((registration.formResponses || []).map((r) => r.attendeeId).filter(Boolean));
|
||||
|
||||
const resendConsent = async (requestId: string) => {
|
||||
try {
|
||||
const res = await apiFetch<{ message: string }>(`/api/consent/requests/${encodeURIComponent(requestId)}/resend`, { method: "POST", authToken: token });
|
||||
onMessage(res.message);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || "Couldn't re-send the consent request");
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Couldn't re-send the consent request");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Cookie } from "lucide-react";
|
||||
import { getConsent, setConsent } from "@/lib/analytics";
|
||||
@@ -11,11 +11,24 @@ import { getConsent, setConsent } from "@/lib/analytics";
|
||||
*/
|
||||
export function CookieConsent() {
|
||||
const [show, setShow] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { setShow(getConsent() === null); }, []);
|
||||
// Publishes the banner's height as --cookie-banner-h so HelpFab can sit above it.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!show || !el) return;
|
||||
const root = document.documentElement;
|
||||
const update = () => root.style.setProperty("--cookie-banner-h", `${el.offsetHeight}px`);
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => { ro.disconnect(); root.style.removeProperty("--cookie-banner-h"); };
|
||||
}, [show]);
|
||||
if (!show) return null;
|
||||
const choose = (v: "accepted" | "declined") => { setConsent(v); setShow(false); };
|
||||
return (
|
||||
<div className="fixed bottom-0 inset-x-0 z-40 p-3 sm:p-4 pointer-events-none" role="region" aria-label="Cookie consent">
|
||||
// On mobile, sit above BottomNav (fixed, h-16 + safe-area, z-50).
|
||||
<div ref={ref} className="fixed bottom-[calc(4rem+env(safe-area-inset-bottom))] md:bottom-0 inset-x-0 z-40 p-3 sm:p-4 pointer-events-none" role="region" aria-label="Cookie consent">
|
||||
<div className="pointer-events-auto max-w-3xl mx-auto bg-white border shadow-lg rounded-xl p-4 flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<Cookie className="w-6 h-6 text-brand-600 shrink-0 hidden sm:block" />
|
||||
<p className="text-sm text-gray-700 flex-1">
|
||||
|
||||
@@ -34,8 +34,9 @@ export default function HelpFab() {
|
||||
type="button"
|
||||
onClick={openGuide}
|
||||
aria-label="Help"
|
||||
// bottom-20 on mobile clears BottomNav (fixed, h-16 + safe-area, z-50).
|
||||
className="fixed z-40 bottom-20 md:bottom-6 right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
|
||||
// 5rem on mobile clears BottomNav (fixed, h-16 + safe-area, z-50);
|
||||
// --cookie-banner-h (set by CookieConsent while it shows) lifts it above the banner.
|
||||
className="fixed z-40 bottom-[calc(5rem+var(--cookie-banner-h,0px))] md:bottom-[calc(1.5rem+var(--cookie-banner-h,0px))] right-4 md:right-6 w-12 h-12 rounded-full bg-brand-600 text-white shadow-lg hover:bg-brand-700 hover:scale-105 hover:shadow-xl active:scale-95 flex items-center justify-center transition-all"
|
||||
>
|
||||
<HelpCircle className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ListChecks } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Entry = {
|
||||
id: string;
|
||||
@@ -30,8 +31,8 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
|
||||
const res = await apiFetch<{ message: string }>(`/api/waitlist/${encodeURIComponent(id)}`, { method: "DELETE", authToken: token });
|
||||
onMessage(res.message);
|
||||
load();
|
||||
} catch (e: any) {
|
||||
onMessage(e?.message || "Couldn't leave the waitlist");
|
||||
} catch (e) {
|
||||
onMessage(errorMessage(e) || "Couldn't leave the waitlist");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,5 +27,7 @@ export function QrImage({ value, size = 120, alt = "QR", className }: Props) {
|
||||
}, [value, size]);
|
||||
|
||||
if (!dataUrl) return <div className={`${className || ""} bg-gray-100 animate-pulse`} aria-hidden="true" />;
|
||||
// A data: URL generated in the browser — nothing for next/image to optimize.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
return <img src={dataUrl} alt={alt} className={className} />;
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export function SetupGuard() {
|
||||
}
|
||||
})
|
||||
.catch(() => {/* API down — don't block */});
|
||||
}, [pathname]);
|
||||
}, [pathname, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -4,9 +4,18 @@ import React, { useState } from "react";
|
||||
import { ArrowRightLeft } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isMinorOn } from "@/components/shared/AttendeeNamesEditor";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type TransferTicket = { id: string; isUsed?: boolean; quantity?: number; usages?: unknown[] };
|
||||
type TransferEvent = {
|
||||
startDate?: string;
|
||||
allowTicketTransfer?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
collectGender?: boolean;
|
||||
};
|
||||
|
||||
/** Whether to offer "Transfer" on a ticket (the server checks the rules again). */
|
||||
export function canOfferTransfer(ticket: any, event: any, isSupervisor = false): boolean {
|
||||
export function canOfferTransfer(ticket: TransferTicket | null | undefined, event: TransferEvent | null | undefined, isSupervisor = false): boolean {
|
||||
if (!ticket || !event) return false;
|
||||
if ((ticket.usages || []).length > 0 || ticket.isUsed) return false;
|
||||
if (event.startDate && new Date(event.startDate) <= new Date()) return false;
|
||||
@@ -19,8 +28,8 @@ export function canOfferTransfer(ticket: any, event: any, isSupervisor = false):
|
||||
* to the details typed, and the ticket only moves (with a new QR code) once they accept.
|
||||
*/
|
||||
export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
ticket: any;
|
||||
event: any;
|
||||
ticket: TransferTicket;
|
||||
event: TransferEvent | null | undefined;
|
||||
token: string;
|
||||
onClose: () => void;
|
||||
onDone: (message: string) => void;
|
||||
@@ -42,8 +51,8 @@ export function TransferTicketModal({ ticket, event, token, onClose, onDone }: {
|
||||
if (youth) Object.assign(body, { dateOfBirth: f.dateOfBirth, parentName: f.parentName, parentEmail: f.parentEmail, parentPhone: f.parentPhone });
|
||||
const res = await apiFetch<{ message: string }>(`/api/tickets/${ticket.id}/transfer`, { method: "POST", authToken: token, body });
|
||||
onDone(res.message);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || "Couldn't transfer the ticket");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err) || "Couldn't transfer the ticket");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export const dashboardAdminHelpContent: HelpContent = {
|
||||
A day-by-day revenue chart for the past month, and a ranking of your best-performing events by revenue, registrations, and tickets sold.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Zap} title="Payments overview" tone="amber">
|
||||
Today, past week, and past month totals — a quicker check than opening Reports for a simple "how much came in" question.
|
||||
Today, past week, and past month totals — a quicker check than opening Reports for a simple "how much came in" question.
|
||||
</GuideItem>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">For a deeper breakdown, filters, and exports, use Reports instead — this page is a summary, not a substitute.</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { FileText, Eye, Edit3, ListChecks } from "lucide-react";
|
||||
import { Eye, Edit3, ListChecks } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export type AuthContextValue = {
|
||||
user: AuthUser | null;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<AuthUser | (TwoFactorChallenge & { twoFactorRequired: true })>;
|
||||
login: (email: string, password: string) => Promise<AuthUser | TwoFactorChallenge>;
|
||||
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
||||
|
||||
@@ -42,7 +42,7 @@ function addToast(toast: ToastInput) {
|
||||
const newToast: Toast = {
|
||||
...toast,
|
||||
id,
|
||||
open: true as any,
|
||||
open: true,
|
||||
}
|
||||
|
||||
// Ensure limit
|
||||
@@ -62,11 +62,11 @@ function updateToast(id: string, update: Partial<Toast>) {
|
||||
function dismissToast(id?: string) {
|
||||
if (id) {
|
||||
queueRemoval(id)
|
||||
updateToast(id, { open: false } as any)
|
||||
updateToast(id, { open: false })
|
||||
} else {
|
||||
state.toasts.forEach((t) => {
|
||||
queueRemoval(t.id)
|
||||
updateToast(t.id, { open: false } as any)
|
||||
updateToast(t.id, { open: false })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,17 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
export function useDismissingState<T>(initial: T, ms = 7000) {
|
||||
const [value, setValue] = useState<T>(initial);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Held in a ref so the setter keeps one identity across renders (like useState's),
|
||||
// even when the caller passes a fresh object/array literal as `initial`.
|
||||
const initialRef = useRef(initial);
|
||||
|
||||
const set = useCallback((next: T) => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
||||
setValue(next);
|
||||
if (next) {
|
||||
timeoutRef.current = setTimeout(() => setValue(initial), ms);
|
||||
timeoutRef.current = setTimeout(() => setValue(initialRef.current), ms);
|
||||
}
|
||||
}, [ms, initial]);
|
||||
}, [ms]);
|
||||
|
||||
useEffect(() => () => { if (timeoutRef.current) clearTimeout(timeoutRef.current); }, []);
|
||||
|
||||
|
||||
@@ -44,9 +44,9 @@ export function useQRScanner(videoEl: HTMLVideoElement | null, opts: UseQRScanne
|
||||
try {
|
||||
if (!videoEl) return;
|
||||
const result = await reader.decodeOnceFromVideoElement(videoEl);
|
||||
const text = (result as any)?.getText ? (result as any).getText() : (result as any)?.text ?? String(result ?? "");
|
||||
const text = result?.getText() ?? "";
|
||||
if (text) onResult?.(text);
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Ignore transient decode errors and retry
|
||||
} finally {
|
||||
if (!cancelled && active) setTimeout(scan, 200);
|
||||
@@ -57,10 +57,12 @@ export function useQRScanner(videoEl: HTMLVideoElement | null, opts: UseQRScanne
|
||||
cancelRef.current = () => {
|
||||
cancelled = true;
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.name === "NotAllowedError") {
|
||||
} catch (err) {
|
||||
// getUserMedia rejects with a DOMException whose `name` says why.
|
||||
const name = (err as { name?: string } | null)?.name;
|
||||
if (name === "NotAllowedError") {
|
||||
setPermissionError("Camera permission denied. Please enable camera access.");
|
||||
} else if (err?.name === "NotFoundError") {
|
||||
} else if (name === "NotFoundError") {
|
||||
setPermissionError("No suitable camera found. Try a device with a back camera.");
|
||||
} else {
|
||||
setPermissionError("Unable to access camera.");
|
||||
|
||||
+24
-16
@@ -1,7 +1,9 @@
|
||||
import type { Paginated, Payment, User } from "@/types";
|
||||
|
||||
export type ApiOptions = {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: any;
|
||||
body?: unknown;
|
||||
authToken?: string | null;
|
||||
nextOptions?: RequestInit;
|
||||
};
|
||||
@@ -72,14 +74,18 @@ export function resolveToApiOrigin(urlOrPath: string | null | undefined): string
|
||||
return API_BASE ? `${API_BASE}${raw}` : raw;
|
||||
}
|
||||
|
||||
// The parsed JSON body of a failed request — the backend always sends `message`, and
|
||||
// some routes add a machine-readable `code` or extra detail fields.
|
||||
export type ApiErrorData = { message?: string; code?: string; [key: string]: unknown };
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public readonly status: number, message: string, public readonly data?: any) {
|
||||
constructor(public readonly status: number, message: string, public readonly data?: ApiErrorData) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T = any>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
export async function apiFetch<T = unknown>(path: string, options: ApiOptions = {}): Promise<T> {
|
||||
const { method = "GET", headers = {}, body, authToken, nextOptions } = options;
|
||||
|
||||
const url = path.startsWith("http") ? path : `${API_BASE}${path}`;
|
||||
@@ -110,7 +116,7 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
let message = text || `Request failed with status ${res.status}`;
|
||||
let data: any;
|
||||
let data: ApiErrorData | undefined;
|
||||
// Backend returns { message: "..." } — extract it so callers get a plain string
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
@@ -138,8 +144,8 @@ export async function apiFetch<T = any>(path: string, options: ApiOptions = {}):
|
||||
if (contentType.includes("application/json")) {
|
||||
return res.json();
|
||||
}
|
||||
// @ts-ignore
|
||||
return res.text();
|
||||
// Non-JSON responses come back as text; callers that expect that pass T = string.
|
||||
return (await res.text()) as T;
|
||||
}
|
||||
|
||||
// For server components that prerender a public list: during `next build` the
|
||||
@@ -159,10 +165,10 @@ export async function apiFetchListForPrerender<T>(path: string, options: ApiOpti
|
||||
// Several list endpoints (/api/users, /api/payments) are paginated and cap
|
||||
// `limit` at 200 server-side, so a single request can silently miss records
|
||||
// beyond the first page. This walks every page and returns the full list.
|
||||
async function fetchAllPages(path: string, token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
async function fetchAllPages<T>(path: string, token: string, params: Record<string, string> = {}): Promise<T[]> {
|
||||
const query = new URLSearchParams({ ...params, limit: "200", page: "1" });
|
||||
const first = await apiFetch<any>(`${path}?${query.toString()}`, { authToken: token });
|
||||
const items: any[] = Array.isArray(first?.data) ? first.data : [];
|
||||
const first = await apiFetch<Paginated<T>>(`${path}?${query.toString()}`, { authToken: token });
|
||||
const items: T[] = Array.isArray(first?.data) ? first.data : [];
|
||||
const pages = first?.pages || 1;
|
||||
|
||||
if (pages > 1) {
|
||||
@@ -173,7 +179,7 @@ async function fetchAllPages(path: string, token: string, params: Record<string,
|
||||
Array.from({ length: pages - 1 }, (_, i) => {
|
||||
const page = i + 2;
|
||||
const pageQuery = new URLSearchParams({ ...params, limit: "200", page: String(page) });
|
||||
return apiFetch<any>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
||||
return apiFetch<Paginated<T>>(`${path}?${pageQuery.toString()}`, { authToken: token });
|
||||
})
|
||||
);
|
||||
for (const res of remaining) {
|
||||
@@ -184,19 +190,21 @@ async function fetchAllPages(path: string, token: string, params: Record<string,
|
||||
return items;
|
||||
}
|
||||
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
return fetchAllPages("/api/users", token, params);
|
||||
export function fetchAllUsers(token: string, params: Record<string, string> = {}): Promise<User[]> {
|
||||
return fetchAllPages<User>("/api/users", token, params);
|
||||
}
|
||||
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<any[]> {
|
||||
return fetchAllPages("/api/payments", token, params);
|
||||
export function fetchAllPayments(token: string, params: Record<string, string> = {}): Promise<Payment[]> {
|
||||
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||
}
|
||||
|
||||
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
|
||||
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
|
||||
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<{ redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string }> {
|
||||
return apiFetch("/api/payments/yoco-checkout", {
|
||||
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string): Promise<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
|
||||
@@ -30,7 +30,7 @@ export type TwoFactorChallenge = {
|
||||
enrolling?: boolean;
|
||||
};
|
||||
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge & { twoFactorRequired: true } {
|
||||
export function isTwoFactorChallenge(res: unknown): res is TwoFactorChallenge {
|
||||
return !!res && typeof res === "object" && (res as TwoFactorChallenge).twoFactorRequired === true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type TableRow = Record<string, any>;
|
||||
export type TableRow = Record<string, unknown>;
|
||||
|
||||
export function downloadCsv(filename: string, rows: TableRow[], columns?: { key: string; label?: string }[]) {
|
||||
const cols = columns && columns.length > 0 ? columns : Object.keys(rows[0] || {}).map(k => ({ key: k, label: k }));
|
||||
@@ -24,7 +24,7 @@ function escapeCsv(v: string) {
|
||||
return v;
|
||||
}
|
||||
|
||||
function valueToString(v: any): string {
|
||||
function valueToString(v: unknown): string {
|
||||
if (v == null) return '';
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
|
||||
@@ -15,7 +15,9 @@ export function editDistance(a: string, b: string): number {
|
||||
}
|
||||
|
||||
/** Return a score 0–1 for how well a user matches a search query */
|
||||
export function scoreUser(u: any, raw: string): number {
|
||||
export type MatchableUser = { name?: string | null; email?: string | null; phoneNumber?: string | null };
|
||||
|
||||
export function scoreUser(u: MatchableUser, raw: string): number {
|
||||
const q = raw.toLowerCase().trim();
|
||||
if (!q || q.length < 2) return 0;
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ export function currentImpersonation(): Impersonation | null {
|
||||
export const isImpersonating = () => !!currentImpersonation();
|
||||
|
||||
/** Switches this browser to the impersonation token and opens the user's dashboard. */
|
||||
export function beginImpersonation(res: { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } }) {
|
||||
export type ImpersonationStart = { token: string; session: { id: string; expiresAt: string; target: { name: string; email: string } } };
|
||||
|
||||
export function beginImpersonation(res: ImpersonationStart) {
|
||||
try {
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, localStorage.getItem(TOKEN_KEY) || "");
|
||||
localStorage.setItem(ADMIN_CACHE_KEY, localStorage.getItem(USER_CACHE_KEY) || "");
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { EarlyBirdTier, EventOption, OptionVariant, RegistrationOption } from "@/types";
|
||||
|
||||
// Effective unit price for a registration option. Uses priceSnapshot when available
|
||||
// (authoritative backend price, variant-aware). Falls back to a deadline-only early-bird
|
||||
// calculation for legacy rows without a snapshot: the cheapest tier still open both at
|
||||
// `referenceTime` (e.g. the last payment) and at `atTime`.
|
||||
export function registrationOptionUnitPrice(opt: RegistrationOption, referenceTime: string | Date | null, 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;
|
||||
// Use variant price as base if available, otherwise option base
|
||||
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() : [];
|
||||
// Filter: variant options use only their variant-specific tiers; plain options use option-level tiers
|
||||
const tiers = variantId
|
||||
? allTiers.filter((t) => t.variantId === variantId)
|
||||
: allTiers.filter((t) => !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) => ({ ...x, deadlineAt: new Date(x.deadline) }))
|
||||
.filter((x) => (ref < x.deadlineAt) && (t < x.deadlineAt))
|
||||
.sort((a, b) => a.deadlineAt.getTime() - b.deadlineAt.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;
|
||||
}
|
||||
|
||||
// The earliest-deadline early-bird tier that's still open, or undefined when none are.
|
||||
function currentTier(tiers: EarlyBirdTier[]): EarlyBirdTier | undefined {
|
||||
const now = new Date();
|
||||
return tiers
|
||||
.map((t) => ({ ...t, deadlineAt: new Date(t.deadline) }))
|
||||
.filter((t) => now < t.deadlineAt)
|
||||
.sort((a, b) => a.deadlineAt.getTime() - b.deadlineAt.getTime() || (a.order || 0) - (b.order || 0) || a.price - b.price)[0];
|
||||
}
|
||||
|
||||
// What an event option costs right now: its current early-bird price, else its base price.
|
||||
export function optionUnitNow(opt: EventOption): number {
|
||||
const base = opt.price || 0;
|
||||
const tiers = (Array.isArray(opt.earlyBirdTiers) ? opt.earlyBirdTiers : []).filter((t) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
return currentTier(tiers)?.price ?? base;
|
||||
}
|
||||
|
||||
// Same for a variant: its own tiers if it has any, otherwise the option's.
|
||||
export function variantUnitNow(opt: EventOption, variant: OptionVariant): 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) => t.variantId === variant.id);
|
||||
const tiers = variantTiers.length > 0 ? variantTiers : allTiers.filter((t) => !t.variantId);
|
||||
if (tiers.length === 0) return base;
|
||||
return currentTier(tiers)?.price ?? base;
|
||||
}
|
||||
@@ -25,3 +25,11 @@ export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.slice(0, maxLength)}...`
|
||||
}
|
||||
|
||||
// Message from a caught value (an Error, an ApiError, or anything thrown), or undefined when
|
||||
// it has none — so callers can write `errorMessage(e) || "Fallback text"`.
|
||||
export function errorMessage(e: unknown): string | undefined {
|
||||
if (e instanceof Error) return e.message || undefined
|
||||
if (typeof e === "object" && e !== null && "message" in e && typeof e.message === "string") return e.message || undefined
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse, NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
// Minimal pass-through middleware to satisfy Next.js requirements
|
||||
export function middleware(_req: NextRequest) {
|
||||
export function middleware() {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
+282
-19
@@ -1,4 +1,9 @@
|
||||
// Shapes of the records the backend API returns. These mirror the Prisma models
|
||||
// (backend/prisma/schema.prisma) with dates as ISO strings. Relations are optional
|
||||
// because each endpoint includes a different subset of them.
|
||||
|
||||
export type Role = "user" | "staff" | "supervisor" | "admin";
|
||||
export type NotificationPreference = "email" | "whatsapp" | "both";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
@@ -16,27 +21,126 @@ export type AuthResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export type EventOption = {
|
||||
// A user record as admin/supervisor endpoints return it (never includes the password).
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
isMainTicket?: boolean;
|
||||
email: string;
|
||||
phoneNumber?: string | null;
|
||||
role?: Role;
|
||||
isActive?: boolean;
|
||||
notificationPreference?: NotificationPreference;
|
||||
twoFactorEnabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
// The few user fields most endpoints embed in related records.
|
||||
export type UserSummary = Pick<User, "id" | "name" | "email"> & Partial<Pick<User, "phoneNumber" | "role">>;
|
||||
|
||||
export type OptionVariant = {
|
||||
id: string;
|
||||
eventOptionId?: string;
|
||||
name: string;
|
||||
price?: number | null;
|
||||
staffPrice?: number | null;
|
||||
stockLimit?: number;
|
||||
order?: number;
|
||||
// Public event endpoints add how many are left when a stock limit is set.
|
||||
availableCount?: number;
|
||||
};
|
||||
|
||||
export type EarlyBirdTier = {
|
||||
id: string;
|
||||
eventOptionId?: string;
|
||||
variantId?: string | null;
|
||||
deadline: string;
|
||||
price: number;
|
||||
order?: number;
|
||||
stockLimit?: number;
|
||||
};
|
||||
|
||||
export type EventOption = {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
name: string;
|
||||
price: number;
|
||||
isMainTicket?: boolean;
|
||||
stockLimit?: number;
|
||||
availableCount?: number;
|
||||
staffPrice?: number | null;
|
||||
variants?: OptionVariant[];
|
||||
earlyBirdTiers?: EarlyBirdTier[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type FormFieldType =
|
||||
| "yes_no" | "text" | "date" | "numeric" | "statement" | "paragraph" | "rating" | "multiple_choice";
|
||||
|
||||
export type EventFormField = {
|
||||
id: string;
|
||||
formId?: string;
|
||||
type: FormFieldType;
|
||||
label: string;
|
||||
isRequired?: boolean;
|
||||
order?: number;
|
||||
helpText?: string | null;
|
||||
options?: unknown;
|
||||
};
|
||||
|
||||
export type EventForm = {
|
||||
id?: string;
|
||||
eventId?: string;
|
||||
isRequired: boolean;
|
||||
fields: EventFormField[];
|
||||
};
|
||||
|
||||
export type EventAttachment = { id: string; eventId?: string; originalName: string; filename?: string; mimeType: string; size: number; url: string; createdAt?: string };
|
||||
|
||||
export type Event = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
description?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
registrationDeadline?: string | null;
|
||||
goLiveAt?: string;
|
||||
price: number;
|
||||
picture?: string;
|
||||
picture?: string | null;
|
||||
location?: string | null;
|
||||
isActive?: boolean;
|
||||
isHidden?: boolean;
|
||||
isLive?: boolean;
|
||||
requiresAuth?: boolean;
|
||||
requiresRegistration?: boolean;
|
||||
allowNamedAttendees?: boolean;
|
||||
requiresParentalConsent?: boolean;
|
||||
consentText?: string | null;
|
||||
consentTextVersion?: number;
|
||||
consentCollectMedical?: boolean;
|
||||
consentCollectEmergency?: boolean;
|
||||
consentCollectPhoto?: boolean;
|
||||
waitlistEnabled?: boolean;
|
||||
waitlistOfferHours?: number;
|
||||
waitlistPartialOffers?: boolean;
|
||||
allowTicketTransfer?: boolean;
|
||||
collectGender?: boolean;
|
||||
allowSponsorships?: boolean;
|
||||
sponsorshipGoalSpots?: number | null;
|
||||
showSponsorshipProgress?: boolean;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
contactEmail?: string | null;
|
||||
createdById?: string | null;
|
||||
// Supervisor views of an event include who created it and who gets its notifications.
|
||||
createdBy?: UserSummary | null;
|
||||
notifyRecipients?: UserSummary[];
|
||||
redirectUrl?: string | null;
|
||||
options?: EventOption[];
|
||||
eventOptions?: EventOption[];
|
||||
attachments?: EventAttachment[];
|
||||
form?: EventForm | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
cashupStatus?: "open" | "closed";
|
||||
@@ -117,6 +221,16 @@ export type ReconciledSnapshot = {
|
||||
byMethod: Record<CashupMethod, ReconciledByMethod>;
|
||||
};
|
||||
|
||||
// Reconciliation figures saved part-way through, before the event is closed.
|
||||
export type CashupDraft = {
|
||||
lines?: {
|
||||
method: string;
|
||||
actualAmount?: string | number;
|
||||
notes?: string;
|
||||
denominations?: { value: number; count: number }[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type SalesByOption = {
|
||||
eventOptionId: string;
|
||||
name: string;
|
||||
@@ -125,7 +239,7 @@ export type SalesByOption = {
|
||||
};
|
||||
|
||||
export type EventFinancials = {
|
||||
event: Pick<Event, "id" | "title" | "cashupStatus" | "closedAt" | "closedById" | "reopenedAt" | "reopenedById"> & { cashupDraft?: any };
|
||||
event: Pick<Event, "id" | "title" | "cashupStatus" | "closedAt" | "closedById" | "reopenedAt" | "reopenedById"> & { cashupDraft?: CashupDraft | null };
|
||||
paymentsByMethod: Record<CashupMethod, number>;
|
||||
totalRevenue: number;
|
||||
costs: EventCost[];
|
||||
@@ -137,28 +251,68 @@ export type EventFinancials = {
|
||||
effectiveGrossIncomeByMethod: Record<CashupMethod, number>;
|
||||
effectiveTotalRevenue: number;
|
||||
netProfit: number;
|
||||
// Coupon discounts given on the event's registrations.
|
||||
discountsTotal?: number;
|
||||
unallocatedDonations: Payment[];
|
||||
unallocatedDonationsTotal: number;
|
||||
totalDonations: number;
|
||||
salesByOption: SalesByOption[];
|
||||
history: EventCashup[];
|
||||
};
|
||||
export type RegistrationStatus = "pending" | "confirmed" | "partial_paid" | "paid" | "cancelled";
|
||||
|
||||
export type RegistrationItem = {
|
||||
id?: string;
|
||||
export type RegistrationOption = {
|
||||
id: string;
|
||||
registrationId?: string;
|
||||
eventOptionId: string;
|
||||
quantity: number;
|
||||
variantId?: string | null;
|
||||
priceSnapshot?: number | null;
|
||||
appliedTierId?: string | null;
|
||||
eventOption?: EventOption;
|
||||
variant?: OptionVariant | null;
|
||||
// One entry per price the line was bought at (e.g. some before an early-bird tier expired).
|
||||
tranches?: { id: string; quantity: number; priceSnapshot: number; appliedTierId?: string | null }[];
|
||||
tickets?: { id: string; quantity?: number }[];
|
||||
registration?: Registration;
|
||||
// Tranche-aware total for the line, attached by registration list endpoints.
|
||||
lineTotal?: number | null;
|
||||
};
|
||||
|
||||
// A named person on a group registration, for one main-ticket place.
|
||||
export type Attendee = {
|
||||
id: string; eventOptionId: string; variantId: string | null; name: string; email: string | null; phone: string | null;
|
||||
registrationId?: string; registrationOptionId?: string | null; userId?: string | null; order?: number;
|
||||
dateOfBirth?: string | null; gender?: string | null; parentName?: string | null; parentEmail?: string | null; parentPhone?: string | null;
|
||||
ticket?: { id: string; isUsed: boolean } | null;
|
||||
consentRequests?: { id: string; status: "pending" | "given" | "declined" }[];
|
||||
};
|
||||
|
||||
export type FormAnswer = { id: string; responseId?: string; fieldId: string; value: string; field?: { label: string } | null };
|
||||
export type FormResponse = {
|
||||
id: string; registrationId?: string; attendeeId?: string | null; createdAt: string; answers?: FormAnswer[];
|
||||
registration?: Registration;
|
||||
};
|
||||
|
||||
export type Registration = {
|
||||
id: string;
|
||||
userId: string;
|
||||
eventId: string;
|
||||
status: "pending" | "paid" | string;
|
||||
createdAt?: string;
|
||||
status: RegistrationStatus;
|
||||
discountTotal?: number;
|
||||
ticketDelivery?: "payer" | "each";
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
event?: Pick<Event, "id" | "title" | "price">;
|
||||
options?: RegistrationItem[];
|
||||
user?: UserSummary;
|
||||
event?: Event;
|
||||
registrationOptions?: RegistrationOption[];
|
||||
payments?: Payment[];
|
||||
formResponses?: FormResponse[];
|
||||
attendees?: Attendee[];
|
||||
// The discount code currently applied (released redemptions are left out).
|
||||
couponRedemptions?: { id: string; discountAmount: number; createdAt?: string; coupon?: { id: string; code: string; type?: string; value?: number; description?: string | null } | null }[];
|
||||
// Tranche-aware total the backend attaches to registration responses.
|
||||
totalDueComputed?: number | null;
|
||||
};
|
||||
|
||||
export type Ticket = {
|
||||
@@ -167,11 +321,24 @@ export type Ticket = {
|
||||
registrationOptionId?: string;
|
||||
userId: string;
|
||||
eventId: string;
|
||||
quantity?: number;
|
||||
isUsed: boolean;
|
||||
emailSent: boolean;
|
||||
emailSent?: boolean;
|
||||
attendeeId?: string | null;
|
||||
holderUserId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
event?: Pick<Event, "id" | "title">;
|
||||
updatedAt?: string;
|
||||
user?: UserSummary;
|
||||
attendee?: {
|
||||
id: string; name: string; email?: string | null; phone?: string | null;
|
||||
// Room/group allocations shown on the ticket (only sets marked "show on ticket").
|
||||
allocations?: { set: { name: string; order?: number }; group: { name: string } }[];
|
||||
} | null;
|
||||
// An open offer to transfer this ticket to someone else, awaiting their acceptance.
|
||||
transferRequests?: { id: string; toName: string; toEmail?: string | null; toPhone?: string | null; createdAt?: string }[];
|
||||
event?: Event;
|
||||
registrationOption?: RegistrationOption;
|
||||
usages?: { id: string; scannedAt: string; quantityRedeemed?: number }[];
|
||||
};
|
||||
|
||||
export type Payment = {
|
||||
@@ -179,9 +346,105 @@ export type Payment = {
|
||||
amount: number;
|
||||
method: string;
|
||||
userId: string;
|
||||
registrationId?: string;
|
||||
eventId?: string;
|
||||
recordedById?: string | null;
|
||||
registrationId?: string | null;
|
||||
eventId?: string | null;
|
||||
isDonation?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
isSponsorship?: boolean;
|
||||
sponsorName?: string | null;
|
||||
sponsorMessage?: string | null;
|
||||
sponsorAnonymous?: boolean;
|
||||
status?: string | null;
|
||||
externalId?: string | null;
|
||||
originalPaymentId?: string | null;
|
||||
createdAt: string;
|
||||
user?: UserSummary;
|
||||
recordedBy?: UserSummary | null;
|
||||
event?: Pick<Event, "id" | "title"> | null;
|
||||
registration?: Registration | null;
|
||||
};
|
||||
|
||||
// A page of results from a paginated list endpoint (/api/users, /api/payments, ...).
|
||||
export type Paginated<T> = {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
pages: number;
|
||||
};
|
||||
|
||||
// /api/stats/{staff,supervisor,admin} — dashboard summaries.
|
||||
export type ScanStats = {
|
||||
totalToday: number;
|
||||
myToday: number;
|
||||
lastHour: number;
|
||||
byStaff: { scannedById: string; name: string; count: number }[];
|
||||
};
|
||||
|
||||
export type PaymentStats = {
|
||||
totalToday: number;
|
||||
donationsToday?: number;
|
||||
totalWeek?: number;
|
||||
totalMonth?: number;
|
||||
};
|
||||
|
||||
export type RecentScan = {
|
||||
id: string;
|
||||
scannedAt: string;
|
||||
quantityRedeemed: number;
|
||||
scannedById?: string;
|
||||
scannedBy?: { id: string; name: string } | null;
|
||||
ticket?: {
|
||||
id: string;
|
||||
eventId?: string;
|
||||
event?: { title: string } | null;
|
||||
registrationOption?: { eventOption?: { name: string } | null; variant?: { name: string } | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type DashboardStats = {
|
||||
scanStats: ScanStats;
|
||||
recentScans: RecentScan[];
|
||||
paymentStats: PaymentStats;
|
||||
activeEventsCount: number;
|
||||
};
|
||||
|
||||
// What the email/WhatsApp send, preview and schedule endpoints return.
|
||||
export type MessageSendResult<Recipient = { phone: string; name?: string }> = {
|
||||
matched?: number;
|
||||
queued?: number;
|
||||
sent?: number;
|
||||
recipients?: Recipient[];
|
||||
job?: { id: string };
|
||||
};
|
||||
|
||||
// One person's parental-consent status on a youth event (GET /api/consent/events/:id).
|
||||
export type ConsentRow = {
|
||||
attendeeId: string;
|
||||
name: string;
|
||||
dateOfBirth: string | null;
|
||||
minor: boolean;
|
||||
parentName: string | null;
|
||||
parentEmail: string | null;
|
||||
parentPhone: string | null;
|
||||
registrationStatus: string;
|
||||
bookedBy: { name: string; email: string };
|
||||
status: "not_required" | "missing" | "pending" | "given" | "declined";
|
||||
request: { id: string; status: string; textVersion: number; signedName: string | null; respondedAt: string | null; lastSentAt: string; remindersSent: number; photoConsent: boolean | null; outdated: boolean } | null;
|
||||
};
|
||||
|
||||
// One person's medical/emergency details (GET /api/consent/events/:id/medical).
|
||||
export type MedicalRow = {
|
||||
name: string; dateOfBirth: string | null; parentName: string | null; parentPhone: string | null; parentEmail: string | null;
|
||||
medical: { allergies: string; medication: string; conditions: string; medicalAid: string } | null;
|
||||
emergency: { name: string; phone: string; relationship: string } | null;
|
||||
photoConsent: boolean | null; purged: boolean;
|
||||
};
|
||||
|
||||
// A scanning section (e.g. "Main hall") and the ticket options valid at it.
|
||||
export type Section = {
|
||||
id: string;
|
||||
eventId: string;
|
||||
name: string;
|
||||
allowedOptions?: { id: string; sectionId?: string; eventOptionId: string; eventOption?: EventOption }[];
|
||||
event?: Pick<Event, "id" | "title">;
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user