Compare commits

..
33 Commits
Author SHA1 Message Date
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
joshuaandClaude Sonnet 5 a0a3dc2416 Bump version to 1.5.3
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:16:53 +02:00
joshuaandClaude Sonnet 5 51639322e2 Fix help guide popups overflowing the screen on mobile
The popup card had no overall height cap, only the tab content area
did, so the header/quick-links/footer could push the close button off
screen on short mobile viewports. The card is now capped to the
screen height with the header and footer pinned in place, and only
the tab content scrolls internally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 01:14:44 +02:00
joshuaandClaude Sonnet 5 1f186d6d2a Bump version to 1.5.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:47:54 +02:00
joshua 55e7ca9546 Merge branch 'fix/dashboard-mobile-styling' into main 2026-08-07 00:47:14 +02:00
joshua 8f69e58aa2 Add tab icons and fix mobile dashboard styling issues
- Add matching lucide-react icons to tab/mode switcher buttons on
  payments, at-the-door, manual, email-attendees, whatsapp-attendees,
  and admin cashup pages, mirroring icons already used in their help menus
- Fix navbar Logout button sitting lower than other nav links (missing
  border/padding classes that other links use for their active-underline)
- Fix stat card labels getting truncated on mobile by removing the
  ellipsis-cut label and widening the mobile grid to one column
- Fix Revenue trend / Top performing events rendering outside the
  viewport on mobile by containing horizontal overflow and truncating
  long event titles in the table
2026-08-07 00:41:52 +02:00
joshuaandClaude Sonnet 5 1b2f2a7c79 Stop tracking banner.json runtime data store
Same reasoning as scheduled-emails.json: it's mutated by the running
app (site banner content), not source. readBanner() already falls
back to sane defaults when the file is missing, so untracking it is
safe for fresh checkouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:04:45 +02:00
joshuaandClaude Sonnet 5 7906248c67 Stop tracking scheduled-emails.json runtime data store
It's mutated by the running app (scheduled job queue), not source -
committing it churns the repo with live recipient/message content.
Kept on disk, just untracked; a fresh checkout will get an empty
store created on demand by ensureStore().

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:03:04 +02:00
joshua 22ffbf201e Merge branch 'fix/whatsapp-scheduling-channel-bug' into main (1.5.1) 2026-08-07 00:01:01 +02:00
joshuaandClaude Sonnet 5 be499f0d66 Fix scheduled job list/edit dropping message content
toClient() never returned the message body (html/text for email,
message for WhatsApp), so "Manage scheduled" showed nothing to view
or edit even though the content existed in storage. Editing a
scheduled WhatsApp message also saved to the wrong payload field
(text instead of message), silently discarding the edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:00:43 +02:00
joshuaandClaude Sonnet 5 75b2f8ccc5 Bump version to 1.5.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:41:43 +02:00
joshua 4f003f1628 Merge branch 'fix/whatsapp-scheduling-channel-bug' 2026-08-06 23:40:32 +02:00
joshuaandClaude Sonnet 5 b3ff2b9c5e Fix scheduled WhatsApp messages sending as email; add 24h cleanup and recipient display
The scheduled-job store never persisted the channel field, so the
send worker always fell through to its email branch regardless of
what was requested. Also purges sent jobs 24h after sending instead
of keeping them forever, and surfaces who each scheduled job will go
to in the admin "manage scheduled" lists (now correctly filtered per
channel too).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:37:35 +02:00
joshuaandClaude Sonnet 5 0833888eaa Bump version to 1.5.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:29:17 +02:00
joshuaandClaude Sonnet 5 b626404640 Merge branch 'feature/site-redesign' (1.5.0)
Full site visual redesign, site-wide contextual help system, dashboard
KPI overhaul (rolling trailing-month window), and assorted fixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:27:39 +02:00
joshuaandClaude Sonnet 5 0f2b5afa74 Help guide opens on demand only; add At the door to admin quick actions
- The site-wide help guide no longer auto-opens on first visit to a page
  — it only opens when the help button is clicked. Removed the
  now-pointless "Don't show this again" checkbox and the per-page
  dismissal tracking it drove.
- Admin dashboard quick actions was missing "At the door" (walk-in
  registration, payments, ticket printing), which Supervisor already had.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:17:12 +02:00
joshuaandClaude Sonnet 5 8e6cb542d9 Full site redesign, help system, and dashboard stats fixes
Multi-phase visual facelift (design tokens, dashboards, sidebar/navbar
shell, per-page help guides, and a layout/content pass across every
remaining page) plus backend fixes to the dashboard KPI stats:

- Admin/Supervisor dashboard KPIs (revenue, donations, registrations,
  tickets sold) now use a rolling trailing-month window (today back one
  calendar month, e.g. 9 May - 8 June if today is 8 June) instead of
  calendar month-to-date, which under-counted for most of the month.
  The comparison window shifts the same way, so like is still compared
  with like.
- Reports deep-links from those stat tiles now match the same window
  (range=trailing_month, replacing range=this_month).
- Design tokens (brand-* Tailwind scale + shadcn CSS variables), a
  site-wide contextual help button, fixed dashboard sidebar/navbar,
  Admin/Supervisor/Staff/User dashboard rebuilds backed by a new
  GET /api/stats/overview endpoint, a dedicated Contact page, Site
  Settings restyle with WhatsApp config folded in, and an Account
  activity feed backed by a new SecurityEvent model.
- Every remaining page (home, events, registration flow, auth, legal,
  payment results, and every Admin/Supervisor/Staff/User tool page)
  restyled onto the same design tokens, several with real layout
  upgrades (home hero, events list/detail, donate page, auth pages).
- 20+ new dedicated help guides so the whole site has page-specific
  help content instead of falling back to a generic guide.
- Assorted fixes surfaced along the way: donation-leg double-counting
  in payment stats, donations not counting toward revenue, refund
  netting in per-method report breakdowns, and donation
  over-allocation after a refund.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 15:00:10 +02:00
