Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbb84b037c | ||
|
|
2dfe8d32c4 | ||
|
|
305499ee91 | ||
|
|
c2112bf707 | ||
|
|
e63fb2cc27 | ||
|
|
d6da2c8227 | ||
|
|
7eed7a01df | ||
|
|
a7be2baab2 | ||
|
|
5a9137e252 | ||
|
|
4e63a78e8e | ||
|
|
2a067a3687 | ||
|
|
bdae0c6b08 | ||
|
|
c84cc257d0 | ||
|
|
7cbb147b00 | ||
|
|
b75be18a87 | ||
|
|
05840541c2 |
@@ -7,6 +7,41 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.9.4] - 2026-08-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- New `TRUST_PROXY` backend env var — set it when the app runs behind a reverse proxy (e.g. nginx on a separate server) so rate limiting reads the real client IP from `X-Forwarded-For` instead of the proxy's. Accepts a hop count, `true`/`false`, or trusted proxy IP(s)/CIDR(s).
|
||||||
|
|
||||||
|
## [1.9.3] - 2026-08-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The server used to hard-crash (`process.exit(1)`) on any unhandled promise rejection, so a single missed error handler anywhere in the app's fire-and-forget notification code (email/WhatsApp sending) could take the whole server down. It now logs the error and keeps running.
|
||||||
|
- Accounts created on someone's behalf (at-the-door walk-in registration, or manual registration from the Admin/Supervisor dashboard) now get their activation link (email or WhatsApp, whichever they have) sent immediately when the account is created, instead of only on their first failed login attempt — matching what the Terms of Use already promised.
|
||||||
|
- Manual registration with a real email address used to create the account already active with a fixed, undisclosed password (`Hope123`) — the visitor had no way to know it. That account is now created inactive and gets the same immediate activation link, so the visitor sets their own password — unless a password was supplied directly (see below), in which case it's activated immediately with no link needed.
|
||||||
|
- The self-service kiosk's "Create an account" password field never actually worked — the account was always created with a different password behind the scenes, so visitors who set one couldn't log in with it. Manual registration now honours a caller-supplied password and activates the account immediately instead of discarding it.
|
||||||
|
- Removed the "Guest (no account)" checkboxes from the Manual Registration pages (both the current one and the legacy form) and the equivalent flag from the at-the-door kiosk — they stopped affecting backend behaviour once every walk-in account started being created inactive with an activation link. The self-service kiosk's own "Create an account" toggle still controls whether that link is sent, since that one is the visitor's own choice rather than staff acting on their behalf.
|
||||||
|
|
||||||
|
## [1.9.2] - 2026-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Contact-only events (no registration possible) were still getting the daily summary email — it now skips them since there's nothing to summarize.
|
||||||
|
|
||||||
|
## [1.9.1] - 2026-08-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- The browser tab title and homepage "Welcome to..." heading always showed the app's built-in default name instead of the organisation name configured in Site Settings → Organisation.
|
||||||
|
- The backend's status page (`/`) and API docs page (`/docs`) always showed "Cross Code Events" instead of the configured organisation name.
|
||||||
|
|
||||||
|
## [1.9.0] - 2026-08-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Events now have an optional Location field (address), defaulting to the organisation's configured address when creating a new event. Wherever an address is shown — event admin form, public event page, event card listings, the Contact page, and Site Settings → Organisation — there's now a "Directions"/"View on map" link, and the event detail and Contact pages also show an embedded Google Maps view (no API key required).
|
||||||
|
|
||||||
## [1.8.0] - 2026-08-21
|
## [1.8.0] - 2026-08-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ JWT_SECRET=your_jwt_secret_here_minimum_32_characters
|
|||||||
PORT=5000
|
PORT=5000
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Set this if the app runs behind a reverse proxy (e.g. nginx on a separate
|
||||||
|
# server) so rate limiting reads the real client IP instead of the proxy's.
|
||||||
|
# Accepts a hop count ("1"), "true"/"false", or comma-separated IP(s)/CIDR(s)
|
||||||
|
# of your trusted proxy (e.g. "10.0.0.5" or "10.0.0.0/8"). Leave unset if the
|
||||||
|
# app is not behind a proxy.
|
||||||
|
# TRUST_PROXY=1
|
||||||
|
|
||||||
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
# ─── CORS ─────────────────────────────────────────────────────────────────────
|
||||||
# Comma-separated list of allowed frontend origins
|
# Comma-separated list of allowed frontend origins
|
||||||
FRONTEND_URL=http://localhost:3000
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ Create `backend/.env` from `.env.example`. The only variables you must set are:
|
|||||||
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
|
| `APP_BASE_URL` | — | Public frontend URL — used in email links (fallback if not set via admin panel) |
|
||||||
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
|
| `BACKEND_URL` | — | Public backend URL — used to serve ticket PDFs over WhatsApp |
|
||||||
| `PORT` | — | Port to listen on (default `3000`) |
|
| `PORT` | — | Port to listen on (default `3000`) |
|
||||||
|
| `TRUST_PROXY` | — | Set when running behind a reverse proxy (e.g. nginx on a separate server), so `req.ip`/`X-Forwarded-For` are read correctly by rate limiting. Accepts a hop count (`1`), `true`/`false`, or comma-separated trusted proxy IP(s)/CIDR(s). |
|
||||||
| `NODE_ENV` | — | `production` or `development` |
|
| `NODE_ENV` | — | `production` or `development` |
|
||||||
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
|
| `WAWP_ACCESS_TOKEN` | — | WAWP fallback token (preferred: set via Admin → Site Settings) |
|
||||||
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
|
| `WAWP_INSTANCE_ID` | — | WAWP fallback instance ID (preferred: set via Admin → Site Settings) |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "event-management-backend",
|
"name": "event-management-backend",
|
||||||
"version": "1.8.0",
|
"version": "1.9.4",
|
||||||
"description": "Event Management System Backend",
|
"description": "Event Management System Backend",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
|
||||||
@@ -105,6 +105,7 @@ model Event {
|
|||||||
contactName String?
|
contactName String?
|
||||||
contactPhone String?
|
contactPhone String?
|
||||||
contactEmail String?
|
contactEmail String?
|
||||||
|
location String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
createdById String?
|
createdById String?
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) {
|
|||||||
// @access Private/Admin
|
// @access Private/Admin
|
||||||
const createEvent = async (req, res) => {
|
const createEvent = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
|
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -62,6 +62,7 @@ const createEvent = async (req, res) => {
|
|||||||
contactName: contactName || null,
|
contactName: contactName || null,
|
||||||
contactPhone: contactPhone || null,
|
contactPhone: contactPhone || null,
|
||||||
contactEmail: contactEmail || null,
|
contactEmail: contactEmail || null,
|
||||||
|
location: location || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -459,7 +460,7 @@ const updateEvent = async (req, res) => {
|
|||||||
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
// totals — same rule already enforced for payments/costs. Admin can reopen first.
|
||||||
await assertEventOpen(req.params.id, res);
|
await assertEventOpen(req.params.id, res);
|
||||||
|
|
||||||
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail } = req.body;
|
const { title, description, startDate, endDate, registrationDeadline, goLiveAt, price, picture, isActive, redirectUrl, isHidden, requiresAuth, requiresRegistration, contactName, contactPhone, contactEmail, location } = req.body;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
title: title || event.title,
|
title: title || event.title,
|
||||||
@@ -477,6 +478,7 @@ const updateEvent = async (req, res) => {
|
|||||||
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
|
||||||
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
|
||||||
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
contactEmail: contactEmail !== undefined ? (contactEmail || null) : event.contactEmail,
|
||||||
|
location: location !== undefined ? (location || null) : event.location,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
const prisma = require('../config/db');
|
const prisma = require('../config/db');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const axios = require("axios");
|
|
||||||
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
||||||
const { emailTickets } = require('./ticketController');
|
const { emailTickets } = require('./ticketController');
|
||||||
const { hashPassword } = require('../config/auth');
|
const { hashPassword } = require('../config/auth');
|
||||||
@@ -760,7 +759,7 @@ const getRegistrationsByEvent = async (req, res) => {
|
|||||||
const createManualRegistration = async (req, res) => {
|
const createManualRegistration = async (req, res) => {
|
||||||
let userRecord;
|
let userRecord;
|
||||||
try {
|
try {
|
||||||
const { eventId, options, user, guestOnly, notificationPreference: prefFromBody } = req.body;
|
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice } = req.body;
|
||||||
|
|
||||||
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
||||||
res.status(400);
|
res.status(400);
|
||||||
@@ -844,7 +843,7 @@ const createManualRegistration = async (req, res) => {
|
|||||||
? prefFromBody
|
? prefFromBody
|
||||||
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
||||||
|
|
||||||
// Always search by email AND/OR phone regardless of guestOnly.
|
// Always search by email AND/OR phone.
|
||||||
// Resolve each channel independently (rather than a single findFirst with an OR
|
// Resolve each channel independently (rather than a single findFirst with an OR
|
||||||
// across both) so that an email belonging to one account and a phone number
|
// across both) so that an email belonging to one account and a phone number
|
||||||
// belonging to a *different* account can never be silently collapsed into
|
// belonging to a *different* account can never be silently collapsed into
|
||||||
@@ -907,42 +906,58 @@ const createManualRegistration = async (req, res) => {
|
|||||||
if (Object.keys(updateData).length > 0) {
|
if (Object.keys(updateData).length > 0) {
|
||||||
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
||||||
}
|
}
|
||||||
} else if (!guestOnly && hasValidEmail) {
|
|
||||||
// Create a real active account (non-guest with email)
|
|
||||||
try {
|
|
||||||
const password = 'Hope123';
|
|
||||||
const response = await axios.post(
|
|
||||||
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/users`,
|
|
||||||
{ name: user.name, email: user.email, password, phoneNumber: phone || null }
|
|
||||||
);
|
|
||||||
const createdUser = response.data.user || response.data;
|
|
||||||
if (!createdUser?.id) { res.status(400); throw new Error('User creation failed: No user ID returned'); }
|
|
||||||
userId = createdUser.id;
|
|
||||||
// Set derived preference on the new account
|
|
||||||
await prisma.user.update({ where: { id: userId }, data: { notificationPreference: derivedPref } }).catch(() => {});
|
|
||||||
} catch (userErr) {
|
|
||||||
res.status(400);
|
|
||||||
throw new Error(`Failed to create user: ${userErr.response?.data?.message || userErr.message}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// Guest path: phone-only, guestOnly=true, or no valid email
|
const suppliedPassword = typeof user.password === 'string' && user.password.trim().length >= 6
|
||||||
const placeholderEmail = hasValidEmail
|
? user.password.trim()
|
||||||
? user.email
|
: null;
|
||||||
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
|
||||||
const hashed = await hashPassword(uuidv4());
|
if (hasValidEmail && suppliedPassword) {
|
||||||
const created = await prisma.user.create({
|
// Caller supplied their own password (the self-service kiosk, where the
|
||||||
data: {
|
// visitor sets it themselves on the spot) — activate immediately, since
|
||||||
id: uuidv4(),
|
// there's nothing left for them to do via an activation link.
|
||||||
name: user.name,
|
const hashed = await hashPassword(suppliedPassword);
|
||||||
email: placeholderEmail,
|
const created = await prisma.user.create({
|
||||||
password: hashed,
|
data: {
|
||||||
phoneNumber: phone || null,
|
id: uuidv4(),
|
||||||
isActive: false,
|
name: user.name,
|
||||||
notificationPreference: derivedPref,
|
email: user.email,
|
||||||
updatedAt: new Date(),
|
password: hashed,
|
||||||
|
phoneNumber: phone || null,
|
||||||
|
isActive: true,
|
||||||
|
notificationPreference: derivedPref,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userId = created.id;
|
||||||
|
} else {
|
||||||
|
// New account: uses the real email if a valid one was given, otherwise a
|
||||||
|
// guest.local placeholder (phone-only registration). Always created inactive
|
||||||
|
// with a random password — the visitor activates it themselves via the link
|
||||||
|
// sent immediately below (email or WhatsApp), unless the caller explicitly
|
||||||
|
// opted out of that nudge (e.g. a self-service visitor who declined to
|
||||||
|
// create an account at all).
|
||||||
|
const placeholderEmail = hasValidEmail
|
||||||
|
? user.email
|
||||||
|
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
||||||
|
const hashed = await hashPassword(uuidv4());
|
||||||
|
const created = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id: uuidv4(),
|
||||||
|
name: user.name,
|
||||||
|
email: placeholderEmail,
|
||||||
|
password: hashed,
|
||||||
|
phoneNumber: phone || null,
|
||||||
|
isActive: false,
|
||||||
|
notificationPreference: derivedPref,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
userId = created.id;
|
||||||
|
if (!skipActivationNotice) {
|
||||||
|
const { sendActivationLink } = require('./userController');
|
||||||
|
sendActivationLink(created);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
userId = created.id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge into existing non-cancelled registration if one exists, otherwise create new
|
// Merge into existing non-cancelled registration if one exists, otherwise create new
|
||||||
|
|||||||
@@ -23,6 +23,53 @@ function getClientIp(req) {
|
|||||||
|
|
||||||
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
const PRIVATE_IP_RE = /^(::1|::ffff:127\.|127\.|10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.)/;
|
||||||
|
|
||||||
|
// Fire-and-forget: create a 24h activation token and deliver it to an inactive
|
||||||
|
// account — via email if it has a real (non-guest) address, otherwise via
|
||||||
|
// WhatsApp if it has a phone number. Used both when a login attempt hits an
|
||||||
|
// inactive account, and immediately when an admin/supervisor creates an
|
||||||
|
// account on someone's behalf (walk-in / manual registration).
|
||||||
|
async function sendActivationLink(user) {
|
||||||
|
const hasRealEmail = !!(user?.email && !user.email.endsWith('@guest.local'));
|
||||||
|
if (!hasRealEmail && !user?.phoneNumber) return;
|
||||||
|
try {
|
||||||
|
const token = uuidv4();
|
||||||
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
||||||
|
await prisma.passwordReset.updateMany({
|
||||||
|
where: { userId: user.id, used: false },
|
||||||
|
data: { used: true }
|
||||||
|
});
|
||||||
|
await prisma.passwordReset.create({
|
||||||
|
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
||||||
|
});
|
||||||
|
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
||||||
|
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
|
if (hasRealEmail) {
|
||||||
|
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
||||||
|
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
||||||
|
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
||||||
|
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
||||||
|
} else {
|
||||||
|
const orgName = getOrgName();
|
||||||
|
const waMessage = [
|
||||||
|
`🔓 *Activate your ${orgName} account*`,
|
||||||
|
'',
|
||||||
|
`Hi ${user.name || 'there'},`,
|
||||||
|
'',
|
||||||
|
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
||||||
|
'',
|
||||||
|
activationUrl,
|
||||||
|
'',
|
||||||
|
`_This link expires in 24 hours._`,
|
||||||
|
].join('\n');
|
||||||
|
const { waTextAny } = require('../utils/notify');
|
||||||
|
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fire-and-forget: send a login notification email with approximate geo location
|
// Fire-and-forget: send a login notification email with approximate geo location
|
||||||
async function sendLoginNotification(user, req) {
|
async function sendLoginNotification(user, req) {
|
||||||
try {
|
try {
|
||||||
@@ -176,61 +223,16 @@ const loginUser = async (req, res) => {
|
|||||||
|
|
||||||
// Check if user is active
|
// Check if user is active
|
||||||
if (!user.isActive) {
|
if (!user.isActive) {
|
||||||
// If the account has a real email (not a guest placeholder), send an activation link via email
|
// Resend the activation link on each failed login attempt against an inactive
|
||||||
|
// account, in case the original one (sent at creation, or a prior attempt) expired.
|
||||||
|
await sendActivationLink(user);
|
||||||
|
// If the account has a real email (not a guest placeholder), it went out via email
|
||||||
if (user.email && !user.email.endsWith('@guest.local')) {
|
if (user.email && !user.email.endsWith('@guest.local')) {
|
||||||
try {
|
|
||||||
const token = uuidv4();
|
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
||||||
await prisma.passwordReset.updateMany({
|
|
||||||
where: { userId: user.id, used: false },
|
|
||||||
data: { used: true }
|
|
||||||
});
|
|
||||||
await prisma.passwordReset.create({
|
|
||||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
|
||||||
});
|
|
||||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
|
||||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
|
||||||
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
|
|
||||||
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
|
|
||||||
sendMail({ to: user.email, subject: `Activate your ${getOrgName()} account`, ...content })
|
|
||||||
.catch(e => console.warn('[activation email] Failed:', e?.message || e));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[activation token] Failed to create activation token:', e?.message || e);
|
|
||||||
}
|
|
||||||
res.status(401);
|
res.status(401);
|
||||||
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
throw new Error('Your account is not yet active. We\'ve sent you an email with a link to activate your account.');
|
||||||
}
|
}
|
||||||
// No real email — if they have a phone number, send the activation link via WhatsApp
|
// No real email — if they have a phone number, it went out via WhatsApp
|
||||||
if (user.phoneNumber) {
|
if (user.phoneNumber) {
|
||||||
try {
|
|
||||||
const token = uuidv4();
|
|
||||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24h
|
|
||||||
await prisma.passwordReset.updateMany({
|
|
||||||
where: { userId: user.id, used: false },
|
|
||||||
data: { used: true }
|
|
||||||
});
|
|
||||||
await prisma.passwordReset.create({
|
|
||||||
data: { id: uuidv4(), userId: user.id, token, expiresAt, used: false }
|
|
||||||
});
|
|
||||||
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
|
||||||
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
|
|
||||||
const orgName = getOrgName();
|
|
||||||
const waMessage = [
|
|
||||||
`🔓 *Activate your ${orgName} account*`,
|
|
||||||
'',
|
|
||||||
`Hi ${user.name || 'there'},`,
|
|
||||||
'',
|
|
||||||
`Your account needs to be activated before you can log in. Tap the link below to set a password and activate your account:`,
|
|
||||||
'',
|
|
||||||
activationUrl,
|
|
||||||
'',
|
|
||||||
`_This link expires in 24 hours._`,
|
|
||||||
].join('\n');
|
|
||||||
const { waTextAny } = require('../utils/notify');
|
|
||||||
waTextAny(user, waMessage).catch(e => console.warn('[activation WA] Failed:', e?.message || e));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[activation token WA] Failed to create activation token:', e?.message || e);
|
|
||||||
}
|
|
||||||
res.status(401);
|
res.status(401);
|
||||||
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
throw new Error('Your account is not yet active. We\'ve sent you a WhatsApp message with a link to activate your account.');
|
||||||
}
|
}
|
||||||
@@ -970,4 +972,5 @@ module.exports = {
|
|||||||
adminRevokeUserSessions,
|
adminRevokeUserSessions,
|
||||||
closeAccount,
|
closeAccount,
|
||||||
getMyActivity,
|
getMyActivity,
|
||||||
|
sendActivationLink,
|
||||||
};
|
};
|
||||||
+30
-10
@@ -18,6 +18,22 @@ const prisma = new PrismaClient();
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
// Trust proxy — required when running behind a reverse proxy (e.g. nginx on a
|
||||||
|
// separate server) so req.ip / X-Forwarded-For are read correctly by
|
||||||
|
// express-rate-limit and friends. Accepts a hop count ("1"), "true"/"false",
|
||||||
|
// or a comma-separated list of trusted proxy IPs/CIDRs.
|
||||||
|
if (process.env.TRUST_PROXY) {
|
||||||
|
const raw = process.env.TRUST_PROXY.trim();
|
||||||
|
let trustProxyValue;
|
||||||
|
if (raw === 'true') trustProxyValue = true;
|
||||||
|
else if (raw === 'false') trustProxyValue = false;
|
||||||
|
else if (/^\d+$/.test(raw)) trustProxyValue = parseInt(raw, 10);
|
||||||
|
else if (raw.includes(',')) trustProxyValue = raw.split(',').map((s) => s.trim());
|
||||||
|
else trustProxyValue = raw;
|
||||||
|
app.set('trust proxy', trustProxyValue);
|
||||||
|
console.log(`[startup] trust proxy set to: ${JSON.stringify(trustProxyValue)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// CORS — allow only the configured frontend origin
|
// CORS — allow only the configured frontend origin
|
||||||
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
const allowedOrigins = (process.env.FRONTEND_URL || 'http://localhost:3000')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -141,7 +157,8 @@ app.use('/api', costRoutes);
|
|||||||
app.use('/api/cashups', cashupRoutes);
|
app.use('/api/cashups', cashupRoutes);
|
||||||
|
|
||||||
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
// Pre-warm the settings cache so synchronous helpers have DB values from startup
|
||||||
require('./utils/settingsCache').warmCache().catch(() => {});
|
const { getSettingSync, warmCache } = require('./utils/settingsCache');
|
||||||
|
warmCache().catch(() => {});
|
||||||
app.use('/uploads', express.static('public/uploads'));
|
app.use('/uploads', express.static('public/uploads'));
|
||||||
|
|
||||||
// ── Shared page helpers ────────────────────────────────────────────────────────
|
// ── Shared page helpers ────────────────────────────────────────────────────────
|
||||||
@@ -244,8 +261,9 @@ app.get('/', async (req, res) => {
|
|||||||
? `<span class="badge badge-warn">testing</span>`
|
? `<span class="badge badge-warn">testing</span>`
|
||||||
: `<span class="badge badge-warn">development</span>`;
|
: `<span class="badge badge-warn">development</span>`;
|
||||||
|
|
||||||
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
|
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||||
<h1>Cross Code Events API</h1>
|
const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
|
||||||
|
<h1>${orgName} Events API</h1>
|
||||||
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
<p class="subtitle">v${API_VERSION} — ${now}</p>
|
||||||
|
|
||||||
<div class="stat-grid">
|
<div class="stat-grid">
|
||||||
@@ -1030,13 +1048,14 @@ app.get('/docs', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
|
const notificationsHtml = NOTIFICATIONS.map(renderNotificationCategory).join('');
|
||||||
|
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Cross Code Events — API Docs</title>
|
<title>${orgName} Events — API Docs</title>
|
||||||
<style>
|
<style>
|
||||||
*{box-sizing:border-box;margin:0;padding:0}
|
*{box-sizing:border-box;margin:0;padding:0}
|
||||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:#f3f4f6;color:#1f2937;min-height:100vh;padding:24px 16px}
|
||||||
@@ -1060,7 +1079,7 @@ app.get('/docs', async (req, res) => {
|
|||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
<div style="display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px;margin-bottom:6px">
|
||||||
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">Cross Code Events — API Reference</h1>
|
<h1 style="font-size:1.4rem;font-weight:700;color:#111827">${orgName} Events — API Reference</h1>
|
||||||
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
<a href="/" style="font-size:.82rem;color:#6b7280">← Status page</a>
|
||||||
</div>
|
</div>
|
||||||
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
|
||||||
@@ -1087,7 +1106,7 @@ app.get('/docs', async (req, res) => {
|
|||||||
${notificationsHtml}
|
${notificationsHtml}
|
||||||
|
|
||||||
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
|
||||||
Cross Code Events API v${API_VERSION} — ${new Date().toISOString()}
|
${orgName} Events API v${API_VERSION} — ${new Date().toISOString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
@@ -1262,11 +1281,12 @@ app.listen(PORT, () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle unhandled promise rejections
|
// Log unhandled promise rejections without killing the server, since a single
|
||||||
|
// missed .catch() on fire-and-forget notification code (email/WhatsApp sends)
|
||||||
|
// would otherwise take the whole app down.
|
||||||
process.on('unhandledRejection', (err) => {
|
process.on('unhandledRejection', (err) => {
|
||||||
console.log('UNHANDLED REJECTION! Shutting down...');
|
console.error('UNHANDLED REJECTION!', err?.name, err?.message);
|
||||||
console.log(err.name, err.message);
|
console.error(err?.stack || err);
|
||||||
process.exit(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { app, prisma };
|
module.exports = { app, prisma };
|
||||||
@@ -1370,14 +1370,14 @@ async function sendDailyEventSummaries(now = new Date()) {
|
|||||||
const today = new Date(now);
|
const today = new Date(now);
|
||||||
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
const notifyInclude = { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } };
|
||||||
let events = await prisma.event.findMany({
|
let events = await prisma.event.findMany({
|
||||||
where: { isActive: true, startDate: { gte: today } },
|
where: { isActive: true, startDate: { gte: today }, requiresRegistration: true },
|
||||||
include: notifyInclude,
|
include: notifyInclude,
|
||||||
orderBy: { startDate: 'asc' },
|
orderBy: { startDate: 'asc' },
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
events = await prisma.event.findMany({
|
events = await prisma.event.findMany({
|
||||||
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today } },
|
where: { isActive: true, startDate: { gte: today }, goLiveAt: { lte: today }, requiresRegistration: true },
|
||||||
include: notifyInclude,
|
include: notifyInclude,
|
||||||
orderBy: { startDate: 'asc' },
|
orderBy: { startDate: 'asc' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'hope-events-backend',
|
||||||
|
cwd: __dirname + '/backend',
|
||||||
|
script: 'src/index.js',
|
||||||
|
env: { NODE_ENV: 'production' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hope-events-frontend',
|
||||||
|
cwd: __dirname + '/frontend',
|
||||||
|
script: 'npm',
|
||||||
|
args: 'start -- -p 3001',
|
||||||
|
env: { NODE_ENV: 'production' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events-frontend",
|
"name": "hope-events-frontend",
|
||||||
"version": "1.8.0",
|
"version": "1.9.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
import { appName } from "@/lib/siteConfig";
|
import { appName } from "@/lib/siteConfig";
|
||||||
|
import { LocationMap } from "@/components/events/LocationMap";
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
const { settings, loading } = useSiteSettings();
|
const { settings, loading } = useSiteSettings();
|
||||||
@@ -68,6 +69,12 @@ export default function ContactPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{address && (
|
||||||
|
<div className="mt-8 border rounded-xl p-5 bg-white shadow-sm">
|
||||||
|
<LocationMap address={address} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon }
|
|||||||
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||||
import { extractDominantColors } from "@/lib/extractColors";
|
import { extractDominantColors } from "@/lib/extractColors";
|
||||||
|
import { mapsSearchUrl } from "@/lib/maps";
|
||||||
|
|
||||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
|
||||||
|
|
||||||
@@ -410,9 +411,15 @@ function SiteSettingsPageInner() {
|
|||||||
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
value={orgPhone} onChange={e => setOrgPhone(e.target.value)} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<Field label="Address">
|
<Field label="Address" hint="Used as the default location for new events.">
|
||||||
<input className={inputCls} placeholder="123 Church St, City"
|
<input className={inputCls} placeholder="123 Church St, City"
|
||||||
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
value={orgAddress} onChange={e => setOrgAddress(e.target.value)} />
|
||||||
|
{orgAddress.trim() && (
|
||||||
|
<a href={mapsSearchUrl(orgAddress.trim())} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="inline-block text-xs text-brand-600 hover:underline mt-1">
|
||||||
|
View on map ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
<Field label="Site URL" hint="The public URL of this site — used in email links (e.g. password reset, ticket delivery). e.g. https://events.yourchurch.org">
|
||||||
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
<input className={inputCls} placeholder="https://events.yourchurch.org"
|
||||||
|
|||||||
@@ -242,7 +242,6 @@ export default function AtTheDoorPage() {
|
|||||||
authToken: token,
|
authToken: token,
|
||||||
body: {
|
body: {
|
||||||
eventId,
|
eventId,
|
||||||
guestOnly: pendingUser.guestOnly,
|
|
||||||
user: {
|
user: {
|
||||||
name: pendingUser.name,
|
name: pendingUser.name,
|
||||||
...(pendingUser.email ? { email: pendingUser.email } : {}),
|
...(pendingUser.email ? { email: pendingUser.email } : {}),
|
||||||
@@ -282,7 +281,7 @@ export default function AtTheDoorPage() {
|
|||||||
});
|
});
|
||||||
setQuantities(qtyMap);
|
setQuantities(qtyMap);
|
||||||
setMinQuantities({});
|
setMinQuantities({});
|
||||||
setPendingUser({ guestOnly: true, name, email: email || null, phone: phone || null, notifPref });
|
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
|
||||||
setPendingEditReg(null);
|
setPendingEditReg(null);
|
||||||
setShowNewAttendeeModal(false);
|
setShowNewAttendeeModal(false);
|
||||||
setShowOptionsModal(true);
|
setShowOptionsModal(true);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
|
||||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||||
|
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||||
import { Calendar } from "lucide-react";
|
import { Calendar } from "lucide-react";
|
||||||
|
|
||||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||||
@@ -756,13 +757,15 @@ interface EventDraft {
|
|||||||
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
|
||||||
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
|
||||||
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
|
||||||
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankDraft = (): EventDraft => ({
|
const blankDraft = (location = ""): EventDraft => ({
|
||||||
title: "", description: "", startDate: "", endDate: "",
|
title: "", description: "", startDate: "", endDate: "",
|
||||||
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
|
||||||
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
|
||||||
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
|
||||||
|
location,
|
||||||
});
|
});
|
||||||
|
|
||||||
const blankOptions = (): OptionDraft[] => [
|
const blankOptions = (): OptionDraft[] => [
|
||||||
@@ -781,12 +784,15 @@ interface EventModalProps {
|
|||||||
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
||||||
const { token, user } = useAuth();
|
const { token, user } = useAuth();
|
||||||
const isAdmin = user?.role === "admin";
|
const isAdmin = user?.role === "admin";
|
||||||
|
const { settings } = useSiteSettings();
|
||||||
const [step, setStep] = useState<StepIdx>(0);
|
const [step, setStep] = useState<StepIdx>(0);
|
||||||
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
const [pricingSubstep, setPricingSubstep] = useState<PricingSubstep>(0);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// ── event draft ──
|
// ── event draft ──
|
||||||
|
// New events default their location to the organisation's address (settings.org_address);
|
||||||
|
// editing an existing event always reflects its own saved location instead.
|
||||||
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
|
const [draft, setDraft] = useState<EventDraft>(() => ev ? {
|
||||||
title: ev.title || "", description: ev.description || "",
|
title: ev.title || "", description: ev.description || "",
|
||||||
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
|
startDate: toLocalDT(ev.startDate), endDate: toLocalDT(ev.endDate),
|
||||||
@@ -795,7 +801,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
|||||||
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
|
||||||
requiresRegistration: ev.requiresRegistration !== false,
|
requiresRegistration: ev.requiresRegistration !== false,
|
||||||
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
|
||||||
} : blankDraft());
|
location: ev.location || "",
|
||||||
|
} : blankDraft(settings.org_address || ""));
|
||||||
|
|
||||||
// ── options (with per-variant tiers) ──
|
// ── options (with per-variant tiers) ──
|
||||||
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
const [options, setOptions] = useState<OptionDraft[]>(() => {
|
||||||
@@ -1065,6 +1072,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
|||||||
contactName: draft.contactName || undefined,
|
contactName: draft.contactName || undefined,
|
||||||
contactPhone: draft.contactPhone || undefined,
|
contactPhone: draft.contactPhone || undefined,
|
||||||
contactEmail: draft.contactEmail || undefined,
|
contactEmail: draft.contactEmail || undefined,
|
||||||
|
location: draft.location.trim(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (mode === "edit") {
|
if (mode === "edit") {
|
||||||
@@ -1209,6 +1217,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
|
|||||||
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
|
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
|
||||||
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-600 mb-1">Location</label>
|
||||||
|
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.location} onChange={e => upd({ location: e.target.value })} placeholder="e.g. 123 Church St, City" />
|
||||||
|
<p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation's address — shown to attendees with a map link.</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export default function ManualRegistrationPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [phoneNumber, setPhoneNumber] = useState("");
|
const [phoneNumber, setPhoneNumber] = useState("");
|
||||||
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useDismissingState<string | null>(null);
|
const [error, setError] = useDismissingState<string | null>(null);
|
||||||
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
const [createdReg, setCreatedReg] = useState<any | null>(null);
|
||||||
@@ -43,7 +42,6 @@ export default function ManualRegistrationPage() {
|
|||||||
eventId,
|
eventId,
|
||||||
options: [{ eventOptionId: optionId, quantity }],
|
options: [{ eventOptionId: optionId, quantity }],
|
||||||
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
user: { name, ...(email ? { email } : {}), ...(phoneNumber ? { phoneNumber } : {}) },
|
||||||
guestOnly: registerAsGuest,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setCreatedReg(res);
|
setCreatedReg(res);
|
||||||
@@ -89,10 +87,7 @@ export default function ManualRegistrationPage() {
|
|||||||
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
|
<input className="w-full border rounded px-3 py-2" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between">
|
<label className="block text-sm font-medium">Email</label>
|
||||||
<label className="block text-sm font-medium">Email</label>
|
|
||||||
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={registerAsGuest} onChange={e=>setRegisterAsGuest(e.target.checked)} /> Guest (no account)</label>
|
|
||||||
</div>
|
|
||||||
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
|
<input type="email" className="w-full border rounded px-3 py-2" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email@example.com" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +95,7 @@ export default function ManualRegistrationPage() {
|
|||||||
<label className="block text-sm font-medium">Cell Number</label>
|
<label className="block text-sm font-medium">Cell Number</label>
|
||||||
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
|
<input type="tel" className="w-full border rounded px-3 py-2" value={phoneNumber} onChange={(e) => setPhoneNumber(e.target.value)} placeholder="+27…" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
|
<p className="text-xs text-gray-500">At least one of email or cell number is required. The account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so the attendee can set their own password.</p>
|
||||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
|
||||||
{busy ? "Submitting..." : "Create"}
|
{busy ? "Submitting..." : "Create"}
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ export default function ManualRegistrationPage() {
|
|||||||
const [allUsers, setAllUsers] = useState<any[]>([]);
|
const [allUsers, setAllUsers] = useState<any[]>([]);
|
||||||
|
|
||||||
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
const [guest, setGuest] = useState({ name: "", email: "", phoneNumber: "" });
|
||||||
const [registerAsGuest, setRegisterAsGuest] = useState(false);
|
|
||||||
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
const [notifPref, setNotifPref] = useState<"email" | "whatsapp" | "both">("email");
|
||||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
@@ -287,7 +286,7 @@ export default function ManualRegistrationPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
if (!selectedEventId) { setError("Please select an event."); return; }
|
if (!selectedEventId) { setError("Please select an event."); return; }
|
||||||
if (!guest.name || (!registerAsGuest && !guest.email)) { setError("Guest name and email are required."); return; }
|
if (!guest.name || (!guest.email.trim() && !guest.phoneNumber.trim())) { setError("Guest name and at least one of email or phone are required."); return; }
|
||||||
const opts = Object.entries(quantities)
|
const opts = Object.entries(quantities)
|
||||||
.filter(([, qty]) => qty > 0)
|
.filter(([, qty]) => qty > 0)
|
||||||
.map(([key, quantity]) => {
|
.map(([key, quantity]) => {
|
||||||
@@ -308,7 +307,6 @@ export default function ManualRegistrationPage() {
|
|||||||
eventId: selectedEventId,
|
eventId: selectedEventId,
|
||||||
options: opts,
|
options: opts,
|
||||||
user: guest,
|
user: guest,
|
||||||
guestOnly: registerAsGuest,
|
|
||||||
notificationPreference: resolvedPref,
|
notificationPreference: resolvedPref,
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -324,7 +322,6 @@ export default function ManualRegistrationPage() {
|
|||||||
|
|
||||||
// Reset guest/ticket fields so the next registration starts from a clean slate
|
// Reset guest/ticket fields so the next registration starts from a clean slate
|
||||||
setGuest({ name: "", email: "", phoneNumber: "" });
|
setGuest({ name: "", email: "", phoneNumber: "" });
|
||||||
setRegisterAsGuest(false);
|
|
||||||
setNotifPref("email");
|
setNotifPref("email");
|
||||||
setUserQuery("");
|
setUserQuery("");
|
||||||
setDropdownOpen(false);
|
setDropdownOpen(false);
|
||||||
@@ -442,13 +439,6 @@ export default function ManualRegistrationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-3 mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input id="registerAsGuest" type="checkbox" checked={registerAsGuest} onChange={e => setRegisterAsGuest(e.target.checked)} />
|
|
||||||
<label htmlFor="registerAsGuest" className="text-sm text-gray-700">Guest (do not link to an existing account)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<input
|
<input
|
||||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
@@ -458,11 +448,10 @@ export default function ManualRegistrationPage() {
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
|
placeholder="Email (or provide a phone number below)"
|
||||||
type="email"
|
type="email"
|
||||||
value={guest.email}
|
value={guest.email}
|
||||||
onChange={e => setGuest({ ...guest, email: e.target.value })}
|
onChange={e => setGuest({ ...guest, email: e.target.value })}
|
||||||
required={!registerAsGuest}
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import ClientActions from "@/app/events/[id]/ClientActions";
|
import ClientActions from "@/app/events/[id]/ClientActions";
|
||||||
import { ContactButton } from "@/components/events/ContactButton";
|
import { ContactButton } from "@/components/events/ContactButton";
|
||||||
|
import { LocationMap } from "@/components/events/LocationMap";
|
||||||
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
export const revalidate = 60;
|
export const revalidate = 60;
|
||||||
@@ -37,6 +38,7 @@ type Event = {
|
|||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
contactPhone?: string | null;
|
contactPhone?: string | null;
|
||||||
contactEmail?: string | null;
|
contactEmail?: string | null;
|
||||||
|
location?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
import { apiFetch, ApiError } from "@/lib/api";
|
import { apiFetch, ApiError } from "@/lib/api";
|
||||||
@@ -142,6 +144,13 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
|
||||||
|
|
||||||
|
{event.location && (
|
||||||
|
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900 mb-3">Location</h2>
|
||||||
|
<LocationMap address={event.location} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{event.attachments && event.attachments.length > 0 && (
|
{event.attachments && event.attachments.length > 0 && (
|
||||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
|||||||
@@ -44,10 +44,11 @@ async function getServerSettings(): Promise<SiteSettings> {
|
|||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const settings = await getServerSettings();
|
const settings = await getServerSettings();
|
||||||
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
|
||||||
|
const displayName = settings.org_name || appName;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: appName,
|
title: displayName,
|
||||||
description: `Manage and register for events with ${appName}`,
|
description: `Manage and register for events with ${displayName}`,
|
||||||
icons: {
|
icons: {
|
||||||
icon: faviconUrl || "/favicon.ico",
|
icon: faviconUrl || "/favicon.ico",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Navbar } from "@/components/layout/Navbar";
|
|||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
import { JoinUsButton } from "@/components/home/JoinUsButton";
|
||||||
import { appName } from "@/lib/siteConfig";
|
import { appName } from "@/lib/siteConfig";
|
||||||
|
import { API_BASE } from "@/lib/api";
|
||||||
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
|
||||||
|
|
||||||
type Event = {
|
type Event = {
|
||||||
@@ -26,8 +27,22 @@ const FEATURES = [
|
|||||||
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
|
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
async function getOrgName(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
|
||||||
|
if (!res.ok) return appName;
|
||||||
|
const settings = await res.json();
|
||||||
|
return settings?.org_name || appName;
|
||||||
|
} catch {
|
||||||
|
return appName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
|
const [events, displayName] = await Promise.all([
|
||||||
|
apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } }),
|
||||||
|
getOrgName(),
|
||||||
|
]);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const upcoming = (events || []).filter(e => {
|
const upcoming = (events || []).filter(e => {
|
||||||
const t = new Date(e.startDate).getTime();
|
const t = new Date(e.startDate).getTime();
|
||||||
@@ -52,7 +67,7 @@ export default async function HomePage() {
|
|||||||
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
|
<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>
|
<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">
|
<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">
|
<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">
|
||||||
|
|||||||
@@ -386,7 +386,9 @@ export default function SelfServicePage() {
|
|||||||
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
...(visitorEmail.trim() ? { email: visitorEmail.trim() } : {}),
|
||||||
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
...(visitorPhone.trim() ? { phoneNumber: visitorPhone.trim() } : {}),
|
||||||
},
|
},
|
||||||
guestOnly: !createAccount,
|
// If the visitor declined to create an account, don't push an unsolicited
|
||||||
|
// activation email/WhatsApp at them afterward.
|
||||||
|
skipActivationNotice: !createAccount,
|
||||||
notificationPreference: notificationPref,
|
notificationPreference: notificationPref,
|
||||||
};
|
};
|
||||||
if (createAccount && visitorPassword) {
|
if (createAccount && visitorPassword) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiImage } from "@/components/shared/ApiImage";
|
import { ApiImage } from "@/components/shared/ApiImage";
|
||||||
import { Calendar } from "lucide-react";
|
import { Calendar, MapPin } from "lucide-react";
|
||||||
import { ContactButton } from "@/components/events/ContactButton";
|
import { ContactButton } from "@/components/events/ContactButton";
|
||||||
|
|
||||||
type Event = {
|
type Event = {
|
||||||
@@ -17,6 +17,7 @@ import { ContactButton } from "@/components/events/ContactButton";
|
|||||||
contactName?: string | null;
|
contactName?: string | null;
|
||||||
contactPhone?: string | null;
|
contactPhone?: string | null;
|
||||||
contactEmail?: string | null;
|
contactEmail?: string | null;
|
||||||
|
location?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
import { formatDateTimeRange } from "@/lib/date";
|
import { formatDateTimeRange } from "@/lib/date";
|
||||||
@@ -40,6 +41,12 @@ export const EventCard = ({ event }: { event: Event }) => {
|
|||||||
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
<Calendar className="w-3.5 h-3.5 shrink-0" />
|
||||||
{dateRange}
|
{dateRange}
|
||||||
</p>
|
</p>
|
||||||
|
{event.location && (
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
<span className="truncate">{event.location}</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
|
<p className="text-sm text-gray-600 mt-2 line-clamp-2">{event.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { MapPin, ExternalLink } from "lucide-react";
|
||||||
|
import { mapsSearchUrl, mapsEmbedUrl } from "@/lib/maps";
|
||||||
|
|
||||||
|
export function LocationMap({ address, className }: { address?: string | null; className?: string }) {
|
||||||
|
if (!address) return null;
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<p className="text-sm text-gray-700 flex items-start gap-1.5">
|
||||||
|
<MapPin className="w-4 h-4 shrink-0 mt-0.5 text-gray-400" />
|
||||||
|
<span>{address}</span>
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={mapsSearchUrl(address)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-xs text-brand-600 hover:underline flex items-center gap-1 shrink-0 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
Directions <ExternalLink className="w-3 h-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 rounded-lg overflow-hidden border">
|
||||||
|
<iframe
|
||||||
|
title={`Map showing ${address}`}
|
||||||
|
src={mapsEmbedUrl(address)}
|
||||||
|
className="w-full h-48 border-0"
|
||||||
|
loading="lazy"
|
||||||
|
referrerPolicy="no-referrer-when-downgrade"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ export const supervisorManualHelpContent: HelpContent = {
|
|||||||
icon: UserPlus,
|
icon: UserPlus,
|
||||||
content: (
|
content: (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p>Pick an event, then either search for an existing user or enter guest details (name, email, phone) and tick "Guest" to skip linking an account. Choose ticket options and quantities — early-bird pricing is applied automatically — then create the registration.</p>
|
<p>Pick an event, then either search for an existing user or enter guest details (name, and at least one of email or phone). Choose ticket options and quantities — early-bird pricing is applied automatically — then create the registration. A new guest account is created inactive, and an activation link is sent immediately (by email if provided, otherwise WhatsApp) so they can set their own password.</p>
|
||||||
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
|
<p className="text-xs text-gray-500">After creating a registration, the Record Payment tab is pre-filled with it — switch tabs to take payment right away.</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Opens Google Maps with the address pre-filled — no API key required. */
|
||||||
|
export function mapsSearchUrl(address: string): string {
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(address)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Embeddable Google Maps iframe src for the given address — no API key required. */
|
||||||
|
export function mapsEmbedUrl(address: string): string {
|
||||||
|
return `https://maps.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
|
||||||
|
}
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hope-events",
|
"name": "hope-events",
|
||||||
"version": "1.8.0",
|
"version": "1.9.4",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev:backend": "cd backend && npm run dev",
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
|||||||
Reference in New Issue
Block a user