Compare commits

..
Author SHA1 Message Date
joshuaandClaude Sonnet 5 7eed7a01df Bump version to 1.9.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:24:07 +02:00
joshua a7be2baab2 Merge branch 'fix/contact-only-daily-summary' into main 2026-08-22 21:23:24 +02:00
joshuaandClaude Sonnet 5 5a9137e252 Skip daily summary emails for contact-only events
Contact-only events have no registration flow, so their daily
summary (registration/payment stats) was always empty — exclude
them from the query instead of sending a pointless email.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:22:53 +02:00
joshuaandClaude Sonnet 5 4e63a78e8e Bump version to 1.9.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:12:37 +02:00
joshua 2a067a3687 Merge branch 'fix/org-name-branding' into main 2026-08-22 21:11:52 +02:00
joshuaandClaude Sonnet 5 bdae0c6b08 Fix org name not showing in tab title, homepage, and backend pages
The browser tab title, homepage "Welcome to..." heading, and the
backend status/API docs pages all had the "Cross Code" default
hardcoded instead of reading the configured org_name setting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 21:09:35 +02:00
joshuaandClaude Sonnet 5 c84cc257d0 Bump version to 1.9.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 11:51:27 +02:00
joshua 7cbb147b00 Merge branch 'feature/event-location-maps' into main 2026-08-21 11:50:31 +02:00
joshua b75be18a87 Add event/organisation location with Google Maps links
Events and the organisation profile now have an address, with a
"Directions" link and an embedded Google Maps view (no API key
required) shown on event pages, event cards, and the Contact page.
New events default their location to the org's configured address.
2026-08-21 11:47:23 +02:00
joshua 05840541c2 Merge branch 'feature/early-bird-tranches-and-contact-events' into main 2026-08-21 10:33:19 +02:00
joshuaandClaude Sonnet 5 96aa9b10cc Bump version to 1.8.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:32:17 +02:00
joshuaandClaude Sonnet 5 bc64069021 Add PDF invoices/receipts with manual send, fix early-bird tier edit data loss
- Registration confirmations attach an invoice PDF (itemized breakdown,
  early-bird discount, balance due, Yoco pay-now link/QR) whenever a
  balance is outstanding; payment/donation confirmations attach a
  payment receipt PDF. Sent as an email attachment and, over WhatsApp,
  as the PDF itself with the existing message as its caption.
- Users can also (re)send either document on demand: an "Invoice"
  button on the registration detail popup, and a "Receipt" button next
  to each payment there and on the Payment history page, each opening
  an Email/WhatsApp choice popup, via two new endpoints restricted to
  the registration/payment's own owner.
- Fix: editing an event option's early-bird tiers deleted and
  recreated every tier for that option with brand-new ids, silently
  severing the appliedTierId link on all historical purchases (losing
  early-bird attribution and undercounting stock-limit usage) even for
  tiers the admin didn't touch. Tiers are now upserted by id.
- Update the "My Events" help content and the API docs index for the
  new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 10:26:49 +02:00
joshuaandClaude Sonnet 5 e9cb238ce1 Fix early-bird total in notifications; group dashboard registration items
Registration/payment/reminder emails and WhatsApp messages loaded
registrations without their price tranches, so any line spanning more
than one tranche fell back to charging the full quantity at the most
recent tranche's price, silently dropping the early-bird discount from
the outstanding balance and itemized amounts shown to the user.

The user dashboard's registration detail popup also listed one raw
line per tranche; it now merges same item/price/tier lines and groups
early-bird lines separately from standard-price ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 08:57:29 +02:00
joshuaandClaude Sonnet 5 f0f8d4c242 Fix early-bird price blending and mislabeling; add contact-only events
- Early-bird pricing: RegistrationOption now tracks each purchase as a
  separate price tranche instead of overwriting a single price/quantity
  on repeat purchases, so buying more tickets after a tier expires no
  longer re-prices tickets already bought at the old price. Stock-limit
  checks, total-due calculation, and the Finance report's revenue-by-
  option are all tranche-aware; pages that showed one blended price per
  line now render/total each tranche. Viewing a pending/partially-paid
  registration (dashboard, detail page, or an event's registration
  list) now refreshes stale pricing on the spot instead of only at
  payment time.
- Fixed the "(early bird)" dashboard label incorrectly firing on any
  line priced below the base option price (e.g. a plain cheaper
  variant) — it now checks the real applied-tier flag.
- Added contact-only events (e.g. baptism): no registration/payment
  flow, shown on the public site with a "Contact us" popup instead of
  a Register button. Configurable via the admin event wizard.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:26:35 +02:00
joshua f2c3172e16 Merge branch 'feature/site-branding-customization' into main 2026-08-20 14:57:44 +02:00
joshuaandClaude Sonnet 5 fd9e917b62 Bump version to 1.7.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:57:23 +02:00
joshuaandClaude Sonnet 5 86af7093ac Add admin-configurable branding (colors, logo, favicon) and generic default fallbacks
Site Settings -> Branding now supports a Primary/Secondary/Accent brand color
system applied site-wide (buttons, nav, hover states, links) and to outgoing
email header/CTA colors, plus a favicon upload alongside the existing logo
upload, a live preview panel (website/email x desktop/mobile), and
logo-based color suggestions. The setup wizard's Branding step got the same
treatment. Fixes two related bugs found along the way: the setup wizard's
logo/favicon upload was missing its auth token, and a static favicon.ico in
Next's special app/ convention path was silently overriding the dynamic one.