165 changed files with 8709 additions and 4935 deletions
+5 -1
View File
@@ -33,4 +33,8 @@ Thumbs.db
# misc scratch / generated files
temp/
backend/public/uploads/
backend/public/uploads/
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
backend/data/banner.json
+128
View File
@@ -7,6 +7,134 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [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
### Fixed
- Help guide popups could render taller than the screen on mobile, with no way to reach the close button or "Got it" button: only the tab content area had a height cap, so the header, quick links, and footer weren't accounted for. The whole popup is now capped to the screen height, with just the tab content scrolling internally.
## [1.5.2] - 2026-08-07
### Fixed
- Stat card labels on the Admin and Supervisor dashboards (e.g. "Registrations (past month)") were getting cut off mid-word on mobile, where a cramped 2-column grid left too little room for the text. The mobile layout now shows one card per row, and labels wrap onto two lines instead of truncating.
- The "Revenue trend" and "Top performing events" cards on the Admin and Supervisor dashboards could render outside the viewport on mobile: a wide events table (long titles, four columns) forced the containing grid column past the screen width instead of scrolling internally. Long event titles are now truncated in the table, and the page properly contains horizontal overflow.
- The navbar Logout button sat about 2px lower than the other nav links (Home/Events/Contact/Dashboard) because it was missing the same underline-spacing classes those links use.
### Added
- Tab/mode switcher buttons on the Payments, At the door, Manual registration, Email attendees, WhatsApp attendees, and Cashup detail pages now show icons, matching the icons already used for the same tabs in each page's help guide.
## [1.5.1] - 2026-08-06
### Fixed
- Scheduling a WhatsApp message was silently sent as an email instead: the scheduled-job store never persisted the `channel` field, so the send worker always fell through to its email branch regardless of what was requested. Scheduled WhatsApp jobs now correctly send via WhatsApp.
- Scheduled emails/WhatsApp messages showed no content when viewing or editing them in "Manage scheduled", even though the message existed in storage: the list endpoint never returned the message body (`html`/`text` for email, `message` for WhatsApp), and editing a scheduled WhatsApp message saved to the wrong payload field (`text` instead of `message`), so edits were silently lost. Both are now fixed, and the email list also shows a body preview like the WhatsApp one already did.
### Added
- Scheduled emails/WhatsApp messages are now automatically purged from storage 24 hours after they're sent, instead of accumulating indefinitely.
- The "Manage scheduled" lists on the Email Attendees and WhatsApp Attendees pages now show who each scheduled job will be sent to, and each tab only shows jobs for its own channel (previously both tabs showed the same unfiltered list).
## [1.5.0] - 2026-08-06
### Added
- Site redesign, phase 3: the fixed indigo/purple brand palette is now applied consistently across the whole site — every remaining page that still used the old ad-hoc indigo/blue colors (home, events, registration flow, auth pages, legal pages, payment result pages, and every Admin/Supervisor/Staff/User tool page) now uses the same design tokens as the redesigned dashboards. Deliberate exceptions (status badges like "Confirmed", multi-option selectors like Email/WhatsApp/Both, the WhatsApp role badge) were left alone since they're meaningfully distinct colors, not leftover brand color.
- New help guides for account pages (login/register/forgot-password) and My Payments/Donate, replacing the generic fallback.
- Site redesign, deep pass: beyond the phase 3 color sweep, the home page, events list, and event detail page were rebuilt with real layout upgrades (hero section, icon-chip headers, image placeholders, a sticky two-column tickets layout on event detail). Every Admin/Supervisor/Staff/User tool page now has an icon-chip header consistent with the dashboards, and the smaller public/auth pages (Login, Register, Forgot Password, Activate Account, Reset Password, Site Banner, Registration Success, Payment Success/Failure/Cancel, the standalone Attendee Forms page) were restyled to match, including wrapping the previously bare Attendee Forms page in the site's Navbar/Footer for the first time.
- 17 new dedicated help guides were added (home, events, event detail, and every remaining Admin/Supervisor/Staff/User tool page), so the whole site now has page-specific help content instead of falling back to the generic guide.
- Site redesign, phase 1 (foundation): fixed indigo/purple design tokens wired into Tailwind and the previously-unstyled shadcn components (Button, Card, Badge, Table, Sheet, etc. were silently missing their CSS variables); a site-wide floating "Need help?" button, present on every page, showing a contextual guide with quick links (e.g. "Browse events", "View my tickets") — content is resolved per page via a new registry, with a general fallback (how to register / manage tickets) for any page without dedicated content yet.
- Dashboard sidebar (My Events, Profile & Security, Admin, Site Settings) now also shows on `/dashboard/user/profile` and `/dashboard/admin/settings`, not just each role's root page, and is now fixed in place so it no longer scrolls with page content.
- New shared components (`StatCard`, `QuickActionTile`, `AreaTrendChart`) scaffolded for the upcoming dashboard redesign.
- Contact now has its own dedicated page (`/contact`) instead of being a scroll-to section on the home page — same org email/phone/address, restyled to match the new design.
- The general (public) and My Events help guides now go into much more detail: the general guide explains guest vs. account-required registration with quick links to log in or create an account, and the My Events guide walks through registration status badges, attendee forms, early-bird pricing, editing/cancelling, bulk ticket actions, and the difference between paying a balance, payment history, and donations. The Admin, Supervisor, Staff, and Site Settings pages now have their own dedicated help guides too, instead of falling back to the general one.
- Site redesign, phase 2 (dashboards): Admin and Supervisor dashboards now show a KPI row (active events, revenue, donations, registrations, tickets sold — each with a "vs last month" comparison), a revenue trend chart for the current month, and a top-performing-events table, backed by a new `GET /api/stats/overview` endpoint. Staff and My Events dashboards are visually reskinned onto the new design tokens only — no new financial data for Staff, by design.
- Admin/Supervisor dashboard stat tiles now link somewhere useful: Revenue/Donations/Tickets sold jump straight into the matching Reports report, pre-filtered to this month (new `?report=&range=this_month` deep-linking support in Reports); Active events links to Manage events.
- Reports revenue trend chart now shows gridlines, axis labels, and a marker dot per data point, so a sparse day-or-two of data still renders as a visible chart instead of an empty-looking box.
- WhatsApp API management moved from its own page into a new "WhatsApp" tab on Site Settings (`/dashboard/admin/settings?tab=whatsapp`); the old `/dashboard/admin/whatsapp` route now just redirects there.
- Site Settings restyled to match the rest of the redesign (icon tab bar, card layout, brand colors).
- My Events dashboard now shows event thumbnails on registrations, upcoming events, and tickets, with upcoming events and tickets laid out side by side.
- Profile & Security page restyled into a two-column layout with a new "Account activity" section showing recent logins and password changes (device + timestamp), backed by a new append-only `SecurityEvent` log and `GET /api/users/activity` endpoint.
- Help guide modal now animates in instead of appearing abruptly, and got a general visual polish pass (rounded corners, active-tab styling, hover states).
### Fixed
- Admin/Supervisor dashboard "Revenue" figures (today/week/month) were overcounting: they didn't exclude donation-application "legs" (the money was already counted once via the original donation) the way Reports and the admin payments-stats endpoint already did. Both endpoints now use the same exclusion.
- Admin/Supervisor dashboard "Revenue" KPI, trend chart, and top-performing-events table were undercounting the other way — donations themselves weren't being counted as revenue at all, only tracked in the separate "Donations" figure. Donations now count toward revenue (donation-application legs are still excluded either way, since that money was already counted once via the original donation).
- Admin/Supervisor dashboard KPIs (revenue, donations, registrations, tickets sold) used a calendar-month-to-date window, which under-counted for most of the month (e.g. only 3 days' worth of data on the 3rd). They now use a rolling trailing-month window instead — today back one calendar month, e.g. 9 May through 8 June if today is 8 June — compared against the equal-length month before that. The "View report" links from those tiles now deep-link into the same trailing-month range.
- Admin dashboard was missing the "At the door" quick action that Supervisor already had (walk-in registration, payments, and ticket printing at the door) — added.
### Changed
- Reports' "Reporting guide" is now powered by the new site-wide help system instead of a Reports-only modal — same content, opened from the floating help button instead of a sidebar button.
- Navbar/bottom nav link colors are now fixed to the site's indigo brand color instead of the admin-configurable accent color; the accent color setting now only affects the org name text next to the logo.
- "Contact" links across the nav and help content now point to `/contact` instead of `/#contact`.
- Admin/Supervisor dashboard quick actions no longer list "Manage sections" or "Event tickets & printing" (redundant with the Events and At-the-door pages) or "Manage WhatsApp API" (moved into Site Settings). Staff keeps "Event tickets & printing" — their only other tool is ticket scanning, and they can't reach At-the-door.
- Admin dashboard no longer shows ticket-scanning stats ("Recent scans") — kept on Supervisor/Staff, where it's actually actionable.
- The old hand-rolled `Button` component (`components/shared/Button.tsx`) is gone — its one remaining caller now uses the standard `components/ui/button.tsx`.
- The site-wide help guide no longer opens itself automatically on first visit to a page — it only opens when the help button is clicked. The now-pointless "Don't show this again" checkbox was removed along with the per-page dismissal tracking it drove.
### Removed
- Deleted dead code found while migrating the last `Button` usage: an unused `EventForm` component, a stale pre-redesign `Reports.tsx`, and a stray `ReportsV2_backup.tsx` — none were imported anywhere.
## [1.4.2] - 2026-08-06
### Fixed
+1
View File
@@ -111,6 +111,7 @@ Documentation: see Frontend README for environment and scripts.
- Backend API Documentation: ./backend/API_DOCUMENTATION.md
- Backend README: ./backend/README.md
- Frontend README: ./frontend/README.md
- Site Settings — defaults reference: ./SETTINGS.md
## 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.
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 |
|--------|------|------|-------------|
| GET | `/api/settings` | public | Public settings (org name, tagline, colour, logo, legal keys) |
-6
View File
@@ -1,6 +0,0 @@
{
"message": "",
"type": "info",
"liveFrom": null,
"liveTill": null
}
-3
View File
@@ -1,3 +0,0 @@
{
"jobs": []
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.4.2",
"version": "1.8.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,20 @@
-- CreateEnum
CREATE TYPE "SecurityEventType" AS ENUM ('login', 'password_changed', 'password_reset');
-- CreateTable
CREATE TABLE "SecurityEvent" (
"id" TEXT NOT NULL,
"userId" TEXT,
"type" "SecurityEventType" NOT NULL,
"ip" TEXT,
"device" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SecurityEvent_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SecurityEvent_userId_createdAt_idx" ON "SecurityEvent"("userId", "createdAt");
-- AddForeignKey
ALTER TABLE "SecurityEvent" ADD CONSTRAINT "SecurityEvent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -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;
+53 -1
View File
@@ -47,6 +47,12 @@ enum EventCostType {
per_item
}
enum SecurityEventType {
login
password_changed
password_reset
}
model User {
id String @id @default(uuid())
name String
@@ -78,6 +84,8 @@ model User {
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
}
model Event {
@@ -93,6 +101,10 @@ model Event {
isActive Boolean @default(true)
isHidden Boolean @default(false)
requiresAuth Boolean @default(true)
requiresRegistration Boolean @default(true)
contactName String?
contactPhone String?
contactEmail String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String?
@@ -155,6 +167,7 @@ model EarlyBirdTier {
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
registrationOptions RegistrationOption[]
tranches RegistrationOptionTranche[]
@@index([eventOptionId, deadline])
@@index([variantId])
@@ -193,6 +206,7 @@ model Registration {
@@index([eventId])
@@index([userId, status])
@@index([eventId, status])
@@index([createdAt, status])
}
model RegistrationOption {
@@ -208,6 +222,7 @@ model RegistrationOption {
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: SetNull)
appliedTier EarlyBirdTier? @relation(fields: [appliedTierId], references: [id], onDelete: SetNull)
tickets Ticket[]
tranches RegistrationOptionTranche[]
@@index([registrationId])
@@index([eventOptionId])
@@ -215,6 +230,27 @@ model RegistrationOption {
@@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 {
id String @id @default(uuid())
amount Float
@@ -241,7 +277,7 @@ model Payment {
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId])
@@index([originalPaymentId, isDonation])
}
model Ticket {
@@ -263,6 +299,7 @@ model Ticket {
@@index([eventId])
@@index([userId])
@@index([registrationOptionId])
@@index([createdAt])
}
model TicketUsage {
@@ -291,6 +328,21 @@ model PasswordReset {
@@index([userId])
}
// Append-only "Account activity" log for the Profile & Security page (logins, password
// changes). Nullable FK with SetNull (not Cascade) so entries survive account
// close/anonymization, the same pattern EventCashup uses for its performedBy audit trail.
model SecurityEvent {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
type SecurityEventType
ip String?
device String?
createdAt DateTime @default(now())
@@index([userId, createdAt])
}
model EventAttachment {
id String @id @default(uuid())
eventId String
+4 -3
View File
@@ -64,8 +64,9 @@ const MAPPINGS = [
{ key: 'org_tagline', envVars: ['ORG_TAGLINE'] },
{ key: 'org_email', envVars: ['EMAIL_FROM', 'EMAIL_USER', 'SMTP_FROM', 'SMTP_USER'] },
// Branding
{ key: 'accent_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Branding — these env vars historically fed the single "brand color" concept,
// which is now the Primary color in the 3-color Primary/Secondary/Accent system.
{ key: 'primary_color', envVars: ['EMAIL_HEADER_COLOR', 'BRAND_COLOR'] },
// Notifications
{ key: 'reg_notification_emails',envVars: ['REGISTRATIONS_EMAIL'] },
@@ -88,7 +89,7 @@ async function main() {
const prisma = new PrismaClient();
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 (FORCE) console.log(' MODE: FORCE (will overwrite existing DB values)');
console.log(`${'─'.repeat(60)}\n`);
@@ -57,6 +57,23 @@ function parseFreeformEmails(lines) {
return recipients;
}
// Human-readable summary of who a broadcast will go to, for the scheduled-jobs admin UI
async function describeBroadcastRecipients({ userIds, emails }) {
const parts = [];
try {
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
if (ids.length) {
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
const names = users.map(u => u.name).filter(Boolean);
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
}
if (Array.isArray(emails) && emails.length) {
parts.push(`${emails.length} email address${emails.length === 1 ? '' : 'es'}`);
}
} catch {}
return parts.length ? parts.join('; ') : 'No recipients';
}
// @desc Preview broadcast recipients and sample
// @route POST /api/broadcasts/preview
// @access Private/Supervisor or Admin
@@ -191,11 +208,14 @@ const scheduleBroadcast = async (req, res) => {
const payload = { subject, html, text, userIds, emails, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
recipientSummary,
payload,
});
+60 -17
View File
@@ -41,7 +41,7 @@ function toAbsoluteUrl(req, url) {
// @access Private/Admin
const createEvent = async (req, res) => {
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 } = req.body;
const data = {
id: uuidv4(),
@@ -58,14 +58,19 @@ const createEvent = async (req, res) => {
redirectUrl,
isHidden: isHidden === true || isHidden === 'true',
requiresAuth: requiresAuth === false || requiresAuth === 'false' ? false : true,
requiresRegistration: requiresRegistration === false || requiresRegistration === 'false' ? false : true,
contactName: contactName || null,
contactPhone: contactPhone || null,
contactEmail: contactEmail || null,
};
try {
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 {
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({
data: {
id: uuidv4(),
@@ -454,7 +459,7 @@ const updateEvent = async (req, res) => {
// totals — same rule already enforced for payments/costs. Admin can reopen first.
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 } = req.body;
const data = {
title: title || event.title,
@@ -468,6 +473,10 @@ const updateEvent = async (req, res) => {
isActive: isActive !== undefined ? isActive : event.isActive,
isHidden: isHidden !== undefined ? (isHidden === true || isHidden === 'true') : (event.isHidden ?? false),
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,
updatedAt: new Date(),
redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl,
};
@@ -682,26 +691,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)) {
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++) {
const t = earlyBirdTiers[i];
if (!t || !t.deadline || (t.price === undefined || t.price === null)) continue;
const deadline = new Date(t.deadline);
const p = parseFloat(t.price);
if (!(deadline instanceof Date) || isNaN(deadline.getTime()) || !(p >= 0)) continue;
await prisma.earlyBirdTier.create({
data: {
id: require('uuid').v4(),
eventOptionId: updatedEventOption.id,
variantId: t.variantId || null,
deadline,
price: p,
order: typeof t.order === 'number' ? t.order : i,
stockLimit: t.stockLimit ? parseInt(t.stockLimit, 10) : 0,
}
});
const data = {
eventOptionId: updatedEventOption.id,
variantId: t.variantId || null,
deadline,
price: p,
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 } });
}
}
}
@@ -1569,6 +1601,15 @@ const whatsappEventAttendees = async (req, res) => {
}
};
// Human-readable summary of who an attendees-scoped send will go to, for the scheduled-jobs admin UI
function describeAttendeeFilter(filter) {
if (Array.isArray(filter?.attendeeIds) && filter.attendeeIds.length > 0) {
return `${filter.attendeeIds.length} selected attendee${filter.attendeeIds.length === 1 ? '' : 's'}`;
}
const labels = { paid: 'Paid attendees', unpaid: 'Unpaid attendees', partial_paid: 'Partially paid attendees', cancelled: 'Cancelled registrations' };
return labels[filter?.status] || 'All attendees';
}
// @desc Schedule email to attendees at a specific date/time
// @route POST /api/events/:id/email-attendees/schedule
// @access Private/Supervisor or Admin
@@ -1600,6 +1641,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
eventId,
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title}${describeAttendeeFilter(filter)}`,
payload,
});
@@ -1637,6 +1679,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
channel: 'whatsapp',
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title}${describeAttendeeFilter(filter)}`,
payload,
});
+157 -12
View File
@@ -5,7 +5,7 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const axios = require('axios');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen } = require('../utils/cashupUtils');
const { assertEventOpen, assertRegistrationEventOpen } = require('../utils/cashupUtils');
// @desc Create a new payment
// @route POST /api/payments
@@ -78,7 +78,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
user: { select: { id: true } }
}
@@ -134,7 +134,7 @@ const createPayment = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
user: { select: { id: true } }
}
@@ -242,7 +242,8 @@ const createPayment = async (req, res) => {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } }
eventOption: { include: { earlyBirdTiers: true } },
tranches: true
}
},
payments: true
@@ -641,7 +642,8 @@ const assignDonationToRegistration = async (req, res) => {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } }
eventOption: { include: { earlyBirdTiers: true } },
tranches: 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 }
// Does NOT check user authorization — callers are responsible for ensuring the user owns the registration.
async function createRegistrationCheckoutInternal(registrationId, userId, { successUrl, cancelUrl, failureUrl } = {}) {
@@ -780,6 +905,7 @@ async function createRegistrationCheckoutInternal(registrationId, userId, { succ
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
@@ -894,7 +1020,7 @@ const createYocoCheckout = async (req, res) => {
const freshReg = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true
}
});
@@ -917,6 +1043,7 @@ const createYocoCheckout = async (req, res) => {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: true,
tranches: true,
}
},
payments: true,
@@ -1057,20 +1184,22 @@ const sendPaymentLink = async (req, res) => {
const { user, event } = registration;
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 (!user.email) { res.status(400); throw new Error('This user has no email address on file'); }
const { sendMail } = require('../utils/email');
await sendMail({
sendMail({
to: user.email,
subject: `Payment link — ${event?.title || 'Registration'}`,
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>`
});
}).catch(e => console.error('Failed to send payment link email:', e));
} else {
const { isValidZAPhone } = require('../utils/whatsapp');
if (!isValidZAPhone(user.phoneNumber)) { res.status(400); throw new Error('This user has no valid WhatsApp number on file'); }
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 });
@@ -1145,7 +1274,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
// Load tickets to check usage if needed
_count: true
@@ -1199,7 +1328,7 @@ const createRefund = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: linkRegistrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: 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 = {
createPayment,
getPayments,
@@ -1299,9 +1442,11 @@ module.exports = {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
sendPaymentLink,
createRefund,
getPaymentStats
getPaymentStats,
sendReceipt,
};
+354 -126
View File
@@ -4,7 +4,7 @@ const axios = require("axios");
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
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');
/**
@@ -136,16 +136,16 @@ const createRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Validate each option and resolve prices / check stock
const resolvedOptions = [];
for (const option of options) {
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
// 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);
if (!eventOption) {
res.status(400);
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
}
if (!option.quantity || option.quantity < 1) {
res.status(400);
throw new Error('Quantity must be at least 1');
}
@@ -155,11 +155,12 @@ const createRegistration = async (req, res) => {
try {
const stockCheck = await checkOptionStock(eventOption, qty);
if (!stockCheck.available) {
res.status(400);
throw new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
throw err;
}
} 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
}
@@ -169,17 +170,17 @@ const createRegistration = async (req, res) => {
if (variantId && canIncludeVariants) {
const variant = (eventOption.variants || []).find(v => v.id === variantId);
if (!variant) {
res.status(400);
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty);
if (!vStock.available) {
res.status(400);
throw new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
throw err;
}
} catch (e) {
if (res.statusCode !== 200) throw e;
if (e.isStockError) throw e;
}
variantPrice = variant.price; // null = use option price
}
@@ -213,8 +214,8 @@ const createRegistration = async (req, res) => {
}
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
const existingReg = await prisma.registration.findFirst({
@@ -225,17 +226,31 @@ const createRegistration = async (req, res) => {
let registration;
let isNewRegistration = false;
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) {
// Match on eventOptionId + variantId for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
);
if (existing) {
await prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
});
await prisma.$transaction([
prisma.registrationOptionTranche.create({
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 {
await prisma.registrationOption.create({
data: {
@@ -246,6 +261,14 @@ const createRegistration = async (req, res) => {
variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null,
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({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
}
@@ -285,11 +308,19 @@ const createRegistration = async (req, res) => {
variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
}))
}
},
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: 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) {
res.status(400).json({ message: error.message });
}
@@ -378,28 +409,44 @@ const createRegistration = async (req, res) => {
// @access Private/Admin
const getRegistrations = async (req, res) => {
try {
const registrations = await prisma.registration.findMany({
include: {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
}
},
event: true,
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
const include = {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tranches: true,
}
},
event: true,
user: {
select: {
id: true,
name: true,
email: 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) {
res.status(400).json({ message: error.message });
}
@@ -418,6 +465,18 @@ const getUserRegistrations = async (req, res) => {
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({
where: whereClause,
include: {
@@ -425,6 +484,7 @@ const getUserRegistrations = async (req, res) => {
include: {
eventOption: { include: { earlyBirdTiers: 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
@@ -437,12 +497,13 @@ const getUserRegistrations = async (req, res) => {
email: true,
phoneNumber: true
}
}
},
payments: true,
},
orderBy: { createdAt: 'desc' }
});
res.json(registrations);
res.json(attachComputedTotalsToList(registrations));
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -453,35 +514,42 @@ const getUserRegistrations = async (req, res) => {
// @access Private
const getRegistrationById = async (req, res) => {
try {
const registration = await prisma.registration.findUnique({
where: { id: req.params.id },
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tickets: true
}
},
event: true,
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
}
});
const include = {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tickets: true,
tranches: true
}
},
event: true,
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
},
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
};
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
if (!registration) {
res.status(404);
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)
// 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') {
@@ -489,7 +557,7 @@ const getRegistrationById = async (req, res) => {
throw new Error('Not authorized to view this registration');
}
res.json(registration);
res.json(attachComputedTotals(registration));
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -643,6 +711,17 @@ const cancelRegistration = async (req, res) => {
const getRegistrationsByEvent = async (req, res) => {
try {
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({
where: { eventId: req.params.eventId },
include: {
@@ -651,6 +730,7 @@ const getRegistrationsByEvent = async (req, res) => {
eventOption: { include: { earlyBirdTiers: true } },
variant: { select: { id: true, name: true, price: true } },
tickets: true,
tranches: 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) {
res.status(400).json({ message: error.message });
}
@@ -875,20 +955,33 @@ const createManualRegistration = async (req, res) => {
let isNewRegistration = false;
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 => {
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
);
if (existing) {
return prisma.registrationOption.update({
where: { id: existing.id },
data: {
quantity: existing.quantity + opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
}
});
return prisma.$transaction([
prisma.registrationOptionTranche.create({
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,
}
})
]);
}
return prisma.registrationOption.create({
data: {
@@ -899,6 +992,14 @@ const createManualRegistration = async (req, res) => {
variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null,
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({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
payments: true,
}
});
@@ -921,7 +1022,7 @@ const createManualRegistration = async (req, res) => {
registration = await prisma.registration.findUnique({
where: { id: existingReg.id },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
},
@@ -944,11 +1045,19 @@ const createManualRegistration = async (req, res) => {
variantId: option.variantId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
create: [{
id: uuidv4(),
quantity: option.quantity,
priceSnapshot: option.priceSnapshot,
appliedTierId: option.appliedTierId || null,
}]
}
})),
},
},
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: 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) {
console.error(error);
return res.status(400).json({message: error.message});
@@ -1047,6 +1156,7 @@ const updateRegistrationOptions = async (req, res) => {
registrationOptions: {
include: {
tickets: true,
tranches: true,
eventOption: { include: { earlyBirdTiers: true, variants: true } }
}
},
@@ -1118,39 +1228,11 @@ const updateRegistrationOptions = async (req, res) => {
}
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
// 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();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
@@ -1158,6 +1240,86 @@ const updateRegistrationOptions = async (req, res) => {
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
// 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.
@@ -1192,20 +1354,63 @@ const updateRegistrationOptions = async (req, res) => {
const existingRows = oldByKey.get(key);
if (existingRows && existingRows.length > 0) {
const [primary, ...dupes] = existingRows;
await tx.registrationOption.update({
where: { id: primary.id },
data: {
quantity: opt.quantity,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
}
});
// Consolidate dupes into primary first: move their tranches and tickets rather
// than discarding them, so price history (and money) survives duplicate cleanup.
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) {
await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.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 {
await tx.registrationOption.create({
data: {
@@ -1216,6 +1421,14 @@ const updateRegistrationOptions = async (req, res) => {
variantId: opt.variantId || null,
appliedTierId: opt.appliedTierId || null,
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({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
payments: true,
@@ -1261,7 +1474,7 @@ const updateRegistrationOptions = async (req, res) => {
})();
}
return res.json(updated);
return res.json(attachComputedTotals(updated));
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -1280,7 +1493,7 @@ const submitFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: true } },
event: true,
}
});
@@ -1372,7 +1585,7 @@ const replaceFormResponses = async (req, res) => {
const registration = await prisma.registration.findUnique({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: true } },
registrationOptions: { include: { eventOption: true, tranches: 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 = {
createRegistration,
getRegistrations,
@@ -1548,4 +1775,5 @@ module.exports = {
replaceFormResponses,
getFormDraft,
saveFormDraft,
sendInvoice,
};
+2 -1
View File
@@ -250,7 +250,8 @@ async function renderReportPdfToFile(payload) {
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
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();
const sheetName = (title || 'Report').replace(/[\\/*?:[\]]/g, ' ').slice(0, 31) || 'Report';
const ws = wb.addWorksheet(sheetName);
@@ -6,11 +6,14 @@ function toClient(job) {
const subject = job?.payload?.subject || '';
const html = job?.payload?.html || '';
const text = job?.payload?.text || '';
const message = job?.payload?.message || '';
return {
id: job.id,
kind,
eventId: job.eventId || null,
broadcast: !!job.broadcast,
channel: job.channel || 'email',
recipient: job.recipientSummary || null,
scheduledAt: job.scheduledAt,
createdAt: job.createdAt,
status: job.status,
@@ -18,26 +21,21 @@ function toClient(job) {
sentAt: job.sentAt || null,
lastError: job.lastError || null,
subject,
html,
text,
message,
hasHtml: !!html,
hasText: !!text,
};
}
// GET /api/scheduled-emails
// Returns jobs excluding emails sent more than a week ago
// GET /api/scheduled-emails?channel=email|whatsapp
// Sent jobs are purged from storage 24h after sending, so nothing older than that is ever returned here
const listScheduledEmails = async (req, res) => {
try {
const raw = listJobs();
const now = new Date();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const filtered = raw.filter(j => {
if (j.status === 'sent' && j.sentAt) {
const sentAt = new Date(j.sentAt).getTime();
return (now.getTime() - sentAt) <= weekMs;
}
// Include queued, sending, error by default
return true;
})
const { channel } = req.query || {};
const raw = listJobs(channel ? { channel: String(channel) } : {});
const filtered = raw
// Provide most-relevant first: queued -> sending -> error -> recent sent
.sort((a, b) => {
const order = { queued: 0, sending: 1, error: 2, sent: 3 };
@@ -56,7 +54,7 @@ const listScheduledEmails = async (req, res) => {
};
// PATCH /api/scheduled-emails/:id
// Allows editing scheduledAt, subject, html/text on queued jobs only
// Allows editing scheduledAt and message content (subject/html/text for email jobs, message for WhatsApp jobs) on queued jobs only
const updateScheduledEmail = async (req, res) => {
try {
const { id } = req.params;
@@ -64,7 +62,7 @@ const updateScheduledEmail = async (req, res) => {
if (!job) return res.status(404).json({ message: 'Job not found' });
if (job.status !== 'queued') return res.status(400).json({ message: 'Only queued jobs can be edited' });
const { scheduledAt, subject, html, text } = req.body || {};
const { scheduledAt, subject, html, text, message } = req.body || {};
const patch = {};
if (scheduledAt) {
@@ -72,14 +70,12 @@ const updateScheduledEmail = async (req, res) => {
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
patch.scheduledAt = when.toISOString();
}
if (subject != null || html != null || text != null) {
if (subject != null || html != null || text != null || message != null) {
const payload = { ...(job.payload || {}) };
if (subject != null) payload.subject = subject;
if (html != null || text != null) {
// If html provided explicitly, set html; if text provided, set text
if (html != null) payload.html = html;
if (text != null) payload.text = text;
}
if (html != null) payload.html = html;
if (text != null) payload.text = text;
if (message != null) payload.message = message;
patch.payload = payload;
}
+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
const PUBLIC_KEYS = [
'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_operator_name', 'legal_io_name', 'legal_io_email',
'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
const adminEmail = req.user?.email;
if (adminEmail) {
const orgName = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
await transporter.sendMail({
from: from || user || 'no-reply@hope-events.local',
from: from || user || 'no-reply@crosscode.local',
to: adminEmail,
subject: 'SMTP test — Hope Events',
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}`,
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>`,
subject: `SMTP test — ${orgName}`,
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>${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>`,
});
}
+151 -3
View File
@@ -1,5 +1,6 @@
const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { isDonationLeg } = require('../utils/cashupUtils');
// Shared building blocks for the per-dashboard stats endpoints below. Each dashboard
// (staff/supervisor/admin) gets exactly one endpoint that returns only what it renders,
@@ -56,19 +57,26 @@ function getActiveEventsCount() {
return prisma.event.count({ where: { isActive: true, endDate: { gte: new Date() } } });
}
// A donation-application leg (isDonation:false, originalPaymentId set, amount>0) isn't new
// money — see isDonationLeg in cashupUtils.js. Matches the same exclusion already applied in
// paymentController.getPaymentStats and every report; without it these totals overcount.
const EXCLUDE_DONATION_LEGS = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] },
};
async function computePaymentStats({ includeWeekMonth }) {
const startOfDay = new Date(new Date().setHours(0, 0, 0, 0));
const queries = [
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: startOfDay }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.count({ where: { isDonation: true, createdAt: { gte: startOfDay } } }),
];
if (includeWeekMonth) {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
queries.push(
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth } } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastWeek }, ...EXCLUDE_DONATION_LEGS } }),
prisma.payment.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: lastMonth }, ...EXCLUDE_DONATION_LEGS } }),
);
}
@@ -134,8 +142,148 @@ const getAdminDashboardStats = async (req, res) => {
}
};
// Subtracts `months` calendar months from `date`, clamping to the last day of the target
// month if the original day doesn't exist there (e.g. 31 Mar - 1 month -> 28/29 Feb, not
// 3 Mar which is what naive setMonth() arithmetic would silently produce).
function subtractMonths(date, months) {
const d = new Date(date.getTime());
const originalDate = d.getDate();
d.setMonth(d.getMonth() - months, 1); // move to the 1st of the target month first, so setDate below can't spill into the following month
const daysInTargetMonth = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(originalDate, daysInTargetMonth));
return d;
}
// Returns a rolling one-month window ending at the end of today (so today is fully
// included), and the equal-length window immediately before it — e.g. if today is 8 June,
// thisStart/thisEnd covers 9 May through the end of 8 June, and lastStart/lastEnd covers
// 9 April through 8 May. This replaced a calendar-month-to-date window (1st of the month
// through now), which under-counted for most of the month — e.g. on the 3rd it only
// covered 3 days' worth of data instead of a full trailing month.
function trailingMonthRanges(now = new Date()) {
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
const thisEnd = new Date(startOfToday.getTime() + 24 * 60 * 60 * 1000); // exclusive upper bound, covers all of today
const thisStart = subtractMonths(thisEnd, 1);
const lastEnd = thisStart;
const lastStart = subtractMonths(thisStart, 1);
return { thisStart, thisEnd, lastStart, lastEnd };
}
function pctChange(current, previous) {
if (previous === 0) return current === 0 ? 0 : null;
return ((current - previous) / previous) * 100;
}
function fetchPaymentsWithOriginal(where) {
return prisma.payment.findMany({
where,
select: {
id: true, amount: true, isDonation: true, originalPaymentId: true, eventId: true, createdAt: true,
originalPayment: { select: { isDonation: true } },
},
});
}
// Classifies each payment the same way computeEventFinancials does (cashupUtils.js):
// donation-application legs are excluded entirely (not new money — see isDonationLeg).
// Everything else — registration payments, donations themselves, and refunds of either —
// is real money in/out and counts toward "revenue". "donations" is a breakdown *within*
// that revenue (donations plus any refund of a donation), not a separate bucket.
function classifyAndSum(payments) {
let revenue = 0;
let donations = 0;
for (const p of payments) {
if (isDonationLeg(p)) continue;
revenue += p.amount;
if (p.isDonation) {
donations += p.amount;
} else if (p.originalPaymentId && p.amount < 0 && p.originalPayment?.isDonation) {
donations += p.amount;
}
}
return { revenue, donations };
}
// @desc Registrations/tickets/revenue/donations for the trailing month (today back one
// month) vs the month before that, a daily revenue trend for the trailing month,
// and the top performing events overall.
// @route GET /api/stats/overview
// @access Private/Supervisor+
const getOverviewStats = async (req, res) => {
try {
const now = new Date();
const { thisStart, thisEnd, lastStart, lastEnd } = trailingMonthRanges(now);
const [thisPayments, lastPayments, thisRegistrations, lastRegistrations, thisTickets, lastTickets, activeEvents] = await Promise.all([
fetchPaymentsWithOriginal({ createdAt: { gte: thisStart, lt: thisEnd } }),
fetchPaymentsWithOriginal({ createdAt: { gte: lastStart, lt: lastEnd } }),
prisma.registration.count({ where: { createdAt: { gte: thisStart, lt: thisEnd }, status: { not: 'cancelled' } } }),
prisma.registration.count({ where: { createdAt: { gte: lastStart, lt: lastEnd }, status: { not: 'cancelled' } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: thisStart, lt: thisEnd } } }),
prisma.ticket.aggregate({ _sum: { quantity: true }, where: { createdAt: { gte: lastStart, lt: lastEnd } } }),
getActiveEventsCount(),
]);
const thisTotals = classifyAndSum(thisPayments);
const lastTotals = classifyAndSum(lastPayments);
const thisTicketsSold = thisTickets._sum.quantity || 0;
const lastTicketsSold = lastTickets._sum.quantity || 0;
// Daily revenue trend for the trailing month, from the payment rows already fetched above.
// Same definition as classifyAndSum: everything except donation-application legs.
const trendMap = new Map();
for (const p of thisPayments) {
if (isDonationLeg(p)) continue;
const day = p.createdAt.toISOString().slice(0, 10);
trendMap.set(day, (trendMap.get(day) || 0) + p.amount);
}
const trend = Array.from(trendMap.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, revenue]) => ({ date, revenue }));
// Top performing events overall (not scoped to this month), ranked by revenue — same
// "real revenue" definition as the KPI above: everything except application legs.
const revenueByEvent = await prisma.payment.groupBy({
by: ['eventId'],
where: { ...EXCLUDE_DONATION_LEGS, eventId: { not: null } },
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
take: 5,
});
const topEventIds = revenueByEvent.map(r => r.eventId).filter(Boolean);
const [events, regCounts, ticketSums] = await Promise.all([
prisma.event.findMany({ where: { id: { in: topEventIds } }, select: { id: true, title: true } }),
prisma.registration.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds }, status: { not: 'cancelled' } }, _count: { _all: true } }),
prisma.ticket.groupBy({ by: ['eventId'], where: { eventId: { in: topEventIds } }, _sum: { quantity: true } }),
]);
const eventTitleById = Object.fromEntries(events.map(e => [e.id, e.title]));
const regCountById = Object.fromEntries(regCounts.map(r => [r.eventId, r._count._all]));
const ticketSumById = Object.fromEntries(ticketSums.map(t => [t.eventId, t._sum.quantity || 0]));
const topEvents = revenueByEvent.map(r => ({
eventId: r.eventId,
title: eventTitleById[r.eventId] || 'Untitled event',
revenue: r._sum.amount || 0,
registrations: regCountById[r.eventId] || 0,
ticketsSold: ticketSumById[r.eventId] || 0,
}));
res.json({
activeEvents,
registrations: { thisMonth: thisRegistrations, lastMonth: lastRegistrations, pctChange: pctChange(thisRegistrations, lastRegistrations) },
ticketsSold: { thisMonth: thisTicketsSold, lastMonth: lastTicketsSold, pctChange: pctChange(thisTicketsSold, lastTicketsSold) },
revenue: { thisMonth: thisTotals.revenue, lastMonth: lastTotals.revenue, pctChange: pctChange(thisTotals.revenue, lastTotals.revenue) },
donations: { thisMonth: thisTotals.donations, lastMonth: lastTotals.donations, pctChange: pctChange(thisTotals.donations, lastTotals.donations) },
trend,
topEvents,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = {
getStaffDashboardStats,
getSupervisorDashboardStats,
getAdminDashboardStats,
getOverviewStats,
};
@@ -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
const uploadEventImage = (req, res) => {
// 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}` });
};
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 = {
upload,
uploadEventImage,
uploadLogo,
uploadLogoImage,
uploadFavicon,
uploadFaviconImage,
};
+32 -8
View File
@@ -2,10 +2,18 @@ const prisma = require('../config/db');
const { generateToken, hashPassword, comparePassword } = require('../config/auth');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logSecurityEvent, getRecentSecurityEvents } = require('../utils/securityEvents');
const { getSettingSync } = require('../utils/settingsCache');
const axios = require('axios');
// ─── 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)
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
@@ -40,7 +48,7 @@ async function sendLoginNotification(user, req) {
const { buildWALogin } = require('../utils/waMessages');
const content = buildLoginNotificationEmail({ name: user.name, when, location, userAgent });
// 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');
await waText(user, buildWALogin({ name: user.name, when, location, userAgent })).catch(() => {});
} catch (e) {
@@ -62,7 +70,7 @@ async function sendWelcomeEmail(user) {
const content = buildWelcomeEmail({ name: user.name, events });
const { shouldEmail, waText } = require('../utils/notify');
// 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(() => {});
} catch (e) {
console.warn('[welcome email] Failed:', e?.message || e);
@@ -184,7 +192,7 @@ const loginUser = async (req, res) => {
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const { sendMail, buildAccountActivationEmail } = require('../utils/email');
const content = buildAccountActivationEmail({ name: user.name, activationUrl });
sendMail({ to: user.email, subject: 'Activate your 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 token] Failed to create activation token:', e?.message || e);
@@ -206,7 +214,7 @@ const loginUser = async (req, res) => {
});
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
const activationUrl = `${baseUrl.replace(/\/$/, '')}/activate-account?token=${encodeURIComponent(token)}`;
const orgName = require('../utils/settingsCache').getSettingSync('org_name', process.env.ORG_NAME || 'Hope Events');
const orgName = getOrgName();
const waMessage = [
`🔓 *Activate your ${orgName} account*`,
'',
@@ -273,6 +281,7 @@ const loginUser = async (req, res) => {
// Send login notification in the background
sendLoginNotification(updated, req).catch(() => {});
logSecurityEvent({ userId: updated.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({
id: updated.id,
@@ -392,13 +401,13 @@ const updateUserProfile = async (req, res) => {
// If the password was changed, send a security alert email (fire-and-forget)
if (newHashedPassword) {
const { sendMail, buildPasswordChangedEmail } = require('../utils/email');
const { getSettingSync } = require('../utils/settingsCache');
const supportEmail = getSettingSync('org_email', process.env.EMAIL_FROM || '');
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));
const { waText } = require('../utils/notify');
waText(updatedUser, content.text).catch(() => {});
logSecurityEvent({ userId: updatedUser.id, type: 'password_changed', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
}
res.json({
@@ -744,6 +753,8 @@ const resetPassword = async (req, res) => {
prisma.passwordReset.update({ where: { token }, data: { used: true } })
]);
logSecurityEvent({ userId: user.id, type: 'password_reset', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
res.json({ message: 'Password has been reset successfully' });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
@@ -898,7 +909,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
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)
waText(user, buildWAAccountClosed({ name: realName, dataDeleted: true })).catch(() => {});
}
@@ -919,7 +930,7 @@ const closeAccount = async (req, res) => {
const { waText } = require('../utils/notify');
const { buildWAAccountClosed } = require('../utils/waMessages');
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(() => {});
}
return res.json({ message: 'Your account has been deactivated.' });
@@ -929,6 +940,18 @@ const closeAccount = async (req, res) => {
}
};
// @desc Recent account activity (logins, password changes) for the current user
// @route GET /api/users/activity
// @access Private
const getMyActivity = async (req, res) => {
try {
const events = await getRecentSecurityEvents(req.user.id, 10);
res.json(events);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
module.exports = {
registerUser,
loginUser,
@@ -946,4 +969,5 @@ module.exports = {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
};
+2 -1
View File
@@ -447,7 +447,8 @@ const updateRegistrationStatus = async (registrationId) => {
include: {
earlyBirdTiers: true
}
}
},
tranches: true
}
},
payments: true
@@ -36,6 +36,22 @@ function parseFreeformPhones(lines) {
return recipients;
}
// Human-readable summary of who a WhatsApp broadcast will go to, for the scheduled-jobs admin UI
async function describeBroadcastRecipients({ userIds, phones }) {
const parts = [];
try {
const ids = Array.isArray(userIds) ? userIds.filter(x => typeof x === 'string' && x) : [];
if (ids.length) {
const users = await prisma.user.findMany({ where: { id: { in: ids } }, select: { name: true } });
const names = users.map(u => u.name).filter(Boolean);
parts.push(names.slice(0, 3).join(', ') + (names.length > 3 ? ` +${names.length - 3} more` : ''));
}
const extra = parseFreeformPhones(phones);
if (extra.length) parts.push(`${extra.length} phone number${extra.length === 1 ? '' : 's'}`);
} catch {}
return parts.length ? parts.join('; ') : 'No recipients';
}
// @desc Preview WhatsApp broadcast recipients
// @route POST /api/whatsapp-broadcasts/preview
// @access Private/Supervisor or Admin
@@ -152,12 +168,15 @@ const scheduleWhatsAppBroadcast = async (req, res) => {
const payload = { message, userIds, phones, eventId };
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
const { addJob } = require('../utils/scheduledEmails');
const created = addJob({
broadcast: true,
channel: 'whatsapp',
scheduledAt: when.toISOString(),
createdById: req.user?.id || null,
recipientSummary,
payload,
});
@@ -195,13 +195,14 @@ const handleWebhook = async (req, res) => {
const { getSettingSync } = require('../utils/settingsCache');
const adminEmail = getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || '')
|| getSettingSync('org_email', process.env.EMAIL_FROM || process.env.EMAIL_USER || '');
const dashboardUrl = `${(process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/$/, '')}/dashboard/admin/whatsapp`;
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 when = new Date().toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' });
await sendMail({
to: adminEmail,
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}`,
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>`,
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 ${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 (e) {
@@ -164,7 +164,10 @@ const reconcileYocoTransaction = async (req, res) => {
const createdAt = ytx.createdDate || ytx.createdAt || new Date();
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 = [];
if (registration) {
try {
@@ -172,15 +175,6 @@ const reconcileYocoTransaction = async (req, res) => {
if (updatedReg && updatedReg.status === 'paid') {
try {
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) {
console.error('Error generating tickets after Yoco reconciliation:', genErr);
}
@@ -191,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => {
}
}
// Send emails for the reconciled payment
try {
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(payment.id);
} catch (e) {
console.error('Failed to send payment emails after reconciliation:', e);
}
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
const _rxPaymentId = payment.id;
const _rxUserId = registration?.userId;
const _rxRegId = registration?.id;
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
const updatedTx = await Yoco.update({
+35 -17
View File
@@ -244,8 +244,8 @@ app.get('/', async (req, res) => {
? `<span class="badge badge-warn">testing</span>`
: `<span class="badge badge-warn">development</span>`;
const html = pageShell('Hope Events API — Status', '#2563eb', `
<h1>Hope Events API</h1>
const html = pageShell('Cross Code Events API — Status', '#2563eb', `
<h1>Cross Code Events API</h1>
<p class="subtitle">v${API_VERSION} &mdash; ${now}</p>
<div class="stat-grid">
@@ -529,11 +529,20 @@ app.get('/docs', async (req, res) => {
pathParams:{ ':id':'Registration UUID' },
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' }}]},
{ 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: [
{ 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:[
{ 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.' }},
@@ -546,6 +555,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"',
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 }}]},
{ 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',
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 }}]},
@@ -750,7 +767,7 @@ app.get('/docs', async (req, res) => {
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',
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' }},
]},
{ method:'GET', path:'/api/whatsapp/qr', auth:'admin', desc:'Get a QR code image/string to link a WhatsApp account',
@@ -773,14 +790,14 @@ app.get('/docs', async (req, res) => {
]},
{ 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)',
responses:[{ status:200, desc:'Success', body:{ org_name:'Hope Family Church', org_tagline:'Where everyone belongs', accent_color:'#2563eb', logo_url:'/uploads/logo.png' }}]},
{ 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:'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.',
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).',
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.',
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.' }}]},
{ 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' }},
@@ -798,7 +815,7 @@ app.get('/docs', async (req, res) => {
{ 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.',
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.' }}]},
]},
];
@@ -964,12 +981,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.' },
]},
{ 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: '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: '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: '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: '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 {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 {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 {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: '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: [
{ 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.' },
@@ -1019,7 +1036,7 @@ app.get('/docs', async (req, res) => {
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hope Events API Docs</title>
<title>Cross Code Events API Docs</title>
<style>
*{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}
@@ -1043,7 +1060,7 @@ app.get('/docs', async (req, res) => {
<body>
<div class="wrap">
<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">Cross Code Events API Reference</h1>
<a href="/" style="font-size:.82rem;color:#6b7280">&#8592; Status page</a>
</div>
<p style="font-size:.82rem;color:#6b7280;margin-bottom:20px">
@@ -1070,7 +1087,7 @@ app.get('/docs', async (req, res) => {
${notificationsHtml}
<p style="font-size:.72rem;color:#9ca3af;margin-top:28px;text-align:center">
Hope Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
Cross Code Events API v${API_VERSION} &mdash; ${new Date().toISOString()}
</p>
</div>
<script>
@@ -1192,13 +1209,14 @@ app.listen(PORT, () => {
try {
const enabled = String(process.env.SCHEDULED_EMAILS_ENABLED || 'true').toLowerCase() !== 'false';
if (enabled) {
const { getDueJobs, updateJob } = require('./utils/scheduledEmails');
const { getDueJobs, updateJob, purgeSentJobs } = require('./utils/scheduledEmails');
const { emailEventAttendees, whatsappEventAttendees } = require('./controllers/eventController');
const { sendBroadcast } = require('./controllers/broadcastController');
const { sendWhatsAppBroadcast } = require('./controllers/whatsappBroadcastController');
const intervalMs = parseInt(process.env.SCHEDULED_EMAILS_INTERVAL_MS || '30000', 10);
setInterval(async () => {
try {
try { purgeSentJobs(24 * 60 * 60 * 1000); } catch (e) { console.warn('[scheduled emails] purge failed:', e?.message || e); }
const due = getDueJobs(new Date());
if (!due || due.length === 0) return;
for (const job of due) {
+5 -1
View File
@@ -8,10 +8,12 @@ const {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
sendPaymentLink,
createRefund,
getPaymentStats
getPaymentStats,
sendReceipt,
} = require('../controllers/paymentController');
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/send', protect, supervisor, sendPaymentLink);
router.get('/mypayments', protect, getUserPayments);
router.post('/:id/send-receipt', protect, sendReceipt);
router.get('/:id', protect, getPaymentById);
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
@@ -27,6 +30,7 @@ router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
router.get('/', protect, supervisor, getPayments);
router.get('/event/:eventId', protect, staff, getPaymentsByEvent);
router.put('/assign-donation', protect, supervisor, assignDonationToRegistration);
router.post('/unassign-donation', protect, supervisor, unassignDonationFromRegistration);
router.post('/refund', protect, supervisor, createRefund);
router.get('/admin/stats', protect, admin, getPaymentStats);
+2
View File
@@ -12,6 +12,7 @@ const {
updateRegistrationOptions,
submitFormResponses, replaceFormResponses,
getFormDraft, saveFormDraft,
sendInvoice,
} = require('../controllers/registrationController');
const { protect, supervisor, staff, optionalAuth } = require('../middleware/authMiddleware');
@@ -20,6 +21,7 @@ router.post('/', optionalAuth, createRegistration);
router.get('/myregistrations', protect, getUserRegistrations);
router.put('/:id/options', protect, updateRegistrationOptions);
router.delete('/:id', protect, cancelRegistration);
router.post('/:id/send-invoice', protect, sendInvoice);
// Registration detail + forms — optionalAuth so guests can access with just the registrationId
router.get('/:id', optionalAuth, getRegistrationById);
+5 -1
View File
@@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats } = require('../controllers/statsController');
const { getStaffDashboardStats, getSupervisorDashboardStats, getAdminDashboardStats, getOverviewStats } = require('../controllers/statsController');
const { protect, staff, supervisor, admin } = require('../middleware/authMiddleware');
// One endpoint per dashboard — each returns exactly what that dashboard renders in a
@@ -10,4 +10,8 @@ router.get('/staff', protect, staff, getStaffDashboardStats);
router.get('/supervisor', protect, supervisor, getSupervisorDashboardStats);
router.get('/admin', protect, admin, getAdminDashboardStats);
// Month-over-month KPIs + trend + top events, shared by the Admin and Supervisor dashboards.
// Staff never sees financial data, so this has no staff-accessible route.
router.get('/overview', protect, supervisor, getOverviewStats);
module.exports = router;
+18 -7
View File
@@ -1,7 +1,7 @@
const express = require('express');
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 prisma = require('../config/db');
@@ -17,11 +17,9 @@ router.post('/event-image', protect, supervisor, (req, res, next) => {
});
}, uploadEventImage);
// @route POST /api/uploads/logo
// @desc Upload site logo — admin, OR allowed during first-time setup (no users yet)
// @access Admin or setup
async function logoAccess(req, res, next) {
// Shared access rule for branding assets (logo, favicon): admin, OR allowed
// during first-time setup (no users yet).
async function brandingAssetAccess(req, res, next) {
try {
const count = await prisma.user.count();
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) => {
if (err) req.multerError = err;
next();
});
}, 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;
+2
View File
@@ -17,6 +17,7 @@ const {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
} = require('../controllers/userController');
const { protect, admin, supervisor, loginLimiter} = require('../middleware/authMiddleware');
@@ -34,6 +35,7 @@ router.route('/profile')
router.post('/revoke-sessions', protect, revokeMySession);
router.post('/close-account', protect, closeAccount);
router.get('/activity', protect, getMyActivity);
// Admin routes
router.route('/')
+6 -4
View File
@@ -83,7 +83,7 @@ async function computeEventFinancials(eventId) {
}),
prisma.registrationOption.findMany({
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({
where: { eventId },
@@ -191,15 +191,17 @@ async function computeEventFinancials(eventId) {
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
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 = {};
for (const ro of salesRows) {
const opt = ro.eventOption;
if (!opt) continue;
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].revenue += unitPrice * ro.quantity;
salesByOptionMap[opt.id].revenue += computeOptionLineTotal(ro, null, new Date());
}
const salesByOption = Object.values(salesByOptionMap);
+21 -8
View File
@@ -56,7 +56,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
return;
}
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 } : {}) });
if (transporter.options && transporter.options.jsonTransport) {
@@ -74,12 +74,17 @@ async function sendMail({ to, subject, html, text, attachments }) {
function getOrg() {
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 {
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'),
email: getSettingSync('smtp_from', process.env.EMAIL_FROM || process.env.EMAIL_USER || ''),
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>`;
}
/** Renders a prominent CTA button. */
function ctaButton(label, url, { bg = '#2563eb', fg = '#ffffff' } = {}) {
/** 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, fg = '#ffffff' } = {}) {
bg = bg || getOrg().headerColor;
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">
<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. */
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">
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>`;
}
@@ -160,7 +167,12 @@ function divider() {
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') {
const map = {
info: { bg: '#eff6ff', border: '#3b82f6', color: '#1e40af' },
@@ -177,9 +189,10 @@ function callout(content, type = 'info') {
/** Numbered payment option row. */
function paymentOption(num, title, detail) {
const bg = getOrg().headerColor;
return `<tr>
<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 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>
+404 -26
View File
@@ -1,6 +1,7 @@
const fs = require('fs');
const prisma = require('../config/db');
const { sendMail, emailWrapper, ctaButton, fallbackLink, divider, callout, paymentOption } = require('./email');
const { computeRegistrationTotalDue } = require('./pricing');
const { computeRegistrationTotalDue, computeOptionLineTotal } = require('./pricing');
// ─── Formatting helpers ───────────────────────────────────────────────────────
@@ -23,7 +24,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() {
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 || '',
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({
where: { id: registrationId },
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: 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 } } } },
@@ -98,7 +99,7 @@ async function loadPaymentFull(paymentId) {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
registration: {
include: {
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } } } },
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: 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 } } } },
@@ -116,11 +117,11 @@ function selectionsTable(registrationOptions) {
const rows = (registrationOptions || []).map(ro => {
const name = ro.eventOption?.name || 'Option';
const qty = ro.quantity || 1;
const price = ro.eventOption?.price || 0;
const lineTotal = computeOptionLineTotal(ro, null, new Date());
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 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>`;
});
@@ -270,7 +271,7 @@ function buildRegistrationConfirmation(reg, { isNew = true } = {}) {
${paymentSection({ balance, yocoLink: null, source: 'user', siteUrl: org.url, formRequired: false, isUserActive })}
${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}`;
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 })}
${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
? `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
@@ -338,7 +339,7 @@ function buildRegistrationAdminNotice(reg, { isNew = true, isUpdated = false } =
`<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;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('');
const body = `
@@ -639,6 +640,109 @@ function buildPaymentAdminNotice(payment) {
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 ─────────────────────────────────────────────────────────────
function buildRefundEmail(payment) {
@@ -795,25 +899,74 @@ function buildDailySummary(ev, registrations, payments, now) {
// ─── 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) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
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)
if (reg.user?.email && !reg.user.email.endsWith('@guest.local')) {
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
sends.push(waText(reg.user, buildWARegistration(reg, { isNew: true, totalDue, totalPaid, balance: Math.max(totalDue - totalPaid, 0) })));
// WhatsApp: always attempt — waText/waPdf check canWhatsApp (preference + valid phone) internally
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 });
if (adminMsg.to && adminMsg.to.length) {
@@ -822,25 +975,34 @@ async function sendRegistrationEmails(registrationId) {
await Promise.all(sends);
} catch (e) {
console.error('Failed to send registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendRegistrationUpdatedEmails(registrationId) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
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')) {
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 });
if (adminMsg.to && adminMsg.to.length) {
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);
} catch (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 } = {}) {
let invoicePdf = null;
try {
const reg = await loadRegistrationFull(registrationId);
if (!reg) return;
const { shouldEmail, waText } = require('./notify');
const { shouldEmail, waPdf, waText } = require('./notify');
const { buildWARegistration } = require('./waMessages');
const { computeRegistrationTotalDue } = require('./pricing');
const sends = [];
const totalDue = computeRegistrationTotalDue(reg, new Date());
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')) {
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 });
if (adminMsg.to && adminMsg.to.length) {
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);
} catch (e) {
console.error('Failed to send self-service registration emails:', e);
} finally {
if (invoicePdf) try { fs.unlinkSync(invoicePdf.filePath); } catch {}
}
}
async function sendPaymentEmails(paymentId) {
let receiptPdf = null;
try {
const payment = await loadPaymentFull(paymentId);
if (!payment) return;
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 { generateReceiptPdf } = require('./pdfDocs');
try { receiptPdf = await generateReceiptPdf(payment); } catch (e) { console.error('Failed to generate receipt PDF:', e); }
const sends = [];
const hasValidEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
if (hasValidEmail) {
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) {
sends.push(waText(user, buildWAPayment(payment)));
sends.push(receiptPdf ? waPdf(user, receiptPdf.filePath, receiptPdf.filename, waCaption) : waText(user, waCaption));
} 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);
if (hasEvent) {
@@ -907,6 +1090,8 @@ async function sendPaymentEmails(paymentId) {
await Promise.all(sends);
} catch (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 {
sends.push(waTextAny(user, buildWADonationAppliedToRegistrant(payment)));
}
const adminMsg = buildPaymentAdminNotice(payment);
const adminMsg = buildDonationAssignmentAdminNotice(payment);
if (adminMsg.to && adminMsg.to.length) {
sends.push(sendMail({ to: adminMsg.to.join(','), subject: adminMsg.subject, html: adminMsg.html, text: adminMsg.text }));
}
@@ -990,6 +1175,196 @@ 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()) {
try {
const today = new Date(now);
@@ -1013,7 +1388,7 @@ async function sendDailyEventSummaries(now = new Date()) {
where: { eventId: ev.id },
include: {
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,
},
orderBy: { createdAt: 'asc' },
@@ -1045,6 +1420,9 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendDonationUnassignmentEmails,
sendCheckInEmails,
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.
* - getEffectiveUnitPrice: deadline-only check; used for line-item display in Yoco checkout and
* as a fallback for legacy RegistrationOption rows that have no priceSnapshot.
* - computeRegistrationTotalDue: uses priceSnapshot when present (authoritative after
* refreshPricingForRegistration runs), otherwise falls back to getEffectiveUnitPrice.
* - refreshPricingForRegistration: re-runs resolveOptionPrice for every RegistrationOption
* that has an appliedTierId; updates priceSnapshot + appliedTierId in the DB if the tier
* is now expired or its stock is exhausted.
* - A RegistrationOption is an aggregate line (one per registration+option+variant); each
* purchase-at-a-price is its own RegistrationOptionTranche row underneath it (quantity +
* priceSnapshot + appliedTierId, never mutated after creation mirrors the Payment model's
* append-only pattern). This is what lets someone buy more of the same ticket type after an
* 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');
@@ -74,12 +84,15 @@ async function resolveOptionPrice(option, requestedQty = 1) {
// Skip expired tiers
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) {
const soldAgg = await prisma.registrationOption.aggregate({
const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: {
appliedTierId: tier.id,
registration: { status: { not: 'cancelled' } }
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
_sum: { quantity: true }
});
@@ -125,8 +138,8 @@ async function resolveVariantTierPrice(option, variantId, requestedQty = 1) {
for (const tier of tiers) {
if (now >= new Date(tier.deadline)) continue;
if (tier.stockLimit > 0) {
const soldAgg = await prisma.registrationOption.aggregate({
where: { appliedTierId: tier.id, registration: { status: { not: 'cancelled' } } },
const soldAgg = await prisma.registrationOptionTranche.aggregate({
where: { appliedTierId: tier.id, registrationOption: { registration: { status: { not: 'cancelled' } } } },
_sum: { quantity: true }
});
const tierSold = soldAgg._sum?.quantity || 0;
@@ -154,58 +167,115 @@ async function refreshPricingForRegistration(registrationId) {
include: {
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true, variants: true } }
eventOption: { include: { earlyBirdTiers: true, variants: true } },
tranches: true,
}
}
},
payments: true,
}
});
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) {
// Only refresh options that were priced via a tier
if (!ro.appliedTierId) continue;
const tranches = ro.tranches || [];
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
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)) {
// 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
const resolved = ro.variantId
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, ro.quantity)
: await resolveOptionPrice(ro.eventOption, ro.quantity);
? await resolveVariantTierPrice(ro.eventOption, ro.variantId, newest.quantity)
: await resolveOptionPrice(ro.eventOption, newest.quantity);
const tierChanged = resolved.tierId !== ro.appliedTierId;
const priceChanged = ro.priceSnapshot !== null && Math.abs(resolved.price - ro.priceSnapshot) > 0.001;
const tierChanged = resolved.tierId !== newest.appliedTierId;
const priceChanged = Math.abs(resolved.price - newest.priceSnapshot) > 0.001;
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({
where: { id: ro.id },
data: {
priceSnapshot: resolved.price,
appliedTierId: resolved.tierId
}
data: { 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.
*
* Uses priceSnapshot when present (authoritative set at registration time and refreshed
* before payment via refreshPricingForRegistration). Falls back to getEffectiveUnitPrice
* for legacy rows without a snapshot.
* Sums computeOptionLineTotal() across each RegistrationOption (tranche-aware when
* `.tranches` is included, legacy priceSnapshot/getEffectiveUnitPrice fallback otherwise).
*
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption}
* @param {object} registration - includes registrationOptions[].{priceSnapshot, quantity, eventOption, tranches?}
* and optionally payments[]
* @param {Date} atTime - evaluation time (used for legacy fallback only)
* @returns {number}
@@ -221,22 +291,38 @@ function computeRegistrationTotalDue(registration, atTime) {
}
} catch {}
return registration.registrationOptions.reduce((sum, ro) => {
const qty = Number(ro.quantity || 0);
let unit;
return registration.registrationOptions.reduce((sum, ro) => sum + computeOptionLineTotal(ro, lastPaymentAt, atTime), 0);
}
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);
/**
* Attach computed, tranche-aware display totals to a registration in place: `lineTotal` on
* each RegistrationOption and `totalDueComputed` on the registration itself. Lets API
* responses hand the frontend an exact total instead of every page re-deriving
* unitPrice*quantity client-side (which goes wrong once a line spans multiple tranches).
*
* @param {object} registration - requires registrationOptions[].tranches included
* @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;
}, 0);
function attachComputedTotalsToList(registrations) {
return (registrations || []).map(attachComputedTotals);
}
module.exports = {
@@ -244,5 +330,8 @@ module.exports = {
resolveOptionPrice,
resolveVariantTierPrice,
refreshPricingForRegistration,
computeOptionLineTotal,
computeRegistrationTotalDue,
attachComputedTotals,
attachComputedTotalsToList,
};
+19
View File
@@ -46,6 +46,8 @@ function addJob(job) {
id,
eventId: job.eventId || null,
broadcast: !!job.broadcast,
channel: job.channel || 'email',
recipientSummary: job.recipientSummary || null,
createdById: job.createdById || null,
scheduledAt: job.scheduledAt,
createdAt: now.toISOString(),
@@ -66,10 +68,26 @@ function listJobs(filter = {}) {
return jobs.filter(j => {
if (filter.status && j.status !== filter.status) return false;
if (filter.eventId && j.eventId !== filter.eventId) return false;
if (filter.channel && (j.channel || 'email') !== filter.channel) return false;
return true;
});
}
/**
* Permanently remove jobs that finished sending more than maxAgeMs ago.
* Only touches 'sent' jobs - queued/sending/error jobs are left for admins to review.
*/
function purgeSentJobs(maxAgeMs = 24 * 60 * 60 * 1000) {
const jobs = loadAll();
const now = Date.now();
const kept = jobs.filter(j => {
if (j.status !== 'sent' || !j.sentAt) return true;
return (now - new Date(j.sentAt).getTime()) <= maxAgeMs;
});
if (kept.length !== jobs.length) saveAll(kept);
return jobs.length - kept.length;
}
function getDueJobs(now = new Date()) {
const jobs = loadAll();
const t = now instanceof Date ? now : new Date(now);
@@ -105,4 +123,5 @@ module.exports = {
updateJob,
getJob,
deleteJob,
purgeSentJobs,
};
+34
View File
@@ -0,0 +1,34 @@
const prisma = require('../config/db');
const { describeUserAgent } = require('./userAgent');
// Fire-and-forget by design — a logging failure must never break login/password-change,
// so this swallows its own errors rather than propagating them to the caller (same
// posture as the existing email/WhatsApp notification sends elsewhere in this codebase).
async function logSecurityEvent({ userId, type, ip, userAgent }) {
try {
await prisma.securityEvent.create({
data: {
userId: userId || null,
type,
ip: ip || null,
device: describeUserAgent(userAgent),
},
});
} catch (e) {
console.error('Failed to log security event:', e?.message);
}
}
// Recent activity for a user's Profile & Security page — no raw IP in the response,
// just what the mockup shows (what happened, on what device, when).
async function getRecentSecurityEvents(userId, limit = 10) {
const rows = await prisma.securityEvent.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
take: limit,
select: { id: true, type: true, device: true, createdAt: true },
});
return rows;
}
module.exports = { logSecurityEvent, getRecentSecurityEvents };
+31 -28
View File
@@ -54,42 +54,43 @@ const generateTicketsForRegistration = async (registrationId) => {
byOption.get(key).push(ro);
}
// For each group with duplicates, merge into the one that has tickets (or the first)
for (const [, group] of byOption) {
if (group.length <= 1) continue;
// For each group with duplicates, merge into the one that has tickets (or the first).
// Different (eventOptionId, variantId) groups touch disjoint rows, so process groups
// 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
const withTickets = group.filter(ro => (ro.tickets || []).length > 0);
const primary = withTickets.length > 0 ? withTickets[0] : group[0];
const duplicates = group.filter(ro => ro.id !== primary.id);
// Move all tickets from duplicates to primary, then delete duplicate options
for (const dup of duplicates) {
for (const t of (dup.tickets || [])) {
await 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 } });
}
// Move all tickets from duplicates to primary (independent rows — safe to parallelize)
await Promise.all(duplicates.map(dup => Promise.all(
(dup.tickets || []).map(t =>
prisma.ticket.update({ where: { id: t.id }, data: { registrationOptionId: primary.id, updatedAt: new Date() } })
)
)));
// 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 } });
primary.quantity = updated?.quantity ?? primary.quantity;
// Reload tickets
primary.tickets = await prisma.ticket.findMany({
where: { registrationOptionId: primary.id },
include: { usages: true },
orderBy: { createdAt: 'asc' }
});
}
}));
// ── 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)
const freshOptions = await prisma.registrationOption.findMany({
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 existingTickets = option.tickets || [];
if (existingTickets.length === 0) {
// Create one ticket
const ticket = await prisma.ticket.create({
return prisma.ticket.create({
data: {
id: uuidv4(),
qrCode: uuidv4(),
@@ -115,27 +117,28 @@ const generateTicketsForRegistration = async (registrationId) => {
updatedAt: new Date()
}
});
generatedTickets.push(ticket);
continue;
}
// Pick primary: prefer scanned, otherwise oldest
const withUsages = existingTickets.filter(t => (t.usages || []).length > 0);
const primary = withUsages.length > 0 ? withUsages[0] : existingTickets[0];
const updates = [];
// Update quantity on primary if needed
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
const dups = existingTickets.filter(t => t.id !== primary.id && (t.usages || []).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) {
console.error('Error generating tickets:', error);
throw error;
+36
View File
@@ -0,0 +1,36 @@
// Lightweight User-Agent -> "Browser on OS" summary for account-activity logging.
// No UA-parsing library is installed in this project; this covers the common cases
// (desktop/mobile browsers, major OSes) without pulling one in for a single display string.
function detectBrowser(ua) {
if (/Edg\//.test(ua)) return 'Edge';
if (/OPR\//.test(ua) || /Opera/.test(ua)) return 'Opera';
if (/SamsungBrowser/.test(ua)) return 'Samsung Internet';
if (/CriOS/.test(ua)) return 'Chrome';
if (/FxiOS/.test(ua)) return 'Firefox';
if (/Firefox\//.test(ua)) return 'Firefox';
if (/Chrome\//.test(ua)) return 'Chrome';
if (/Safari\//.test(ua) && /Version\//.test(ua)) return 'Safari';
return null;
}
function detectOS(ua) {
if (/Windows/.test(ua)) return 'Windows';
if (/iPhone|iPad|iPod/.test(ua)) return 'iOS';
if (/Mac OS X/.test(ua)) return 'macOS';
if (/Android/.test(ua)) return 'Android';
if (/Linux/.test(ua)) return 'Linux';
return null;
}
function describeUserAgent(uaString) {
if (!uaString) return 'Unknown device';
const browser = detectBrowser(uaString);
const os = detectOS(uaString);
if (browser && os) return `${browser} on ${os}`;
if (browser) return browser;
if (os) return os;
return 'Unknown device';
}
module.exports = { describeUserAgent };
+36 -2
View File
@@ -23,7 +23,7 @@ const { getSettingSync } = require('./settingsCache');
function getOrg() {
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 || '',
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}` : ''}.`
: `Your registration for *${eventTitle}* has been updated.`;
const { computeOptionLineTotal } = require('./pricing');
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');
const paid = totalPaid ?? (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
@@ -266,11 +267,44 @@ function buildWADonationAppliedToRegistrant(payment) {
].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 = {
buildWARegistration,
buildWAPayment,
buildWARefund,
buildWADonationAppliedToRegistrant,
buildWADonationUnassignedFromRegistrant,
buildWALogin,
buildWAWelcome,
buildWAAccountClosed,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.4.2",
"version": "1.8.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

@@ -3,6 +3,7 @@ import { useState } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { KeyRound } from "lucide-react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
@@ -32,16 +33,21 @@ export default function ForgotPasswordPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<h1 className="text-2xl font-semibold mb-4">Forgot your password?</h1>
<p className="text-gray-700 mb-6">Enter your account email and we'll send you a link to reset your password.</p>
<main className="flex-1 flex items-center justify-center p-6 bg-gradient-to-br from-brand-50 via-white to-brand-50">
<div className="w-full max-w-md border rounded-2xl p-8 bg-white shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<KeyRound className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900">Forgot your password?</h1>
</div>
<p className="text-gray-600 text-sm mb-6">Enter your account email and we'll send you a link to reset your password.</p>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Email</label>
<input type="email" value={email} onChange={e => setEmail(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
<button disabled={loading || !email} className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60">
<button disabled={loading || !email} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60">
{loading ? "Sending..." : "Send reset link"}
</button>
</form>
+12 -3
View File
@@ -3,14 +3,23 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { LoginForm } from "@/components/auth/LoginForm";
import { LogIn } from "lucide-react";
export default function LoginPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold mb-4">Login</h1>
<main className="flex-1 flex items-center justify-center p-6 bg-gradient-to-br from-brand-50 via-white to-brand-50">
<div className="w-full max-w-md border rounded-2xl p-8 bg-white shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<LogIn className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900">Welcome back</h1>
<p className="text-sm text-gray-500">Log in to manage your events and tickets.</p>
</div>
</div>
<Suspense fallback={<div />}>
<LoginForm />
</Suspense>
+12 -3
View File
@@ -3,14 +3,23 @@ import React, { Suspense } from "react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { RegisterForm } from "@/components/auth/RegisterForm";
import { UserPlus } from "lucide-react";
export default function RegisterPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 flex items-center justify-center p-6">
<div className="w-full max-w-md">
<h1 className="text-2xl font-semibold mb-4">Create Account</h1>
<main className="flex-1 flex items-center justify-center p-6 bg-gradient-to-br from-brand-50 via-white to-brand-50">
<div className="w-full max-w-md border rounded-2xl p-8 bg-white shadow-sm">
<div className="flex items-center gap-3 mb-6">
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<UserPlus className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900">Create account</h1>
<p className="text-sm text-gray-500">One account for all your registrations and tickets.</p>
</div>
</div>
<Suspense fallback={<div />}>
<RegisterForm />
</Suspense>
+2 -2
View File
@@ -22,7 +22,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
<p className="text-gray-500 mb-6">
The page you&apos;re looking for doesn&apos;t exist.
</p>
<a href="/" className="text-blue-600 hover:underline transition-all duration-200">
<a href="/" className="text-brand-600 hover:underline transition-all duration-200">
Back to Home &rarr;
</a>
</main>
@@ -37,7 +37,7 @@ export default async function EventRedirectPage({ params }: { params: Promise<{
<main className="flex flex-col items-center justify-center min-h-screen text-center">
<h1 className="text-3xl font-bold mb-2">This event has ended</h1>
<p className="text-gray-600">Thanks for joining us! Stay tuned for the next one.</p>
<a href="/" className="mt-4 text-blue-600 hover:underline">
<a href="/" className="mt-4 text-brand-600 hover:underline">
Back to Home &rarr;
</a>
</main>
+8 -2
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { appName } from "@/lib/siteConfig";
import { ShieldCheck } from "lucide-react";
function ActivateAccountContent() {
const search = useSearchParams();
@@ -42,7 +43,12 @@ function ActivateAccountContent() {
<Navbar />
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<h1 className="text-2xl font-semibold mb-2">Activate your account</h1>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<ShieldCheck className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Activate your account</h1>
</div>
<p className="text-gray-500 text-sm mb-5">Set a password to activate your {appName} account.</p>
{!token && <p className="text-red-600 mb-4">Missing or invalid activation link.</p>}
<form onSubmit={submit} className="space-y-4">
@@ -58,7 +64,7 @@ function ActivateAccountContent() {
{password && confirm && password !== confirm && (
<p className="text-xs text-red-600">Passwords do not match.</p>
)}
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60 w-full py-2.5 font-medium">
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full py-2.5 font-medium">
{loading ? "Activating…" : "Activate my account"}
</button>
</form>
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { Mail, Phone, MapPin } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { appName } from "@/lib/siteConfig";
export default function ContactPage() {
const { settings, loading } = useSiteSettings();
const orgName = settings.org_name || appName;
const email = settings.org_email || "";
const phone = settings.org_phone || "";
const address = settings.org_address || "";
const hasAny = !!(email || phone || address);
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-12 px-4 max-w-3xl mx-auto w-full">
<div className="text-center mb-10">
<h1 className="text-3xl font-bold text-gray-900">Contact {orgName}</h1>
<p className="text-gray-600 mt-2">We&apos;d love to hear from you reach out any of the ways below.</p>
</div>
{!loading && !hasAny && (
<p className="text-center text-sm text-gray-400">Contact details haven&apos;t been set up yet.</p>
)}
<div className="grid gap-4 sm:grid-cols-3">
{email && (
<a
href={`mailto:${email}`}
className="flex flex-col items-center text-center gap-3 rounded-xl border border-gray-200 bg-white p-6 shadow-sm hover:border-brand-200 hover:shadow transition-shadow"
>
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center">
<Mail className="w-6 h-6 text-brand-600" />
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Email</div>
<div className="text-sm text-gray-600 break-all">{email}</div>
</div>
</a>
)}
{phone && (
<a
href={`tel:${phone}`}
className="flex flex-col items-center text-center gap-3 rounded-xl border border-gray-200 bg-white p-6 shadow-sm hover:border-brand-200 hover:shadow transition-shadow"
>
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center">
<Phone className="w-6 h-6 text-brand-600" />
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Phone</div>
<div className="text-sm text-gray-600">{phone}</div>
</div>
</a>
)}
{address && (
<div className="flex flex-col items-center text-center gap-3 rounded-xl border border-gray-200 bg-white p-6 shadow-sm">
<div className="w-12 h-12 rounded-full bg-brand-50 flex items-center justify-center">
<MapPin className="w-6 h-6 text-brand-600" />
</div>
<div>
<div className="text-sm font-semibold text-gray-900">Address</div>
<div className="text-sm text-gray-600">{address}</div>
</div>
</div>
)}
</div>
</main>
<Footer />
</div>
);
}
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import type { EventCost, EventCostType, EventFinancials, CashupMethod } from "@/types";
import { Wallet, DollarSign, ClipboardCheck, FileBarChart } from "lucide-react";
const METHOD_LABELS: Record<CashupMethod, string> = { cash: "Cash", card: "Card", eft: "EFT", other: "Other" };
const METHODS: CashupMethod[] = ["cash", "card", "eft", "other"];
@@ -57,12 +58,17 @@ export default function EventCashupPage() {
return (
<div className="max-w-4xl mx-auto space-y-4">
<div className="flex items-center justify-between">
<div>
<button className="text-xs text-indigo-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold mt-1">{data?.event?.title || "Event"} Cashup</h1>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<div>
<button className="text-xs text-brand-600 hover:underline" onClick={() => router.push("/dashboard/admin/cashup")}> Back to cashup</button>
<h1 className="text-xl font-semibold text-gray-900 mt-0.5">{data?.event?.title || "Event"} Cashup</h1>
</div>
</div>
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
<span className={"text-xs px-2 py-1 rounded-full font-medium " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
@@ -70,9 +76,9 @@ export default function EventCashupPage() {
{error && <div className="text-sm text-red-600 bg-red-50 border border-red-100 rounded p-2">{error}</div>}
<div className="flex gap-2 border-b">
<button className={"px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}>Costs</button>
<button className={"px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}>Reconciliation</button>
<button className={"px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-indigo-600 text-indigo-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}>Report</button>
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "costs" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("costs")}><DollarSign className="w-4 h-4" />Costs</button>
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "reconciliation" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("reconciliation")}><ClipboardCheck className="w-4 h-4" />Reconciliation</button>
<button className={"inline-flex items-center gap-1.5 px-3 py-2 text-sm " + (tab === "report" ? "border-b-2 border-brand-600 text-brand-700 font-medium" : "text-gray-500")} onClick={() => setTab("report")}><FileBarChart className="w-4 h-4" />Report</button>
</div>
{loading && <div className="text-sm text-gray-400">Loading</div>}
@@ -169,7 +175,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Event costs</div>
{!isClosed && editingId === null && (
<button className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={startNew}>+ Add cost</button>
<button className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={startNew}>+ Add cost</button>
)}
</div>
@@ -199,7 +205,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
<td className="py-1.5 text-right font-medium">{money(c.total ?? c.amount)}</td>
{!isClosed && (
<td className="py-1.5 text-right whitespace-nowrap">
<button className="text-xs text-indigo-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-brand-600 hover:underline mr-2" onClick={() => startEdit(c)}>Edit</button>
<button className="text-xs text-red-600 hover:underline" onClick={() => remove(c.id)}>Delete</button>
</td>
)}
@@ -264,7 +270,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
</div>
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={cancel} disabled={saving}>Cancel</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
)}
@@ -378,7 +384,7 @@ function CashByUserSection({ eventId, token, onCashSummaryChange }: {
<td className="py-2.5 text-right pl-2">
{r.userId && (
<button
className="text-xs text-indigo-600 hover:underline whitespace-nowrap"
className="text-xs text-brand-600 hover:underline whitespace-nowrap"
onClick={() => setEditingUserId(editingUserId === r.userId ? null : r.userId)}
>
{r.cash.actual != null ? "Edit count" : "Enter count"}
@@ -495,7 +501,7 @@ function PersonCashCountEditor({ eventId, token, userId, initialDenominations, i
<div className="flex gap-2 justify-end">
<button className="text-xs px-3 py-1.5 rounded border" onClick={onCancel} disabled={saving}>Cancel</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
</div>
</div>
);
@@ -803,7 +809,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
<div className="flex flex-wrap gap-2 justify-end pt-1">
<button className="text-xs px-3 py-1.5 rounded border" disabled={busy} onClick={saveDraft}>Save draft</button>
<button className="text-xs px-3 py-1.5 rounded bg-amber-600 text-white hover:bg-amber-700" disabled={busy} onClick={quickClose}>Quick close (skip cashup)</button>
<button className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
<button className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" disabled={busy} onClick={closeWithCashup}>Close event with cashup</button>
</div>
</div>
)}
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Wallet } from "lucide-react";
export default function CashupLandingPage() {
const { token } = useAuth();
@@ -46,14 +47,19 @@ export default function CashupLandingPage() {
return (
<div className="max-w-3xl mx-auto space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">Post-event Cashup</h1>
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-xl font-semibold text-gray-900">Post-event Cashup</h1>
<p className="text-sm text-gray-500 mt-1">Set costs, reconcile takings, and close out an event. Admin only.</p>
</div>
</div>
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shrink-0"
onClick={() => router.push("/dashboard")}
>Back</button>
</div>
@@ -87,7 +93,7 @@ export default function CashupLandingPage() {
return (
<li
key={ev.id}
className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-center justify-between gap-3"
onClick={() => router.push(`/dashboard/admin/cashup/${ev.id}`)}
>
<div className="min-w-0">
@@ -101,7 +107,7 @@ export default function CashupLandingPage() {
{ev.startDate ? new Date(ev.startDate).toLocaleDateString() : ""}{ev.endDate ? ` ${new Date(ev.endDate).toLocaleDateString()}` : ""}
</div>
</div>
<span className="text-xs text-indigo-600 shrink-0">Manage </span>
<span className="text-xs text-brand-600 shrink-0">Manage </span>
</li>
);
})}
+128 -164
View File
@@ -6,6 +6,45 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
const formatCount = (n: number) => (n || 0).toLocaleString();
const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/admin/users", label: "Manage users", description: "Create, edit, change roles and passwords", icon: Users },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/admin/registrations", label: "Manage registrations", description: "Cancel, update status, and search registrations", icon: ClipboardList },
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/admin/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
revenue: OverviewMetric;
donations: OverviewMetric;
trend: { date: string; revenue: number }[];
topEvents: { eventId: string; title: string; revenue: number; registrations: number; ticketsSold: number }[];
};
export default function AdminDashboardPage() {
const { user, loading, token } = useAuth();
@@ -18,29 +57,26 @@ export default function AdminDashboardPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/admin) that
// computes it all server-side — no more separate calls plus a full payments/events pull
// just to reduce them down to a couple of numbers client-side.
// useStableState skips the re-render entirely when a poll returns identical data, and
// hasLoadedOnce below means "Refreshing…" only ever shows for the very first load —
// together these stop the stats panels from flickering on every 15s poll.
// /api/stats/admin covers today/week/month payment totals (unchanged from before);
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint. Both are
// computed server-side — no full payments/events list ever ships to the client.
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
const [paymentsTab, setPaymentsTab] = useState<"today" | "week" | "month">("today");
const loadStats = async () => {
if (!token) return;
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/admin", { authToken: token });
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
const [dash, overviewData] = await Promise.all([
apiFetch<any>("/api/stats/admin", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
]);
setPaymentStats(dash.paymentStats);
setOverview(overviewData);
} catch (e) {
// ignore errors for dashboard summaries
} finally {
@@ -61,18 +97,15 @@ export default function AdminDashboardPage() {
loadStats();
}, 15000, !!token);
const paymentsTabValue = paymentStats
? paymentsTab === "today" ? paymentStats.totalToday : paymentsTab === "week" ? paymentStats.totalWeek : paymentStats.totalMonth
: 0;
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Admin Dashboard{user ? ` ${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>Manage users</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>Manage registrations</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>WhatsApp API</button>
</div>
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
{!isAdmin && (
@@ -81,158 +114,89 @@ export default function AdminDashboardPage() {
</div>
)}
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/users")}>
Manage users
<div className="text-xs text-white/90">Create, edit, change roles and passwords</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>
Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/registrations")}>
Manage registrations
<div className="text-xs text-white/90">Cancel, update status, and search registrations</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>
Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>
Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>
Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>
Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/forms")}>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees")}>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees")}>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/whatsapp")}>
Manage WhatsApp API
<div className="text-xs text-white/90">Manage the WhatsApp API config</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/admin/cashup")}>
Post-event Cashup
<div className="text-xs text-white/90">Set costs, reconcile takings, and close out events</div>
</button>
</div>
</div>
<StatCardRow>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href="/dashboard/admin/registrations" linkLabel="View registrations" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<ul className="text-sm space-y-2 max-h-96 overflow-auto pr-2">
{recentScans.map((u: any) => (
<li key={u.id} className="border rounded p-2">
<div className="flex justify-between">
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
<div className="text-lg font-semibold mb-3">Quick actions</div>
<QuickActionGrid>
{QUICK_ACTIONS.map(a => (
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
))}
</QuickActionGrid>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6 min-w-0">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Revenue trend past month</h2>
{overview && overview.trend.length > 0 ? (
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
) : (
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Payments</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{paymentStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">R{paymentStats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Past Week</div>
<div className="text-lg font-semibold">R{paymentStats.totalWeek}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Past Month</div>
<div className="text-lg font-semibold">R{paymentStats.totalMonth}</div>
</div>
</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
{overview && overview.topEvents.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event</TableHead>
<TableHead className="text-right">Registrations</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Tickets sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.topEvents.map(e => (
<TableRow key={e.eventId}>
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
</TableRow>
))}
</ul>
</div>
</TableBody>
</Table>
) : (
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
)}
</div>
</div>
<div>
<div className="space-y-6 min-w-0">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Admin stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Payments overview</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
<div className="flex gap-1 mb-3 bg-gray-100 rounded-lg p-1">
{(["today", "week", "month"] as const).map(t => (
<button
key={t}
type="button"
onClick={() => setPaymentsTab(t)}
className={"flex-1 text-xs font-medium py-1.5 rounded-md transition-colors " + (paymentsTab === t ? "bg-white text-brand-700 shadow-sm" : "text-gray-500 hover:text-gray-700")}
>
{t === "today" ? "Today" : t === "week" ? "Past week" : "Past month"}
</button>
))}
</div>
{paymentStats ? (
<div className="text-2xl font-semibold text-gray-900">{formatRand(paymentsTabValue)}</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
</div>
<div className="text-sm text-gray-600 mt-6">
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
</div>
<p className="text-sm text-gray-600">As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
</div>
</div>
</div>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList } from "lucide-react";
const STATUS_OPTIONS = ["pending", "confirmed", "partial_paid", "paid", "cancelled"] as const;
@@ -157,7 +158,9 @@ export default function AdminRegistrationsPage() {
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)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
@@ -185,9 +188,17 @@ export default function AdminRegistrationsPage() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manage Registrations</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Manage Registrations</h1>
<p className="text-sm text-gray-500">{registrations.length} registration{registrations.length !== 1 ? "s" : ""} total</p>
</div>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!isAdmin && (
@@ -304,7 +315,7 @@ export default function AdminRegistrationsPage() {
{/* Actions */}
<div className="flex items-center gap-2 py-2 border-b border-gray-200 mb-3">
<select
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-60"
className="px-2 py-1 text-xs rounded-lg border border-gray-300 bg-white shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 disabled:opacity-60"
value={r.status}
onChange={e => updateStatus(r, e.target.value)}
disabled={r.status === 'cancelled'}
@@ -325,25 +336,40 @@ export default function AdminRegistrationsPage() {
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Ticket options</div>
<div className="grid sm:grid-cols-2 gap-2">
{r.registrationOptions.map((opt: any) => (
<div key={opt.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{opt.eventOption?.name || opt.eventOptionId}
{opt.variant?.name && <span className="text-gray-500"> ({opt.variant.name})</span>}
</div>
<div className="text-gray-500">
{(() => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
{r.registrationOptions.map((opt: any) => {
// A line can span multiple price tranches (e.g. tickets bought
// before and after an early-bird tier expired) — show one row per
// tranche so its own price/tier status is accurate, not blended.
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
? opt.tranches
: [{
id: opt.id,
quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
return `Qty: ${opt.quantity} × R ${unit.toFixed(2)} = R ${(unit * (opt.quantity || 0)).toFixed(2)}`;
})()}
: (opt.variant?.price ?? opt.eventOption?.price ?? 0),
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>
{opt.appliedTierId && (
<div className="text-green-700 text-[10px] mt-0.5">Early-bird price applied</div>
)}
</div>
))}
);
})}
</div>
<div className="text-xs text-gray-700 mt-1 font-medium">Total: R {totalDue.toFixed(2)}</div>
</div>
File diff suppressed because it is too large Load Diff
+23 -11
View File
@@ -5,6 +5,8 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Users as UsersIcon } from "lucide-react";
import { RoleBadge, type Role as RoleBadgeRole } from "@/components/shared/RoleBadge";
interface UserItem {
id: string;
@@ -206,11 +208,19 @@ export default function AdminUsersPage() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">User Management</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<UsersIcon className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">User Management</h1>
<p className="text-sm text-gray-500">{total} user{total !== 1 ? "s" : ""} total</p>
</div>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => setCreateOpen(v => !v)}>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={() => setCreateOpen(v => !v)}>
{createOpen ? "Close" : "Create user"}
</button>
</div>
@@ -249,7 +259,7 @@ export default function AdminUsersPage() {
</select>
</div>
<div className="flex items-end">
<button disabled={creating} className="px-3 py-2 rounded bg-indigo-600 text-white disabled:opacity-50" type="submit">
<button disabled={creating} className="px-3 py-2 rounded bg-brand-600 text-white disabled:opacity-50" type="submit">
{creating ? "Creating…" : "Create"}
</button>
</div>
@@ -298,8 +308,7 @@ export default function AdminUsersPage() {
</div>
<div className="text-xs text-gray-500 mb-2">
{total} user{total !== 1 ? "s" : ""} total
{total > 0 && ` — page ${page} of ${totalPages}`}
{total > 0 && `Page ${page} of ${totalPages}`}
</div>
<div className="overflow-auto">
@@ -325,7 +334,7 @@ export default function AdminUsersPage() {
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
</td>
<td className="p-2">
<span className="capitalize">{u.role}</span>
<RoleBadge role={u.role as RoleBadgeRole} />
</td>
<td className="p-2">
<span>{u.phoneNumber || ""}</span>
@@ -334,7 +343,10 @@ export default function AdminUsersPage() {
<span className="capitalize">{u.notificationPreference || "email"}</span>
</td>
<td className="p-2">
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${u.isActive ? "bg-green-50 text-green-700" : "bg-gray-100 text-gray-500"}`}>
<span className={`w-1.5 h-1.5 rounded-full ${u.isActive ? "bg-green-500" : "bg-gray-400"}`} />
{u.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="p-2">
<div className="flex gap-1 flex-wrap">
@@ -384,7 +396,7 @@ export default function AdminUsersPage() {
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>
@@ -461,7 +473,7 @@ export default function AdminUsersPage() {
</div>
<div className="flex justify-end gap-2 mt-4">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
@@ -1,831 +1,15 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
// ─── Types ────────────────────────────────────────────────────────────────────
type WAStatus =
| "WORKING"
| "CONNECTED"
| "SCAN_QR_CODE"
| "STARTING"
| "FAILED"
| "STOPPED"
| string;
interface ConfigResponse {
tokenMasked: string;
instanceId: string;
hasToken: boolean;
hasInstance: boolean;
configured: boolean;
}
interface StatusResponse {
status: WAStatus;
message?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const STATUS_COLORS: Record<string, string> = {
WORKING: "bg-green-100 text-green-800 border-green-300",
CONNECTED: "bg-green-100 text-green-800 border-green-300",
SCAN_QR_CODE: "bg-yellow-100 text-yellow-800 border-yellow-300",
STARTING: "bg-blue-100 text-blue-800 border-blue-300",
FAILED: "bg-red-100 text-red-800 border-red-300",
STOPPED: "bg-gray-100 text-gray-700 border-gray-300",
};
const STATUS_ICONS: Record<string, string> = {
WORKING: "🟢",
CONNECTED: "🟢",
SCAN_QR_CODE: "📷",
STARTING: "🔄",
FAILED: "🔴",
STOPPED: "⚫",
};
const ACTIVE_STATUSES = new Set(["WORKING", "CONNECTED"]);
const POLLING_STATUSES = new Set(["STARTING", "SCAN_QR_CODE", "FAILED", "STOPPED"]);
function Spinner() {
return (
<svg
className="animate-spin h-4 w-4 text-indigo-600"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
);
}
function Alert({
type,
children,
}: {
type: "ok" | "err" | "info";
children: React.ReactNode;
}) {
const cls =
type === "ok"
? "bg-green-50 text-green-800 border-green-200"
: type === "err"
? "bg-red-50 text-red-800 border-red-200"
: "bg-blue-50 text-blue-800 border-blue-200";
return (
<div className={`p-3 rounded-lg text-sm border ${cls}`}>{children}</div>
);
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function WhatsAppAdminPage() {
const { user, loading, token } = useAuth();
// WhatsApp management moved into Site Settings (its own tab) — this route now
// just redirects there so old links/bookmarks (and the backend's failure-alert
// email, which links here) still land somewhere useful.
export default function WhatsAppRedirectPage() {
const router = useRouter();
const isAdmin = useMemo(() => user?.role === "admin", [user]);
useEffect(() => {
if (loading) return;
if (!user || !isAdmin) router.replace("/dashboard");
}, [user, loading, isAdmin, router]);
// ── Config state (drives wizard steps) ──────────────────────────────────────
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
const [cfgLoading, setCfgLoading] = useState(true);
// Derived wizard step: 1 = no token, 2 = token but no instance, 3 = fully configured
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
// ── Step 1 inputs ────────────────────────────────────────────────────────────
const [inputToken, setInputToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
// ── Step 2 inputs ────────────────────────────────────────────────────────────
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
const [inputInstanceId, setInputInstanceId] = useState("");
const [savingInstance, setSavingInstance] = useState(false);
// ── Step 3: session state ────────────────────────────────────────────────────
const [status, setStatus] = useState<WAStatus | null>(null);
const [statusMsg, setStatusMsg] = useState<string | null>(null);
const [qrSrc, setQrSrc] = useState<string | null>(null);
const [pairingPhone, setPairingPhone] = useState("");
// ── Shared action feedback ───────────────────────────────────────────────────
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
// ── Load config ──────────────────────────────────────────────────────────────
const fetchConfig = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<ConfigResponse>("/api/whatsapp/config", { authToken: token });
setCfg(res);
} catch {
// network error — leave cfg null, user sees loading state
} finally {
setCfgLoading(false);
}
}, [token]);
useEffect(() => { fetchConfig(); }, [fetchConfig]);
// ── Status fetch (step 3 only) ───────────────────────────────────────────────
const fetchStatus = useCallback(async () => {
if (!token || step !== 3) return;
try {
const res = await apiFetch<StatusResponse>("/api/whatsapp/status", { authToken: token });
setStatus(res.status ?? null);
setStatusMsg(res.message ?? null);
} catch (e: any) {
// Re-fetch config — if the session was not found, backend clears the
// instance ID and the step recomputes to 2 (Session Instance setup).
await fetchConfig();
setStatus("FAILED");
setStatusMsg(null);
}
}, [token, step, fetchConfig]);
useEffect(() => { if (step === 3) fetchStatus(); }, [step, fetchStatus]);
// Auto-poll status when not stable
useEffect(() => {
if (step !== 3 || status === null) return;
if (ACTIVE_STATUSES.has(status)) return;
const id = setInterval(fetchStatus, 5_000);
return () => clearInterval(id);
}, [step, status, fetchStatus]);
// ── QR fetch ─────────────────────────────────────────────────────────────────
const fetchQr = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ qr?: string }>("/api/whatsapp/qr", { authToken: token });
if (res.qr) setQrSrc(`data:image/png;base64,${res.qr}`);
} catch {
setQrSrc(null);
}
}, [token]);
useEffect(() => {
if (status === "SCAN_QR_CODE") { fetchQr(); }
else { setQrSrc(null); }
}, [status, fetchQr]);
// Auto-refresh QR every 20s while waiting
useEffect(() => {
if (status !== "SCAN_QR_CODE") return;
const id = setInterval(fetchQr, 20_000);
return () => clearInterval(id);
}, [status, fetchQr]);
// ── Generic session action ───────────────────────────────────────────────────
const doAction = async (action: string, body?: object) => {
if (!token) return;
setBusy(action);
setActionMsg(null);
try {
const res = await apiFetch<any>(`/api/whatsapp/${action}`, {
method: "POST",
authToken: token,
body,
});
setActionMsg({ type: "ok", text: res?.message || `${action} successful.` });
await fetchStatus();
await fetchConfig();
} catch (e: any) {
let msg = e?.message || `${action} failed.`;
try { msg = JSON.parse(msg)?.message || msg; } catch {}
// SESSION_NOT_FOUND: backend cleared the instance ID — re-fetch config so
// the wizard steps back to Step 2; no need to show an error message.
await fetchConfig();
if (!msg.includes("SESSION_NOT_FOUND")) {
setActionMsg({ type: "err", text: msg });
}
await fetchStatus();
} finally {
setBusy(null);
}
};
// ─── Step 1: Save token ──────────────────────────────────────────────────────
const saveToken = async () => {
if (!inputToken.trim()) {
setActionMsg({ type: "err", text: "Please enter your WAWP access token." });
return;
}
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: inputToken.trim(), instanceId: "" },
});
setInputToken("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save token." });
} finally {
setSavingToken(false);
}
};
// ─── Step 2: Enter existing instance ID ──────────────────────────────────────
const saveInstanceId = async () => {
if (!inputInstanceId.trim()) {
setActionMsg({ type: "err", text: "Please enter the Instance ID." });
return;
}
setSavingInstance(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: "", instanceId: inputInstanceId.trim() },
// token left blank → backend keeps existing token
});
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
} finally {
setSavingInstance(false);
}
};
// ─── Step 2: Create new instance ─────────────────────────────────────────────
const createInstance = async () => {
setSavingInstance(true);
setActionMsg(null);
try {
const res = await apiFetch<any>("/api/whatsapp/create-instance", {
method: "POST",
authToken: token!,
});
setActionMsg({ type: "ok", text: res?.message || "Instance created." });
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to create instance." });
} finally {
setSavingInstance(false);
}
};
// ─── Pairing code ────────────────────────────────────────────────────────────
const requestPairingCode = async () => {
if (!pairingPhone.trim()) {
setActionMsg({ type: "err", text: "Enter your phone number first." });
return;
}
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
};
// ─── Reset credentials (go back to step 1) ───────────────────────────────────
const resetToken = async () => {
if (!confirm("This will clear your saved access token. You will need to re-enter it. Continue?")) return;
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: { token: "_clear_", instanceId: "" },
});
} catch {}
// Force a re-read — even if the above fails, clear local state
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
// ────────────────────────────────────────────────────────────────────────────
// Render
// ────────────────────────────────────────────────────────────────────────────
if (loading || cfgLoading) {
return (
<div className="max-w-xl mx-auto w-full p-6 flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Loading
</div>
);
}
return (
<div className="max-w-xl mx-auto w-full p-6 space-y-6">
{/* Back button */}
<button
onClick={() => router.push("/dashboard")}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clipRule="evenodd" />
</svg>
Back
</button>
{/* Header */}
<div className="flex items-center gap-3">
<span className="text-3xl">💬</span>
<div>
<h1 className="text-2xl font-semibold leading-tight">WhatsApp Integration</h1>
<p className="text-sm text-gray-500">Powered by WAWP</p>
</div>
</div>
{/* Step indicator */}
<StepIndicator step={step} />
{/* Global action message */}
{actionMsg && (
<Alert type={actionMsg.type}>{actionMsg.text}</Alert>
)}
{/* ── STEP 1: Enter access token ──────────────────────────────────────── */}
{step === 1 && (
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Step 1 Enter your WAWP Access Token</h2>
<p className="text-sm text-gray-600">
Your access token is found in your WAWP account dashboard at{" "}
<a
href="https://app.wawp.net"
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
app.wawp.net
</a>
.
</p>
<div className="space-y-2">
<label className="block text-xs font-medium text-gray-700">Access Token</label>
<input
type="password"
value={inputToken}
onChange={e => setInputToken(e.target.value)}
onKeyDown={e => e.key === "Enter" && saveToken()}
placeholder="Paste your WAWP access token"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={saveToken}
disabled={savingToken}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Token & Continue"}
</button>
</section>
)}
{/* ── STEP 2: Instance ID ─────────────────────────────────────────────── */}
{step === 2 && (
<section className="border rounded-xl p-6 bg-white shadow-sm space-y-5">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Step 2 Set Up Session Instance</h2>
<span className="text-xs text-gray-400 font-mono bg-gray-100 px-2 py-0.5 rounded">
Token: {cfg?.tokenMasked}
</span>
</div>
<p className="text-sm text-gray-600">
You need a WAWP session instance. Either create a brand-new one, or enter an
existing Instance ID.
</p>
{/* Tab toggle */}
<div className="flex rounded-lg border overflow-hidden text-sm font-medium">
<button
onClick={() => setInstanceMode("create")}
className={`flex-1 px-4 py-2.5 transition-colors ${
instanceMode === "create"
? "bg-indigo-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
Create new instance
</button>
<button
onClick={() => setInstanceMode("enter")}
className={`flex-1 px-4 py-2.5 border-l transition-colors ${
instanceMode === "enter"
? "bg-indigo-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
>
Enter existing ID
</button>
</div>
{instanceMode === "create" && (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Click below to create a new WAWP session. The Instance ID will be saved
automatically.
</p>
<button
onClick={createInstance}
disabled={savingInstance}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingInstance && <Spinner />}
{savingInstance ? "Creating…" : "Create Instance"}
</button>
</div>
)}
{instanceMode === "enter" && (
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Instance ID
</label>
<input
type="text"
value={inputInstanceId}
onChange={e => setInputInstanceId(e.target.value)}
onKeyDown={e => e.key === "Enter" && saveInstanceId()}
placeholder="e.g. BF14B761C364"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={saveInstanceId}
disabled={savingInstance}
className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingInstance && <Spinner />}
{savingInstance ? "Saving…" : "Save & Continue"}
</button>
</div>
)}
<button
onClick={resetToken}
className="text-xs text-gray-400 hover:text-red-500 hover:underline"
>
Change access token
</button>
</section>
)}
{/* ── STEP 3: Full management ─────────────────────────────────────────── */}
{step === 3 && (
<>
{/* Status card */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Session Status</h2>
<button
onClick={fetchStatus}
className="text-xs text-indigo-600 hover:underline"
>
Refresh
</button>
</div>
{status === null ? (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Fetching status
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-lg">{STATUS_ICONS[status] ?? "⚪"}</span>
<span
className={`inline-flex items-center px-3 py-1 rounded-full border text-sm font-semibold ${
STATUS_COLORS[status] ?? "bg-gray-100 text-gray-700 border-gray-300"
}`}
>
{status}
</span>
</div>
)}
{statusMsg && <p className="text-xs text-gray-500">{statusMsg}</p>}
{status === "FAILED" && (
<Alert type="err">
The session has failed. The system will attempt to auto-restart. You can also
restart manually below.
</Alert>
)}
{status && POLLING_STATUSES.has(status) && (
<p className="text-xs text-gray-400 flex items-center gap-1">
<Spinner /> Auto-refreshing every 5 seconds
</p>
)}
{/* Config info strip */}
<div className="flex flex-wrap gap-3 pt-2 border-t text-xs text-gray-500">
<span>
Token: <span className="font-mono">{cfg?.tokenMasked || "—"}</span>
</span>
<span>
Instance: <span className="font-mono">{cfg?.instanceId || "—"}</span>
</span>
</div>
</section>
{/* QR Code */}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Scan QR Code</h2>
<button
onClick={fetchQr}
className="text-xs text-indigo-600 hover:underline"
>
Refresh QR
</button>
</div>
<p className="text-sm text-gray-600">
Open WhatsApp Linked Devices Link a Device, then scan the code below.
</p>
{qrSrc ? (
<img
src={qrSrc}
alt="WhatsApp QR Code"
className="w-56 h-56 border rounded-lg"
/>
) : (
<div className="flex items-center gap-2 text-sm text-gray-400">
<Spinner /> Loading QR
</div>
)}
<p className="text-xs text-gray-400">
QR codes expire after ~20 seconds click Refresh QR if it stops working.
</p>
</section>
)}
{/* Pairing code */}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Link by Phone Number Instead</h2>
<p className="text-sm text-gray-600">
Enter your WhatsApp number (SA format, e.g. 082 123 4567) to receive a pairing
code on your phone.
</p>
<div className="flex gap-2">
<input
type="tel"
value={pairingPhone}
onChange={e => setPairingPhone(e.target.value)}
placeholder="082 123 4567"
className="flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
<button
onClick={requestPairingCode}
disabled={busy === "request-code"}
className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{busy === "request-code" && <Spinner />}
{busy === "request-code" ? "Sending…" : "Send code"}
</button>
</div>
</section>
)}
{/* Session controls */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Session Controls</h2>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Start"
busyLabel="Starting…"
isBusy={busy === "start"}
disabled={!!busy}
color="green"
onClick={() => doAction("start")}
/>
<ActionButton
label="Restart"
busyLabel="Restarting…"
isBusy={busy === "restart"}
disabled={!!busy}
color="amber"
onClick={() => doAction("restart")}
/>
<ActionButton
label="Logout"
busyLabel="Logging out…"
isBusy={busy === "logout"}
disabled={!!busy}
color="red-outline"
onClick={() => {
if (!confirm("This will log out the linked WhatsApp account. Are you sure?")) return;
doAction("logout");
}}
/>
</div>
</section>
{/* Instance management */}
<section className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
<h2 className="text-lg font-semibold">Instance Management</h2>
<p className="text-sm text-gray-600">
Create a brand-new instance or permanently delete the current one. Deleting will
require you to set up a new instance.
</p>
<div className="flex flex-wrap gap-3">
<ActionButton
label="Create New Instance"
busyLabel="Creating…"
isBusy={busy === "create-instance"}
disabled={!!busy}
color="blue"
onClick={() => doAction("create-instance")}
/>
<ActionButton
label="Delete Instance"
busyLabel="Deleting…"
isBusy={busy === "delete-instance"}
disabled={!!busy}
color="red-outline"
onClick={() => {
if (!confirm("This will PERMANENTLY delete the instance. You'll need to create a new one. Are you sure?")) return;
doAction("delete-instance");
}}
/>
</div>
</section>
{/* Update credentials */}
<details className="border rounded-xl bg-white shadow-sm">
<summary className="p-5 cursor-pointer text-sm font-semibold text-gray-700 select-none list-none flex items-center justify-between">
<span>Update Credentials</span>
<span className="text-gray-400 text-xs">expand </span>
</summary>
<div className="px-5 pb-5 space-y-3 border-t pt-4">
<p className="text-sm text-gray-600">
Change your WAWP access token or Instance ID. Leave a field blank to keep the
current value.
</p>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
New Access Token
</label>
<input
type="password"
value={inputToken}
onChange={e => setInputToken(e.target.value)}
placeholder="Leave blank to keep current token"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
New Instance ID
</label>
<input
type="text"
value={inputInstanceId}
onChange={e => setInputInstanceId(e.target.value)}
placeholder="Leave blank to keep current instance"
className="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<button
onClick={async () => {
if (!inputToken.trim() && !inputInstanceId.trim()) {
setActionMsg({ type: "err", text: "Enter at least one field to update." });
return;
}
setSavingToken(true);
setActionMsg(null);
try {
await apiFetch("/api/whatsapp/config", {
method: "POST",
authToken: token!,
body: {
token: inputToken.trim() || undefined,
instanceId: inputInstanceId.trim() || undefined,
},
});
setActionMsg({ type: "ok", text: "Credentials updated." });
setInputToken("");
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to update." });
} finally {
setSavingToken(false);
}
}}
disabled={savingToken}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Changes"}
</button>
</div>
</details>
</>
)}
<p className="text-xs text-gray-400 text-center">
WhatsApp notifications powered by{" "}
<a
href="https://wawp.net"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
WAWP
</a>
. Session auto-recovers on failure; admin alert sent if recovery fails.
</p>
</div>
);
router.replace("/dashboard/admin/settings?tab=whatsapp");
}, [router]);
return <div className="p-6 text-sm text-gray-500">Redirecting</div>;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StepIndicator({ step }: { step: number }) {
const steps = [
{ n: 1, label: "Access Token" },
{ n: 2, label: "Session Instance" },
{ n: 3, label: "Connected" },
];
return (
<div className="flex items-center gap-0">
{steps.map((s, i) => {
const done = step > s.n;
const current = step === s.n;
return (
<React.Fragment key={s.n}>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold border-2 transition-colors ${
done
? "bg-green-500 border-green-500 text-white"
: current
? "bg-indigo-600 border-indigo-600 text-white"
: "bg-white border-gray-300 text-gray-400"
}`}
>
{done ? "✓" : s.n}
</div>
<span
className={`text-xs mt-1 font-medium ${
done || current ? "text-gray-700" : "text-gray-400"
}`}
>
{s.label}
</span>
</div>
{i < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mb-5 mx-1 transition-colors ${
done ? "bg-green-400" : "bg-gray-200"
}`}
/>
)}
</React.Fragment>
);
})}
</div>
);
}
type ButtonColor = "green" | "amber" | "blue" | "red-outline";
function ActionButton({
label,
busyLabel,
isBusy,
disabled,
color,
onClick,
}: {
label: string;
busyLabel: string;
isBusy: boolean;
disabled: boolean;
color: ButtonColor;
onClick: () => void;
}) {
const base = "flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50 transition-colors";
const colors: Record<ButtonColor, string> = {
green: "bg-green-600 text-white hover:bg-green-700",
amber: "bg-amber-500 text-white hover:bg-amber-600",
blue: "bg-blue-600 text-white hover:bg-blue-700",
"red-outline": "border border-red-600 text-red-600 hover:bg-red-50",
};
return (
<button onClick={onClick} disabled={disabled} className={`${base} ${colors[color]}`}>
{isBusy && <Spinner />}
{isBusy ? busyLabel : label}
</button>
);
}
+34 -21
View File
@@ -7,6 +7,19 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
// The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign
// mockups). Every other /dashboard/* route relies on the global floating
// help button instead of sidebar nav, same as before this redesign.
const SIDEBAR_ROUTES = [
"/dashboard/admin",
"/dashboard/supervisor",
"/dashboard/staff",
"/dashboard/user",
"/dashboard/user/profile",
"/dashboard/admin/settings",
];
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, loading } = useAuth();
const router = useRouter();
@@ -63,31 +76,31 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
// Sub-pages within staff/supervisor/admin (e.g. /dashboard/supervisor/reports,
// /dashboard/admin/cashup/[id]) are full-width workspaces with their own internal navigation
// and header — the dashboard sidebar's section links (My Events, Profile, Admin, etc.) would
// just crowd them. The sidebar stays visible only on each role's root landing page; every
// deeper sub-page hides it. Navigating back is never a dead end: Navbar's "Dashboard" link is
// always present and /dashboard auto-redirects to the role root.
const SIDEBAR_ROOTS = ["/dashboard/staff", "/dashboard/supervisor", "/dashboard/admin"];
const hideSidebar = !!pathname && SIDEBAR_ROOTS.some(root => pathname !== root && pathname.startsWith(root + "/"));
const showSidebar = !!pathname && SIDEBAR_ROUTES.includes(pathname);
if (showSidebar) {
// App-shell layout: the whole viewport is claimed (h-screen, no body
// scroll) so the sidebar can be a plain sibling column that never
// scrolls — only the content column (main + footer) scrolls internally.
return (
<div className="h-screen flex flex-col overflow-hidden">
<Navbar />
<MobileSidebar />
<div className="flex-1 flex min-h-0">
<Sidebar />
<div className="flex-1 min-w-0 flex flex-col overflow-y-auto">
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex-1 flex flex-col md:flex-row">
{!hideSidebar && (
<>
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
</>
)}
<main className="flex-1 p-6 bg-gray-50">{children}</main>
</div>
<main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer />
</div>
);
@@ -7,6 +7,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter, useSearchParams } from "next/navigation";
import { formatDate } from "@/lib/date";
import { QrImage } from "@/components/shared/QrImage";
import { Ticket } from "lucide-react";
function EventTicketsContent() {
const { token, user } = useAuth();
@@ -229,10 +230,15 @@ function EventTicketsContent() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event Tickets</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Event Tickets</h1>
</div>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm"
onClick={() => router.push("/dashboard")}
>
Back
@@ -325,7 +331,7 @@ function EventTicketsContent() {
onClick={() =>
printTickets(filteredTickets.filter((t) => !t.isUsed))
}
className="px-3 py-2 bg-blue-600 text-white rounded"
className="px-3 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded"
>
Print unused
</button>
+40 -51
View File
@@ -6,6 +6,14 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { QrCode, Ticket, Activity, UserCheck, Clock } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
const QUICK_ACTIONS = [
{ href: "/dashboard/staff/ticket-scanning", label: "Scan tickets", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/staff/event-tickets", label: "Event tickets & printing", description: "Browse event tickets and print lists", icon: Ticket },
] as const;
export default function StaffDashboardPage() {
const { user, loading, token } = useAuth();
@@ -60,13 +68,10 @@ export default function StaffDashboardPage() {
}, 10000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Staff Dashboard{user ? ` ${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets</button>
</div>
<div className="max-w-6xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s today&apos;s scanning activity.</p>
</div>
{!canView && (
@@ -75,20 +80,23 @@ export default function StaffDashboardPage() {
</div>
)}
<StatCardRow>
<StatCard icon={Activity} label="Scans today" value={stats ? stats.totalToday : "—"} tone="brand" />
<StatCard icon={UserCheck} label="My scans today" value={stats ? stats.myToday : "—"} tone="blue" />
<StatCard icon={Clock} label="Last hour" value={stats ? stats.lastHour : "—"} tone="amber" />
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
<div className="text-lg font-semibold mb-3">Quick actions</div>
<QuickActionGrid>
{QUICK_ACTIONS.map(a => (
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
))}
</QuickActionGrid>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-2 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Scan tickets
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
</div>
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
@@ -101,7 +109,7 @@ export default function StaffDashboardPage() {
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0, 8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
@@ -112,37 +120,18 @@ export default function StaffDashboardPage() {
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Scanner stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading stats</div>}
{stats && (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{stats.totalToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">My scans</div>
<div className="text-lg font-semibold">{stats.myToday}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Last hour</div>
<div className="text-lg font-semibold">{stats.lastHour}</div>
</div>
</div>
)}
{stats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
<h2 className="text-lg font-semibold mb-3">Today by staff</h2>
{stats?.byStaff?.length > 0 ? (
<ul className="text-sm text-gray-700 space-y-1">
{stats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
) : (
<div className="text-sm text-gray-500">No scans recorded yet today.</div>
)}
</div>
</div>
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { QrCode } from "lucide-react";
export default function TicketScanningPage() {
const router = useRouter();
@@ -246,9 +247,14 @@ export default function TicketScanningPage() {
<div className="max-w-6xl mx-auto w-full p-4">
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Ticket Scanning</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<QrCode className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Ticket Scanning</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">
Use the button to start/stop scanning. The back camera will be used when available.
@@ -363,7 +369,7 @@ export default function TicketScanningPage() {
{confirmModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-white rounded-xl shadow-lg max-w-md w-full p-6">
<h3 className="text-lg font-semibold mb-1 text-indigo-700">Confirm Scan</h3>
<h3 className="text-lg font-semibold mb-1 text-brand-700">Confirm Scan</h3>
<p className="text-sm text-gray-500 mb-4">Review the ticket details before confirming.</p>
<div className="bg-gray-50 border rounded-lg p-4 mb-4 space-y-1 text-sm">
@@ -382,7 +388,7 @@ export default function TicketScanningPage() {
inputMode="numeric"
min={1}
max={confirmModal.remaining}
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-24 border rounded px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
value={confirmQtyRaw}
onChange={e => setConfirmQtyRaw(e.target.value)}
onBlur={() => {
@@ -404,7 +410,7 @@ export default function TicketScanningPage() {
</button>
<button
onClick={commitScan}
className="flex-1 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-semibold hover:bg-indigo-700"
className="flex-1 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700"
>
Confirm Scan
</button>
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type LucideIcon } from "lucide-react";
type Mode = "registration" | "payment" | "checkin" | "tickets" | "refund";
@@ -17,6 +18,14 @@ const MODE_LABELS: Record<Mode, string> = {
refund: "REFUND",
};
const MODE_ICONS: Record<Mode, LucideIcon> = {
registration: UserPlus,
payment: CreditCard,
checkin: CheckSquare,
tickets: Ticket,
refund: RotateCcw,
};
// ─── Fuzzy search helpers ─────────────────────────────────────────────────────
function fuzzyFilterRegs(allRegs: any[], search: string): any[] {
@@ -343,7 +352,12 @@ export default function AtTheDoorPage() {
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h1 className="text-2xl font-semibold shrink-0">At The Door</h1>
<div className="flex items-center gap-3 shrink-0">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<DoorOpen className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">At The Door</h1>
</div>
<select
className="border rounded px-3 py-2 text-sm w-full sm:w-72 max-w-full"
value={eventId}
@@ -384,21 +398,25 @@ export default function AtTheDoorPage() {
{/* Mode Buttons */}
<div className="flex gap-2 mb-4 flex-wrap">
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => (
<button
key={m}
onClick={() => setMode(m)}
className={`px-4 py-2 rounded text-sm font-medium border ${
mode === m
? m === "refund"
? "bg-red-600 text-white border-red-600"
: "bg-indigo-600 text-white border-indigo-600"
: "bg-white hover:bg-gray-50"
}`}
>
{MODE_LABELS[m]}
</button>
))}
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => {
const Icon = MODE_ICONS[m];
return (
<button
key={m}
onClick={() => setMode(m)}
className={`inline-flex items-center gap-1.5 px-4 py-2 rounded text-sm font-medium border ${
mode === m
? m === "refund"
? "bg-red-600 text-white border-red-600"
: "bg-brand-600 text-white border-brand-600"
: "bg-white hover:bg-gray-50"
}`}
>
<Icon className="w-4 h-4" />
{MODE_LABELS[m]}
</button>
);
})}
</div>
{/* ✅ Panels */}
@@ -538,7 +556,7 @@ function DoorRegistrationPanel({ token, eventId, onCreated, onSelected, onEditRe
</div>
</div>
<div className="flex gap-2">
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-indigo-600 text-white">Open</button>
<button onClick={() => onSelected(r)} className="px-2 py-1 text-xs rounded bg-brand-600 text-white">Open</button>
<button onClick={() => onEditRegistration(r)} className="px-2 py-1 text-xs rounded bg-emerald-600 text-white">Edit</button>
<button onClick={() => onDonation(r.user)} className="px-2 py-1 text-xs rounded bg-amber-500 text-white">Donation</button>
</div>
@@ -590,8 +608,9 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const options = registration.options || registration.registrationOptions || [];
const payments = registration.payments || [];
const totalValue = options.reduce((sum: number, opt: any) => {
// Use priceSnapshot (authoritative backend price, variant-aware) if available
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalValue = registration.totalDueComputed ?? options.reduce((sum: number, opt: any) => {
const price = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
@@ -632,7 +651,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
const updatedOptions = updated.options || updated.registrationOptions || [];
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)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price ?? opt.price ?? 0);
@@ -712,7 +731,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
{balance > 0 && (
<button
onClick={() => setAmount(String(balance))}
className="px-4 rounded bg-indigo-600 text-white text-sm font-medium"
className="px-4 rounded bg-brand-600 text-white text-sm font-medium"
>
Full
</button>
@@ -747,7 +766,7 @@ function DoorPaymentPanel({ token, registration, onSuccess, setError }: any) {
<div className="mt-4 flex gap-2">
<button
onClick={() => onSuccess(registration)}
className="flex-1 py-3 rounded bg-indigo-600 text-white text-sm font-medium"
className="flex-1 py-3 rounded bg-brand-600 text-white text-sm font-medium"
>
Print Tickets
</button>
@@ -1113,7 +1132,7 @@ function DoorCheckInPanel({ token, eventId, registration, setError, setInfo }: a
<button
onClick={() => commit(t)}
disabled={submittingId === t.id}
className="ml-auto px-4 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
className="ml-auto px-4 py-2 text-sm rounded bg-brand-600 text-white disabled:opacity-50"
>
{submittingId === t.id ? "Checking in…" : `Check In ${qty}`}
</button>
@@ -1175,7 +1194,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div key={opt.id} className="border rounded-xl overflow-hidden">
<div className="px-4 py-2 bg-gray-50 border-b font-medium text-sm">
{opt.name}
{opt.isMainTicket && <span className="ml-1.5 text-xs text-blue-600 font-normal"> Main</span>}
{opt.isMainTicket && <span className="ml-1.5 text-xs text-brand-600 font-normal"> Main</span>}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
@@ -1424,7 +1443,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
className={`flex-1 py-2 rounded border text-sm font-medium ${
channel === c
? c === "whatsapp" ? "bg-green-600 text-white border-green-600"
: c === "both" ? "bg-indigo-600 text-white border-indigo-600"
: c === "both" ? "bg-brand-600 text-white border-brand-600"
: "bg-blue-600 text-white border-blue-600"
: "bg-white text-gray-700 border-gray-300 hover:bg-gray-50"
}`}
@@ -1472,7 +1491,7 @@ function SendTicketsModal({ open, onClose, token, registration, setError, setInf
type="button"
onClick={send}
disabled={sending}
className="flex-1 py-2 rounded bg-indigo-600 text-white text-sm font-medium disabled:opacity-60"
className="flex-1 py-2 rounded bg-brand-600 text-white text-sm font-medium disabled:opacity-60"
>
{sending ? "Sending…" : "Send Tickets"}
</button>
@@ -1719,12 +1738,12 @@ function DoorRefundPanel({ token, eventId, setError, setInfo }: any) {
<div className="border rounded p-3 text-center">
<div className="text-xs text-gray-500">TOTAL</div>
<div className="font-semibold">
R {(selectedReg.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
? Number(o.priceSnapshot)
: (o.eventOption?.price || o.price || 0);
return s + price * (o.quantity || 0);
}, 0).toFixed(2)}
}, 0)).toFixed(2)}
</div>
</div>
<div className="border rounded p-3 text-center bg-red-50">
@@ -1872,7 +1891,7 @@ function NewAttendeeModal({ open, onClose, seed, onConfirm }: {
className={`flex-1 py-2 transition-colors ${
notifPref === p
? p === "whatsapp" ? "bg-green-600 text-white"
: p === "both" ? "bg-indigo-600 text-white"
: p === "both" ? "bg-brand-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Mail, Users, Zap, Clock } from "lucide-react";
type Attendee = { id: string; name: string; email: string; pref: string };
@@ -84,7 +85,7 @@ function AttendeesCheckboxDropdown({
<div className="flex gap-1 flex-wrap">
<button
type="button"
className="text-xs px-2 py-0.5 rounded border border-indigo-300 text-indigo-700 hover:bg-indigo-50"
className="text-xs px-2 py-0.5 rounded border border-brand-300 text-brand-700 hover:bg-brand-50"
onClick={() => onChange(attendees.filter(a => prefMatch(a.pref)).map(a => a.id))}
>
Select {channel === "email" ? "Email/both" : "WhatsApp/both"}
@@ -114,7 +115,7 @@ function AttendeesCheckboxDropdown({
<span className="truncate flex-1">
{a.name ? `${a.name} <${a.email}>` : a.email}
</span>
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-indigo-700 bg-indigo-50" : "text-amber-700 bg-amber-50"}`}>
<span className={`text-[10px] px-1 rounded shrink-0 ${match ? "text-brand-700 bg-brand-50" : "text-amber-700 bg-amber-50"}`}>
{prefLabel(a.pref)}
</span>
</label>
@@ -260,7 +261,7 @@ function EmailAttendeesPageInner() {
};
// Scheduled jobs state
type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; hasHtml?: boolean; hasText?: boolean };
type ScheduledJob = { id: string; kind: 'attendees'|'broadcast'|'unknown'; eventId?: string|null; broadcast?: boolean; channel?: string; recipient?: string|null; scheduledAt: string; createdAt: string; status: 'queued'|'sending'|'sent'|'error'; attempts: number; sentAt?: string|null; lastError?: string|null; subject?: string; html?: string; text?: string; hasHtml?: boolean; hasText?: boolean };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null);
@@ -273,7 +274,7 @@ function EmailAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails`, { authToken: token });
const res = await apiFetch<{jobs: ScheduledJob[]}>(`/api/scheduled-emails?channel=email`, { authToken: token });
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch (e) {
// ignore here; surfaces via UI when tab open
@@ -287,7 +288,7 @@ function EmailAttendeesPageInner() {
const openEdit = (job: ScheduledJob) => {
setEditing(job);
setEditSubject(job.subject || '');
setEditBody(''); // body not included in list; will let user set a new one if needed
setEditBody(job.html || job.text || '');
try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(''); }
};
@@ -479,10 +480,15 @@ function EmailAttendeesPageInner() {
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Email Attendees</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Mail className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Email Attendees</h1>
</div>
<div className="flex items-center gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
</div>
@@ -497,20 +503,24 @@ function EmailAttendeesPageInner() {
{/* Tabs like on Payments page */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'attendees' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="attendees" className="hidden" checked={tab==='attendees'} onChange={() => setTab('attendees')} />
<Users className="w-4 h-4" />
Attendees
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'automations' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="automations" className="hidden" checked={tab==='automations'} onChange={() => setTab('automations')} />
<Zap className="w-4 h-4" />
Automations
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'broadcasts' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="broadcasts" className="hidden" checked={tab==='broadcasts'} onChange={() => setTab('broadcasts')} />
<Mail className="w-4 h-4" />
Broadcasts
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'scheduled' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="emailTab" value="scheduled" className="hidden" checked={tab==='scheduled'} onChange={() => setTab('scheduled')} />
<Clock className="w-4 h-4" />
Scheduled
</label>
</div>
@@ -636,7 +646,7 @@ function EmailAttendeesPageInner() {
<div className="flex items-center flex-wrap gap-2">
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={onPreview}>Preview recipients</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={onSend}>{sending ? 'Sending…' : 'Send now'}</button>
<button type="button" disabled={sending} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={onSend}>{sending ? 'Sending…' : 'Send now'}</button>
<button type="button" disabled={sending || !scheduledAtLocal} className="px-3 py-1.5 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
@@ -776,7 +786,7 @@ Jane Doe <jane@example.com>
}
}}>Preview recipients</button>
<button type="button" className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={async () => {
<button type="button" className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={async () => {
try {
setError(null); setInfo(null);
if (!token) { setError('Not authenticated'); return; }
@@ -866,7 +876,7 @@ Jane Doe <jane@example.com>
{loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled items. Items sent more than a week ago are hidden.</div>
<div className="text-sm text-gray-600">No scheduled emails. Sent items are cleared 24 hours after sending.</div>
) : (
<ul className="divide-y border rounded">
{scheduled.map(job => (
@@ -876,6 +886,16 @@ Jane Doe <jane@example.com>
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-gray-50">{job.kind}</span>
<span className="truncate">{job.subject || '(no subject)'}</span>
</div>
<div className="text-xs text-gray-600 mt-1 truncate">
{(() => {
const body = (job.html || job.text || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (!body) return '(no content)';
return body.length > 80 ? body.slice(0, 80) + '…' : body;
})()}
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">To: {job.recipient || 'Unknown recipients'}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span>
<span className="mr-2">Scheduled: {(() => { try { return new Date(job.scheduledAt).toLocaleString(); } catch { return job.scheduledAt; } })()}</span>
@@ -915,7 +935,7 @@ Jane Doe <jane@example.com>
<input className="w-full border rounded px-3 py-2 text-sm" type="datetime-local" value={editWhen} onChange={e => setEditWhen(e.target.value)} />
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" onClick={saveEdit}>{savingEdit ? 'Saving…' : 'Save changes'}</button>
<button type="button" disabled={savingEdit} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" onClick={saveEdit}>{savingEdit ? 'Saving…' : 'Save changes'}</button>
<button type="button" className="px-3 py-1.5 text-sm rounded border bg-white hover:bg-gray-50" onClick={() => setEditing(null)}>Cancel</button>
</div>
</div>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Ticket } from "lucide-react";
// Format a Date (or date-like input) to the value expected by <input type="datetime-local">
// This returns local time (browser timezone) as YYYY-MM-DDTHH:mm
@@ -20,7 +21,7 @@ function toLocalDateTimeInputValue(input: string | number | Date | null | undefi
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 [open, setOpen] = React.useState(false);
const [saving, setSaving] = React.useState(false);
@@ -53,7 +54,7 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
try {
const tiers = rows
.filter((r) => !!r.deadline && String(r.price).trim() !== '')
.map((r, i) => ({ deadline: new Date(r.deadline).toISOString(), price: parseFloat(r.price), order: typeof r.order === 'number' ? r.order : i }))
.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()));
onSave(tiers);
} finally {
@@ -85,8 +86,8 @@ function EarlyBirdTiersEditor({ option, onSave }: { option: any; onSave: (tiers:
</ul>
)}
<div className="flex items-center gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addRow}>Add tier</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-60" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save tiers'}</button>
</div>
</div>
)}
@@ -202,9 +203,14 @@ function EventOptionsContent() {
return (
<div className="max-w-5xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Event options</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Event options</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard/supervisor/events')}>Back</button>
</div>
{!canView && (
@@ -280,7 +286,7 @@ function EventOptionsContent() {
<input className="border rounded px-3 py-2 text-sm" placeholder="Name" value={newOpt.name} onChange={e => setNewOpt({ ...newOpt, name: e.target.value })} />
<input className="border rounded px-3 py-2 text-sm" placeholder="Price" type="number" step="0.01" value={newOpt.price} onChange={e => setNewOpt({ ...newOpt, price: e.target.value })} />
<label className="text-sm flex items-center gap-2"><input type="checkbox" checked={newOpt.isMainTicket} onChange={e => setNewOpt({ ...newOpt, isMainTicket: e.target.checked })} /> Main ticket</label>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm">Create option</button>
<button onClick={createOption} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm">Create option</button>
</div>
</div>
</div>
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, resolveToApiOrigin } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Calendar } from "lucide-react";
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -86,7 +87,7 @@ function UploadImageButton({ onUploaded, label = "Upload image" }: { onUploaded:
} finally { setUploading(false); if (ref.current) ref.current.value = ""; }
}} />
<button type="button" onClick={() => ref.current?.click()} disabled={uploading}
className="px-3 py-1.5 text-sm rounded border border-indigo-200 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 disabled:opacity-50">
className="px-3 py-1.5 text-sm rounded border border-brand-200 bg-brand-50 text-brand-700 hover:bg-brand-100 disabled:opacity-50">
{uploading ? "Uploading…" : label}
</button>
</div>
@@ -120,7 +121,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded p-2">
<div className="flex items-center gap-1 mb-1.5">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium">{typeLabel(f.type)}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => mov(idx, -1)} disabled={idx === 0}></button>
@@ -149,7 +150,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
</ul>
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => add("text")}>+ Question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => add("text")}>+ Question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("statement")}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700" onClick={() => add("paragraph")}>+ Heading</button>
</div>
@@ -204,7 +205,7 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
</div>
);
})}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={add}>+ Add option</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={add}>+ Add option</button>
</div>
);
}
@@ -254,7 +255,7 @@ function VariantsEditor({ options, onChange }: { options: OptionDraft[]; onChang
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200 hover:bg-red-100 self-end" onClick={() => remVariant(vi)}></button>
</div>
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addVariant}>+ Add variant</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addVariant}>+ Add variant</button>
</div>
</div>
);
@@ -315,7 +316,7 @@ function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onCha
onChange={updated => { const c = opt.earlyBirdTiers.slice(); c[ti] = updated; updTiers(c); }}
onRemove={() => { const c = opt.earlyBirdTiers.slice(); c.splice(ti, 1); updTiers(c); }} />
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addTier}>+ Add tier</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
</div>
</div>
);
@@ -350,7 +351,7 @@ function EarlyBirdsEditor({ options, onChange }: { options: OptionDraft[]; onCha
onChange={updated => { const c = v.earlyBirdTiers.slice(); c[ti] = updated; updVTiers(c); }}
onRemove={() => { const c = v.earlyBirdTiers.slice(); c.splice(ti, 1); updVTiers(c); }} />
))}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={addTier}>+ Add tier</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={addTier}>+ Add tier</button>
</div>
);
})}
@@ -414,7 +415,7 @@ function SectionsDraftEditor({ sections, options, onChange }: {
</div>
</>
)}
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={create}>Create section</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
</div>
</div>
);
@@ -479,7 +480,7 @@ function SectionsManager({ eventId }: { eventId: string }) {
</label>
))}
</div>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={create}>Create section</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={create}>Create section</button>
</div>
</div>
);
@@ -501,7 +502,7 @@ function SectionRow({ sec, options, onDelete, onUpdate }: any) {
))}
</div>
<div className="flex gap-2">
<button type="button" className="text-xs px-2 py-1 rounded bg-blue-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-brand-600 text-white" onClick={() => onUpdate(sec.id, name, optIds)}>Save</button>
<button type="button" className="text-xs px-2 py-1 rounded bg-red-50 text-red-700 border border-red-200" onClick={onDelete}>Delete</button>
</div>
</div>
@@ -557,7 +558,7 @@ function AttachmentsManager({ eventId }: { eventId: string }) {
<ul className="space-y-1">
{items.map(it => (
<li key={it.id} className="flex items-center justify-between text-xs">
<a href={it.url} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline truncate max-w-xs">{it.originalName}</a>
<a href={it.url} target="_blank" rel="noreferrer" className="text-brand-600 hover:underline truncate max-w-xs">{it.originalName}</a>
<button type="button" className="text-red-600 hover:underline ml-2" onClick={() => del(it.id)}>Delete</button>
</li>
))}
@@ -671,7 +672,7 @@ function NotifyRecipientsPicker({ selected, onChange, disabled }: { selected: No
className="border rounded bg-white shadow-lg overflow-y-auto"
>
{visibleResults.map(u => (
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-indigo-50 cursor-pointer" onClick={() => add(u)}>
<li key={u.id} className="px-3 py-1.5 text-sm hover:bg-brand-50 cursor-pointer" onClick={() => add(u)}>
{u.name} <span className="text-xs text-gray-400">{u.email}</span>
</li>
))}
@@ -754,12 +755,14 @@ interface EventDraft {
title: string; description: string; startDate: string; endDate: string;
registrationDeadline: string; goLiveAt: string; price: string; picture: string;
redirectUrl: string; isActive: boolean; isHidden: boolean; requiresAuth: boolean;
requiresRegistration: boolean; contactName: string; contactPhone: string; contactEmail: string;
}
const blankDraft = (): EventDraft => ({
title: "", description: "", startDate: "", endDate: "",
registrationDeadline: "", goLiveAt: "", price: "", picture: "",
redirectUrl: "", isActive: true, isHidden: false, requiresAuth: true,
requiresRegistration: true, contactName: "", contactPhone: "", contactEmail: "",
});
const blankOptions = (): OptionDraft[] => [
@@ -790,6 +793,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
registrationDeadline: toLocalDT(ev.registrationDeadline), goLiveAt: toLocalDT(ev.goLiveAt),
price: String(ev.price ?? ""), picture: ev.picture || "", redirectUrl: ev.redirectUrl || "",
isActive: ev.isActive !== false, isHidden: !!ev.isHidden, requiresAuth: ev.requiresAuth !== false,
requiresRegistration: ev.requiresRegistration !== false,
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
} : blankDraft());
// ── options (with per-variant tiers) ──
@@ -872,6 +877,17 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// ── save helpers ──
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") {
for (const opt of options) {
// Build flat tier array: option-level + all variant tiers
@@ -1031,7 +1047,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const handleSave = async () => {
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 (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 (options.some(o => isPriceInvalid(o.price))) { setError("Every option needs a price (enter 0 for a free option)"); setStep(1); setPricingSubstep(0); return; }
}
@@ -1042,9 +1058,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
startDate: new Date(draft.startDate).toISOString(), endDate: new Date(draft.endDate).toISOString(),
registrationDeadline: draft.registrationDeadline ? new Date(draft.registrationDeadline).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,
redirectUrl: draft.redirectUrl?.trim().replace(/\s+/g, "-") || undefined,
requiresRegistration: draft.requiresRegistration,
contactName: draft.contactName || undefined,
contactPhone: draft.contactPhone || undefined,
contactEmail: draft.contactEmail || undefined,
};
if (mode === "edit") {
@@ -1114,10 +1134,11 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
const isOnLastSubstep = step !== 1 || pricingSubstep === 2;
// 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.
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
// 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;
// ── render ──
@@ -1140,7 +1161,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<button key={i} type="button"
onClick={clickable ? () => { setStep(i as StepIdx); if (i === 1) setPricingSubstep(0); } : undefined}
className={`px-3 py-1.5 text-xs rounded border whitespace-nowrap transition-colors ${
step === i ? "bg-indigo-600 text-white border-indigo-600"
step === i ? "bg-brand-600 text-white border-brand-600"
: clickable ? "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 cursor-pointer"
: "bg-white text-gray-400 border-gray-100 cursor-default"
}`}
@@ -1156,7 +1177,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<button key={i} type="button"
onClick={() => setPricingSubstep(i as PricingSubstep)}
className={`px-3 py-1 text-xs rounded border whitespace-nowrap transition-colors ${
pricingSubstep === i ? "bg-indigo-100 text-indigo-700 border-indigo-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
pricingSubstep === i ? "bg-brand-100 text-brand-700 border-brand-300 font-medium" : "bg-white text-gray-600 border-gray-200 hover:bg-gray-50"
}`}
>{s}</button>
))}
@@ -1188,11 +1209,40 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
<DTInput label="Registration Deadline (optional)" value={draft.registrationDeadline} onChange={v => upd({ registrationDeadline: v })} />
<DTInput label="Go Live At (optional)" value={draft.goLiveAt} onChange={v => upd({ goLiveAt: v })} hint="Leave blank to show immediately" />
</div>
<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 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>
<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" />}
@@ -1344,13 +1394,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{mode === "create" ? (
isLastStep && isOnLastSubstep ? (
<button type="button" disabled={saving} onClick={handleSave}
className="px-5 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 font-medium">
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Creating…" : "Create Event"}
</button>
) : (
<button type="button" disabled={nextDisabled} onClick={handleNext}
title={basicDetailsIncomplete ? "Fill in title, start, end date, and base price to continue" : optionsIncomplete ? "Every option needs a price before continuing" : undefined}
className="px-4 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-indigo-600">Next </button>
className="px-4 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 font-medium disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-brand-600">Next </button>
)
) : (
<>
@@ -1360,7 +1410,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
)}
<button type="button" disabled={saving || isClosed} onClick={handleSave}
title={isClosed ? "This event is closed — reopen it first to make changes" : undefined}
className="px-5 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 font-medium">
className="px-5 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 font-medium">
{saving ? "Saving…" : "Save Changes"}
</button>
</>
@@ -1379,7 +1429,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
const isInactive = ev.isActive === false;
const isClosed = ev.cashupStatus === "closed";
return (
<li className="border rounded-lg p-3 bg-white hover:bg-indigo-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
<li className="border rounded-lg p-3 bg-white hover:bg-brand-50/40 cursor-pointer transition-colors flex items-start justify-between gap-3" onClick={onEdit}>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{ev.title}</span>
@@ -1393,7 +1443,7 @@ function EventCard({ ev, onEdit }: { ev: any; onEdit: () => void }) {
{ev.price != null ? <span className="ml-2">R{Number(ev.price).toFixed(0)}</span> : ""}
</div>
</div>
<span className="text-xs text-indigo-600 shrink-0 mt-0.5">Edit </span>
<span className="text-xs text-brand-600 shrink-0 mt-0.5">Edit </span>
</li>
);
}
@@ -1461,14 +1511,19 @@ export default function ManageEventsPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Events</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
<div className="flex items-center justify-between mb-6 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Events</h1>
<p className="text-sm text-gray-500 mt-0.5">Manage and create events</p>
</div>
</div>
<div className="flex items-center gap-2">
<button type="button" className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
<button type="button" className="px-4 py-2 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700 shadow-sm font-medium" onClick={openCreate}>+ Add Event</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => router.push("/dashboard")}>Back</button>
</div>
</div>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
type FormFieldType = 'yes_no' | 'text' | 'date' | 'numeric' | 'statement' | 'paragraph';
@@ -55,7 +56,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
{fields.map((f, idx) => (
<li key={idx} className="bg-white border rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1 mb-2">
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-700 font-medium shrink-0">{typeLabel(f.type)}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700 font-medium shrink-0">{typeLabel(f.type)}</span>
<span className="text-xs text-gray-400 shrink-0">#{idx + 1}</span>
<div className="flex-1" />
<button type="button" className="text-gray-400 hover:text-gray-700 px-1" onClick={() => moveField(idx, -1)} disabled={idx === 0} title="Move up"></button>
@@ -109,7 +110,7 @@ function FormBuilder({ value, onChange }: { value: EventFormDef; onChange: (v: E
</ul>
)}
<div className="flex gap-2 flex-wrap">
<button type="button" className="text-xs px-3 py-1.5 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => addField('text')}>+ Add question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => addField('text')}>+ Add question</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('statement')}>+ Statement</button>
<button type="button" className="text-xs px-3 py-1.5 rounded bg-gray-200 text-gray-700 hover:bg-gray-300" onClick={() => addField('paragraph')}>+ Heading</button>
</div>
@@ -438,12 +439,17 @@ export default function FormsBrowserPage() {
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4 no-print">
<h1 className="text-2xl font-semibold">Forms</h1>
<div className="flex items-center justify-between mb-4 no-print flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<FileText className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Forms</h1>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard")}>Back</button>
{mode === 'view' && (
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700" onClick={onPrint} disabled={items.length === 0}>
<button className="px-3 py-1.5 text-sm rounded-lg bg-brand-600 text-white hover:bg-brand-700" onClick={onPrint} disabled={items.length === 0}>
Print forms
</button>
)}
@@ -459,7 +465,7 @@ export default function FormsBrowserPage() {
{/* Mode tabs */}
<div className="mb-4 flex items-center gap-2 no-print">
{(['view', 'fill', 'manage'] as const).map(m => (
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
<label key={m} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${mode === m ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200 hover:bg-gray-50'}`}>
<input type="radio" name="mode" value={m} className="hidden" checked={mode===m} onChange={() => setMode(m)} />
{m === 'view' ? 'View responses' : m === 'fill' ? 'Fill / edit responses' : 'Edit form structure'}
</label>
@@ -511,7 +517,7 @@ export default function FormsBrowserPage() {
onBlur={() => setTimeout(() => setUserDropdownOpen(false), 150)}
/>
{userId && (
<div className="text-xs text-indigo-600 mt-0.5 truncate max-w-xs">
<div className="text-xs text-brand-600 mt-0.5 truncate max-w-xs">
{allUsers.find(u => u.id === userId)?.name || userId}
<button className="ml-1 text-gray-400 hover:text-gray-600" onMouseDown={e => { e.preventDefault(); setUserId(""); setUserSearch(""); }}></button>
</div>
@@ -521,7 +527,7 @@ export default function FormsBrowserPage() {
{filteredUsers.map(u => (
<button
key={u.id}
className="w-full text-left px-3 py-2 text-sm hover:bg-indigo-50 flex flex-col"
className="w-full text-left px-3 py-2 text-sm hover:bg-brand-50 flex flex-col"
onMouseDown={e => { e.preventDefault(); setUserId(u.id); setUserSearch(u.name || u.email || u.id); setUserDropdownOpen(false); }}
>
<span className="font-medium truncate">{u.name || "(no name)"}</span>
@@ -538,7 +544,7 @@ export default function FormsBrowserPage() {
</div>
<div className="flex gap-2 mt-3">
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700"
onClick={() => search(false)}
disabled={loadingList}
>
@@ -634,7 +640,7 @@ export default function FormsBrowserPage() {
<div className="mt-3 flex gap-2">
<button
type="button"
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
onClick={saveForm}
disabled={formLoading}
>
@@ -719,7 +725,7 @@ export default function FormsBrowserPage() {
))}
<div className="flex gap-2">
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50"
onClick={saveResponses}
disabled={savingFill || mainTicketCount === 0}
>
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus } from "lucide-react";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -61,7 +62,12 @@ export default function ManualRegistrationPage() {
return (
<div className="max-w-xl">
<h1 className="text-xl font-semibold mb-4">Manual Registration</h1>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<UserPlus className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900">Manual Registration</h1>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-sm font-medium">Event ID</label>
@@ -96,7 +102,7 @@ export default function ManualRegistrationPage() {
</div>
<p className="text-xs text-gray-500">At least one of email or cell number is required. If no email is provided, a guest account is created automatically.</p>
{error && <p className="text-sm text-red-600">{error}</p>}
<button type="submit" disabled={busy} className="bg-blue-600 text-white rounded px-4 py-2 disabled:opacity-60">
<button type="submit" disabled={busy} className="bg-brand-600 hover:bg-brand-700 text-white rounded px-4 py-2 disabled:opacity-60">
{busy ? "Submitting..." : "Create"}
</button>
</form>
@@ -198,7 +204,7 @@ function AttendeeFormsSection({ registration, form, formsData, setFormsData }: {
</div>
))}
</div>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
<button className="mt-3 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={submitting || !canSubmit} onClick={submit}>{submitting ? 'Submitting…' : 'Submit forms'}</button>
</div>
);
}
@@ -6,6 +6,7 @@ import { useRouter } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { scoreUser } from "@/lib/fuzzyMatch";
import { useDismissingState } from "@/hooks/useDismissingState";
import { UserPlus, CreditCard } from "lucide-react";
// ─── Pricing helpers ─────────────────────────────────────────────────────────
@@ -125,7 +126,7 @@ export default function ManualRegistrationPage() {
const regOutstanding = useMemo(() => {
const map: Record<string, { totalDue: number; totalPaid: number; outstanding: number }> = {};
for (const r of allRegistrations) {
const totalDue = (r.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);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
@@ -337,9 +338,14 @@ export default function ManualRegistrationPage() {
return (
<div className="max-w-4xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Manual registration</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<UserPlus className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Manual registration</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
@@ -349,12 +355,14 @@ export default function ManualRegistrationPage() {
)}
<div className="mb-4 flex items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'register' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="register" className="hidden" checked={tab==='register'} onChange={() => setTab('register')} />
<UserPlus className="w-4 h-4" />
Register
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${tab === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="tab" value="payment" className="hidden" checked={tab==='payment'} onChange={() => setTab('payment')} />
<CreditCard className="w-4 h-4" />
Record Payment
</label>
</div>
@@ -395,7 +403,7 @@ export default function ManualRegistrationPage() {
Search existing user <span className="font-normal">(name, email or phone)</span>
</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Start typing to find a user…"
value={userQuery}
autoComplete="off"
@@ -414,7 +422,7 @@ export default function ManualRegistrationPage() {
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
@@ -443,13 +451,13 @@ export default function ManualRegistrationPage() {
<div className="grid gap-2">
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Full name"
value={guest.name}
onChange={e => setGuest({ ...guest, name: e.target.value })}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder={registerAsGuest ? "Email (optional for guest)" : "Email"}
type="email"
value={guest.email}
@@ -457,7 +465,7 @@ export default function ManualRegistrationPage() {
required={!registerAsGuest}
/>
<input
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Phone (optional)"
value={guest.phoneNumber}
onChange={e => {
@@ -491,7 +499,7 @@ export default function ManualRegistrationPage() {
className={`flex-1 py-2 transition-colors ${
notifPref === p
? p === "whatsapp" ? "bg-green-600 text-white border-green-600"
: p === "both" ? "bg-indigo-600 text-white"
: p === "both" ? "bg-brand-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
@@ -529,7 +537,7 @@ export default function ManualRegistrationPage() {
return (
<div key={opt.id} className="border rounded overflow-hidden col-span-full sm:col-span-1">
<div className="px-3 py-2 bg-gray-50 border-b text-sm font-medium text-gray-800">
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-blue-600 font-normal"> Main</span> : null}
{opt.name}{opt.isMainTicket ? <span className="ml-1.5 text-xs text-brand-600 font-normal"> Main</span> : null}
</div>
{(opt.variants as any[]).map((v: any) => {
const unit = effectiveVariantUnit(opt, v);
@@ -586,7 +594,7 @@ export default function ManualRegistrationPage() {
<div className="flex items-center justify-between mt-6">
<div className="text-sm">Total due: <span className="font-semibold">R {totalDue.toFixed(2)}</span></div>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
<button disabled={submitting} onClick={submit} className="px-4 py-2 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create registration'}</button>
</div>
</>
)}
@@ -615,7 +623,7 @@ export default function ManualRegistrationPage() {
<div ref={paySearchRef} className="relative">
<label className="block text-xs text-gray-600 mb-1">User</label>
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Search by name, email or phone…"
value={payUserQuery}
autoComplete="off"
@@ -633,7 +641,7 @@ export default function ManualRegistrationPage() {
<button
key={u.id}
type="button"
className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors"
className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors"
onClick={() => selectPayUser(u)}
>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
@@ -669,7 +677,7 @@ export default function ManualRegistrationPage() {
</div>
</div>
<div className="mt-3">
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
<button disabled={paySubmitting} onClick={createManualPayment} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{paySubmitting ? 'Recording…' : 'Record payment'}</button>
</div>
</div>
</div>
+120 -87
View File
@@ -6,6 +6,42 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, DoorOpen, Wallet,
UserPlus, FileText, MessageCircle, BarChart2, Mail,
} from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
import { AreaTrendChart } from "@/components/charts/AreaTrendChart";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
const formatRand = (n: number) => `R ${(n || 0).toFixed(2)}`;
const formatRandAxis = (n: number) => `R${new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(n)}`;
const formatCount = (n: number) => (n || 0).toLocaleString();
const REPORTS_URL = "/dashboard/supervisor/reports";
const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/manual", label: "Manual registration", description: "Register a guest and issue tickets", icon: UserPlus },
{ href: "/dashboard/supervisor/events", label: "Manage events", description: "Create, edit, and update ticket types", icon: Calendar },
{ href: "/dashboard/supervisor/payments", label: "Payments & donations", description: "Manual payments and assignment", icon: Wallet },
{ href: "/dashboard/staff/ticket-scanning", label: "Open scanner", description: "Use your device camera to validate tickets", icon: QrCode },
{ href: "/dashboard/supervisor/at-the-door", label: "At the door", description: "Walk-ins, payments, ticket printing", icon: DoorOpen },
{ href: "/dashboard/supervisor/reports", label: "Reports", description: "View, export, and email reports", icon: BarChart2 },
{ href: "/dashboard/supervisor/forms", label: "Attendee forms", description: "View submitted attendee forms", icon: FileText },
{ href: "/dashboard/supervisor/email-attendees", label: "Email attendees", description: "Send a message to attendees of an event", icon: Mail },
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
type Overview = {
activeEvents: number;
registrations: OverviewMetric;
ticketsSold: OverviewMetric;
revenue: OverviewMetric;
donations: OverviewMetric;
trend: { date: string; revenue: number }[];
topEvents: { eventId: string; title: string; revenue: number; registrations: number; ticketsSold: number }[];
};
export default function SupervisorDashboardPage() {
const { user, loading, token } = useAuth();
@@ -21,16 +57,13 @@ export default function SupervisorDashboardPage() {
if (!user) router.replace("/login");
}, [user, loading, router]);
// Everything this dashboard displays comes from one endpoint (/api/stats/supervisor)
// that computes it all server-side — no more separate calls plus a full payments/events
// pull just to reduce them down to a couple of numbers client-side.
// useStableState skips re-renders when a poll returns identical data, and hasLoadedOnce
// below means "Refreshing…" only shows on the very first load — together these stop the
// stats panels from flickering on every 15s poll.
// /api/stats/supervisor covers scan activity + today's payments (unchanged from before);
// /api/stats/overview is the month-over-month KPI/trend/top-events endpoint, shared
// with the admin dashboard since supervisors already have full revenue/donation visibility.
const [scanStats, setScanStats] = useStableState<any | null>(null);
const [paymentStats, setPaymentStats] = useStableState<any | null>(null);
const [activeEventsCount, setActiveEventsCount] = useStableState<number>(0);
const [recentScans, setRecentScans] = useStableState<any[]>([]);
const [overview, setOverview] = useStableState<Overview | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const hasLoadedOnce = useRef(false);
@@ -39,11 +72,14 @@ export default function SupervisorDashboardPage() {
const isFirstLoad = !hasLoadedOnce.current;
try {
if (isFirstLoad) setLoadingStats(true);
const data = await apiFetch<any>("/api/stats/supervisor", { authToken: token });
const [data, overviewData] = await Promise.all([
apiFetch<any>("/api/stats/supervisor", { authToken: token }),
apiFetch<Overview>("/api/stats/overview", { authToken: token }),
]);
setScanStats(data.scanStats);
setPaymentStats(data.paymentStats);
setActiveEventsCount(data.activeEventsCount || 0);
setRecentScans(Array.isArray(data.recentScans) ? data.recentScans : []);
setOverview(overviewData);
} catch (e) {
// ignore
} finally {
@@ -65,14 +101,10 @@ export default function SupervisorDashboardPage() {
}, 15000, !!token);
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Supervisor Dashboard{user ? ` ${user.name}` : ""}</h1>
<div className="hidden sm:flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Manual registration</button>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events</button>
<button className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Payments</button>
</div>
<div className="max-w-6xl mx-auto w-full overflow-x-hidden">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Welcome back{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events today.</p>
</div>
{!canView && (
@@ -81,52 +113,63 @@ export default function SupervisorDashboardPage() {
</div>
)}
<StatCardRow>
<StatCard icon={Calendar} label="Active events" value={overview ? formatCount(overview.activeEvents) : "—"} tone="brand" href="/dashboard/supervisor/events" linkLabel="View all events" />
<StatCard icon={Banknote} label="Revenue (past month)" value={overview ? formatRand(overview.revenue.thisMonth) : "—"} tone="green" href={`${REPORTS_URL}?report=revenue&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.revenue.pctChange } : undefined} />
<StatCard icon={Gift} label="Donations (past month)" value={overview ? formatRand(overview.donations.thisMonth) : "—"} tone="rose" href={`${REPORTS_URL}?report=donations&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.donations.pctChange } : undefined} />
<StatCard icon={Users} label="Registrations (past month)" value={overview ? formatCount(overview.registrations.thisMonth) : "—"} tone="blue" href={`${REPORTS_URL}?report=regStatus&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.registrations.pctChange } : undefined} />
<StatCard icon={Ticket} label="Tickets sold (past month)" value={overview ? formatCount(overview.ticketsSold.thisMonth) : "—"} tone="amber" href={`${REPORTS_URL}?report=usage&range=trailing_month`} linkLabel="View report" delta={overview ? { value: overview.ticketsSold.pctChange } : undefined} />
</StatCardRow>
<div className="border rounded-xl p-4 bg-white shadow-sm my-6">
<div className="text-lg font-semibold mb-3">Quick actions</div>
<QuickActionGrid>
{QUICK_ACTIONS.map(a => (
<QuickActionTile key={a.href} icon={a.icon} title={a.label} description={a.description} href={a.href} />
))}
</QuickActionGrid>
</div>
<div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="text-lg font-semibold mb-2">Quick actions</div>
<div className="grid sm:grid-cols-3 gap-3">
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/manual")}>Create manual registration
<div className="text-xs text-white/90">Register a guest and issue tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/events")}>Manage events
<div className="text-xs text-white/90">Create, edit, and update ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/sections")}>Manage sections
<div className="text-xs text-white/90">Create sections and assign ticket types</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/payments")}>Record payment / donations
<div className="text-xs text-white/90">Manual payments and assignment</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/ticket-scanning")}>Open scanner
<div className="text-xs text-white/90">Use your device camera to validate tickets</div>
</button>
<button className="rounded-lg p-3 text-left bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/staff/event-tickets")}>Event tickets & printing
<div className="text-xs text-white/90">Browse event tickets and print lists</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/at-the-door")}>At the door
<div className="text-xs text-white/90">Walk-ins, payments, ticket printing</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/reports")}>
Reports
<div className="text-xs text-white/90">View, export, and email reports</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/forms") }>
Attendee forms
<div className="text-xs text-white/90">View submitted attendee forms</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/email-attendees") }>
Email attendees
<div className="text-xs text-white/90">Send message to attendees of an event</div>
</button>
<button className="rounded-lg p-3 text-left bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/supervisor/whatsapp-attendees") }>
WhatsApp attendees
<div className="text-xs text-white/90">Send WhatsApp message to event attendees</div>
</button>
</div>
<div className="lg:col-span-2 space-y-6 min-w-0">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Revenue trend past month</h2>
{overview && overview.trend.length > 0 ? (
<AreaTrendChart data={overview.trend.map(t => ({ label: t.date.slice(5), value: t.revenue }))} valueFormatter={formatRand} axisFormatter={formatRandAxis} />
) : (
<div className="text-sm text-gray-400">No revenue recorded in the past month.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm mb-6">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Top performing events</h2>
{overview && overview.topEvents.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>Event</TableHead>
<TableHead className="text-right">Registrations</TableHead>
<TableHead className="text-right">Revenue</TableHead>
<TableHead className="text-right">Tickets sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{overview.topEvents.map(e => (
<TableRow key={e.eventId}>
<TableCell className="font-medium max-w-[140px] sm:max-w-[220px] truncate" title={e.title}>{e.title}</TableCell>
<TableCell className="text-right">{formatCount(e.registrations)}</TableCell>
<TableCell className="text-right">{formatRand(e.revenue)}</TableCell>
<TableCell className="text-right">{formatCount(e.ticketsSold)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-sm text-gray-400">No event revenue recorded yet.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
@@ -138,21 +181,23 @@ export default function SupervisorDashboardPage() {
<div className="font-medium">{u.ticket?.event?.title || u.ticket?.eventId || 'Event'}</div>
<div className="text-xs text-gray-500">{new Date(u.scannedAt).toLocaleString()}</div>
</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0,8)}</div>
<div className="text-xs text-gray-600">{u.ticket?.registrationOption?.eventOption?.name || 'Ticket'} #{String(u.ticket?.id || '').slice(0, 8)}</div>
<div className="text-xs text-gray-500">Scanned by: {u.scannedBy?.name || u.scannedById}</div>
</li>
))}
{recentScans.length === 0 && <li className="text-gray-500">No scans yet.</li>}
</ul>
</div>
</div>
<div className="space-y-6 min-w-0">
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Scanner activity</h2>
{loadingStats && <span className="text-xs text-gray-500">Refreshing</span>}
</div>
{scanStats ? (
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="grid grid-cols-3 gap-2">
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Today</div>
<div className="text-lg font-semibold">{scanStats.totalToday}</div>
@@ -169,9 +214,21 @@ export default function SupervisorDashboardPage() {
) : (
<div className="text-sm text-gray-500">No scanner data yet.</div>
)}
</div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Payments today</h2>
{paymentStats ? (
<div className="border rounded-lg p-3 flex items-center justify-between">
<div className="text-sm text-gray-500">Today</div>
<div className="text-base font-semibold">{formatRand(paymentStats.totalToday)}</div>
</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="mt-4">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
@@ -185,30 +242,6 @@ export default function SupervisorDashboardPage() {
)}
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Supervisor stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
@@ -6,6 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers, fetchAllPayments } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { scoreUser } from "@/lib/fuzzyMatch";
import { Wallet, RotateCcw, HandHeart, ScanLine, Link2 } from "lucide-react";
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -85,7 +86,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
return (
<div ref={ref} className="relative">
<input
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400 disabled:bg-gray-50 pr-7"
className="w-full border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 disabled:bg-gray-50 pr-7"
placeholder={placeholder}
value={query}
disabled={disabled}
@@ -102,7 +103,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
<>
<div className="px-3 py-1.5 text-[11px] text-gray-400 bg-gray-50 border-b">{matches.length} result{matches.length !== 1 ? "s" : ""}</div>
{matches.map(u => (
<button key={u.id} type="button" className="w-full text-left px-3 py-2.5 hover:bg-indigo-50 border-b border-gray-100 last:border-b-0 transition-colors" onClick={() => { onChange(String(u.id)); setQuery(u.name || ""); setOpen(false); }}>
<button key={u.id} type="button" className="w-full text-left px-3 py-2.5 hover:bg-brand-50 border-b border-gray-100 last:border-b-0 transition-colors" onClick={() => { onChange(String(u.id)); setQuery(u.name || ""); setOpen(false); }}>
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex gap-2 flex-wrap">
{u.email && <span>{u.email}</span>}
@@ -186,42 +187,25 @@ function PaymentsContent() {
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 () => {
if (!token) return;
try {
setLoadingRegs(true);
const regs = await apiFetch<any[]>("/api/registrations", { authToken: token });
const list = Array.isArray(regs) ? regs : [];
// Compute initial totalDue using priceSnapshot (authoritative backend price)
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) {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
baseMap[r.id] = { totalDue, totalPaid: 0, outstanding: totalDue };
// Backend attaches a tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) — fall back to the old client-side estimate otherwise.
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, now) * (opt.quantity || 0), 0);
const totalPaid = (r.payments || []).reduce((s: number, p: any) => s + (p.amount || 0), 0);
map[r.id] = { totalDue, totalPaid, outstanding: Math.max(0, totalDue - totalPaid) };
}
setRegOutstanding(baseMap);
// 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
}
})
);
setRegOutstanding(map);
// Sort by createdAt desc
const sorted = list.sort((a,b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
setRegistrations(sorted);
@@ -448,8 +432,7 @@ function PaymentsContent() {
});
setInfo(`Payment created (R ${amt.toFixed(2)})`);
setAmount(""); setRegistrationId(""); setIsDonation(false); setEventId(""); setPaidAtLocal("");
await loadPayments();
await loadRegistrations();
await Promise.all([loadPayments(), loadRegistrations()]);
} catch (e: any) {
setError(e?.message || "Failed to create payment");
} finally {
@@ -531,9 +514,7 @@ function PaymentsContent() {
setInfo('Reconciled as donation');
}
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
await loadYocoUnreconciled();
await loadPayments();
await loadRegistrations();
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
} catch (e: any) {
setError(e?.message || 'Failed to reconcile');
} finally {
@@ -545,9 +526,14 @@ function PaymentsContent() {
return (
<div className="max-w-6xl mx-auto w-full p-4 sm:p-6 overflow-x-hidden">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Payments</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Wallet className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Payments</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
{!canView && (
@@ -560,24 +546,29 @@ function PaymentsContent() {
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="mb-4 flex flex-wrap items-center gap-2">
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'payment' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="payment" className="hidden" checked={mode==='payment'} onChange={() => setMode('payment')} />
<Wallet className="w-4 h-4" />
Payment
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'refund' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="refund" className="hidden" checked={mode==='refund'} onChange={() => setMode('refund')} />
<RotateCcw className="w-4 h-4" />
Refund
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'donation' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="donation" className="hidden" checked={mode==='donation'} onChange={() => setMode('donation')} />
<HandHeart className="w-4 h-4" />
Donations
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'reconcile' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="reconcile" className="hidden" checked={mode==='reconcile'} onChange={() => setMode('reconcile')} />
<ScanLine className="w-4 h-4" />
Reconcile
</label>
<label className={`px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
<Link2 className="w-4 h-4" />
Payment Link
</label>
</div>
@@ -617,7 +608,7 @@ function PaymentsContent() {
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
<td className="py-2 pr-3">{tx.methodType || '-'}</td>
<td className="py-2 flex flex-wrap gap-2">
<button className="px-2 py-1 rounded bg-indigo-600 text-white hover:bg-indigo-700" onClick={() => openReconcileToRegistration(tx)}>To Registration</button>
<button className="px-2 py-1 rounded bg-brand-600 text-white hover:bg-brand-700" onClick={() => openReconcileToRegistration(tx)}>To Registration</button>
<button className="px-2 py-1 rounded bg-emerald-600 text-white hover:bg-emerald-700" onClick={() => openReconcileAsDonation(tx)}>As Donation</button>
<button className="px-2 py-1 rounded bg-gray-600 text-white hover:bg-gray-700" onClick={async () => {
if (!token) return;
@@ -657,7 +648,7 @@ function PaymentsContent() {
</select>
</div>
<div className="flex gap-2">
<button className="px-3 py-1.5 rounded bg-indigo-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.registrationId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
<button className="px-3 py-1.5 rounded bg-brand-600 text-white text-sm disabled:opacity-50" disabled={!reconcileForm.registrationId || reconcileForm.submitting} onClick={submitReconcile}>{reconcileForm.submitting ? 'Saving…' : 'Confirm'}</button>
<button className="px-3 py-1.5 rounded bg-gray-200 text-gray-800 text-sm" onClick={cancelReconcile}>Cancel</button>
</div>
</div>
@@ -752,7 +743,7 @@ function PaymentsContent() {
</div>
</div>
<div className="mt-3">
<button disabled={submitting} onClick={createPayment} className="mt-2 w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create payment'}</button>
<button disabled={submitting} onClick={createPayment} className="mt-2 w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">{submitting ? 'Creating…' : 'Create payment'}</button>
</div>
</div>
)}
@@ -765,7 +756,7 @@ function PaymentsContent() {
usersList={usersList}
regsForUser={(uid:string)=> registrations.filter((r:any)=> String(r.user?.id||r.userId)===String(uid))}
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>}
</>
@@ -778,9 +769,13 @@ function PaymentsContent() {
allUsers={allUsers}
registrations={registrations}
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>}
<AssignedDonationsList
payments={payments}
onDone={async()=>{ await Promise.all([loadPayments(), loadRegistrations()]); setInfo('Donation unassigned'); }}
/>
</>
)}
@@ -806,7 +801,7 @@ function PaymentsContent() {
</div>
</div>
<div className="mt-3">
<button disabled={linkGenerating || !linkRegistrationId} onClick={generatePaymentLink} className="w-full px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 shadow-sm">
<button disabled={linkGenerating || !linkRegistrationId} onClick={generatePaymentLink} className="w-full px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm">
{linkGenerating ? 'Generating…' : 'Generate link'}
</button>
</div>
@@ -816,7 +811,7 @@ function PaymentsContent() {
<div className="text-sm break-all font-mono bg-white border rounded px-2 py-1.5">{linkResult.redirectUrl}</div>
<div className="flex flex-wrap gap-2 pt-1">
<button onClick={copyPaymentLink} className="px-3 py-1.5 text-xs rounded bg-gray-200 hover:bg-gray-300 text-gray-800">{linkCopied ? 'Copied!' : 'Copy link'}</button>
<button disabled={linkSending==='email'} onClick={() => sendPaymentLink('email')} className="px-3 py-1.5 text-xs rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50">{linkSending==='email' ? 'Sending…' : 'Send via Email'}</button>
<button disabled={linkSending==='email'} onClick={() => sendPaymentLink('email')} className="px-3 py-1.5 text-xs rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50">{linkSending==='email' ? 'Sending…' : 'Send via Email'}</button>
<button disabled={linkSending==='whatsapp'} onClick={() => sendPaymentLink('whatsapp')} className="px-3 py-1.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50">{linkSending==='whatsapp' ? 'Sending…' : 'Send via WhatsApp'}</button>
</div>
<div className="text-[10px] text-amber-700">Generating a new link for this registration will replace this one the old link will no longer be honored.</div>
@@ -1019,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 = {
payments: any[];
allUsers: any[];
@@ -1203,7 +1336,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
<button
disabled={!paymentId || assigning || !(amountNum > 0)}
onClick={assign}
className="mt-2 px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50 shadow-sm"
className="mt-2 px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50 shadow-sm"
>
{assigning ? 'Assigning…' : 'Assign donation'}
</button>
@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { Suspense } from "react";
import { useRouter } from "next/navigation";
import ReportsV2 from "@/components/reports/ReportsV2";
@@ -8,7 +8,9 @@ export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="w-full p-6">
<ReportsV2 onBack={() => router.push('/dashboard')} />
<Suspense fallback={<div className="text-sm text-gray-400">Loading</div>}>
<ReportsV2 onBack={() => router.push('/dashboard')} />
</Suspense>
</div>
);
}
@@ -5,11 +5,19 @@ import { useAuth } from "@/hooks/useAuth";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, fetchAllUsers } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { MessageCircle, Users, Zap, Send, Clock, type LucideIcon } from "lucide-react";
// Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string };
type UserEntry = { id: string; name: string; phone: string; pref: string };
const WA_TAB_ICONS: Record<"attendees" | "automations" | "broadcasts" | "scheduled", LucideIcon> = {
attendees: Users,
automations: Zap,
broadcasts: Send,
scheduled: Clock,
};
function toLocalInputValue(d: Date) {
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
@@ -534,7 +542,7 @@ function WhatsAppAttendeesPageInner() {
};
// ── Scheduled tab ──────────────────────────────────────────────────────────
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; payload?: any };
type ScheduledJob = { id: string; kind: string; eventId?: string | null; broadcast?: boolean; channel?: string; recipient?: string | null; scheduledAt: string; createdAt: string; status: string; attempts: number; sentAt?: string | null; lastError?: string | null; subject?: string; message?: string };
const [scheduled, setScheduled] = useState<ScheduledJob[]>([]);
const [loadingScheduled, setLoadingScheduled] = useState(false);
const [editing, setEditing] = useState<ScheduledJob | null>(null);
@@ -546,10 +554,8 @@ function WhatsAppAttendeesPageInner() {
try {
if (!token) return;
setLoadingScheduled(true);
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails`, { authToken: token });
// Filter to only WhatsApp jobs
const all = Array.isArray(res?.jobs) ? res.jobs : [];
setScheduled(all.filter((j) => j.channel === "whatsapp" || (j.broadcast && j.channel === "whatsapp")));
const res = await apiFetch<{ jobs: ScheduledJob[] }>(`/api/scheduled-emails?channel=whatsapp`, { authToken: token });
setScheduled(Array.isArray(res?.jobs) ? res.jobs : []);
} catch { } finally { setLoadingScheduled(false); }
};
@@ -562,7 +568,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; }
const body: any = {};
if (editWhen) body.scheduledAt = new Date(editWhen).toISOString();
if (editMessage.trim()) body.text = editMessage;
if (editMessage.trim()) body.message = editMessage;
await apiFetch(`/api/scheduled-emails/${encodeURIComponent(editing.id)}`, { method: "PATCH", authToken: token, body });
setInfo("Scheduled message updated.");
setEditing(null);
@@ -590,9 +596,14 @@ function WhatsAppAttendeesPageInner() {
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">WhatsApp Attendees</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<MessageCircle className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">WhatsApp Attendees</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push("/dashboard")}>
Back
</button>
</div>
@@ -608,12 +619,16 @@ function WhatsAppAttendeesPageInner() {
{/* Tabs */}
<div className="mb-4 flex items-center gap-2 flex-wrap">
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => (
<label key={t} className={`px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
{t.charAt(0).toUpperCase() + t.slice(1)}
</label>
))}
{(["attendees", "automations", "broadcasts", "scheduled"] as const).map((t) => {
const Icon = WA_TAB_ICONS[t];
return (
<label key={t} className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border cursor-pointer ${tab === t ? "bg-green-600 text-white border-green-600" : "bg-white text-gray-800 border-gray-200"}`}>
<input type="radio" name="waTab" value={t} className="hidden" checked={tab === t} onChange={() => setTab(t)} />
<Icon className="w-4 h-4" />
{t.charAt(0).toUpperCase() + t.slice(1)}
</label>
);
})}
</div>
{/* ── Attendees Tab ── */}
@@ -876,7 +891,7 @@ function WhatsAppAttendeesPageInner() {
{loadingScheduled ? (
<div className="text-sm text-gray-600">Loading</div>
) : scheduled.length === 0 ? (
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Items sent more than a week ago are hidden.</div>
<div className="text-sm text-gray-600">No scheduled WhatsApp messages. Sent items are cleared 24 hours after sending.</div>
) : (
<ul className="divide-y border rounded">
{scheduled.map((job) => (
@@ -884,7 +899,10 @@ function WhatsAppAttendeesPageInner() {
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900 flex items-center gap-2">
<span className="inline-block px-2 py-0.5 text-xs rounded border bg-green-50 text-green-700">{job.broadcast ? "broadcast" : "attendees"}</span>
<span className="truncate text-gray-700">{job.payload?.message ? String(job.payload.message).slice(0, 60) + (String(job.payload.message).length > 60 ? "…" : "") : "(no message)"}</span>
<span className="truncate text-gray-700">{job.message ? String(job.message).slice(0, 60) + (String(job.message).length > 60 ? "…" : "") : "(no message)"}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">To: {job.recipient || "Unknown recipients"}</span>
</div>
<div className="text-xs text-gray-600 mt-1">
<span className="mr-2">Status: {job.status}</span>
@@ -895,7 +913,7 @@ function WhatsAppAttendeesPageInner() {
</div>
<div className="flex items-center gap-2 shrink-0">
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50"
onClick={() => { setEditing(job); setEditMessage(job.payload?.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
onClick={() => { setEditing(job); setEditMessage(job.message || ""); try { setEditWhen(toLocalInputValue(new Date(job.scheduledAt))); } catch { setEditWhen(""); } }}>
Edit
</button>
<button type="button" disabled={job.status !== "queued"} className="px-2 py-1 text-xs rounded border bg-white hover:bg-gray-50 disabled:opacity-50" onClick={() => removeJob(job)}>
+35 -23
View File
@@ -3,6 +3,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { HandHeart } from "lucide-react";
export default function DonatePage() {
const { token } = useAuth();
@@ -57,32 +58,43 @@ export default function DonatePage() {
return (
<div className="max-w-xl mx-auto w-full p-6">
<h1 className="text-2xl font-semibold mb-4">Make a donation</h1>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3">{info}</p>}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-xl bg-rose-50 flex items-center justify-center shrink-0">
<HandHeart className="w-5 h-5 text-rose-600" />
</div>
<div>
<h1 className="text-2xl font-semibold text-gray-900">Make a donation</h1>
<p className="text-sm text-gray-500">Support an event or the ministry directly.</p>
</div>
</div>
<label className="block text-sm font-medium mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 mb-3" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<div className="border rounded-xl p-5 bg-white shadow-sm">
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded px-3 py-2 mb-1"
/>
<p className="text-xs text-gray-600 mb-3">Minimum donation is R15.</p>
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
<select className="w-full border rounded-lg px-3 py-2 mb-4 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400" value={eventId} onChange={(e)=>setEventId(e.target.value)}>
{events.map(ev => <option key={ev.id} value={ev.id}>{ev.title}</option>)}
</select>
<button
disabled={loading}
onClick={submit}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
>{loading?"Starting checkout...":"Donate"}</button>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
<input
type="number"
min="15"
step="1"
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded-lg px-3 py-2 mb-1 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
<p className="text-xs text-gray-500 mb-4">Minimum donation is R15.</p>
<button
disabled={loading}
onClick={submit}
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
>{loading?"Starting checkout...":"Donate"}</button>
</div>
</div>
);
}
+10 -4
View File
@@ -4,6 +4,7 @@ import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { FileText } from "lucide-react";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -130,9 +131,14 @@ function FormsContent() {
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Attendee forms</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<FileText className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Attendee forms</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200" onClick={() => router.push('/dashboard/user')}>Back</button>
</div>
{loading && <div className="text-sm text-gray-600 mb-2">Loading</div>}
@@ -214,7 +220,7 @@ function FormsContent() {
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
+220 -107
View File
@@ -6,7 +6,9 @@ import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { QrImage } from "@/components/shared/QrImage";
import { ApiImage } from "@/components/shared/ApiImage";
import { useDismissingState } from "@/hooks/useDismissingState";
import { ClipboardList, Calendar, Ticket, ChevronRight } from "lucide-react";
// Helper formatters
const formatRand = (n: number) => `R ${n.toFixed(2)}`;
@@ -87,7 +89,9 @@ export default function UserDashboardPage() {
// Registration details modal
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
const [activeEventHasForm, setActiveEventHasForm] = useState<boolean | null>(null);
@@ -138,12 +142,11 @@ export default function UserDashboardPage() {
setRegistrations(myRegs);
setTickets(myTicks);
// Compute totalDue from priceSnapshot (authoritative backend price, variant-aware).
// priceSnapshot is set at registration time and refreshed before each payment.
const now = new Date();
// Use the backend's tranche-aware totalDueComputed (exact even when a line spans
// multiple early-bird prices) rather than re-deriving from priceSnapshot client-side.
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
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: [] };
}
setBilling(totals);
@@ -154,8 +157,8 @@ export default function UserDashboardPage() {
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, 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);
// totalDueComputed is not time-dependent, no need to recompute per payment time
const totalDue = r.totalDueComputed ?? (r.registrationOptions || []).reduce((sum: number, opt: any) => sum + optionUnitPrice(opt, null, new Date()) * (opt.quantity || 0), 0);
const outstanding = Math.max(0, totalDue - totalPaid);
setBilling(prev => ({
...prev,
@@ -252,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[]) => {
if (!token || ticketIds.length === 0) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
@@ -292,26 +275,60 @@ export default function UserDashboardPage() {
}
};
const whatsappRegistration = async (registrationId: string) => {
const sendInvoice = async (registrationId: string, channel: 'email' | 'whatsapp') => {
if (!token) return;
if (!user?.phoneNumber) { setError("No phone number on your account. Add one in your profile."); return; }
setError(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 {
const res: any = await apiFetch("/api/tickets/email", {
const res: any = await apiFetch(`/api/registrations/${encodeURIComponent(registrationId)}/send-invoice`, {
method: "POST",
body: { registrationId, channel: "whatsapp" },
body: { channel },
authToken: token,
});
const msg = (res && res.message) ? res.message : `Tickets sent to WhatsApp.`;
setDialog({ open: true, message: msg, loading: false });
setDialog({ open: true, loading: false, title: "Invoice sent", message: (res && res.message) || "Invoice sent." });
} catch (e: any) {
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 —
// choosing a partial amount is only available from the supervisor payments dashboard.
const payNow = async (registrationId: string) => {
@@ -636,11 +653,10 @@ export default function UserDashboardPage() {
const myTicks = await apiFetch<any[]>("/api/tickets/mytickets", { authToken: token });
setTickets(myTicks);
} catch {}
// Recompute billing totals using priceSnapshot
const now2 = new Date();
// Recompute billing totals using the backend's tranche-aware totalDueComputed
const totals: Record<string, { totalDue: number; totalPaid: number; outstanding: number; payments: any[] }> = {};
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: [] };
}
setBilling(totals);
@@ -672,20 +688,23 @@ export default function UserDashboardPage() {
return (
<div className="max-w-6xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-4">
<h1 className="text-2xl font-semibold">Welcome{user ? `, ${user.name}` : ""}</h1>
<div className="max-w-6xl mx-auto w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-6">
<div>
<h1 className="text-2xl font-semibold text-gray-900">Welcome{user ? `, ${user.name}` : ""} 👋</h1>
<p className="text-sm text-gray-500 mt-1">Here&apos;s what&apos;s happening with your events.</p>
</div>
<div className="flex flex-wrap gap-2">
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/reset-password")}
>Reset password</button>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button>
<button
className="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
</div>
@@ -695,16 +714,21 @@ export default function UserDashboardPage() {
{loading && <p className="text-gray-500 text-sm mb-3">Loading</p>}
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xl font-semibold">My Registrations</h2>
<label className="text-sm flex items-center gap-2">
<section className="mb-6 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<ClipboardList className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Registrations</h2>
</div>
<label className="text-sm flex items-center gap-2 text-gray-600">
<input type="checkbox" checked={showPast} onChange={e => setShowPast(e.target.checked)} />
<span>Show past events</span>
</label>
</div>
<p className="text-sm text-gray-600 mb-3 italic">
Click on registration to edit or make payment
<p className="text-sm text-gray-500 mb-3">
Click on a registration to edit or make a payment
</p>
{registrations.length === 0 ? (
<p className="text-gray-600 text-sm">No registrations yet.</p>
@@ -722,16 +746,20 @@ export default function UserDashboardPage() {
return (
<li
key={r.id}
className={"border rounded p-3 cursor-pointer " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50")}
className={"border rounded-xl p-3 cursor-pointer flex items-start gap-3 " + (isCancelled ? "opacity-60 bg-gray-50" : "hover:bg-gray-50 hover:border-brand-200")}
onClick={() => setActiveRegId(r.id)}
>
<div className="flex items-start justify-between gap-3">
<div>
<ApiImage src={r.event?.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="flex items-start justify-between gap-3 flex-1 min-w-0">
<div className="min-w-0">
<div className="font-medium flex items-center gap-2">
<span>{r.event?.title || r.eventId}</span>
<span className="truncate">{r.event?.title || r.eventId}</span>
<EventStatusBadge event={r.event} />
</div>
<div className="text-xs text-gray-600">
{r.event?.startDate && (
<div className="text-xs text-gray-500 mt-0.5">{formatDate(new Date(r.event.startDate))}</div>
)}
<div className="text-xs text-gray-600 mt-1">
Status: <RegistrationStatusBadge status={r.status} />
</div>
{(() => {
@@ -788,6 +816,7 @@ export default function UserDashboardPage() {
)}
</div>
</div>
<ChevronRight className="w-4 h-4 text-gray-300 shrink-0 self-center hidden sm:block" />
</li>
);
})}
@@ -795,16 +824,24 @@ export default function UserDashboardPage() {
)}
</section>
<section className="mb-8">
<h2 className="text-xl font-semibold mb-2">Upcoming Events</h2>
<div className="grid lg:grid-cols-2 gap-6">
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">Upcoming Events</h2>
</div>
{upcomingEvents.length === 0 ? (
<p className="text-gray-600 text-sm">No upcoming events.</p>
) : (
<ul className="grid md:grid-cols-2 gap-3">
<ul className="space-y-3">
{upcomingEvents.map(ev => (
<li key={ev.id} className="border rounded p-4">
<li key={ev.id} className="border rounded-xl p-3 flex gap-3">
<ApiImage src={ev.picture} alt="" className="w-14 h-14 rounded-lg object-cover shrink-0 hidden sm:block" />
<div className="min-w-0 flex-1">
<div className="font-medium flex items-center gap-2">
<span>{ev.title}</span>
<span className="truncate">{ev.title}</span>
<EventStatusBadge event={ev} />
</div>
<div className="text-sm text-gray-600">{new Date(ev.startDate).toLocaleString()} - {new Date(ev.endDate).toLocaleString()}</div>
@@ -817,7 +854,7 @@ export default function UserDashboardPage() {
}}
>Print all tickets</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
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={() => {
const ids = tickets.filter(t => t.eventId === ev.id).map(t => t.id);
emailTickets(ids);
@@ -833,15 +870,21 @@ export default function UserDashboardPage() {
>WhatsApp tickets</button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<section>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-2">
<h2 className="text-xl font-semibold">My Tickets</h2>
<section className="mb-8 border rounded-xl p-5 bg-white shadow-sm">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 mb-3">
<div className="flex items-center gap-2">
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
<Ticket className="w-5 h-5 text-brand-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">My Tickets</h2>
</div>
{tickets.length > 0 && (
<div className="flex flex-wrap gap-2">
<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={() => selectAll(true)}>Select all</button>
@@ -852,7 +895,7 @@ export default function UserDashboardPage() {
onClick={() => printTickets(tickets.filter(t => selectedIds.includes(t.id)))}
>Print selected</button>
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 disabled:opacity-50 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
disabled={selectedIds.length === 0}
onClick={() => emailTickets(selectedIds)}
>Email selected</button>
@@ -869,7 +912,7 @@ export default function UserDashboardPage() {
{tickets.filter(t => showPast || !isEventOver(t.event)).length === 0 ? (
<p className="text-gray-600 text-sm">No tickets yet.</p>
) : (
<ul className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
<ul className="grid sm:grid-cols-2 gap-3">
{tickets
.filter((t: any) => showPast || !isEventOver(t.event))
.map((t: any) => {
@@ -878,11 +921,15 @@ export default function UserDashboardPage() {
const variantName = t.registrationOption?.variant?.name;
const type = variantName ? `${optionName}${variantName}` : optionName;
return (
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-white shadow-sm">
<li key={t.id} className="border rounded-xl p-3 space-y-2 bg-gray-50">
<div className="flex items-center gap-2">
<ApiImage src={t.event?.picture} alt="" className="w-10 h-10 rounded-lg object-cover shrink-0" />
<span className="font-medium text-sm truncate flex-1">{t.event?.title || t.eventId}</span>
</div>
<div className="flex items-start justify-between">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={selected[t.id]} onChange={e => toggleSelect(t.id, e.target.checked)} />
<span className="font-medium">{t.event?.title || t.eventId}</span>
<span className="text-xs text-gray-500">Select</span>
</label>
<EventStatusBadge event={t.event} />
{eventDate && <span className="text-xs text-gray-500">{formatDate(eventDate)}</span>}
@@ -894,7 +941,7 @@ export default function UserDashboardPage() {
<div className="flex justify-center"><QrImage value={t.qrCode || t.id} size={120} className="w-28 h-28" /></div>
<div className="text-center text-xs text-gray-500">#{t.id.slice(0, 8)}</div>
<div className="flex gap-2 pt-1 flex-wrap">
<button onClick={() => emailTickets([t.id])} className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1">Email</button>
<button onClick={() => emailTickets([t.id])} 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">Email</button>
{user?.phoneNumber && (
<button onClick={() => whatsappTickets([t.id])} 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">WhatsApp</button>
)}
@@ -906,6 +953,7 @@ export default function UserDashboardPage() {
</ul>
)}
</section>
</div>
{activeReg && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => { setEditMode(false); setEditError(null); setShowCancelConfirm(false); setActiveRegId(null); }}>
@@ -932,27 +980,67 @@ export default function UserDashboardPage() {
<div className="font-medium mb-1 flex items-center justify-between">
<span>Registration items</span>
{!editMode && canModifyActive && (
<button disabled={editLoading} onClick={beginEdit} className="px-2.5 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-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>
{!editMode ? (
<ul className="text-sm list-disc pl-5 space-y-1">
{(activeReg.registrationOptions || []).map((opt: any) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const unitPrice = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.variant?.price ?? opt.eventOption?.price ?? 0);
return (
<li key={opt.id}>
{opt.eventOption?.name}{variantLabel} x {opt.quantity} {formatRand(unitPrice * (opt.quantity || 0))}
{unitPrice < (opt.eventOption?.price || 0) && (
<span className="ml-1 text-xs text-green-700">(early bird)</span>
)}
</li>
);
})}
</ul>
) : (
{!editMode ? (() => {
// A line can span multiple price tranches (e.g. tickets bought before
// and after an early-bird tier expired, or across repeat registrations).
// Merge tranches that share a name/price/tier into one row, then group
// early-bird rows separately from standard-price rows so repeat purchases
// of the same item don't show as a wall of near-duplicate lines.
const rows: { key: string; label: string; quantity: number; unitPrice: number; isEarlyBird: boolean }[] = [];
(activeReg.registrationOptions || []).forEach((opt: any) => {
const variantLabel = opt.variant?.name ? ` (${opt.variant.name})` : '';
const label = `${opt.eventOption?.name || ''}${variantLabel}`;
const tranches = Array.isArray(opt.tranches) && opt.tranches.length > 0
? opt.tranches
: [{
quantity: opt.quantity,
priceSnapshot: (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (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">
{editError && <p className="text-xs text-red-600">{editError}</p>}
{editLoading ? (
@@ -1057,9 +1145,12 @@ export default function UserDashboardPage() {
{activeBill && activeBill.payments.length > 0 && (
<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) => (
<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>
</div>
@@ -1067,28 +1158,25 @@ export default function UserDashboardPage() {
<div className="flex flex-wrap gap-2 pt-2">
{showFormsButton && (
<button
className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1"
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push(`/dashboard/user/forms?registrationId=${encodeURIComponent(activeReg.id)}`)}
>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
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)}
>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-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-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>
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
@@ -1122,6 +1210,31 @@ export default function UserDashboardPage() {
</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 && (
<div
className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@@ -1130,17 +1243,17 @@ export default function UserDashboardPage() {
<div className="bg-white rounded-lg shadow-lg max-w-sm w-full p-5" onClick={e => e.stopPropagation()}>
{dialog.loading ? (
<div className="flex flex-col items-center">
<div className="w-10 h-10 mb-3 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="w-10 h-10 mb-3 border-4 border-brand-600 border-t-transparent rounded-full animate-spin" aria-label="Loading" />
<div className="text-base font-medium">{dialog.loadingTitle || "Sending tickets…"}</div>
<p className="text-sm text-gray-600 mt-1">{dialog.loadingSubtitle || "Please wait while we send your tickets."}</p>
</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>
<div className="flex justify-end">
<button
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
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={() => setDialog({ open: false, message: "", loading: false })}
>OK</button>
</div>
@@ -6,6 +6,7 @@ import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { formatDateTime } from "@/lib/date";
import { formatPaymentMethod } from "@/lib/paymentMethod";
import { Receipt } from "lucide-react";
interface PaymentItem {
id: string;
@@ -32,10 +33,35 @@ export default function UserPaymentsPage() {
const [payments, setPayments] = useState<PaymentItem[]>([]);
const [fetching, setFetching] = useState(false);
const [error, setError] = useDismissingState<string | null>(null);
const [info, setInfo] = useDismissingState<string | null>(null);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
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
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
@@ -74,15 +100,22 @@ export default function UserPaymentsPage() {
return (
<div className="max-w-4xl mx-auto w-full px-4 py-6 sm:px-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">My Payments</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Receipt className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">My Payments</h1>
</div>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>}
{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="flex flex-wrap items-end gap-3 mb-4">
@@ -150,8 +183,19 @@ export default function UserPaymentsPage() {
</div>
<div className="text-xs text-gray-500">{formatDateTime(p.createdAt)}</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 className="flex items-center justify-between gap-2">
<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>
);
})}
@@ -183,7 +227,7 @@ export default function UserPaymentsPage() {
) : (
<button
key={p}
className={`px-2 py-1 rounded ${page === p ? "bg-indigo-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
className={`px-2 py-1 rounded ${page === p ? "bg-brand-600 text-white" : "bg-gray-100 hover:bg-gray-200"}`}
disabled={fetching}
onClick={() => goToPage(p as number)}
>{p}</button>
@@ -198,6 +242,31 @@ export default function UserPaymentsPage() {
</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>
);
}
+228 -199
View File
@@ -6,6 +6,15 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState";
import { User, Lock, ShieldAlert, Trash2, Clock, LogIn, KeyRound, RotateCcw } from "lucide-react";
type ActivityEvent = { id: string; type: "login" | "password_changed" | "password_reset"; device: string | null; createdAt: string };
const ACTIVITY_META: Record<ActivityEvent["type"], { label: (device: string | null) => string; icon: typeof LogIn }> = {
login: { label: d => `Logged in${d ? ` from ${d}` : ""}`, icon: LogIn },
password_changed: { label: () => "Password changed", icon: KeyRound },
password_reset: { label: () => "Password reset via email link", icon: RotateCcw },
};
export default function UserProfilePage() {
const { user, token, logout, updateToken } = useAuth();
@@ -91,6 +100,7 @@ export default function UserProfilePage() {
setNewPassword("");
setConfirmPassword("");
setPwMsg({ type: "ok", text: "Password changed." });
loadActivity();
} catch (e: any) {
setPwMsg({ type: "err", text: e?.message || "Failed to change password." });
} finally {
@@ -123,6 +133,25 @@ export default function UserProfilePage() {
}
};
// ── Account activity ──────────────────────────────────────────────────────
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [loadingActivity, setLoadingActivity] = useState(false);
const loadActivity = async () => {
if (!token) return;
setLoadingActivity(true);
try {
const res = await apiFetch<ActivityEvent[]>("/api/users/activity", { authToken: token });
setActivity(Array.isArray(res) ? res : []);
} catch (e) {
// Non-critical — just leave the list empty rather than showing an error banner.
} finally {
setLoadingActivity(false);
}
};
useEffect(() => { loadActivity(); }, [token]);
// ── Account closure ───────────────────────────────────────────────────────
const [closeStep, setCloseStep] = useState<"idle" | "confirm">("idle");
const [deleteData, setDeleteData] = useState(false);
@@ -153,214 +182,214 @@ export default function UserProfilePage() {
// ── Shared helpers ────────────────────────────────────────────────────────
const Alert = ({ msg }: { msg: { type: "ok" | "err"; text: string } }) => (
<div className={`mt-3 p-3 rounded text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
<div className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{msg.text}
</div>
);
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const SectionHeader = ({ icon: Icon, title, tone = "brand" }: { icon: typeof User; title: string; tone?: "brand" | "amber" | "red" }) => {
const toneCls = tone === "amber" ? "bg-amber-50 text-amber-600" : tone === "red" ? "bg-red-50 text-red-600" : "bg-brand-50 text-brand-600";
return (
<div className="flex items-center gap-2 mb-4">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${toneCls}`}>
<Icon className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
);
};
return (
<div className="max-w-2xl mx-auto w-full p-6 space-y-8">
<h1 className="text-2xl font-semibold">Profile &amp; Security</h1>
<div className="max-w-4xl mx-auto w-full">
<div className="mb-6">
<h1 className="text-2xl font-semibold text-gray-900">Profile &amp; Security</h1>
<p className="text-sm text-gray-500 mt-1">Manage your personal information and keep your account secure.</p>
</div>
{/* ── Profile info ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={name}
onChange={e => setName(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={phone}
onChange={e => setPhone(e.target.value)}
placeholder="e.g. 082 123 4567"
/>
</div>
<div className="grid lg:grid-cols-2 gap-6 items-start">
<div className="space-y-6">
{/* ── Profile info ─────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={User} title="Personal information" />
<form onSubmit={saveProfile} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Full name</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Email address</label>
<input type="email" className={inputCls} value={email} onChange={e => setEmail(e.target.value)} required />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Phone number</label>
<input type="tel" className={inputCls} value={phone} onChange={e => setPhone(e.target.value)} placeholder="e.g. 082 123 4567" />
</div>
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={notifPref}
onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}
>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
{hasValidPhone && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notification preference</label>
<select className={inputCls} value={notifPref} onChange={e => setNotifPref(e.target.value as "email" | "whatsapp" | "both")}>
<option value="email">Email only</option>
<option value="whatsapp">WhatsApp only</option>
<option value="both">Email &amp; WhatsApp</option>
</select>
<p className="mt-1 text-xs text-gray-500">Security alerts (password reset, login notifications) are always sent via email regardless of this setting.</p>
</div>
)}
<button
type="submit"
disabled={savingProfile}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingProfile ? "Saving…" : "Save changes"}
</button>
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Password ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Change password</h2>
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={savingPw}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
{savingPw ? "Changing…" : "Change password"}
</button>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-1">Security</h2>
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold text-red-700 mb-1">Close account</h2>
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
<button type="submit" disabled={savingProfile} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingProfile ? "Saving…" : "Save changes"}
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Account activity ─────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Clock} title="Account activity" />
{loadingActivity && activity.length === 0 ? (
<p className="text-sm text-gray-400">Loading</p>
) : activity.length === 0 ? (
<p className="text-sm text-gray-500">No recent activity recorded yet.</p>
) : (
<ul className="space-y-3">
{activity.map(ev => {
const meta = ACTIVITY_META[ev.type] || ACTIVITY_META.login;
const Icon = meta.icon;
return (
<li key={ev.id} className="flex items-start gap-3">
<div className="w-7 h-7 rounded-full bg-gray-100 flex items-center justify-center shrink-0 mt-0.5">
<Icon className="w-3.5 h-3.5 text-gray-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm text-gray-800">{meta.label(ev.device)}</div>
<div className="text-xs text-gray-400">{new Date(ev.createdAt).toLocaleString()}</div>
</div>
</li>
);
})}
</ul>
)}
</section>
</div>
<div className="space-y-6">
{/* ── Password ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Lock} title="Change password" />
<form onSubmit={changePassword} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Current password</label>
<input type="password" className={inputCls} value={currentPassword} onChange={e => setCurrentPassword(e.target.value)} required autoComplete="current-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">New password</label>
<input type="password" className={inputCls} value={newPassword} onChange={e => setNewPassword(e.target.value)} required minLength={8} autoComplete="new-password" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm new password</label>
<input type="password" className={inputCls} value={confirmPassword} onChange={e => setConfirmPassword(e.target.value)} required autoComplete="new-password" />
</div>
<button type="submit" disabled={savingPw} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingPw ? "Changing…" : "Change password"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ShieldAlert} title="Security" tone="amber" />
<p className="text-sm text-gray-500 mb-4">
If you suspect someone else has access to your account, you can sign out of all other devices immediately.
You will remain logged in on this device.
</p>
<button
onClick={revokeSessions}
disabled={revoking}
className="px-4 py-2 rounded-lg bg-amber-500 text-white text-sm font-medium hover:bg-amber-600 disabled:opacity-50"
>
{revoking ? "Signing out…" : "Sign out all other devices"}
</button>
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Trash2} title="Close account" tone="red" />
<p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records
will remain for accounting purposes but will show as &quot;Deleted User&quot;.
</p>
{closeStep === "idle" && (
<button
onClick={() => setCloseStep("confirm")}
className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50"
>
Close my account
</button>
)}
{closeStep === "confirm" && (
<form onSubmit={submitAccountClosure} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>This action cannot be undone.</strong> Please read carefully before continuing.
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input
type="checkbox"
className="mt-0.5"
checked={deleteData}
onChange={e => setDeleteData(e.target.checked)}
/>
<span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> your name, email address,
and phone number will be permanently removed and cannot be recovered.
</span>
</label>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Confirm with your password
</label>
<input
type="password"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-red-500"
value={closePassword}
onChange={e => setClosePassword(e.target.value)}
required
placeholder="Enter your password to confirm"
autoComplete="current-password"
/>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => { setCloseStep("idle"); setClosePassword(""); setDeleteData(false); setCloseMsg(null); }}
className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200"
>
Cancel
</button>
<button
type="submit"
disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
>
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
</button>
</div>
{closeMsg && <Alert msg={closeMsg} />}
</form>
)}
</section>
</div>
</div>
</div>
);
}
}
@@ -4,6 +4,7 @@ import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
export default function ResetPasswordPage() {
const { token } = useAuth();
@@ -43,10 +44,15 @@ export default function ResetPasswordPage() {
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reset password</h1>
<div className="flex items-center justify-between mb-4 flex-wrap gap-3">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<KeyRound className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Reset password</h1>
</div>
<button
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
className="px-2.5 py-1 text-xs rounded-lg bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user")}
>Back to dashboard</button>
</div>
@@ -96,7 +102,7 @@ export default function ResetPasswordPage() {
<button
type="submit"
disabled={!canSubmit || loading}
className="px-4 py-2 rounded bg-blue-600 text-white disabled:opacity-60"
className="px-4 py-2 rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60"
>{loading ? "Saving…" : "Update password"}</button>
<button
type="button"
@@ -18,7 +18,7 @@ export default function ClientActions({ event }: { event: any }) {
console.error("Error sharing:", err);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
<Share2 className="w-4 h-4" />
Share
@@ -38,7 +38,7 @@ export default function ClientActions({ event }: { event: any }) {
console.error("Error generating QR code:", err);
}
}}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded hover:bg-gray-200"
className="flex items-center gap-2 px-4 py-2 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors text-sm font-medium"
>
<QrCode className="w-4 h-4" />
Save QR
+198 -123
View File
@@ -2,6 +2,8 @@ import { notFound } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import ClientActions from "@/app/events/[id]/ClientActions";
import { ContactButton } from "@/components/events/ContactButton";
import { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -31,6 +33,10 @@ type Event = {
eventOptions?: EventOption[];
attachments?: EventAttachment[];
requiresAuth?: boolean;
requiresRegistration?: boolean;
contactName?: string | null;
contactPhone?: string | null;
contactEmail?: string | null;
};
import { apiFetch, ApiError } from "@/lib/api";
@@ -49,6 +55,54 @@ function lowStockThreshold(stockLimit: number): number {
return Math.round(stockLimit * pct);
}
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 end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
if (notYetOpen) {
return (
<button disabled className="w-full bg-gray-200 text-gray-600 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium" title="Registration not yet open">
Opens {goLive?.toLocaleString()}
</button>
);
}
if (closed) {
return (
<button disabled className="w-full bg-gray-200 text-gray-600 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium" title="Registration closed">
Registration closed
</button>
);
}
if (eventSoldOut) {
return (
<button disabled className="w-full bg-red-50 text-red-700 border border-red-200 px-4 py-2.5 rounded-lg cursor-not-allowed font-medium">
Sold Out
</button>
);
}
return (
<a href={`/register/${event.id}`} 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">
Register
</a>
);
}
export default async function EventDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let event: Event;
@@ -64,138 +118,159 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-3xl mx-auto w-full">
<div className="space-y-4">
<h1 className="text-3xl font-bold">{event.title}</h1>
<p className="text-sm text-gray-500">{formatDateTimeRange(event.startDate, event.endDate)}</p>
<ClientActions event={event} />
{event.picture && (
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded" />
)}
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.attachments && event.attachments.length > 0 && (
<main className="flex-1 py-8 px-4 max-w-6xl mx-auto w-full">
<div className="grid lg:grid-cols-3 gap-8">
<div className="lg:col-span-2 space-y-6">
{event.picture ? (
<ApiImage src={event.picture} alt={event.title} className="w-full aspect-video object-cover rounded-xl" />
) : (
<div className="w-full aspect-video rounded-xl bg-brand-50 flex items-center justify-center">
<Calendar className="w-12 h-12 text-brand-200" />
</div>
)}
<div>
<h2 className="text-xl font-semibold mt-6">Downloads</h2>
<ul className="list-disc pl-5 text-gray-700">
{event.attachments.map((att) => (
<li key={att.id}>
<a className="text-blue-600 hover:underline" href={att.url} target="_blank" rel="noopener noreferrer">
{att.originalName}
</a>
</li>
))}
</ul>
<h1 className="text-3xl font-bold text-gray-900">{event.title}</h1>
<p className="text-sm text-gray-500 flex items-center gap-1.5 mt-2">
<Calendar className="w-4 h-4 shrink-0" />
{formatDateTimeRange(event.startDate, event.endDate)}
</p>
<div className="mt-3">
<ClientActions event={event} />
</div>
</div>
)}
<h2 className="text-xl font-semibold mt-6">Tickets</h2>
<div className="space-y-2">
{(event.eventOptions || []).map((opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
if (hasVariants) {
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
return (
<div key={opt.id} className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span className="font-medium text-sm">{opt.name}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{priceLabel}</span>
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
</div>
<ul className="divide-y">
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
return (
<li key={v.id} className="flex items-center justify-between px-3 py-2 text-sm text-gray-700">
<span>{v.name}</span>
<div className="flex items-center gap-2">
<span>{vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`}</span>
{vSoldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{vNearlyOut && !vSoldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{v.availableCount} remaining</span>}
</div>
</li>
);
})}
</ul>
</div>
);
}
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
// Early bird pricing display
const now = new Date();
const activeTiers = (opt.earlyBirdTiers || [])
.filter((t) => now < new Date(t.deadline))
.sort((a, b) => a.price - b.price);
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price;
return (
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
<span className="font-medium">{opt.name}</span>
<div className="flex items-center gap-2">
{activeTiers.length > 0 ? (
<>
<span className="text-indigo-700 font-medium">R{displayPrice.toFixed(2)}</span>
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
<span className="text-xs text-indigo-600">Early bird</span>
</>
) : (
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
)}
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
{event.attachments && event.attachments.length > 0 && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
<div className="flex items-center gap-2 mb-3">
<Paperclip className="w-4 h-4 text-gray-500" />
<h2 className="text-base font-semibold text-gray-900">Downloads</h2>
</div>
);
})}
<ul className="space-y-1.5">
{event.attachments.map((att) => (
<li key={att.id}>
<a className="text-brand-600 hover:underline text-sm" href={att.url} target="_blank" rel="noopener noreferrer">
{att.originalName}
</a>
</li>
))}
</ul>
</div>
)}
{/* Tickets are shown in the sticky card on desktop; repeat here for mobile below the fold */}
<div className="border rounded-xl p-5 bg-white shadow-sm lg:hidden">
<div className="flex items-center gap-2 mb-3">
<Ticket className="w-4 h-4 text-gray-500" />
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
</div>
<TicketList event={event} />
<div className="mt-4">
<RegisterCta event={event} />
</div>
</div>
</div>
<div className="hidden lg:block">
<div className="border rounded-xl p-5 bg-white shadow-sm sticky top-20">
<div className="flex items-center gap-2 mb-3">
<Ticket className="w-4 h-4 text-gray-500" />
<h2 className="text-base font-semibold text-gray-900">Tickets</h2>
</div>
<TicketList event={event} />
<div className="mt-4">
<RegisterCta event={event} />
</div>
</div>
</div>
{(() => {
const now = new Date();
const end = new Date(event.endDate);
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const goLive = event.goLiveAt ? new Date(event.goLiveAt) : null;
const notYetOpen = goLive ? now < goLive : false;
const closed = (deadline ? now >= deadline : false) || now >= end;
const limitedOpts = (event.eventOptions || []).filter(o => (o.stockLimit ?? 0) > 0);
const eventSoldOut = limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
if (notYetOpen) {
return (
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration not yet open">
Opens {goLive?.toLocaleString()}
</button>
);
}
if (closed) {
return (
<button disabled className="inline-block bg-gray-300 text-gray-600 px-4 py-2 rounded cursor-not-allowed" title="Registration closed">
Registration closed
</button>
);
}
if (eventSoldOut) {
return (
<button disabled className="inline-block bg-red-50 text-red-700 border border-red-200 px-4 py-2 rounded cursor-not-allowed">
Sold Out
</button>
);
}
return (
<a href={`/register/${event.id}`} className="inline-block bg-blue-600 text-white px-4 py-2 rounded">
Register
</a>
);
})()}
</div>
</main>
<Footer />
</div>
);
}
function TicketList({ event }: { event: Event }) {
const options = event.eventOptions || [];
if (options.length === 0) {
return (
<div className="text-center py-6">
<Sparkles className="w-6 h-6 text-gray-300 mx-auto mb-1.5" />
<p className="text-sm text-gray-400">Free entry no tickets required.</p>
</div>
);
}
return (
<div className="space-y-2">
{options.map((opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const soldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
const nearlyOut = !soldOut && (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= lowStockThreshold(opt.stockLimit ?? 0);
if (hasVariants) {
const prices = opt.variants!.map((v) => v.price !== null && v.price !== undefined ? v.price : opt.price);
const minPrice = Math.min(...prices);
const maxPrice = Math.max(...prices);
const priceLabel = minPrice === 0 && maxPrice === 0 ? "Free" : minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : minPrice === 0 ? `Free R${maxPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
return (
<div key={opt.id} className="border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span className="font-medium text-sm">{opt.name}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{priceLabel}</span>
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
</div>
<ul className="divide-y">
{opt.variants!.slice().sort((a, b) => (a as any).order - (b as any).order).map((v) => {
const vPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const vSoldOut = (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= 0;
const vNearlyOut = !vSoldOut && (v.stockLimit ?? 0) > 0 && v.availableCount !== undefined && v.availableCount <= lowStockThreshold(v.stockLimit ?? 0);
return (
<li key={v.id} className="flex items-center justify-between px-3 py-2 text-sm text-gray-700">
<span>{v.name}</span>
<div className="flex items-center gap-2">
<span>{vPrice === 0 ? "Free" : `R${vPrice.toFixed(2)}`}</span>
{vSoldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{vNearlyOut && !vSoldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{v.availableCount} remaining</span>}
</div>
</li>
);
})}
</ul>
</div>
);
}
// Early bird pricing display
const now = new Date();
const activeTiers = (opt.earlyBirdTiers || [])
.filter((t) => now < new Date(t.deadline))
.sort((a, b) => a.price - b.price);
const displayPrice = activeTiers.length > 0 ? activeTiers[0].price : opt.price;
return (
<div key={opt.id} className="flex items-center justify-between border rounded-lg px-3 py-2 text-sm text-gray-700">
<span className="font-medium">{opt.name}</span>
<div className="flex items-center gap-2">
{activeTiers.length > 0 ? (
<>
<span className="text-brand-700 font-medium">R{displayPrice.toFixed(2)}</span>
<span className="text-xs text-gray-400 line-through">R{opt.price.toFixed(2)}</span>
<span className="text-xs text-brand-600">Early bird</span>
</>
) : (
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
)}
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
</div>
);
})}
</div>
);
}
+14 -2
View File
@@ -2,6 +2,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { EventCard } from "@/components/events/EventCard";
import { apiFetch } from "@/lib/api";
import { Calendar, CalendarX } from "lucide-react";
export const revalidate = 60;
@@ -33,9 +34,20 @@ export default async function EventsPage() {
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 py-10 px-4 max-w-7xl mx-auto w-full">
<h1 className="text-3xl font-bold mb-6">All Events</h1>
<div className="flex items-center gap-3 mb-8">
<div className="w-11 h-11 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Calendar className="w-6 h-6 text-brand-600" />
</div>
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">All Events</h1>
<p className="text-sm text-gray-500">{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}</p>
</div>
</div>
{sorted.length === 0 ? (
<p className="text-gray-600">No events available.</p>
<div className="text-center py-16 border rounded-xl bg-gray-50">
<CalendarX className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No events available right now check back soon.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{sorted.map((event) => (
+27 -5
View File
@@ -3,6 +3,9 @@ import React, { Suspense, useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { FileText } from "lucide-react";
// Types for form fields
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -143,13 +146,30 @@ function FormsContent() {
}
};
if (!registrationId) return <div className="p-6">Missing registrationId.</div>;
if (!registrationId) {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm text-gray-700">Missing registrationId.</div>
</main>
<Footer />
</div>
);
}
return (
<div className="max-w-2xl mx-auto w-full p-6">
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Attendee forms</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={skip}>Skip for now</button>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<FileText className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Attendee forms</h1>
</div>
<button className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 transition-colors" onClick={skip}>Skip for now</button>
</div>
{loading && <div className="text-sm text-gray-600 mb-2">Loading</div>}
@@ -231,7 +251,7 @@ function FormsContent() {
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
@@ -240,6 +260,8 @@ function FormsContent() {
{eventForm && remaining === 0 && (
<div className="text-sm text-emerald-700">All required attendee forms are completed for this registration.</div>
)}
</main>
<Footer />
</div>
);
}
+68
View File
@@ -1,6 +1,74 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/*
* Design tokens for shadcn/ui primitives (components.json: baseColor "slate",
* cssVariables: true). Base scale is shadcn's stock "slate" theme.
*
* --primary/--primary-foreground/--ring/--secondary/--secondary-foreground/
* --accent/--accent-foreground/--brand-50..900 below are DEFAULTS ONLY used
* before any branding setting has been saved, or if the server-side settings
* fetch fails for a request. The real values come from the admin's saved
* primary_color/secondary_color/accent_color settings, computed via
* lib/colorScale.ts and injected per-request as a `<style id="brand-theme">`
* 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 {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 243 75% 59%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 243 75% 59%;
--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;
}
}
@layer base {
* {
@apply border-border;
}
}
html {
scroll-behavior: smooth;
}
+48 -10
View File
@@ -3,9 +3,13 @@ import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/contexts/AuthContext";
import { SiteSettingsProvider } from "@/contexts/SiteSettingsContext";
import type { SiteSettings } from "@/contexts/SiteSettingsContext";
import { ToastProvider } from "@/components/shared/ToastProvider";
import { BannerBar } from "@/components/shared/BannerBar";
import { SetupGuard } from "@/components/shared/SetupGuard";
import HelpFab from "@/components/shared/HelpFab";
import { API_BASE, resolveToApiOrigin } from "@/lib/api";
import { buildThemeCssVars } from "@/lib/colorScale";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -17,26 +21,60 @@ const geistMono = Geist_Mono({
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,
description: `Manage and register for events with ${appName}`,
icons: {
icon: "/favicon.ico",
},
};
/**
* Server-side settings fetch, shared by generateMetadata() and RootLayout()
* below. Both call this with the identical URL + options so Next's per-request
* fetch memoization dedupes them into a single network call. Degrades to `{}`
* (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;
return {
title: appName,
description: `Manage and register for events with ${appName}`,
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 (
<html lang="en">
<body>
<SiteSettingsProvider>
{cssVars && (
<style id="brand-theme" dangerouslySetInnerHTML={{ __html: `:root{${cssVars}}` }} />
)}
<SiteSettingsProvider initialSettings={settings}>
<AuthProvider>
<ToastProvider>
<SetupGuard />
<BannerBar />
{children}
<HelpFab />
</ToastProvider>
</AuthProvider>
</SiteSettingsProvider>
+23 -16
View File
@@ -27,12 +27,19 @@ export default function PrivacyPolicyPage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church";
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
const ioName = settings.legal_io_name || "Joshua Oosthuizen";
const ioEmail = settings.legal_io_email || "joshua@crosscode.co.za";
const effectiveDate = settings.legal_effective_date || "April 2026";
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
// required field at setup — this fallback is practically unreachable in production.
const orgName = settings.org_name || "Cross Code";
// The fields below are all OPTIONAL legal-specific settings that can realistically
// 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 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 (
<div className="min-h-screen flex flex-col bg-gray-50">
@@ -46,8 +53,8 @@ export default function PrivacyPolicyPage() {
<li key={section.id}>
<a
href={`#${section.id}`}
className={`block hover:text-blue-600 transition-colors ${
activeId === section.id ? "text-blue-600 font-semibold" : ""
className={`block hover:text-brand-600 transition-colors ${
activeId === section.id ? "text-brand-600 font-semibold" : ""
}`}
>
{section.title}
@@ -59,27 +66,27 @@ export default function PrivacyPolicyPage() {
{/* Main Content */}
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-blue-700 mb-2">Privacy Policy</h1>
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
<p className="text-sm text-gray-500 mb-6">Effective Date: {effectiveDate}</p>
<div className="border-l-4 border-blue-600 pl-4 mb-8 text-gray-700">
<div className="border-l-4 border-brand-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-blue-600 hover:underline">
<a href={`https://${websiteUrl}`} className="text-brand-600 hover:underline">
{websiteUrl}
</a>
</p>
<p><strong>Responsible Party:</strong> {orgName}</p>
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${orgEmail}`} className="text-blue-600 hover:underline">
<a href={`mailto:${orgEmail}`} className="text-brand-600 hover:underline">
{orgEmail}
</a>
</p>
</div>
<section id="summary" className="mb-10">
<h2 className="text-2xl font-semibold text-blue-600 mb-4">🟢 Plain English Summary</h2>
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to register you for events and process payments.</li>
@@ -210,7 +217,7 @@ function Section({
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-blue-600 mb-3">{title}</h3>
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
@@ -220,7 +227,7 @@ function Section({
return (
<p key={i} className="mb-2">
{parts[0]}
<a href={`mailto:${email}`} className="text-blue-600 hover:underline">
<a href={`mailto:${email}`} className="text-brand-600 hover:underline">
{email}
</a>
{parts[1] || ""}
@@ -231,7 +238,7 @@ function Section({
return (
<p key={i} className="mb-2">
{line.replace("www.inforeg.org.za", "")}
<a href="https://www.inforeg.org.za" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
<a href="https://www.inforeg.org.za" target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
www.inforeg.org.za
</a>
</p>
+20 -13
View File
@@ -26,11 +26,18 @@ export default function TermsOfUsePage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
const orgName = settings.org_name || "Hope Family Church";
const orgEmail = settings.org_email || "admin@hopehenley.co.za";
const websiteUrl = settings.legal_website_url || "events.hopehenley.co.za";
const operatorName = settings.legal_operator_name || "Joshua Oosthuizen on behalf of Hope Family Church, Henley-on-Klip, South Africa";
const effectiveDate = settings.legal_effective_date || "April 2026";
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
// required field at setup — this fallback is practically unreachable in production.
const orgName = settings.org_name || "Cross Code";
// The fields below are all OPTIONAL legal-specific settings that can realistically
// 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 (
<div className="min-h-screen flex flex-col bg-gray-50">
@@ -44,8 +51,8 @@ export default function TermsOfUsePage() {
<li key={section.id}>
<a
href={`#${section.id}`}
className={`block hover:text-blue-600 transition-colors ${
activeId === section.id ? "text-blue-600 font-semibold" : ""
className={`block hover:text-brand-600 transition-colors ${
activeId === section.id ? "text-brand-600 font-semibold" : ""
}`}
>
{section.title}
@@ -57,13 +64,13 @@ export default function TermsOfUsePage() {
{/* Main Content */}
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-blue-700 mb-2">Terms of Use</h1>
<h1 className="text-4xl font-bold text-brand-700 mb-2">Terms of Use</h1>
<p className="text-sm text-gray-500 mb-6">Effective Date: {effectiveDate}</p>
<div className="border-l-4 border-blue-600 pl-4 mb-8 text-gray-700">
<div className="border-l-4 border-brand-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-blue-600 hover:underline">
<a href={`https://${websiteUrl}`} className="text-brand-600 hover:underline">
{websiteUrl}
</a>
</p>
@@ -71,7 +78,7 @@ export default function TermsOfUsePage() {
</div>
<section id="summary" className="mb-10">
<h2 className="text-2xl font-semibold text-blue-600 mb-4">🟢 Plain English Summary</h2>
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>Welcome to {orgName}! This site helps you book and pay for events hosted or supported by {orgName}.</p>
<p>By using it, you agree to:</p>
<ul className="list-disc ml-6 space-y-1">
@@ -188,7 +195,7 @@ function Section({
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-blue-600 mb-3">{title}</h3>
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{content[id]?.map((line, i) => {
if (line.includes("@") && !line.startsWith("•") && (line.includes("📧") || line.endsWith(orgEmail))) {
const email = orgEmail;
@@ -196,7 +203,7 @@ function Section({
return (
<p key={i} className="mb-2">
{parts[0]}
<a href={`mailto:${email}`} className="text-blue-600 hover:underline">
<a href={`mailto:${email}`} className="text-brand-600 hover:underline">
{email}
</a>
{parts[1] || ""}
+78 -22
View File
@@ -1,10 +1,9 @@
import { EventCard } from "@/components/events/EventCard";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { ContactSection } from "@/components/shared/ContactSection";
import { JoinUsButton } from "@/components/home/JoinUsButton";
import { appName } from "@/lib/siteConfig";
import { Calendar, ArrowRight, CalendarCheck, Users, Heart, ShieldCheck } from "lucide-react";
type Event = {
id: string;
@@ -20,6 +19,13 @@ type Event = {
import { apiFetch } from "@/lib/api";
const FEATURES = [
{ icon: CalendarCheck, title: "Meaningful Events", description: "Events designed to inspire, encourage and strengthen your faith." },
{ icon: Calendar, title: "Easy Registration", description: "Simple and secure registration so you can focus on what matters." },
{ icon: Users, title: "Community", description: "Connect with others and grow in a supportive and loving community." },
{ icon: Heart, title: "Make an Impact", description: "Be part of what God is doing and make a difference together." },
];
export default async function HomePage() {
const events = await apiFetch<Event[]>("/api/events", { nextOptions: { next: { revalidate: 60 } } });
const now = Date.now();
@@ -34,34 +40,84 @@ export default async function HomePage() {
<Navbar />
<main className="flex-1">
<section className="bg-gradient-to-br from-blue-100 to-white py-16 text-center">
<h1 className="text-4xl font-bold mb-4">Welcome to {appName}</h1>
<p className="text-gray-600 text-lg mb-6">Experience unforgettable moments. Powered by purpose.</p>
<div className="space-x-4">
<a href="/events" className="px-6 py-2 bg-blue-600 text-white rounded-xl hover:bg-blue-700">
View Events
</a>
<a href="/register" className="px-6 py-2 border border-blue-600 text-blue-600 rounded-xl hover:bg-blue-50">
Join Us
</a>
<section className="relative overflow-hidden bg-gradient-to-br from-brand-50 via-white to-brand-50 py-20 text-center">
<div className="absolute inset-0 pointer-events-none" aria-hidden="true">
<div className="absolute -top-24 -right-24 w-96 h-96 rounded-full bg-brand-100/60 blur-3xl" />
<div className="absolute -bottom-24 -left-24 w-96 h-96 rounded-full bg-brand-100/40 blur-3xl" />
</div>
<div className="relative max-w-2xl mx-auto px-4">
{sorted.length > 0 && (
<div className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full bg-white border border-brand-200 text-brand-700 shadow-sm mb-5">
<CalendarCheck className="w-3.5 h-3.5" />
{sorted.length} upcoming event{sorted.length === 1 ? "" : "s"}
</div>
)}
<h1 className="text-4xl sm:text-5xl font-bold mb-4 text-gray-900">Welcome to {appName}</h1>
<p className="text-gray-600 text-lg mb-8">Experience unforgettable moments. Powered by purpose.</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<a href="/events" className="inline-flex items-center gap-2 px-6 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 shadow-sm font-medium transition-colors">
<Calendar className="w-4 h-4" />
View Events
</a>
<JoinUsButton />
</div>
</div>
</section>
<section className="py-12 px-4 max-w-7xl mx-auto">
<SectionHeader title="Upcoming Events" />
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{sorted.slice(0, 3).map(event => (
<EventCard key={event.id} event={event} />
))}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-900">Upcoming Events</h2>
<a href="/events" className="hidden sm:inline-flex items-center gap-1 text-sm font-medium text-brand-600 hover:underline">
View all events
<ArrowRight className="w-3.5 h-3.5" />
</a>
</div>
<div className="text-center mt-8">
<a href="/events" className="text-blue-600 hover:underline text-sm">
View all events
{sorted.length === 0 ? (
<div className="text-center py-12 border rounded-xl bg-gray-50">
<CalendarCheck className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-gray-500 text-sm">No upcoming events right now check back soon.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-3 sm:grid-cols-2">
{sorted.slice(0, 3).map(event => (
<EventCard key={event.id} event={event} />
))}
</div>
)}
<div className="text-center mt-8 sm:hidden">
<a href="/events" className="inline-flex items-center gap-1 text-brand-600 hover:underline text-sm font-medium">
View all events
<ArrowRight className="w-3.5 h-3.5" />
</a>
</div>
</section>
<ContactSection />
<section className="bg-gray-50 py-14 px-4">
<div className="max-w-6xl mx-auto grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
{FEATURES.map(f => {
const Icon = f.icon;
return (
<div key={f.title} className="text-center sm:text-left">
<div className="w-12 h-12 rounded-xl bg-brand-50 flex items-center justify-center mb-3 mx-auto sm:mx-0">
<Icon className="w-6 h-6 text-brand-600" />
</div>
<h3 className="font-semibold text-gray-900 mb-1">{f.title}</h3>
<p className="text-sm text-gray-600">{f.description}</p>
</div>
);
})}
</div>
</section>
<section className="py-12 px-4">
<div className="max-w-3xl mx-auto text-center">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-brand-50 mb-4">
<ShieldCheck className="w-6 h-6 text-brand-600" />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Secure & reliable</h2>
<p className="text-sm text-gray-600">Your details and payments are handled securely from registration through to the day of the event.</p>
</div>
</section>
</main>
<Footer />
+12 -4
View File
@@ -3,6 +3,7 @@
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import { AlertCircle } from "lucide-react";
import React from "react";
export default function PaymentCancelPage() {
@@ -10,10 +11,17 @@ export default function PaymentCancelPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<h1 className="text-2xl font-semibold mb-2">Payment cancelled</h1>
<p className="text-gray-700">Your checkout was cancelled. You can resume payment from your dashboard later.</p>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm" onClick={() => router.push('/dashboard/user')}>Go to Dashboard</button>
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-amber-50 flex items-center justify-center shrink-0">
<AlertCircle className="w-5 h-5 text-amber-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Payment cancelled</h1>
</div>
<p className="text-gray-700 mb-4">Your checkout was cancelled. You can resume payment from your dashboard later.</p>
<button className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors" onClick={() => router.push('/dashboard/user')}>Go to Dashboard</button>
</div>
</main>
<Footer />
</div>
+12 -4
View File
@@ -2,6 +2,7 @@
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import { XCircle } from "lucide-react";
import React from "react";
export default function PaymentFailurePage() {
@@ -9,10 +10,17 @@ export default function PaymentFailurePage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<h1 className="text-2xl font-semibold mb-2">Payment failed</h1>
<p className="text-gray-700">Unfortunately, your payment failed. Please try again or use a different method.</p>
<button className="px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm" onClick={() => router.push('/dashboard/user')}>Go to Dashboard</button>
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-red-50 flex items-center justify-center shrink-0">
<XCircle className="w-5 h-5 text-red-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Payment failed</h1>
</div>
<p className="text-gray-700 mb-4">Unfortunately, your payment failed. Please try again or use a different method.</p>
<button className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors" onClick={() => router.push('/dashboard/user')}>Go to Dashboard</button>
</div>
</main>
<Footer />
</div>
+27 -19
View File
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useRouter } from "next/navigation";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CheckCircle2 } from "lucide-react";
import React from "react";
export default function PaymentSuccessPage() {
@@ -14,25 +15,32 @@ export default function PaymentSuccessPage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 p-6 max-w-2xl mx-auto w-full">
<h1 className="text-2xl font-semibold mb-2">Payment successful</h1>
<p className="text-gray-700">
Thank you! Your payment was received. Your tickets will be sent to you shortly.
{contactEmail && (
<>
{" "}If you have any questions, contact us at{" "}
<a href={`mailto:${contactEmail}`} className="text-blue-600 hover:underline">
{contactEmail}
</a>.
</>
)}
</p>
<button
className="mt-4 px-3 py-1.5 text-sm rounded bg-indigo-100 hover:bg-indigo-200 text-indigo-800 shadow-sm"
onClick={() => router.push('/dashboard/user')}
>
Go to Dashboard
</button>
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center shrink-0">
<CheckCircle2 className="w-5 h-5 text-green-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Payment successful</h1>
</div>
<p className="text-gray-700 mb-4">
Thank you! Your payment was received. Your tickets will be sent to you shortly.
{contactEmail && (
<>
{" "}If you have any questions, contact us at{" "}
<a href={`mailto:${contactEmail}`} className="text-brand-600 hover:underline">
{contactEmail}
</a>.
</>
)}
</p>
<button
className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition-colors"
onClick={() => router.push('/dashboard/user')}
>
Go to Dashboard
</button>
</div>
</main>
<Footer />
</div>
@@ -207,7 +207,7 @@ function OptionCard({
{tier ? (
<>
<span>R{price.toFixed(2)}</span>
<span className="text-indigo-600">
<span className="text-brand-600">
Early bird ends {new Date(tier.deadline).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "2-digit" })}
</span>
</>
@@ -417,11 +417,11 @@ export default function RegisterForm({ event }: { event: Event }) {
<div className="text-base font-semibold mb-1">Your details</div>
<p className="text-sm text-gray-500 mb-3">
No account needed for this event.{" "}
<a href={`/login?redirect=/register/${eventId}`} className="text-indigo-600 hover:underline">Log in</a> if you already have one.
<a href={`/login?redirect=/register/${eventId}`} className="text-brand-600 hover:underline">Log in</a> if you already have one.
</p>
<div className="space-y-2">
<input
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Full name *"
value={guestName}
onChange={e => setGuestName(e.target.value)}
@@ -429,7 +429,7 @@ export default function RegisterForm({ event }: { event: Event }) {
/>
<input
type="email"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Email address *"
value={guestEmail}
onChange={e => setGuestEmail(e.target.value)}
@@ -437,7 +437,7 @@ export default function RegisterForm({ event }: { event: Event }) {
/>
<input
type="tel"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400"
placeholder="Phone number (optional)"
value={guestPhone}
onChange={e => setGuestPhone(e.target.value)}
@@ -486,7 +486,7 @@ export default function RegisterForm({ event }: { event: Event }) {
<button
onClick={submitRegistration}
disabled={creatingRegistration || (!isLoggedIn && !isGuestEligible)}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
className="bg-brand-600 text-white px-4 py-2 rounded hover:bg-brand-700 disabled:opacity-60"
>
{creatingRegistration ? "Registering..." : "Register"}
</button>
+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 { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
@@ -18,6 +18,12 @@ export default async function RegisterPage({ params }: { params: Promise<{ event
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 (
<div className="min-h-screen flex flex-col">
<Navbar />
+11 -2
View File
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { CheckCircle2 } from "lucide-react";
type FormField = { id: string; type: 'yes_no'|'text'|'date'|'numeric'|'statement'|'paragraph'; label: string; isRequired?: boolean; helpText?: string|null };
@@ -55,6 +56,9 @@ function RegistrationSuccessContent() {
const totalDue = React.useMemo(() => {
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 {
return (reg.registrationOptions || []).reduce((s: number, ro: any) => {
const unit = (ro.priceSnapshot !== null && ro.priceSnapshot !== undefined)
@@ -116,7 +120,12 @@ function RegistrationSuccessContent() {
<Navbar />
<main className="flex-1 px-4 py-10 max-w-2xl mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<div className="text-2xl font-semibold mb-2">Registration successful</div>
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-xl bg-green-50 flex items-center justify-center shrink-0">
<CheckCircle2 className="w-5 h-5 text-green-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Registration successful</h1>
</div>
<p className="text-gray-700 mb-4">Thank you! Your registration has been created{registrationId ? ` (#${registrationId.slice(0,8)})` : ""}.</p>
{form?.isRequired && reg ? (
<div className="mb-6 p-3 border rounded bg-yellow-50 text-yellow-800">
@@ -255,7 +264,7 @@ function AttendeeForms({ reg, form, formsData, setFormsData, setError, setInfo }
))}
</div>
<div className="mt-3 flex gap-2">
<button className="px-3 py-1.5 text-sm rounded bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 disabled:opacity-50" disabled={!canSubmit} onClick={submit}>Submit attendee forms</button>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200" onClick={saveDraft}>Save for later</button>
</div>
</div>
+8 -2
View File
@@ -4,6 +4,7 @@ import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch } from "@/lib/api";
import { useSearchParams, useRouter } from "next/navigation";
import { KeyRound } from "lucide-react";
function ResetPasswordContent() {
const search = useSearchParams();
@@ -37,7 +38,12 @@ function ResetPasswordContent() {
<Navbar />
<main className="flex-1 px-4 py-10 max-w-md mx-auto w-full">
<div className="border rounded-xl p-6 bg-white shadow-sm">
<h1 className="text-2xl font-semibold mb-4">Reset your password</h1>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<KeyRound className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Reset your password</h1>
</div>
{!token && <p className="text-red-600 mb-4">Missing or invalid token.</p>}
<form onSubmit={submit} className="space-y-4">
<div>
@@ -49,7 +55,7 @@ function ResetPasswordContent() {
<label className="block text-sm font-medium mb-1">Confirm Password</label>
<input type="password" value={confirm} onChange={e => setConfirm(e.target.value)} required className="w-full border rounded px-3 py-2" />
</div>
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded bg-green-600 text-white disabled:opacity-60">
<button disabled={loading || !canSubmit} className="px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-60 w-full font-medium transition-colors">
{loading ? "Saving..." : "Set new password"}
</button>
</form>
+32 -32
View File
@@ -631,7 +631,7 @@ export default function SelfServicePage() {
type={showChangePassword ? "text" : "password"}
value={changePassword}
onChange={(e) => setChangePassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-lg px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="Your password"
autoFocus
required
@@ -651,7 +651,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={changeLoading}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 disabled:opacity-60 transition"
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 disabled:opacity-60 transition"
>
{changeLoading ? "Verifying…" : "Confirm"}
</button>
@@ -678,7 +678,7 @@ export default function SelfServicePage() {
type="email"
value={setupEmail}
onChange={(e) => setSetupEmail(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="supervisor@example.com"
required
autoComplete="email"
@@ -691,7 +691,7 @@ export default function SelfServicePage() {
type={showSetupPassword ? "text" : "password"}
value={setupPassword}
onChange={(e) => setSetupPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="••••••••"
required
autoComplete="current-password"
@@ -703,7 +703,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={setupLoading}
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-60 transition mt-2"
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-60 transition mt-2"
>
{setupLoading ? "Signing in…" : "Sign In"}
</button>
@@ -723,7 +723,7 @@ export default function SelfServicePage() {
<select
value={selectedEventId}
onChange={(e) => setSelectedEventId(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
>
<option value=""> Select an event </option>
{events.map((ev) => (
@@ -735,9 +735,9 @@ export default function SelfServicePage() {
</div>
)}
{selectedEvent && (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-sm text-blue-800">
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-sm text-brand-800">
<p className="font-semibold">{selectedEvent.title}</p>
<p className="text-blue-600">{fmtDate(selectedEvent.startDate)} {fmtDate(selectedEvent.endDate)}</p>
<p className="text-brand-600">{fmtDate(selectedEvent.startDate)} {fmtDate(selectedEvent.endDate)}</p>
{selectedEvent.form?.isRequired && (
<p className="text-orange-600 mt-1 text-xs font-medium">This event requires an attendee form to be completed at registration.</p>
)}
@@ -746,7 +746,7 @@ export default function SelfServicePage() {
<button
onClick={handleStartKiosk}
disabled={!selectedEventId}
className="w-full bg-blue-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-blue-700 disabled:opacity-50 transition"
className="w-full bg-brand-600 text-white rounded-xl py-3.5 text-lg font-semibold hover:bg-brand-700 disabled:opacity-50 transition"
>
Start Kiosk
</button>
@@ -784,7 +784,7 @@ export default function SelfServicePage() {
else if (!v.trim() && visitorPhone.trim()) setNotificationPref("whatsapp");
}}
onBlur={handleEmailBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="john@example.com"
/>
</div>
@@ -804,7 +804,7 @@ export default function SelfServicePage() {
else if (!v.trim() && visitorEmail.trim()) setNotificationPref("email");
}}
onBlur={handlePhoneBlur}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="+27 82 000 0000"
/>
</div>
@@ -831,7 +831,7 @@ export default function SelfServicePage() {
type="text"
value={visitorName}
onChange={(e) => setVisitorName(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3.5 text-lg focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="John Smith"
required
/>
@@ -850,7 +850,7 @@ export default function SelfServicePage() {
className={`flex-1 py-3 transition-colors ${
notificationPref === p
? p === "whatsapp" ? "bg-green-600 text-white"
: p === "both" ? "bg-indigo-600 text-white"
: p === "both" ? "bg-brand-600 text-white"
: "bg-blue-600 text-white"
: "bg-white text-gray-600 hover:bg-gray-50"
}`}
@@ -901,7 +901,7 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [key]: (q[key] || 0) + 1 }))}
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
>
+
</button>
@@ -932,7 +932,7 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => setQuantities((q) => ({ ...q, [opt.id]: (q[opt.id] || 0) + 1 }))}
className="w-10 h-10 rounded-full border-2 border-blue-500 text-xl font-bold text-blue-600 hover:bg-blue-50 flex items-center justify-center transition"
className="w-10 h-10 rounded-full border-2 border-brand-500 text-xl font-bold text-brand-600 hover:bg-brand-50 flex items-center justify-center transition"
>
+
</button>
@@ -959,7 +959,7 @@ export default function SelfServicePage() {
<label className="flex items-center gap-3 cursor-pointer">
<div
onClick={() => setCreateAccount((v) => !v)}
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-blue-600" : "bg-gray-300"}`}
className={`relative w-12 h-6 rounded-full transition-colors ${createAccount ? "bg-brand-600" : "bg-gray-300"}`}
>
<span className={`absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform ${createAccount ? "translate-x-6" : ""}`} />
</div>
@@ -976,7 +976,7 @@ export default function SelfServicePage() {
type={showVisitorPassword ? "text" : "password"}
value={visitorPassword}
onChange={(e) => setVisitorPassword(e.target.value)}
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 pr-12 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
placeholder="Min. 6 characters"
autoComplete="new-password"
/>
@@ -996,7 +996,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={formLoading || accountConflict}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
>
{formLoading ? "Registering…" : eventHasRequiredForm && mainTicketCount > 0 ? "Next — Fill in Form" : "Register"}
</button>
@@ -1033,7 +1033,7 @@ export default function SelfServicePage() {
<button
type="button"
onClick={() => { setShowUpdateConfirm(false); handleRegister(); }}
className="flex-1 bg-blue-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-blue-700 transition"
className="flex-1 bg-brand-600 text-white rounded-lg py-3 text-base font-semibold hover:bg-brand-700 transition"
>
Confirm &amp; Update
</button>
@@ -1051,7 +1051,7 @@ export default function SelfServicePage() {
<h2 className="text-2xl font-bold text-gray-900 mt-1">Attendee Information</h2>
{mainTicketCount > 1 && (
<div className="mt-3">
<p className="text-base font-semibold text-blue-700">
<p className="text-base font-semibold text-brand-700">
Attendee {currentTicketIndex + 1} of {mainTicketCount}
</p>
<div className="flex gap-1.5 justify-center mt-2">
@@ -1062,7 +1062,7 @@ export default function SelfServicePage() {
i < currentTicketIndex
? "w-6 bg-green-500"
: i === currentTicketIndex
? "w-6 bg-blue-600"
? "w-6 bg-brand-600"
: "w-6 bg-gray-200"
}`}
/>
@@ -1096,7 +1096,7 @@ export default function SelfServicePage() {
checked={currentTicketAnswers[field.id] === opt}
onChange={() => setCurrentTicketAnswers(a => ({ ...a, [field.id]: opt }))}
required={field.isRequired && !currentTicketAnswers[field.id]}
className="accent-blue-600"
className="accent-brand-600"
/>
<span className="text-base">{opt}</span>
</label>
@@ -1108,7 +1108,7 @@ export default function SelfServicePage() {
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
) : field.type === "date" ? (
<input
@@ -1116,7 +1116,7 @@ export default function SelfServicePage() {
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
) : field.type === "paragraph" ? (
<textarea
@@ -1124,7 +1124,7 @@ export default function SelfServicePage() {
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
rows={3}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500 resize-none"
/>
) : (
<input
@@ -1132,7 +1132,7 @@ export default function SelfServicePage() {
value={currentTicketAnswers[field.id] || ""}
onChange={e => setCurrentTicketAnswers(a => ({ ...a, [field.id]: e.target.value }))}
required={field.isRequired}
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full border border-gray-300 rounded-xl px-4 py-3 text-base focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
)}
</div>
@@ -1147,7 +1147,7 @@ export default function SelfServicePage() {
<button
type="submit"
disabled={formSubmitLoading}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 disabled:opacity-60 transition"
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 disabled:opacity-60 transition"
>
{formSubmitLoading
? "Submitting…"
@@ -1175,7 +1175,7 @@ export default function SelfServicePage() {
<p className="text-lg text-gray-600 mb-3">Welcome, <span className="font-semibold">{confirmationName}</span>!</p>
)}
{selectedEvent && (
<p className="text-blue-700 font-medium mb-4">{selectedEvent.title}</p>
<p className="text-brand-700 font-medium mb-4">{selectedEvent.title}</p>
)}
{registrationIsFree ? (
@@ -1189,7 +1189,7 @@ export default function SelfServicePage() {
</p>
</div>
) : (
<div className="bg-blue-50 border border-blue-200 rounded-xl px-4 py-3 text-blue-800 text-sm mb-5 text-left">
<div className="bg-brand-50 border border-brand-200 rounded-xl px-4 py-3 text-brand-800 text-sm mb-5 text-left">
<p className="font-semibold mb-1">
{notificationPref === "whatsapp"
? "Payment instructions have been sent to your WhatsApp."
@@ -1197,12 +1197,12 @@ export default function SelfServicePage() {
? "Payment instructions have been sent via email and WhatsApp."
: "Payment instructions have been emailed to you."}
</p>
<p className="text-blue-700">Payment can also be made at the door cash and card accepted.</p>
<p className="text-brand-700">Payment can also be made at the door cash and card accepted.</p>
</div>
)}
<div className="mb-6">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-blue-200 bg-blue-50 text-2xl font-bold text-blue-700">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full border-4 border-brand-200 bg-brand-50 text-2xl font-bold text-brand-700">
{countdown}
</div>
<p className="text-sm text-gray-500 mt-2">Returning to registration in {countdown}s</p>
@@ -1210,7 +1210,7 @@ export default function SelfServicePage() {
<button
onClick={resetAndGoToForm}
className="w-full bg-blue-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-blue-700 transition"
className="w-full bg-brand-600 text-white rounded-xl py-4 text-xl font-bold hover:bg-brand-700 transition"
>
OK Next Person
</button>
+9 -3
View File
@@ -4,6 +4,7 @@ import React, { useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api";
import { Megaphone } from "lucide-react";
type BannerType = "info" | "warning" | "success" | "danger";
@@ -107,8 +108,13 @@ export default function SetBannerPage() {
return (
<div className="min-h-screen bg-gray-50 flex items-start justify-center px-4 py-12">
<div className="w-full max-w-lg border rounded-xl bg-white shadow-sm p-6">
<h1 className="text-xl font-semibold mb-1">Site Banner</h1>
<p className="text-sm text-gray-500 mb-6">Set a message that appears at the top of every page for visitors within the scheduled window.</p>
<div className="flex items-center gap-3 mb-1">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<Megaphone className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-xl font-semibold text-gray-900">Site Banner</h1>
</div>
<p className="text-sm text-gray-500 mb-6 mt-2">Set a message that appears at the top of every page for visitors within the scheduled window.</p>
<form onSubmit={submit} className="space-y-4">
<div>
@@ -147,7 +153,7 @@ export default function SetBannerPage() {
{status && <p className="text-sm text-emerald-700">{status}</p>}
<div className="flex gap-3 pt-1">
<button type="submit" disabled={busy} className="px-4 py-2 rounded bg-indigo-600 text-white text-sm disabled:opacity-60 hover:bg-indigo-700">
<button type="submit" disabled={busy} className="px-4 py-2 rounded bg-brand-600 text-white text-sm disabled:opacity-60 hover:bg-brand-700">
{busy ? "Saving…" : "Save banner"}
</button>
<button type="button" onClick={clear} disabled={busy} className="px-4 py-2 rounded bg-gray-100 text-gray-700 text-sm disabled:opacity-60 hover:bg-gray-200">
+111 -37
View File
@@ -3,6 +3,9 @@
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
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;
@@ -15,7 +18,7 @@ function ProgressBar({ step }: { step: Step }) {
<React.Fragment key={s}>
<div
className={`h-2 flex-1 rounded-full transition-colors ${
s < step ? "bg-indigo-600" : s === step ? "bg-indigo-400" : "bg-gray-200"
s < step ? "bg-brand-600" : s === step ? "bg-brand-400" : "bg-gray-200"
}`}
/>
</React.Fragment>
@@ -40,7 +43,7 @@ function Field({
);
}
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
export default function SetupPage() {
const router = useRouter();
@@ -68,9 +71,13 @@ export default function SetupPage() {
const [adminConfirm, setAdminConfirm] = useState("");
// 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 [logoPreview, setLogoPreview] = useState<string | null>(null);
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
const [notifEmail, setNotifEmail] = useState("");
// Step 4 — Legal
@@ -98,11 +105,19 @@ export default function SetupPage() {
const next = () => setStep((s) => Math.min(s + 1, 6) as Step);
const back = () => { setError(null); setStep((s) => Math.max(s - 1, 1) as Step); };
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
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 ───────────────────────────────────────────────────────
@@ -171,19 +186,47 @@ export default function SetupPage() {
setError(null);
setLoading(true);
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 = "";
if (logoFile) {
const fd = new FormData();
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) {
const up = await uploadRes.json();
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> = {
org_name: orgName.trim(),
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
};
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 (notifEmail.trim()) settings.reg_notification_emails = notifEmail.trim();
if (logoUrl) settings.logo_url = logoUrl;
if (faviconUrl) settings.favicon_url = faviconUrl;
// Legal
if (legalOperatorName.trim()) settings.legal_operator_name = legalOperatorName.trim();
if (legalWebsiteUrl.trim()) settings.legal_website_url = legalWebsiteUrl.trim();
@@ -224,13 +268,13 @@ export default function SetupPage() {
};
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-50 to-blue-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg w-full max-w-lg p-8">
<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 p-8 transition-all ${step === 3 ? "max-w-4xl" : "max-w-lg"}`}>
{/* Heading */}
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-indigo-100 mb-3">
<svg className="w-7 h-7 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-brand-100 mb-3">
<svg className="w-7 h-7 text-brand-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
@@ -255,7 +299,7 @@ export default function SetupPage() {
</div>
<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 />
</Field>
<Field label="Tagline">
@@ -282,7 +326,7 @@ export default function SetupPage() {
</Field>
<div className="pt-2 flex justify-end">
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">
<button type="submit" className="px-5 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium">
Next
</button>
</div>
@@ -316,7 +360,7 @@ export default function SetupPage() {
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<button type="submit" disabled={loading} className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
<button type="submit" disabled={loading} className="px-5 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium">
{loading ? "Creating account…" : "Next →"}
</button>
</div>
@@ -331,32 +375,62 @@ export default function SetupPage() {
<p className="text-sm text-gray-500">Customise the look of your site. You can change this later.</p>
</div>
<Field label="Accent / brand colour">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} value={accentColor}
onChange={e => setAccentColor(e.target.value)} placeholder="#2563eb" />
<div className="grid lg:grid-cols-5 gap-6 items-start">
<div className="lg:col-span-3 space-y-4">
<div className="grid sm:grid-cols-3 gap-4">
<ColorPickerField label="Primary" value={primaryColor} onChange={setPrimaryColor} />
<ColorPickerField label="Secondary" value={secondaryColor} onChange={setSecondaryColor} />
<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 className="mt-2 h-8 rounded-lg" style={{ background: accentColor }} />
</Field>
<Field label="Logo image (optional)" hint="PNG, JPG, SVG or WebP, max 2 MB. If skipped, a default logo is used.">
<input type="file" accept="image/*" onChange={handleLogoChange} className="text-sm" />
{logoPreview && (
<img src={logoPreview} alt="Preview" className="mt-2 h-16 object-contain rounded border" />
)}
</Field>
<Field label="Registration notification email"
hint="Who to notify when someone registers for an event. Separate multiple addresses with commas.">
<input type="email" className={inputCls} placeholder="registrations@yourchurch.org"
value={notifEmail} onChange={e => setNotifEmail(e.target.value)} />
</Field>
<div className="lg:col-span-2">
<BrandingPreviewPanel
primaryColor={primaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={logoPreview}
orgName={orgName}
orgTagline={orgTagline}
/>
</div>
</div>
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">Next </button>
<button type="submit" className="px-5 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium">Next </button>
</div>
</form>
)}
@@ -404,7 +478,7 @@ export default function SetupPage() {
<div className="pt-2 flex justify-between">
<button type="button" onClick={back} className="px-4 py-2 rounded-lg text-sm text-gray-600 hover:bg-gray-100"> Back</button>
<button type="submit" className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium">Next </button>
<button type="submit" className="px-5 py-2 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium">Next </button>
</div>
</form>
)}
@@ -487,7 +561,7 @@ export default function SetupPage() {
type="button"
disabled={loading}
onClick={() => handleFinish(false)}
className="px-5 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
className="px-5 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{loading ? "Setting up…" : "Finish setup"}
</button>
@@ -515,7 +589,7 @@ export default function SetupPage() {
)}
<button
onClick={() => router.push("/login")}
className="mt-4 px-6 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg text-sm font-medium"
className="mt-4 px-6 py-2.5 bg-brand-600 hover:bg-brand-700 text-white rounded-lg text-sm font-medium"
>
Go to login
</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>
);
}

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