Compare commits

...
Author SHA1 Message Date
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
joshuaandClaude Sonnet 5 d74fec3a5c Bump version to 1.4.2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:13:04 +02:00
joshuaandClaude Sonnet 5 4bee1b24f7 Fix supervisor payments mobile tabs, reconcile donor, report method buckets, and refund/donation accounting
- Supervisor Payments: mode tabs now wrap on mobile instead of overflowing off-screen.
- Reconciling a Yoco transaction as a donation now lets staff pick who it's from.
- Reports payment-method breakdown now buckets into Cash/Card/EFT/Other everywhere,
  folding Apple Pay, Google Pay, and Yoco-portal payments into Card.
- Refunds now net against their original method's bucket (Cashup/Finance/Profit reports,
  My Payments filtering/display) instead of vanishing or falling into "Other".
- Refund form's method dropdown mirrors the real payment methods and auto-fills from the
  payment being refunded, replacing an ambiguous generic "Refund" option.
- Fixed donation remaining/unallocated balance inflating instead of shrinking when a
  donation is refunded (assign-donation endpoint, cashup reports, donations reports,
  and the Assign Donation panel all summed refund legs with the wrong sign).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 09:08:53 +02:00
joshuaandClaude Sonnet 5 70605923bf Bump version to 1.4.1
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 15:00:44 +02:00
joshuaandClaude Sonnet 5 424141f216 Fix Reports mobile layout overflow and desktop date range overflow
Report popup buttons and the page's search/Back controls overflowed
off-screen on mobile instead of wrapping, and the sidebar's custom
date range inputs spilled outside the filter box on desktop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 14:57:09 +02:00
joshuaandClaude Sonnet 5 d6ea4c37d7 Sync root package.json version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:49:03 +02:00
joshua ebf521e64e Merge branch 'feature/reports-and-cashup-overhaul' (1.4.0) 2026-08-04 15:46:55 +02:00
joshuaandClaude Sonnet 5 e5fbcc2222 Bump version to 1.4.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:46:09 +02:00
joshuaandClaude Sonnet 5 1b4446c828 Hide dashboard sidebar on staff/supervisor/admin sub-pages; fix mobile overflow
- Sidebar now shows only on each role's root landing page
  (/dashboard/staff, /dashboard/supervisor, /dashboard/admin) and hides on
  every sub-page beneath them, not just Reports. Navbar's "Dashboard" link
  always leads back to the role root.
- Fixed two dropdown panels (EventsDropdown, the Email/WhatsApp Attendees
  recipient picker) that could extend past the viewport's right edge on
  narrow screens; both now stretch to their trigger's width like other
  dropdowns in the app already do.