Also replaces every "Hope Events"/"Hope Family Church" default (org name,
email subjects, WhatsApp messages, report metadata, API docs) with a neutral
"Cross Code" placeholder, and the optional legal settings (operator name, IO
details, website URL, effective date) with obviously-generic placeholders
instead of defaulting to real personal/organisational details -- since this
platform is deployed for multiple organisations. Adds SETTINGS.md documenting
every setting's default behaviour.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 14:49:51 +02:00
joshua 2d99b7cafe Merge branch 'feature/supervisor-payments-improvements' into main 2026-08-08 01:24:52 +02:00
joshuaandClaude Sonnet 5 906c79a8e8 Bump version to 1.6.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 01:19:49 +02:00
joshuaandClaude Sonnet 5 47b79181b7 Add search and event filter to the Assigned donations list
Finding a specific leg to unassign meant scanning the whole list by
eye. Adds a text search (matches registrant, donor, or event) and an
event dropdown (built from events that actually have assigned legs)
above the list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 01:15:45 +02:00
joshuaandClaude Sonnet 5 34f9826829 Fix donation-assignment admin email reading as a real payment
The internal admin notice for applying a donation to a registration
reused buildPaymentAdminNotice as-is, so it read "Payment recorded" /
"Registration payment" — indistinguishable from an actual incoming
payment even though no new money changed hands. Adds a dedicated
buildDonationAssignmentAdminNotice wrapper (mirroring the one already
added for unassignment) so the subject, heading, and Type field all
say "Donation applied" instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 00:59:24 +02:00
joshuaandClaude Sonnet 5 252f80fadb Add ability to unassign a donation from a registration
Assigning a donation to a registration was one-way: the refund flow
could reverse the money but left the donation's "leg" payment in
place, permanently locking that portion of the donation as used even
though it had been refunded back out. Adds POST
/api/payments/unassign-donation, which deletes the leg, reverts the
registration's status/tickets the same way a refund downgrade already
does, and notifies the registrant. New "Assigned donations" list on
the supervisor Payments page surfaces existing legs with an Unassign
action, since no such list existed before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 00:53:21 +02:00
joshuaandClaude Sonnet 5 0a5b08020f Speed up payment/refund processing on the supervisor dashboard
Reconciling a Yoco payment and sending payment links blocked the HTTP
response on ticket-PDF generation and email/WhatsApp sends; they now
run in the background like the other payment flows already did.
Registration/payment option loops (pricing, stock checks, ticket
generation) now resolve concurrently instead of sequentially. The
Payments page dropped a per-registration N+1 fetch and now refreshes
its lists in parallel after each action. Added missing indexes for
dashboard stats and donation-leg lookups, and made GET
/api/registrations optionally paginated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-08 00:00:29 +02:00
joshua 6759e9c2d3 Merge branch 'fix/join-us-button-logged-in' into main 2026-08-07 01:57:23 +02:00
joshuaandClaude Sonnet 5 8799eec717 Bump version to 1.5.4
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:57:02 +02:00
joshuaandClaude Sonnet 5 56e0c8a31f Fix Join Us button linking logged-in users to register page
Send logged-in users to their dashboard instead of the registration
form, matching how the Navbar already treats logged-in vs guest users.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:52:35 +02:00
joshua 4bcb07f9d1 Merge branch 'fix/help-modal-mobile-overflow' into main 2026-08-07 01:17:08 +02:00
72 changed files with 3641 additions and 648 deletions
+73
View File
@@ -7,6 +7,79 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [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
### Added
- Events can now be marked "contact-only" (e.g. baptism) — they appear on the public events list/detail pages with a "Contact us" button (opening a popup with name/phone/email) instead of a Register button, and have no ticket options or registration flow. Configurable from a new toggle in the admin event wizard's Basic Details step.
- Registration and payment notifications now include a branded PDF: registration confirmations attach an **invoice** (itemized breakdown, early-bird discount, balance due, and — when there's an outstanding balance — a clickable "pay now" link and QR code pointing at a Yoco checkout) whenever the registration isn't fully paid, and payment/donation confirmations attach a **payment receipt** (itemized breakdown plus the amount paid on that transaction). Sent as an email attachment and, over WhatsApp, as the PDF itself (with the existing message text as its caption) in place of the previous text-only send. Both documents pick up the org's configured name/logo/brand color from Site Settings → Branding.
- The user dashboard's registration detail popup now has an "Invoice" button and, next to each payment, a "Receipt" button — both pop up a small Email/WhatsApp choice and (re)send that document on demand, via two new endpoints (`POST /api/registrations/:id/send-invoice`, `POST /api/payments/:id/send-receipt`), restricted to the registration/payment's own owner. The invoice works regardless of balance, showing "Paid in full" when nothing is owed. The Payment history page (`/dashboard/user/payments`) got the same per-payment "Receipt" button too.
### Fixed
- The user dashboard's "(early bird)" label was a price-comparison heuristic — it fired on any line priced below the option's base price, including plain cheaper variants that were never actually early-bird tickets. It now checks the real applied-tier flag the backend already tracks.
- Buying more of an already-purchased ticket type after its early-bird tier expired re-priced the *entire* line at the new price instead of adding the new quantity at the new price (e.g. 5 tickets @ R50 + 1 more after the price rose to R100 came out to R600 instead of R350). Each purchase now gets its own price "tranche" recorded against the registration line, so previously-bought tickets keep their original price and only the newly added quantity uses the current price. Registration/reporting pages that showed a single blended price per line now render (or total) each tranche separately.
- An unpaid (or partially paid) registration's price only ever got refreshed when a payment was actually attempted — an early-bird tier that expired while tickets sat unpaid kept showing its old, no-longer-honoured price (and its "(early bird)" tag) indefinitely on the dashboard until the user tried to pay. Viewing a registration (dashboard, registration detail, or an event's registration list) now refreshes still-outstanding pricing on the spot, same as payment already did.
- The user dashboard's registration detail popup listed one line per price tranche in raw creation order, so a ticket bought across several separate registrations (e.g. some early-bird, some not) showed as a wall of near-duplicate lines. It now merges tranches with the same item/price/tier into one line and groups early-bird lines together, separately from standard-price lines.
- Registration confirmation, payment/donation, and reminder emails and WhatsApp messages computed "Total due"/"Balance" by loading a registration without its price tranches, so any line spanning more than one tranche (e.g. some tickets bought at the early-bird price, more added later at full price) fell back to charging the *entire* quantity at the most recent tranche's price — silently wiping out the early-bird discount from the outstanding balance shown to the user. These sends now load tranches and total each line the same tranche-aware way the dashboard already did; the itemized line amounts in those messages (which previously always showed the option's undiscounted base price) are now correct too.
- Editing an event option's early-bird tiers (even just tweaking one tier's deadline or price) deleted and recreated *every* tier for that option with brand-new ids, including tiers the admin didn't touch. Since past purchases point at a specific tier by id, this silently severed that link on every edit — wiping the "early bird" attribution (and locked-in stock-limit counts, risking oversold tiers) off historical registrations that were never meant to change. Tiers are now upserted by id, so their identity — and everything referencing them — survives an edit; a tier is only ever removed if it's no longer in the saved list *and* has no purchase history against it.
## [1.7.0] - 2026-08-20
### Added
- Site Settings → Branding now supports a full Primary/Secondary/Accent brand color system (previously a single accent color that only recolored the org name in the navbar) applied site-wide — buttons, nav highlights, hover states, and links — plus a favicon upload alongside the existing logo upload, and a live preview panel (with website/email and desktop/mobile views) that updates as colors are picked, before saving. Selecting a logo now also suggests brand colors extracted from it. Outgoing emails' header and call-to-action buttons now pick up the Primary color too, while semantic alert colors (success/warning/danger) stay fixed. The setup wizard's Branding step got the same preview and color-suggestion treatment.
### Fixed
- The setup wizard's logo/favicon upload was silently failing — by the time it ran, the admin account already existed and the upload endpoint required admin auth, but the request never attached the token. It now does, and a failed upload surfaces as a visible error instead of quietly leaving branding unset.
- A custom favicon saved via Site Settings never actually showed in the browser tab: the site had a static `favicon.ico` in the special Next.js `app/` convention path, which generates its own `<link rel="icon">` tag that always took priority over the admin's saved one. Moved the static file to `public/` so it's just the default fallback, not a competing tag.
- Every unconfigured-organisation default across the app (org name, email subjects, WhatsApp messages, report file metadata, backend status/API-docs pages, `From:` fallback address) said "Hope Events"/"Hope Family Church" instead of a neutral placeholder — now defaults to "Cross Code" (the platform vendor) consistently. Several email subject lines (login/welcome/activation/password-changed/account-closed) were also fully hardcoded regardless of the configured org name; they now interpolate it dynamically like the email body already did.
- The optional legal settings (operator name, Information Officer name/email, website URL, effective date, and the Legal pages' own email fallback) defaulted to this project's real founder's name/email/church details — actively misleading if ever shown on another organisation's deployed site before they're configured. These now default to obviously-generic placeholders (e.g. `example.com`, `[not yet configured]`) instead.
- Added `SETTINGS.md`, a repo-root reference documenting every setting's default behaviour when left unconfigured.
## [1.6.0] - 2026-08-08
### Added
- Supervisors can now unassign a donation that was previously applied to a registration, from a new "Assigned donations" list on the Payments page. This reverses the allocation (the registration's balance goes back up and the donation becomes available again), reverting the registration's status and revoking any tickets issued only because that allocation completed payment — blocked if a ticket has already been scanned. The registrant is notified by email/WhatsApp, mirroring the notification sent when a donation is first applied. The list can be searched (registrant, donor, or event) and filtered by event.
### Fixed
- The internal admin notification for a donation applied to a registration read "Payment recorded" / "Registration payment", indistinguishable from a real incoming payment even though no new money changed hands — it now says "Donation applied" throughout, matching the registrant-facing email's distinct wording.
### Performance
- Reconciling a Yoco card payment or sending a payment link on the supervisor Payments page no longer blocks the response on ticket-PDF generation and email/WhatsApp sends — these now run in the background, matching how manual payments already worked.
- Registration creation and payment capture now resolve per-option pricing, stock checks, and ticket generation concurrently instead of one option at a time.
- The supervisor Payments page no longer fires one request per registration to compute outstanding balances (that data was already included in the registrations response); post-action refreshes also run in parallel instead of sequentially.
- Added indexes for `Registration(createdAt, status)`, `Ticket(createdAt)`, and `Payment(originalPaymentId, isDonation)` to speed up dashboard stats and donation-leg lookups. `GET /api/registrations` now supports optional `page`/`limit` pagination.
## [1.5.4] - 2026-08-07
### Fixed
- The homepage "Join Us" button sent logged-in users to the registration page instead of somewhere useful. It now shows "My Dashboard" and links to their dashboard when a user is already logged in.
## [1.5.3] - 2026-08-07 ## [1.5.3] - 2026-08-07
### Fixed ### Fixed
+1
View File
@@ -111,6 +111,7 @@ Documentation: see Frontend README for environment and scripts.
- Backend API Documentation: ./backend/API_DOCUMENTATION.md - Backend API Documentation: ./backend/API_DOCUMENTATION.md
- Backend README: ./backend/README.md - Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md - Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
## Recent Changes ## Recent Changes
+81
View File
@@ -0,0 +1,81 @@
# Site Settings — Defaults Reference
Every setting below is stored as a row in the `AppSetting` key-value table and
is editable at runtime via **Admin → Site Settings** (or, for the first few,
the `/setup` wizard on a fresh install). None of them are required — this
page documents exactly what happens when a given key has never been saved.
Settings changes take effect within 60 seconds everywhere (the backend's
in-process settings cache TTL) without a server restart.
## Organisation
| Setting | Default when unset | Notes |
|---|---|---|
| `org_name` | `"Cross Code"` | Falls back to `ORG_NAME` env var first, then this hardcoded default (the platform vendor's name, used as a neutral placeholder — `org_name` is a required field in the setup wizard, so this fallback is practically unreachable once a site has been set up). Shown in the navbar, email headers/subjects, and WhatsApp messages. |
| `org_tagline` | `"Connecting community through events"` | Falls back to `ORG_TAGLINE` env var first. Shown under the org name in the email header. |
| `org_email` | *(blank)* | No fallback in most places. **Exception:** the Terms of Use / Privacy Policy pages default to `privacy@example.com` (an obvious placeholder) if unset — see the Legal section below. |
| `org_phone` / `org_address` | *(blank)* | No fallback — simply omitted wherever shown if unset. |
| `app_base_url` | `APP_BASE_URL` or `FRONTEND_URL` env var, else `http://localhost:3001` | Used to build links in emails (password reset, ticket delivery, etc.). Should always be set in production. |
## Branding
| Setting | Default when unset | Notes |
|---|---|---|
| `primary_color` | Legacy `accent_color` value if present, else `#4F46E5` (indigo) on the website / `#1e3a5f` (navy) in emails | See [Two different fallbacks](#two-different-fallbacks-for-primary_color) below — this is the one setting with a genuine inconsistency worth knowing about. Drives buttons, links, nav highlights, and the email header/CTA. |
| `secondary_color` | `210 40% 96.1%` background / near-black text (light gray "wash", shadcn's stock theme) | The Branding tab's color picker *pre-fills* `#8B5CF6` as a suggested starting point, but that's only a UI default — if you never hit Save, the actual site uses the light-gray fallback, not violet. |
| `accent_color` | `210 40% 96.1%` background / near-black text (same light gray as Secondary) | Same story as Secondary — the picker pre-fills `#EC4899` (pink) as a suggestion only. |
| `logo_url` | Bundled logo (`frontend/src/app/church_logo.jpg`) | Shown in the navbar. This is still the original Hope Family Church artwork checked into the repo — swap the file (or upload a real logo via the Branding tab) before deploying for a different organisation. |
| `favicon_url` | `frontend/public/favicon.ico` (the site's original generic favicon) | Shown in the browser tab. |
### Two different fallbacks for `primary_color`
If `primary_color` has *never* been saved (no admin has ever used the new
3-color Branding tab) **and** there's no legacy `accent_color` value either,
the website and outgoing emails currently disagree on what "no color" means:
- **Website** (`globals.css` / `tailwind.config.js` static defaults): `#4F46E5`, a bright indigo.
- **Emails** (`backend/src/utils/email.js` `getOrg()`): `#1e3a5f`, a dark navy — unless overridden by the `EMAIL_HEADER_COLOR` env var.
In practice this only matters on a brand-new install before the setup wizard
or Branding tab has been saved even once — the wizard always writes
`primary_color`, so this gap closes itself the moment setup completes.
## Notifications
| Setting | Default when unset | Notes |
|---|---|---|
| `reg_notification_emails` | `REGISTRATIONS_EMAIL` env var, else *(blank)* | If truly blank, no internal notification email is sent for new registrations/payments/donations — it's silently skipped, not an error. |
## Email delivery (SMTP)
| Setting | Default when unset | Notes |
|---|---|---|
| `smtp_host` | `SMTP_HOST`/`EMAIL_HOST` env var, else *(blank)* | If blank, the app **does not send real email** — it uses nodemailer's `jsonTransport`, which just logs the would-be email to the console. This is the single biggest "nothing is configured" gotcha — ticket/payment/password-reset emails silently don't go anywhere until SMTP is set up. |
| `smtp_port` | `587` | |
| `smtp_secure` | `false` | |
| `smtp_user` / `smtp_pass` | *(blank)* | Stored AES-256-GCM encrypted (key derived from `JWT_SECRET`). |
| `smtp_from` | *(blank)* | If blank, outgoing mail's `From:` address falls back to the literal `no-reply@crosscode.local`. |
## Legal (Terms of Use / Privacy Policy)
| Setting | Default when unset | Notes |
|---|---|---|
| `legal_operator_name` | `"[operator details not yet configured]"` | Deliberately a generic, obviously-incomplete placeholder rather than any real person's or organisation's name. Unlike `org_name`, these legal fields are optional and can realistically stay unset on a live site — defaulting them to Cross Code's (or anyone else's) real identity would misrepresent who's legally responsible for that deployment. |
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. |
| `legal_io_email` | `"io@example.com"` | Rendered as a `mailto:` link, so the placeholder stays a syntactically valid address on the IANA-reserved `example.com` domain rather than a bracketed non-email string that would break the link. |
| `legal_website_url` | `"example.com"` | Same reasoning — used to build an `https://` link. |
| `legal_effective_date` | `"[not yet set]"` | Plain text only, no link — a bracketed placeholder is fine here. |
| `org_email` (as used on legal pages specifically) | `"privacy@example.com"` | Different fallback than `org_email`'s general default (blank) — also rendered as a `mailto:` link, so it needs to stay a valid-looking address. |
## WhatsApp
| Setting | Default when unset | Notes |
|---|---|---|
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
## Setup state
| Setting | Default when unset | Notes |
|---|---|---|
| `setup_complete` | `false` (treated as `"not true"`) | Controls whether `/setup` redirects to `/login` or runs the first-time wizard. Set to `"true"` automatically when the wizard finishes. |
+2
View File
@@ -452,6 +452,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted. Runtime configuration stored in the `AppSetting` table. Sensitive values (SMTP credentials, WAWP token) are stored AES-256-GCM encrypted.
For what each individual setting key defaults to when it's never been saved, see [`SETTINGS.md`](../SETTINGS.md) at the repo root.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| |--------|------|------|-------------|
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) | | GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.5.3", "version": "1.9.2",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -0,0 +1,11 @@
-- DropIndex
DROP INDEX "Payment_originalPaymentId_idx";
-- CreateIndex
CREATE INDEX "Payment_originalPaymentId_isDonation_idx" ON "Payment"("originalPaymentId", "isDonation");
-- CreateIndex
CREATE INDEX "Registration_createdAt_status_idx" ON "Registration"("createdAt", "status");
-- CreateIndex
CREATE INDEX "Ticket_createdAt_idx" ON "Ticket"("createdAt");
@@ -0,0 +1,16 @@
/*
Warnings:
- You are about to drop the column `feeAmount` on the `EventCashupLine` table. All the data in the column will be lost.
- You are about to drop the column `feeAmount` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeChannel` on the `Payment` table. All the data in the column will be lost.
- You are about to drop the column `feeRate` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "EventCashupLine" DROP COLUMN "feeAmount";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feeAmount",
DROP COLUMN "feeChannel",
DROP COLUMN "feeRate";
@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "contactEmail" TEXT,
ADD COLUMN "contactName" TEXT,
ADD COLUMN "contactPhone" TEXT,
ADD COLUMN "requiresRegistration" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "RegistrationOptionTranche" (
"id" TEXT NOT NULL,
"registrationOptionId" TEXT NOT NULL,
"quantity" INTEGER NOT NULL,
"priceSnapshot" DOUBLE PRECISION NOT NULL,
"appliedTierId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RegistrationOptionTranche_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_registrationOptionId_idx" ON "RegistrationOptionTranche"("registrationOptionId");
-- CreateIndex
CREATE INDEX "RegistrationOptionTranche_appliedTierId_idx" ON "RegistrationOptionTranche"("appliedTierId");
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_registrationOptionId_fkey" FOREIGN KEY ("registrationOptionId") REFERENCES "RegistrationOption"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RegistrationOptionTranche" ADD CONSTRAINT "RegistrationOptionTranche_appliedTierId_fkey" FOREIGN KEY ("appliedTierId") REFERENCES "EarlyBirdTier"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "location" TEXT;
+31 -1
View File
@@ -101,6 +101,11 @@ model Event {
isActive Boolean @default(true) isActive Boolean @default(true)
isHidden Boolean @default(false) isHidden Boolean @default(false)
requiresAuth Boolean @default(true) requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
contactName String?
contactPhone String?
contactEmail String?
location String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
createdById String? createdById String?
@@ -163,6 +168,7 @@ model EarlyBirdTier {
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade) eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[] registrationOptions RegistrationOption[]
tranches RegistrationOptionTranche[]
@@index([eventOptionId, deadline]) @@index([eventOptionId, deadline])
@@index([variantId]) @@index([variantId])
@@ -201,6 +207,7 @@ model Registration {
@@index([eventId]) @@index([eventId])
@@index([userId, status]) @@index([userId, status])
@@index([eventId, status]) @@index([eventId, status])
@@index([createdAt, status])
} }
model RegistrationOption { model RegistrationOption {
@@ -216,6 +223,7 @@ model RegistrationOption {
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull) variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull) appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[] tickets Ticket[]
tranches RegistrationOptionTranche[]
@@index([registrationId]) @@index([registrationId])
@@index([eventOptionId]) @@index([eventOptionId])
@@ -223,6 +231,27 @@ model RegistrationOption {
@@index([appliedTierId]) @@index([appliedTierId])
} }
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
// (mirrors the Payment model's append-only pattern) — this is what lets a single ticket
// type be bought in multiple batches at different early-bird prices without either batch's
// price bleeding into the other. RegistrationOption.quantity/priceSnapshot/appliedTierId
// stay in sync as an aggregate (quantity = sum of tranche quantities; priceSnapshot/appliedTierId
// mirror the most recently added tranche) for the many call sites that only need "how many"
// or a single display price.
model RegistrationOptionTranche {
id String @id @default(uuid())
registrationOptionId String
quantity Int
priceSnapshot Float
appliedTierId String?
createdAt DateTime @default(now())
registrationOption RegistrationOption @relation(fields: [registrationOptionId], references: [id], onDelete: Cascade)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
@@index([registrationOptionId])
@@index([appliedTierId])
}
model Payment { model Payment {
id String @id @default(uuid()) id String @id @default(uuid())
amount Float amount Float
@@ -249,7 +278,7 @@ model Payment {
@@index([registrationId]) @@index([registrationId])
@@index([eventId]) @@index([eventId])
@@index([createdAt]) @@index([createdAt])
@@index([originalPaymentId]) @@index([originalPaymentId, isDonation])
} }
model Ticket { model Ticket {
@@ -271,6 +300,7 @@ model Ticket {
@@index([eventId]) @@index([eventId])
@@index([userId]) @@index([userId])
@@index([registrationOptionId]) @@index([registrationOptionId])
@@index([createdAt])
} }
model TicketUsage { model TicketUsage {
+4 -3
View File
@@ -64,8 +64,9 @@ const MAPPINGS = [
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] }, { key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] }, { key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
// Branding // Branding — these env vars historically fed the single "brand color" concept,
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] }, // which is now the Primary color in the 3-color Primary/Secondary/Accent system.
{ key: 'primary_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Notifications // Notifications
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] }, { key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
@@ -88,7 +89,7 @@ async function main() {
const prisma = new PrismaClient(); const prisma = new PrismaClient();
console.log(`\n${'─'.repeat(60)}`); console.log(`\n${'─'.repeat(60)}`);
console.log(' Hope Events — .env → DB settings migration'); console.log(' Settings migration: .env → DB');
if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)'); if (DRY_RUN) console.log(' MODE: DRY RUN (no changes will be made)');
if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)'); if (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
console.log(`${'─'.repeat(60)}\n`); console.log(`${'─'.repeat(60)}\n`);
+51 -17
View File
@@ -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 } = 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(),
@@ -58,14 +58,20 @@ const createEvent = async (req, res) => {
redirectUrl, redirectUrl,
isHidden: isHidden === true || isHidden === 'true', isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true, requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
location: location || null,
}; };
try { try {
const event = await prisma.event.create({ data }); const event = await prisma.event.create({ data });
// Automatically create a main ticket (event option) with the event price // Automatically create a main ticket (event option) with the event price — contact-only
// events have no bookable options, so there's nothing to auto-create for them.
try { try {
if (prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') { if (data.requiresRegistration !== false && prisma && prisma.eventOption && typeof prisma.eventOption.create === 'function') {
await prisma.eventOption.create({ await prisma.eventOption.create({
data: { data: {
id: uuidv4(), id: uuidv4(),
@@ -454,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 } = 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,
@@ -468,6 +474,11 @@ const updateEvent = async (req, res) => {
isActive: isActive !== undefined ? isActive : event.isActive, isActive: isActive !== undefined ? isActive : event.isActive,
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false), isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true), requiresAuth: requiresAuth !== undefined ? !(requiresAuth === false || requiresAuth === 'false') : (event.requiresAuth ?? true),
requiresRegistration: requiresRegistration !== undefined ? !(requiresRegistration === false || requiresRegistration === 'false') : (event.requiresRegistration ?? true),
contactName: contactName !== undefined ? (contactName || null) : event.contactName,
contactPhone: contactPhone !== undefined ? (contactPhone || null) : event.contactPhone,
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,
}; };
@@ -682,26 +693,49 @@ const updateEventOption = async (req, res) => {
} }
}); });
// If earlyBirdTiers provided, replace all tiers for this option (including per-variant ones) // If earlyBirdTiers provided, upsert tiers for this option (including per-variant ones) by
// id — mirrors the variants upsert just below. Editing tiers used to delete-and-recreate
// every tier for the option on every save, which silently reassigned each one a brand-new
// id; RegistrationOption/RegistrationOptionTranche.appliedTierId (onDelete: SetNull) then
// pointed at nothing, so every past purchase under that tier lost its "early bird"
// attribution — even for tiers the admin didn't touch — and any stock-limit count for the
// tier reset to zero (allowing it to be oversold). Upserting by id keeps existing tiers'
// ids stable across edits so that history stays linked.
if (Array.isArray(earlyBirdTiers)) { if (Array.isArray(earlyBirdTiers)) {
try { await prisma.earlyBirdTier.deleteMany({ where: { eventOptionId: updatedEventOption.id } }); } catch {} const incomingIds = earlyBirdTiers.filter(t => t && t.id).map(t => t.id);
const existingTiers = await prisma.earlyBirdTier.findMany({ where: { eventOptionId: updatedEventOption.id } });
for (const et of existingTiers) {
if (incomingIds.includes(et.id)) continue;
// Never delete a tier that's still attributed on past purchases — just leave it
// orphaned from the option's active tier list rather than nulling out history.
const usageCount = await prisma.registrationOptionTranche.count({ where: { appliedTierId: et.id } });
if (usageCount === 0) {
try { await prisma.earlyBirdTier.delete({ where: { id: et.id } }); } catch {}
}
}
for (let i = 0; i < earlyBirdTiers.length; i++) { for (let i = 0; i < earlyBirdTiers.length; i++) {
const t = earlyBirdTiers[i]; const t = earlyBirdTiers[i];
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue; if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
const deadline = new Date(t.deadline); const deadline = new Date(t.deadline);
const p = parseFloat(t.price); const p = parseFloat(t.price);
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue; if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
await prisma.earlyBirdTier.create({ const data = {
data: { eventOptionId: updatedEventOption.id,
id: require('uuid').v4(), variantId: t.variantId || null,
eventOptionId: updatedEventOption.id, deadline,
variantId: t.variantId || null, price: p,
deadline, order: typeof t.order === 'number' ? t.order : i,
price: p, stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
order: typeof t.order === 'number' ? t.order : i, };
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0, if (t.id) {
} await prisma.earlyBirdTier.upsert({
}); where: { id: t.id },
update: data,
create: { id: t.id, ...data },
});
} else {
await prisma.earlyBirdTier.create({ data: { id: require('uuid').v4(), ...data } });
}
} }
} }
+157 -12
View File
@@ -5,7 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const axios = require('axios'); const axios = require('axios');
const { emailTickets } = require('./ticketController'); const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils'); const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen } = require('../utils/cashupUtils'); const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
// @desc Create a new payment // @desc Create a new payment
// @route POST /api/payments // @route POST /api/payments
@@ -78,7 +78,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
user: { select: { id: true } } user: { select: { id: true } }
} }
@@ -134,7 +134,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
user: { select: { id: true } } user: { select: { id: true } }
} }
@@ -242,7 +242,8 @@ const createPayment = async (req, res) => {
include: { include: {
registrationOptions: { registrationOptions: {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } } eventOption: { include: { earlyBirdTiers: true } },
tranches: true
} }
}, },
payments: true payments: true
@@ -641,7 +642,8 @@ const assignDonationToRegistration = async (req, res) => {
include: { include: {
registrationOptions: { registrationOptions: {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } } eventOption: { include: { earlyBirdTiers: true } },
tranches: true
} }
}, },
payments: true payments: true
@@ -770,6 +772,129 @@ const assignDonationToRegistration = async (req, res) => {
} }
}; };
// @desc Reverse a previous donation assignment — hard-deletes the leg payment and reverts
// the registration's status/tickets. The donation itself (never touched by assign)
// is unaffected, so its remaining balance simply goes back up.
// @route POST /api/payments/unassign-donation
// @access Private/Supervisor
const unassignDonationFromRegistration = async (req, res) => {
try {
const { legId } = req.body;
if (!legId) {
res.status(400);
throw new Error('legId is required');
}
const leg = await prisma.payment.findUnique({ where: { id: legId } });
if (!leg) {
res.status(404);
throw new Error('Payment not found');
}
// Mirror the frontend's isDonationLeg check: a real donation-assignment leg is a positive,
// non-donation payment that references a donation via originalPaymentId. This also rejects
// refund rows, which set originalPaymentId too but always with a negative amount.
if (leg.isDonation || !leg.originalPaymentId || !(leg.amount > 0)) {
res.status(400);
throw new Error('This payment is not a donation-assignment leg');
}
if (!leg.registrationId) {
res.status(400);
throw new Error('This leg is not linked to a registration');
}
const donation = await prisma.payment.findUnique({ where: { id: leg.originalPaymentId } });
if (!donation || !donation.isDonation) {
res.status(400);
throw new Error('The original donation for this leg could not be found');
}
await assertRegistrationEventOpen(leg.registrationId, res);
const registration = await prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
if (!registration) {
res.status(404);
throw new Error('Registration not found');
}
const originalStatus = registration.status;
const totalPaidExisting = registration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDue = computeRegistrationTotalDue(registration, new Date());
const totalPaidAfter = totalPaidExisting - leg.amount;
const willDowngradeFromPaid = (originalStatus === 'paid') && (totalPaidAfter < totalDue);
if (willDowngradeFromPaid) {
// Same guard createRefund uses: block if any ticket on the registration has been scanned.
const regTickets = await prisma.ticket.findMany({
where: { registrationOption: { registrationId: leg.registrationId } },
include: { usages: true }
});
const hasUsed = regTickets.some(t => t.isUsed || (t.usages && t.usages.length > 0));
if (hasUsed) {
res.status(400);
throw new Error('Cannot unassign this donation because a ticket has already been used');
}
}
await prisma.payment.delete({ where: { id: legId } });
// Recompute status the same way createRefund does after removing money from a registration.
const updatedRegistration = await prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
let finalRegistration = updatedRegistration;
if (updatedRegistration) {
const totalPaid = updatedRegistration.payments.reduce((sum, p) => sum + p.amount, 0);
const totalDueNow = computeRegistrationTotalDue(updatedRegistration, new Date());
let newStatus;
if (totalPaid >= totalDueNow) newStatus = 'paid';
else if (totalPaid > 0) newStatus = 'partial_paid';
else newStatus = 'pending';
finalRegistration = await prisma.registration.update({
where: { id: leg.registrationId },
data: { status: newStatus, updatedAt: new Date() }
});
// Same blunt scope createRefund uses — tickets aren't tagged per-leg, so a downgrade
// clears every unused ticket on the registration, not just the ones this leg funded.
if (originalStatus === 'paid' && newStatus !== 'paid') {
await prisma.ticket.deleteMany({
where: {
registrationOption: { registrationId: leg.registrationId },
isUsed: false
}
});
}
}
// Fire-and-forget notification
const { sendDonationUnassignmentEmails } = require('../utils/notifications');
const _udLeg = { id: leg.id, amount: leg.amount, createdAt: leg.createdAt, method: leg.method, externalId: leg.externalId, userId: leg.userId, registrationId: leg.registrationId };
(async () => {
try { await sendDonationUnassignmentEmails(_udLeg); }
catch (e) { console.error('Failed to send emails after unassigning donation:', e); }
})();
return res.status(200).json({
message: 'Donation unassigned',
updatedRegistration: finalRegistration,
donationId: donation.id
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount } // Internal helper: create a Yoco checkout for a registration and return { checkoutId, redirectUrl, amount }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration. // Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) { async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
@@ -780,6 +905,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: true, variant: true,
tranches: true,
} }
}, },
payments: true, payments: true,
@@ -894,7 +1020,7 @@ const createYocoCheckout = async (req, res) => {
const freshReg = await prisma.registration.findUnique({ const freshReg = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true payments: true
} }
}); });
@@ -917,6 +1043,7 @@ const createYocoCheckout = async (req, res) => {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: true, variant: true,
tranches: true,
} }
}, },
payments: true, payments: true,
@@ -1057,20 +1184,22 @@ const sendPaymentLink = async (req, res) => {
const { user, event } = registration; const { user, event } = registration;
const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`; const message = `Hi ${user.name || ''}, here's your payment link for ${event?.title || 'your registration'}: ${redirectUrl}`;
// Validation is synchronous (fast, no network); the actual send is backgrounded since
// SMTP/WAWP round trips shouldn't block this request.
if (channel === 'email') { if (channel === 'email') {
if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); } if (!user.email) { res.status(400); throw new Error('This user has no email address on file'); }
const { sendMail } = require('../utils/email'); const { sendMail } = require('../utils/email');
await sendMail({ sendMail({
to: user.email, to: user.email,
subject: `Payment link — ${event?.title || 'Registration'}`, subject: `Payment link — ${event?.title || 'Registration'}`,
text: message, text: message,
html: `<p>Hi ${user.name || ''},</p><p>Here's your payment link for <strong>${event?.title || 'your registration'}</strong>:</p><p><a href="${redirectUrl}">${redirectUrl}</a></p>` html: `<p>Hi ${user.name || ''},</p><p>Here's your payment link for <strong>${event?.title || 'your registration'}</strong>:</p><p><a href="${redirectUrl}">${redirectUrl}</a></p>`
}); }).catch(e => console.error('Failed to send payment link email:', e));
} else { } else {
const { isValidZAPhone } = require('../utils/whatsapp'); const { isValidZAPhone } = require('../utils/whatsapp');
if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); } if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); }
const { waTextAny } = require('../utils/notify'); const { waTextAny } = require('../utils/notify');
await waTextAny(user, message); waTextAny(user, message).catch(e => console.error('Failed to send payment link via WhatsApp:', e));
} }
return res.status(200).json({ sent: true, channel }); return res.status(200).json({ sent: true, channel });
@@ -1145,7 +1274,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId }, where: { id: linkRegistrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
// Load tickets to check usage if needed // Load tickets to check usage if needed
_count: true _count: true
@@ -1199,7 +1328,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId }, where: { id: linkRegistrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true payments: true
} }
}); });
@@ -1291,6 +1420,20 @@ const getPaymentStats = async (req, res) => {
} }
} }
// @desc Re-send the receipt PDF for one of the caller's own payments
// @route POST /api/payments/:id/send-receipt
// @access Private
const sendReceipt = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendReceiptToUser } = require('../utils/notifications');
await sendReceiptToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Receipt sent to WhatsApp.' : 'Receipt emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = { module.exports = {
createPayment, createPayment,
getPayments, getPayments,
@@ -1299,9 +1442,11 @@ module.exports = {
getPaymentsByRegistration, getPaymentsByRegistration,
getPaymentsByEvent, getPaymentsByEvent,
assignDonationToRegistration, assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout, createYocoCheckout,
createRegistrationCheckoutInternal, createRegistrationCheckoutInternal,
sendPaymentLink, sendPaymentLink,
createRefund, createRefund,
getPaymentStats getPaymentStats,
sendReceipt,
}; };
+354 -126
View File
@@ -4,7 +4,7 @@ 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');
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue } = require('../utils/pricing'); const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
const { assertEventOpen } = require('../utils/cashupUtils'); const { assertEventOpen } = require('../utils/cashupUtils');
/** /**
@@ -136,16 +136,16 @@ const createRegistration = async (req, res) => {
throw new Error('At least one option must be selected'); throw new Error('At least one option must be selected');
} }
// Validate each option and resolve prices / check stock // Validate each option and resolve prices / check stock.
const resolvedOptions = []; // Options are independent of each other, so resolve them concurrently. Errors are marked
for (const option of options) { // with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
const resolvedOptions = await Promise.all(options.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId); const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) { if (!eventOption) {
res.status(400);
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`); throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
} }
if (!option.quantity || option.quantity < 1) { if (!option.quantity || option.quantity < 1) {
res.status(400);
throw new Error('Quantity must be at least 1'); throw new Error('Quantity must be at least 1');
} }
@@ -155,11 +155,12 @@ const createRegistration = async (req, res) => {
try { try {
const stockCheck = await checkOptionStock(eventOption, qty); const stockCheck = await checkOptionStock(eventOption, qty);
if (!stockCheck.available) { if (!stockCheck.available) {
res.status(400); const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
throw new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`); err.isStockError = true;
throw err;
} }
} catch (e) { } catch (e) {
if (res.statusCode !== 200) throw e; // propagate stock errors if (e.isStockError) throw e; // propagate stock errors
// If stock check function fails (pre-migration), continue without stock check // If stock check function fails (pre-migration), continue without stock check
} }
@@ -169,17 +170,17 @@ const createRegistration = async (req, res) => {
if (variantId && canIncludeVariants) { if (variantId && canIncludeVariants) {
const variant = (eventOption.variants || []).find(v => v.id === variantId); const variant = (eventOption.variants || []).find(v => v.id === variantId);
if (!variant) { if (!variant) {
res.status(400);
throw new Error(`Variant not found for option "${eventOption.name}"`); throw new Error(`Variant not found for option "${eventOption.name}"`);
} }
try { try {
const vStock = await checkVariantStock(variant, qty); const vStock = await checkVariantStock(variant, qty);
if (!vStock.available) { if (!vStock.available) {
res.status(400); const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
throw new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`); err.isStockError = true;
throw err;
} }
} catch (e) { } catch (e) {
if (res.statusCode !== 200) throw e; if (e.isStockError) throw e;
} }
variantPrice = variant.price; // null = use option price variantPrice = variant.price; // null = use option price
} }
@@ -213,8 +214,8 @@ const createRegistration = async (req, res) => {
} }
if (priceSnapshot === null) priceSnapshot = eventOption.price; if (priceSnapshot === null) priceSnapshot = eventOption.price;
resolvedOptions.push({ ...option, variantId, appliedTierId, priceSnapshot }); return { ...option, variantId, appliedTierId, priceSnapshot };
} }));
// Check for existing non-cancelled registration for this user+event → merge instead // Check for existing non-cancelled registration for this user+event → merge instead
const existingReg = await prisma.registration.findFirst({ const existingReg = await prisma.registration.findFirst({
@@ -225,17 +226,31 @@ const createRegistration = async (req, res) => {
let registration; let registration;
let isNewRegistration = false; let isNewRegistration = false;
if (existingReg) { if (existingReg) {
// Merge: upsert each requested option into the existing registration // Merge: add a new price tranche per requested option into the existing registration.
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) { for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row // Match on eventOptionId + variantId for correct row
const existing = existingReg.registrationOptions.find( const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
); );
if (existing) { if (existing) {
await prisma.registrationOption.update({ await prisma.$transaction([
where: { id: existing.id }, prisma.registrationOptionTranche.create({
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null } data: {
}); id: uuidv4(),
registrationOptionId: existing.id,
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
})
]);
} else { } else {
await prisma.registrationOption.create({ await prisma.registrationOption.create({
data: { data: {
@@ -246,6 +261,14 @@ const createRegistration = async (req, res) => {
variantId: opt.variantId || null, variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null, appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot, priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
} }
}); });
} }
@@ -262,7 +285,7 @@ const createRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: existingReg.id }, where: { id: existingReg.id },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } } user: { select: { id: true, name: true, email: true, phoneNumber: true } }
} }
@@ -285,11 +308,19 @@ const createRegistration = async (req, res) => {
variantId: option.variantId || null, variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null, appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot, priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
})) }))
} }
}, },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } } user: { select: { id: true, name: true, email: true, phoneNumber: true } }
} }
@@ -367,7 +398,7 @@ const createRegistration = async (req, res) => {
} }
})(); })();
res.status(201).json(registration); res.status(201).json(attachComputedTotals(registration));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -378,28 +409,44 @@ const createRegistration = async (req, res) => {
// @access Private/Admin // @access Private/Admin
const getRegistrations = async (req, res) => { const getRegistrations = async (req, res) => {
try { try {
const registrations = await prisma.registration.findMany({ const include = {
include: { payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, registrationOptions: {
registrationOptions: { include: {
include: { eventOption: { include: { earlyBirdTiers: true } },
eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { id: true, name: true, price: true } },
variant: { select: { id: true, name: true, price: true } }, tranches: true,
} }
}, },
event: true, event: true,
user: { user: {
select: { select: {
id: true, id: true,
name: true, name: true,
email: true, email: true,
phoneNumber: true phoneNumber: true
}
} }
} }
}); };
res.json(registrations); // Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
// array of every registration) working unchanged; callers that pass either param get back
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
const registrations = await prisma.registration.findMany({ include });
return res.json(attachComputedTotalsToList(registrations));
}
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
const skip = (page - 1) * limit;
const [registrations, total] = await prisma.$transaction([
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.registration.count()
]);
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -418,6 +465,18 @@ const getUserRegistrations = async (req, res) => {
event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } } event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } }
}; };
// Keep pending/partial-paid registrations' prices current before serving them — an
// early-bird tier can expire while items sit unpaid in someone's registration, and
// without this the dashboard would keep showing a price that was never actually locked
// in by a payment, indefinitely, until the user happens to attempt a payment.
const staleCandidates = await prisma.registration.findMany({
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
const registrations = await prisma.registration.findMany({ const registrations = await prisma.registration.findMany({
where: whereClause, where: whereClause,
include: { include: {
@@ -425,6 +484,7 @@ const getUserRegistrations = async (req, res) => {
include: { include: {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } }, variant: { select: { id: true, name: true, price: true } },
tranches: true,
} }
}, },
// Nest the event's form so the frontend can tell whether attendee forms are // Nest the event's form so the frontend can tell whether attendee forms are
@@ -437,12 +497,13 @@ const getUserRegistrations = async (req, res) => {
email: true, email: true,
phoneNumber: true phoneNumber: true
} }
} },
payments: true,
}, },
orderBy: { createdAt: 'desc' } orderBy: { createdAt: 'desc' }
}); });
res.json(registrations); res.json(attachComputedTotalsToList(registrations));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -453,35 +514,42 @@ const getUserRegistrations = async (req, res) => {
// @access Private // @access Private
const getRegistrationById = async (req, res) => { const getRegistrationById = async (req, res) => {
try { try {
const registration = await prisma.registration.findUnique({ const include = {
where: { id: req.params.id }, registrationOptions: {
include: { include: {
registrationOptions: { eventOption: { include: { earlyBirdTiers: true } },
include: { variant: { select: { id: true, name: true, price: true } },
eventOption: { include: { earlyBirdTiers: true } }, tickets: true,
variant: { select: { id: true, name: true, price: true } }, tranches: true
tickets: true }
} },
}, event: true,
event: true, user: {
user: { select: {
select: { id: true,
id: true, name: true,
name: true, email: true,
email: true, phoneNumber: true
phoneNumber: true }
} },
}, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, formResponses: { include: { answers: true } }
formResponses: { include: { answers: true } } };
}
}); let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
if (!registration) { if (!registration) {
res.status(404); res.status(404);
throw new Error('Registration not found'); throw new Error('Registration not found');
} }
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
// whenever it's viewed, not just at payment time.
if (registration.status === 'pending' || registration.status === 'partial_paid') {
await refreshPricingForRegistration(registration.id).catch(() => {});
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
}
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable) // Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
// Authenticated users must be the owner or staff+ // Authenticated users must be the owner or staff+
if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') {
@@ -489,7 +557,7 @@ const getRegistrationById = async (req, res) => {
throw new Error('Not authorized to view this registration'); throw new Error('Not authorized to view this registration');
} }
res.json(registration); res.json(attachComputedTotals(registration));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -643,6 +711,17 @@ const cancelRegistration = async (req, res) => {
const getRegistrationsByEvent = async (req, res) => { const getRegistrationsByEvent = async (req, res) => {
try { try {
const { search } = req.query; const { search } = req.query;
// See getUserRegistrations — keep pending/partial-paid registrations' prices current
// before serving them, rather than only at payment time.
const staleCandidates = await prisma.registration.findMany({
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
select: { id: true }
});
if (staleCandidates.length > 0) {
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
}
let registrations = await prisma.registration.findMany({ let registrations = await prisma.registration.findMany({
where: { eventId: req.params.eventId }, where: { eventId: req.params.eventId },
include: { include: {
@@ -651,6 +730,7 @@ const getRegistrationsByEvent = async (req, res) => {
eventOption: { include: { earlyBirdTiers: true } }, eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } }, variant: { select: { id: true, name: true, price: true } },
tickets: true, tickets: true,
tranches: true,
} }
}, },
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
@@ -668,7 +748,7 @@ const getRegistrationsByEvent = async (req, res) => {
); );
} }
res.json(registrations); res.json(attachComputedTotalsToList(registrations));
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
} }
@@ -875,20 +955,33 @@ const createManualRegistration = async (req, res) => {
let isNewRegistration = false; let isNewRegistration = false;
if (existingReg) { if (existingReg) {
// Upsert each requested option into the existing registration (all in parallel) // Add a new price tranche per requested option into the existing registration (all in
// parallel) — never overwrite an existing row's priceSnapshot/quantity in place, or
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => { await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find( const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
); );
if (existing) { if (existing) {
return prisma.registrationOption.update({ return prisma.$transaction([
where: { id: existing.id }, prisma.registrationOptionTranche.create({
data: { data: {
quantity: existing.quantity + opt.quantity, id: uuidv4(),
priceSnapshot: opt.priceSnapshot, registrationOptionId: existing.id,
appliedTierId: opt.appliedTierId || null, quantity: opt.quantity,
} priceSnapshot: opt.priceSnapshot,
}); appliedTierId: opt.appliedTierId || null,
}
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: {
quantity: existing.quantity + opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
})
]);
} }
return prisma.registrationOption.create({ return prisma.registrationOption.create({
data: { data: {
@@ -899,6 +992,14 @@ const createManualRegistration = async (req, res) => {
variantId: opt.variantId || null, variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null, appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot, priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
} }
}); });
})); }));
@@ -906,7 +1007,7 @@ const createManualRegistration = async (req, res) => {
const freshForStatus = await prisma.registration.findUnique({ const freshForStatus = await prisma.registration.findUnique({
where: { id: existingReg.id }, where: { id: existingReg.id },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
} }
}); });
@@ -921,7 +1022,7 @@ const createManualRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({ registration = await prisma.registration.findUnique({
where: { id: existingReg.id }, where: { id: existingReg.id },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
}, },
@@ -944,11 +1045,19 @@ const createManualRegistration = async (req, res) => {
variantId: option.variantId || null, variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null, appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot, priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
})), })),
}, },
}, },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
}, },
@@ -1019,7 +1128,7 @@ const createManualRegistration = async (req, res) => {
} }
})(); })();
return res.status(201).json(registration); return res.status(201).json(attachComputedTotals(registration));
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return res.status(400).json({message: error.message}); return res.status(400).json({message: error.message});
@@ -1047,6 +1156,7 @@ const updateRegistrationOptions = async (req, res) => {
registrationOptions: { registrationOptions: {
include: { include: {
tickets: true, tickets: true,
tranches: true,
eventOption: { include: { earlyBirdTiers: true, variants: true } } eventOption: { include: { earlyBirdTiers: true, variants: true } }
} }
}, },
@@ -1118,39 +1228,11 @@ const updateRegistrationOptions = async (req, res) => {
} }
const mergedOptions = Array.from(mergedOptionsMap.values()); const mergedOptions = Array.from(mergedOptionsMap.values());
// Resolve pricing for each incoming option (variant-aware, with stock check)
const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo]));
const resolvedUpdateOptions = [];
for (const opt of mergedOptions) {
const eventOption = eventOptionsMap.get(opt.eventOptionId);
const variantId = opt.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, opt.quantity);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, opt.quantity);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId });
}
const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0);
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
}
// Group existing registrationOptions by eventOptionId::variantId so tickets that // Group existing registrationOptions by eventOptionId::variantId so tickets that
// have already been issued are never deleted, only ever updated in place. // have already been issued are never deleted, only ever updated in place. Computed
// before pricing resolution because pricing now depends on whether a quantity is
// increasing (and by how much) — an unchanged or reduced quantity must never
// re-price tickets already locked in at an earlier price (see tranche design).
const oldByKey = new Map(); const oldByKey = new Map();
for (const ro of registration.registrationOptions) { for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`; const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
@@ -1158,6 +1240,86 @@ const updateRegistrationOptions = async (req, res) => {
oldByKey.get(key).push(ro); oldByKey.get(key).push(ro);
} }
// A legacy row (created before the tranche migration) has no tranches — fall back to
// its own priceSnapshot/quantity as a single implicit tranche for totals purposes.
const trancheSum = (tranches) => (tranches || []).reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
const rowsTotal = (rows) => rows.reduce((sum, ro) => {
const tranches = ro.tranches || [];
if (tranches.length > 0) return sum + trancheSum(tranches);
return sum + Number(ro.quantity || 0) * Number(ro.priceSnapshot ?? ro.eventOption?.price ?? 0);
}, 0);
// Remove `qtyToRemove` units from a set of tranches, newest-first (LIFO) — mirrors the
// ticket-floor invariant below: issued tickets always map to the oldest tranches, so the
// newest (least-committed) tranches are the ones trimmed first on a quantity decrease.
const planLIFORemoval = (tranches, qtyToRemove) => {
const sorted = [...tranches].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
let remaining = qtyToRemove;
const ops = [];
const kept = [];
for (const t of sorted) {
if (remaining <= 0) { kept.push(t); continue; }
if (t.quantity <= remaining) {
remaining -= t.quantity;
ops.push({ type: 'delete', trancheId: t.id });
} else {
const newQuantity = t.quantity - remaining;
remaining = 0;
ops.push({ type: 'update', trancheId: t.id, newQuantity });
kept.push({ ...t, quantity: newQuantity });
}
}
return { kept, ops };
};
// Resolve pricing for each incoming option (variant-aware, with stock check) — but only
// for the delta being newly added. Unchanged or decreasing quantities never re-resolve.
const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo]));
const resolvedUpdateOptions = [];
let newTotalDue = 0;
for (const opt of mergedOptions) {
const key = `${opt.eventOptionId}::${opt.variantId || ''}`;
const existingRows = oldByKey.get(key) || [];
const existingQty = existingRows.reduce((sum, ro) => sum + (ro.quantity || 0), 0);
const delta = (opt.quantity || 0) - existingQty;
const existingTotal = rowsTotal(existingRows);
const eventOption = eventOptionsMap.get(opt.eventOptionId);
const variantId = opt.variantId || null;
let priceSnapshot = null;
let appliedTierId = null;
let removalPlan = null;
if (delta > 0) {
try {
if (variantId) {
const variantResolved = await resolveVariantTierPrice(eventOption, variantId, delta);
priceSnapshot = variantResolved.price;
appliedTierId = variantResolved.tierId;
} else {
const resolved = await resolveOptionPrice(eventOption, delta);
priceSnapshot = resolved.price;
appliedTierId = resolved.tierId;
}
} catch (e) {
priceSnapshot = Number(eventOption?.price || 0);
}
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
} else if (delta < 0) {
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
removalPlan = planLIFORemoval(existingTranches, -delta);
newTotalDue += trancheSum(removalPlan.kept);
} else {
newTotalDue += existingTotal;
}
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
}
// Per-item floor: a ticket is only ever created once a registration is paid, and it is // Per-item floor: a ticket is only ever created once a registration is paid, and it is
// never deleted or shrunk — only grown. So an option can never be reduced (or removed) // never deleted or shrunk — only grown. So an option can never be reduced (or removed)
// below the quantity of any ticket already issued for it. // below the quantity of any ticket already issued for it.
@@ -1192,20 +1354,63 @@ const updateRegistrationOptions = async (req, res) => {
const existingRows = oldByKey.get(key); const existingRows = oldByKey.get(key);
if (existingRows && existingRows.length > 0) { if (existingRows && existingRows.length > 0) {
const [primary, ...dupes] = existingRows; const [primary, ...dupes] = existingRows;
await tx.registrationOption.update({
where: { id: primary.id }, // Consolidate dupes into primary first: move their tranches and tickets rather
data: { // than discarding them, so price history (and money) survives duplicate cleanup.
quantity: opt.quantity,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
}
});
for (const dup of dupes) { for (const dup of dupes) {
if ((dup.tranches || []).length > 0) {
await tx.registrationOptionTranche.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
}
if ((dup.tickets || []).length > 0) { if ((dup.tickets || []).length > 0) {
await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } }); await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
} }
await tx.registrationOption.delete({ where: { id: dup.id } }); await tx.registrationOption.delete({ where: { id: dup.id } });
} }
if (opt.delta > 0) {
// Increase: a new tranche for the delta at the freshly-resolved price — never
// overwrite the existing tranches' locked-in prices.
await tx.registrationOptionTranche.create({
data: {
id: uuidv4(),
registrationOptionId: primary.id,
quantity: opt.delta,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
});
await tx.registrationOption.update({
where: { id: primary.id },
data: { quantity: opt.quantity, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot }
});
} else if (opt.delta < 0 && opt.removalPlan) {
// Decrease: trim tranches newest-first (LIFO); issued tickets always map to the
// oldest tranches, and the floor check above already guarantees this never dips
// below issued-ticket quantity.
for (const op of opt.removalPlan.ops) {
if (op.type === 'delete') {
await tx.registrationOptionTranche.delete({ where: { id: op.trancheId } });
} else {
await tx.registrationOptionTranche.update({ where: { id: op.trancheId }, data: { quantity: op.newQuantity } });
}
}
const newest = [...opt.removalPlan.kept].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0];
await tx.registrationOption.update({
where: { id: primary.id },
data: {
quantity: opt.quantity,
appliedTierId: newest ? (newest.appliedTierId || null) : null,
priceSnapshot: newest ? newest.priceSnapshot : null,
}
});
} else {
// Unchanged quantity: only reflect dupe-consolidation in the aggregate; leave
// the locked-in price/tier untouched.
await tx.registrationOption.update({
where: { id: primary.id },
data: { quantity: opt.quantity }
});
}
} else { } else {
await tx.registrationOption.create({ await tx.registrationOption.create({
data: { data: {
@@ -1216,6 +1421,14 @@ const updateRegistrationOptions = async (req, res) => {
variantId: opt.variantId || null, variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null, appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot, priceSnapshot: opt.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}]
}
} }
}); });
} }
@@ -1237,7 +1450,7 @@ const updateRegistrationOptions = async (req, res) => {
const updated = await prisma.registration.findUnique({ const updated = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true, payments: true,
@@ -1261,7 +1474,7 @@ const updateRegistrationOptions = async (req, res) => {
})(); })();
} }
return res.json(updated); return res.json(attachComputedTotals(updated));
} catch (error) { } catch (error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
@@ -1280,7 +1493,7 @@ const submitFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
} }
}); });
@@ -1372,7 +1585,7 @@ const replaceFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({ const registration = await prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: true } }, registrationOptions: { include: { eventOption: true, tranches: true } },
event: true, event: true,
} }
}); });
@@ -1534,6 +1747,20 @@ const saveFormDraft = async (req, res) => {
} }
}; };
// @desc Re-send the current invoice PDF for one of the caller's own registrations
// @route POST /api/registrations/:id/send-invoice
// @access Private
const sendInvoice = async (req, res) => {
try {
const { channel } = req.body || {};
const { sendInvoiceToUser } = require('../utils/notifications');
await sendInvoiceToUser(req.params.id, req.user.id, channel);
res.json({ message: channel === 'whatsapp' ? 'Invoice sent to WhatsApp.' : 'Invoice emailed.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: error.message });
}
};
module.exports = { module.exports = {
createRegistration, createRegistration,
getRegistrations, getRegistrations,
@@ -1548,4 +1775,5 @@ module.exports = {
replaceFormResponses, replaceFormResponses,
getFormDraft, getFormDraft,
saveFormDraft, saveFormDraft,
sendInvoice,
}; };
+2 -1
View File
@@ -250,7 +250,8 @@ async function renderReportPdfToFile(payload) {
async function buildReportWorkbook(payload) { async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {}; const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook(); const wb = new ExcelJS.Workbook();
wb.creator = 'Hope Family Church Events'; const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
wb.creator = `${orgName} Events`;
wb.created = new Date(); wb.created = new Date();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report'; const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName); const ws = wb.addWorksheet(sheetName);
+12 -5
View File
@@ -8,7 +8,13 @@ const { encrypt, decrypt, isEncrypted } = require('../utils/encryption');
// Keys safe to return without auth — includes legal keys needed by public legal pages // Keys safe to return without auth — includes legal keys needed by public legal pages
const PUBLIC_KEYS = [ const PUBLIC_KEYS = [
'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address', 'org_name', 'org_tagline', 'org_email', 'org_phone', 'org_address',
'accent_color', 'logo_url', 'setup_complete', 'app_base_url', // Branding — primary_color/secondary_color/accent_color are the 3-color brand
// system (accent_color's meaning was repurposed from "the one brand color" to
// "tertiary accent color"; primary_color falls back to accent_color's legacy
// value wherever it's consumed, so older data still resolves sensibly until
// the admin re-saves the Branding tab).
'primary_color', 'secondary_color', 'accent_color', 'logo_url', 'favicon_url',
'setup_complete', 'app_base_url',
// Legal pages // Legal pages
'legal_operator_name', 'legal_io_name', 'legal_io_email', 'legal_operator_name', 'legal_io_name', 'legal_io_email',
'legal_website_url', 'legal_effective_date', 'legal_website_url', 'legal_effective_date',
@@ -293,12 +299,13 @@ const testSmtp = async (req, res) => {
// Send a real test email to the authenticated user so there's visible proof // Send a real test email to the authenticated user so there's visible proof
const adminEmail = req.user?.email; const adminEmail = req.user?.email;
if (adminEmail) { if (adminEmail) {
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
await transporter.sendMail({ await transporter.sendMail({
from: from || user || 'no-reply@hope-events.local', from: from || user || 'no-reply@crosscode.local',
to: adminEmail, to: adminEmail,
subject: 'SMTP test — Hope Events', subject: `SMTP test — ${orgName}`,
text: `This is a test email sent from the Hope Events admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`, text: `This is a test email sent from the ${orgName} admin panel to confirm that your SMTP settings are working correctly.\n\nHost: ${host}:${port}\nFrom: ${from || user}`,
html: `<p>This is a test email sent from the <strong>Hope Events</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`, html: `<p>This is a test email sent from the <strong>${orgName}</strong> admin panel to confirm that your SMTP settings are working correctly.</p><p><strong>Host:</strong> ${host}:${port}<br/><strong>From:</strong> ${from || user}</p>`,
}); });
} }
@@ -70,6 +70,34 @@ const uploadLogo = multer({
} }
}); });
// Favicon storage (same subfolder as the logo — both are "branding" assets)
const faviconStorage = multer.diskStorage({
destination: function (req, file, cb) {
const uploadPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'branding');
try {
if (!fs.existsSync(uploadPath)) fs.mkdirSync(uploadPath, { recursive: true });
cb(null, uploadPath);
} catch (error) {
cb(new Error(`Cannot access upload directory: ${error.message}`));
}
},
filename: function (req, file, cb) {
cb(null, `favicon-${Date.now()}${path.extname(file.originalname).toLowerCase()}`);
}
});
const uploadFavicon = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: function (req, file, cb) {
const ext = path.extname(file.originalname).toLowerCase();
if (!['.ico', '.png', '.svg'].includes(ext)) {
return cb(new Error('Only .ico, .png, or .svg files are allowed for the favicon'), false);
}
cb(null, true);
}
});
// Controller function // Controller function
const uploadEventImage = (req, res) => { const uploadEventImage = (req, res) => {
// Check for multer errors which would be passed in req.multerError // Check for multer errors which would be passed in req.multerError
@@ -95,9 +123,21 @@ const uploadLogoImage = (req, res) => {
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` }); res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
}; };
const uploadFaviconImage = (req, res) => {
if (req.multerError) {
return res.status(500).json({ message: `Upload failed: ${req.multerError.message}` });
}
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
res.status(200).json({ url: `/uploads/branding/${req.file.filename}` });
};
module.exports = { module.exports = {
upload, upload,
uploadEventImage, uploadEventImage,
uploadLogo, uploadLogo,
uploadLogoImage, uploadLogoImage,
uploadFavicon,
uploadFaviconImage,
}; };
+14 -8
View File
@@ -3,10 +3,17 @@ const { generateToken, hashPassword, comparePassword } = require('../config/auth
const { v4: uuidv4 } = require('uuid'); const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils'); const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents'); const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const axios = require('axios'); const axios = require('axios');
// ─── Helpers ──────────────────────────────────────────────────────────────── // ─── Helpers ────────────────────────────────────────────────────────────────
// Org name for email subjects — falls back to Cross Code (the platform vendor)
// rather than any specific customer's name when unconfigured.
function getOrgName() {
return getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
}
// Resolve a client IP from the request (works behind proxies) // Resolve a client IP from the request (works behind proxies)
function getClientIp(req) { function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for']; const forwarded = req.headers['x-forwarded-for'];
@@ -41,7 +48,7 @@ async function sendLoginNotification(user, req) {
const { buildWALogin } = require('../utils/waMessages'); const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent }); const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// Security: always email; also WhatsApp if preferred // Security: always email; also WhatsApp if preferred
await sendMail({ to: user.email, subject: 'New login to your Hope Events account', ...content }); await sendMail({ to: user.email, subject: `New login to your ${getOrgName()} account`, ...content });
const { waText } = require('../utils/notify'); const { waText } = require('../utils/notify');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {}); await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) { } catch (e) {
@@ -63,7 +70,7 @@ async function sendWelcomeEmail(user) {
const content = buildWelcomeEmail({ name: user.name, events }); const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify'); const { shouldEmail, waText } = require('../utils/notify');
// Welcome is always sent via email; also via WhatsApp if preferred // Welcome is always sent via email; also via WhatsApp if preferred
await sendMail({ to: user.email, subject: 'Welcome to Hope Events!', ...content }); await sendMail({ to: user.email, subject: `Welcome to ${getOrgName()}!`, ...content });
await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {}); await waText(user, buildWAWelcome({ name: user.name, events })).catch(() => {});
} catch (e) { } catch (e) {
console.warn('[welcome email] Failed:', e?.message || e); console.warn('[welcome email] Failed:', e?.message || e);
@@ -185,7 +192,7 @@ const loginUser = async (req, res) => {
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`; const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const { sendMail, buildAccountActivationEmail } = require('../utils/email'); const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl }); const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: 'Activate your Hope Events account', ...content }) 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 email] Failed:', e?.message || e));
} catch (e) { } catch (e) {
console.warn('[activation token] Failed to create activation token:', e?.message || e); console.warn('[activation token] Failed to create activation token:', e?.message || e);
@@ -207,7 +214,7 @@ const loginUser = async (req, res) => {
}); });
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001'; const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`; const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'); const orgName = getOrgName();
const waMessage = [ const waMessage = [
`🔓 *Activate your ${orgName} account*`, `🔓 *Activate your ${orgName} account*`,
'', '',
@@ -394,10 +401,9 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget) // If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) { if (newHashedPassword) {
const { sendMail, buildPasswordChangedEmail } = require('../utils/email'); const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const { getSettingSync } = require('../utils/settingsCache');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || ''); const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail }); const content = buildPasswordChangedEmail({ name: updatedUser.name, when: Date.now(), supportEmail });
sendMail({ to: updatedUser.email, subject: 'Your Hope Events password was changed', ...content }) sendMail({ to: updatedUser.email, subject: `Your ${getOrgName()} password was changed`, ...content })
.catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e)); .catch(e => console.warn('[email] Failed to send password changed alert:', e?.message || e));
const { waText } = require('../utils/notify'); const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {}); waText(updatedUser, content.text).catch(() => {});
@@ -903,7 +909,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify'); const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages'); const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: true }); const content = buildAccountClosedEmail({ name: realName, dataDeleted: true });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {}); sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
// WhatsApp while we still have phone (send before data wipe completes in-flight) // WhatsApp while we still have phone (send before data wipe completes in-flight)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {}); waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
} }
@@ -924,7 +930,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify'); const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages'); const { buildWAAccountClosed } = require('../utils/waMessages');
const content = buildAccountClosedEmail({ name: realName, dataDeleted: false }); const content = buildAccountClosedEmail({ name: realName, dataDeleted: false });
sendMail({ to: realEmail, subject: 'Your Hope Events account has been closed', ...content }).catch(() => {}); sendMail({ to: realEmail, subject: `Your ${getOrgName()} account has been closed`, ...content }).catch(() => {});
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {}); waText(user, buildWAAccountClosed({ name: realName, dataDeleted: false })).catch(() => {});
} }
return res.json({ message: 'Your account has been deactivated.' }); return res.json({ message: 'Your account has been deactivated.' });
+2 -1
View File
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
include: { include: {
earlyBirdTiers: true earlyBirdTiers: true
} }
} },
tranches: true
} }
}, },
payments: true payments: true
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache'); const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '') const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''); || getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`; const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/settings?tab=whatsapp`;
const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }); const when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({ await sendMail({
to: adminEmail, to: adminEmail,
subject: 'WhatsApp session is down — action required', subject: 'WhatsApp session is down — action required',
text: `The Hope Events WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`, text: `The ${orgName} WhatsApp session has failed and could not be automatically recovered.\n\nTime: ${when}\n\nPlease visit the admin dashboard to reconnect:\n${dashboardUrl}`,
html: `<p>The Hope Events WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`, html: `<p>The ${orgName} WhatsApp session has failed and could not be automatically recovered after ${MAX_ATTEMPTS} attempts.</p><p><strong>Time:</strong> ${when}</p><p>Please <a href="${dashboardUrl}">visit the admin dashboard</a> to re-scan the QR code and reconnect.</p>`,
}).catch(() => {}); }).catch(() => {});
} }
} catch (e) { } catch (e) {
@@ -164,7 +164,10 @@ const reconcileYocoTransaction = async (req, res) => {
const createdAt = ytx.createdDate || ytx.createdAt || new Date(); const createdAt = ytx.createdDate || ytx.createdAt || new Date();
const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } }); const payment = await prisma.payment.create({ data: { ...paymentData, createdAt } });
// Optionally update registration status when applicable and generate/email tickets if paid // Optionally update registration status when applicable and generate tickets if paid.
// Ticket generation stays synchronous so `generatedTickets` can be included in the response;
// emailing/WhatsApp-ing the tickets and payment confirmation are backgrounded below since
// they involve slow PDF rendering + SMTP/WAWP round trips that shouldn't block this request.
let generatedTickets = []; let generatedTickets = [];
if (registration) { if (registration) {
try { try {
@@ -172,15 +175,6 @@ const reconcileYocoTransaction = async (req, res) => {
if (updatedReg && updatedReg.status === 'paid') { if (updatedReg && updatedReg.status === 'paid') {
try { try {
generatedTickets = await generateTicketsForRegistration(registration.id); generatedTickets = await generateTicketsForRegistration(registration.id);
if (Array.isArray(generatedTickets) && generatedTickets.length > 0) {
try {
const mockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
} catch (emailErr) {
console.error('Error emailing tickets after Yoco reconciliation:', emailErr);
}
}
} catch (genErr) { } catch (genErr) {
console.error('Error generating tickets after Yoco reconciliation:', genErr); console.error('Error generating tickets after Yoco reconciliation:', genErr);
} }
@@ -191,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => {
} }
} }
// Send emails for the reconciled payment // Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
try { const { sendPaymentEmails } = require('../utils/notifications');
const { sendPaymentEmails } = require('../utils/notifications'); const _rxPaymentId = payment.id;
await sendPaymentEmails(payment.id); const _rxUserId = registration?.userId;
} catch (e) { const _rxRegId = registration?.id;
console.error('Failed to send payment emails after reconciliation:', e); const _rxShouldEmailTickets = Array.isArray(generatedTickets) && generatedTickets.length > 0;
} (async () => {
try { await sendPaymentEmails(_rxPaymentId); } catch (e) { console.error('Failed to send payment emails after reconciliation:', e); }
if (_rxShouldEmailTickets && _rxUserId) {
const mockReq = { user: { id: _rxUserId }, body: { registrationId: _rxRegId } };
const mockRes = { status: () => mockRes, json: () => {} };
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Error emailing tickets after Yoco reconciliation:', e); }
}
})();
// Update yoco transaction as reconciled // Update yoco transaction as reconciled
const updatedTx = await Yoco.update({ const updatedTx = await Yoco.update({
+37 -17
View File
@@ -141,7 +141,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 +245,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('Hope Events API — Status', '#2563eb', ` const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
<h1>Hope Events API</h1> const html = pageShell(`${orgName} Events API — Status`, '#2563eb', `
<h1>${orgName} Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p> <p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid"> <div class="stat-grid">
@@ -529,11 +531,20 @@ app.get('/docs', async (req, res) => {
pathParams:{ ':id':'Registration UUID' }, pathParams:{ ':id':'Registration UUID' },
request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }}, request:{ body:{ answers:[{ fieldId:'field-uuid-...', value:'Yes' }, { fieldId:'field-uuid-2', value:'Vegetarian' }] }},
responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]}, responses:[{ status:201, desc:'Submitted', body:{ id:'response-uuid-...', createdAt:'2025-06-01T10:05:00.000Z' }}]},
{ method:'POST', path:'/api/registrations/:id/send-invoice', auth:'user+', desc:'(Re)generate the invoice PDF for one of the caller\'s own registrations and send it on the requested channel — email or WhatsApp. Works regardless of balance (shows "Paid in full" when nothing is owed); fails only if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s invoiceNo is derived, not sequential: INV-<year of registration.createdAt>-<last 6 hex chars of the registration id, uppercased>. When there is an outstanding balance, generates a fresh Yoco checkout link for the invoice\'s QR code/pay-now link on each call.',
pathParams:{ ':id':'Registration UUID' },
request:{ body:{ channel:'email' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Invoice emailed.' }},
{ status:400, desc:'No valid contact for channel', body:{ message:'No valid email address on file. Add one in your profile.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this registration' }},
]},
]}, ]},
{ title: 'Payments', base: '/api/payments', endpoints: [ { title: 'Payments', base: '/api/payments', endpoints: [
{ method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.', { method:'POST', path:'/api/payments/yoco-checkout', auth:'user+', desc:'Initiate a Yoco checkout session. Before creating the checkout, re-evaluates early-bird tier eligibility (deadline + stock). If any price changed since registration, returns priceUpdated:true instead of creating a checkout — the client must inform the user and retry.',
request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.hopehenley.co.za/payment/success', cancelUrl:'https://events.hopehenley.co.za/payment/cancel', failureUrl:'https://events.hopehenley.co.za/payment/failure' }}, request:{ body:{ registrationId:'reg-uuid-...', amount:450, successUrl:'https://events.example.com/payment/success', cancelUrl:'https://events.example.com/payment/cancel', failureUrl:'https://events.example.com/payment/failure' }},
responses:[ responses:[
{ status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }}, { status:200, desc:'Checkout created — proceed to Yoco', body:{ redirectUrl:'https://pay.yoco.com/checkout/abc123', checkoutId:'yoco-checkout-id', amount:450 }},
{ status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }}, { status:200, desc:'Early-bird price changed — checkout NOT created. Frontend must show warning and let user confirm before retrying.', body:{ priceUpdated:true, newTotal:500, message:'One or more early-bird prices have changed since your registration was created. Please review the updated total before proceeding.' }},
@@ -546,6 +557,14 @@ app.get('/docs', async (req, res) => {
{ method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"', { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher|other — apple_pay/google_pay report as "card", any other gateway-reported value reports as "other"',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' }, queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by normalized method: cash|card|eft|voucher|other', kind:'payment|refund — filters by amount sign' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]}, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]},
{ method:'POST', path:'/api/payments/:id/send-receipt', auth:'user+', desc:'Regenerate the receipt PDF for one of the caller\'s own payments and send it on the requested channel — email or WhatsApp. Fails if there is no valid email/phone on file for the chosen channel.',
notes:'The PDF\'s receiptNo is derived, not sequential: RCPT-<year of payment.createdAt>-<last 6 hex chars of the payment id, uppercased>.',
pathParams:{ ':id':'Payment UUID' },
request:{ body:{ channel:'whatsapp' }},
responses:[
{ status:200, desc:'Sent', body:{ message:'Receipt sent to WhatsApp.' }},
{ status:403, desc:'Not the owner', body:{ message:'Not authorized to access this payment' }},
]},
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments',
queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' },
responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]},
@@ -750,7 +769,7 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]}, responses:[{ status:200, desc:'Saved', body:{ message:'WhatsApp config saved.' }}]},
{ method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status', { method:'GET', path:'/api/whatsapp/status', auth:'admin', desc:'Get the current WAWP session status',
responses:[ responses:[
{ status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Hope Events' }}, { status:200, desc:'Connected', body:{ status:'open', phoneNumber:'+27821234567', pushName:'Cross Code' }},
{ status:200, desc:'Not connected', body:{ status:'close' }}, { status:200, desc:'Not connected', body:{ status:'close' }},
]}, ]},
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account', { method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
@@ -773,14 +792,14 @@ app.get('/docs', async (req, res) => {
]}, ]},
{ title: 'Settings', base: '/api/settings', endpoints: [ { title: 'Settings', base: '/api/settings', endpoints: [
{ method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, accent colour, logo URL, legal page slugs, registration notification email (no secrets)', { method:'GET', path:'/api/settings', auth:'public', desc:'Public settings — org name, brand colours (primary/secondary/accent), logo URL, favicon URL, legal page slugs, registration notification email (no secrets)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]}, responses:[{ status:200, desc:'Success', body:{ org_name:'Cross Code', org_tagline:'Event management, made simple', primary_color:'#4F46E5', secondary_color:'#8B5CF6', accent_color:'#EC4899', logo_url:'/uploads/branding/logo-123.png', favicon_url:'/uploads/branding/favicon-123.png' }}]},
{ method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.', { method:'GET', path:'/api/settings/all', auth:'admin', desc:'All settings including SMTP config. smtp_pass is returned masked (••••••••); smtp_user is returned decrypted.',
responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]}, responses:[{ status:200, desc:'Success', body:[{ key:'smtp_host', value:'smtp.example.com' },{ key:'smtp_user', value:'user@example.com' },{ key:'smtp_pass', value:'••••••••' }]}]},
{ method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).', { method:'GET', path:'/api/settings/needs-setup', auth:'public', desc:'Returns true until the setup wizard has been completed (setup_complete flag is set).',
responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]}, responses:[{ status:200, desc:'Success', body:{ needsSetup:true }}]},
{ method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.', { method:'PUT', path:'/api/settings', auth:'admin', desc:'Upsert one or more settings. smtp_user and smtp_pass are AES-256-GCM encrypted before storage. Sending •••••••• for smtp_pass is a no-op.',
request:{ body:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }}, request:{ body:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password' }},
responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]}, responses:[{ status:200, desc:'Saved', body:{ message:'Settings saved.' }}]},
{ method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.', { method:'POST', path:'/api/settings/test-smtp', auth:'admin (or setup token)', desc:'Test the SMTP connection with provided credentials. On success, sends a real test email to the authenticated admin and returns a friendly message. On failure, returns a human-readable message plus a raw field containing the original SMTP error for debugging. Error code 530 (Microsoft "Client not authenticated") maps to the authentication-failure message.',
request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }}, request:{ body:{ host:'smtp.gmail.com', port:587, secure:false, user:'me@gmail.com', pass:'app-password', from:'me@gmail.com' }},
@@ -798,7 +817,7 @@ app.get('/docs', async (req, res) => {
{ status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }}, { status:400, desc:'Already exists', body:{ message:'Setup already completed — users exist.' }},
]}, ]},
{ method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.', { method:'POST', path:'/api/setup', auth:'setup token or admin', desc:'Final step of the setup wizard. Saves initial site settings. Requires the JWT returned by POST /api/setup/register.',
request:{ body:{ settings:{ org_name:'Hope Family Church', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}}, request:{ body:{ settings:{ org_name:'Cross Code', smtp_host:'smtp.gmail.com', smtp_port:'587', smtp_user:'user@gmail.com', smtp_pass:'app-password', mail_from:'noreply@example.com' }}},
responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]}, responses:[{ status:200, desc:'Setup complete', body:{ message:'Setup complete.' }}]},
]}, ]},
]; ];
@@ -964,12 +983,12 @@ app.get('/docs', async (req, res) => {
{ trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' }, { trigger: 'Scheduled — every day at 07:00 local server time, once per active event that has gone live and has not yet started', channels: ['Email'], recipients: 'Admin — registrations inbox + event notify recipients (falls back to the event creator)', subject: 'Daily summary: {event} — {date}', content: 'Stat tiles (registrations, paid, awaiting payment, revenue), full registrations table with balances, full payments & donations table.' },
]}, ]},
{ category: 'Account & security', items: [ { category: 'Account & security', items: [
{ trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to Hope Events!', content: 'Welcome message plus a list of upcoming events.' }, { trigger: 'New account registered', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'New user', subject: 'Welcome to {org}!', content: 'Welcome message plus a list of upcoming events.' },
{ trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your Hope Events account', content: 'One-time activation link; expires after 24 hours.' }, { trigger: 'Login attempt on an account that is not yet active', channels: ['Email (if a real address is on file)', 'WhatsApp (fallback when there is no usable email)'], recipients: 'User', subject: 'Activate your {org} account', content: 'One-time activation link; expires after 24 hours.' },
{ trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your Hope Events account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' }, { trigger: 'Successful login', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'New login to your {org} account', content: 'Login time, approximate location, device/user agent. Security alert — always emailed regardless of the user\'s notification preference.' },
{ trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your Hope Events password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' }, { trigger: 'Password changed via profile update', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Your {org} password was changed', content: 'Confirms the change and gives a support contact to use if it wasn\'t them.' },
{ trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' }, { trigger: 'Forgot-password request', channels: ['Email (always)', 'WhatsApp (if preferred)'], recipients: 'User', subject: 'Reset your password', content: 'Password reset link.' },
{ trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your Hope Events account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' }, { trigger: 'Account closed (self-service — "Deactivate" or "Delete my data")', channels: ['Email', 'WhatsApp (if preferred)'], recipients: 'User (sent to their last-known address/number just before data is wiped)', subject: 'Your {org} account has been closed', content: 'Confirms closure; wording differs slightly when personal data was also erased.' },
]}, ]},
{ category: 'Admin-triggered broadcasts', items: [ { category: 'Admin-triggered broadcasts', items: [
{ trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' }, { trigger: 'Bulk email to event attendees, or an ad-hoc email broadcast — sent immediately or on a schedule', channels: ['Email'], recipients: 'Selected attendees / users / ad-hoc addresses chosen by the sender', subject: 'Admin-authored', content: 'Free-form subject & body written by the sender, supporting {{name}}, {{event.title}}, {{event.link}} placeholders. Used for reminders, thank-yous, promos, and multi-step automations.' },
@@ -1013,13 +1032,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>Hope 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}
@@ -1043,7 +1063,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">Hope 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">&#8592; Status page</a> <a href="/" style="font-size:.82rem;color:#6b7280">&#8592; 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">
@@ -1070,7 +1090,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">
Hope Events API v${API_VERSION} &mdash; ${new Date().toISOString()} ${orgName} Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p> </p>
</div> </div>
<script> <script>
+5 -1
View File
@@ -8,10 +8,12 @@ const {
getPaymentsByRegistration, getPaymentsByRegistration,
getPaymentsByEvent, getPaymentsByEvent,
assignDonationToRegistration, assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout, createYocoCheckout,
sendPaymentLink, sendPaymentLink,
createRefund, createRefund,
getPaymentStats getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController'); } = require('../controllers/paymentController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware'); const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
@@ -20,6 +22,7 @@ router.post('/', protect, supervisor, createPayment);
router.post('/yoco-checkout', protect, createYocoCheckout); router.post('/yoco-checkout', protect, createYocoCheckout);
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink); router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
router.get('/mypayments', protect, getUserPayments); router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
router.get('/:id', protect, getPaymentById); router.get('/:id', protect, getPaymentById);
router.get('/registration/:registrationId', protect, getPaymentsByRegistration); router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
@@ -27,6 +30,7 @@ router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
router.get('/', protect, supervisor, getPayments); router.get('/', protect, supervisor, getPayments);
router.get('/event/:eventId', protect, staff, getPaymentsByEvent); router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration); router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, createRefund); router.post('/refund', protect, supervisor, createRefund);
router.get('/admin/stats', protect, admin, getPaymentStats); router.get('/admin/stats', protect, admin, getPaymentStats);
+2
View File
@@ -12,6 +12,7 @@ const {
updateRegistrationOptions, updateRegistrationOptions,
submitFormResponses, replaceFormResponses, submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft, getFormDraft, saveFormDraft,
sendInvoice,
} = require('../controllers/registrationController'); } = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware'); const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -20,6 +21,7 @@ router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations); router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions); router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration); router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId // Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById); router.get('/:id', optionalAuth, getRegistrationById);
+18 -7
View File
@@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { upload, uploadEventImage, uploadLogo, uploadLogoImage } = require('../controllers/uploadController'); const { upload, uploadEventImage, uploadLogo, uploadLogoImage, uploadFavicon, uploadFaviconImage } = require('../controllers/uploadController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware'); const { protect, supervisor, admin } = require('../middleware/authMiddleware');
const prisma = require('../config/db'); const prisma = require('../config/db');
@@ -17,11 +17,9 @@ router.post('/event-image', protect, supervisor, (req, res, next) => {
}); });
}, uploadEventImage); }, uploadEventImage);
// @route POST /api/uploads/logo // Shared access rule for branding assets (logo, favicon): admin, OR allowed
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet) // during first-time setup (no users yet).
// @access Admin or setup async function brandingAssetAccess(req, res, next) {
async function logoAccess(req, res, next) {
try { try {
const count = await prisma.user.count(); const count = await prisma.user.count();
if (count === 0) return next(); // first-time setup if (count === 0) return next(); // first-time setup
@@ -31,11 +29,24 @@ async function logoAccess(req, res, next) {
} }
} }
router.post('/logo', logoAccess, (req, res, next) => { // @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/logo', brandingAssetAccess, (req, res, next) => {
uploadLogo.single('image')(req, res, (err) => { uploadLogo.single('image')(req, res, (err) => {
if (err) req.multerError = err; if (err) req.multerError = err;
next(); next();
}); });
}, uploadLogoImage); }, uploadLogoImage);
// @route POST /api/uploads/favicon
// @desc Upload site favicon — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
router.post('/favicon', brandingAssetAccess, (req, res, next) => {
uploadFavicon.single('image')(req, res, (err) => {
if (err) req.multerError = err;
next();
});
}, uploadFaviconImage);
module.exports = router; module.exports = router;
+6 -4
View File
@@ -83,7 +83,7 @@ async function computeEventFinancials(eventId) {
}), }),
prisma.registrationOption.findMany({ prisma.registrationOption.findMany({
where: { registration: { eventId, status: 'paid' } }, where: { registration: { eventId, status: 'paid' } },
include: { eventOption: { select: { id: true, name: true, price: true } } } include: { eventOption: { select: { id: true, name: true, price: true } }, tranches: true }
}), }),
prisma.eventCashup.findMany({ prisma.eventCashup.findMany({
where: { eventId }, where: { eventId },
@@ -191,15 +191,17 @@ async function computeEventFinancials(eventId) {
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0); const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts; const netProfit = effectiveTotalRevenue - totalCosts;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown // What was actually sold, by ticket type — for the Finance report's income-stream breakdown.
// Revenue is tranche-aware: a line spanning two early-bird prices contributes each tranche
// at the price it was actually bought at, not one blended/stale price for the whole line.
const { computeOptionLineTotal } = require('./pricing');
const salesByOptionMap = {}; const salesByOptionMap = {};
for (const ro of salesRows) { for (const ro of salesRows) {
const opt = ro.eventOption; const opt = ro.eventOption;
if (!opt) continue; if (!opt) continue;
if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 }; if (!salesByOptionMap[opt.id]) salesByOptionMap[opt.id] = { eventOptionId: opt.id, name: opt.name, quantitySold: 0, revenue: 0 };
const unitPrice = ro.priceSnapshot != null ? ro.priceSnapshot : opt.price;
salesByOptionMap[opt.id].quantitySold += ro.quantity; salesByOptionMap[opt.id].quantitySold += ro.quantity;
salesByOptionMap[opt.id].revenue += unitPrice * ro.quantity; salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
} }
const salesByOption = Object.values(salesByOptionMap); const salesByOption = Object.values(salesByOptionMap);
+21 -8
View File
@@ -56,7 +56,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
return; return;
} }
const { transporter, cfg } = _getTransporter(); const { transporter, cfg } = _getTransporter();
const from = cfg.from || 'no-reply@hope-events.local'; const from = cfg.from || 'no-reply@crosscode.local';
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) }); const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) });
if (transporter.options && transporter.options.jsonTransport) { if (transporter.options && transporter.options.jsonTransport) {
@@ -74,12 +74,17 @@ async function sendMail({ to, subject, html, text, attachments }) {
function getOrg() { function getOrg() {
const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001'; const urlFallback = process.env.APP_BASE_URL || process.env.FRONTEND_URL || 'http://localhost:3001';
// headerColor prefers the new primary_color setting; falls back to the
// legacy accent_color value (which used to double as "the one brand color"
// before the 3-color Primary/Secondary/Accent system existed) so emails
// stay branded correctly until the admin re-saves the Branding tab.
const primary = getSettingSync('primary_color', '') || getSettingSync('accent_color', '');
return { return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'), tagline: getSettingSync('org_tagline', process.env.ORG_TAGLINE || 'Connecting community through events'),
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''), email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''), url: getSettingSync('app_base_url', urlFallback).replace(/\/$/, ''),
headerColor: getSettingSync('accent_color', process.env.EMAIL_HEADER_COLOR || '#1e3a5f'), headerColor: primary || process.env.EMAIL_HEADER_COLOR || '#1e3a5f',
}; };
} }
@@ -137,8 +142,9 @@ ${preheader ? `<div style="display:none;font-size:1px;line-height:1px;max-height
</html>`; </html>`;
} }
/** Renders a prominent CTA button. */ /** Renders a prominent CTA button. Defaults to the org's brand color unless a semantic override (e.g. green for "activate", dark neutral for "log in") is passed explicitly. */
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) { function ctaButton(label, url, { bg, fg = '#ffffff' } = {}) {
bg = bg || getOrg().headerColor;
return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto"> return `<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:28px auto 8px auto">
<tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px"> <tr><td align="center" style="border-radius:8px;background-color:${bg};mso-padding-alt:0px">
<a href="${url}" target="_blank" <a href="${url}" target="_blank"
@@ -150,8 +156,9 @@ function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
/** Renders a fallback link below a CTA button. */ /** Renders a fallback link below a CTA button. */
function fallbackLink(url) { function fallbackLink(url) {
const color = getOrg().headerColor;
return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all"> return `<p style="text-align:center;margin:4px 0 0 0;font-size:12px;color:#94a3b8;word-break:break-all">
Or copy this link: <a href="${url}" style="color:#2563eb">${url}</a> Or copy this link: <a href="${url}" style="color:${color}">${url}</a>
</p>`; </p>`;
} }
@@ -160,7 +167,12 @@ function divider() {
return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`; return `<div style="border-top:1px solid #f1f5f9;margin:32px 0"></div>`;
} }
/** Coloured callout box. type: info | success | warning | danger | neutral */ /**
* Coloured callout box. type: info | success | warning | danger | neutral
* These 4 semantic colors are deliberately NOT brand-driven a "this wasn't
* you" security warning must always read as urgent/red regardless of the
* org's brand color, so don't wire these to getOrg().headerColor.
*/
function callout(content, type = 'info') { function callout(content, type = 'info') {
const map = { const map = {
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' }, info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
@@ -177,9 +189,10 @@ function callout(content, type = 'info') {
/** Numbered payment option row. */ /** Numbered payment option row. */
function paymentOption(num, title, detail) { function paymentOption(num, title, detail) {
const bg = getOrg().headerColor;
return `<tr> return `<tr>
<td style="padding:14px 16px 14px 0;vertical-align:top;width:28px"> <td style="padding:14px 16px 14px 0;vertical-align:top;width:28px">
<div style="width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div> <div style="width:26px;height:26px;border-radius:50%;background:${bg};color:#fff;font-size:13px;font-weight:700;text-align:center;line-height:26px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${num}</div>
</td> </td>
<td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top"> <td style="padding:14px 0;border-bottom:1px solid #f1f5f9;vertical-align:top">
<p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p> <p style="margin:0 0 4px 0;font-size:14px;font-weight:700;color:#1e293b;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif">${title}</p>
+406 -28
View File
@@ -1,6 +1,7 @@
const fs = require('fs');
const prisma = require('../config/db'); const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email'); const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
// ─── Formatting helpers ─────────────────────────────────────────────────────── // ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -23,7 +24,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() { function getOrg() {
return { return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '', email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''), url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
}; };
@@ -72,7 +73,7 @@ async function loadRegistrationFull(registrationId) {
return prisma.registration.findUnique({ return prisma.registration.findUnique({
where: { id: registrationId }, where: { id: registrationId },
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true, payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
@@ -98,7 +99,7 @@ async function loadPaymentFull(paymentId) {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
registration: { registration: {
include: { include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: true, tranches: true } },
payments: true, payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } }, event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
@@ -116,11 +117,11 @@ function selectionsTable(registrationOptions) {
const rows = (registrationOptions || []).map(ro => { const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option'; const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1; const qty = ro.quantity || 1;
const price = ro.eventOption?.price || 0; const lineTotal = computeOptionLineTotal(ro, null, new Date());
return `<tr> return `<tr>
<td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td> <td style="padding:10px 16px 10px 0;font-size:14px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${name}</td>
<td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td> <td style="padding:10px 0;font-size:14px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${qty}</td>
<td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(price * qty)}</td> <td style="padding:10px 0 10px 16px;font-size:14px;color:#374151;text-align:right;font-weight:500;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(lineTotal)}</td>
</tr>`; </tr>`;
}); });
@@ -270,7 +271,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })} ${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${accountCta(isUserActive, org.url)}`; ${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`; const text = `${heading}\n\nHi ${reg.user?.name || 'there'},\n\n${isNew ? `You are registered for ${eventTitle}` : `Your registration for ${eventTitle} has been updated`}${eventDate ? ' on ' + eventDate : ''}.\n\nYour selections:\n${itemsText || ' —'}\n\nTotal due: ${fmtAmount(totalDue)}\nAmount paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${balance > 0 ? `Payment options:\n 1. On our website: ${org.url}\n 2. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` : 'No payment required — your tickets have been sent separately.'}\n\n${org.name}${org.email}\n${org.url}`;
return { subject, text, html: emailWrapper(body, { preheader }) }; return { subject, text, html: emailWrapper(body, { preheader }) };
@@ -310,7 +311,7 @@ function buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink = null, for
${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })} ${paymentSection({ balance, yocoLink, source: 'admin', siteUrl: org.url, formRequired, isUserActive })}
${accountCta(isUserActive, org.url)}`; ${accountCta(isUserActive, org.url)}`;
const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`).join('\n'); const itemsText = (reg.registrationOptions || []).map(ro => `${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`).join('\n');
const payText = balance > 0 const payText = balance > 0
? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.` ? `Payment options:\n${yocoLink ? ` 1. Pay online (Yoco): ${yocoLink}\n (Already paid? Ignore this option)\n` : ''} ${yocoLink ? '2' : '1'}. On our website: ${org.url}\n ${yocoLink ? '3' : '2'}. At the door (cash or card)\n\nYour tickets will be sent once payment is confirmed.`
: formRequired : formRequired
@@ -338,7 +339,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } =
`<tr> `<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td> <td style="padding:8px 12px;font-size:13px;color:#374151;font-family:${ff};border-bottom:1px solid #f1f5f9">${ro.eventOption?.name || 'Option'}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td> <td style="padding:8px 12px;font-size:13px;color:#374151;text-align:center;font-family:${ff};border-bottom:1px solid #f1f5f9">×${ro.quantity}</td>
<td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}</td> <td style="padding:8px 12px;font-size:13px;color:#374151;text-align:right;font-family:${ff};border-bottom:1px solid #f1f5f9">${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}</td>
</tr>`).join(''); </tr>`).join('');
const body = ` const body = `
@@ -639,6 +640,109 @@ function buildPaymentAdminNotice(payment) {
return { to, subject, text, html: emailWrapper(body) }; return { to, subject, text, html: emailWrapper(body) };
} }
// ─── Donation unassignment ─────────────────────────────────────────────────────
//
// Sent when staff reverse a previous donation-assignment. Distinct from
// buildDonationAppliedToRegistrant: the leg payment no longer exists by the time this runs
// (it's hard-deleted before the notification fires), so callers pass a synthetic payment-shaped
// object — { amount, createdAt, registration } — built from the leg's captured values plus a
// freshly re-fetched registration so the balance table reflects the post-removal total.
// Kept anonymous (no donor name), same reasoning as the "applied" email.
function buildDonationUnassignedFromRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg?.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
const isUserActive = reg?.user?.isActive;
const subject = `A donation was removed from your registration ${eventTitle}`;
const preheader = `A donation of ${fmtAmount(payment.amount)} was removed from your registration for ${eventTitle}.`;
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0;letter-spacing:-0.3px">A donation was removed from your registration</p>
<p style="font-size:14px;color:#64748b;margin:0 0 32px 0">Your balance has changed</p>
<p style="margin:0 0 4px 0;color:#374151;font-family:${ff}">Hi <strong>${reg?.user?.name || 'there'}</strong>,</p>
<p style="margin:0 0 28px 0;color:#374151;font-family:${ff}">
A donation of <strong>${fmtAmount(payment.amount)}</strong> previously applied to your registration for <strong>${eventTitle}</strong> has been removed by our team.
</p>
${callout(`<strong style="font-size:15px">${fmtAmount(payment.amount)} removed</strong><br/>
<span style="font-size:13px">Date: ${fmtDate(new Date())}</span>`,
'warning')}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin:24px 0">
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total due</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalDue)}</td>
</tr>
<tr>
<td style="font-size:13px;color:#64748b;padding:5px 0;font-family:${ff}">Total paid</td>
<td style="font-size:13px;text-align:right;font-weight:500;color:#374151;padding:5px 0;font-family:${ff}">${fmtAmount(totalPaid)}</td>
</tr>
<tr>
<td style="font-size:14px;font-weight:700;color:#0f172a;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${balance <= 0 ? 'Fully paid ✓' : 'Balance remaining'}</td>
<td style="font-size:14px;font-weight:700;color:#${balance <= 0 ? '059669' : '0f172a'};text-align:right;padding:10px 0 5px 0;border-top:1px solid #e2e8f0;font-family:${ff}">${fmtAmount(balance)}</td>
</tr>
</table>
${balance > 0
? callout(`<strong>A balance is now owing.</strong> Please arrange payment of ${fmtAmount(balance)} to secure your registration.`, 'warning')
: ''}
${accountCta(isUserActive, org.url)}`;
const text = `A donation was removed from your registration\n\nHi ${reg?.user?.name || 'there'},\n\nA donation of ${fmtAmount(payment.amount)} previously applied to your registration for ${eventTitle} has been removed by our team.\n\nTotal due: ${fmtAmount(totalDue)}\nTotal paid: ${fmtAmount(totalPaid)}\nBalance: ${fmtAmount(balance)}\n\n${org.name}${org.email}`;
return { subject, text, html: emailWrapper(body, { preheader }) };
}
// Internal admin notice for a donation-assignment — same table layout as
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
// since legs copy userId from the original donation, not the registrant), relabeled so it
// doesn't read as a fresh incoming payment: no new money changed hands here, an
// already-recorded donation was just reallocated to a registration.
function buildDonationAssignmentAdminNotice(payment) {
const notice = buildPaymentAdminNotice(payment);
const eventTitle = payment.registration?.event?.title || 'Event';
const payerName = payment.user?.name || '—';
const subject = `Donation applied: ${fmtAmount(payment.amount)}${payerName} (${eventTitle})`;
return {
...notice,
subject,
html: notice.html
.replace('Payment recorded', 'Donation applied')
.replace('Internal notification', 'Internal notification — donation applied to a registration')
.replace('>Registration payment<', '>Donation applied<'),
text: notice.text
.replace('Payment recorded', 'Donation applied')
.replace('Type: Registration payment', 'Type: Donation applied'),
};
}
// Internal admin notice for a donation-unassignment — same table layout as
// buildPaymentAdminNotice (which reads payment.user as "Payer" — for a leg that's the donor,
// since legs copy userId from the original donation, not the registrant), with copy adjusted
// for a removal rather than a new payment.
function buildDonationUnassignmentAdminNotice(payment) {
const notice = buildPaymentAdminNotice(payment);
const eventTitle = payment.registration?.event?.title || 'Event';
const payerName = payment.user?.name || '—';
const subject = `Donation unassigned: ${fmtAmount(payment.amount)}${payerName} (${eventTitle})`;
return {
...notice,
subject,
html: notice.html
.replace('Payment recorded', 'Donation unassigned')
.replace('Internal notification', 'Internal notification — donation removed from registration')
.replace('>Registration payment<', '>Donation unassigned<'),
text: notice.text
.replace('Payment recorded', 'Donation unassigned')
.replace('Type: Registration payment', 'Type: Donation unassigned'),
};
}
// ─── Refund email ───────────────────────────────────────────────────────────── // ─── Refund email ─────────────────────────────────────────────────────────────
function buildRefundEmail(payment) { function buildRefundEmail(payment) {
@@ -795,25 +899,74 @@ function buildDailySummary(ev, registrations, payments, now) {
// ─── Send functions ─────────────────────────────────────────────────────────── // ─── Send functions ───────────────────────────────────────────────────────────
/**
* Generates an invoice PDF for a registration when it still has an outstanding balance,
* reusing (or, if none was supplied, creating) a Yoco checkout link so the invoice can carry
* a clickable "pay now" link and a scannable QR code. Returns null when nothing is owed or
* PDF generation fails callers fall back to the existing text-only notification either way.
*/
/**
* Resolves a Yoco checkout link for an invoice's "pay now" link/QR code. Returns the given
* hint as-is when supplied; otherwise creates a fresh checkout, or null if nothing is owed
* (no point paying) or checkout creation fails (invoice still generates, just without a link).
*/
async function resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (paymentUrlHint) return paymentUrlHint;
if (totalDue - totalPaid <= 0.01) return null;
try {
const { createRegistrationCheckoutInternal } = require('../controllers/paymentController');
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const checkout = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return checkout.redirectUrl || null;
} catch (e) {
console.warn('Could not create Yoco checkout for invoice PDF:', e?.message || e);
return null;
}
}
// Only used by the automatic registration-confirmation sends, which should stay silent (no
// invoice attached) when nothing is owed — receipts already cover the fully-paid case.
async function maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint = null }) {
if (totalDue - totalPaid <= 0.01) return null;
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid, paymentUrlHint });
try {
const { generateInvoicePdf } = require('./pdfDocs');
return await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
return null;
}
}
async function sendRegistrationEmails(registrationId) { async function sendRegistrationEmails(registrationId) {
let invoicePdf = null;
try { try {
const reg = await loadRegistrationFull(registrationId); const reg = await loadRegistrationFull(registrationId);
if (!reg) return; if (!reg) return;
const { shouldEmail, waText } = require('./notify'); const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages'); const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue } = require('./pricing');
const sends = []; const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
// Email: only for real addresses (skip guest.local placeholders) // Email: only for real addresses (skip guest.local placeholders)
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: true }); const msg = buildRegistrationConfirmation(reg, { isNew: true });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
// WhatsApp: always attempt — waText checks canWhatsApp (preference + valid phone) internally // WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const waCaption = buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true }); const adminMsg = buildRegistrationAdminNotice(reg, { isNew: true });
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
@@ -822,25 +975,34 @@ async function sendRegistrationEmails(registrationId) {
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send registration emails:', e); console.error('Failed to send registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
} }
} }
async function sendRegistrationUpdatedEmails(registrationId) { async function sendRegistrationUpdatedEmails(registrationId) {
let invoicePdf = null;
try { try {
const reg = await loadRegistrationFull(registrationId); const reg = await loadRegistrationFull(registrationId);
if (!reg) return; if (!reg) return;
const { shouldEmail, waText } = require('./notify'); const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages'); const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue } = require('./pricing');
const sends = []; const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildRegistrationConfirmation(reg, { isNew: false }); const msg = buildRegistrationConfirmation(reg, { isNew: false });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const waCaption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true }); const adminMsg = buildRegistrationAdminNotice(reg, { isNew: false, isUpdated: true });
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
@@ -848,25 +1010,34 @@ async function sendRegistrationUpdatedEmails(registrationId) {
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send registration updated emails:', e); console.error('Failed to send registration updated emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
} }
} }
async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) { async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl = null, formRequired = false, isNew = true } = {}) {
let invoicePdf = null;
try { try {
const reg = await loadRegistrationFull(registrationId); const reg = await loadRegistrationFull(registrationId);
if (!reg) return; if (!reg) return;
const { shouldEmail, waText } = require('./notify'); const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages'); const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing'); const { computeRegistrationTotalDue } = require('./pricing');
const sends = []; const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date()); const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
invoicePdf = await maybeGenerateInvoicePdf(reg, { totalDue, totalPaid, paymentUrlHint: paymentUrl });
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) { if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew }); const msg = buildAdminInitiatedRegistrationConfirmation(reg, { yocoLink: paymentUrl, formRequired, isNew });
if (shouldEmail(reg.user)) sends.push(sendMail({ to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(reg.user)) sends.push(sendMail({
to: reg.user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(invoicePdf ? { attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
sends.push(waText(reg.user, buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) }))); const waCaption = buildWARegistration(reg, { isNew, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
sends.push(invoicePdf ? waPdf(reg.user, invoicePdf.filePath, invoicePdf.filename, waCaption) : waText(reg.user, waCaption));
const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew }); const adminMsg = buildRegistrationAdminNotice(reg, { isNew, isUpdated: !isNew });
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
@@ -874,28 +1045,40 @@ async function sendSelfServiceRegistrationEmails(registrationId, { paymentUrl =
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send self-service registration emails:', e); console.error('Failed to send self-service registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
} }
} }
async function sendPaymentEmails(paymentId) { async function sendPaymentEmails(paymentId) {
let receiptPdf = null;
try { try {
const payment = await loadPaymentFull(paymentId); const payment = await loadPaymentFull(paymentId);
if (!payment) return; if (!payment) return;
const user = payment.registration?.user || payment.user; const user = payment.registration?.user || payment.user;
const { shouldEmail, waText, waTextAny } = require('./notify'); const { shouldEmail, waPdf, waPdfAny, waText, waTextAny } = require('./notify');
const { buildWAPayment } = require('./waMessages'); const { buildWAPayment } = require('./waMessages');
const { generateReceiptPdf } = require('./pdfDocs');
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
const sends = []; const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) { if (hasValidEmail) {
const msg = buildPaymentReceipt(payment); const msg = buildPaymentReceipt(payment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text })); if (shouldEmail(user)) sends.push(sendMail({
to: user.email, subject: msg.subject, html: msg.html, text: msg.text,
...(receiptPdf ? { attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }] } : {}),
}));
} }
// WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't // WhatsApp: respect preference when email is available; use as unconditional fallback when it isn't.
// Send the receipt PDF (with the usual text as its caption) when generation succeeded,
// otherwise fall back to the plain text message so a PDF failure never blocks delivery.
const waCaption = buildWAPayment(payment);
if (hasValidEmail) { if (hasValidEmail) {
sends.push(waText(user, buildWAPayment(payment))); sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
} else { } else {
sends.push(waTextAny(user, buildWAPayment(payment))); sends.push(receiptPdf ? waPdfAny(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waTextAny(user, waCaption));
} }
const hasEvent = !!(payment.registration?.eventId || payment.eventId); const hasEvent = !!(payment.registration?.eventId || payment.eventId);
if (hasEvent) { if (hasEvent) {
@@ -907,6 +1090,8 @@ async function sendPaymentEmails(paymentId) {
await Promise.all(sends); await Promise.all(sends);
} catch (e) { } catch (e) {
console.error('Failed to send payment emails:', e); console.error('Failed to send payment emails:', e);
} finally {
if (receiptPdf) try { fs.unlinkSync(receiptPdf.filePath); } catch {}
} }
} }
@@ -980,7 +1165,7 @@ async function sendDonationAssignmentEmails(paymentId) {
} else { } else {
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment))); sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
} }
const adminMsg = buildPaymentAdminNotice(payment); const adminMsg = buildDonationAssignmentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) { if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text })); sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
} }
@@ -990,19 +1175,209 @@ async function sendDonationAssignmentEmails(paymentId) {
} }
} }
// Sent when staff reverse a previous donation-assignment (the leg payment is hard-deleted
// before this runs, so it can't be re-fetched by id like loadPaymentFull does elsewhere —
// callers pass a snapshot of the leg's fields captured just before deletion instead).
// Notifies the registrant (balance likely increased) and logs an internal admin notice showing
// the donor as "Payer", same as the original assignment notice did.
async function sendDonationUnassignmentEmails(leg) {
try {
const [registration, donor] = await Promise.all([
prisma.registration.findUnique({
where: { id: leg.registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true, isActive: true, notificationPreference: true } },
event: { include: { createdBy: { select: { id: true, name: true, email: true } }, notifyRecipients: { select: { id: true, name: true, email: true } } } },
},
}),
leg.userId ? prisma.user.findUnique({ where: { id: leg.userId }, select: { id: true, name: true, email: true } }) : null,
]);
if (!registration) return;
// Synthetic payment-shaped object matching what buildDonationUnassignedFromRegistrant and
// buildPaymentAdminNotice (via buildDonationUnassignmentAdminNotice) expect.
const pseudoPayment = {
id: leg.id,
amount: leg.amount,
createdAt: leg.createdAt,
method: leg.method,
externalId: leg.externalId,
registrationId: leg.registrationId,
registration,
user: donor,
};
const user = registration.user;
const { shouldEmail, waText, waTextAny } = require('./notify');
const { buildWADonationUnassignedFromRegistrant } = require('./waMessages');
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
const msg = buildDonationUnassignedFromRegistrant(pseudoPayment);
if (shouldEmail(user)) sends.push(sendMail({ to: user.email, subject: msg.subject, html: msg.html, text: msg.text }));
}
if (hasValidEmail) {
sends.push(waText(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
} else {
sends.push(waTextAny(user, buildWADonationUnassignedFromRegistrant(pseudoPayment)));
}
const adminMsg = buildDonationUnassignmentAdminNotice(pseudoPayment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
await Promise.all(sends);
} catch (e) {
console.error('Failed to send donation-unassignment emails:', e);
}
}
// ─── Manual "send to me" requests ──────────────────────────────────────────────
// Unlike the fire-and-forget sends above, these are awaited directly from a
// request handler (dashboard "Email/WhatsApp invoice|receipt" buttons) — they
// throw an Error with a `statusCode` on failure instead of swallowing it, so the
// controller can surface a real error to the user rather than failing silently.
function userFacingError(message, statusCode) {
const e = new Error(message);
e.statusCode = statusCode;
return e;
}
/**
* (re)generates and sends a registration's invoice PDF to the registrant on a single,
* explicitly-chosen channel. Only the registrant themself may request their own invoice.
* @param {string} registrationId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendInvoiceToUser(registrationId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const reg = await loadRegistrationFull(registrationId);
if (!reg) throw userFacingError('Registration not found', 404);
if (reg.userId !== requesterId) throw userFacingError('Not authorized to access this registration', 403);
if (channel === 'email' && (!reg.user?.email || reg.user.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !reg.user?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
// Unlike the automatic post-registration send, a manually-requested invoice is generated
// regardless of balance — someone may want it as a paid-in-full record too.
const paymentUrl = await resolveInvoicePaymentUrl(reg, { totalDue, totalPaid });
let invoicePdf;
try {
const { generateInvoicePdf } = require('./pdfDocs');
invoicePdf = await generateInvoicePdf(reg, { paymentUrl, totalDue, totalPaid });
} catch (e) {
console.error('Failed to generate invoice PDF:', e);
throw userFacingError('Could not generate the invoice right now. Please try again shortly.', 500);
}
try {
const eventTitle = reg.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: reg.user.email,
subject: `Your invoice for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your invoice is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${reg.user?.name || 'there'}</strong>, here's the invoice you requested for <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${reg.user?.name || 'there'},\n\nHere's the invoice you requested for ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: invoicePdf.filename, path: invoicePdf.filePath, contentType: 'application/pdf' }],
});
} else {
// Explicit user-requested channel — bypass the notification-preference gate (waPdfAny)
// the same way ticket resends do, so "WhatsApp invoice" works even if the user's saved
// preference is email-only.
const { waPdfAny } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const caption = buildWARegistration(reg, { isNew: false, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) });
await waPdfAny(reg.user, invoicePdf.filePath, invoicePdf.filename, caption);
}
} finally {
try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
/**
* (Re)generates and sends a payment's receipt PDF to the payer on a single, explicitly-chosen
* channel. Only the person the payment belongs to (via the payment itself or its registration)
* may request their own receipt.
* @param {string} paymentId
* @param {string} requesterId - req.user.id of the caller
* @param {'email'|'whatsapp'} channel
*/
async function sendReceiptToUser(paymentId, requesterId, channel) {
if (channel !== 'email' && channel !== 'whatsapp') throw userFacingError('Invalid channel', 400);
const payment = await loadPaymentFull(paymentId);
if (!payment) throw userFacingError('Payment not found', 404);
const owner = payment.registration?.user || payment.user;
const ownerId = payment.registration?.userId || payment.userId;
if (ownerId !== requesterId) throw userFacingError('Not authorized to access this payment', 403);
if (channel === 'email' && (!owner?.email || owner.email.endsWith('@guest.local'))) {
throw userFacingError('No valid email address on file. Add one in your profile.', 400);
}
if (channel === 'whatsapp' && !owner?.phoneNumber) {
throw userFacingError('No phone number on file. Add one in your profile.', 400);
}
const { generateReceiptPdf } = require('./pdfDocs');
let receiptPdf;
try {
receiptPdf = await generateReceiptPdf(payment);
} catch (e) {
console.error('Failed to generate receipt PDF:', e);
throw userFacingError('Could not generate the receipt right now. Please try again shortly.', 500);
}
try {
const eventTitle = payment.registration?.event?.title || payment.event?.title || 'your registration';
if (channel === 'email') {
await sendMail({
to: owner.email,
subject: `Your payment receipt for ${eventTitle}`,
html: emailWrapper(
`<p style="font-size:18px;font-weight:700;color:#0f172a;margin:0 0 12px 0">Your receipt is attached</p>
<p style="margin:0;color:#374151;font-family:${ff}">Hi <strong>${owner?.name || 'there'}</strong>, here's the receipt you requested for your payment of <strong>${fmtAmount(payment.amount)}</strong> towards <strong>${eventTitle}</strong>.</p>`
),
text: `Hi ${owner?.name || 'there'},\n\nHere's the receipt you requested for your payment of ${fmtAmount(payment.amount)} towards ${eventTitle} — see the attached PDF.`,
attachments: [{ filename: receiptPdf.filename, path: receiptPdf.filePath, contentType: 'application/pdf' }],
});
} else {
const { waPdfAny } = require('./notify');
const { buildWAPayment } = require('./waMessages');
await waPdfAny(owner, receiptPdf.filePath, receiptPdf.filename, buildWAPayment(payment));
}
} finally {
try { fs.unlinkSync(receiptPdf.filePath); } catch {}
}
}
async function sendDailyEventSummaries(now = new Date()) { async function sendDailyEventSummaries(now = new Date()) {
try { try {
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' },
}); });
@@ -1013,7 +1388,7 @@ async function sendDailyEventSummaries(now = new Date()) {
where: { eventId: ev.id }, where: { eventId: ev.id },
include: { include: {
user: { select: { id: true, name: true, email: true, phoneNumber: true } }, user: { select: { id: true, name: true, email: true, phoneNumber: true } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } }, registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true, payments: true,
}, },
orderBy: { createdAt: 'asc' }, orderBy: { createdAt: 'asc' },
@@ -1045,6 +1420,9 @@ module.exports = {
sendRefundEmail, sendRefundEmail,
sendSelfServiceRegistrationEmails, sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails, sendDonationAssignmentEmails,
sendDonationUnassignmentEmails,
sendCheckInEmails, sendCheckInEmails,
buildCheckInConfirmation, buildCheckInConfirmation,
sendInvoiceToUser,
sendReceiptToUser,
}; };
+351
View File
@@ -0,0 +1,351 @@
/**
* Branded PDF documents: payment receipts and registration invoices.
*
* Both are generated with pdfkit into backend/temp and returned as
* { filePath, filename } for callers to attach to an email/WhatsApp send and
* clean up afterwards (see notifications.js).
*
* Line items are built the same tranche-aware way the user dashboard renders
* them (backend/src/utils/pricing.js) merging tranches that share a
* name/price/tier and separating early-bird lines from standard-price ones
* so the PDF total always matches computeRegistrationTotalDue().
*/
const fs = require('fs');
const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const { getSetting } = require('./settingsCache');
function fmtAmount(amt) {
return `R${Number(amt || 0).toFixed(2)}`;
}
function fmtDate(d) {
try { return new Date(d).toLocaleDateString('en-GB', { day: 'numeric', month: 'long', year: 'numeric' }); } catch { return ''; }
}
function formatMethod(method) {
const m = String(method || '').toLowerCase();
if (!m) return '—';
if (m === 'eft') return 'EFT';
return m.replace(/^./, c => c.toUpperCase());
}
function tempDir() {
const dir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
return dir;
}
function docNumber(prefix, id, date) {
const year = new Date(date || Date.now()).getFullYear();
const short = String(id || '').replace(/-/g, '').slice(-6).toUpperCase() || '000000';
return `${prefix}-${year}-${short}`;
}
async function getBranding() {
const [name, address, email, phone, primary, accent, logoUrl] = await Promise.all([
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
getSetting('org_address', ''),
getSetting('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
getSetting('org_phone', ''),
getSetting('primary_color', ''),
getSetting('accent_color', ''),
getSetting('logo_url', ''),
]);
const url = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
let logoPath = null;
if (logoUrl) {
const p = path.join(__dirname, '..', '..', 'public', logoUrl.replace(/^\//, ''));
if (fs.existsSync(p)) logoPath = p;
}
return { name, address, email, phone, brandColor: primary || accent || '#1e3a5f', url, logoPath };
}
/**
* Merge a registration's tranches into display rows, same grouping logic as
* the user dashboard: one row per (name, unit price, early-bird flag), with
* `basePrice` carried along so callers can work out the early-bird discount.
*/
function buildLineItems(registrationOptions) {
const rows = [];
for (const ro of (registrationOptions || [])) {
const variantLabel = ro.variant?.name ? ` (${ro.variant.name})` : '';
const label = `${ro.eventOption?.name || 'Option'}${variantLabel}`;
const basePrice = Number((ro.variant?.price ?? ro.eventOption?.price ?? 0));
const tranches = Array.isArray(ro.tranches) && ro.tranches.length > 0
? ro.tranches
: [{
quantity: ro.quantity,
priceSnapshot: (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) ? Number(ro.priceSnapshot) : basePrice,
appliedTierId: ro.appliedTierId,
}];
for (const t of tranches) {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
let row = rows.find(r => r.key === key);
if (!row) { row = { key, label, quantity: 0, unitPrice, isEarlyBird, basePrice }; rows.push(row); }
row.quantity += (t.quantity || 0);
}
}
return rows;
}
/** Draws a two-column item table starting at `y`; returns the y position after the last row. */
function drawItemsTable(doc, { x, width, y, rows, brandColor, headerLight = true }) {
const colDesc = x;
// Fixed-width columns anchored to the right edge so amounts never wrap,
// regardless of the overall table width (receipt vs. narrower invoice table).
const totalColW = 75, priceColW = 65, qtyColW = 35;
const colTotal = x + width - totalColW;
const colPrice = colTotal - priceColW;
const colQty = colPrice - qtyColW;
const rowH = 22;
if (headerLight) {
doc.rect(x, y, width, rowH).fill('#f8fafc');
doc.fillColor('#64748b').font('Helvetica-Bold').fontSize(9);
} else {
doc.rect(x, y, width, rowH).fill(brandColor);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(9);
}
doc.text('DESCRIPTION', colDesc + 8, y + 7);
doc.text('QTY', colQty, y + 7, { width: qtyColW - 8, align: 'right' });
doc.text('PRICE', colPrice, y + 7, { width: priceColW - 8, align: 'right' });
doc.text('TOTAL', colTotal, y + 7, { width: totalColW - 8, align: 'right' });
y += rowH;
doc.font('Helvetica').fontSize(10).fillColor('#374151');
for (const row of rows) {
doc.text(row.label + (row.isEarlyBird ? ' (early bird)' : ''), colDesc + 8, y + 6, { width: colQty - colDesc - 12 });
doc.text(String(row.quantity), colQty, y + 6, { width: qtyColW - 8, align: 'right' });
doc.text(fmtAmount(row.unitPrice), colPrice, y + 6, { width: priceColW - 8, align: 'right' });
doc.text(fmtAmount(row.unitPrice * row.quantity), colTotal, y + 6, { width: totalColW - 8, align: 'right' });
doc.moveTo(x, y + rowH).lineTo(x + width, y + rowH).strokeColor('#e2e8f0').lineWidth(0.5).stroke();
y += rowH;
}
return y;
}
// ─── Payment receipt ──────────────────────────────────────────────────────────
/**
* @param {object} payment - from notifications.js loadPaymentFull: amount, method, externalId,
* createdAt, user, registration { event, registrationOptions[{eventOption,variant,tranches}] }
* @returns {Promise<{ filePath: string, filename: string }>}
*/
async function generateReceiptPdf(payment) {
const org = await getBranding();
const reg = payment.registration;
const user = reg?.user || payment.user;
const eventTitle = reg?.event?.title || payment.event?.title || 'Event';
const eventStartDate = reg?.event?.startDate || payment.event?.startDate || null;
const eventDate = eventStartDate ? fmtDate(eventStartDate) : '';
const receiptNo = docNumber('RCPT', payment.id, payment.createdAt);
const rows = reg ? buildLineItems(reg.registrationOptions) : [];
const filename = `receipt-${receiptNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const writeStream = fs.createWriteStream(filePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const marginX = 40;
// Header banner
const bannerH = 150;
doc.rect(0, 0, pageWidth, bannerH).fill(org.brandColor);
if (org.logoPath) {
try { doc.image(org.logoPath, marginX, 28, { fit: [36, 36] }); } catch {}
}
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(13).text(org.name, marginX + (org.logoPath ? 46 : 0), 38, { width: 260 });
doc.font('Helvetica-Bold').fontSize(26).text('Payment Receipt', marginX, 68, { width: 320 });
doc.font('Helvetica').fontSize(11).text('Thank you for your payment.', marginX, 102, { width: 320 });
if (eventStartDate) doc.font('Helvetica-Bold').fontSize(10).text('We look forward to seeing you at the event!', marginX, 122, { width: 320 });
const metaX = pageWidth - 250;
const meta = [
['Receipt No.', receiptNo],
['Date', fmtDate(payment.createdAt)],
['Payment Method', formatMethod(payment.method)],
['Transaction ID', payment.externalId || payment.id.slice(0, 12)],
];
let metaY = 34;
for (const [label, value] of meta) {
doc.font('Helvetica').fontSize(9).fillColor('#ffffff').fillOpacity(0.75).text(label, metaX, metaY, { width: 90 });
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').fillOpacity(1).text(value, metaX + 90, metaY, { width: 120, align: 'right' });
metaY += 24;
}
// Body
let y = bannerH + 30;
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYER DETAILS', marginX, y);
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('EVENT DETAILS', marginX + 280, y);
y += 16;
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(user?.name || 'Guest', marginX, y);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#0f172a').text(eventTitle, marginX + 280, y, { width: 240 });
y += 16;
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user?.email || '', marginX, y);
if (eventDate) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(eventDate, marginX + 280, y);
y += 14;
if (user?.phoneNumber) doc.font('Helvetica').fontSize(9).fillColor('#374151').text(user.phoneNumber, marginX, y);
y += 30;
if (rows.length > 0) {
y = drawItemsTable(doc, { x: marginX, width: pageWidth - marginX * 2, y, rows, brandColor: org.brandColor });
}
y += 20;
const boxW = 160, boxH = 32;
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('TOTAL PAID', pageWidth - marginX - boxW - 110, y + 10, { width: 90, align: 'right' });
doc.roundedRect(pageWidth - marginX - boxW, y, boxW, boxH, 4).fill(org.brandColor);
doc.font('Helvetica-Bold').fontSize(14).fillColor('#ffffff').text(fmtAmount(payment.amount), pageWidth - marginX - boxW, y + 9, { width: boxW, align: 'center' });
y += boxH + 30;
// Footer
const footerH = 60;
const footerY = Math.max(pageHeight - footerH, y + 20);
doc.rect(0, footerY, pageWidth, footerH).fill('#0f172a');
doc.font('Helvetica-BoldOblique').fontSize(16).fillColor('#ffffff').text('Thank you!', marginX, footerY + 20);
doc.font('Helvetica').fontSize(9).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 0, footerY + 24, { width: pageWidth - marginX, align: 'right' });
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
return { filePath, filename };
}
// ─── Invoice ───────────────────────────────────────────────────────────────────
/**
* @param {object} registration - from notifications.js loadRegistrationFull: id, createdAt,
* user, event, registrationOptions[{eventOption,variant,tranches}], payments
* @param {{ paymentUrl?: string|null, totalDue?: number, totalPaid?: number }} opts
* @returns {Promise<{ filePath: string, filename: string }>}
*/
async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = null, totalPaid = null } = {}) {
const org = await getBranding();
const { computeRegistrationTotalDue } = require('./pricing');
const due = totalDue !== null ? totalDue : computeRegistrationTotalDue(registration, new Date());
const paid = totalPaid !== null ? totalPaid : (registration.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(due - paid, 0);
const invoiceNo = docNumber('INV', registration.id, registration.createdAt);
const rows = buildLineItems(registration.registrationOptions);
// Subtotal/discount: only early-bird rows are compared against today's base price, so an
// ordinary base-price change over time never shows up as a false "discount" on standard rows.
const fullPriceTotal = rows.reduce((s, r) => s + (r.isEarlyBird ? r.basePrice : r.unitPrice) * r.quantity, 0);
const chargedTotal = rows.reduce((s, r) => s + r.unitPrice * r.quantity, 0);
const discount = Math.max(0, fullPriceTotal - chargedTotal);
const filename = `invoice-${invoiceNo}.pdf`;
const filePath = path.join(tempDir(), `${Date.now()}-${filename}`);
const doc = new PDFDocument({ size: 'A4', margin: 0 });
const writeStream = fs.createWriteStream(filePath);
doc.pipe(writeStream);
const pageWidth = doc.page.width;
const pageHeight = doc.page.height;
const sidebarW = 190;
const mainX = sidebarW + 30;
const mainW = pageWidth - mainX - 40;
// Sidebar
doc.rect(0, 0, sidebarW, pageHeight).fill('#111827');
let sy = 40;
if (org.logoPath) {
try { doc.image(org.logoPath, 28, sy, { fit: [32, 32] }); sy += 0; } catch {}
}
doc.font('Helvetica-Bold').fontSize(12).fillColor('#ffffff').text(org.name, org.logoPath ? 68 : 28, sy + 8, { width: sidebarW - (org.logoPath ? 96 : 56) });
sy += 60;
doc.font('Helvetica-Bold').fontSize(20).fillColor('#ffffff').text('INVOICE', 28, sy, { width: sidebarW - 56 });
sy += 28;
doc.font('Helvetica-Bold').fontSize(11).fillColor(org.brandColor).text(invoiceNo, 28, sy, { width: sidebarW - 56 });
sy += 34;
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('DATE ISSUED', 28, sy);
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(fmtDate(registration.createdAt), 28, sy + 11);
sy += 40;
doc.font('Helvetica').fontSize(8).fillColor(org.brandColor).text('BILL TO', 28, sy);
sy += 13;
doc.font('Helvetica-Bold').fontSize(10).fillColor('#ffffff').text(registration.user?.name || 'Guest', 28, sy, { width: sidebarW - 56 });
sy += 15;
doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user?.email || '', 28, sy, { width: sidebarW - 56 });
sy += 12;
if (registration.user?.phoneNumber) { doc.font('Helvetica').fontSize(8).fillColor('#cbd5e1').text(registration.user.phoneNumber, 28, sy, { width: sidebarW - 56 }); sy += 12; }
let by = pageHeight - 140;
doc.font('Helvetica-Bold').fontSize(9).fillColor('#ffffff').text(org.name, 28, by, { width: sidebarW - 56 });
by += 13;
if (org.address) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.address, 28, by, { width: sidebarW - 56 }); by += 12 * Math.ceil(org.address.length / 28); }
if (org.email) { doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.email, 28, by, { width: sidebarW - 56 }); by += 12; }
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text(org.url.replace(/^https?:\/\//, ''), 28, by, { width: sidebarW - 56 });
// Main content
let y = 40;
doc.font('Helvetica-Bold').fontSize(9).fillColor(org.brandColor).text('EVENT', mainX, y);
y += 14;
doc.font('Helvetica-Bold').fontSize(14).fillColor('#0f172a').text(registration.event?.title || 'Event', mainX, y, { width: mainW });
y += 18;
if (registration.event?.startDate) { doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(fmtDate(registration.event.startDate), mainX, y); y += 14; }
y += 16;
y = drawItemsTable(doc, { x: mainX, width: mainW, y, rows, brandColor: org.brandColor, headerLight: false });
y += 16;
const totalsX = mainX + mainW - 220;
const totalLine = (label, value, opts = {}) => {
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
.text(label, totalsX, y, { width: 120 });
doc.font(opts.bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(opts.size || 10).fillColor(opts.color || '#374151')
.text(value, totalsX + 120, y, { width: 100, align: 'right' });
y += (opts.size || 10) + 10;
};
if (discount > 0.01) {
totalLine('SUBTOTAL', fmtAmount(fullPriceTotal));
totalLine('DISCOUNT', `-${fmtAmount(discount)}`, { color: '#059669' });
doc.moveTo(totalsX, y).lineTo(totalsX + 220, y).strokeColor('#e2e8f0').stroke();
y += 8;
}
totalLine('TOTAL DUE', fmtAmount(due), { bold: true, size: 13, color: org.brandColor });
if (paid > 0) totalLine('Already paid', `-${fmtAmount(paid)}`, { size: 9 });
if (paid > 0) totalLine('BALANCE DUE', fmtAmount(balance), { bold: true, size: 12, color: balance > 0 ? org.brandColor : '#059669' });
y += 20;
// Payment section
if (balance > 0.01) {
doc.font('Helvetica-Bold').fontSize(10).fillColor(org.brandColor).text('PAYMENT', mainX, y);
y += 16;
if (paymentUrl) {
const qrSize = 100;
try {
const qrBuffer = await QRCode.toBuffer(paymentUrl, { width: qrSize, margin: 1 });
doc.image(qrBuffer, mainX + mainW - qrSize, y, { width: qrSize, height: qrSize });
doc.font('Helvetica').fontSize(8).fillColor('#94a3b8').text('Scan to pay', mainX + mainW - qrSize, y + qrSize + 4, { width: qrSize, align: 'center' });
} catch {}
doc.font('Helvetica').fontSize(9).fillColor('#374151').text('Pay online — tap the link or scan the QR code:', mainX, y, { width: mainW - 120 });
doc.font('Helvetica-Bold').fontSize(10).fillColor('#2563eb').text(paymentUrl, mainX, y + 16, { width: mainW - 120, link: paymentUrl, underline: true });
y += 60;
} else {
doc.font('Helvetica').fontSize(9).fillColor('#374151').text(`Pay online at ${org.url} or at the door (cash/card).`, mainX, y, { width: mainW });
y += 24;
}
} else {
doc.font('Helvetica-Bold').fontSize(11).fillColor('#059669').text('PAID IN FULL', mainX, y);
y += 24;
}
// Footer
const footerH = 40;
const footerY = Math.max(pageHeight - footerH, y + 20);
doc.rect(sidebarW, footerY, pageWidth - sidebarW, footerH).fill(org.brandColor);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#ffffff').text('Thank you for your support!', sidebarW, footerY + 13, { width: pageWidth - sidebarW, align: 'center' });
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
return { filePath, filename };
}
module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, getBranding, docNumber };
+133 -44
View File
@@ -7,11 +7,21 @@
* Used at registration-creation time and again at payment-initiation time. * Used at registration-creation time and again at payment-initiation time.
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and * - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot. * as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after * - A RegistrationOption is an aggregate line (one per registration+option+variant); each
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice. * purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption * priceSnapshot + appliedTierId, never mutated after creation mirrors the Payment model's
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier * append-only pattern). This is what lets someone buy more of the same ticket type after an
* is now expired or its stock is exhausted. * early-bird tier expires without the new price bleeding onto tickets already bought.
* RegistrationOption.quantity/priceSnapshot/appliedTierId are kept as a maintained mirror
* (quantity = sum of tranche quantities; priceSnapshot/appliedTierId = most recent tranche)
* for code that only needs "how many" or a single display price.
* - computeRegistrationTotalDue: sums quantity*priceSnapshot across each RegistrationOption's
* tranches (falling back to the legacy single-priceSnapshot/getEffectiveUnitPrice path for
* rows created before tranches existed).
* - refreshPricingForRegistration: re-evaluates only the newest, not-yet-paid-for tranche of
* each RegistrationOption; if its tier has expired or lost stock, resolves the next
* applicable tier/price for just that tranche. Older tranches already priced-in are
* never touched.
*/ */
const prisma = require('../config/db'); const prisma = require('../config/db');
@@ -74,12 +84,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
// Skip expired tiers // Skip expired tiers
if (now >= new Date(tier.deadline)) continue; if (now >= new Date(tier.deadline)) continue;
// Check stock limit if one is set // Check stock limit if one is set. Sold-so-far is summed across tranches (not
// RegistrationOption rows directly) because one RegistrationOption can now span
// multiple tiers across its tranches — the row's own appliedTierId/quantity only
// reflects its most recent tranche.
if (tier.stockLimit > 0) { if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({ const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: { where: {
appliedTierId: tier.id, appliedTierId: tier.id,
registration: { status: { not: 'cancelled' } } registrationOption: { registration: { status: { not: 'cancelled' } } }
}, },
_sum: { quantity: true } _sum: { quantity: true }
}); });
@@ -125,8 +138,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
for (const tier of tiers) { for (const tier of tiers) {
if (now >= new Date(tier.deadline)) continue; if (now >= new Date(tier.deadline)) continue;
if (tier.stockLimit > 0) { if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({ const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } }, where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
_sum: { quantity: true } _sum: { quantity: true }
}); });
const tierSold = soldAgg._sum?.quantity || 0; const tierSold = soldAgg._sum?.quantity || 0;
@@ -154,58 +167,115 @@ async function refreshPricingForRegistration(registrationId) {
include: { include: {
registrationOptions: { registrationOptions: {
include: { include: {
eventOption: { include: { earlyBirdTiers: true, variants: true } } eventOption: { include: { earlyBirdTiers: true, variants: true } },
tranches: true,
} }
} },
payments: true,
} }
}); });
if (!registration) return { changed: false }; if (!registration) return { changed: false };
let anyChanged = false; const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
// For each option, only its single newest tranche is ever a repricing candidate — older
// tranches were already locked in at purchase time and must never be touched. A newest
// tranche is only touched once payments-so-far are established to not yet cover it (i.e.
// it's the still-unpaid remainder) — legacy rows with no tranches are left to the
// getEffectiveUnitPrice fallback in computeRegistrationTotalDue instead.
let registrationOlderTotal = 0;
const candidates = [];
for (const ro of registration.registrationOptions) { for (const ro of registration.registrationOptions) {
// Only refresh options that were priced via a tier const tranches = ro.tranches || [];
if (!ro.appliedTierId) continue; if (tranches.length === 0) continue;
const sorted = [...tranches].sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
const newest = sorted[sorted.length - 1];
const older = sorted.slice(0, -1);
registrationOlderTotal += older.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
candidates.push({ ro, newest });
}
// Payments don't even cover the already-locked-in older tranches yet — leave everything
// alone rather than guessing which portion is "paid for".
if (totalPaid < registrationOlderTotal - 0.001) return { changed: false };
// Each candidate is independent, so resolve/update them concurrently instead of one at a
// time — this loop sits directly in the payment-capture path.
const results = await Promise.all(candidates.map(async ({ ro, newest }) => {
// Only refresh tranches that were priced via a tier
if (!newest.appliedTierId) return false;
// Find the currently applied tier // Find the currently applied tier
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.appliedTierId); const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === newest.appliedTierId);
if (currentTier && new Date() < new Date(currentTier.deadline)) { if (currentTier && new Date() < new Date(currentTier.deadline)) {
// The tier's deadline is still in the future — honor the locked price. // The tier's deadline is still in the future — honor the locked price.
continue; return false;
} }
// Deadline has passed (or tier record missing) — resolve the next applicable tier // Deadline has passed (or tier record missing) — resolve the next applicable tier
const resolved = ro.variantId const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity) ? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, ro.quantity); : await resolveOptionPrice(ro.eventOption, newest.quantity);
const tierChanged = resolved.tierId !== ro.appliedTierId; const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001; const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
if (tierChanged || priceChanged) { if (tierChanged || priceChanged) {
await prisma.registrationOptionTranche.update({
where: { id: newest.id },
data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
});
// Mirror onto the aggregate row — it's this option's most recent tranche.
await prisma.registrationOption.update({ await prisma.registrationOption.update({
where: { id: ro.id }, where: { id: ro.id },
data: { data: { priceSnapshot: resolved.price, appliedTierId: resolved.tierId }
priceSnapshot: resolved.price,
appliedTierId: resolved.tierId
}
}); });
anyChanged = true; return true;
} }
return false;
}));
return { changed: results.some(Boolean) };
}
/**
* Compute the total for a single RegistrationOption line sums quantity*priceSnapshot
* across its tranches (each priced at whatever was in effect when it was purchased).
* Falls back to the legacy single priceSnapshot/getEffectiveUnitPrice path for rows
* created before tranches existed.
*
* @param {object} ro - RegistrationOption, optionally with .tranches[] included
* @param {Date|null} lastPaymentAt - used only for the legacy getEffectiveUnitPrice fallback
* @param {Date} atTime - evaluation time (used only for the legacy fallback)
* @returns {number}
*/
function computeOptionLineTotal(ro, lastPaymentAt, atTime) {
if (Array.isArray(ro.tranches) && ro.tranches.length > 0) {
return ro.tranches.reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
} }
return { changed: anyChanged }; const qty = Number(ro.quantity || 0);
let unit;
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) {
// priceSnapshot is authoritative — set at registration creation and kept current
// by refreshPricingForRegistration at payment initiation time.
unit = ro.priceSnapshot;
} else {
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines
const eo = ro.eventOption || {};
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime);
}
return qty * unit;
} }
/** /**
* Compute total due for a registration at a given time. * Compute total due for a registration at a given time.
* *
* Uses priceSnapshot when present (authoritative set at registration time and refreshed * Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice * `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
* for legacy rows without a snapshot.
* *
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption} * @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[] * and optionally payments[]
* @param {Date} atTime - evaluation time (used for legacy fallback only) * @param {Date} atTime - evaluation time (used for legacy fallback only)
* @returns {number} * @returns {number}
@@ -221,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) {
} }
} catch {} } catch {}
return registration.registrationOptions.reduce((sum, ro) => { return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
const qty = Number(ro.quantity || 0); }
let unit;
if (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) { /**
// priceSnapshot is authoritative — set at registration creation and kept current * Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
// by refreshPricingForRegistration at payment initiation time. * each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
unit = ro.priceSnapshot; * responses hand the frontend an exact total instead of every page re-deriving
} else { * unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
// Fallback: legacy row without a snapshot — re-evaluate from tier deadlines *
const eo = ro.eventOption || {}; * @param {object} registration - requires registrationOptions[].tranches included
unit = getEffectiveUnitPrice(eo, lastPaymentAt, atTime); * @returns {object} the same registration, mutated
*/
function attachComputedTotals(registration) {
if (!registration) return registration;
let lastPaymentAt = null;
try {
if (registration.payments && Array.isArray(registration.payments) && registration.payments.length > 0) {
lastPaymentAt = new Date(Math.max(...registration.payments.map(p => new Date(p.createdAt).getTime())));
} }
} catch {}
const now = new Date();
if (Array.isArray(registration.registrationOptions)) {
for (const ro of registration.registrationOptions) {
ro.lineTotal = computeOptionLineTotal(ro, lastPaymentAt, now);
}
}
registration.totalDueComputed = computeRegistrationTotalDue(registration, now);
return registration;
}
return sum + qty * unit; function attachComputedTotalsToList(registrations) {
}, 0); return (registrations || []).map(attachComputedTotals);
} }
module.exports = { module.exports = {
@@ -244,5 +330,8 @@ module.exports = {
resolveOptionPrice, resolveOptionPrice,
resolveVariantTierPrice, resolveVariantTierPrice,
refreshPricingForRegistration, refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue, computeRegistrationTotalDue,
attachComputedTotals,
attachComputedTotalsToList,
}; };
+31 -28
View File
@@ -54,42 +54,43 @@ const generateTicketsForRegistration = async (registrationId) => {
byOption.get(key).push(ro); byOption.get(key).push(ro);
} }
// For each group with duplicates, merge into the one that has tickets (or the first) // For each group with duplicates, merge into the one that has tickets (or the first).
for (const [, group] of byOption) { // Different (eventOptionId, variantId) groups touch disjoint rows, so process groups
if (group.length <= 1) continue; // concurrently instead of one at a time.
await Promise.all(Array.from(byOption.values()).map(async (group) => {
if (group.length <= 1) return;
// Prefer the option that already has tickets // Prefer the option that already has tickets
const withTickets = group.filter(ro => (ro.tickets || []).length > 0); const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
const primary = withTickets.length > 0 ? withTickets[0] : group[0]; const primary = withTickets.length > 0 ? withTickets[0] : group[0];
const duplicates = group.filter(ro => ro.id !== primary.id); const duplicates = group.filter(ro => ro.id !== primary.id);
// Move all tickets from duplicates to primary, then delete duplicate options // Move all tickets from duplicates to primary (independent rows — safe to parallelize)
for (const dup of duplicates) { await Promise.all(duplicates.map(dup => Promise.all(
for (const t of (dup.tickets || [])) { (dup.tickets || []).map(t =>
await prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } }); prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } })
} )
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0); )));
await prisma.registrationOption.update({
where: { id: primary.id },
data: { quantity: (primary.quantity || 0) + totalMergedQty, }
});
await prisma.registrationOption.delete({ where: { id: dup.id } });
}
// Re-load the primary's current quantity after merge // Single write of the merged quantity, then delete the now-empty duplicate options
const totalMergedQty = duplicates.reduce((s, d) => s + (d.quantity || 0), 0);
await prisma.registrationOption.update({
where: { id: primary.id },
data: { quantity: (primary.quantity || 0) + totalMergedQty }
});
await Promise.all(duplicates.map(dup => prisma.registrationOption.delete({ where: { id: dup.id } })));
// Re-load the primary's current quantity + tickets after merge
const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } }); const updated = await prisma.registrationOption.findUnique({ where: { id: primary.id } });
primary.quantity = updated?.quantity ?? primary.quantity; primary.quantity = updated?.quantity ?? primary.quantity;
// Reload tickets
primary.tickets = await prisma.ticket.findMany({ primary.tickets = await prisma.ticket.findMany({
where: { registrationOptionId: primary.id }, where: { registrationOptionId: primary.id },
include: { usages: true }, include: { usages: true },
orderBy: { createdAt: 'asc' } orderBy: { createdAt: 'asc' }
}); });
} }));
// ── Step 2: For each unique option, ensure exactly one ticket with correct qty ── // ── Step 2: For each unique option, ensure exactly one ticket with correct qty ──
const generatedTickets = [];
// Re-read fresh list (some options may have been deleted above) // Re-read fresh list (some options may have been deleted above)
const freshOptions = await prisma.registrationOption.findMany({ const freshOptions = await prisma.registrationOption.findMany({
where: { registrationId }, where: { registrationId },
@@ -98,13 +99,14 @@ const generateTicketsForRegistration = async (registrationId) => {
} }
}); });
for (const option of freshOptions) { // Each option owns disjoint tickets, so resolve them concurrently instead of one at a time.
const perOptionResults = await Promise.all(freshOptions.map(async (option) => {
const targetQty = option.quantity || 1; const targetQty = option.quantity || 1;
const existingTickets = option.tickets || []; const existingTickets = option.tickets || [];
if (existingTickets.length === 0) { if (existingTickets.length === 0) {
// Create one ticket // Create one ticket
const ticket = await prisma.ticket.create({ return prisma.ticket.create({
data: { data: {
id: uuidv4(), id: uuidv4(),
qrCode: uuidv4(), qrCode: uuidv4(),
@@ -115,27 +117,28 @@ const generateTicketsForRegistration = async (registrationId) => {
updatedAt: new Date() updatedAt: new Date()
} }
}); });
generatedTickets.push(ticket);
continue;
} }
// Pick primary: prefer scanned, otherwise oldest // Pick primary: prefer scanned, otherwise oldest
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0); const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0]; const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
const updates = [];
// Update quantity on primary if needed // Update quantity on primary if needed
if (primary.quantity !== targetQty) { if (primary.quantity !== targetQty) {
await prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }); updates.push(prisma.ticket.update({ where: { id: primary.id }, data: { quantity: targetQty, updatedAt: new Date() } }));
} }
// Delete unscanned duplicates // Delete unscanned duplicates
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0); const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).length === 0);
if (dups.length > 0) { if (dups.length > 0) {
await prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }); updates.push(prisma.ticket.deleteMany({ where: { id: { in: dups.map(t => t.id) } } }));
} }
} if (updates.length > 0) await Promise.all(updates);
return null;
}));
return generatedTickets; return perOptionResults.filter(Boolean);
} catch (error) { } catch (error) {
console.error('Error generating tickets:', error); console.error('Error generating tickets:', error);
throw error; throw error;
+36 -2
View File
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() { function getOrg() {
return { return {
name: getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events'), name: getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code'),
email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '', email: process.env.EMAIL_FROM || process.env.EMAIL_USER || '',
url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''), url: (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, ''),
}; };
@@ -46,8 +46,9 @@ function buildWARegistration(reg, { isNew = true, balance, totalDue, totalPaid }
? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.` ? `You're registered for *${eventTitle}*${eventDate ? ` on ${eventDate}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`; : `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
const items = (reg.registrationOptions || []) const items = (reg.registrationOptions || [])
.map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount((ro.eventOption?.price || 0) * ro.quantity)}`) .map(ro => `- ${ro.eventOption?.name || 'Option'} ×${ro.quantity}${fmtAmount(computeOptionLineTotal(ro, null, new Date()))}`)
.join('\n'); .join('\n');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0); const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
@@ -266,11 +267,44 @@ function buildWADonationAppliedToRegistrant(payment) {
].join('\n'); ].join('\n');
} }
// Mirrors buildWADonationAppliedToRegistrant for the reverse action — payment.createdAt here is
// the leg's original creation time, not "now"; use the current date for the balance display.
function buildWADonationUnassignedFromRegistrant(payment) {
const org = getOrg();
const reg = payment.registration;
const eventTitle = reg?.event?.title || 'the event';
const name = reg?.user?.name || 'there';
const amount = fmtAmount(payment.amount);
let balLine = '';
if (reg) {
const { computeRegistrationTotalDue } = require('./pricing');
const totalDue = computeRegistrationTotalDue(reg, new Date());
const totalPaid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
const balance = Math.max(totalDue - totalPaid, 0);
balLine = balance > 0
? `\n*Balance now owing:* ${fmtAmount(balance)}\n_Please arrange payment at ${org.url} or at the door._`
: '';
}
return [
`⚠️ *Donation Removed*`,
'',
`Hi ${name},`,
'',
`A donation of *${amount}* previously applied to your registration for *${eventTitle}* has been removed by our team.`,
balLine,
'',
`_${org.name}_ | ${org.url}`,
].join('\n');
}
module.exports = { module.exports = {
buildWARegistration, buildWARegistration,
buildWAPayment, buildWAPayment,
buildWARefund, buildWARefund,
buildWADonationAppliedToRegistrant, buildWADonationAppliedToRegistrant,
buildWADonationUnassignedFromRegistrant,
buildWALogin, buildWALogin,
buildWAWelcome, buildWAWelcome,
buildWAAccountClosed, buildWAAccountClosed,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.5.3", "version": "1.9.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

+7
View File
@@ -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>
@@ -158,7 +158,9 @@ export default function AdminRegistrationsPage() {
return "text-gray-700 bg-gray-50"; return "text-gray-700 bg-gray-50";
}; };
const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => { // 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) const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0); : (opt.eventOption?.price || 0);
@@ -334,25 +336,40 @@ export default function AdminRegistrationsPage() {
<div className="mb-3"> <div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div> <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"> <div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => ( {r.registrationOptions.map((opt: any) => {
<div key={opt.id} className="bg-white border rounded p-2 text-xs"> // A line can span multiple price tranches (e.g. tickets bought
<div className="font-medium"> // before and after an early-bird tier expired) — show one row per
{opt.eventOption?.name || opt.eventOptionId} // tranche so its own price/tier status is accurate, not blended.
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>} const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
</div> ? opt.tranches
<div className="text-gray-500"> : [{
{(() => { id: opt.id,
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0); : (opt.variant?.price ?? opt.eventOption?.price ?? 0),
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`; appliedTierId: opt.appliedTierId,
})()} }];
return (
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
{tranches.map((t: any, idx: number) => {
const unit = Number(t.priceSnapshot || 0);
return (
<div key={t.id || idx} className="text-gray-500">
{`Qty: ${t.quantity} × R ${unit.toFixed(2)} = R ${(unit * (t.quantity || 0)).toFixed(2)}`}
{t.appliedTierId && (
<span className="text-green-700 text-[10px] ml-1">(early bird)</span>
)}
</div>
);
})}
</div> </div>
{opt.appliedTierId && ( );
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div> })}
)}
</div>
))}
</div> </div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div> <div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
</div> </div>
@@ -7,6 +7,10 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState"; import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react"; import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
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";
@@ -97,11 +101,18 @@ function SiteSettingsPageInner() {
const [appBaseUrl, setAppBaseUrl] = useState(""); const [appBaseUrl, setAppBaseUrl] = useState("");
// ── Branding ── // ── Branding ──
const [accentColor, setAccentColor] = useState("#2563eb"); const [primaryColor, setPrimaryColor] = useState("#4F46E5");
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
const [accentColor, setAccentColor] = useState("#EC4899");
const [logoUrl, setLogoUrl] = useState(""); const [logoUrl, setLogoUrl] = useState("");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [faviconUrl, setFaviconUrl] = useState("");
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
const faviconRef = useRef<HTMLInputElement>(null);
// ── Notifications ── // ── Notifications ──
const [notifEmails, setNotifEmails] = useState(""); const [notifEmails, setNotifEmails] = useState("");
@@ -135,8 +146,16 @@ function SiteSettingsPageInner() {
setOrgPhone(s.org_phone || ""); setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || ""); setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || ""); setAppBaseUrl(s.app_base_url || "");
setAccentColor(s.accent_color || "#2563eb"); // primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
// primary_color already exists — otherwise it's still doing legacy-
// primary duty and must not also seed the Accent field.
setPrimaryColor(s.primary_color || s.accent_color || "#4F46E5");
setSecondaryColor(s.secondary_color || "#8B5CF6");
setAccentColor(s.primary_color ? (s.accent_color || "#EC4899") : "#EC4899");
setLogoUrl(s.logo_url || ""); setLogoUrl(s.logo_url || "");
setFaviconUrl(s.favicon_url || "");
setNotifEmails(s.reg_notification_emails || ""); setNotifEmails(s.reg_notification_emails || "");
setSmtpHost(s.smtp_host || ""); setSmtpHost(s.smtp_host || "");
setSmtpPort(s.smtp_port || "587"); setSmtpPort(s.smtp_port || "587");
@@ -183,31 +202,47 @@ function SiteSettingsPageInner() {
app_base_url: appBaseUrl.trim(), app_base_url: appBaseUrl.trim(),
}); });
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
const fd = new FormData();
fd.append("image", file);
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
const data = await res.json();
return data.url || "";
};
const saveBranding = async () => { const saveBranding = async () => {
if (!token) return; if (!token) return;
setSaving(true); setSaving(true);
setResult(null); setResult(null);
try { try {
let finalLogoUrl = logoUrl; let finalLogoUrl = logoUrl;
if (logoFile) { if (logoFile) finalLogoUrl = await uploadBrandingAsset(logoFile, "logo") || logoUrl;
const fd = new FormData();
fd.append("image", logoFile); let finalFaviconUrl = faviconUrl;
const res = await fetch(`${API_BASE}/api/uploads/logo`, { if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error("Logo upload failed");
const data = await res.json();
finalLogoUrl = data.url || logoUrl;
}
await apiFetch("/api/settings", { await apiFetch("/api/settings", {
method: "PUT", authToken: token, method: "PUT", authToken: token,
body: { accent_color: accentColor, logo_url: finalLogoUrl }, body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
logo_url: finalLogoUrl,
favicon_url: finalFaviconUrl,
},
}); });
setLogoUrl(finalLogoUrl); setLogoUrl(finalLogoUrl);
setLogoFile(null); setLogoFile(null);
setLogoPreview(null); setLogoPreview(null);
setFaviconUrl(finalFaviconUrl);
setFaviconFile(null);
setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Saved." }); setResult({ ok: true, message: "Saved." });
reloadSettings(); reloadSettings();
} catch (e: any) { } catch (e: any) {
@@ -217,6 +252,39 @@ function SiteSettingsPageInner() {
} }
}; };
const resetBranding = async () => {
if (!token) return;
if (!confirm("Reset branding to defaults? This clears your custom colors, logo, and favicon.")) return;
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
setSecondaryColor("#8B5CF6");
setAccentColor("#EC4899");
setLogoUrl(""); setLogoFile(null); setLogoPreview(null);
setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Reset to defaults." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to reset" });
} finally {
setSaving(false);
}
};
const handleLogoFileChange = async (file: File | undefined) => {
if (!file) return;
setLogoFile(file);
const preview = URL.createObjectURL(file);
setLogoPreview(preview);
setSuggestedColors(await extractDominantColors(preview));
};
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() }); const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
const saveSmtp = async () => { const saveSmtp = async () => {
@@ -279,9 +347,10 @@ function SiteSettingsPageInner() {
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings</div>; if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings</div>;
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null); const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
return ( return (
<div className="max-w-3xl mx-auto w-full"> <div className={activeTab === "branding" ? "max-w-6xl mx-auto w-full" : "max-w-3xl mx-auto w-full"}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div> <div>
@@ -318,13 +387,14 @@ function SiteSettingsPageInner() {
})} })}
</div> </div>
<div className="border rounded-xl p-5 bg-white shadow-sm"> <div className={activeTab === "branding" ? "grid lg:grid-cols-5 gap-5 items-start" : ""}>
<div className={`border rounded-xl p-5 bg-white shadow-sm ${activeTab === "branding" ? "lg:col-span-3" : ""}`}>
{/* ── Organisation ─────────────────────────────────────────────────── */} {/* ── Organisation ─────────────────────────────────────────────────── */}
{activeTab === "organisation" && ( {activeTab === "organisation" && (
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2> <h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
<Field label="Organisation name" required> <Field label="Organisation name" required>
<input className={inputCls} placeholder="Hope Family Church" <input className={inputCls} placeholder="Your Organisation"
value={orgName} onChange={e => setOrgName(e.target.value)} /> value={orgName} onChange={e => setOrgName(e.target.value)} />
</Field> </Field>
<Field label="Tagline"> <Field label="Tagline">
@@ -341,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"
@@ -357,15 +433,15 @@ function SiteSettingsPageInner() {
{activeTab === "branding" && ( {activeTab === "branding" && (
<div className="space-y-5"> <div className="space-y-5">
<h2 className="text-lg font-semibold text-gray-900">Branding</h2> <h2 className="text-lg font-semibold text-gray-900">Branding</h2>
<Field label="Accent / brand colour" hint="Only recolors the organisation name next to the logo in the top navigation bar — every other button, link, and highlight across the site uses a fixed color scheme.">
<div className="flex items-center gap-3"> <div className="grid sm:grid-cols-3 gap-4">
<input type="color" className="h-10 w-20 border rounded cursor-pointer" <ColorPickerField label="Primary" hint="Buttons, links, nav highlights, and the email header/CTA."
value={accentColor} onChange={e => setAccentColor(e.target.value)} /> value={primaryColor} onChange={setPrimaryColor} />
<input className={`${inputCls} font-mono`} placeholder="#2563eb" <ColorPickerField label="Secondary" hint="Subtle backgrounds and outline-button borders/text."
value={accentColor} onChange={e => setAccentColor(e.target.value)} /> value={secondaryColor} onChange={setSecondaryColor} />
</div> <ColorPickerField label="Accent" hint="Hover states and highlight chips/badges."
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} /> value={accentColor} onChange={setAccentColor} />
</Field> </div>
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar."> <Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
{currentLogoSrc && ( {currentLogoSrc && (
@@ -375,21 +451,77 @@ function SiteSettingsPageInner() {
<button <button
type="button" type="button"
className="text-xs text-red-500 hover:text-red-700" className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }} onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
> >
Remove Remove
</button> </button>
</div> </div>
)} )}
<input ref={fileRef} type="file" accept="image/*" onChange={e => { <input ref={fileRef} type="file" accept="image/*" onChange={e => handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
{suggestedColors.length > 0 && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo click a swatch to apply it</p>
<div className="flex flex-wrap gap-2">
{suggestedColors.map((hex) => (
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
</div>
))}
</div>
</div>
)}
</Field>
<Field label="Favicon" hint="ICO, PNG, or SVG, max 2 MB, ideally square. Shown in the browser tab — falls back to the default if not set.">
{currentFaviconSrc && (
<div className="mb-3 flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={currentFaviconSrc} alt="Current favicon" className="h-8 w-8 object-contain border rounded p-1 bg-gray-50" />
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={faviconRef} type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={e => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setLogoFile(file); setFaviconFile(file);
setLogoPreview(URL.createObjectURL(file)); setFaviconPreview(URL.createObjectURL(file));
}} className="text-sm" /> }} className="text-sm" />
</Field> </Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} /> <div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
<button
type="button"
disabled={saving}
onClick={resetBranding}
className="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
Reset to defaults
</button>
<div className="flex items-center gap-3">
{result && (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
</span>
)}
<button
type="button"
disabled={saving}
onClick={saveBranding}
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div> </div>
)} )}
@@ -531,6 +663,20 @@ function SiteSettingsPageInner() {
{/* ── WhatsApp ──────────────────────────────────────────────────────── */} {/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />} {activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
</div> </div>
{activeTab === "branding" && (
<div className="lg:col-span-2 lg:sticky lg:top-4">
<BrandingPreviewPanel
primaryColor={primaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={currentLogoSrc}
orgName={orgName}
orgTagline={orgTagline}
/>
</div>
)}
</div>
</div> </div>
); );
} }
@@ -608,8 +608,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const options = registration.options || registration.registrationOptions || []; const options = registration.options || registration.registrationOptions || [];
const payments = registration.payments || []; const payments = registration.payments || [];
const totalValue = options.reduce((sum: number, opt: any) => { // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// Use priceSnapshot (authoritative backend price, variant-aware) if available // 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) const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0); : (opt.eventOption?.price ?? opt.price ?? 0);
@@ -650,7 +651,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const updatedOptions = updated.options || updated.registrationOptions || []; const updatedOptions = updated.options || updated.registrationOptions || [];
const updatedPayments = updated.payments || []; const updatedPayments = updated.payments || [];
const totalValue = updatedOptions.reduce((sum: number, opt: any) => { const totalValue = updated.totalDueComputed ?? updatedOptions.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot) ? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0); : (opt.eventOption?.price ?? opt.price ?? 0);
@@ -1737,12 +1738,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="border rounded p-3 text-center"> <div className="border rounded p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div> <div className="text-xs text-gray-500">TOTAL</div>
<div className="font-semibold"> <div className="font-semibold">
R {(selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => { R {(selectedReg.totalDueComputed ?? (selectedReg.options || selectedReg.registrationOptions || []).reduce((s: number, o: any) => {
const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined const price = o.priceSnapshot !== null && o.priceSnapshot !== undefined
? Number(o.priceSnapshot) ? Number(o.priceSnapshot)
: (o.eventOption?.price || o.price || 0); : (o.eventOption?.price || o.price || 0);
return s + price * (o.quantity || 0); return s + price * (o.quantity || 0);
}, 0).toFixed(2)} }, 0)).toFixed(2)}
</div> </div>
</div> </div>
<div className="border rounded p-3 text-center bg-red-50"> <div className="border rounded p-3 text-center bg-red-50">
@@ -21,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
return `${y}-${m}-${day}T${hh}:${mm}`; return `${y}-${m}-${day}T${hh}:${mm}`;
} }
function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { deadline: string; price: number; order?: number }[]) => void }) { function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers: { id?: string; deadline: string; price: number; order?: number }[]) => void }) {
const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]); const [rows, setRows] = React.useState<{ id?: string; deadline: string; price: string; order?: number }[]>([]);
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
@@ -54,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
try { try {
const tiers = rows const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '') .filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i })) .map((r, i) => ({ id: r.id, deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime())); .filter((t) => t.price >= 0 && !isNaN(new Date(t.deadline).getTime()));
onSave(tiers); onSave(tiers);
} finally { } finally {
@@ -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 ────────────────────────────────────────────────────────────────
@@ -755,12 +756,16 @@ interface EventDraft {
title: string; description: string; startDate: string; endDate: string; title: string; description: string; startDate: string; endDate: string;
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;
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: "",
location,
}); });
const blankOptions = (): OptionDraft[] => [ const blankOptions = (): OptionDraft[] => [
@@ -779,19 +784,25 @@ 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),
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt), registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "", price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false, isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
} : blankDraft()); requiresRegistration: ev.requiresRegistration !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
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[]>(() => {
@@ -873,6 +884,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── save helpers ── // ── save helpers ──
const saveOptions = async (eventId: string) => { const saveOptions = async (eventId: string) => {
if (!draft.requiresRegistration) {
// Contact-only events have no ticket options to save — but a form (if any) still needs
// saving in edit mode; for create mode the form is already included in the POST body.
if (mode === "edit") {
await apiFetch(`/api/events/${eventId}`, {
method: "PUT", authToken: token || undefined,
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 })) } }
});
}
return;
}
if (mode === "edit") { if (mode === "edit") {
for (const opt of options) { for (const opt of options) {
// Build flat tier array: option-level + all variant tiers // Build flat tier array: option-level + all variant tiers
@@ -1032,7 +1054,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => { const handleSave = async () => {
if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; } if (!draft.title.trim()) { setError("Title is required"); setStep(0); return; }
if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; } if (!draft.startDate || !draft.endDate) { setError("Start and end dates are required"); setStep(0); return; }
if (mode === "create") { if (mode === "create" && draft.requiresRegistration) {
if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; } if (isPriceInvalid(draft.price)) { setError("Base price is required (enter 0 for a free event)"); setStep(0); return; }
if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; } if (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
} }
@@ -1043,9 +1065,14 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(), startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined, registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).toISOString() : undefined,
goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined, goLiveAt: draft.goLiveAt ? new Date(draft.goLiveAt).toISOString() : undefined,
price: draft.price ? parseFloat(draft.price) : 0, price: draft.requiresRegistration ? (draft.price ? parseFloat(draft.price) : 0) : 0,
picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth, picture: draft.picture || undefined, isHidden: draft.isHidden, requiresAuth: draft.requiresAuth,
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined, redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
requiresRegistration: draft.requiresRegistration,
contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
location: draft.location.trim(),
}; };
if (mode === "edit") { if (mode === "edit") {
@@ -1115,10 +1142,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2; const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// Basic Details step requires title, start/end dates, and a base price before moving on // Basic Details step requires title, start/end dates, and a base price before moving on
// (mirrors handleSave's own checks). Base price is only compulsory when creating a new event. // (mirrors handleSave's own checks). Base price is only compulsory when creating a new event.
const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && isPriceInvalid(draft.price))); const basicDetailsIncomplete = step === 0 && (!draft.title.trim() || !draft.startDate || !draft.endDate || (mode === "create" && draft.requiresRegistration && isPriceInvalid(draft.price)));
// Items & Pricing: every option needs a valid price before leaving the step (checked across // Items & Pricing: every option needs a valid price before leaving the step (checked across
// all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate). // all pricing substeps so switching to Variants/Early Birds can't be used to skip the gate).
const optionsIncomplete = mode === "create" && step === 1 && options.some(o => isPriceInvalid(o.price)); // Not applicable to contact-only events, which have no ticket options at all.
const optionsIncomplete = mode === "create" && draft.requiresRegistration && step === 1 && options.some(o => isPriceInvalid(o.price));
const nextDisabled = basicDetailsIncomplete || optionsIncomplete; const nextDisabled = basicDetailsIncomplete || optionsIncomplete;
// ── render ── // ── render ──
@@ -1190,10 +1218,44 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<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> <div>
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label> <label className="block text-xs text-gray-600 mb-1">Location</label>
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" /> <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" />
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below enter 0 for a free event.</p>} <p className="text-[10px] text-gray-400 mt-0.5">Defaults to your organisation&apos;s address shown to attendees with a map link.</p>
</div> </div>
<div className="flex items-start gap-2 p-3 border rounded bg-gray-50">
<input
type="checkbox"
id="contact-only-toggle"
className="mt-0.5"
checked={!draft.requiresRegistration}
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".
</label>
</div>
{draft.requiresRegistration ? (
<div>
<label className="block text-xs text-gray-600 mb-1">Base Price (R) {mode === "create" && <span className="text-red-500">*</span>}</label>
<input type="number" step="1" min="0" required={mode === "create"} className="w-full border rounded px-3 py-2 text-sm" value={draft.price} onChange={e => upd({ price: e.target.value })} placeholder="0" />
{mode === "create" && <p className="text-[10px] text-gray-400 mt-0.5">Auto-fills the first ticket option below enter 0 for a free event.</p>}
</div>
) : (
<div className="space-y-3 p-3 border rounded">
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Name</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactName} onChange={e => upd({ contactName: e.target.value })} placeholder="e.g. Pastor John" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Phone</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={draft.contactPhone} onChange={e => upd({ contactPhone: e.target.value })} placeholder="e.g. 082 123 4567" />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Contact Email</label>
<input type="email" className="w-full border rounded px-3 py-2 text-sm" value={draft.contactEmail} onChange={e => upd({ contactEmail: e.target.value })} placeholder="e.g. info@hopefamilychurch.org" />
</div>
</div>
)}
<div> <div>
<label className="block text-xs text-gray-600 mb-1">Picture</label> <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" />} {draft.picture && <img src={resolveToApiOrigin(draft.picture) || undefined} alt="" className="h-20 w-20 object-cover rounded border mb-2" />}
@@ -126,7 +126,7 @@ export default function ManualRegistrationPage() {
const regOutstanding = useMemo(() => { const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {}; const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) { for (const r of allRegistrations) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt) * (opt.quantity || 0), 0); 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 totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) }; map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
} }
@@ -187,42 +187,25 @@ function PaymentsContent() {
return (price >= 0) ? price : base; return (price >= 0) ? price : base;
}; };
// Load registrations for dropdowns // 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 = async () => {
if (!token) return; if (!token) return;
try { try {
setLoadingRegs(true); setLoadingRegs(true);
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token }); const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs : []; const list = Array.isArray(regs) ? regs : [];
// Compute initial totalDue using priceSnapshot (authoritative backend price)
const now = new Date(); const now = new Date();
const baseMap: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {}; const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of list) { for (const r of list) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); // Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
baseMap[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue }; // 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);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
} }
setRegOutstanding(baseMap); setRegOutstanding(map);
// Fetch payments per registration to compute outstanding
await Promise.all(
list.map(async (r: any) => {
try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = (pays || []).reduce((s, p) => s + (p.amount || 0), 0);
// totalDue uses priceSnapshot — not time-dependent
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
setRegOutstanding(prev => ({
...prev,
[r.id]: {
totalDue,
totalPaid,
outstanding: Math.max(0, totalDue - totalPaid)
}
}));
} catch (e) {
// ignore per-reg errors
}
})
);
// Sort by createdAt desc // Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted); setRegistrations(sorted);
@@ -449,8 +432,7 @@ function PaymentsContent() {
}); });
setInfo(`Payment created (R ${amt.toFixed(2)})`); setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal(""); setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
await loadPayments(); await Promise.all([loadPayments(), loadRegistrations()]);
await loadRegistrations();
} catch (e: any) { } catch (e: any) {
setError(e?.message || "Failed to create payment"); setError(e?.message || "Failed to create payment");
} finally { } finally {
@@ -532,9 +514,7 @@ function PaymentsContent() {
setInfo('Reconciled as donation'); setInfo('Reconciled as donation');
} }
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false }); setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await loadYocoUnreconciled(); await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
await loadPayments();
await loadRegistrations();
} catch (e: any) { } catch (e: any) {
setError(e?.message || 'Failed to reconcile'); setError(e?.message || 'Failed to reconcile');
} finally { } finally {
@@ -776,7 +756,7 @@ function PaymentsContent() {
usersList={usersList} usersList={usersList}
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))} regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
regOutstanding={regOutstanding} regOutstanding={regOutstanding}
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Refund recorded'); }} onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Refund recorded'); }}
/> />
{loadingUsers && <div className="text-xs text-gray-500">Loading users</div>} {loadingUsers && <div className="text-xs text-gray-500">Loading users</div>}
</> </>
@@ -789,9 +769,13 @@ function PaymentsContent() {
allUsers={allUsers} allUsers={allUsers}
registrations={registrations} registrations={registrations}
regOutstanding={regOutstanding} regOutstanding={regOutstanding}
onDone={async()=>{ await loadPayments(); await loadRegistrations(); setInfo('Donation assigned to registration'); }} onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation assigned to registration'); }}
/> />
{loadingUsers && <div className="text-xs text-gray-500">Loading users</div>} {loadingUsers && <div className="text-xs text-gray-500">Loading users</div>}
<AssignedDonationsList
payments={payments}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
/>
</> </>
)} )}
@@ -1030,6 +1014,144 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
); );
} }
// Lists every donation-assignment leg (isDonationLeg) across all donations, with an Unassign
// action per row. Legs aren't shown anywhere else in the UI — "Recent payments" and the "Today"
// 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[];
onDone: () => void | Promise<void>;
};
function AssignedDonationsList({ payments, onDone }: AssignedDonationsListProps) {
const { token } = useAuth();
const [unassigningId, setUnassigningId] = useState<string | null>(null);
const [err, setErr] = useDismissingState<string | null>(null);
const [query, setQuery] = useState("");
const [eventFilter, setEventFilter] = useState("");
const allLegs = useMemo(() => {
return payments
.filter(isDonationLeg)
.sort((a: any, b: any) => 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); });
return m;
}, [payments]);
// Events with at least one assigned leg — built from the legs themselves so the dropdown
// never shows an event with nothing to filter down to.
const eventOptions = useMemo(() => {
const m = new Map<string, string>();
allLegs.forEach((leg: any) => {
const ev = leg.registration?.event;
if (ev?.id) m.set(String(ev.id), ev.title || String(ev.id));
});
return Array.from(m.entries()).sort((a, b) => a[1].localeCompare(b[1], undefined, { sensitivity: 'base' }));
}, [allLegs]);
const legs = useMemo(() => {
const q = query.trim().toLowerCase();
return allLegs.filter((leg: any) => {
if (eventFilter && String(leg.registration?.eventId || leg.registration?.event?.id) !== eventFilter) return false;
if (!q) return true;
const donation = donationById.get(leg.originalPaymentId);
const haystack = [
leg.registration?.user?.name,
leg.registration?.user?.email,
leg.registration?.event?.title,
donation?.user?.name,
donation?.user?.email,
leg.registrationId,
leg.originalPaymentId,
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(q);
});
}, [allLegs, donationById, query, eventFilter]);
const unassign = async (leg: any) => {
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.");
if (!ok) return;
try {
setUnassigningId(leg.id);
await apiFetch('/api/payments/unassign-donation', {
method: 'POST',
authToken: token,
body: { legId: leg.id }
});
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to unassign donation');
} finally {
setUnassigningId(null);
}
};
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Assigned donations</div>
{err && <div className="p-2 mb-2 text-xs bg-red-50 text-red-700 border rounded">{err}</div>}
{allLegs.length > 0 && (
<div className="flex flex-col sm:flex-row gap-2 mb-3">
<input
className="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Search by registrant, donor, event…"
value={query}
onChange={e => setQuery(e.target.value)}
/>
<select
className="w-full sm:w-56 border rounded px-3 py-2 text-sm shrink-0"
value={eventFilter}
onChange={e => setEventFilter(e.target.value)}
>
<option value="">All events</option>
{eventOptions.map(([id, title]) => (<option key={id} value={id}>{title}</option>))}
</select>
</div>
)}
{allLegs.length === 0 ? (
<div className="text-sm text-gray-500">No donations have been assigned to registrations yet.</div>
) : legs.length === 0 ? (
<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);
return (
<li key={leg.id} className="border rounded p-2">
<div className="flex justify-between items-start gap-2">
<div>
<div className="font-medium">R {(leg.amount || 0).toFixed(2)} {leg.registration?.user?.name || leg.registration?.userId || 'Registrant'}</div>
<div className="text-xs text-gray-600">
Registration: #{String(leg.registrationId).slice(0,8)}{leg.registration?.event?.title ? `${leg.registration.event.title}` : ''}
</div>
<div className="text-xs text-gray-500">
From donation by {donation?.user?.name || donation?.userId || 'Donor'} #{String(leg.originalPaymentId).slice(0,8)}
</div>
<div className="text-xs text-gray-400">{new Date(leg.createdAt).toLocaleString()}</div>
</div>
<button
disabled={unassigningId === leg.id}
onClick={() => unassign(leg)}
className="px-2 py-1 text-xs rounded bg-rose-600 text-white hover:bg-rose-700 disabled:opacity-50 shrink-0"
>
{unassigningId === leg.id ? 'Unassigning…' : 'Unassign'}
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
);
}
type DonationAssignSectionProps = { type DonationAssignSectionProps = {
payments: any[]; payments: any[];
allUsers: any[]; allUsers: any[];
+151 -72
View File
@@ -89,7 +89,9 @@ export default function UserDashboardPage() {
// Registration details modal // Registration details modal
const [activeRegId, setActiveRegId] = useState<string | null>(null); const [activeRegId, setActiveRegId] = useState<string | null>(null);
const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string }>({ open: false, message: "", loading: false }); const [dialog, setDialog] = useState<{ open: boolean; message: string; loading?: boolean; loadingTitle?: string; loadingSubtitle?: string; title?: string }>({ open: false, message: "", loading: false });
// Channel-choice popup shown by the single "Invoice"/"Receipt" buttons — asks Email or WhatsApp.
const [channelPicker, setChannelPicker] = useState<{ kind: 'invoice' | 'receipt'; id: string } | null>(null);
// Track whether the active registration's event has attendee forms // Track whether the active registration's event has attendee forms
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null); const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -140,12 +142,11 @@ export default function UserDashboardPage() {
setRegistrations(myRegs); setRegistrations(myRegs);
setTickets(myTicks); setTickets(myTicks);
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware). // Use the backend's tranche-aware totalDueComputed (exact even when a line spans
// priceSnapshot is set at registration time and refreshed before each payment. // multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
const now = new Date();
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {}; const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) { for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
} }
setBilling(totals); setBilling(totals);
@@ -156,8 +157,8 @@ export default function UserDashboardPage() {
try { try {
const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token }); const pays = await apiFetch<any[]>(`/api/payments/registration/${encodeURIComponent(r.id)}`, { authToken: token });
const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0); const totalPaid = pays.reduce((s, p) => s + (p.amount || 0), 0);
// totalDue uses priceSnapshot — not time-dependent, no need to recompute per payment time // totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid); const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({ setBilling(prev => ({
...prev, ...prev,
@@ -254,26 +255,6 @@ export default function UserDashboardPage() {
} }
}; };
const emailRegistration = async (registrationId: string) => {
if (!token) return;
setError(null);
setInfo(null);
// Show loading dialog while sending
setDialog({ open: true, message: "Sending tickets…", loading: true });
try {
const res: any = await apiFetch("/api/tickets/email", {
method: "POST",
body: { registrationId },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets for registration emailed successfully.`;
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to email registration tickets");
}
};
const whatsappTickets = async (ticketIds: string[]) => { const whatsappTickets = async (ticketIds: string[]) => {
if (!token || ticketIds.length === 0) return; if (!token || ticketIds.length === 0) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; } if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
@@ -294,26 +275,60 @@ export default function UserDashboardPage() {
} }
}; };
const whatsappRegistration = async (registrationId: string) => { const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
if (!token) return; if (!token) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
setError(null); setError(null);
setInfo(null); setInfo(null);
setDialog({ open: true, message: "Sending to WhatsApp…", loading: true }); setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending invoice to WhatsApp…" : "Sending invoice…",
loadingSubtitle: "Please wait while we prepare your invoice.",
});
try { try {
const res: any = await apiFetch("/api/tickets/email", { const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST", method: "POST",
body: { registrationId, channel: "whatsapp" }, body: { channel },
authToken: token, authToken: token,
}); });
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`; setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
setDialog({ open: true, message: msg, loading: false });
} catch (e: any) { } catch (e: any) {
setDialog({ open: false, message: "", loading: false }); setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send tickets to WhatsApp"); setError(e?.message || "Failed to send invoice");
} }
}; };
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setError(null);
setInfo(null);
setDialog({
open: true, loading: true, message: "",
loadingTitle: channel === 'whatsapp' ? "Sending receipt to WhatsApp…" : "Sending receipt…",
loadingSubtitle: "Please wait while we prepare your receipt.",
});
try {
const res: any = await apiFetch(`/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) {
setDialog({ open: false, message: "", loading: false });
setError(e?.message || "Failed to send receipt");
}
};
// Resolves the channel-picker popup: dispatches to the invoice or receipt sender for
// whichever id it was opened with, then closes the popup.
const chooseChannel = (channel: 'email' | 'whatsapp') => {
if (!channelPicker) return;
const { kind, id } = channelPicker;
setChannelPicker(null);
if (kind === 'invoice') sendInvoice(id, channel);
else sendReceipt(id, channel);
};
// Creates a Yoco checkout for the full outstanding balance and redirects there directly — // Creates a Yoco checkout for the full outstanding balance and redirects there directly —
// choosing a partial amount is only available from the supervisor payments dashboard. // choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => { const payNow = async (registrationId: string) => {
@@ -638,11 +653,10 @@ export default function UserDashboardPage() {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token }); const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks); setTickets(myTicks);
} catch {} } catch {}
// Recompute billing totals using priceSnapshot // Recompute billing totals using the backend's tranche-aware totalDueComputed
const now2 = new Date();
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {}; const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
for (const r of myRegs) { for (const r of myRegs) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now2) * (opt.quantity || 0), 0); const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] }; totals[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue, payments: [] };
} }
setBilling(totals); setBilling(totals);
@@ -969,24 +983,64 @@ export default function UserDashboardPage() {
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button> <button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50">Edit</button>
)} )}
</div> </div>
{!editMode ? ( {!editMode ? (() => {
<ul className="text-sm list-disc pl-5 space-y-1"> // A line can span multiple price tranches (e.g. tickets bought before
{(activeReg.registrationOptions || []).map((opt: any) => { // and after an early-bird tier expired, or across repeat registrations).
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : ''; // Merge tranches that share a name/price/tier into one row, then group
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined) // early-bird rows separately from standard-price rows so repeat purchases
? Number(opt.priceSnapshot) // of the same item don't show as a wall of near-duplicate lines.
: (opt.variant?.price ?? opt.eventOption?.price ?? 0); const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
return ( (activeReg.registrationOptions || []).forEach((opt: any) => {
<li key={opt.id}> const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
{opt.eventOption?.name}{variantLabel} x {opt.quantity} {formatRand(unitPrice * (opt.quantity || 0))} const label = `${opt.eventOption?.name || ''}${variantLabel}`;
{unitPrice < (opt.eventOption?.price || 0) && ( const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
<span className="ml-1 text-xs text-green-700">(early bird)</span> ? opt.tranches
)} : [{
</li> quantity: opt.quantity,
); priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
})} ? Number(opt.priceSnapshot)
</ul> : (opt.variant?.price ?? opt.eventOption?.price ?? 0),
) : ( appliedTierId: opt.appliedTierId,
}];
tranches.forEach((t: any) => {
const unitPrice = Number(t.priceSnapshot || 0);
const isEarlyBird = !!t.appliedTierId;
const key = `${label}__${isEarlyBird}__${unitPrice}`;
const existing = rows.find(r => r.key === key);
if (existing) {
existing.quantity += (t.quantity || 0);
} else {
rows.push({ key, label, quantity: t.quantity || 0, unitPrice, isEarlyBird });
}
});
});
const earlyBirdRows = rows.filter(r => r.isEarlyBird);
const standardRows = rows.filter(r => !r.isEarlyBird);
const showGroupLabels = earlyBirdRows.length > 0 && standardRows.length > 0;
const renderGroup = (heading: string, groupRows: typeof rows) => groupRows.length > 0 && (
<div key={heading}>
{showGroupLabels && (
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wide mt-2 first:mt-0">{heading}</div>
)}
<ul className="text-sm list-disc pl-5 space-y-1">
{groupRows.map(r => (
<li key={r.key}>
{r.label} x {r.quantity} {formatRand(r.unitPrice * r.quantity)}
{r.isEarlyBird && (
<span className="ml-1 text-xs text-green-700">(early bird)</span>
)}
</li>
))}
</ul>
</div>
);
return (
<div>
{renderGroup('Early bird', earlyBirdRows)}
{renderGroup('Standard price', standardRows)}
</div>
);
})() : (
<div className="border rounded p-3 space-y-2 bg-gray-50"> <div className="border rounded p-3 space-y-2 bg-gray-50">
{editError && <p className="text-xs text-red-600">{editError}</p>} {editError && <p className="text-xs text-red-600">{editError}</p>}
{editLoading ? ( {editLoading ? (
@@ -1091,9 +1145,12 @@ export default function UserDashboardPage() {
{activeBill && activeBill.payments.length > 0 && ( {activeBill && activeBill.payments.length > 0 && (
<div> <div>
<div className="font-medium mb-1">Payments</div> <div className="font-medium mb-1">Payments</div>
<ul className="text-sm list-disc pl-5 space-y-1"> <ul className="text-sm space-y-1">
{activeBill.payments.map((p: any) => ( {activeBill.payments.map((p: any) => (
<li key={p.id}>{new Date(p.createdAt).toLocaleString()} {formatRand(p.amount)} ({formatPaymentMethod(p.method)})</li> <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>
</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -1105,24 +1162,21 @@ export default function UserDashboardPage() {
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)} onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>Attendee forms</button> >Attendee forms</button>
)} )}
{canModifyActive && activeBill && activeBill.outstanding > 0 ? ( <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={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
<button <button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)} onClick={() => payNow(activeReg.id)}
>Make payment</button> >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));
printTickets(list);
}}>Print all tickets</button>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</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));
printTickets(list);
}}>Print all tickets</button>
</div> </div>
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */} {/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
@@ -1156,6 +1210,31 @@ export default function UserDashboardPage() {
</div> </div>
)} )}
{channelPicker && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-[60]"
onClick={() => setChannelPicker(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send {channelPicker.kind === 'invoice' ? 'invoice' : 'receipt'}</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => chooseChannel('email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => chooseChannel('whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setChannelPicker(null)}>Cancel</button>
</div>
</div>
)}
{dialog.open && ( {dialog.open && (
<div <div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@@ -1170,7 +1249,7 @@ export default function UserDashboardPage() {
</div> </div>
) : ( ) : (
<> <>
<div className="text-lg font-semibold mb-2">Tickets sent</div> <div className="text-lg font-semibold mb-2">{dialog.title || "Tickets sent"}</div>
<p className="text-sm text-gray-700 mb-4">{dialog.message}</p> <p className="text-sm text-gray-700 mb-4">{dialog.message}</p>
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
@@ -33,10 +33,35 @@ export default function UserPaymentsPage() {
const [payments, setPayments] = useState<PaymentItem[]>([]); const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false); const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
// Receipt send: which payment's channel-choice popup is open, and whether a send is in flight.
const [receiptPickerId, setReceiptPickerId] = useState<string | null>(null);
const [sendingReceipt, setSendingReceipt] = useState(false);
const sendReceipt = async (paymentId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
setReceiptPickerId(null);
setError(null);
setInfo(null);
setSendingReceipt(true);
try {
const res: any = await apiFetch<any>(`/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");
} finally {
setSendingReceipt(false);
}
};
// Filters // Filters
const [startDate, setStartDate] = useState(""); const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState(""); const [endDate, setEndDate] = useState("");
@@ -89,6 +114,8 @@ export default function UserPaymentsPage() {
</div> </div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>} {error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
{sendingReceipt && <p className="text-gray-500 text-sm mb-3">Sending receipt</p>}
<div className="border rounded-xl p-4 bg-white shadow-sm"> <div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex flex-wrap items-end gap-3 mb-4"> <div className="flex flex-wrap items-end gap-3 mb-4">
@@ -156,8 +183,19 @@ export default function UserPaymentsPage() {
</div> </div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div> <div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</div>
</div> </div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div> <div className="flex items-center justify-between gap-2">
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>} <div>
<div className="text-xs text-gray-600">Method: {formatPaymentMethod(p.method)}</div>
{eventTitle && <div className="text-xs text-gray-600">Event: {eventTitle}</div>}
</div>
{!isRefund && (
<button
className="text-xs text-brand-700 hover:underline shrink-0"
disabled={sendingReceipt}
onClick={() => setReceiptPickerId(p.id)}
>Receipt</button>
)}
</div>
</li> </li>
); );
})} })}
@@ -204,6 +242,31 @@ export default function UserPaymentsPage() {
</div> </div>
)} )}
</div> </div>
{receiptPickerId && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setReceiptPickerId(null)}
>
<div className="bg-white rounded-lg shadow-lg w-full max-w-xs mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="text-base font-semibold mb-1">Send receipt</div>
<p className="text-sm text-gray-600 mb-4">How would you like to receive it?</p>
<div className="flex flex-col gap-2">
<button
className="px-3 py-2 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'email')}
>Email</button>
{user?.phoneNumber && (
<button
className="px-3 py-2 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => sendReceipt(receiptPickerId, 'whatsapp')}
>WhatsApp</button>
)}
</div>
<button className="mt-3 text-xs text-gray-500 hover:underline" onClick={() => setReceiptPickerId(null)}>Cancel</button>
</div>
</div>
)}
</div> </div>
); );
} }
+24
View File
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar"; 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 { 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;
@@ -32,6 +34,11 @@ type Event = {
eventOptions?: EventOption[]; eventOptions?: EventOption[];
attachments?: EventAttachment[]; attachments?: EventAttachment[];
requiresAuth?: boolean; requiresAuth?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
}; };
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
@@ -51,6 +58,16 @@ function lowStockThreshold(stockLimit: number): number {
} }
function RegisterCta({ event }: { event: Event }) { function RegisterCta({ event }: { event: Event }) {
if (event.requiresRegistration === false) {
return (
<ContactButton
contactName={event.contactName}
contactPhone={event.contactPhone}
contactEmail={event.contactEmail}
className="block w-full text-center bg-brand-600 text-white px-4 py-2.5 rounded-lg hover:bg-brand-700 font-medium transition-colors"
/>
);
}
const now = new Date(); const now = new Date();
const end = new Date(event.endDate); const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
@@ -127,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">
+23 -10
View File
@@ -4,17 +4,18 @@
/* /*
* Design tokens for shadcn/ui primitives (components.json: baseColor "slate", * Design tokens for shadcn/ui primitives (components.json: baseColor "slate",
* cssVariables: true). Base scale is shadcn's stock "slate" theme; --primary * cssVariables: true). Base scale is shadcn's stock "slate" theme.
* and --ring are overridden to the site's fixed indigo/purple brand color
* (matches the `brand` scale in tailwind.config.js, brand-600 #4F46E5).
* *
* IMPORTANT: the org's admin-configurable accent color * --primary/--primary-foreground/--ring/--secondary/--secondary-foreground/
* (SiteSettingsContext.settings.accent_color) must NEVER be wired into any * --accent/--accent-foreground/--brand-50..900 below are DEFAULTS ONLY used
* variable here or into any Tailwind color token. It is applied in exactly * before any branding setting has been saved, or if the server-side settings
* one place an inline `style={{ color }}` on the org name text in * fetch fails for a request. The real values come from the admin's saved
* Navbar.tsx and nowhere else. Every other themed element (buttons, links, * primary_color/secondary_color/accent_color settings, computed via
* active nav states, icon chips, the help button, etc.) uses the fixed * lib/colorScale.ts and injected per-request as a `<style id="brand-theme">`
* brand/primary tokens below regardless of what the org sets that setting to. * override in layout.tsx (see buildThemeCssVars). This intentionally reverses
* an earlier decision (commit 8e6cb542) that kept the accent color scoped to
* a single inline style on the navbar's org-name text branding now flows
* through these same tokens site-wide and into outgoing emails.
*/ */
@layer base { @layer base {
:root { :root {
@@ -47,6 +48,18 @@
--ring: 243 75% 59%; --ring: 243 75% 59%;
--radius: 0.5rem; --radius: 0.5rem;
/* Fallback brand scale (matches the pre-branding-feature default indigo, brand-600 #4F46E5) */
--brand-50: #EEF2FF;
--brand-100: #E0E7FF;
--brand-200: #C7D2FE;
--brand-300: #A5B4FC;
--brand-400: #818CF8;
--brand-500: #6366F1;
--brand-600: #4F46E5;
--brand-700: #4338CA;
--brand-800: #3730A3;
--brand-900: #312E81;
} }
} }
+47 -10
View File
@@ -3,10 +3,13 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css"; import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext"; import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext"; import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { ToastProvider } from "@/components/shared/ToastProvider"; import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar"; import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard"; import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab"; import HelpFab from "@/components/shared/HelpFab";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
@@ -18,21 +21,55 @@ const geistMono = Geist_Mono({
subsets: ["latin"], subsets: ["latin"],
}); });
const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events'; const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
export const metadata: Metadata = { /**
title: appName, * Server-side settings fetch, shared by generateMetadata() and RootLayout()
description: `Manage and register for events with ${appName}`, * below. Both call this with the identical URL + options so Next's per-request
icons: { * fetch memoization dedupes them into a single network call. Degrades to `{}`
icon: "/favicon.ico", * (static defaults everywhere downstream) if the backend is unreachable at
}, * request time intentional, not a bug: branding just isn't critical enough
}; * to fail the whole page render over.
*/
async function getServerSettings(): Promise<SiteSettings> {
try {
const res = await fetch(`${API_BASE}/api/settings`, { next: { revalidate: 60 } });
if (!res.ok) return {};
return await res.json();
} catch {
return {};
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await getServerSettings();
const faviconUrl = settings.favicon_url ? resolveToApiOrigin(settings.favicon_url) : null;
const displayName = settings.org_name || appName;
return {
title: displayName,
description: `Manage and register for events with ${displayName}`,
icons: {
icon: faviconUrl || "/favicon.ico",
},
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const settings = await getServerSettings();
const cssVars = buildThemeCssVars({
primary: settings.primary_color || settings.accent_color,
secondary: settings.secondary_color,
accent: settings.primary_color ? settings.accent_color : undefined,
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<SiteSettingsProvider> {cssVars && (
<style id="brand-theme" dangerouslySetInnerHTML={{ __html: `:root{${cssVars}}` }} />
)}
<SiteSettingsProvider initialSettings={settings}>
<AuthProvider> <AuthProvider>
<ToastProvider> <ToastProvider>
<SetupGuard /> <SetupGuard />
+13 -6
View File
@@ -27,12 +27,19 @@ export default function PrivacyPolicyPage() {
const activeId = useScrollSpy(sections.map((s) => s.id)); const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings(); const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church"; // orgName follows the sitewide org_name default (Cross Code) since org_name is a
const orgEmail = settings.org_email || "admin@hopehenley.co.za"; // required field at setup — this fallback is practically unreachable in production.
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za"; const orgName = settings.org_name || "Cross Code";
const ioName = settings.legal_io_name || "Joshua Oosthuizen"; // The fields below are all OPTIONAL legal-specific settings that can realistically
const ioEmail = settings.legal_io_email || "joshua@crosscode.co.za"; // stay unset on a live site. Defaulting them to a real person/org's identity would be
const effectiveDate = settings.legal_effective_date || "April 2026"; // actively misleading on someone else's deployment, so these are generic placeholders
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
// silently showing Cross Code's or any other real organisation's details.
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const ioName = settings.legal_io_name || "[not yet configured]";
const ioEmail = settings.legal_io_email || "io@example.com";
const effectiveDate = settings.legal_effective_date || "[not yet set]";
return ( return (
<div className="min-h-screen flex flex-col bg-gray-50"> <div className="min-h-screen flex flex-col bg-gray-50">
+12 -5
View File
@@ -26,11 +26,18 @@ export default function TermsOfUsePage() {
const activeId = useScrollSpy(sections.map((s) => s.id)); const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings(); const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church"; // orgName follows the sitewide org_name default (Cross Code) since org_name is a
const orgEmail = settings.org_email || "admin@hopehenley.co.za"; // required field at setup — this fallback is practically unreachable in production.
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za"; const orgName = settings.org_name || "Cross Code";
const operatorName = settings.legal_operator_name || "Joshua Oosthuizen on behalf of Hope Family Church, Henley-on-Klip, South Africa"; // The fields below are all OPTIONAL legal-specific settings that can realistically
const effectiveDate = settings.legal_effective_date || "April 2026"; // stay unset on a live site. Defaulting them to a real person/org's identity would be
// actively misleading on someone else's deployment, so these are generic placeholders
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
// silently showing Cross Code's or any other real organisation's details.
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const operatorName = settings.legal_operator_name || "[operator details not yet configured]";
const effectiveDate = settings.legal_effective_date || "[not yet set]";
return ( return (
<div className="min-h-screen flex flex-col bg-gray-50"> <div className="min-h-screen flex flex-col bg-gray-50">
+20 -7
View File
@@ -1,8 +1,10 @@
import { EventCard } from "@/components/events/EventCard"; import { EventCard } from "@/components/events/EventCard";
import { Navbar } from "@/components/layout/Navbar"; 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 { appName } from "@/lib/siteConfig"; import { appName } from "@/lib/siteConfig";
import { Calendar, UserPlus, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react"; import { API_BASE } from "@/lib/api";
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
type Event = { type Event = {
id: string; id: string;
@@ -25,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();
@@ -51,17 +67,14 @@ 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">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
View Events View Events
</a> </a>
<a href="/register" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors"> <JoinUsButton />
<UserPlus className="w-4 h-4" />
Join Us
</a>
</div> </div>
</div> </div>
</section> </section>
+7 -1
View File
@@ -1,4 +1,4 @@
import { notFound } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api"; import { apiFetch, ApiError } from "@/lib/api";
@@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
throw e; throw e;
} }
// Contact-only events (e.g. baptism) have no registration flow — bounce a stale/direct
// link back to the event detail page, which renders the Contact affordance instead.
if (event.requiresRegistration === false) {
redirect(`/events/${eventId}`);
}
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<Navbar /> <Navbar />
@@ -56,6 +56,9 @@ function RegistrationSuccessContent() {
const totalDue = React.useMemo(() => { const totalDue = React.useMemo(() => {
if (!reg) return 0; if (!reg) return 0;
// 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.
if (reg.totalDueComputed !== null && reg.totalDueComputed !== undefined) return reg.totalDueComputed;
try { try {
return (reg.registrationOptions || []).reduce((s: number, ro: any) => { return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined) const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
+100 -26
View File
@@ -3,6 +3,9 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { apiFetch, API_BASE } from "@/lib/api"; import { apiFetch, API_BASE } from "@/lib/api";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
type Step = 1 | 2 | 3 | 4 | 5 | 6; type Step = 1 | 2 | 3 | 4 | 5 | 6;
@@ -68,9 +71,13 @@ export default function SetupPage() {
const [adminConfirm, setAdminConfirm] = useState(""); const [adminConfirm, setAdminConfirm] = useState("");
// Step 3 — Branding // Step 3 — Branding
const [accentColor, setAccentColor] = useState("#2563eb"); const [primaryColor, setPrimaryColor] = useState("#4F46E5");
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
const [accentColor, setAccentColor] = useState("#EC4899");
const [logoFile, setLogoFile] = useState<File | null>(null); const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null); const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
const [notifEmail, setNotifEmail] = useState(""); const [notifEmail, setNotifEmail] = useState("");
// Step 4 — Legal // Step 4 — Legal
@@ -98,11 +105,19 @@ export default function SetupPage() {
const next = () => setStep((s) => Math.min(s + 1, 6) as Step); const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); }; const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
setLogoFile(file); setLogoFile(file);
setLogoPreview(URL.createObjectURL(file)); const preview = URL.createObjectURL(file);
setLogoPreview(preview);
setSuggestedColors(await extractDominantColors(preview));
};
const handleFaviconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setFaviconFile(file);
}; };
// ── Step validators ─────────────────────────────────────────────────────── // ── Step validators ───────────────────────────────────────────────────────
@@ -171,19 +186,47 @@ export default function SetupPage() {
setError(null); setError(null);
setLoading(true); setLoading(true);
try { try {
// The admin account already exists by this point (created in step 2),
// so the branding-asset upload endpoints require admin auth — the
// setupToken from /api/setup/register covers that.
let logoUrl = ""; let logoUrl = "";
if (logoFile) { if (logoFile) {
const fd = new FormData(); const fd = new FormData();
fd.append("image", logoFile); fd.append("image", logoFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, { method: "POST", body: fd }); const uploadRes = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
body: fd,
});
if (uploadRes.ok) { if (uploadRes.ok) {
const up = await uploadRes.json(); const up = await uploadRes.json();
logoUrl = up.url || ""; logoUrl = up.url || "";
} else {
throw new Error("Logo upload failed. Please try again.");
}
}
let faviconUrl = "";
if (faviconFile) {
const fd = new FormData();
fd.append("image", faviconFile);
const uploadRes = await fetch(`${API_BASE}/api/uploads/favicon`, {
method: "POST",
headers: setupToken ? { Authorization: `Bearer ${setupToken}` } : undefined,
body: fd,
});
if (uploadRes.ok) {
const up = await uploadRes.json();
faviconUrl = up.url || "";
} else {
throw new Error("Favicon upload failed. Please try again.");
} }
} }
const settings: Record<string, string> = { const settings: Record<string, string> = {
org_name: orgName.trim(), org_name: orgName.trim(),
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor, accent_color: accentColor,
}; };
if (orgTagline.trim()) settings.org_tagline = orgTagline.trim(); if (orgTagline.trim()) settings.org_tagline = orgTagline.trim();
@@ -193,6 +236,7 @@ export default function SetupPage() {
if (appBaseUrl.trim()) settings.app_base_url = appBaseUrl.trim(); if (appBaseUrl.trim()) settings.app_base_url = appBaseUrl.trim();
if (notifEmail.trim()) settings.reg_notification_emails = notifEmail.trim(); if (notifEmail.trim()) settings.reg_notification_emails = notifEmail.trim();
if (logoUrl) settings.logo_url = logoUrl; if (logoUrl) settings.logo_url = logoUrl;
if (faviconUrl) settings.favicon_url = faviconUrl;
// Legal // Legal
if (legalOperatorName.trim()) settings.legal_operator_name = legalOperatorName.trim(); if (legalOperatorName.trim()) settings.legal_operator_name = legalOperatorName.trim();
if (legalWebsiteUrl.trim()) settings.legal_website_url = legalWebsiteUrl.trim(); if (legalWebsiteUrl.trim()) settings.legal_website_url = legalWebsiteUrl.trim();
@@ -225,7 +269,7 @@ export default function SetupPage() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-brand-50 to-brand-100 flex items-center justify-center p-4"> <div className="min-h-screen bg-gradient-to-br from-brand-50 to-brand-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8"> <div className={`bg-white rounded-2xl shadow-lg w-full p-8 transition-all ${step === 3 ? "max-w-4xl" : "max-w-lg"}`}>
{/* Heading */} {/* Heading */}
<div className="text-center mb-6"> <div className="text-center mb-6">
@@ -255,7 +299,7 @@ export default function SetupPage() {
</div> </div>
<Field label="Organisation name" required> <Field label="Organisation name" required>
<input className={inputCls} placeholder="e.g. Hope Family Church" <input className={inputCls} placeholder="e.g. Your Organisation"
value={orgName} onChange={e => setOrgName(e.target.value)} autoFocus /> value={orgName} onChange={e => setOrgName(e.target.value)} autoFocus />
</Field> </Field>
<Field label="Tagline"> <Field label="Tagline">
@@ -331,28 +375,58 @@ export default function SetupPage() {
<p className="text-sm text-gray-500">Customise the look of your site. You can change this later.</p> <p className="text-sm text-gray-500">Customise the look of your site. You can change this later.</p>
</div> </div>
<Field label="Accent / brand colour"> <div className="grid lg:grid-cols-5 gap-6 items-start">
<div className="flex items-center gap-3"> <div className="lg:col-span-3 space-y-4">
<input type="color" className="h-10 w-20 border rounded cursor-pointer" <div className="grid sm:grid-cols-3 gap-4">
value={accentColor} onChange={e => setAccentColor(e.target.value)} /> <ColorPickerField label="Primary" value={primaryColor} onChange={setPrimaryColor} />
<input className={`${inputCls} font-mono`} value={accentColor} <ColorPickerField label="Secondary" value={secondaryColor} onChange={setSecondaryColor} />
onChange={e => setAccentColor(e.target.value)} placeholder="#2563eb" /> <ColorPickerField label="Accent" value={accentColor} onChange={setAccentColor} />
</div>
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
{logoPreview && (
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
)}
{suggestedColors.length > 0 && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo click a swatch to apply it</p>
<div className="flex flex-wrap gap-2">
{suggestedColors.map((hex) => (
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
</div>
))}
</div>
</div>
)}
</Field>
<Field label="Favicon (optional)" hint="ICO, PNG, or SVG, max 2 MB, ideally square. If skipped, the default favicon is used.">
<input type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={handleFaviconChange} className="text-sm" />
</Field>
<Field label="Registration notification email"
hint="Who to notify when someone registers for an event. Separate multiple addresses with commas.">
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org"
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} />
</Field>
</div> </div>
<div className="mt-2 h-8 rounded-lg" style={{ background: accentColor }} />
</Field>
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used."> <div className="lg:col-span-2">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" /> <BrandingPreviewPanel
{logoPreview && ( primaryColor={primaryColor}
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" /> secondaryColor={secondaryColor}
)} accentColor={accentColor}
</Field> logoSrc={logoPreview}
orgName={orgName}
<Field label="Registration notification email" orgTagline={orgTagline}
hint="Who to notify when someone registers for an event. Separate multiple addresses with commas."> />
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org" </div>
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} /> </div>
</Field>
<div className="pt-2 flex justify-between"> <div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button> <button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
@@ -0,0 +1,171 @@
"use client";
import React, { useMemo, useState } from "react";
import { Monitor, Smartphone, RefreshCw } from "lucide-react";
import { buildBrandScale, buildSecondaryTokens, buildAccentTokens, contrastForeground, isValidHex } from "@/lib/colorScale";
type View = "website" | "email";
type Viewport = "desktop" | "mobile";
/**
* Hand-built mockup (not an iframe of the real site) so it can reflect
* in-progress, unsaved color-picker values instantly the real site's CSS
* variables only update once the admin actually saves (see layout.tsx's SSR
* style tag), and this preview must not leak into the surrounding admin
* page's own theme while the admin is still experimenting with colors.
*/
export function BrandingPreviewPanel({
primaryColor, secondaryColor, accentColor, logoSrc, orgName, orgTagline,
}: {
primaryColor: string;
secondaryColor: string;
accentColor: string;
logoSrc: string | null;
orgName: string;
orgTagline?: string;
}) {
const [view, setView] = useState<View>("website");
const [viewport, setViewport] = useState<Viewport>("desktop");
const safePrimary = isValidHex(primaryColor) ? primaryColor : "#4F46E5";
const safeSecondary = isValidHex(secondaryColor) ? secondaryColor : "#8B5CF6";
const safeAccent = isValidHex(accentColor) ? accentColor : "#EC4899";
const scale = useMemo(() => buildBrandScale(safePrimary), [safePrimary]);
const secondary = useMemo(() => buildSecondaryTokens(safeSecondary), [safeSecondary]);
const accent = useMemo(() => buildAccentTokens(safeAccent), [safeAccent]);
const primaryFg = useMemo(() => contrastForeground(safePrimary), [safePrimary]);
return (
<div className="border rounded-xl bg-white shadow-sm overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b bg-gray-50">
<div>
<h3 className="text-sm font-semibold text-gray-900">Live Preview</h3>
<p className="text-xs text-gray-500">See how your branding looks across the site.</p>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center rounded-lg border bg-white overflow-hidden">
<button type="button" onClick={() => setView("website")}
className={`px-2.5 py-1.5 text-xs font-medium ${view === "website" ? "bg-brand-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}>
Website
</button>
<button type="button" onClick={() => setView("email")}
className={`px-2.5 py-1.5 text-xs font-medium ${view === "email" ? "bg-brand-600 text-white" : "text-gray-600 hover:bg-gray-100"}`}>
Email
</button>
</div>
{view === "website" && (
<div className="flex items-center rounded-lg border bg-white overflow-hidden">
<button type="button" onClick={() => setViewport("desktop")} title="Desktop"
className={`p-1.5 ${viewport === "desktop" ? "bg-gray-200" : "text-gray-500 hover:bg-gray-100"}`}>
<Monitor className="w-3.5 h-3.5" />
</button>
<button type="button" onClick={() => setViewport("mobile")} title="Mobile"
className={`p-1.5 ${viewport === "mobile" ? "bg-gray-200" : "text-gray-500 hover:bg-gray-100"}`}>
<Smartphone className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
<div className="p-4 bg-gray-100 flex justify-center overflow-x-auto">
{view === "website" ? (
<div
className="bg-white rounded-lg shadow-md overflow-hidden transition-all"
style={{ width: viewport === "mobile" ? 320 : "100%", maxWidth: viewport === "mobile" ? 320 : 640 }}
>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b">
{logoSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={logoSrc} alt="Logo" className="h-6 w-6 object-contain rounded-sm" />
) : (
<div className="h-6 w-6 rounded-sm shrink-0" style={{ background: scale["600"] }} />
)}
<span className="text-sm font-bold truncate" style={{ color: scale["600"] }}>{orgName || "Your Organisation"}</span>
<div className="ml-auto flex items-center gap-2 text-[10px] text-gray-400">
<span>Home</span><span>Events</span><span>Contact</span>
</div>
</div>
{/* Hero */}
<div className="px-4 py-6 text-center" style={{ background: `hsl(${secondary.bg})` }}>
<p className="text-base font-bold text-gray-900">Welcome to {orgName || "Your Organisation"}</p>
<p className="text-[11px] text-gray-500 mt-1 mb-4">Experience unforgettable moments.</p>
<div className="flex items-center justify-center gap-2">
<span
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg"
style={{ background: scale["600"], color: primaryFg }}
>
View Events
</span>
<span
className="text-[11px] font-semibold px-3 py-1.5 rounded-lg border bg-white"
style={{ borderColor: `hsl(${secondary.fg})`, color: `hsl(${secondary.fg})` }}
>
My Dashboard
</span>
</div>
</div>
{/* Upcoming events */}
<div className="px-4 py-4">
<p className="text-xs font-semibold text-gray-900 mb-2">Upcoming events</p>
<div className="space-y-2">
{["Sunday Service", "Community Outreach"].map((title) => (
<div key={title} className="flex items-center gap-2 border rounded-lg p-2">
<span
className="w-2 h-2 rounded-full shrink-0"
style={{ background: `hsl(${accent.fg})` }}
/>
<span className="text-[11px] text-gray-700 truncate">{title}</span>
<span
className="ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded"
style={{ background: `hsl(${accent.bg})`, color: `hsl(${accent.fg})` }}
>
New
</span>
</div>
))}
</div>
</div>
{/* Footer — fixed dark neutral, not brand-driven */}
<div className="px-4 py-4 bg-slate-900 text-center">
<p className="text-[10px] text-slate-400">&copy; {new Date().getFullYear()} {orgName || "Your Organisation"}. All rights reserved.</p>
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow-md overflow-hidden w-full max-w-md">
<div
className="px-6 py-8 text-center"
style={{ background: `linear-gradient(135deg, ${scale["600"]} 0%, #2d5287 100%)` }}
>
<p className="text-lg font-extrabold text-white">{orgName || "Your Organisation"}</p>
{orgTagline && <p className="text-xs text-white/90 mt-1">{orgTagline}</p>}
</div>
<div className="px-6 py-6">
<p className="text-sm font-bold text-gray-900 mb-1">Sample notification</p>
<div className="h-2 bg-gray-100 rounded w-full mb-1.5" />
<div className="h-2 bg-gray-100 rounded w-5/6 mb-4" />
<div className="flex justify-center">
<span
className="text-xs font-bold px-6 py-2.5 rounded-lg"
style={{ background: scale["600"], color: primaryFg }}
>
Call to action
</span>
</div>
</div>
</div>
)}
</div>
<div className="px-4 py-2 border-t bg-gray-50 flex items-center gap-1.5 text-[11px] text-gray-400">
<RefreshCw className="w-3 h-3" />
Updates live as you change colors save to apply site-wide.
</div>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
import React from "react";
import { isValidHex } from "@/lib/colorScale";
/** A native color swatch + hex text input, shared by the admin Branding tab and the setup wizard's Branding step. */
export function ColorPickerField({
label, hint, value, onChange,
}: {
label: string;
hint?: string;
value: string;
onChange: (hex: string) => void;
}) {
const valid = isValidHex(value);
return (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
<div className="flex items-center gap-3">
<input
type="color"
className="h-10 w-20 border rounded cursor-pointer shrink-0"
value={valid ? value : "#000000"}
onChange={e => onChange(e.target.value)}
/>
<input
className={`w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-brand-400 ${valid ? "" : "border-red-300"}`}
placeholder="#2563eb"
value={value}
onChange={e => onChange(e.target.value)}
/>
</div>
<div className="mt-2 h-6 rounded-lg transition-colors border" style={{ background: valid ? value : "#f1f5f9" }} />
{!valid && <p className="text-xs text-red-500 mt-1">Enter a valid hex color, e.g. #4F46E5</p>}
{hint && <p className="text-xs text-gray-400 mt-1">{hint}</p>}
</div>
);
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { BRAND_600 } from "@/lib/theme"; import { useBrandColorVar } from "@/lib/theme";
export type TrendDatum = { label: string; value: number }; export type TrendDatum = { label: string; value: number };
@@ -33,6 +33,10 @@ export function AreaTrendChart({
}) { }) {
const fmt = valueFormatter || ((v: number) => String(v)); const fmt = valueFormatter || ((v: number) => String(v));
const axisFmt = axisFormatter || defaultAxisFormatter; const axisFmt = axisFormatter || defaultAxisFormatter;
// Raw SVG needs a literal hex value — reads the live admin-configurable
// primary color off the CSS custom property (see lib/theme.ts). Called
// before the early return below so hook order stays stable.
const brandColor = useBrandColorVar("--brand-600");
if (data.length === 0) { if (data.length === 0) {
return <div className="text-xs text-gray-400">No data to chart.</div>; return <div className="text-xs text-gray-400">No data to chart.</div>;
@@ -75,8 +79,8 @@ export function AreaTrendChart({
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="w-full h-auto" role="img" aria-label="Trend chart"> <svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} className="w-full h-auto" role="img" aria-label="Trend chart">
<defs> <defs>
<linearGradient id="areaTrendFill" x1="0" y1="0" x2="0" y2="1"> <linearGradient id="areaTrendFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND_600} stopOpacity="0.25" /> <stop offset="0%" stopColor={brandColor} stopOpacity="0.25" />
<stop offset="100%" stopColor={BRAND_600} stopOpacity="0" /> <stop offset="100%" stopColor={brandColor} stopOpacity="0" />
</linearGradient> </linearGradient>
</defs> </defs>
{gridlines.map((g, i) => ( {gridlines.map((g, i) => (
@@ -88,9 +92,9 @@ export function AreaTrendChart({
</g> </g>
))} ))}
<path d={areaPath} fill="url(#areaTrendFill)" stroke="none" /> <path d={areaPath} fill="url(#areaTrendFill)" stroke="none" />
<path d={linePath} fill="none" stroke={BRAND_600} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" /> <path d={linePath} fill="none" stroke={brandColor} strokeWidth={2} strokeLinejoin="round" strokeLinecap="round" />
{points.map((p, i) => ( {points.map((p, i) => (
<circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={BRAND_600} strokeWidth={2} /> <circle key={i} cx={p.x} cy={p.y} r={data.length === 1 ? 4 : 3} fill="#fff" stroke={brandColor} strokeWidth={2} />
))} ))}
</svg> </svg>
<div className="flex justify-between mt-1 ml-12 text-[10px] text-gray-400"> <div className="flex justify-between mt-1 ml-12 text-[10px] text-gray-400">
@@ -0,0 +1,72 @@
"use client";
import { useState } from "react";
import { X, Phone, Mail, User } from "lucide-react";
type ContactButtonProps = {
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
className?: string;
label?: string;
};
export function ContactButton({ contactName, contactPhone, contactEmail, className, label = "Contact us" }: ContactButtonProps) {
const [open, setOpen] = useState(false);
const hasDetails = !!(contactName || contactPhone || contactEmail);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={className || "flex-1 text-center text-sm text-white bg-brand-600 rounded-lg py-2 hover:bg-brand-700 transition-colors"}
>
{label}
</button>
{open && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
onClick={() => setOpen(false)}
>
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full mx-4 p-5" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold">Contact us</h3>
<button
className="p-1 rounded hover:bg-gray-100 text-gray-500"
onClick={() => setOpen(false)}
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
{hasDetails ? (
<div className="space-y-2 text-sm">
{contactName && (
<div className="flex items-center gap-2 text-gray-800">
<User className="w-4 h-4 text-gray-400 shrink-0" />
<span>{contactName}</span>
</div>
)}
{contactPhone && (
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-gray-400 shrink-0" />
<a href={`tel:${contactPhone}`} className="text-brand-600 hover:underline">{contactPhone}</a>
</div>
)}
{contactEmail && (
<div className="flex items-center gap-2">
<Mail className="w-4 h-4 text-gray-400 shrink-0" />
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">{contactEmail}</a>
</div>
)}
</div>
) : (
<p className="text-sm text-gray-500">No contact details have been provided for this event.</p>
)}
</div>
</div>
)}
</>
);
}
+22 -1
View File
@@ -1,5 +1,6 @@
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";
type Event = { type Event = {
id: string; id: string;
@@ -12,6 +13,11 @@ import { Calendar } from "lucide-react";
price: number; price: number;
picture?: string; picture?: string;
isSoldOut?: boolean; isSoldOut?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
location?: string | null;
}; };
import { formatDateTimeRange } from "@/lib/date"; import { formatDateTimeRange } from "@/lib/date";
@@ -35,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>
@@ -47,6 +59,15 @@ export const EventCard = ({ event }: { event: Event }) => {
View details View details
</a> </a>
{(() => { {(() => {
if (event.requiresRegistration === false) {
return (
<ContactButton
contactName={event.contactName}
contactPhone={event.contactPhone}
contactEmail={event.contactEmail}
/>
);
}
const now = new Date(); const now = new Date();
const end = new Date(event.endDate); const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
@@ -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>
);
}
@@ -0,0 +1,23 @@
"use client";
import Link from "next/link";
import { UserPlus, LayoutDashboard } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
export const JoinUsButton = () => {
const { user, loading } = useAuth();
if (loading) return null;
return user ? (
<Link href="/dashboard/user" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
<LayoutDashboard className="w-4 h-4" />
My Dashboard
</Link>
) : (
<Link href="/register" className="inline-flex items-center gap-2 px-6 py-2.5 border border-brand-600 text-brand-600 rounded-xl hover:bg-brand-50 font-medium transition-colors">
<UserPlus className="w-4 h-4" />
Join Us
</Link>
);
};
+4 -4
View File
@@ -4,14 +4,14 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react"; import { Home, Calendar, Phone, LogIn, UserPlus, LayoutDashboard, LogOut } from "lucide-react";
import { BRAND_600 } from "@/lib/theme"; import { useBrandColorVar } from "@/lib/theme";
export const BottomNav = () => { export const BottomNav = () => {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const pathname = usePathname(); const pathname = usePathname();
// Fixed brand color — unlike the navbar's org name, nothing here is // Raw SVG needs a literal hex value — reads the live admin-configurable
// admin-configurable (see globals.css for the rule). // primary color off the CSS custom property (see lib/theme.ts).
const accentColor = BRAND_600; const accentColor = useBrandColorVar("--brand-600");
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
+3 -7
View File
@@ -7,7 +7,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { BottomNav } from "./BottomNav"; import { BottomNav } from "./BottomNav";
import churchLogo from "@/app/church_logo.jpg"; import churchLogo from "@/app/church_logo.jpg";
import { appName, brandColor } from "@/lib/siteConfig"; import { appName } from "@/lib/siteConfig";
import { resolveToApiOrigin } from "@/lib/api"; import { resolveToApiOrigin } from "@/lib/api";
type NavLink = { type NavLink = {
@@ -20,11 +20,7 @@ export const Navbar = () => {
const { user, loading: authLoading, logout } = useAuth(); const { user, loading: authLoading, logout } = useAuth();
const { settings, loading: settingsLoading } = useSiteSettings(); const { settings, loading: settingsLoading } = useSiteSettings();
const pathname = usePathname(); const pathname = usePathname();
// Admin-configurable — applied ONLY to the org name text below via inline const displayName = settings.org_name || appName;
// style. No other element in the navbar (or anywhere else) should read
// this; everything else uses the fixed brand-* palette.
const displayName = settings.org_name || appName;
const displayColor = settings.accent_color || brandColor;
const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null; const logoSrc = settings.logo_url ? resolveToApiOrigin(settings.logo_url) : null;
const isActive = (href: string) => { const isActive = (href: string) => {
@@ -79,7 +75,7 @@ export const Navbar = () => {
/> />
)} )}
{!settingsLoading && ( {!settingsLoading && (
<span className="text-xl font-bold" style={{ color: displayColor }}>{displayName}</span> <span className="text-xl font-bold text-brand-600">{displayName}</span>
)} )}
</Link> </Link>
+20 -13
View File
@@ -434,10 +434,14 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
Object.keys(registrationsByEvent).forEach(evId => { Object.keys(registrationsByEvent).forEach(evId => {
(registrationsByEvent[evId] || []).forEach((r: any) => { (registrationsByEvent[evId] || []).forEach((r: any) => {
const lastAt = lastPaymentAtByReg.get(r.id) || null; const lastAt = lastPaymentAtByReg.get(r.id) || null;
const dueNow = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0); // Backend attaches a tranche-aware totalDueComputed, which locks each tranche's price
// at the time it was purchased — it's already time-invariant, so the "dueNow vs
// dueAtLast" lock-in dance below is only needed as a fallback for legacy rows without it.
const hasComputed = r.totalDueComputed !== null && r.totalDueComputed !== undefined;
const dueNow = hasComputed ? r.totalDueComputed : (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, now) * (ro.quantity || 0), 0);
const paid = paidByReg.get(r.id) || 0; const paid = paidByReg.get(r.id) || 0;
let outstanding = Math.max(dueNow - paid, 0); let outstanding = Math.max(dueNow - paid, 0);
if (lastAt) { if (lastAt && !hasComputed) {
const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0); const dueAtLast = (r.registrationOptions || []).reduce((sum: number, ro: any) => sum + optionUnitPrice(ro, lastAt, lastAt) * (ro.quantity || 0), 0);
if (paid >= dueAtLast) outstanding = 0; if (paid >= dueAtLast) outstanding = 0;
} }
@@ -722,10 +726,18 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const ro = (r.registrationOptions || []) const ro = (r.registrationOptions || [])
.find((x: any) => x.eventOption?.id === opt.id); .find((x: any) => x.eventOption?.id === opt.id);
const price = ro ? optionUnitPrice(ro, null, new Date()) : 0; // Backend attaches a tranche-aware lineTotal (exact even when this line spans
// multiple early-bird prices) — fall back to the old blended-price estimate
// otherwise. __prices stores an *average* unit price derived from that, purely
// for display; __revenue carries the real total used for aggregation below.
const lineTotal = ro
? (ro.lineTotal !== null && ro.lineTotal !== undefined ? ro.lineTotal : optionUnitPrice(ro, null, new Date()) * qty)
: 0;
baseRow.__prices[opt.name] = price; // 👈 store price baseRow.__prices[opt.name] = qty > 0 ? lineTotal / qty : 0;
baseRow.orderTotal += price * qty; baseRow.__revenue = baseRow.__revenue || {};
baseRow.__revenue[opt.name] = lineTotal;
baseRow.orderTotal += lineTotal;
}); });
rows.push(baseRow); rows.push(baseRow);
@@ -768,14 +780,9 @@ export default function ReportsV2({ onBack }: { onBack?: () => void } = {}) {
const qty = row[opt.name] || 0; const qty = row[opt.name] || 0;
totals[opt.name] += qty; totals[opt.name] += qty;
// revenue per option // revenue per option — use the tranche-aware per-row total computed above rather
const price = // than re-deriving qty*price from a blended average price.
Number( totals[`${opt.name}_revenue`] += Number(row.__revenue?.[opt.name] ?? 0);
masterRows
.find(r => r === row)?.__prices?.[opt.name] ?? 0
);
totals[`${opt.name}_revenue`] += qty * price;
}); });
}); });
+7 -1
View File
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt } from "lucide-react"; import { Calendar, Ticket, CreditCard, HandHeart, Printer, CheckCircle2, ClipboardList, Clock, SquarePen, History, CheckSquare, Receipt, FileText } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem"; import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; import type { HelpContent } from "./types";
@@ -33,6 +33,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet"> <GuideItem icon={SquarePen} title="Editing or cancelling" tone="violet">
Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely. Tap a registration to change quantities or options, pay what&apos;s outstanding, or cancel it entirely.
</GuideItem> </GuideItem>
<GuideItem icon={FileText} title="Invoice" tone="rose">
The Invoice button sends a PDF breakdown of the registration itemized cost, any early-bird discount, and what&apos;s still owing (or &quot;Paid in full&quot;) to your email or WhatsApp, whichever you pick.
</GuideItem>
<GuideItem icon={History} title="Show past events" tone="gray"> <GuideItem icon={History} title="Show past events" tone="gray">
Registrations page defaults to upcoming events only tick &quot;Show past events&quot; at the top to bring back ones that have already happened. Registrations page defaults to upcoming events only tick &quot;Show past events&quot; at the top to bring back ones that have already happened.
</GuideItem> </GuideItem>
@@ -72,6 +75,9 @@ export const dashboardUserHelpContent: HelpContent = {
<GuideItem icon={Receipt} title="Payment history" tone="green"> <GuideItem icon={Receipt} title="Payment history" tone="green">
The Payment history page lists every payment you&apos;ve made across all your registrations, past and present. The Payment history page lists every payment you&apos;ve made across all your registrations, past and present.
</GuideItem> </GuideItem>
<GuideItem icon={Receipt} title="Receipts" tone="amber">
Every payment in a registration&apos;s Payments list, or on the Payment history page has its own Receipt button. Pick email or WhatsApp to get a PDF receipt for that specific payment.
</GuideItem>
<GuideItem icon={HandHeart} title="Donations" tone="rose"> <GuideItem icon={HandHeart} title="Donations" tone="rose">
A donation isn&apos;t tied to any one registration make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance. A donation isn&apos;t tied to any one registration make one any time to support an event or the ministry directly. It can later be used to help cover an outstanding balance.
</GuideItem> </GuideItem>
+18 -3
View File
@@ -9,8 +9,14 @@ export type SiteSettings = {
org_email?: string; org_email?: string;
org_phone?: string; org_phone?: string;
org_address?: string; org_address?: string;
// Branding — 3-color system. accent_color is the tertiary/highlight color;
// primary_color falls back to accent_color's legacy value if unset (see
// settingsController.js PUBLIC_KEYS comment).
primary_color?: string;
secondary_color?: string;
accent_color?: string; accent_color?: string;
logo_url?: string; logo_url?: string;
favicon_url?: string;
setup_complete?: string; setup_complete?: string;
// Legal pages // Legal pages
legal_operator_name?: string; legal_operator_name?: string;
@@ -32,9 +38,15 @@ const SiteSettingsContext = createContext<SiteSettingsContextValue>({
reload: () => {}, reload: () => {},
}); });
export function SiteSettingsProvider({ children }: { children: React.ReactNode }) { export function SiteSettingsProvider({
const [settings, setSettings] = useState<SiteSettings>({}); children, initialSettings,
const [loading, setLoading] = useState(true); }: {
children: React.ReactNode;
/** Server-fetched settings from layout.tsx's generateMetadata/RootLayout — seeds state so there's no loading flash for anything reading useSiteSettings() (e.g. the navbar logo). */
initialSettings?: SiteSettings;
}) {
const [settings, setSettings] = useState<SiteSettings>(initialSettings || {});
const [loading, setLoading] = useState(!initialSettings);
const load = async () => { const load = async () => {
try { try {
@@ -47,6 +59,9 @@ export function SiteSettingsProvider({ children }: { children: React.ReactNode }
} }
}; };
// Always refreshes in the background (cheap, 60s-cached server-side) even
// when seeded from initialSettings — self-heals if the SSR fetch in
// layout.tsx failed, and picks up any change since that request.
useEffect(() => { load(); }, []); useEffect(() => { load(); }, []);
return ( return (
+208
View File
@@ -0,0 +1,208 @@
/**
* Framework-agnostic color math for the admin-configurable brand system.
* No DOM/window/document access this module runs identically server-side
* (the SSR `<style id="brand-theme">` tag in layout.tsx) and client-side
* (the live Branding Preview panel, recomputing on every color-picker
* keystroke before the admin has saved anything).
*/
export type Hsl = { h: number; s: number; l: number };
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
export function isValidHex(v: string): boolean {
return HEX_RE.test((v || "").trim());
}
/** Expands 3-digit hex to 6-digit, lowercases, ensures a leading #. */
function normalizeHex(hex: string): string {
let h = (hex || "").trim();
if (!h.startsWith("#")) h = `#${h}`;
if (h.length === 4) {
h = `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}`;
}
return h.toLowerCase();
}
export function hexToHsl(hex: string): Hsl {
const h6 = normalizeHex(hex);
const r = parseInt(h6.slice(1, 3), 16) / 255;
const g = parseInt(h6.slice(3, 5), 16) / 255;
const b = parseInt(h6.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: l * 100 };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let hue: number;
switch (max) {
case r: hue = ((g - b) / d + (g < b ? 6 : 0)); break;
case g: hue = (b - r) / d + 2; break;
default: hue = (r - g) / d + 4; break;
}
hue *= 60;
return { h: hue, s: s * 100, l: l * 100 };
}
export function hslToHex(h: number, s: number, l: number): string {
const hh = ((h % 360) + 360) % 360;
const ss = clamp01(s / 100);
const ll = clamp01(l / 100);
if (ss === 0) {
const v = Math.round(ll * 255);
return rgbToHex(v, v, v);
}
const q = ll < 0.5 ? ll * (1 + ss) : ll + ss - ll * ss;
const p = 2 * ll - q;
const r = hueToRgb(p, q, hh / 360 + 1 / 3);
const g = hueToRgb(p, q, hh / 360);
const b = hueToRgb(p, q, hh / 360 - 1 / 3);
return rgbToHex(Math.round(r * 255), Math.round(g * 255), Math.round(b * 255));
}
function hueToRgb(p: number, q: number, t: number): number {
let tt = t;
if (tt < 0) tt += 1;
if (tt > 1) tt -= 1;
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
if (tt < 1 / 2) return q;
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
return p;
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => clampByte(v).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function clampByte(v: number): number {
return Math.min(255, Math.max(0, Math.round(v)));
}
function clamp01(v: number): number {
return Math.min(1, Math.max(0, v));
}
export function clampL(l: number): number {
return Math.min(100, Math.max(0, l));
}
/** Rounded "H S% L%" triple matching this codebase's existing shadcn CSS-variable convention (no hsl() wrapper — see globals.css). */
export function hexToHslTriple(hex: string): string {
const { h, s, l } = hexToHsl(hex);
return `${Math.round(h)} ${Math.round(s)}% ${Math.round(l)}%`;
}
/**
* WCAG-style relative luminance picks readable foreground text/icon color
* for a solid fill of `hex`. Used for Primary (a solid button/nav fill needs
* real contrast, unlike Secondary/Accent which stay light tints see
* buildSecondaryTokens/buildAccentTokens).
*/
export function contrastForeground(hex: string): "#ffffff" | "#0a0a0a" {
const h6 = normalizeHex(hex);
const chan = (v: number) => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
};
const r = chan(parseInt(h6.slice(1, 3), 16));
const g = chan(parseInt(h6.slice(3, 5), 16));
const b = chan(parseInt(h6.slice(5, 7), 16));
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.45 ? "#0a0a0a" : "#ffffff";
}
/** Matches the shadcn-token foreground convention used elsewhere in globals.css (near-white / near-black HSL triples, not pure #fff/#000). */
export function contrastForegroundTriple(hex: string): string {
return contrastForeground(hex) === "#ffffff" ? "210 40% 98%" : "222.2 47.4% 11.2%";
}
const BRAND_SCALE_STEPS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
export type BrandScaleStep = (typeof BRAND_SCALE_STEPS)[number];
/** Lightness offsets (percentage points) applied around the picked color, which is anchored exactly at step 600 — matches the site's pre-existing default (brand-600 #4F46E5). */
const BRAND_SCALE_OFFSETS: Record<BrandScaleStep, number> = {
"50": 38, "100": 32, "200": 24, "300": 16, "400": 8,
"500": 4, "600": 0, "700": -8, "800": -16, "900": -24,
};
/** Generates the full brand-50..900 hex scale from one admin-picked "Primary" hex, anchored at 600. */
export function buildBrandScale(primaryHex: string): Record<BrandScaleStep, string> {
const { h, s, l } = hexToHsl(primaryHex);
const scale = {} as Record<BrandScaleStep, string>;
for (const step of BRAND_SCALE_STEPS) {
if (step === "600") {
scale[step] = normalizeHex(primaryHex);
} else {
scale[step] = hslToHex(h, s, clampL(l + BRAND_SCALE_OFFSETS[step]));
}
}
return scale;
}
/**
* Secondary/Accent both stay light background washes (not bold fills) so
* they read the way shadcn's own default `--secondary`/`--accent` tokens do
* (subtle hover/outline surfaces, e.g. Button's `secondary`/`outline`/`ghost`
* variants) regardless of what hue the admin picks. Lightness is PINNED to a
* fixed target rather than offset from the input (like the primary scale is)
* a relative offset would let a pastel input wash out to near-invisible.
* Saturation is capped so the tint doesn't read as a neon pastel.
*/
function buildLightTintTokens(hex: string): { bg: string; fg: string } {
const { h, s } = hexToHsl(hex);
const cappedS = Math.min(s, 60);
const bg = `${Math.round(h)} ${Math.round(cappedS)}% 96%`;
const fg = hexToHslTriple(hex);
return { bg, fg };
}
export function buildSecondaryTokens(secondaryHex: string): { bg: string; fg: string } {
return buildLightTintTokens(secondaryHex);
}
export function buildAccentTokens(accentHex: string): { bg: string; fg: string } {
return buildLightTintTokens(accentHex);
}
/**
* Builds the full CSS custom-property declaration block (no `:root{}` wrapper
* callers embed this string themselves) for the 3-color brand system.
* Any input left undefined/invalid is simply omitted, so callers can pass a
* partial set (e.g. only Primary saved so far) without emitting broken CSS.
*/
export function buildThemeCssVars(input: { primary?: string; secondary?: string; accent?: string }): string {
const decls: string[] = [];
if (input.primary && isValidHex(input.primary)) {
const scale = buildBrandScale(input.primary);
for (const step of BRAND_SCALE_STEPS) {
decls.push(`--brand-${step}:${scale[step]}`);
}
decls.push(`--primary:${hexToHslTriple(input.primary)}`);
decls.push(`--primary-foreground:${contrastForegroundTriple(input.primary)}`);
decls.push(`--ring:${hexToHslTriple(input.primary)}`);
}
if (input.secondary && isValidHex(input.secondary)) {
const { bg, fg } = buildSecondaryTokens(input.secondary);
decls.push(`--secondary:${bg}`);
decls.push(`--secondary-foreground:${fg}`);
}
if (input.accent && isValidHex(input.accent)) {
const { bg, fg } = buildAccentTokens(input.accent);
decls.push(`--accent:${bg}`);
decls.push(`--accent-foreground:${fg}`);
}
return decls.join(";");
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Client-only (canvas + Image) extracts the dominant, non-background colors
* from a locally-selected logo file, to surface as "suggested" swatches in
* the admin Branding tab. Runs off a local blob URL (URL.createObjectURL),
* never a remote URL, so there's no cross-origin/tainted-canvas concern.
*/
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error("Could not load image"));
img.src = src;
});
}
function rgbToHex(r: number, g: number, b: number): string {
const toHex = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, "0");
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
/**
* Downscales the image to a small canvas and buckets pixels by a coarse RGB
* quantization (histogram approach cheap enough at this resolution that a
* real k-means isn't needed). Near-white/near-black/low-saturation pixels are
* discarded since those are almost always logo whitespace/background, not
* the brand color someone would want suggested.
*/
export async function extractDominantColors(imageSrc: string, maxColors = 5): Promise<string[]> {
if (typeof document === "undefined") return [];
let img: HTMLImageElement;
try {
img = await loadImage(imageSrc);
} catch {
return [];
}
const size = 50;
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
if (!ctx) return [];
ctx.drawImage(img, 0, 0, size, size);
let data: Uint8ClampedArray;
try {
data = ctx.getImageData(0, 0, size, size).data;
} catch {
return [];
}
const BIN = 24; // quantization step per channel — coarse enough to merge near-duplicate shades, fine enough to keep distinct hues apart
const buckets = new Map<string, { count: number; r: number; g: number; b: number }>();
for (let i = 0; i < data.length; i += 4) {
const r = data[i], g = data[i + 1], b = data[i + 2], a = data[i + 3];
if (a < 200) continue; // transparent/near-transparent — not a real pixel
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const lightness = (max + min) / 2 / 255;
const sat = max === min ? 0 : (max - min) / (255 - Math.abs(max + min - 255));
if (lightness > 0.92 || lightness < 0.08) continue; // near-white / near-black
if (sat < 0.15) continue; // low-saturation gray
const key = `${Math.round(r / BIN)}_${Math.round(g / BIN)}_${Math.round(b / BIN)}`;
const bucket = buckets.get(key);
if (bucket) {
bucket.count++;
bucket.r += r; bucket.g += g; bucket.b += b;
} else {
buckets.set(key, { count: 1, r, g, b });
}
}
return Array.from(buckets.values())
.sort((a, b) => b.count - a.count)
.slice(0, maxColors)
.map(b => rgbToHex(b.r / b.count, b.g / b.count, b.b / b.count));
}
+9
View File
@@ -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`;
}
+4 -5
View File
@@ -2,8 +2,7 @@
* Site-wide configuration sourced from environment variables. * Site-wide configuration sourced from environment variables.
* Set these in your .env file (NEXT_PUBLIC_ prefix required for browser access). * Set these in your .env file (NEXT_PUBLIC_ prefix required for browser access).
*/ */
export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Hope Events'; export const appName = process.env.NEXT_PUBLIC_APP_NAME || 'Cross Code';
export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Hope Family Church'; export const orgName = process.env.NEXT_PUBLIC_ORG_NAME || 'Cross Code';
export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@hopehenley.co.za'; export const contactEmail = process.env.NEXT_PUBLIC_CONTACT_EMAIL || 'admin@crosscode.co.za';
export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://events.hopehenley.co.za'; export const appUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://crosscode.co.za';
export const brandColor = process.env.NEXT_PUBLIC_BRAND_COLOR || '#2563eb';
+28 -18
View File
@@ -1,20 +1,30 @@
"use client";
import { useEffect, useState } from "react";
/** /**
* Hex mirrors of the `brand` scale in tailwind.config.js, for the rare spot * Static fallback for BRAND_600 used as the initial value before the
* that needs a literal color value instead of a Tailwind class (SVG * client can read the live CSS custom property (see useBrandColorVar below),
* stroke/fill, canvas, etc.). Keep these in sync with tailwind.config.js by * and as the SSR/first-paint value. Keep in sync with globals.css's
* hand there are few enough call sites that a build-time sync step isn't * `--brand-600` default.
* worth it.
*
* Do NOT use these for the org name/heading text that stays bound to
* SiteSettingsContext.settings.accent_color, not the fixed brand palette.
*/ */
export const BRAND_50 = "#EEF2FF"; const BRAND_600_FALLBACK = "#4F46E5";
export const BRAND_100 = "#E0E7FF";
export const BRAND_200 = "#C7D2FE"; /**
export const BRAND_300 = "#A5B4FC"; * Reads a live brand CSS custom property (e.g. `--brand-600`) off the
export const BRAND_400 = "#818CF8"; * document root. Needed by the rare spot that requires a literal color value
export const BRAND_500 = "#6366F1"; * instead of a Tailwind class (SVG stroke/fill, canvas, etc.) Tailwind
export const BRAND_600 = "#4F46E5"; * classes like `text-brand-600` pick up the admin-configurable primary color
export const BRAND_700 = "#4338CA"; * automatically via CSS variables (see tailwind.config.js), but a literal
export const BRAND_800 = "#3730A3"; * hex string has to be read explicitly since it can't reference `var()`.
export const BRAND_900 = "#312E81"; */
export function useBrandColorVar(cssVar: string, fallback: string = BRAND_600_FALLBACK): string {
const [color, setColor] = useState(fallback);
useEffect(() => {
const value = getComputedStyle(document.documentElement).getPropertyValue(cssVar).trim();
if (value) setColor(value);
}, [cssVar]);
return color;
}
+17 -15
View File
@@ -39,22 +39,24 @@ module.exports = {
DEFAULT: "hsl(var(--card))", DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))", foreground: "hsl(var(--card-foreground))",
}, },
// Fixed indigo/purple brand scale — the site's hard-coded design-system // Brand scale — the site's primary design-system color. Use this (not
// color. Use this (not `indigo-*`/`blue-*` literals) for anything that // `indigo-*`/`blue-*` literals) for anything that isn't a shadcn
// isn't a shadcn primitive: nav active-states, icon chips, the help FAB, // primitive: nav active-states, icon chips, the help FAB, etc.
// etc. Never bind this scale to the admin-configurable accent_color // Values come from CSS custom properties set at request-time in
// setting (see globals.css) — that stays an inline style on the org name only. // layout.tsx from the admin's saved `primary_color` setting (see
// colorScale.ts's buildBrandScale + globals.css for the static
// fallback defaults used when no setting is saved yet).
brand: { brand: {
50: "#EEF2FF", 50: "var(--brand-50)",
100: "#E0E7FF", 100: "var(--brand-100)",
200: "#C7D2FE", 200: "var(--brand-200)",
300: "#A5B4FC", 300: "var(--brand-300)",
400: "#818CF8", 400: "var(--brand-400)",
500: "#6366F1", 500: "var(--brand-500)",
600: "#4F46E5", 600: "var(--brand-600)",
700: "#4338CA", 700: "var(--brand-700)",
800: "#3730A3", 800: "var(--brand-800)",
900: "#312E81", 900: "var(--brand-900)",
}, },
}, },
borderRadius: { borderRadius: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events", "name": "hope-events",
"version": "1.5.3", "version": "1.9.2",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"dev:backend": "cd backend && npm run dev", "dev:backend": "cd backend && npm run dev",