- Wrapped the Admin Cashup event-costs table in the same overflow-auto
  container every sibling table on that page already uses, so it scrolls
  horizontally on narrow screens instead of squeezing its columns.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:30:56 +02:00
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:52:54 +02:00
joshua 56f2a9f7fc Merge branch 'fix/self-service-kiosk-lookup' 2026-08-03 13:03:12 +02:00
joshuaandClaude Sonnet 5 b081ed3c8b Rework self-service kiosk account lookup for privacy and safety (1.3.2)
Replaces the explicit "look up existing account" search field with automatic
lookup as email/phone are entered, requires operator confirmation before any
matched account's name/email/phone/preference is changed, adds a password
show/hide toggle, and fixes two bugs found during testing: entering a phone
number belonging to a different account could silently overwrite the form
with that account's details, and re-checking an unchanged field (e.g. from
tapping a ticket quantity button) could revert edits already made. Also adds
a server-side check rejecting registrations whose email and phone resolve to
two different existing accounts, as defense in depth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 12:56:26 +02:00
joshua 8a75c9155b Bump version to 1.3.1 2026-07-28 16:17:37 +02:00
joshua 8850984055 Merge branch 'fix/self-service-kiosk' 2026-07-28 16:16:31 +02:00
177 changed files with 11542 additions and 6117 deletions
+4
View File
@@ -34,3 +34,7 @@ Thumbs.db
# misc scratch / generated files
temp/
backend/public/uploads/
# runtime data stores (mutated by the running app, not source)
backend/data/scheduled-emails.json
backend/data/banner.json
+192 -1
View File
@@ -7,10 +7,198 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
### 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
- Supervisor Payments page: the mode tabs (Payment/Refund/Donations/Reconcile/Payment Link) now wrap onto multiple lines on mobile instead of overflowing off-screen and becoming unreachable.
- Reports: the payments report and its "by method" breakdown/chart now bucket into the same 4 categories used everywhere else (Cash, Card, EFT, Other) instead of showing raw method strings — Apple Pay, Google Pay, and Yoco checkout-portal payments now fold into "Card".
- Cashup/Finance/Profit reports: refunds were being dropped entirely from the per-method breakdown (`paymentsByMethod`) instead of netting against the method they were refunded against, so e.g. a card refund silently vanished instead of reducing the "Card" total — the per-method figures now correctly sum back to total revenue.
- Refund method now nets against the correct bucket everywhere a payment's method is normalized for display/filtering (My Payments page, reports) — `card-refund` etc. was falling through to "Other" instead of being recognized as a refund of its base method.
- Supervisor Payments page: the Refund form's method dropdown now mirrors the actual payment methods (Cash/Card/EFT/Voucher refund) instead of offering an ambiguous generic "Refund" option that couldn't be attributed to any method bucket; it now auto-fills from the original payment's method when refunding a specific payment.
- Donations: refunding a donation (fully or partially) was inflating its remaining/unallocated balance by the refunded amount instead of reducing it, since the refund's negative amount was subtracted straight into the balance (subtracting a negative adds). Could let staff over-allocate a donation that had actually shrunk. Fixed in the donation-assignment leg totals used by the assign-donation endpoint, the Cashup/Finance/Profit reports' unallocated-donations figure, the Donations and Master Orders reports' Used/Unused breakdown, and the "Assign donation" panel on the Supervisor Payments page.
### Added
- Supervisor Payments page: reconciling a Yoco transaction as a donation now lets staff optionally pick who the donation is from, instead of it always being attributed to whatever the checkout metadata (or the reconciling staff member) happened to resolve to.
## [1.4.1] - 2026-08-05
### Fixed
- Reports: on mobile, the report popup's close button now sits pinned beside the title instead of getting cut off inline with the Print/Email/Excel/WhatsApp buttons, which now have their own wrapping row below.
- Reports: on mobile, the page header's search box and Back button no longer run off-screen — they now sit on their own row and shrink to fit.
- Reports: the sidebar's custom date range inputs (From/To) are now stacked instead of side-by-side, fixing them overflowing outside the filter box on desktop.
## [1.4.0] - 2026-08-04
### Added
- Admin Manage Users page: notification preference (Email/WhatsApp/Both) can now be viewed and edited directly from the user list, instead of only being self-editable from the user's own profile.
- Admin Registrations dashboard: added aggregate stat tiles (counts per status, total revenue, total outstanding) and per-registration paid/outstanding amounts, plus a "Payments" detail block per registration showing each payment's amount, method, date, and who recorded it.
- Reports: registration status breakdown now has a "Count by" toggle to switch between counting one per registration and counting by ticket quantity (so a registration with 3 tickets counts as 3).
- Reports: donations breakdown now shows Used/Unused amounts per event, reflecting the new donation-leg tracking below.
- Payment accountability: payments now record who recorded them (`recordedBy`), separate from who they're for. Self-service/webhook payments record the payer as the recorder. Surfaced across the payments report, supervisor payments page, and the registrations dashboard.
- Cashup: new "Payment accountability by staff member" section breaking down recorded payments per staff member for an event, by method (Cash/Card/EFT/Other) — cash also shows a live actual-vs-expected variance once staff enter physical denomination counts per person.
- Cashup: staff can now enter each other's actual cash denomination counts at any time (not required to close the event); the event's cash "actual" figure is the live sum of these per-person counts instead of one manual event-wide entry.
- Cashup: new "Report" tab presenting a clean read-only summary of the cashup, which opens automatically once an event is closed.
- Reports: complete redesign — a sidebar of universal filters (events, date range with presets, include past/inactive/closed events) that apply across whichever report is open, a searchable/categorized report card grid, and a popup viewer with report-specific filters and Print/Email/Excel/WhatsApp export actions. Includes a new in-app Reporting Guide.
- Reports: WhatsApp added as an export channel alongside Print, Email, and Excel — sends the report PDF to the current user's own WhatsApp number.
- Reports: visual pass on the report popup — colored stat tiles for key totals (Order Total, Paid, Paid via donations, Outstanding, Unassigned donations, etc.) and small bar charts (revenue by method, registration status, ticket usage, donations used/unused, registration types, income by method, net profit by event) added to most reports, plus search boxes on the Master Orders' Orders/Donations tables. The Reports page also hides the dashboard sidebar since it's a full-width workspace of its own.
- Reports: Master Orders Breakdown moved from "Orders" into the "Registration" category.
- Reports: the Reporting Guide's non-Overview tabs now use the same icon-card layout as Overview instead of plain bullet lists; "Need more help?" now points to the site administrator's email (admin@crosscode.co.za).
- Email/WhatsApp Attendees: new `{{payment.link}}` placeholder that generates a live, per-recipient Yoco payment link for their outstanding balance at send time.
- Reports: PDF exports (download, Print, Email, WhatsApp) now share a branded design matching the web report — a colored title band, the same colored stat tiles and bar chart shown on screen (where applicable), an explanatory note box, and a highlighted totals row in tables — instead of a plain black-and-white dump. Since Email and WhatsApp already reuse the same PDF renderer, both now send this styled PDF automatically.
- Reports: the "Excel" export now downloads a styled `.xlsx` workbook (colored header, stat rows, a chart rendered with a native data-bar, a bold colored table header, a highlighted totals row, and frozen header/auto-sized columns) instead of a plain CSV.
### Changed
- Donations are no longer mutated when assigned to a registration — assignment now creates an immutable "leg" record referencing the original donation, so a partially-used donation keeps its original amount and remains assignable for its remaining balance instead of losing its history.
- Email Attendees / WhatsApp Attendees now respond immediately after queuing recipients and send in the background, instead of blocking the page until every message has been sent.
- Dashboard: the sidebar (My Events, Profile, Admin, etc.) is now hidden on every sub-page under Staff, Supervisor, and Admin — not just Reports — so those pages get the full width for their own content and navigation. It's still shown on each role's root landing page (`/dashboard/staff`, `/dashboard/supervisor`, `/dashboard/admin`); the top navbar's "Dashboard" link always leads back there.
### Fixed
- Reports/Cashup: fixed several places where money was double-counted once a donation was partially or fully assigned to a registration (e.g. a R250 donation with R50 assigned was showing as R300 received). Payments between dates, Revenue summary, Cashup reconciliation, Finance report, and Profit report now count each real inflow exactly once.
- Reports: Revenue Detailed and Master Orders Breakdown no longer attribute a donation-funded portion of an order to the registrant as if they'd paid it themselves — "Paid" now reflects only what the person actually paid directly, with the donation-covered amount broken out separately and attributed to the donor.
- Reports: Finance report was double-counting a registration's ticket value once under "what was sold" and again under a separate "Donations" line when the order was funded (even partially) by a donation.
- Reports: Master Orders Breakdown's "Donations made" table (donor, amount, used/unused) is now included in the PDF, Excel, Email, and WhatsApp exports — previously only the Orders table was exported and the donations breakdown was visible on screen only. Also fixed a PDF rendering bug where a table title following another table (e.g. "Donations made" below the Orders table) could render at the page's right edge instead of the left margin.
- Reports: "Email" export failed with `connect ECONNREFUSED 127.0.0.1:587` — it built its own mail transporter directly from `EMAIL_HOST`/etc. env vars instead of using the shared, DB-configurable SMTP settings (Admin → Site Settings) that the rest of the app already sends through, so it never picked up a working mail server. Now reuses the same shared mailer as tickets and account emails, with matching branded HTML styling.
- Reports: "WhatsApp" export surfaced an unhelpful `Request failed with status code 500` on failure. WhatsApp send errors now report the actual reason from the WhatsApp API, and a disconnected WhatsApp session is now detected and auto-recovered the same way it already is for other WhatsApp actions (previously only ticket/text sends had this handling — PDF sends did not).
- Dashboard mobile: the Reports page's Events filter dropdown and the Email/WhatsApp Attendees recipient-picker dropdown could extend past the right edge of narrow screens instead of staying within the viewport; they now stretch to match their trigger's width like the other dropdowns in the app already do. The Cashup event-costs table on the Admin Cashup detail page now scrolls horizontally on narrow screens instead of squeezing its columns.
## [1.3.2] - 2026-08-03
### Added
- Self-service kiosk: all password fields (supervisor sign-in, change event, and the visitor "Choose a password" field) now have a show/hide toggle button, so staff can verify what they've typed on the touchscreen instead of typing blind.
### Fixed
- Self-service kiosk: removed the separate "Look up existing account" search field — for privacy, staff no longer type a visitor's email/phone into a dedicated search box. Instead, entering an email or phone number in the registration form itself (Email and Cell Number are now the first two fields, followed by Name) automatically checks for a matching account once that field is left.
- Self-service kiosk: matched accounts are no longer updated silently. If the operator's typed Name, Email, Cell Number, or "Send tickets via" preference differs from what's on file, a confirmation dialog now lists exactly what will change (old value → new value) and requires the operator to confirm before the account is updated.
- Self-service kiosk / manual registration: an existing account's name is now actually updated when confirmed changed (previously silently discarded), and email/phone corrections are applied even when the account already had a real value on file (previously only blank phone numbers or guest-placeholder emails could be replaced).
- Self-service kiosk: fixed a bug where changing the phone number to one belonging to a different account would silently replace the Name/Email fields with that other account's details, and re-editing the email back to the original value afterward would not re-check it — together this could result in a registration being (or looking like it would be) saved under the wrong account. Email and phone matches are now tracked independently; if they resolve to two different existing accounts, the kiosk shows a clear warning naming both accounts and blocks registration until the operator corrects one of the fields, instead of silently merging or overwriting details.
- Manual registration API: added a server-side check, independent of the kiosk UI, that rejects (`409`) a registration whose submitted email and phone number belong to two different existing accounts — a defense-in-depth safeguard against one account's contact details being overwritten with, or hijacked by, another's.
- Manual registration API: an existing account's notification preference is now validated against its final email/phone after any confirmed update (e.g. falls back off "WhatsApp"/"Both" if no valid phone remains, or onto "WhatsApp" if the email was cleared in favor of a real phone), instead of persisting a preference that no longer matches the account's actual contact info.
- Self-service kiosk: tapping anywhere else on the page (e.g. a ticket quantity +/ button) while Email or Cell Number was focused blurred that field and silently re-ran its account lookup; even though the match hadn't changed, this reset Name/Email/Phone/preference back to the matched account's original values, discarding any edits the operator had just made. The autofill now only applies once per distinct matched account instead of on every re-check.
## [1.3.1] - 2026-07-28
### Fixed
- Self-service kiosk: closed events no longer appear in the event picker — only open events are selectable.
- Self-service kiosk: added a "Look up existing account" search field (by email or phone, triggered only by Enter or the Search button — never as-you-type) that autofills a returning visitor's name, contact details, and notification preference from their exact matching account, instead of requiring staff to re-enter details already on file.
- Self-service kiosk: when the account lookup finds no match, the typed query now carries over into whichever of Email/Phone it resembles (instead of being discarded), while Name and everything else resets blank for a fresh entry.
## [1.3.0] - 2026-07-27
@@ -74,7 +262,10 @@ and this project follows [Semantic Versioning](https://semver.org/).
- Initial release of the Hope Family Church event management app (Next.js frontend + Express/Prisma backend).
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...main
[Unreleased]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.4.0...main
[1.4.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.2...v1.4.0
[1.3.2]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.1...v1.3.2
[1.3.1]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.3.0...v1.3.1
[1.3.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.2.0...v1.3.0
[1.2.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.crosscode.co.za/joshua/hope-events/compare/v1.0.1...v1.1.0
+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": []
}
+841 -7
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.3.0",
"version": "1.6.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -21,6 +21,7 @@
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"exceljs": "^4.4.0",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.0.2",
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
ADD COLUMN "feeChannel" TEXT,
ADD COLUMN "feePayer" TEXT,
ADD COLUMN "feeRate" DOUBLE PRECISION;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "feePayerOnline";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feePayer";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "recordedById" TEXT;
-- CreateIndex
CREATE INDEX "Payment_recordedById_idx" ON "Payment"("recordedById");
-- CreateIndex
CREATE INDEX "Payment_originalPaymentId_idx" ON "Payment"("originalPaymentId");
-- AddForeignKey
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_recordedById_fkey" FOREIGN KEY ("recordedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "EventCashupPersonCount" (
"id" TEXT NOT NULL,
"eventId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"enteredById" TEXT,
"notes" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "EventCashupPersonCount_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "EventCashupPersonCountDenomination" (
"id" TEXT NOT NULL,
"countId" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"count" INTEGER NOT NULL,
CONSTRAINT "EventCashupPersonCountDenomination_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "EventCashupPersonCount_eventId_idx" ON "EventCashupPersonCount"("eventId");
-- CreateIndex
CREATE UNIQUE INDEX "EventCashupPersonCount_eventId_userId_key" ON "EventCashupPersonCount"("eventId", "userId");
-- CreateIndex
CREATE INDEX "EventCashupPersonCountDenomination_countId_idx" ON "EventCashupPersonCountDenomination"("countId");
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCount" ADD CONSTRAINT "EventCashupPersonCount_enteredById_fkey" FOREIGN KEY ("enteredById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "EventCashupPersonCountDenomination" ADD CONSTRAINT "EventCashupPersonCountDenomination_countId_fkey" FOREIGN KEY ("countId") REFERENCES "EventCashupPersonCount"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -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");
+64
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
@@ -63,6 +69,7 @@ model User {
notificationPreference NotificationPreference @default(email)
registrations Registration[]
payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
tickets Ticket[]
ticketScans TicketUsage[]
passwordResets PasswordReset[]
@@ -74,6 +81,11 @@ model User {
eventsReopened Event[] @relation("EventReopenedBy")
cashupsPerformed EventCashup[]
notifyForEvents Event[] @relation("EventNotifyRecipients")
personCashCountsFor EventCashupPersonCount[] @relation("EventCashupPersonCountFor")
personCashCountsEntered EventCashupPersonCount[] @relation("EventCashupPersonCountEnteredBy")
securityEvents SecurityEvent[]
}
model Event {
@@ -112,6 +124,7 @@ model Event {
reopenedBy User? @relation("EventReopenedBy", fields: [reopenedById], references: [id], onDelete: SetNull)
costs EventCost[]
cashups EventCashup[]
personCashCounts EventCashupPersonCount[]
// Users who should receive registration/payment/daily-summary notifications for this
// event. Falls back to `createdBy` when empty (see backend/src/utils/notifications.js).
@@ -188,6 +201,7 @@ model Registration {
@@index([eventId])
@@index([userId, status])
@@index([eventId, status])
@@index([createdAt, status])
}
model RegistrationOption {
@@ -215,6 +229,7 @@ model Payment {
amount Float
method String
userId String
recordedById String?
registrationId String?
eventId String?
isDonation Boolean @default(false)
@@ -223,6 +238,7 @@ model Payment {
originalPaymentId String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
recordedBy User? @relation("PaymentRecordedBy", fields: [recordedById], references: [id], onDelete: SetNull)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
event Event? @relation(fields: [eventId], references: [id], onDelete: SetNull)
originalPayment Payment? @relation("SplitPayments", fields: [originalPaymentId], references: [id], onDelete: SetNull)
@@ -230,9 +246,11 @@ model Payment {
YocoTransaction YocoTransaction[]
@@index([userId])
@@index([recordedById])
@@index([registrationId])
@@index([eventId])
@@index([createdAt])
@@index([originalPaymentId, isDonation])
}
model Ticket {
@@ -254,6 +272,7 @@ model Ticket {
@@index([eventId])
@@index([userId])
@@index([registrationOptionId])
@@index([createdAt])
}
model TicketUsage {
@@ -282,6 +301,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
@@ -479,6 +513,36 @@ model EventCashupDenomination {
@@index([lineId])
}
// Actual physical cash counted for one staff member's recorded cash payments, entered any
// time (not part of the close flow) purely for accountability — compared against the
// system-expected amount (computeCashAccountabilityByUser) to show a per-person variance.
model EventCashupPersonCount {
id String @id @default(uuid())
eventId String
userId String
enteredById String?
notes String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
user User @relation("EventCashupPersonCountFor", fields: [userId], references: [id], onDelete: Cascade)
enteredBy User? @relation("EventCashupPersonCountEnteredBy", fields: [enteredById], references: [id], onDelete: SetNull)
denominations EventCashupPersonCountDenomination[]
@@unique([eventId, userId])
@@index([eventId])
}
model EventCashupPersonCountDenomination {
id String @id @default(uuid())
countId String
value Float
count Int
personCount EventCashupPersonCount @relation(fields: [countId], references: [id], onDelete: Cascade)
@@index([countId])
}
// ─── Church Website Models (disabled — kept for reference, not active Prisma models) ──
//
// model Ministry {
+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`);
+24 -11
View File
@@ -29,14 +29,7 @@ function buildEventContext(event) {
return { eventTitle, eventStart, eventLink, eventLinkHtml };
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''));
}
const { replacePlaceholders } = require('../utils/placeholders');
function parseFreeformEmails(lines) {
// Supports formats:
@@ -64,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
@@ -166,9 +176,9 @@ const sendBroadcast = async (req, res) => {
eventLink: escapeHtml(eventCtx.eventLink),
eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase;
const finalSubject = replacePlaceholders(subject, ctxBase);
const finalHtml = html ? replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? replacePlaceholders(text || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
}));
@@ -198,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,
});
+68 -16
View File
@@ -1,7 +1,7 @@
const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials } = require('../utils/cashupUtils');
const { ALL_METHODS, assertEventOpen, computeEventFinancials, computeAccountabilityByUser, computeEventCashActualFromPersonCounts, savePersonCashCount } = require('../utils/cashupUtils');
// @desc Cashup preview for an event: live expected/actual numbers, costs, donations-to-profit, and history
// @route GET /api/cashups/event/:eventId
@@ -15,6 +15,45 @@ const getEventCashup = async (req, res) => {
}
};
// @desc Payments recorded for an event, broken down by staff member and method (cash/card/eft/
// other) — cash also includes the live actual-vs-expected from per-person counts.
// @route GET /api/cashups/event/:eventId/cash-by-user
// @access Private/Supervisor
const getCashByRecordedUser = async (req, res) => {
try {
const [rows, cash] = await Promise.all([
computeAccountabilityByUser(req.params.eventId),
computeEventCashActualFromPersonCounts(req.params.eventId)
]);
res.json({ rows, cashActualTotal: cash.actual, cashDenominations: cash.denominations });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Enter (or update) one staff member's actual physical cash count for an event —
// optional, can be done any time, purely for per-person accountability. Never blocks
// or is required for closing the event.
// @route PUT /api/cashups/event/:eventId/person-cash/:userId
// @access Private/Supervisor
const savePersonCash = async (req, res) => {
try {
const { eventId, userId } = req.params;
const { denominations, notes } = req.body;
const targetUser = await prisma.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!targetUser) {
res.status(404);
throw new Error('User not found');
}
const record = await savePersonCashCount(eventId, userId, { denominations, notes, enteredById: req.user.id });
res.json(record);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Save in-progress reconciliation entries without closing the event
// @route PUT /api/cashups/event/:eventId/draft
// @access Private/Admin
@@ -49,30 +88,41 @@ const closeEvent = async (req, res) => {
const financials = await computeEventFinancials(eventId);
const isFullCashup = Array.isArray(lines) && lines.length > 0;
const cashupLines = isFullCashup
? lines
.filter(l => l && ALL_METHODS.includes(l.method))
let cashupLines = [];
if (isFullCashup) {
const nonCashLines = lines
.filter(l => l && ALL_METHODS.includes(l.method) && l.method !== 'cash')
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0)
: [];
const actual = denominations.length > 0
? denominations.reduce((sum, d) => sum + d.value * d.count, 0)
: (l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null);
const actual = l.actualAmount !== undefined && l.actualAmount !== null && l.actualAmount !== '' ? parseFloat(l.actualAmount) : null;
return {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null,
denominations: denominations.length > 0 ? { create: denominations } : undefined
notes: l.notes || null
};
})
: [];
});
// Cash is never entered as a single event-wide figure — it's always the live sum of every
// staff member's per-person count (see computeEventCashActualFromPersonCounts), so it's
// always sourced here rather than from whatever (if anything) the frontend sent for it.
const cashLineInput = lines.find(l => l && l.method === 'cash');
const { actual: cashActual, denominations: cashDenominations } = await computeEventCashActualFromPersonCounts(eventId);
const cashExpected = financials.expectedCashByMethod.cash || 0;
const cashLine = {
id: uuidv4(),
method: 'cash',
expectedAmount: cashExpected,
actualAmount: cashActual,
variance: cashActual !== null ? cashActual - cashExpected : null,
notes: cashLineInput?.notes || null,
denominations: cashDenominations.length > 0 ? { create: cashDenominations } : undefined
};
cashupLines = [cashLine, ...nonCashLines];
}
const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
@@ -179,6 +229,8 @@ const getCashupAudit = async (req, res) => {
module.exports = {
getEventCashup,
getCashByRecordedUser,
savePersonCash,
saveEventCashupDraft,
closeEvent,
reopenEvent,
+105 -62
View File
@@ -1226,6 +1226,7 @@ const emailEventAttendees = async (req, res) => {
const { sendMail } = require('../utils/email');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
@@ -1234,17 +1235,6 @@ const emailEventAttendees = async (req, res) => {
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
}
// Build per-recipient registration aggregates for this event
const regsByEmail = new Map();
@@ -1255,42 +1245,66 @@ const emailEventAttendees = async (req, res) => {
regsByEmail.get(em).push(r);
}
let sent = 0;
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
// Special handling for tickets template: trigger ticket emails containing attachments
// Generates a live Yoco checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}, to
// avoid an unnecessary Yoco API call per recipient otherwise.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Fire-and-forget: respond immediately with a queued count, then send in the background.
// Large recipient lists used to block the request until every email was sent — now the
// caller gets an instant response and failures are just logged server-side.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Send tickets for each registration that belongs to this recipient for this event
for (const reg of regs) {
// Use the controller helper as in other parts of the code
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
sent++;
} catch (e) {
try { console.warn('[email-attendees tickets] Failed for', rcpt.email, e?.message || e); } catch {}
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees tickets] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
});
})();
return;
}
const eventTitle = event?.title || 'the event';
const eventStart = event?.startDate ? fmtDate(event.startDate) : '';
// Build event and promo links for placeholders
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
const eventLinkHtml = `<a href="${eventLink}">${eventLink}</a>`;
const promoTitle = promoEvent?.title || '';
const promoLink = promoEvent ? `${baseUrl}/events/${encodeURIComponent(promoEvent.id)}` : '';
const promoLinkHtml = promoLink ? `<a href="${promoLink}">${promoLink}</a>` : '';
// Send individually with personalization
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByEmail.get(rcpt.email) || [];
// Sum outstanding balance across this user's registrations for the event
let totalDue = 0; let totalPaid = 0;
@@ -1300,6 +1314,7 @@ const emailEventAttendees = async (req, res) => {
totalDue += due; totalPaid += paid;
}
const balance = Math.max(totalDue - totalPaid, 0);
const paymentLinkResolver = makePaymentLinkResolver(regs);
// Build context for placeholder replacement
const ctxBase = {
name: rcpt.name || '',
@@ -1310,6 +1325,7 @@ const emailEventAttendees = async (req, res) => {
promoLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver,
};
const ctxHtml = {
...ctxBase,
@@ -1347,18 +1363,18 @@ const emailEventAttendees = async (req, res) => {
}
// Always perform placeholder replacement on whatever we have
const finalSubject = replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? replacePlaceholders(t || '', ctxBase) : undefined);
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
sent++;
} catch (e) {
try { console.warn('[email-attendees] Failed for', rcpt.email, e?.message || e); } catch {}
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[email-attendees] Failed for', recipients[i]?.email, r.reason?.message || r.reason); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -1445,6 +1461,7 @@ const whatsappEventAttendees = async (req, res) => {
const { sendText } = require('../utils/whatsapp');
const { computeRegistrationTotalDue } = require('../utils/pricing');
const { replacePlaceholders } = require('../utils/placeholders');
function fmtAmount(amt) {
const n = Number(amt || 0);
@@ -1453,15 +1470,6 @@ const whatsappEventAttendees = async (req, res) => {
function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); }
}
function replacePlaceholders(str, ctx) {
if (!str) return str;
return String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, ctx.eventLink || '')
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
}
const regsByPhone = new Map();
for (const r of registrations) {
@@ -1476,29 +1484,52 @@ const whatsappEventAttendees = async (req, res) => {
const baseUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const eventLink = `${baseUrl}/events/${encodeURIComponent(event.id)}`;
let sent = 0;
// Generates a live Yoco checkout link for the recipient's first registration with an
// outstanding balance — only called when the template actually uses {{payment.link}}.
function makePaymentLinkResolver(regs) {
return async () => {
const reg = (regs || []).find(r => {
const due = computeRegistrationTotalDue(r, new Date());
const paid = (r.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
return due - paid > 0.01;
});
if (!reg) return '';
const { createRegistrationCheckoutInternal } = require('./paymentController');
const result = await createRegistrationCheckoutInternal(reg.id, reg.userId, {
successUrl: `${baseUrl}/payment/success`,
cancelUrl: `${baseUrl}/payment/cancel`,
failureUrl: `${baseUrl}/payment/failure`,
});
return result.redirectUrl;
};
}
// Special handling for tickets template: send ticket PDFs via WhatsApp
// Fire-and-forget: respond immediately with a queued count, then send in the background.
if (template === 'tickets') {
const { emailTickets } = require('./ticketController');
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: 'tickets' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
for (const reg of regs) {
const mockReq = { user: { id: reg.userId }, body: { registrationId: reg.id } };
const mockRes = { status: () => mockRes, json: () => {} };
await emailTickets(mockReq, mockRes);
}
sent++;
} catch (e) {
try { console.warn('[whatsapp-attendees tickets] Failed for', rcpt.phone, e?.message || e); } catch {}
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees tickets] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: 'tickets' });
});
})();
return;
}
for (const rcpt of recipients) {
try {
res.json({ eventId, matched: recipients.length, queued: recipients.length, template: template || 'custom' });
(async () => {
const results = await Promise.allSettled(recipients.map(async rcpt => {
const regs = regsByPhone.get(rcpt.phone) || [];
let totalDue = 0; let totalPaid = 0;
for (const r of regs) {
@@ -1514,6 +1545,7 @@ const whatsappEventAttendees = async (req, res) => {
eventLink,
balance,
balanceFmt: fmtAmount(balance),
paymentLinkResolver: makePaymentLinkResolver(regs),
};
let msg = message;
@@ -1523,20 +1555,29 @@ const whatsappEventAttendees = async (req, res) => {
msg = `Hi {{name}}\n\nA quick reminder about {{event.title}}.\nStart: {{event.start}}\n\nWe look forward to seeing you!`;
}
const finalMessage = replacePlaceholders(msg || '', ctx);
const finalMessage = await replacePlaceholders(msg || '', ctx);
await sendText(rcpt.phone, finalMessage);
sent++;
} catch (e) {
try { console.warn('[whatsapp-attendees] Failed for', rcpt.phone, e?.message || e); } catch {}
}));
results.forEach((r, i) => {
if (r.status === 'rejected') {
try { console.warn('[whatsapp-attendees] Failed for', recipients[i]?.phone, r.reason?.message || r.reason); } catch {}
}
}
return res.json({ eventId, matched: recipients.length, sent, template: template || 'custom' });
});
})();
} catch (error) {
return res.status(400).json({ message: error.message });
}
};
// 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
@@ -1568,6 +1609,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
eventId,
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title}${describeAttendeeFilter(filter)}`,
payload,
});
@@ -1605,6 +1647,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
channel: 'whatsapp',
createdById: req.user?.id || null,
scheduledAt: when.toISOString(),
recipientSummary: `${event.title}${describeAttendeeFilter(filter)}`,
payload,
});
+217 -52
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
@@ -157,6 +157,7 @@ const createPayment = async (req, res) => {
amount: requestedAmount,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -164,6 +165,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
event: true
}
});
@@ -175,6 +177,7 @@ const createPayment = async (req, res) => {
amount: applyAmount,
method,
userId,
recordedById: req.user.id,
registrationId,
eventId: registrationEventId || eventId || null,
isDonation: false,
@@ -182,6 +185,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: (registrationEventId || eventId) ? true : undefined
}
@@ -195,6 +199,7 @@ const createPayment = async (req, res) => {
amount: excess,
method,
userId,
recordedById: req.user.id,
registrationId: null,
eventId: registration.eventId,
isDonation: true,
@@ -212,6 +217,7 @@ const createPayment = async (req, res) => {
amount: parseFloat(amount),
method,
userId,
recordedById: req.user.id,
registrationId: registrationId || null,
eventId: registrationEventId || eventId || null,
isDonation: isDonation || false,
@@ -219,6 +225,7 @@ const createPayment = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: registrationId ? { include: { event: true } } : undefined,
event: (registrationEventId || eventId) ? true : undefined
}
@@ -329,6 +336,7 @@ const getPayments = async (req, res) => {
const include = {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: {
event: true,
@@ -357,8 +365,16 @@ const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
const CARD_ALIASES = ['apple_pay', 'google_pay'];
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
function normalizeUserMethod(method) {
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
// against the same bucket its original payment counted under. Strip that suffix before
// bucketing so a card refund still displays/filters as "card", not "other".
function stripRefundSuffix(method) {
const m = String(method || '').toLowerCase();
return m.endsWith('-refund') ? m.slice(0, -'-refund'.length) : m;
}
function normalizeUserMethod(method) {
const m = stripRefundSuffix(method);
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return 'card';
return 'other';
@@ -386,18 +402,21 @@ const getUserPayments = async (req, res) => {
if (req.query.method) {
const requested = String(req.query.method).toLowerCase();
if (requested === 'card') {
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
// settlement as a card payment, no separate float to reconcile.
// "Card" also covers card-network wallet types (apple_pay, google_pay) and card
// refunds — same settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: ['card', 'card-refund', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
// "Other" covers every method (and its refund variant) that isn't one of the
// recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: KNOWN_METHODS.flatMap(m => [m, `${m}-refund`]).map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, mode: 'insensitive' };
where.AND = [{
OR: [requested, `${requested}-refund`].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
}
}
@@ -452,6 +471,13 @@ const getPaymentById = async (req, res) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: {
include: {
event: true
@@ -507,6 +533,13 @@ const getPaymentsByRegistration = async (req, res) => {
name: true,
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
});
@@ -533,6 +566,7 @@ const getPaymentsByEvent = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: {
include: { user: { select: { id: true, name: true, email: true } } }
}
@@ -576,10 +610,21 @@ const assignDonationToRegistration = async (req, res) => {
throw new Error('Only donations can be assigned to registrations');
}
// Check if payment is already assigned to a registration
if (payment.registrationId) {
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
// the remaining balance (money that's left the building) instead of increasing it (which a
// raw signed sum would do, since subtracting a negative adds).
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
const remainingDonation = payment.amount - alreadyUsed;
if (remainingDonation <= 0.000001) {
res.status(400);
throw new Error('This payment is already assigned to a registration');
throw new Error('This donation has already been fully allocated');
}
if (payment.eventId) {
@@ -628,16 +673,16 @@ const assignDonationToRegistration = async (req, res) => {
}
// How much of the donation to apply — defaults to today's behaviour (as much as the
// donation covers, capped at what's owed) but staff can specify a smaller amount and
// deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(payment.amount, remainingAmount);
// donation's remaining balance covers, capped at what's owed) but staff can specify a
// smaller amount and deliberately leave the registrant owing a balance.
let allocateAmount = amount != null ? Number(amount) : Math.min(remainingDonation, remainingAmount);
if (!(allocateAmount > 0) || Number.isNaN(allocateAmount)) {
res.status(400);
throw new Error('Allocation amount must be greater than zero');
}
if (allocateAmount > payment.amount) {
if (allocateAmount > remainingDonation) {
res.status(400);
throw new Error('Cannot allocate more than the donation amount');
throw new Error(`Cannot allocate more than the donation's remaining balance of R${remainingDonation.toFixed(2)}`);
}
if (allocateAmount > remainingAmount) {
res.status(400);
@@ -647,38 +692,23 @@ const assignDonationToRegistration = async (req, res) => {
let updatedRegistration;
let generatedTickets = [];
let originalPaymentId = payment.id;
let splitPayment = null;
// Update the payment to be associated with the registration and adjust amount
await prisma.payment.update({
where: { id: payment.id },
data: {
registrationId,
amount: allocateAmount,
isDonation: false
}
});
// If less than the full donation was allocated, the remainder stays as an unassigned
// donation (same donor, no notification — it's a bookkeeping split, not a new gift).
if (allocateAmount < payment.amount) {
const leftoverAmount = payment.amount - allocateAmount;
splitPayment = await prisma.payment.create({
// Create an immutable leg referencing the donation — the donation row itself is never
// touched, so its original amount and history stay intact and it can be assigned again
// later if this leg doesn't use it up.
const leg = await prisma.payment.create({
data: {
id: uuidv4(),
amount: leftoverAmount,
amount: allocateAmount,
method: payment.method,
userId: payment.userId,
eventId: payment.eventId,
isDonation: true,
externalId: payment.externalId ? `${payment.externalId}-split` : null,
status: payment.status,
recordedById: req.user.id,
registrationId,
eventId: registration.eventId,
isDonation: false,
originalPaymentId: payment.id,
createdAt: payment.createdAt
}
});
}
if (allocateAmount >= remainingAmount) {
// Fully covers what's owed
@@ -707,9 +737,9 @@ const assignDonationToRegistration = async (req, res) => {
}
// Fire-and-forget: notify the registrant (not the donor — see sendDonationAssignmentEmails),
// then tickets (guarantees order). The split/leftover payment is never notified.
// then tickets (guarantees order).
const { sendDonationAssignmentEmails } = require('../utils/notifications');
const _adPaymentId = payment?.id;
const _adPaymentId = leg?.id;
const _adShouldEmailTickets = generatedTickets.length > 0;
const _adUserId = registration?.userId;
const _adRegId = registrationId;
@@ -730,10 +760,8 @@ const assignDonationToRegistration = async (req, res) => {
originalPaymentId,
updatedRegistration,
generatedTickets: generatedTickets.length > 0 ? generatedTickets : undefined,
splitPayment: splitPayment ? {
...splitPayment,
originalPaymentId: payment.id
} : null
leg,
donationRemaining: remainingDonation - allocateAmount
};
res.status(200).json(result);
@@ -742,6 +770,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 } } } },
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 } } } },
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 } = {}) {
@@ -1029,20 +1180,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 });
@@ -1151,6 +1304,7 @@ const createRefund = async (req, res) => {
amount: -Math.abs(amt),
method: method || 'refund',
userId,
recordedById: req.user.id,
registrationId: linkRegistrationId,
eventId: linkEventId,
isDonation: false,
@@ -1159,6 +1313,7 @@ const createRefund = async (req, res) => {
},
include: {
user: { select: { id: true, name: true, email: true } },
recordedBy: { select: { id: true, name: true, email: true } },
registration: { include: { event: true } },
event: true
}
@@ -1210,6 +1365,12 @@ const getPaymentStats = async (req, res) => {
const lastWeek = new Date(startOfDay.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastMonth = new Date(startOfDay.getTime() - 30 * 24 * 60 * 60 * 1000);
// Exclude donation-application legs — a leg re-labels part of an already-counted donation
// as applied to a registration, it isn't new money. Summing both would double-count it.
const excludeDonationLegs = {
NOT: { AND: [{ isDonation: false }, { originalPaymentId: { not: null } }, { amount: { gt: 0 } }] }
};
const [totalToday, totalWeek, totalMonth] = await Promise.all([
prisma.payment.aggregate({
_sum: {
@@ -1218,7 +1379,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: startOfDay
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1228,7 +1390,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastWeek
}
},
...excludeDonationLegs
}
}),
prisma.payment.aggregate({
@@ -1238,7 +1401,8 @@ const getPaymentStats = async (req, res) => {
where: {
createdAt: {
gte: lastMonth
}
},
...excludeDonationLegs
}
})
]);
@@ -1260,6 +1424,7 @@ module.exports = {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
createRegistrationCheckoutInternal,
sendPaymentLink,
@@ -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({
@@ -378,9 +379,8 @@ const createRegistration = async (req, res) => {
// @access Private/Admin
const getRegistrations = async (req, res) => {
try {
const registrations = await prisma.registration.findMany({
include: {
payments: true,
const include = {
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
registrationOptions: {
include: {
eventOption: { include: { earlyBirdTiers: true } },
@@ -396,10 +396,26 @@ const getRegistrations = async (req, res) => {
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(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: registrations, total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) {
res.status(400).json({ message: error.message });
}
@@ -472,7 +488,7 @@ const getRegistrationById = async (req, res) => {
phoneNumber: true
}
},
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
formResponses: { include: { answers: true } }
}
});
@@ -653,7 +669,7 @@ const getRegistrationsByEvent = async (req, res) => {
tickets: true,
}
},
payments: true,
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }
},
orderBy: { createdAt: 'asc' }
@@ -764,32 +780,66 @@ const createManualRegistration = async (req, res) => {
? prefFromBody
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
// Always search by email AND/OR phone regardless of guestOnly
// Also try the alternate format (27xxx ↔ 0xxx) so both representations match
// Always search by email AND/OR phone regardless of guestOnly.
// Resolve each channel independently (rather than a single findFirst with an OR
// across both) so that an email belonging to one account and a phone number
// belonging to a *different* account can never be silently collapsed into
// whichever record happens to match first — that would let a registration
// hijack or corrupt someone else's account. Also try the alternate phone
// format (27xxx ↔ 0xxx) so both representations match.
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
const searchClauses = [
...(hasValidEmail ? [{ email: user.email }] : []),
...(phone ? [{ phoneNumber: phone }] : []),
...(phoneAlt ? [{ phoneNumber: phoneAlt }] : []),
];
const existingUser = searchClauses.length > 0
? await prisma.user.findFirst({ where: { OR: searchClauses } })
const emailUser = hasValidEmail
? await prisma.user.findUnique({ where: { email: user.email } })
: null;
const phoneUser = phone
? await prisma.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } })
: null;
if (emailUser && phoneUser && emailUser.id !== phoneUser.id) {
res.status(409);
throw new Error(
`This email and phone number belong to two different existing accounts (${emailUser.name} vs ${phoneUser.name}). Please verify the visitor's details before registering.`
);
}
const existingUser = emailUser || phoneUser || null;
if (existingUser) {
userId = existingUser.id;
const updateData = {};
// Update preference only when the caller explicitly specified one
if (prefFromBody && validPrefs.includes(prefFromBody)) {
updateData.notificationPreference = prefFromBody;
// The kiosk shows the operator a diff of name/email/phone against the matched
// account and requires explicit confirmation before submitting, so any
// difference reaching this point is an already-confirmed correction — apply
// it as a full overwrite rather than only filling in blanks.
if (user.name && user.name.trim() && user.name.trim() !== existingUser.name) {
updateData.name = user.name.trim();
}
// Fill in a missing contact channel with the newly supplied value, without overwriting an existing one
if (phone && !existingUser.phoneNumber) {
if (phone && phone !== existingUser.phoneNumber) {
updateData.phoneNumber = phone;
}
if (hasValidEmail && existingUser.email !== user.email && existingUser.email.endsWith('@guest.local')) {
if (hasValidEmail && existingUser.email !== user.email) {
updateData.email = user.email;
}
// Update preference only when the caller explicitly specified one, but validate it
// against the contact info that will actually be on the account after this update —
// a stale "whatsapp"/"both" preference must not survive a phone number being
// removed, nor "email" survive an email being cleared in favor of a real phone.
if (prefFromBody && validPrefs.includes(prefFromBody)) {
const { isValidZAPhone } = require('../utils/whatsapp');
const finalPhone = updateData.phoneNumber !== undefined ? updateData.phoneNumber : existingUser.phoneNumber;
const finalEmail = updateData.email !== undefined ? updateData.email : existingUser.email;
const finalEmailValid = !!(finalEmail && !finalEmail.endsWith('@guest.local'));
let candidatePref = prefFromBody;
if ((candidatePref === 'whatsapp' || candidatePref === 'both') && !isValidZAPhone(finalPhone)) {
candidatePref = finalEmailValid ? 'email' : candidatePref;
}
if (candidatePref === 'email' && !finalEmailValid && isValidZAPhone(finalPhone)) {
candidatePref = 'whatsapp';
}
updateData.notificationPreference = candidatePref;
}
if (Object.keys(updateData).length > 0) {
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
}
+400 -175
View File
@@ -1,177 +1,123 @@
const PDFDocument = require('pdfkit');
const ExcelJS = require('exceljs');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
// Utility: draw a table
function drawTable(doc, startX, startY, colWidths, rows, header) {
let y = startY;
doc.font('Helvetica-Bold');
if (header && header.length) {
let x = startX;
header.forEach((h, i) => {
const w = colWidths[i] || 80;
doc.rect(x, y, w, 20).stroke();
doc.text(String(h || ''), x + 4, y + 6, { width: w - 8 });
x += w;
});
y += 20;
}
doc.font('Helvetica');
rows.forEach((row) => {
let x = startX;
row.forEach((cell, i) => {
const w = colWidths[i] || 80;
const h = 18;
doc.rect(x, y, w, h).stroke();
doc.text(String(cell ?? ''), x + 4, y + 4, { width: w - 8 });
x += w;
});
y += 18;
// New page if overflow
if (y > doc.page.height - 40) {
doc.addPage();
y = 20;
}
});
}
const { sendMail, emailWrapper } = require('../utils/email');
function a4Doc(orientation = 'portrait') {
return new PDFDocument({ size: 'A4', margin: 20, layout: orientation === 'landscape' ? 'landscape' : 'portrait' });
}
// POST /api/reports/pdf
// body: { title: string, kind: 'table'|'layered', table?: { columns: string[], rows: string[][] }, layered?: { header?: string, sections: { title: string, items: string[] }[] } }
const generatePdf = async (req, res) => {
try {
const { title, kind, table, layered, orientation } = req.body || {};
res.setHeader('Content-Type', 'application/pdf');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Brand palette — mirrors the web reports' indigo theme and the dataviz-skill categorical
// palette used by the on-screen HorizontalBarChart, so exported PDFs/Excel look like the
// same report instead of a plain data dump.
const BRAND = '#4f46e5';
const BRAND_DARK = '#3730a3';
const BRAND_LIGHT = '#eef2ff';
const TEXT_DARK = '#111827';
const TEXT_MUTED = '#6b7280';
const BORDER = '#e5e7eb';
const TONE_COLORS = {
green: { bg: '#ecfdf5', accent: '#059669' },
blue: { bg: '#eff6ff', accent: '#2563eb' },
violet: { bg: '#f5f3ff', accent: '#7c3aed' },
amber: { bg: '#fffbeb', accent: '#d97706' },
rose: { bg: '#fff1f2', accent: '#e11d48' },
gray: { bg: '#f3f4f6', accent: '#4b5563' },
};
const CATEGORICAL_COLORS = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#4a3aa7', '#e34948'];
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
doc.pipe(res);
function isEmphasisRow(firstCell) {
const s = String(firstCell ?? '').trim();
return /^total$/i.test(s) || /net profit/i.test(s) || /unassigned donations/i.test(s) || /revenue per ticket/i.test(s);
}
// Title
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report', { align: 'left' });
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
function drawHeader(doc, title, subtitle) {
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column to mimic site tables
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
// Draw header band
if (columns.length) {
let x = doc.page.margins.left;
const y = doc.y;
const x = doc.page.margins.left;
const y = doc.page.margins.top;
const h = subtitle ? 46 : 32;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
x += w;
});
doc.roundedRect(x, y, pageWidth, h, 6).fill(BRAND);
doc.fillColor('#ffffff').font('Helvetica-Bold').fontSize(16).text(title || 'Report', x + 14, y + 9, { width: pageWidth - 28 });
if (subtitle) {
doc.font('Helvetica').fontSize(9).fillColor('#e0e7ff').text(subtitle, x + 14, y + 30, { width: pageWidth - 28 });
}
doc.restore();
doc.moveDown(1.6);
doc.y = y + h + 14;
}
// Zebra rows
const rows = table.rows;
rows.forEach((row, idx) => {
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
function drawStats(doc, stats) {
if (!Array.isArray(stats) || stats.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const perRow = Math.max(1, Math.min(5, Math.floor(pageWidth / 110)));
const gap = 8;
const boxW = (pageWidth - gap * (perRow - 1)) / perRow;
const boxH = 34;
let rowY = doc.y;
stats.forEach((s, i) => {
const col = i % perRow;
if (col === 0 && i !== 0) rowY += boxH + gap;
const bx = doc.page.margins.left + col * (boxW + gap);
const tone = TONE_COLORS[s.tone] || TONE_COLORS.gray;
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
// Cell text
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
// Vertical separators similar to table borders
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
doc.roundedRect(bx, rowY, boxW, boxH, 5).fill(tone.bg);
doc.fillColor(TEXT_MUTED).font('Helvetica').fontSize(7.5).text(String(s.label || ''), bx + 8, rowY + 6, { width: boxW - 16 });
doc.fillColor(tone.accent).font('Helvetica-Bold').fontSize(11).text(String(s.value || ''), bx + 8, rowY + 17, { width: boxW - 16 });
doc.restore();
});
// Right border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
// Bottom border
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
// Bullet dot
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
}
doc.moveDown(0.5);
doc.y = rowY + boxH + 16;
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
function drawChart(doc, chart) {
if (!chart || !Array.isArray(chart.data) || chart.data.length === 0) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
if (chart.title) {
doc.fillColor(TEXT_MUTED).font('Helvetica-Bold').fontSize(9).text(chart.title, x, doc.y);
doc.moveDown(0.4);
}
const labelW = 110;
const valueW = 80;
const barAreaW = pageWidth - labelW - valueW - 16;
const max = Math.max(1, ...chart.data.map(d => Math.abs(d.value || 0)));
const rowH = 16;
chart.data.forEach((d, i) => {
const y = doc.y;
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(String(d.label || ''), x, y + 3, { width: labelW - 8 });
const trackX = x + labelW;
doc.roundedRect(trackX, y + 2, barAreaW, 8, 4).fill('#f3f4f6');
const w = Math.max(4, (Math.abs(d.value || 0) / max) * barAreaW);
doc.roundedRect(trackX, y + 2, w, 8, 4).fill(CATEGORICAL_COLORS[i % CATEGORICAL_COLORS.length]);
doc.fillColor(TEXT_DARK).font('Helvetica').fontSize(8).text(d.displayValue != null ? String(d.displayValue) : String(d.value), trackX + barAreaW + 8, y + 3, { width: valueW - 8, align: 'right' });
doc.y = y + rowH;
if (doc.y > doc.page.height - 60) doc.addPage();
});
doc.moveDown(0.8);
}
doc.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/email
// body: { title, kind, table?, layered?, subject?, body? }
const emailPdf = async (req, res) => {
try {
const { title, kind, table, layered, subject, body, orientation } = req.body || {};
const user = req.user;
if (!user || !user.email) {
res.status(400);
throw new Error('User email not available');
function drawNote(doc, note) {
if (!note) return;
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const x = doc.page.margins.left;
const y = doc.y;
doc.font('Helvetica-Oblique').fontSize(8.5);
const h = doc.heightOfString(note, { width: pageWidth - 20 }) + 14;
doc.save();
doc.roundedRect(x, y, pageWidth, h, 5).fill('#f9fafb');
doc.fillColor(TEXT_MUTED).text(note, x + 10, y + 7, { width: pageWidth - 20 });
doc.restore();
doc.y = y + h + 12;
if (doc.y > doc.page.height - 60) doc.addPage();
}
// Ensure temp dir
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
// Build PDF to file
await new Promise((resolve, reject) => {
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
doc.font('Helvetica-Bold').fontSize(16).text(title || 'Report');
doc.moveDown(0.5);
if (kind === 'table' && table && Array.isArray(table.rows)) {
// Draws a single branded table (header band, zebra rows, highlighted total rows) at the
// document's current y — shared by the main table body and any extraTables sections below it.
function drawTable(doc, table) {
const columns = Array.isArray(table.columns) ? table.columns : [];
const colCount = columns.length || (table.rows[0] ? table.rows[0].length : 1);
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Slightly wider first column
const baseWidth = Math.floor(pageWidth / Math.max(1, colCount));
const colWidths = new Array(colCount).fill(baseWidth);
if (colCount > 0) colWidths[0] = Math.floor(baseWidth * 1.2);
@@ -181,8 +127,8 @@ const emailPdf = async (req, res) => {
let x = doc.page.margins.left;
const y = doc.y;
doc.save();
doc.rect(x, y, pageWidth, 22).fill('#f3f4f6');
doc.fillColor('#111827').font('Helvetica-Bold').fontSize(11);
doc.rect(x, y, pageWidth, 22).fill(BRAND_LIGHT);
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(10.5);
columns.forEach((h, i) => {
const w = colWidths[i] || baseWidth;
doc.text(String(h || ''), x + 6, y + 6, { width: w - 12 });
@@ -192,78 +138,328 @@ const emailPdf = async (req, res) => {
doc.moveDown(1.6);
}
// Rows zebra
// Rows zebra striped, with a highlighted tint+bold for total/summary rows
const rows = table.rows;
rows.forEach((row, idx) => {
rows.forEach((row) => {
const emphasis = isEmphasisRow(row[0]);
const rowY = doc.y;
const rowH = 18;
const bg = idx % 2 === 0 ? '#ffffff' : '#f9fafb';
const bg = emphasis ? BRAND_LIGHT : (rows.indexOf(row) % 2 === 0 ? '#ffffff' : '#f9fafb');
doc.save();
doc.rect(doc.page.margins.left, rowY - 2, pageWidth, rowH + 4).fill(bg).restore();
let x = doc.page.margins.left;
row.forEach((cell, i) => {
const w = colWidths[i] || baseWidth;
doc.fillColor('#111827').font('Helvetica').fontSize(10).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
doc.fillColor(emphasis ? BRAND_DARK : TEXT_DARK).font(emphasis ? 'Helvetica-Bold' : 'Helvetica').fontSize(9.5).text(String(cell ?? ''), x + 6, rowY, { width: w - 12 });
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(x, rowY - 2).lineTo(x, rowY + rowH + 2).stroke();
x += w;
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left + pageWidth, rowY - 2).lineTo(doc.page.margins.left + pageWidth, rowY + rowH + 2).stroke();
doc.moveDown(1.1);
if (doc.y > doc.page.height - 40) {
doc.addPage();
}
});
doc.strokeColor('#e5e7eb').lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
doc.strokeColor(BORDER).lineWidth(0.5).moveTo(doc.page.margins.left, doc.y).lineTo(doc.page.margins.left + pageWidth, doc.y).stroke();
doc.moveDown(1);
}
// Shared drawing logic for all PDF-producing endpoints (download, email, WhatsApp) — draws a
// branded header, optional stat tiles / bar chart / note, then a 'table' or 'layered' body
// (plus any extraTables sections below it) onto whatever PDFDocument the caller gives it
// (streamed straight to the HTTP response for download, or to a temp file for email/WhatsApp).
function drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }) {
drawHeader(doc, title, subtitle);
drawStats(doc, stats);
drawChart(doc, chart);
drawNote(doc, note);
// Path-drawing ops (rect/moveTo/lineTo, used throughout drawTable's borders) leave PDFKit's
// implicit text cursor (doc.x) at the last point drawn rather than the left margin, so every
// text() call below passes an explicit x — relying on the implicit cursor after a table has
// rendered puts the next label at the table's right edge instead of the margin.
const leftX = doc.page.margins.left;
if (kind === 'table' && table && Array.isArray(table.rows)) {
drawTable(doc, table);
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
doc.font('Helvetica-Bold').fontSize(13).text(layered.header);
doc.font('Helvetica-Bold').fontSize(13).fillColor(TEXT_DARK).text(layered.header, leftX, doc.y);
doc.moveDown(0.3);
}
doc.font('Helvetica').fontSize(11);
for (const section of layered.sections) {
doc.fillColor('#111827').font('Helvetica-Bold').text(String(section.title || ''), { continued: false });
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').text(String(section.title || ''), leftX, doc.y, { continued: false });
doc.moveDown(0.15);
doc.font('Helvetica').fontSize(10);
if (Array.isArray(section.items) && section.items.length) {
for (const item of section.items) {
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill('#374151').stroke();
doc.fillColor('#111827');
doc.circle(doc.page.margins.left + 2, doc.y + 6, 1.5).fill(BRAND).stroke();
doc.fillColor(TEXT_DARK);
doc.text(' ' + String(item || ''), doc.page.margins.left + 8, doc.y, { width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 8 });
doc.moveDown(0.2);
}
} else {
doc.text('No items');
doc.fillColor(TEXT_MUTED).text('No items', leftX, doc.y);
}
doc.moveDown(0.5);
if (doc.y > doc.page.height - 60) doc.addPage();
}
} else {
doc.font('Helvetica').text('No content');
doc.fillColor(TEXT_MUTED).font('Helvetica').text('No content', leftX, doc.y);
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
if (doc.y > doc.page.height - 100) doc.addPage();
doc.moveDown(0.6);
if (t.title) {
doc.fillColor(BRAND_DARK).font('Helvetica-Bold').fontSize(11).text(t.title, leftX, doc.y);
doc.moveDown(0.4);
}
drawTable(doc, t);
});
}
}
// Renders payload to a temp PDF file and returns its path — shared by emailPdf and whatsappPdf.
async function renderReportPdfToFile(payload) {
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const filePath = path.join(tempDir, `${(payload.title || 'report')}-${Date.now()}.pdf`.replace(/[^a-z0-9_.-]/gi, '_'));
await new Promise((resolve, reject) => {
const doc = a4Doc(payload.orientation === 'landscape' ? 'landscape' : 'portrait');
const ws = fs.createWriteStream(filePath);
doc.pipe(ws);
drawReportPdf(doc, payload);
doc.end();
ws.on('finish', resolve);
ws.on('error', reject);
});
// Send email using nodemailer (same config as tickets)
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_PORT === '465',
auth: { user: process.env.EMAIL_USER, pass: process.env.EMAIL_PASS }
});
return filePath;
}
await transporter.sendMail({
from: process.env.EMAIL_FROM,
// Builds a styled .xlsx workbook mirroring the same payload shape used for PDF — branded
// title band, stat label/value rows, a chart rendered as a mini-table with a native Excel
// data-bar conditional format (the closest free/no-extra-viz-dependency equivalent of the
// web's bar chart), then the main table with a bold colored header row and highlighted
// total rows.
async function buildReportWorkbook(payload) {
const { title, subtitle, kind, table, layered, stats, chart, note, extraTables } = payload || {};
const wb = new ExcelJS.Workbook();
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);
const colCount = Math.max(4, (table?.columns?.length || 0));
let r = 1;
ws.mergeCells(r, 1, r, colCount);
const titleCell = ws.getCell(r, 1);
titleCell.value = title || 'Report';
titleCell.font = { bold: true, size: 16, color: { argb: 'FFFFFFFF' } };
titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F46E5' } };
titleCell.alignment = { vertical: 'middle' };
ws.getRow(r).height = 26;
r++;
if (subtitle) {
ws.mergeCells(r, 1, r, colCount);
const subCell = ws.getCell(r, 1);
subCell.value = subtitle;
subCell.font = { italic: true, size: 10, color: { argb: 'FF6B7280' } };
r++;
}
r++;
if (Array.isArray(stats) && stats.length) {
stats.forEach(s => {
ws.getCell(r, 1).value = s.label;
ws.getCell(r, 1).font = { color: { argb: 'FF6B7280' }, size: 10 };
ws.getCell(r, 2).value = s.value;
ws.getCell(r, 2).font = { bold: true, size: 11 };
r++;
});
r++;
}
if (chart && Array.isArray(chart.data) && chart.data.length) {
if (chart.title) {
ws.getCell(r, 1).value = chart.title;
ws.getCell(r, 1).font = { bold: true, size: 10 };
r++;
}
const chartStartRow = r;
chart.data.forEach(d => {
ws.getCell(r, 1).value = d.label;
ws.getCell(r, 2).value = typeof d.value === 'number' ? d.value : Number(d.value) || 0;
r++;
});
ws.addConditionalFormatting({
ref: `B${chartStartRow}:B${r - 1}`,
rules: [{ type: 'dataBar', cfvo: [{ type: 'min' }, { type: 'max' }], color: { argb: 'FF2A78D6' } }]
});
r++;
}
if (note) {
ws.mergeCells(r, 1, r, colCount);
ws.getCell(r, 1).value = note;
ws.getCell(r, 1).font = { italic: true, size: 9, color: { argb: 'FF6B7280' } };
ws.getCell(r, 1).alignment = { wrapText: true };
r += 2;
}
if (kind === 'table' && table && Array.isArray(table.rows)) {
const columns = table.columns || [];
const { nextRow, dataStartRow } = writeTableRows(ws, r, columns, table.rows);
ws.views = [{ state: 'frozen', ySplit: dataStartRow - 1 }];
r = nextRow;
} else if (kind === 'layered' && layered && Array.isArray(layered.sections)) {
if (layered.header) {
ws.getCell(r, 1).value = layered.header;
ws.getCell(r, 1).font = { bold: true, size: 12 };
r += 2;
}
layered.sections.forEach(section => {
ws.getCell(r, 1).value = section.title;
ws.getCell(r, 1).font = { bold: true, color: { argb: 'FF3730A3' } };
r++;
(section.items || []).forEach(item => {
ws.getCell(r, 1).value = `${item}`;
r++;
});
r++;
});
ws.getColumn(1).width = 90;
}
if (Array.isArray(extraTables)) {
extraTables.forEach(t => {
if (!t || !Array.isArray(t.rows) || t.rows.length === 0) return;
r++;
if (t.title) {
ws.getCell(r, 1).value = t.title;
ws.getCell(r, 1).font = { bold: true, size: 12, color: { argb: 'FF3730A3' } };
r++;
}
const { nextRow } = writeTableRows(ws, r, t.columns || [], t.rows);
r = nextRow;
});
}
return wb;
}
// Writes a header row (bold, colored fill) + zebra/emphasis-highlighted data rows at the given
// sheet row — shared by the main table and any extraTables sections. Returns the next free row
// and the data's start row (for optional freeze-pane use by the caller).
function writeTableRows(ws, startRow, columns, rows) {
let r = startRow;
const headerRow = ws.getRow(r);
columns.forEach((c, i) => {
const cell = headerRow.getCell(i + 1);
cell.value = c;
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
cell.border = { bottom: { style: 'thin', color: { argb: 'FFE5E7EB' } } };
});
headerRow.commit();
r++;
const dataStartRow = r;
rows.forEach((row, idx) => {
const excelRow = ws.getRow(r);
const emphasis = isEmphasisRow(row[0]);
row.forEach((val, i) => {
const cell = excelRow.getCell(i + 1);
cell.value = val === '' ? null : val;
if (emphasis) {
cell.font = { bold: true, color: { argb: 'FF3730A3' } };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFEEF2FF' } };
} else if (idx % 2 === 1) {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF9FAFB' } };
}
});
excelRow.commit();
r++;
});
columns.forEach((c, i) => {
let maxLen = String(c || '').length;
rows.forEach(row => { const v = row[i]; if (v != null && v !== '') maxLen = Math.max(maxLen, String(v).length); });
const col = ws.getColumn(i + 1);
col.width = Math.max(col.width || 0, Math.min(40, Math.max(10, maxLen + 2)));
});
return { nextRow: r, dataStartRow };
}
// POST /api/reports/pdf
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => {
try {
const { title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables } = req.body || {};
res.setHeader('Content-Type', 'application/pdf');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const doc = a4Doc(orientation === 'landscape' ? 'landscape' : 'portrait');
doc.pipe(res);
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
doc.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/excel
// body: same payload shape as /pdf — used to produce a styled .xlsx mirroring the PDF/web report.
const generateExcel = async (req, res) => {
try {
const payload = req.body || {};
const wb = await buildReportWorkbook(payload);
const filename = `${(payload.title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.xlsx`;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res);
res.end();
} catch (e) {
res.status(400).json({ message: e.message });
}
};
// POST /api/reports/email
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, subject?, body? }
const emailPdf = async (req, res) => {
try {
const { title, subtitle, kind, table, layered, subject, body, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
if (!user || !user.email) {
res.status(400);
throw new Error('User email not available');
}
const filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
// Use the shared mail utility (DB-configured SMTP via Admin -> Site Settings, with env
// fallback) instead of a one-off transporter — a bare `process.env.EMAIL_HOST` transporter
// ignores that configuration entirely and fails wherever SMTP is only set up via the DB.
const bodyText = body || 'Please find your report attached.';
await sendMail({
to: user.email,
subject: subject || (title ? `${title} PDF` : 'Report PDF'),
text: body || 'Please find your report attached.',
text: bodyText,
html: emailWrapper(
`<p style="margin:0 0 16px 0;color:#374151">${bodyText}</p>` +
`<p style="margin:0;color:#374151">Your report <strong>${title || 'Report'}</strong> is attached as a PDF.</p>`,
{ preheader: title || 'Report PDF' }
),
attachments: [{ filename: path.basename(filePath), path: filePath, contentType: 'application/pdf' }]
});
// Clean
try { fs.unlinkSync(filePath); } catch {}
res.json({ message: `Report emailed to ${user.email}` });
@@ -272,4 +468,33 @@ const emailPdf = async (req, res) => {
}
};
module.exports = { generatePdf, emailPdf };
// POST /api/reports/whatsapp
// body: { title, subtitle?, kind, table?, layered?, stats?, chart?, note?, caption? }
// Sends to the current user's own WhatsApp number (same self-service pattern as emailPdf).
const whatsappPdf = async (req, res) => {
let filePath = null;
try {
const { title, subtitle, kind, table, layered, caption, orientation, stats, chart, note, extraTables } = req.body || {};
const user = req.user;
const { isValidZAPhone } = require('../utils/whatsapp');
if (!user || !isValidZAPhone(user.phoneNumber)) {
res.status(400);
throw new Error('No valid WhatsApp number on your account. Add one in your profile to use this.');
}
filePath = await renderReportPdfToFile({ title, subtitle, kind, table, layered, orientation, stats, chart, note, extraTables });
const { sendPdf } = require('../utils/whatsapp');
const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
res.json({ message: `Report sent to your WhatsApp` });
} catch (e) {
res.status(400).json({ message: e.message });
} finally {
if (filePath) { try { fs.unlinkSync(filePath); } catch {} }
}
};
module.exports = { generatePdf, generateExcel, emailPdf, whatsappPdf };
@@ -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 (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,
};
+48 -20
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,
@@ -354,21 +363,15 @@ const updateUserProfile = async (req, res) => {
}
// Normalize phone
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
const { normalizeZAPhone } = require('../utils/whatsapp');
let newPhone = user.phoneNumber;
if (phoneNumber !== undefined) {
newPhone = phoneNumber ? (normalizeZAPhone(phoneNumber) || phoneNumber.replace(/\D/g, '') || null) : null;
}
// Validate notification preference — WhatsApp requires a valid SA phone number
const allowedPrefs = ['email', 'whatsapp', 'both'];
let newPref = user.notificationPreference;
if (notificationPreference !== undefined) {
newPref = allowedPrefs.includes(notificationPreference) ? notificationPreference : user.notificationPreference;
if ((newPref === 'whatsapp' || newPref === 'both') && !isValidZAPhone(newPhone)) {
newPref = 'email';
}
}
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Update user data
const updatedUser = await prisma.user.update({
@@ -398,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({
@@ -482,7 +485,7 @@ const checkUserExists = async (req, res) => {
const existingUser = await prisma.user.findFirst({
where: { OR: searchClauses },
select: { name: true, email: true, phoneNumber: true, notificationPreference: true },
select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true },
});
const hasEmail = !!existingUser?.email && !existingUser.email.endsWith('@guest.local');
@@ -494,7 +497,11 @@ const checkUserExists = async (req, res) => {
hasPhone,
// Safe-to-display fields only, for autofilling a lookup form — never the password.
// Guest placeholder emails are withheld the same way hasEmail already treats them.
// `id` lets the kiosk tell two different matched accounts apart (e.g. when the
// typed email and phone number resolve to different people) — it's never shown,
// only compared client-side, and this endpoint is already Private/Supervisor.
user: existingUser ? {
id: existingUser.id,
name: existingUser.name,
email: hasEmail ? existingUser.email : null,
phoneNumber: hasPhone ? existingUser.phoneNumber : null,
@@ -550,7 +557,11 @@ const updateUser = async (req, res) => {
throw new Error('User not found');
}
const { name, email, role, isActive, phoneNumber, password } = req.body;
const { name, email, role, isActive, phoneNumber, password, notificationPreference } = req.body;
const newPhone = phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber;
const { resolveNotificationPreference } = require('../utils/notificationPreference');
const newPref = resolveNotificationPreference(notificationPreference, newPhone, user.notificationPreference);
// Prepare data update, allow admin to set a new password
const data = {
@@ -558,7 +569,8 @@ const updateUser = async (req, res) => {
email: email || user.email,
role: role || user.role,
isActive: isActive !== undefined ? isActive : user.isActive,
phoneNumber: phoneNumber !== undefined ? (phoneNumber || null) : user.phoneNumber,
phoneNumber: newPhone,
notificationPreference: newPref,
updatedAt: new Date()
};
@@ -575,6 +587,7 @@ const updateUser = async (req, res) => {
email: true,
role: true,
phoneNumber: true,
notificationPreference: true,
createdAt: true,
updatedAt: true,
isActive: true
@@ -740,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) });
@@ -894,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(() => {});
}
@@ -915,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.' });
@@ -925,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,
@@ -942,4 +969,5 @@ module.exports = {
revokeMySession,
adminRevokeUserSessions,
closeAccount,
getMyActivity,
};
@@ -323,6 +323,7 @@ const handlePaymentSucceeded = async (webhookData) => {
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
recordedById: resolvedUserId, // self-service webhook payment — payer is the recorder
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
},
@@ -334,6 +335,13 @@ const handlePaymentSucceeded = async (webhookData) => {
email: true
}
},
recordedBy: {
select: {
id: true,
name: true,
email: true
}
},
registration: registration ? {
include: {
event: 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) {
@@ -83,7 +83,7 @@ const reconcileYocoTransaction = async (req, res) => {
}
const { id } = req.params;
const { registrationId, eventId } = req.body || {};
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
@@ -118,7 +118,15 @@ const reconcileYocoTransaction = async (req, res) => {
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (ytx?.raw?.payload?.metadata?.userId) {
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
@@ -138,6 +146,7 @@ const reconcileYocoTransaction = async (req, res) => {
amount: amountFloat,
method: ytx.methodType || 'card',
userId: resolvedUserId,
recordedById: req.user?.id || null, // staff who performed the reconciliation
registrationId: registration?.id || null,
eventId: registration?.eventId || eventId || null,
isDonation: !registration?.id,
@@ -155,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 {
@@ -163,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);
}
@@ -182,13 +185,20 @@ const reconcileYocoTransaction = async (req, res) => {
}
}
// Send emails for the reconciled payment
try {
// Fire-and-forget: payment confirmation email, then ticket email/WhatsApp (guarantees order)
const { sendPaymentEmails } = require('../utils/notifications');
await sendPaymentEmails(payment.id);
} catch (e) {
console.error('Failed to send payment emails after reconciliation:', e);
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({
+18 -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">
@@ -533,7 +533,7 @@ app.get('/docs', async (req, res) => {
{ 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.' }},
@@ -750,7 +750,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 +773,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 +798,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 +964,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 +1019,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 +1043,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 +1070,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 +1192,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) {
+3 -1
View File
@@ -1,10 +1,12 @@
const express = require('express');
const router = express.Router();
const { getEventCashup, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { getEventCashup, getCashByRecordedUser, savePersonCash, saveEventCashupDraft, closeEvent, reopenEvent, getCashupAudit } = require('../controllers/cashupController');
const { protect, supervisor, admin } = require('../middleware/authMiddleware');
router.get('/audit', protect, supervisor, getCashupAudit);
router.get('/event/:eventId', protect, supervisor, getEventCashup);
router.get('/event/:eventId/cash-by-user', protect, supervisor, getCashByRecordedUser);
router.put('/event/:eventId/person-cash/:userId', protect, supervisor, savePersonCash);
router.put('/event/:eventId/draft', protect, admin, saveEventCashupDraft);
router.post('/event/:eventId/close', protect, admin, closeEvent);
router.post('/event/:eventId/reopen', protect, admin, reopenEvent);
+2
View File
@@ -8,6 +8,7 @@ const {
getPaymentsByRegistration,
getPaymentsByEvent,
assignDonationToRegistration,
unassignDonationFromRegistration,
createYocoCheckout,
sendPaymentLink,
createRefund,
@@ -27,6 +28,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);
+7 -1
View File
@@ -1,12 +1,18 @@
const express = require('express');
const router = express.Router();
const { generatePdf, emailPdf } = require('../controllers/reportController');
const { generatePdf, generateExcel, emailPdf, whatsappPdf } = require('../controllers/reportController');
const { protect } = require('../middleware/authMiddleware');
// Generate and download PDF
router.post('/pdf', protect, generatePdf);
// Generate and download styled Excel (.xlsx)
router.post('/excel', protect, generateExcel);
// Email PDF to current user
router.post('/email', protect, emailPdf);
// Send PDF to current user's own WhatsApp
router.post('/whatsapp', protect, whatsappPdf);
module.exports = router;
+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('/')
+167 -7
View File
@@ -21,6 +21,16 @@ function bucketForMethod(method) {
return 'other';
}
// A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Revenue/cash totals must count each real inflow exactly once, so legs are
// excluded everywhere money is summed — the money was already counted via the donation itself.
// (Refunds also set originalPaymentId, but always with a negative amount, so they're unaffected.)
function isDonationLeg(p) {
return !p.isDonation && !!p.originalPaymentId && p.amount > 0;
}
// Throws if the event is closed. Callers wrap this in their existing try/catch
// (res.statusCode is set before throwing, matching the rest of the controllers).
async function assertEventOpen(eventId, res) {
@@ -93,16 +103,35 @@ async function computeEventFinancials(eventId) {
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
const nonRefundPayments = payments.filter(p => p.amount > 0);
const unallocatedDonations = payments.filter(p => p.isDonation && !p.registrationId);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + p.amount, 0);
const totalDonations = payments.filter(p => p.isDonation).reduce((sum, p) => sum + p.amount, 0);
// Excludes donation-application legs, which would otherwise double-count money already
// counted once via the source donation (e.g. a R250 donation with R50 assigned to a
// registration must total R250 received, not R300). Refunds (negative amount) are kept in —
// a card refund must subtract from the 'card' bucket it was refunded against, not vanish from
// the per-method breakdown while still being netted out of totalRevenue below.
const realPayments = payments.filter(p => !isDonationLeg(p));
// Donations are never mutated once assigned — assignment creates a separate "leg" Payment
// row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId
// stays null forever. Its actual unallocated amount is its original amount minus every leg
// that already references it, not simply "every donation with no registrationId". A refund
// of the donation itself also creates such a leg, with a negative amount — Math.abs() so a
// refund reduces the unallocated balance instead of inflating it (a raw signed sum would
// subtract a negative, adding the refund back on top).
const legsByDonationId = new Map();
for (const p of payments) {
if (p.originalPaymentId && !p.isDonation) {
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(p.amount));
}
}
const donationPayments = payments.filter(p => p.isDonation);
const unallocatedDonations = donationPayments.filter(p => (p.amount - (legsByDonationId.get(p.id) || 0)) > 0.000001);
const unallocatedDonationsTotal = unallocatedDonations.reduce((sum, p) => sum + Math.max(p.amount - (legsByDonationId.get(p.id) || 0), 0), 0);
const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
for (const p of realPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => {
@@ -195,12 +224,143 @@ async function computeEventFinancials(eventId) {
};
}
// Payment accountability, per staff member who recorded the payment, broken down by every
// method (not just cash) — lets a cashup reconcile not just the total float but who is
// responsible for which portion of it. Cash also folds in any actual physical count entered for
// that person (EventCashupPersonCount, entered any time, independent of the event-wide close) to
// show an actual-vs-expected variance per person — the event's cash actual is the sum of these
// per-person counts (see computeEventCashActualFromPersonCounts), not a separate manual entry.
// Card/EFT/Other have no physical "count" concept, so they're just recorded amounts.
async function computeAccountabilityByUser(eventId) {
const [payments, personCounts] = await Promise.all([
prisma.payment.findMany({
where: { OR: [{ eventId }, { registration: { eventId } }] },
include: { recordedBy: { select: { id: true, name: true, email: true } } }
}),
prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: {
user: { select: { id: true, name: true, email: true } },
enteredBy: { select: { id: true, name: true } },
denominations: true
}
})
]);
const emptyMethodTotals = () => ({ total: 0, count: 0 });
const emptyEntry = (userId, name, email) => ({
userId: userId || null,
name: name || 'Unknown / legacy',
email: email || null,
cash: { ...emptyMethodTotals(), actual: null, variance: null, denominations: [], enteredBy: null, countUpdatedAt: null, notes: null },
card: emptyMethodTotals(),
eft: emptyMethodTotals(),
other: emptyMethodTotals()
});
const byUser = new Map();
for (const p of payments) {
// A donation-application leg isn't new money — it's the same money already recorded once,
// as the donation. Counting it again here would double-attribute it to whoever did the
// assignment, on top of whoever originally recorded the donation.
if (isDonationLeg(p)) continue;
const method = bucketForMethod(p.method);
const key = p.recordedById || 'unknown';
const entry = byUser.get(key) || emptyEntry(p.recordedById, p.recordedBy?.name, p.recordedBy?.email);
entry[method].total += p.amount;
entry[method].count += 1;
byUser.set(key, entry);
}
for (const pc of personCounts) {
const key = pc.userId;
const entry = byUser.get(key) || emptyEntry(pc.userId, pc.user?.name, pc.user?.email);
const actual = pc.denominations.reduce((s, d) => s + d.value * d.count, 0);
entry.cash.actual = actual;
entry.cash.variance = actual - entry.cash.total;
entry.cash.denominations = pc.denominations.map(d => ({ value: d.value, count: d.count }));
entry.cash.enteredBy = pc.enteredBy ? { id: pc.enteredBy.id, name: pc.enteredBy.name } : null;
entry.cash.countUpdatedAt = pc.updatedAt;
entry.cash.notes = pc.notes || null;
byUser.set(key, entry);
}
return Array.from(byUser.values()).sort((a, b) => {
const totalA = a.cash.total + a.card.total + a.eft.total + a.other.total;
const totalB = b.cash.total + b.card.total + b.eft.total + b.other.total;
return totalB - totalA;
});
}
// The event's cash "actual" is the live sum of every staff member's entered physical count —
// there is no separate event-wide entry any more. Used both to display a live figure before
// close and to source the closed cashup's permanent Cash line.
async function computeEventCashActualFromPersonCounts(eventId) {
const personCounts = await prisma.eventCashupPersonCount.findMany({
where: { eventId },
include: { denominations: true }
});
if (personCounts.length === 0) return { actual: null, denominations: [] };
const byValue = new Map();
let actual = 0;
for (const pc of personCounts) {
for (const d of pc.denominations) {
actual += d.value * d.count;
byValue.set(d.value, (byValue.get(d.value) || 0) + d.count);
}
}
const denominations = Array.from(byValue.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.value - a.value);
return { actual, denominations };
}
// Upsert one staff member's actual physical cash count for an event — optional, can be entered
// any time (not required to close the event), purely for per-person accountability.
async function savePersonCashCount(eventId, userId, { denominations, notes, enteredById }) {
const cleanDenoms = (Array.isArray(denominations) ? denominations : [])
.map(d => ({ value: Number(d.value), count: parseInt(d.count, 10) || 0 }))
.filter(d => d.value > 0 && d.count > 0);
const existing = await prisma.eventCashupPersonCount.findUnique({
where: { eventId_userId: { eventId, userId } }
});
const record = existing
? await prisma.eventCashupPersonCount.update({
where: { id: existing.id },
data: {
notes: notes || null,
enteredById: enteredById || null,
denominations: { deleteMany: {}, create: cleanDenoms }
},
include: { denominations: true }
})
: await prisma.eventCashupPersonCount.create({
data: {
eventId,
userId,
notes: notes || null,
enteredById: enteredById || null,
denominations: { create: cleanDenoms }
},
include: { denominations: true }
});
return record;
}
module.exports = {
METHOD_BUCKETS,
ALL_METHODS,
ZAR_DENOMINATIONS,
bucketForMethod,
isDonationLeg,
assertEventOpen,
assertRegistrationEventOpen,
computeEventFinancials
computeEventFinancials,
computeAccountabilityByUser,
computeEventCashActualFromPersonCounts,
savePersonCashCount
};
+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>
@@ -0,0 +1,16 @@
const { isValidZAPhone } = require('./whatsapp');
const ALLOWED_NOTIFICATION_PREFERENCES = ['email', 'whatsapp', 'both'];
// WhatsApp/Both requires a valid SA phone number — silently falls back to email otherwise,
// since a user without a usable phone number can never receive WhatsApp notifications anyway.
function resolveNotificationPreference(requested, phoneNumber, fallback) {
if (requested === undefined) return fallback;
let pref = ALLOWED_NOTIFICATION_PREFERENCES.includes(requested) ? requested : fallback;
if ((pref === 'whatsapp' || pref === 'both') && !isValidZAPhone(phoneNumber)) {
pref = 'email';
}
return pref;
}
module.exports = { ALLOWED_NOTIFICATION_PREFERENCES, resolveNotificationPreference };
+165 -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(/\/$/, ''),
};
@@ -639,6 +639,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) {
@@ -980,7 +1083,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 +1093,65 @@ 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 } } } },
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);
}
}
async function sendDailyEventSummaries(now = new Date()) {
try {
const today = new Date(now);
@@ -1045,6 +1207,7 @@ module.exports = {
sendRefundEmail,
sendSelfServiceRegistrationEmails,
sendDonationAssignmentEmails,
sendDonationUnassignmentEmails,
sendCheckInEmails,
buildCheckInConfirmation,
};
+30
View File
@@ -0,0 +1,30 @@
// Shared template placeholder substitution for bulk emails/WhatsApp messages and broadcasts.
// Async because {{payment.link}} needs to create a live Yoco checkout session per recipient —
// every other placeholder is a plain synchronous string replace.
async function replacePlaceholders(str, ctx = {}) {
if (!str) return str;
let out = String(str)
.replace(/\{\{\s*name\s*\}\}/g, ctx.name || '')
.replace(/\{\{\s*event\.title\s*\}\}/g, ctx.eventTitle || '')
.replace(/\{\{\s*event\.start\s*\}\}/g, ctx.eventStart || '')
.replace(/\{\{\s*event\.(link|url)\s*\}\}/g, (ctx.eventLinkHtml || ctx.eventLink || ''))
.replace(/\{\{\s*promo\.title\s*\}\}/g, ctx.promoTitle || '')
.replace(/\{\{\s*promo\.(link|url)\s*\}\}/g, (ctx.promoLinkHtml || ctx.promoLink || ''))
.replace(/\{\{\s*balance\s*\}\}/g, ctx.balanceFmt || '');
if (/\{\{\s*payment\.link\s*\}\}/.test(out) && typeof ctx.paymentLinkResolver === 'function') {
let link = '';
try {
link = (await ctx.paymentLinkResolver()) || '';
} catch (e) {
try { console.warn('[placeholders] payment.link resolution failed:', e?.message || e); } catch {}
}
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, link);
} else {
out = out.replace(/\{\{\s*payment\.link\s*\}\}/g, '');
}
return out;
}
module.exports = { replacePlaceholders };
+9 -8
View File
@@ -161,18 +161,18 @@ async function refreshPricingForRegistration(registrationId) {
});
if (!registration) return { changed: false };
let anyChanged = false;
for (const ro of registration.registrationOptions) {
// Each registrationOption 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(registration.registrationOptions.map(async (ro) => {
// Only refresh options that were priced via a tier
if (!ro.appliedTierId) continue;
if (!ro.appliedTierId) return false;
// Find the currently applied tier
const currentTier = (ro.eventOption.earlyBirdTiers || []).find(t => t.id === ro.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
@@ -191,11 +191,12 @@ async function refreshPricingForRegistration(registrationId) {
appliedTierId: resolved.tierId
}
});
anyChanged = true;
}
return true;
}
return false;
}));
return { changed: anyChanged };
return { changed: results.some(Boolean) };
}
/**
+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 };
+27 -24
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() } });
}
// 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() } })
)
)));
// 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, }
data: { quantity: (primary.quantity || 0) + totalMergedQty }
});
await prisma.registrationOption.delete({ where: { id: dup.id } });
}
await Promise.all(duplicates.map(dup => prisma.registrationOption.delete({ where: { id: dup.id } })));
// Re-load the primary's current quantity after merge
// 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 };
+34 -1
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(/\/$/, ''),
};
@@ -266,11 +266,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,
+23
View File
@@ -105,6 +105,12 @@ function isSessionNotFound(e) {
return msg.includes('session not found') || msg.includes('instance not found');
}
/** Extracts the WAWP API's own error reason when present, instead of axios's generic
* "Request failed with status code NNN" (which carries no information about what went wrong). */
function wawpErrorMessage(e) {
return e?.response?.data?.message || e?.message || 'Unknown WhatsApp API error';
}
/**
* If the WAWP API reports "Session not found", clear the stale instance ID
* from the DB so the admin UI drops back to the Session Instance setup step.
@@ -221,12 +227,20 @@ async function sendText(toPhone, message) {
const chatId = toChatId(toPhone);
if (!chatId) { console.warn('[whatsapp] Invalid phone, skipping text:', toPhone); return; }
const { token, instanceId } = await getConfig();
try {
await axios.post(`${BASE}/send/text`, {
access_token: token,
instance_id: instanceId,
chatId,
message,
});
} catch (e) {
if (isSessionNotFound(e)) {
try { await handleSessionNotFound(e); } catch {}
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
}
throw new Error(`WhatsApp text send failed: ${wawpErrorMessage(e)}`);
}
}
/**
@@ -258,6 +272,7 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
const pdfUrl = `${backendUrl}/uploads/tickets-temp/${tempName}`;
const { token, instanceId } = await getConfig();
try {
await axios.post(`${BASE}/send/pdf`, {
access_token: token,
instance_id: instanceId,
@@ -269,6 +284,14 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
},
caption: caption || '',
});
} catch (e) {
try { fs.unlinkSync(tempPath); } catch {}
if (isSessionNotFound(e)) {
try { await handleSessionNotFound(e); } catch {}
throw new Error('Your WhatsApp session has disconnected. Reconnect it in Admin → WhatsApp Settings, then try again.');
}
throw new Error(`WhatsApp PDF send failed: ${wawpErrorMessage(e)}`);
}
// Clean up after 5 minutes — WAWP will have fetched the file by then
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.3.0",
"version": "1.6.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"];
@@ -26,7 +27,7 @@ export default function EventCashupPage() {
const router = useRouter();
const { token } = useAuth();
const [tab, setTab] = useState<"costs" | "reconciliation">("costs");
const [tab, setTab] = useState<"costs" | "reconciliation" | "report">("costs");
const [data, setData] = useState<EventFinancials | null>(null);
const [eventOptions, setEventOptions] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(false);
@@ -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>
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
<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-full font-medium " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open"}
</span>
</div>
@@ -70,8 +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={"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>}
@@ -89,8 +96,13 @@ export default function EventCashupPage() {
setBusy={setBusy}
setError={setError}
onChanged={load}
onClosed={() => setTab("report")}
/>
)}
{!loading && data && tab === "report" && (
<ReportTab eventId={eventId} token={token || ""} data={data} isClosed={isClosed} />
)}
</div>
);
}
@@ -163,13 +175,14 @@ 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>
{isClosed && <div className="text-xs text-gray-500">This event is closed costs can't be changed until it's reopened.</div>}
<table className="w-full text-sm">
<div className="overflow-auto">
<table className="w-full text-sm min-w-[600px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
@@ -192,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>
)}
@@ -212,6 +225,7 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
</tfoot>
)}
</table>
</div>
{editingId !== null && (
<div className="border rounded p-3 space-y-2 bg-gray-50">
@@ -256,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>
)}
@@ -266,9 +280,349 @@ function CostsTab({ eventId, token, costs, eventOptions, isClosed, onChanged }:
// ─── Reconciliation tab ─────────────────────────────────────────────────────
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged }: {
type PersonDenom = { value: number; count: number };
type MethodTotals = { total: number; count: number };
type CashMethodTotals = MethodTotals & {
actual: number | null; variance: number | null; denominations: PersonDenom[];
enteredBy: { id: string; name: string } | null; countUpdatedAt: string | null; notes: string | null;
};
type AccountabilityRow = {
userId: string | null; name: string; email: string | null;
cash: CashMethodTotals; card: MethodTotals; eft: MethodTotals; other: MethodTotals;
};
type AccountabilityResponse = { rows: AccountabilityRow[]; cashActualTotal: number | null; cashDenominations: PersonDenom[] };
// Payment accountability, per staff member who recorded the payment, broken down by method —
// separate from the overall cash reconciliation above, which only totals the float without
// saying who's responsible for it. For cash, staff can optionally enter what was physically
// counted for each person, any time (not required to close the event); the event's cash actual
// is the live sum of these per-person counts, shown here and used by the reconciliation table.
function CashByUserSection({ eventId, token, onCashSummaryChange }: {
eventId: string; token: string;
onCashSummaryChange?: (summary: { actualTotal: number | null; denominations: PersonDenom[] }) => void;
}) {
const [data, setData] = useState<AccountabilityResponse | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(true);
const [editingUserId, setEditingUserId] = useState<string | null>(null);
const load = () => {
if (!token || !eventId) return;
setLoading(true);
apiFetch<AccountabilityResponse>(`/api/cashups/event/${eventId}/cash-by-user`, { authToken: token })
.then(r => { setData(r); onCashSummaryChange?.({ actualTotal: r?.cashActualTotal ?? null, denominations: r?.cashDenominations || [] }); })
.catch(() => setData({ rows: [], cashActualTotal: null, cashDenominations: [] }))
.finally(() => setLoading(false));
};
useEffect(load, [eventId, token]);
const rows = data?.rows || [];
const sumOf = (m: "cash" | "card" | "eft" | "other", key: "total" | "count") =>
rows.reduce((s, r) => s + (r[m][key] || 0), 0);
const totalCash = sumOf("cash", "total");
const totalCashActual = rows.reduce((s, r) => s + (r.cash.actual || 0), 0);
const grandTotal = rows.reduce((s, r) => s + r.cash.total + r.card.total + r.eft.total + r.other.total, 0);
return (
<div className="bg-white border rounded-lg p-4 space-y-2">
<button className="text-sm font-medium flex items-center gap-1" onClick={() => setOpen(o => !o)}>
<span>{open ? "▾" : "▸"}</span> Payment accountability by staff member
</button>
{open && (
loading ? (
<div className="text-sm text-gray-400">Loading</div>
) : rows.length === 0 ? (
<div className="text-sm text-gray-500">No payments recorded for this event.</div>
) : (
<div className="overflow-auto">
<table className="w-full text-sm min-w-[760px] border-separate border-spacing-0">
<thead>
<tr className="text-gray-400 text-[10px] uppercase tracking-wide">
<th rowSpan={2} className="text-left align-bottom pb-1 pr-3">Staff member</th>
<th colSpan={3} className="text-center pb-1 border-l border-gray-100 px-2">Cash</th>
<th rowSpan={2} className="text-right align-bottom pb-1 border-l border-gray-100 px-2">Card</th>
<th rowSpan={2} className="text-right align-bottom pb-1 px-2">EFT</th>
<th rowSpan={2} className="text-right align-bottom pb-1 px-2">Other</th>
<th rowSpan={2} className="text-right align-bottom pb-1 border-l border-gray-100 px-2">Total</th>
<th rowSpan={2}></th>
</tr>
<tr className="text-left text-gray-500 border-b text-xs">
<th className="text-right pb-1.5 border-l border-gray-100 px-2 font-normal">Expected</th>
<th className="text-right pb-1.5 px-2 font-normal">Actual</th>
<th className="text-right pb-1.5 px-2 font-normal">Variance</th>
<th className="border-l border-gray-100"></th>
<th></th>
<th></th>
<th className="border-l border-gray-100"></th>
<th></th>
</tr>
</thead>
<tbody>
{rows.map(r => (
<React.Fragment key={r.userId || "unknown"}>
<tr className="border-b border-gray-100 last:border-0">
<td className="py-2.5 pr-3">
<div className="font-medium text-gray-800">{r.name}</div>
{r.email && <div className="text-xs text-gray-400">{r.email}</div>}
</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(r.cash.total)}</td>
<td className="py-2.5 text-right px-2">{r.cash.actual != null ? money(r.cash.actual) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right px-2">
{r.cash.variance != null ? (
Math.abs(r.cash.variance) > 0.01 ? (
<span className={"inline-block px-1.5 py-0.5 rounded text-xs font-medium " + (r.cash.variance < 0 ? "bg-rose-50 text-rose-700" : "bg-amber-50 text-amber-700")}>
{money(r.cash.variance)}
</span>
) : <span className="inline-block px-1.5 py-0.5 rounded text-xs font-medium bg-emerald-50 text-emerald-700">Matches</span>
) : <span className="text-gray-300"></span>}
</td>
<td className="py-2.5 text-right text-gray-600 border-l border-gray-100 px-2">{r.card.total > 0 ? money(r.card.total) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right text-gray-600 px-2">{r.eft.total > 0 ? money(r.eft.total) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right text-gray-600 px-2">{r.other.total > 0 ? money(r.other.total) : <span className="text-gray-300"></span>}</td>
<td className="py-2.5 text-right font-medium border-l border-gray-100 px-2">{money(r.cash.total + r.card.total + r.eft.total + r.other.total)}</td>
<td className="py-2.5 text-right pl-2">
{r.userId && (
<button
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"}
</button>
)}
</td>
</tr>
{r.userId && editingUserId === r.userId && (
<tr>
<td colSpan={9} className="pb-3">
<PersonCashCountEditor
eventId={eventId}
token={token}
userId={r.userId}
initialDenominations={r.cash.denominations}
initialNotes={r.cash.notes}
enteredBy={r.cash.enteredBy}
countUpdatedAt={r.cash.countUpdatedAt}
onSaved={() => { setEditingUserId(null); load(); }}
onCancel={() => setEditingUserId(null)}
/>
</td>
</tr>
)}
</React.Fragment>
))}
<tr className="font-semibold border-t border-gray-200">
<td className="py-2.5 pr-3">Total</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(totalCash)}</td>
<td className="py-2.5 text-right px-2">{money(totalCashActual)}</td>
<td className="py-2.5 text-right px-2">{money(totalCashActual - totalCash)}</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(sumOf("card", "total"))}</td>
<td className="py-2.5 text-right px-2">{money(sumOf("eft", "total"))}</td>
<td className="py-2.5 text-right px-2">{money(sumOf("other", "total"))}</td>
<td className="py-2.5 text-right border-l border-gray-100 px-2">{money(grandTotal)}</td>
<td></td>
</tr>
</tbody>
</table>
</div>
)
)}
</div>
);
}
// Denomination entry for one person's actual cash count — pick a denomination from the dropdown,
// enter how many, add it to the list. Repeatable, editable, removable before saving.
function PersonCashCountEditor({ eventId, token, userId, initialDenominations, initialNotes, enteredBy, countUpdatedAt, onSaved, onCancel }: {
eventId: string; token: string; userId: string;
initialDenominations: PersonDenom[]; initialNotes: string | null;
enteredBy: { id: string; name: string } | null; countUpdatedAt: string | null;
onSaved: () => void; onCancel: () => void;
}) {
const initialCounts: Record<number, string> = {};
for (const d of initialDenominations) initialCounts[d.value] = String(d.count);
const [counts, setCounts] = useState<Record<number, string>>(initialCounts);
const [notes, setNotes] = useState(initialNotes || "");
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const setCount = (value: number, v: string) => setCounts(prev => ({ ...prev, [value]: v }));
const lines: PersonDenom[] = ZAR_DENOMINATIONS
.map(value => ({ value, count: parseInt(counts[value] || "0", 10) || 0 }))
.filter(d => d.count > 0);
const runningTotal = lines.reduce((s, l) => s + l.value * l.count, 0);
const save = async () => {
setSaving(true); setErr(null);
try {
await apiFetch(`/api/cashups/event/${eventId}/person-cash/${userId}`, {
method: "PUT", authToken: token, body: { denominations: lines, notes: notes || null }
});
onSaved();
} catch (e: any) {
setErr(e?.message || "Failed to save count");
} finally {
setSaving(false);
}
};
return (
<div className="border rounded-lg p-3 bg-gray-50 space-y-3">
{err && <div className="text-xs text-red-600">{err}</div>}
{enteredBy && countUpdatedAt && (
<div className="text-[11px] text-gray-500">Last entered by {enteredBy.name} on {new Date(countUpdatedAt).toLocaleString()}</div>
)}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{ZAR_DENOMINATIONS.map(v => (
<div key={v} className="flex items-center gap-2">
<span className="text-sm w-14">{denomLabel(v)}</span>
<span className="text-xs text-gray-400">×</span>
<input
type="number"
min={0}
step={1}
className="w-16 border rounded px-2 py-1 text-sm"
value={counts[v] || ""}
onChange={e => setCount(v, e.target.value)}
/>
</div>
))}
</div>
<div className="text-sm font-medium">Total: {money(runningTotal)}</div>
<div>
<label className="block text-[11px] text-gray-600 mb-1">Notes (optional)</label>
<input className="w-full border rounded px-2 py-1.5 text-sm" value={notes} onChange={e => setNotes(e.target.value)} />
</div>
<div 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-brand-600 text-white hover:bg-brand-700" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save count"}</button>
</div>
</div>
);
}
// ─── Report tab ──────────────────────────────────────────────────────────
// A clean, read-only summary of the cashup — opened automatically once the event is closed, so
// staff land straight on "here's what happened" instead of the editable Reconciliation tab.
function ReportTab({ eventId, token, data, isClosed }: {
eventId: string; token: string; data: EventFinancials; isClosed: boolean;
}) {
const reconciled = data.reconciled;
const latestClose = data.history.find(h => h.action === "closed" || h.action === "quick_closed");
return (
<div className="space-y-4">
<div className="bg-white border rounded-lg p-4">
<div className="flex items-center justify-between mb-1">
<div className="text-sm font-medium">Cashup report</div>
<span className={"text-xs px-2 py-1 rounded " + (isClosed ? "bg-rose-50 text-rose-700" : "bg-emerald-50 text-emerald-700")}>
{isClosed ? "Closed" : "Open (live preview)"}
</span>
</div>
{latestClose ? (
<div className="text-xs text-gray-500">
{latestClose.action === "closed" ? "Closed (full cashup)" : "Quick closed"} by {latestClose.performedBy?.name || "Unknown"} on {new Date(latestClose.createdAt).toLocaleString()}
</div>
) : (
<div className="text-xs text-gray-500">This event hasn&apos;t been closed yet figures below are a live preview and will change as more payments come in.</div>
)}
</div>
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
<div className="text-sm font-medium">Revenue by method</div>
<table className="w-full text-sm min-w-[560px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Method</th>
<th className="py-1 text-right">Income</th>
<th className="py-1 text-right">Expected cash</th>
<th className="py-1 text-right">Actual</th>
<th className="py-1 text-right">Variance</th>
</tr>
</thead>
<tbody>
{METHODS.map(m => {
const r = reconciled?.byMethod?.[m];
return (
<tr key={m} className="border-b last:border-0">
<td className="py-1.5">{METHOD_LABELS[m]}</td>
<td className="py-1.5 text-right">{money(data.paymentsByMethod[m])}</td>
<td className="py-1.5 text-right">{money(data.expectedCashByMethod[m])}</td>
<td className="py-1.5 text-right">{r?.actual != null ? money(r.actual) : "—"}</td>
<td className="py-1.5 text-right">{r?.variance != null ? money(r.variance) : "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
{reconciled?.byMethod?.cash?.denominations?.length ? (
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">Cash denomination count</div>
<table className="text-sm">
<tbody>
{reconciled.byMethod.cash.denominations.map((d: any) => (
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
</table>
</div>
) : null}
<CashByUserSection eventId={eventId} token={token} />
{data.costs.length > 0 && (
<div className="bg-white border rounded-lg p-4 space-y-2 overflow-auto">
<div className="text-sm font-medium">Costs</div>
<table className="w-full text-sm min-w-[480px]">
<thead>
<tr className="text-left text-gray-500 border-b">
<th className="py-1">Label</th>
<th className="py-1">Paid from</th>
<th className="py-1 text-right">Total</th>
</tr>
</thead>
<tbody>
{data.costs.map((c: any) => (
<tr key={c.id} className="border-b last:border-0">
<td className="py-1.5">{c.label}</td>
<td className="py-1.5 capitalize">{c.paidFromMethod || "—"}</td>
<td className="py-1.5 text-right">{money(c.total ?? c.amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div>
<div className="text-gray-500 text-xs">Donations counted as profit</div>
<div className="font-semibold">{money(data.unallocatedDonationsTotal)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Total costs</div>
<div className="font-semibold">{money(data.totalCosts)}</div>
</div>
<div>
<div className="text-gray-500 text-xs">Net profit</div>
<div className="font-semibold">{money(data.netProfit)}</div>
</div>
</div>
</div>
);
}
function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onChanged, onClosed }: {
eventId: string; token: string; data: EventFinancials; busy: boolean;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void;
setBusy: (b: boolean) => void; setError: (e: string | null) => void; onChanged: () => void; onClosed: () => void;
}) {
const isClosed = data.event.cashupStatus === "closed";
const draftLines = (data.event as any).cashupDraft?.lines as Array<{ method: string; actualAmount?: string | number; notes?: string; denominations?: { value: number; count: number }[] }> | undefined;
@@ -281,32 +635,23 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
return base;
}, [draftLines]);
const initialDenomCounts: Record<number, string> = useMemo(() => {
const cashLine = (draftLines || []).find(l => l.method === "cash");
const out: Record<number, string> = {};
for (const d of cashLine?.denominations || []) out[d.value] = String(d.count);
return out;
}, [draftLines]);
const [lines, setLines] = useState<Record<CashupMethod, LineInput>>(initialLines);
const [denomCounts, setDenomCounts] = useState<Record<number, string>>(initialDenomCounts);
const [closeNotes, setCloseNotes] = useState("");
const [reopenNotes, setReopenNotes] = useState("");
// Cash no longer has its own manual entry — it's the live sum of every staff member's
// per-person count, reported up from CashByUserSection below.
const [cashSummary, setCashSummary] = useState<{ actualTotal: number | null; denominations: PersonDenom[] }>({ actualTotal: null, denominations: [] });
useEffect(() => { setLines(initialLines); setDenomCounts(initialDenomCounts); }, [initialLines, initialDenomCounts]);
useEffect(() => { setLines(initialLines); }, [initialLines]);
const setLine = (method: CashupMethod, field: keyof LineInput, value: string) => {
setLines(prev => ({ ...prev, [method]: { ...prev[method], [field]: value } }));
};
const cashDenominationsPayload = () => ZAR_DENOMINATIONS
.map(value => ({ value, count: parseInt(denomCounts[value] || "0", 10) || 0 }))
.filter(d => d.count > 0);
const cashActualFromDenoms = cashDenominationsPayload().reduce((sum, d) => sum + d.value * d.count, 0);
// Cash is included with notes only — its actual/denominations are always sourced server-side
// from per-person counts (see cashupController.closeEvent), never from this payload.
const buildLinesPayload = () => METHODS.map(m => m === "cash"
? { method: "cash", denominations: cashDenominationsPayload(), notes: lines.cash.notes || null }
? { method: "cash", notes: lines.cash.notes || null }
: { method: m, actualAmount: lines[m].actualAmount === "" ? null : parseFloat(lines[m].actualAmount), notes: lines[m].notes || null });
const saveDraft = async () => {
@@ -327,6 +672,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { lines: buildLinesPayload(), notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
@@ -340,6 +686,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
try {
await apiFetch(`/api/cashups/event/${eventId}/close`, { method: "POST", authToken: token, body: { notes: closeNotes || null } });
onChanged();
onClosed();
} catch (e: any) {
setError(e?.message || "Failed to close event");
} finally {
@@ -391,7 +738,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed ? (
money(r?.actual ?? null)
) : m === "cash" ? (
money(cashActualFromDenoms)
cashSummary.actualTotal != null ? money(cashSummary.actualTotal) : <span className="text-gray-400">not counted</span>
) : (
<input type="number" step="0.01" className="w-28 border rounded px-2 py-1 text-sm text-right" value={lines[m].actualAmount} onChange={e => setLine(m, "actualAmount", e.target.value)} />
)}
@@ -400,7 +747,7 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
{isClosed
? (r?.variance != null ? money(r.variance) : <span className="text-gray-400">not reconciled</span>)
: (m === "cash"
? (cashActualFromDenoms > 0 ? money(cashActualFromDenoms - data.expectedCashByMethod[m]) : "—")
? (cashSummary.actualTotal != null ? money(cashSummary.actualTotal - data.expectedCashByMethod[m]) : "—")
: (lines[m].actualAmount !== "" ? money(parseFloat(lines[m].actualAmount) - data.expectedCashByMethod[m]) : "—"))}
</td>
<td className="py-1.5">
@@ -413,38 +760,31 @@ function ReconciliationTab({ eventId, token, data, busy, setBusy, setError, onCh
})}
</tbody>
</table>
{!isClosed && (
<div className="text-xs text-gray-500">Cash actual is the live sum of per-staff-member counts entered below there's no separate event-wide entry.</div>
)}
</div>
<CashByUserSection eventId={eventId} token={token} onCashSummaryChange={setCashSummary} />
<div className="bg-white border rounded-lg p-4 space-y-2">
<div className="text-sm font-medium">Cash denomination count</div>
{isClosed ? (
reconciled?.byMethod?.cash?.denominations?.length ? (
{(() => {
const denoms = isClosed ? (reconciled?.byMethod?.cash?.denominations || []) : cashSummary.denominations;
return denoms.length > 0 ? (
<table className="text-sm">
<tbody>
{reconciled.byMethod.cash.denominations.map(d => (
<tr key={d.id}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
{denoms.map((d: any) => (
<tr key={d.id || d.value}><td className="pr-4 py-0.5">{denomLabel(d.value)}</td><td className="pr-4 py-0.5">× {d.count}</td><td className="py-0.5 text-gray-500">{money(d.value * d.count)}</td></tr>
))}
</tbody>
</table>
) : <div className="text-xs text-gray-400">No denomination breakdown recorded for this cashup.</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
{ZAR_DENOMINATIONS.map(v => (
<div key={v} className="flex items-center gap-2">
<span className="text-sm w-14">{denomLabel(v)}</span>
<span className="text-xs text-gray-400">×</span>
<input
type="number"
min={0}
step={1}
className="w-16 border rounded px-2 py-1 text-sm"
value={denomCounts[v] || ""}
onChange={e => setDenomCounts(prev => ({ ...prev, [v]: e.target.value }))}
/>
<div className="text-xs text-gray-400">
{isClosed ? "No denomination breakdown recorded for this cashup." : "No per-staff-member cash counts entered yet — see “Payment accountability by staff member” above."}
</div>
))}
</div>
)}
);
})()}
</div>
<div className="bg-white border rounded-lg p-4 grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
@@ -469,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 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">Post-event Cashup</h1>
<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>
);
})}
+127 -163
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>
<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 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="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="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">
<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>
<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">Payments</h2>
<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="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-2xl font-semibold text-gray-900">{formatRand(paymentsTabValue)}</div>
) : (
<div className="text-sm text-gray-500">No payment data yet.</div>
)}
{scanStats?.byStaff?.length > 0 && (
<div className="mb-1">
<div className="text-sm font-medium mb-1">Today by staff</div>
<ul className="text-sm text-gray-700 space-y-1">
{scanStats.byStaff.map((s: any) => (
<li key={s.scannedById} className="flex justify-between">
<span>{s.name || 'Staff'}</span>
<span className="font-medium">{s.count}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
<div>
<div className="border rounded-xl p-4 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-3">Admin stats</h2>
{loadingStats && <div className="text-sm text-gray-500 mb-2">Loading</div>}
<div className="space-y-2">
<div className="border rounded p-3 bg-white flex items-center justify-between">
<div>
<div className="text-xs text-gray-500">Active events</div>
<div className="text-lg font-semibold">{activeEventsCount}</div>
</div>
<button className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={() => router.push("/dashboard/staff/event-tickets")}>View</button>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Revenue today</div>
<div className="text-lg font-semibold">R {(paymentStats?.totalToday || 0).toFixed(2)}</div>
</div>
<div className="border rounded p-3 bg-white">
<div className="text-xs text-gray-500">Donations today</div>
<div className="text-lg font-semibold">{paymentStats?.donationsToday || 0}</div>
</div>
</div>
</div>
<div className="text-sm text-gray-600 mt-6">
<p>As an admin you can access Supervisor and Staff tools. Use the quick actions above to jump to common tasks.</p>
</div>
<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,11 +158,45 @@ export default function AdminRegistrationsPage() {
return "text-gray-700 bg-gray-50";
};
const totalDueFor = (r: any) => (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalPaidFor = (r: any) => (r.payments || []).reduce((sum: number, p: any) => sum + (p.amount || 0), 0);
// Aggregate stats across the currently filtered registrations — counts by status, plus
// revenue/outstanding totals (cancelled registrations are excluded from the money totals
// since they're not expected to be paid).
const stats = useMemo(() => {
const counts: Record<string, number> = {};
let totalRevenue = 0;
let totalOutstanding = 0;
filtered.forEach((r: any) => {
counts[r.status] = (counts[r.status] || 0) + 1;
if (r.status === "cancelled") return;
const due = totalDueFor(r);
const paid = totalPaidFor(r);
totalRevenue += paid;
totalOutstanding += Math.max(due - paid, 0);
});
return { counts, totalRevenue, totalOutstanding };
}, [filtered]);
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 && (
@@ -173,6 +208,24 @@ export default function AdminRegistrationsPage() {
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
{/* Aggregate stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-2 mb-4">
{STATUS_OPTIONS.map(s => (
<div key={s} className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500 capitalize">{s.replace("_", " ")}</div>
<div className="text-lg font-semibold">{stats.counts[s] || 0}</div>
</div>
))}
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500">Total revenue</div>
<div className="text-lg font-semibold text-green-700">R {stats.totalRevenue.toFixed(2)}</div>
</div>
<div className="border rounded-lg p-2.5 bg-white shadow-sm">
<div className="text-xs text-gray-500">Total outstanding</div>
<div className="text-lg font-semibold text-amber-700">R {stats.totalOutstanding.toFixed(2)}</div>
</div>
</div>
{/* Filters */}
<div className="border rounded-xl p-4 bg-white shadow-sm mb-4">
<div className="flex flex-wrap gap-3 items-end">
@@ -221,12 +274,9 @@ export default function AdminRegistrationsPage() {
<div className="border rounded-xl bg-white shadow-sm">
<ul className="divide-y text-sm">
{filtered.map((r: any) => {
const totalDue = (r.registrationOptions || []).reduce((sum: number, opt: any) => {
const unit = (opt.priceSnapshot !== null && opt.priceSnapshot !== undefined)
? Number(opt.priceSnapshot)
: (opt.eventOption?.price || 0);
return sum + unit * (opt.quantity || 0);
}, 0);
const totalDue = totalDueFor(r);
const totalPaid = totalPaidFor(r);
const outstanding = Math.max(totalDue - totalPaid, 0);
const isExpanded = expanded.has(r.id);
const responses = formResponses[r.id];
const loadingResponse = loadingForms.has(r.id);
@@ -245,7 +295,9 @@ export default function AdminRegistrationsPage() {
<div className="text-xs text-gray-500 mt-0.5">
{r.user?.email && <span className="mr-2">{r.user.email}</span>}
{r.user?.phoneNumber && <span className="mr-2">{r.user.phoneNumber}</span>}
<span>R {totalDue.toFixed(2)}</span>
<span>R {totalPaid.toFixed(2)} paid</span>
{outstanding > 0.000001 && <span className="ml-2 text-amber-700">R {outstanding.toFixed(2)} owing</span>}
<span className="ml-2 text-gray-400">(R {totalDue.toFixed(2)} total)</span>
<span className="ml-2 text-gray-400">#{String(r.id).slice(0, 8)}</span>
</div>
</div>
@@ -261,7 +313,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'}
@@ -306,6 +358,31 @@ export default function AdminRegistrationsPage() {
</div>
)}
{/* Payments */}
<div className="mb-3">
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Payments</div>
{(r.payments || []).length === 0 ? (
<div className="text-xs text-gray-400">No payments recorded.</div>
) : (
<div className="grid sm:grid-cols-2 gap-2">
{r.payments.map((p: any) => (
<div key={p.id} className="bg-white border rounded p-2 text-xs">
<div className="font-medium">
{p.amount < 0 ? '-' : ''}R {Math.abs(p.amount).toFixed(2)} · {p.method || 'payment'}
</div>
<div className="text-gray-500">{new Date(p.createdAt).toLocaleString()}</div>
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</div>
))}
</div>
)}
<div className="text-xs text-gray-700 mt-1 font-medium">
Paid: R {totalPaid.toFixed(2)}{outstanding > 0.000001 && <span className="text-amber-700"> · Owing: R {outstanding.toFixed(2)}</span>}
</div>
</div>
{/* Form responses */}
<div>
<div className="text-xs font-semibold text-gray-600 mb-1 uppercase tracking-wide">Form responses</div>
@@ -1,24 +1,29 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, type LucideIcon } from "lucide-react";
import { ColorPickerField } from "@/components/admin/ColorPickerField";
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
import { extractDominantColors } from "@/lib/extractColors";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp";
const TABS: { id: TabId; label: string }[] = [
{ id: "organisation", label: "Organisation" },
{ id: "branding", label: "Branding" },
{ id: "notifications", label: "Notifications" },
{ id: "email", label: "Email" },
{ id: "legal", label: "Legal" },
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
{ id: "organisation", label: "Organisation", icon: Building2 },
{ id: "branding", label: "Branding", icon: Palette },
{ id: "notifications", label: "Notifications", icon: Bell },
{ id: "email", label: "Email", icon: Mail },
{ id: "legal", label: "Legal", icon: Scale },
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
];
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400";
function Field({
label, hint, required, children,
@@ -56,7 +61,7 @@ function SaveBar({
type="button"
disabled={saving}
onClick={onSave}
className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Saving…" : "Save"}
</button>
@@ -65,11 +70,21 @@ function SaveBar({
}
export default function SiteSettingsPage() {
return (
<Suspense fallback={<div className="p-6 text-sm text-gray-500">Loading settings</div>}>
<SiteSettingsPageInner />
</Suspense>
);
}
function SiteSettingsPageInner() {
const { token } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const { reload: reloadSettings } = useSiteSettings();
const [activeTab, setActiveTab] = useState<TabId>("organisation");
const initialTab = TABS.some(t => t.id === searchParams.get("tab")) ? (searchParams.get("tab") as TabId) : "organisation";
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
const [loadingInitial, setLoadingInitial] = useState(true);
// Per-tab save state
@@ -85,11 +100,18 @@ export default function SiteSettingsPage() {
const [appBaseUrl, setAppBaseUrl] = useState("");
// ── Branding ──
const [accentColor, setAccentColor] = useState("#2563eb");
const [primaryColor, setPrimaryColor] = useState("#4F46E5");
const [secondaryColor, setSecondaryColor] = useState("#8B5CF6");
const [accentColor, setAccentColor] = useState("#EC4899");
const [logoUrl, setLogoUrl] = useState("");
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);
const [suggestedColors, setSuggestedColors] = useState<string[]>([]);
const fileRef = useRef<HTMLInputElement>(null);
const [faviconUrl, setFaviconUrl] = useState("");
const [faviconFile, setFaviconFile] = useState<File | null>(null);
const [faviconPreview, setFaviconPreview] = useState<string | null>(null);
const faviconRef = useRef<HTMLInputElement>(null);
// ── Notifications ──
const [notifEmails, setNotifEmails] = useState("");
@@ -123,8 +145,16 @@ export default function SiteSettingsPage() {
setOrgPhone(s.org_phone || "");
setOrgAddress(s.org_address || "");
setAppBaseUrl(s.app_base_url || "");
setAccentColor(s.accent_color || "#2563eb");
// primary_color falls back to the legacy accent_color value (which used
// to be "the one brand color" before this 3-color system existed).
// accent_color is only trusted as the NEW tertiary color once
// primary_color already exists — otherwise it's still doing legacy-
// primary duty and must not also seed the Accent field.
setPrimaryColor(s.primary_color || s.accent_color || "#4F46E5");
setSecondaryColor(s.secondary_color || "#8B5CF6");
setAccentColor(s.primary_color ? (s.accent_color || "#EC4899") : "#EC4899");
setLogoUrl(s.logo_url || "");
setFaviconUrl(s.favicon_url || "");
setNotifEmails(s.reg_notification_emails || "");
setSmtpHost(s.smtp_host || "");
setSmtpPort(s.smtp_port || "587");
@@ -171,31 +201,47 @@ export default function SiteSettingsPage() {
app_base_url: appBaseUrl.trim(),
});
const uploadBrandingAsset = async (file: File, kind: "logo" | "favicon"): Promise<string> => {
const fd = new FormData();
fd.append("image", file);
const res = await fetch(`${API_BASE}/api/uploads/${kind}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error(`${kind === "logo" ? "Logo" : "Favicon"} upload failed`);
const data = await res.json();
return data.url || "";
};
const saveBranding = async () => {
if (!token) return;
setSaving(true);
setResult(null);
try {
let finalLogoUrl = logoUrl;
if (logoFile) {
const fd = new FormData();
fd.append("image", logoFile);
const res = await fetch(`${API_BASE}/api/uploads/logo`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: fd,
});
if (!res.ok) throw new Error("Logo upload failed");
const data = await res.json();
finalLogoUrl = data.url || logoUrl;
}
if (logoFile) finalLogoUrl = await uploadBrandingAsset(logoFile, "logo") || logoUrl;
let finalFaviconUrl = faviconUrl;
if (faviconFile) finalFaviconUrl = await uploadBrandingAsset(faviconFile, "favicon") || faviconUrl;
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { accent_color: accentColor, logo_url: finalLogoUrl },
body: {
primary_color: primaryColor,
secondary_color: secondaryColor,
accent_color: accentColor,
logo_url: finalLogoUrl,
favicon_url: finalFaviconUrl,
},
});
setLogoUrl(finalLogoUrl);
setLogoFile(null);
setLogoPreview(null);
setFaviconUrl(finalFaviconUrl);
setFaviconFile(null);
setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Saved." });
reloadSettings();
} catch (e: any) {
@@ -205,6 +251,39 @@ export default function SiteSettingsPage() {
}
};
const resetBranding = async () => {
if (!token) return;
if (!confirm("Reset branding to defaults? This clears your custom colors, logo, and favicon.")) return;
setSaving(true);
setResult(null);
try {
await apiFetch("/api/settings", {
method: "PUT", authToken: token,
body: { primary_color: "", secondary_color: "", accent_color: "", logo_url: "", favicon_url: "" },
});
setPrimaryColor("#4F46E5");
setSecondaryColor("#8B5CF6");
setAccentColor("#EC4899");
setLogoUrl(""); setLogoFile(null); setLogoPreview(null);
setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null);
setSuggestedColors([]);
setResult({ ok: true, message: "Reset to defaults." });
reloadSettings();
} catch (e: any) {
setResult({ ok: false, message: e?.message || "Failed to reset" });
} finally {
setSaving(false);
}
};
const handleLogoFileChange = async (file: File | undefined) => {
if (!file) return;
setLogoFile(file);
const preview = URL.createObjectURL(file);
setLogoPreview(preview);
setSuggestedColors(await extractDominantColors(preview));
};
const saveNotifications = () => save({ reg_notification_emails: notifEmails.trim() });
const saveSmtp = async () => {
@@ -267,17 +346,18 @@ export default function SiteSettingsPage() {
if (loadingInitial) return <div className="p-6 text-sm text-gray-500">Loading settings</div>;
const currentLogoSrc = logoPreview || (logoUrl ? resolveToApiOrigin(logoUrl) : null);
const currentFaviconSrc = faviconPreview || (faviconUrl ? resolveToApiOrigin(faviconUrl) : null);
return (
<div className="max-w-3xl mx-auto w-full p-6">
<div className={activeTab === "branding" ? "max-w-6xl mx-auto w-full" : "max-w-3xl mx-auto w-full"}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold">Site Settings</h1>
<h1 className="text-2xl font-semibold text-gray-900">Site Settings</h1>
<p className="text-sm text-gray-500 mt-1">Configure your organisation, branding, email, and legal pages.</p>
</div>
<button
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200"
className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200"
onClick={() => router.push("/dashboard/admin")}
>
Back
@@ -285,28 +365,35 @@ export default function SiteSettingsPage() {
</div>
{/* Tab bar */}
<div className="flex items-center gap-2 flex-wrap mb-6">
{TABS.map((tab) => (
<div className="flex items-center gap-2 flex-wrap mb-4">
{TABS.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => switchTab(tab.id)}
className={`px-3 py-1.5 text-sm rounded border transition-colors ${
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border transition-colors ${
activeTab === tab.id
? "bg-indigo-600 text-white border-indigo-600"
: "bg-white text-gray-800 border-gray-200 hover:bg-gray-50"
? "bg-brand-600 text-white border-brand-600"
: "bg-white text-gray-700 border-gray-200 hover:bg-gray-50"
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
))}
);
})}
</div>
<div className={activeTab === "branding" ? "grid lg:grid-cols-5 gap-5 items-start" : ""}>
<div className={`border rounded-xl p-5 bg-white shadow-sm ${activeTab === "branding" ? "lg:col-span-3" : ""}`}>
{/* ── Organisation ─────────────────────────────────────────────────── */}
{activeTab === "organisation" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Organisation details</h2>
<Field label="Organisation name" required>
<input className={inputCls} placeholder="Hope Family Church"
<input className={inputCls} placeholder="Your Organisation"
value={orgName} onChange={e => setOrgName(e.target.value)} />
</Field>
<Field label="Tagline">
@@ -338,15 +425,16 @@ export default function SiteSettingsPage() {
{/* ── Branding ─────────────────────────────────────────────────────── */}
{activeTab === "branding" && (
<div className="space-y-5">
<Field label="Accent / brand colour">
<div className="flex items-center gap-3">
<input type="color" className="h-10 w-20 border rounded cursor-pointer"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<input className={`${inputCls} font-mono`} placeholder="#2563eb"
value={accentColor} onChange={e => setAccentColor(e.target.value)} />
<h2 className="text-lg font-semibold text-gray-900">Branding</h2>
<div className="grid sm:grid-cols-3 gap-4">
<ColorPickerField label="Primary" hint="Buttons, links, nav highlights, and the email header/CTA."
value={primaryColor} onChange={setPrimaryColor} />
<ColorPickerField label="Secondary" hint="Subtle backgrounds and outline-button borders/text."
value={secondaryColor} onChange={setSecondaryColor} />
<ColorPickerField label="Accent" hint="Hover states and highlight chips/badges."
value={accentColor} onChange={setAccentColor} />
</div>
<div className="mt-2 h-6 rounded-lg transition-colors" style={{ background: accentColor }} />
</Field>
<Field label="Site logo" hint="PNG, JPG, SVG or WebP, max 2 MB. Displayed in the navigation bar.">
{currentLogoSrc && (
@@ -356,27 +444,84 @@ export default function SiteSettingsPage() {
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); if (fileRef.current) fileRef.current.value = ""; }}
onClick={() => { setLogoUrl(""); setLogoFile(null); setLogoPreview(null); setSuggestedColors([]); if (fileRef.current) fileRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={fileRef} type="file" accept="image/*" onChange={e => {
<input ref={fileRef} type="file" accept="image/*" onChange={e => handleLogoFileChange(e.target.files?.[0])} className="text-sm" />
{suggestedColors.length > 0 && (
<div className="mt-3 border rounded-lg p-3 bg-gray-50">
<p className="text-xs font-medium text-gray-600 mb-2">Suggested from your logo click a swatch to apply it</p>
<div className="flex flex-wrap gap-2">
{suggestedColors.map((hex) => (
<div key={hex} className="flex items-center gap-1 border rounded-lg bg-white p-1">
<div className="w-6 h-6 rounded" style={{ background: hex }} title={hex} />
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setPrimaryColor(hex)}>P</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setSecondaryColor(hex)}>S</button>
<button type="button" className="text-[10px] font-semibold px-1.5 py-1 rounded hover:bg-gray-100" onClick={() => setAccentColor(hex)}>A</button>
</div>
))}
</div>
</div>
)}
</Field>
<Field label="Favicon" hint="ICO, PNG, or SVG, max 2 MB, ideally square. Shown in the browser tab — falls back to the default if not set.">
{currentFaviconSrc && (
<div className="mb-3 flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={currentFaviconSrc} alt="Current favicon" className="h-8 w-8 object-contain border rounded p-1 bg-gray-50" />
<button
type="button"
className="text-xs text-red-500 hover:text-red-700"
onClick={() => { setFaviconUrl(""); setFaviconFile(null); setFaviconPreview(null); if (faviconRef.current) faviconRef.current.value = ""; }}
>
Remove
</button>
</div>
)}
<input ref={faviconRef} type="file" accept=".ico,.png,.svg,image/x-icon,image/png,image/svg+xml" onChange={e => {
const file = e.target.files?.[0];
if (!file) return;
setLogoFile(file);
setLogoPreview(URL.createObjectURL(file));
setFaviconFile(file);
setFaviconPreview(URL.createObjectURL(file));
}} className="text-sm" />
</Field>
<SaveBar saving={saving} onSave={saveBranding} result={result} />
<div className="flex items-center justify-between pt-4 border-t mt-6 flex-wrap gap-3">
<button
type="button"
disabled={saving}
onClick={resetBranding}
className="px-4 py-2 text-sm rounded-lg border border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-50"
>
Reset to defaults
</button>
<div className="flex items-center gap-3">
{result && (
<span className={`text-sm flex items-center gap-1.5 ${result.ok ? "text-green-600" : "text-red-600"}`}>
{result.ok ? "✓" : "✗"} {result.message}
</span>
)}
<button
type="button"
disabled={saving}
onClick={saveBranding}
className="px-6 py-2 bg-brand-600 hover:bg-brand-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
)}
{/* ── Notifications ─────────────────────────────────────────────────── */}
{activeTab === "notifications" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Notifications</h2>
<Field
label="Registration notification emails"
hint="Who gets notified when someone registers for an event. Separate multiple addresses with commas."
@@ -391,6 +536,7 @@ export default function SiteSettingsPage() {
{/* ── Email / SMTP ──────────────────────────────────────────────────── */}
{activeTab === "email" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Email</h2>
<p className="text-sm text-gray-500">
Outgoing email for tickets, payment confirmations, and account notifications.
Leave blank to use server environment variables. The password is stored encrypted.
@@ -470,6 +616,7 @@ export default function SiteSettingsPage() {
{/* ── Legal ─────────────────────────────────────────────────────────── */}
{activeTab === "legal" && (
<div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Legal</h2>
<p className="text-sm text-gray-500">
These values populate the Terms of Use and Privacy Policy pages automatically.
</p>
@@ -505,6 +652,560 @@ export default function SiteSettingsPage() {
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
{/* ── WhatsApp ──────────────────────────────────────────────────────── */}
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
</div>
{activeTab === "branding" && (
<div className="lg:col-span-2 lg:sticky lg:top-4">
<BrandingPreviewPanel
primaryColor={primaryColor}
secondaryColor={secondaryColor}
accentColor={accentColor}
logoSrc={currentLogoSrc}
orgName={orgName}
orgTagline={orgTagline}
/>
</div>
)}
</div>
</div>
);
}
// ─── WhatsApp tab (migrated from the old standalone /dashboard/admin/whatsapp page) ───
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;
}
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-brand-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 WAAlert({ 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>;
}
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-brand-600 border-brand-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>
);
}
/** `active` gates all polling — only run status/QR polling while this tab is actually visible. */
function WhatsAppTab({ active }: { active: boolean }) {
const { token } = useAuth();
const [cfg, setCfg] = useState<ConfigResponse | null>(null);
const [cfgLoading, setCfgLoading] = useState(true);
const step = !cfg ? 0 : !cfg.hasToken ? 1 : !cfg.hasInstance ? 2 : 3;
const [inputToken, setInputToken] = useState("");
const [savingToken, setSavingToken] = useState(false);
const [instanceMode, setInstanceMode] = useState<"enter" | "create">("create");
const [inputInstanceId, setInputInstanceId] = useState("");
const [savingInstance, setSavingInstance] = useState(false);
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("");
const [actionMsg, setActionMsg] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
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, tab shows loading state
} finally {
setCfgLoading(false);
}
}, [token]);
useEffect(() => { if (active) fetchConfig(); }, [fetchConfig, active]);
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) {
await fetchConfig();
setStatus("FAILED");
setStatusMsg(null);
}
}, [token, step, fetchConfig]);
useEffect(() => { if (active && step === 3) fetchStatus(); }, [step, fetchStatus, active]);
// Auto-poll status when not stable — only while this tab is active.
useEffect(() => {
if (!active || step !== 3 || status === null) return;
if (ACTIVE_STATUSES.has(status)) return;
const id = setInterval(fetchStatus, 5_000);
return () => clearInterval(id);
}, [active, step, status, fetchStatus]);
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 (active && status === "SCAN_QR_CODE") { fetchQr(); }
else { setQrSrc(null); }
}, [status, fetchQr, active]);
// Auto-refresh QR every 20s while waiting — only while this tab is active.
useEffect(() => {
if (!active || status !== "SCAN_QR_CODE") return;
const id = setInterval(fetchQr, 20_000);
return () => clearInterval(id);
}, [active, status, fetchQr]);
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 {}
await fetchConfig();
if (!msg.includes("SESSION_NOT_FOUND")) {
setActionMsg({ type: "err", text: msg });
}
await fetchStatus();
} finally {
setBusy(null);
}
};
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);
}
};
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() } });
setInputInstanceId("");
await fetchConfig();
} catch (e: any) {
setActionMsg({ type: "err", text: e?.message || "Failed to save Instance ID." });
} finally {
setSavingInstance(false);
}
};
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);
}
};
const requestPairingCode = async () => {
if (!pairingPhone.trim()) {
setActionMsg({ type: "err", text: "Enter your phone number first." });
return;
}
await doAction("request-code", { phoneNumber: pairingPhone.trim() });
};
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 {}
setCfg(prev => prev ? { ...prev, hasToken: false, hasInstance: false, configured: false, tokenMasked: "", instanceId: "" } : null);
};
if (cfgLoading) {
return (
<div className="flex items-center gap-2 text-sm text-gray-500">
<Spinner /> Loading
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">WhatsApp integration</h2>
<p className="text-sm text-gray-500">Powered by WAWP used to send tickets and notifications via WhatsApp.</p>
</div>
<StepIndicator step={step} />
{actionMsg && <WAAlert type={actionMsg.type}>{actionMsg.text}</WAAlert>}
{step === 1 && (
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
<h3 className="text-base font-semibold">Step 1 Enter your WAWP Access Token</h3>
<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-brand-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-brand-500"
/>
</div>
<button onClick={saveToken} disabled={savingToken} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{savingToken && <Spinner />}
{savingToken ? "Saving…" : "Save Token & Continue"}
</button>
</section>
)}
{step === 2 && (
<section className="border rounded-xl p-5 bg-gray-50 space-y-5">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Step 2 Set Up Session Instance</h3>
<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>
<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-brand-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-brand-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-brand-600 text-white text-sm font-medium hover:bg-brand-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-brand-500"
/>
</div>
<button onClick={saveInstanceId} disabled={savingInstance} className="flex items-center gap-2 px-5 py-2.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-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 && (
<>
<section className="border rounded-xl p-5 bg-gray-50 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Session Status</h3>
<button onClick={fetchStatus} className="text-xs text-brand-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" && (
<WAAlert type="err">The session has failed. The system will attempt to auto-restart. You can also restart manually below.</WAAlert>
)}
{status && POLLING_STATUSES.has(status) && (
<p className="text-xs text-gray-400 flex items-center gap-1"><Spinner /> Auto-refreshing every 5 seconds</p>
)}
<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>
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">Scan QR Code</h3>
<button onClick={fetchQr} className="text-xs text-brand-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 ? (
// eslint-disable-next-line @next/next/no-img-element
<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>
)}
{status === "SCAN_QR_CODE" && (
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
<h3 className="text-base font-semibold">Link by Phone Number Instead</h3>
<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-brand-500"
/>
<button onClick={requestPairingCode} disabled={busy === "request-code"} className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{busy === "request-code" && <Spinner />}
{busy === "request-code" ? "Sending…" : "Send code"}
</button>
</div>
</section>
)}
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
<h3 className="text-base font-semibold">Session Controls</h3>
<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>
<section className="border rounded-xl p-5 bg-gray-50 space-y-4">
<h3 className="text-base font-semibold">Instance Management</h3>
<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>
<details className="border rounded-xl bg-gray-50">
<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-brand-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-brand-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-brand-600 text-white text-sm font-medium hover:bg-brand-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>
);
}
+95 -47
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;
@@ -12,6 +14,7 @@ interface UserItem {
email: string;
role: string;
phoneNumber?: string | null;
notificationPreference?: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
@@ -20,6 +23,8 @@ interface UserItem {
const roleOptions = ["user", "staff", "supervisor", "admin"] as const;
type Role = typeof roleOptions[number];
const notificationPreferenceOptions = ["email", "whatsapp", "both"] as const;
// Simple fuzzy: tolerate one missing/swapped char by checking if query chars appear in order
function fuzzyMatch(query: string, target: string): boolean {
const q = query.toLowerCase();
@@ -71,10 +76,11 @@ export default function AdminUsersPage() {
const [cRole, setCRole] = useState<Role>("user");
const [creating, setCreating] = useState(false);
// Inline edit state
// Edit modal state
const [editingId, setEditingId] = useState<string | null>(null);
const [editData, setEditData] = useState<Partial<UserItem> & { password?: string }>({});
const [saving, setSaving] = useState(false);
const editingUser = useMemo(() => users.find(u => u.id === editingId) || null, [users, editingId]);
const buildQuery = useCallback((p: number, ps = pageSize) => {
const qs = new URLSearchParams({ page: String(p), limit: String(ps) });
@@ -152,6 +158,7 @@ export default function AdminUsersPage() {
email: editData.email,
role: editData.role,
phoneNumber: editData.phoneNumber || null,
notificationPreference: editData.notificationPreference,
isActive: editData.isActive,
};
if (editData.password && editData.password.trim().length > 0) {
@@ -201,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>
@@ -244,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>
@@ -293,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">
@@ -305,8 +319,8 @@ export default function AdminUsersPage() {
<th className="p-2">Email</th>
<th className="p-2">Role</th>
<th className="p-2">Phone</th>
<th className="p-2">Notify</th>
<th className="p-2">Active</th>
<th className="p-2">Password</th>
<th className="p-2">Actions</th>
</tr>
</thead>
@@ -314,63 +328,33 @@ export default function AdminUsersPage() {
{users.map(u => (
<tr key={u.id} className="border-t hover:bg-gray-50">
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-44" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
) : (
<span className={`font-medium ${!u.isActive ? "text-gray-400" : ""}`}>{u.name}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-60" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
) : (
<span className={u.email?.endsWith("@deleted.local") ? "text-gray-400 italic" : ""}>{u.email}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<select className="border rounded px-2 py-1" value={(editData.role as Role) || (u.role as Role)} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
) : (
<span className="capitalize">{u.role}</span>
)}
<RoleBadge role={u.role as RoleBadgeRole} />
</td>
<td className="p-2">
{editingId === u.id ? (
<input className="border rounded px-2 py-1 w-36" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
) : (
<span>{u.phoneNumber || ""}</span>
)}
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
) : (
<span className={u.isActive ? "text-green-700" : "text-gray-400"}>{u.isActive ? "Yes" : "No"}</span>
)}
<span className="capitalize">{u.notificationPreference || "email"}</span>
</td>
<td className="p-2">
{editingId === u.id ? (
<input type="password" placeholder="Set new password" className="border rounded px-2 py-1 w-44" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
) : (
<span className="text-gray-400"></span>
)}
<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">
{editingId === u.id ? (
<div className="flex gap-2">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Cancel</button>
<button className="px-2 py-1 text-xs rounded bg-blue-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
) : (
<div className="flex gap-1 flex-wrap">
<button className="px-2 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200" onClick={() => startEdit(u)}>Edit</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Sign out all devices">Sessions</button>
<button className="px-2 py-1 text-xs rounded bg-amber-500 text-white hover:bg-amber-600" onClick={() => revokeUserSessions(u.id, u.name)} title="Force this user to sign in again on every device where they're currently logged in">Sign out everywhere</button>
<button className="px-2 py-1 text-xs rounded bg-orange-500 text-white hover:bg-orange-600" onClick={() => deactivate(u.id)} title="Deactivate account">Deactivate</button>
<button className="px-2 py-1 text-xs rounded bg-red-700 text-white hover:bg-red-800" onClick={() => deleteUserData(u.id, u.name)} title="Erase personal data">Delete data</button>
</div>
)}
</td>
</tr>
))}
@@ -412,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)}
>
@@ -431,6 +415,70 @@ export default function AdminUsersPage() {
</div>
)}
</div>
{editingUser && (
<div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => !saving && cancelEdit()} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-lg bg-white rounded-lg shadow-lg border p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold">Edit user</h2>
<button type="button" className="text-xs px-2 py-1 rounded bg-gray-100 hover:bg-gray-200" onClick={cancelEdit} disabled={saving}>Close</button>
</div>
<div className="grid gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Name</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.name || ""} onChange={e => setEditData(d => ({ ...d, name: e.target.value }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Email</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.email || ""} onChange={e => setEditData(d => ({ ...d, email: e.target.value }))} />
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Role</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={(editData.role as Role) || "user"} onChange={e => setEditData(d => ({ ...d, role: e.target.value }))}>
{roleOptions.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Phone</label>
<input className="w-full border rounded px-3 py-2 text-sm" value={editData.phoneNumber || ""} onChange={e => setEditData(d => ({ ...d, phoneNumber: e.target.value }))} />
</div>
</div>
<div className="grid sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs text-gray-600 mb-1">Notification preference</label>
<select
className="w-full border rounded px-3 py-2 text-sm"
value={editData.notificationPreference || "email"}
onChange={e => setEditData(d => ({ ...d, notificationPreference: e.target.value }))}
>
{notificationPreferenceOptions.map(p => (
<option key={p} value={p} disabled={p !== "email" && !editData.phoneNumber}>{p}</option>
))}
</select>
</div>
<div className="flex items-end pb-2">
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={!!editData.isActive} onChange={e => setEditData(d => ({ ...d, isActive: e.target.checked }))} />
Active
</label>
</div>
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">New password (leave blank to keep current)</label>
<input type="password" placeholder="Set new password" className="w-full border rounded px-3 py-2 text-sm" value={editData.password || ""} onChange={e => setEditData(d => ({ ...d, password: e.target.value }))} />
</div>
</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-brand-600 text-white disabled:opacity-50" onClick={saveEdit} disabled={saving}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</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>
);
}
// ─── 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>
);
router.replace("/dashboard/admin/settings?tab=whatsapp");
}, [router]);
return <div className="p-6 text-sm text-gray-500">Redirecting</div>;
}
+34 -8
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,18 +76,31 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
);
}
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">
{/* Mobile dropdown navigation */}
<MobileSidebar />
{/* Desktop sidebar */}
<div className="hidden md:block">
<Sidebar />
</div>
<main className="flex-1 p-6 bg-gray-50">{children}</main>
</div>
<Footer />
</div>
);
@@ -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>
+31 -42
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>
)}
<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>
<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">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Recent scans</h2>
@@ -112,28 +120,8 @@ 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>
<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">
@@ -142,7 +130,8 @@ export default function StaffDashboardPage() {
</li>
))}
</ul>
</div>
) : (
<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 => (
{(["registration", "payment", "checkin", "tickets", "refund"] as Mode[]).map(m => {
const Icon = MODE_ICONS[m];
return (
<button
key={m}
onClick={() => setMode(m)}
className={`px-4 py-2 rounded text-sm font-medium border ${
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-indigo-600 text-white border-indigo-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>
@@ -712,7 +730,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 +765,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 +1131,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 +1193,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 +1442,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 +1490,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>
@@ -1872,7 +1890,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 };
@@ -75,7 +76,7 @@ function AttendeesCheckboxDropdown({
)}
</button>
{open && (
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="absolute z-10 mt-1 left-0 right-0 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={allSelected} onChange={e => toggleAll(e.target.checked)} />
@@ -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(''); }
};
@@ -466,8 +467,8 @@ function EmailAttendeesPageInner() {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
}
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
const sent = res?.sent ?? 0; const matched = res?.matched ?? 0;
setInfo(`Sent ${sent} out of ${matched} recipient(s).`);
const queued = res?.queued ?? res?.matched ?? 0;
setInfo(`Queued ${queued} recipient(s) for sending.`);
// Reset form to default state
resetAttendeesForm();
} catch (e: any) {
@@ -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>
@@ -552,7 +562,7 @@ function EmailAttendeesPageInner() {
</select>
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></div>
<div className="text-[11px] text-gray-500 mt-6">Available placeholders: {'{{name}}'}, {'{{event.title}}'}, {'{{event.start}}'}, {'{{balance}}'}, {'{{payment.link}}'} <button type="button" className="ml-2 underline hover:no-underline" onClick={() => setShowInfo(true)}>Learn more</button></div>
</div>
</div>
@@ -572,9 +582,10 @@ function EmailAttendeesPageInner() {
<li><code>{'{{event.title}}'}</code> the event title.</li>
<li><code>{'{{event.start}}'}</code> the event start date/time (local).</li>
<li><code>{'{{balance}}'}</code> outstanding amount across the attendees registrations for the selected event.</li>
<li><code>{'{{payment.link}}'}</code> a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>.</p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend the replacement logic in <span className="font-mono">eventController.emailEventAttendees</span> (replacePlaceholders function) and update this help.</p>
<p className="mb-2">Example: Hi <code>{'{{name}}'}</code>, your balance is <code>{'{{balance}}'}</code>. Pay here: <code>{'{{payment.link}}'}</code></p>
<p className="text-[11px] text-gray-500">To add new placeholders, extend <span className="font-mono">backend/src/utils/placeholders.js</span> and update this help.</p>
</div>
</div>
</div>
@@ -635,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);
@@ -775,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; }
@@ -865,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 => (
@@ -875,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>
@@ -914,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
@@ -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>
))}
@@ -1140,7 +1141,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 +1157,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>
))}
@@ -1344,13 +1345,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 +1361,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 +1380,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 +1394,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 +1462,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 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">Events</h1>
<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 ─────────────────────────────────────────────────────────
@@ -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>
)}
<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>
<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="border rounded-xl p-4 bg-white shadow-sm mb-6">
<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">
<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>}
@@ -145,14 +188,16 @@ export default function SupervisorDashboardPage() {
{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,28 @@ 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.
// That leg is not new money: it just re-labels part of an already-counted donation as applied
// to a registration. Money stats/lists must count each real inflow exactly once, so legs are
// excluded — the money was already counted via the original donation row.
function isDonationLeg(p: any): boolean {
return !p?.isDonation && !!p?.originalPaymentId && (p?.amount || 0) > 0;
}
// Refund methods must mirror the real payment methods (cash/card/eft/voucher) so a refund
// nets against the same bucket its original payment counted under in reports — a card payment
// refunded as "cash-refund" would wrongly drain the cash float and leave card overstated.
function refundMethodForOriginal(method: string | null | undefined): string {
const m = String(method || "").toLowerCase();
if (m.includes("cash")) return "cash-refund";
if (m.includes("eft")) return "eft-refund";
if (m.includes("voucher")) return "voucher-refund";
if (m.includes("card") || m.includes("yoco") || m.includes("pay")) return "card-refund";
return "";
}
function RegistrationOptions({ regs, regOutstanding }: {
regs: any[];
@@ -64,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}
@@ -81,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>}
@@ -165,42 +187,24 @@ 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 };
}
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)
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) };
}
}));
} 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);
@@ -427,8 +431,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 {
@@ -439,7 +442,8 @@ function PaymentsContent() {
// Stats
const todayTotals = useMemo(() => {
const start = new Date(); start.setHours(0,0,0,0);
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime());
// Exclude donation-application legs — that money was already counted once, as the donation.
const today = payments.filter(p => new Date(p.createdAt).getTime() >= start.getTime() && !isDonationLeg(p));
const revenue = today.reduce((s,p)=> s + (p.amount||0), 0);
const donations = today.filter(p => p.isDonation).length;
return { revenue, donations, count: today.length };
@@ -504,14 +508,12 @@ function PaymentsContent() {
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
method: 'POST',
authToken: token,
body: { eventId }
body: { eventId, userId: userId || undefined }
});
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 {
@@ -523,9 +525,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 && (
@@ -537,25 +544,30 @@ function PaymentsContent() {
{error && <div className="p-3 mb-3 border rounded bg-red-50 text-red-700 text-sm">{error}</div>}
{info && <div className="p-3 mb-3 border rounded bg-emerald-50 text-emerald-800 text-sm">{info}</div>}
<div className="mb-4 flex 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`}>
<div className="mb-4 flex flex-wrap items-center gap-2">
<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>
@@ -595,7 +607,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;
@@ -635,13 +647,17 @@ 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>
) : (
<div className="grid sm:grid-cols-3 gap-3 items-end">
<div className="sm:col-span-2">
<div>
<label className="block text-xs text-gray-600 mb-1">From (donor, optional)</label>
<UserSearchField allUsers={allUsers} value={reconcileForm.userId} onChange={uid => setReconcileForm(prev => ({ ...prev, userId: uid }))} />
</div>
<div>
<label className="block text-xs text-gray-600 mb-1">Event</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={reconcileForm.eventId} onChange={e => setReconcileForm(prev => ({ ...prev, eventId: e.target.value }))}>
<option value="">Select event</option>
@@ -726,7 +742,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>
)}
@@ -739,7 +755,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>}
</>
@@ -752,9 +768,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'); }}
/>
</>
)}
@@ -780,7 +800,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>
@@ -790,7 +810,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>
@@ -805,7 +825,7 @@ function PaymentsContent() {
{loadingList && <span className="text-xs text-gray-500">Loading</span>}
</div>
<ul className="text-sm space-y-2 max-h-[520px] overflow-auto pr-2">
{payments.slice(0, 25).map(p => {
{payments.filter(p => !isDonationLeg(p)).slice(0, 25).map(p => {
const amt = p.amount || 0;
const isRefund = amt < 0;
return (
@@ -818,6 +838,9 @@ function PaymentsContent() {
{(p.registration?.user?.name || p.user?.name) && <div className="text-xs text-gray-600">Name: {p.registration?.user?.name || p.user?.name}</div>}
{p.registrationId && <div className="text-xs text-gray-600">Registration: #{String(p.registrationId).slice(0,8)}</div>}
{p.eventId && <div className="text-xs text-gray-600">Event: {p.event?.title || p.eventId}</div>}
{p.recordedBy?.name && String(p.recordedBy.id) !== String(p.userId) && (
<div className="text-xs text-gray-500">Recorded by: {p.recordedBy.name}</div>
)}
</li>
);
})}
@@ -866,7 +889,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
const [paymentId, setPaymentId] = useState<string>("");
const [registrationId, setRegistrationId] = useState<string>("");
const [amount, setAmount] = useState<string>("");
const [method, setMethod] = useState<string>('refund');
const [method, setMethod] = useState<string>("");
const [reason, setReason] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [err, setErr] = useState<string | null>(null);
@@ -884,10 +907,14 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
}, [userId, target]);
useEffect(() => {
// If a payment selected, default amount to that payment's amount
// If a payment selected, default amount and refund method to that payment's own —
// still editable, but staff shouldn't have to remember to change it manually.
if (target === 'payment') {
const p = payments.find(pp => String(pp.id) === String(paymentId));
if (p) setAmount(String(Math.abs(p.amount || 0)));
if (p) {
setAmount(String(Math.abs(p.amount || 0)));
setMethod(refundMethodForOriginal(p.method));
}
}
}, [paymentId, target, payments]);
@@ -901,6 +928,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
if (!userId) { setErr('Select a user'); return; }
if (target === 'payment' && !paymentId) { setErr('Select a payment to refund'); return; }
if (target === 'registration' && !registrationId) { setErr('Select a registration to refund against'); return; }
if (!method) { setErr('Select a refund method'); return; }
try {
setSubmitting(true);
await apiFetch('/api/payments/refund', {
@@ -915,7 +943,7 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
reason: reason || undefined
}
});
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason("");
setUserId(""); setPaymentId(""); setRegistrationId(""); setAmount(""); setReason(""); setMethod("");
await onDone();
} catch (e: any) {
setErr(e?.message || 'Failed to create refund');
@@ -968,11 +996,13 @@ function RefundSection({ payments, allUsers, usersList, regsForUser, regOutstand
<div>
<label className="block text-xs text-gray-600 mb-1">Method</label>
<select className="w-full border rounded px-3 py-2 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
<option value="refund">Refund</option>
<option value="">Select method</option>
<option value="cash-refund">Cash refund</option>
<option value="eft-refund">EFT refund</option>
<option value="card-refund">Card refund</option>
<option value="eft-refund">EFT refund</option>
<option value="voucher-refund">Voucher refund</option>
</select>
<div className="text-[10px] text-gray-500 mt-1">Mirrors the method being refunded this is what nets against that method's total in reports.</div>
</div>
</div>
<label className="block text-xs text-gray-600 mt-2">Reason (optional)</label>
@@ -983,6 +1013,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[];
@@ -1025,27 +1193,53 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
[registrations, registrationId]
);
// Donations "relevant to that event" — unassigned donations logged against the same event
// as the chosen registration.
// A donation is never mutated once assigned — each assignment creates a separate "leg"
// Payment row (isDonation:false, originalPaymentId -> the donation). A donation's remaining
// balance is its original amount minus every leg that references it, so it stays offerable
// (and its registrationId stays null forever) until fully used up. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces the
// remaining balance instead of inflating it (a raw signed sum would subtract a negative,
// adding the refund back on top of what's left to allocate).
const legsById = useMemo(() => {
const m = new Map<string, number>();
payments.forEach((p: any) => {
if (p.originalPaymentId && !p.isDonation) {
m.set(p.originalPaymentId, (m.get(p.originalPaymentId) || 0) + Math.abs(p.amount || 0));
}
});
return m;
}, [payments]);
// Donations "relevant to that event" — donations logged against the same event as the
// chosen registration that still have a remaining, unused balance.
const donationsForEvent = useMemo(() => {
if (!selectedRegistration) return [] as any[];
const eventId = selectedRegistration.eventId || selectedRegistration.event?.id;
return payments.filter((p: any) => p.isDonation && !p.registrationId && String(p.eventId) === String(eventId));
}, [payments, selectedRegistration]);
return payments.filter((p: any) => {
if (!p.isDonation || p.registrationId) return false;
if (String(p.eventId) !== String(eventId)) return false;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
return remaining > 0.000001;
});
}, [payments, selectedRegistration, legsById]);
const selectedDonation = useMemo(
() => payments.find((p: any) => String(p.id) === String(paymentId)),
[payments, paymentId]
);
const donationRemaining = selectedDonation
? (selectedDonation.amount || 0) - (legsById.get(selectedDonation.id) || 0)
: 0;
const outstanding = registrationId ? (regOutstanding[registrationId]?.outstanding ?? 0) : 0;
// The most that can be allocated: never more than the donation itself, never more than
// what's actually owed. Staff can type a smaller amount to leave a balance outstanding.
// The most that can be allocated: never more than the donation's remaining balance, never
// more than what's actually owed. Staff can type a smaller amount to leave a balance owing.
const maxAllocatable = useMemo(() => {
if (!selectedDonation) return 0;
return Math.min(selectedDonation.amount || 0, outstanding);
}, [selectedDonation, outstanding]);
return Math.min(donationRemaining, outstanding);
}, [selectedDonation, donationRemaining, outstanding]);
// Default to "apply as much as needed" whenever a new donation is picked — the common
// case needs no typing, but the field stays editable for a deliberate partial allocation.
@@ -1055,7 +1249,7 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
}, [paymentId]);
const amountNum = parseFloat(amountStr || "0");
const leftover = selectedDonation ? Math.max(0, (selectedDonation.amount || 0) - amountNum) : 0;
const leftover = selectedDonation ? Math.max(0, donationRemaining - amountNum) : 0;
const assign = async () => {
if (!token) return;
@@ -1110,12 +1304,13 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
>
<option value="">Select donation</option>
{donationsForEvent.map((p: any) => {
const label = `R ${(p.amount || 0).toFixed(2)}${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
const remaining = (p.amount || 0) - (legsById.get(p.id) || 0);
const label = `R ${remaining.toFixed(2)} of R ${(p.amount || 0).toFixed(2)} left — ${p.user?.name || p.userId || 'Donor'} — #${String(p.id).slice(0,8)}`;
return <option key={p.id} value={p.id} title={label}>{label}</option>;
})}
</select>
{registrationId && donationsForEvent.length === 0 && (
<div className="text-xs text-gray-500">No unassigned donations for this event.</div>
<div className="text-xs text-gray-500">No donations with a remaining balance for this event.</div>
)}
{selectedDonation && (
@@ -1131,8 +1326,8 @@ function DonationAssignSection({ payments, allUsers, registrations, regOutstandi
onChange={e => setAmountStr(e.target.value)}
/>
<div className="text-xs text-gray-500">
Donation is R {(selectedDonation.amount || 0).toFixed(2)}; outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay unassigned as a donation.</>}
Donation has R {donationRemaining.toFixed(2)} remaining (of R {(selectedDonation.amount || 0).toFixed(2)} total); outstanding balance is R {outstanding.toFixed(2)}.
{leftover > 0.000001 && <> The remaining R {leftover.toFixed(2)} will stay available on this donation for future assignments.</>}
</div>
</>
)}
@@ -1140,7 +1335,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,19 +1,16 @@
"use client";
import React from "react";
import React, { Suspense } from "react";
import { useRouter } from "next/navigation";
import ReportsV2 from "@/components/reports/ReportsV2";
export default function SupervisorReportsPage() {
const router = useRouter();
return (
<div className="max-w-6xl mx-auto w-full p-6">
<div className="flex items-center justify-between mb-4">
<h1 className="text-2xl font-semibold">Reports</h1>
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm" onClick={() => router.push('/dashboard')}>Back</button>
</div>
<p className="text-sm text-gray-600 mb-4">View, export, or email operational reports for events.</p>
<ReportsV2 />
<div className="w-full p-6">
<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())}`;
@@ -86,7 +94,7 @@ function AttendeesCheckboxDropdown({
)}
</button>
{open && (
<div className="absolute z-10 mt-1 w-64 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="absolute z-10 mt-1 left-0 right-0 max-h-72 overflow-auto bg-white border rounded shadow">
<div className="px-3 py-2 border-b sticky top-0 bg-white space-y-1">
<label className="text-sm flex items-center gap-2">
<input type="checkbox" checked={allSelected} onChange={(e) => toggleAll(e.target.checked)} />
@@ -476,7 +484,7 @@ function WhatsAppAttendeesPageInner() {
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
setSending(true);
const res = await apiFetch(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload() });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
setInfo(`Queued ${res?.queued ?? res?.matched ?? 0} recipient(s) for sending.`);
resetAttendeesForm();
} catch (e: any) { setError(e?.message || "Failed to send"); } finally { setSending(false); }
};
@@ -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"}`}>
{(["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 ── */}
@@ -655,7 +670,7 @@ function WhatsAppAttendeesPageInner() {
</div>
<div className="sm:col-span-2">
<div className="text-[11px] text-gray-500 mt-6">
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}
Placeholders: {"{{name}}"}, {"{{event.title}}"}, {"{{event.start}}"}, {"{{balance}}"}, {"{{payment.link}}"}
</div>
</div>
</div>
@@ -676,6 +691,7 @@ function WhatsAppAttendeesPageInner() {
<li><code>{"{{event.title}}"}</code> the event title.</li>
<li><code>{"{{event.start}}"}</code> the event start date/time.</li>
<li><code>{"{{balance}}"}</code> outstanding balance for the event.</li>
<li><code>{"{{payment.link}}"}</code> a direct Yoco payment link for the attendee's outstanding balance (generated per recipient when sending).</li>
</ul>
<p className="text-[11px] text-gray-500 mt-2">
Preference indicators: <span className="text-green-700 bg-green-50 px-1 rounded">WA</span> = WhatsApp only,{" "}
@@ -875,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) => (
@@ -883,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>
@@ -894,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)}>
@@ -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,16 +58,26 @@ 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)}>
<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 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>
<label className="block text-sm font-medium mb-1">Amount (R)</label>
<label className="block text-sm font-medium text-gray-700 mb-1">Amount (R)</label>
<input
type="number"
min="15"
@@ -74,15 +85,16 @@ export default function DonatePage() {
placeholder="Enter amount (min R15)"
value={amount}
onChange={(e)=>setAmount(e.target.value)}
className="w-full border rounded px-3 py-2 mb-1"
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-600 mb-3">Minimum donation is R15.</p>
<p className="text-xs text-gray-500 mb-4">Minimum donation is R15.</p>
<button
disabled={loading}
onClick={submit}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-60"
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>
+70 -36
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)}`;
@@ -672,20 +674,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 +700,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 +732,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 +802,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 +810,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 +840,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 +856,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 +881,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 +898,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 +907,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 +927,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 +939,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,7 +966,7 @@ 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 ? (
@@ -1067,7 +1101,7 @@ 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>
)}
@@ -1083,7 +1117,7 @@ export default function UserDashboardPage() {
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>
<button className="px-3 py-1.5 text-sm bg-brand-600 text-white rounded hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" onClick={() => emailRegistration(activeReg.id)}>Email all tickets</button>
{user?.phoneNumber && (
<button className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" onClick={() => whatsappRegistration(activeReg.id)}>WhatsApp tickets</button>
)}
@@ -1130,7 +1164,7 @@ 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>
@@ -1140,7 +1174,7 @@ export default function UserDashboardPage() {
<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;
@@ -74,10 +75,15 @@ 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>
@@ -183,7 +189,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>
+101 -72
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,57 +182,55 @@ 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>
);
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>
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
{/* ── Profile info ─────────────────────────────────────────────────── */}
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-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>
<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">
<h2 className="text-lg font-semibold mb-4">Personal information</h2>
<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="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
/>
<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="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
/>
<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="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"
/>
<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")}
>
<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>
@@ -212,69 +239,69 @@ export default function UserProfilePage() {
</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"
>
<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>
{profileMsg && <Alert msg={profileMsg} />}
</form>
</section>
{/* ── Password ─────────────────────────────────────────────────────── */}
{/* ── Account activity ─────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-4">Change password</h2>
<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="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"
/>
<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="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"
/>
<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="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"
/>
<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-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 disabled:opacity-50"
>
<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>
{pwMsg && <Alert msg={pwMsg} />}
</form>
</section>
{/* ── Security ─────────────────────────────────────────────────────── */}
{/* ── Security ─────────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<h2 className="text-lg font-semibold mb-1">Security</h2>
<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.
@@ -289,9 +316,9 @@ export default function UserProfilePage() {
{revokeMsg && <Alert msg={revokeMsg} />}
</section>
{/* ── Danger zone ──────────────────────────────────────────────────── */}
{/* ── 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>
<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
@@ -362,5 +389,7 @@ export default function UserProfilePage() {
)}
</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
+116 -56
View File
@@ -2,6 +2,7 @@ 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 { Calendar, Ticket, Paperclip, Sparkles } from "lucide-react";
export const revalidate = 60;
@@ -49,6 +50,44 @@ function lowStockThreshold(stockLimit: number): number {
return Math.round(stockLimit * pct);
}
function RegisterCta({ event }: { event: Event }) {
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,22 +103,40 @@ 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" />
<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>
)}
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{event.attachments && event.attachments.length > 0 && (
<div>
<h2 className="text-xl font-semibold mt-6">Downloads</h2>
<ul className="list-disc pl-5 text-gray-700">
<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>
<p className="text-gray-700 whitespace-pre-line">{event.description}</p>
{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-blue-600 hover:underline" href={att.url} target="_blank" rel="noopener noreferrer">
<a className="text-brand-600 hover:underline text-sm" href={att.url} target="_blank" rel="noopener noreferrer">
{att.originalName}
</a>
</li>
@@ -87,9 +144,52 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
</ul>
</div>
)}
<h2 className="text-xl font-semibold mt-6">Tickets</h2>
{/* 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>
</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">
{(event.eventOptions || []).map((opt) => {
{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);
@@ -143,9 +243,9 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
<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-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-indigo-600">Early bird</span>
<span className="text-xs text-brand-600">Early bird</span>
</>
) : (
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
@@ -157,45 +257,5 @@ export default async function EventDetailPage({ params }: { params: Promise<{ id
);
})}
</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>
);
}
+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;
}
+43 -5
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 = {
/**
* 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: "/favicon.ico",
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] || ""}
+72 -16
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">
<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>
<a href="/register" className="px-6 py-2 border border-blue-600 text-blue-600 rounded-xl hover:bg-blue-50">
Join Us
</a>
<JoinUsButton />
</div>
</div>
</section>
<section className="py-12 px-4 max-w-7xl mx-auto">
<SectionHeader title="Upcoming Events" />
<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>
{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">
<a href="/events" className="text-blue-600 hover:underline text-sm">
View all events
)}
<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>

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